Compare commits

...
Author SHA1 Message Date
arkonandClaude Fable 5 36693b922e feat(ui): theme skins — OG Codeman, Daylight Green, Daylight Blue
Add a per-device skin switcher in App Settings → Display:
- Three skins via html[data-skin]: og (original Codeman look),
  daylight-green, and daylight-blue (new default). Per-skin CSS-variable
  token blocks; the v1.0 "Carbon Aurora" component polish is scoped to
  non-og skins and parameterized so green/blue differ only by token values.
- Self-hosted Manrope (UI) + JetBrains Mono (terminal) variable fonts,
  served from /fonts (no external CDN, CSP-safe via font-src 'self').
- Per-skin xterm terminal theme with live re-theming of open terminals on
  skin change; skin-aware --term-bg so the terminal background fills cleanly
  (fixes the variable-height gap above the toolbar).
- Pre-paint inline script applies the saved skin before first paint (no
  flash); persisted per-device in localStorage + the settings blob, and
  kept out of the server settings payload (device-local).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 19:46:50 +02:00
arkonandClaude Opus 4.8 95a59b0e9f feat(agents): wire AgentArtifactIndex into server, broadcast agent:artifact SSE
Starts agentArtifactIndex alongside the existing subagentWatcher listeners (sourced
from its subagent:tool_call stream) and rebroadcasts artifact:added/updated as a single
agent:artifact SSE event. Registers the event in sse-events.ts + constants.js (parity
test passes). Listeners detached and the index stopped in cleanupSubagentWatcherListeners.

Purely additive — no existing behavior changes. Server boots clean on an isolated beta
instance and discovers subagents as before. Backend half of the Agent Gallery; the serve
route and frontend are still to come.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 06:10:30 +02:00
arkonandClaude Opus 4.8 dae4d8fbee feat(agents): add AgentArtifactIndex — join subagents to files they produce
Subscribes to subagentWatcher's subagent:tool_call events and attributes every
Write/Edit/MultiEdit/NotebookEdit file_path to its agent. Dedupes repeat writes,
classifies by extension (html/image/pdf/document/markdown/text/code/other), bounds
memory (FIFO per-agent + per-index caps), and exposes query-by-agent/session/all.

Metadata only — records paths, never reads/serves contents; guard enforcement stays
in the serving layer. recordExternalArtifact() reserved for image-watcher correlation
of Bash-written screenshots. Load-bearing piece for the Agent Gallery view.

Verified against live w7 transcripts: 8 mockups correctly attributed with write counts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 06:07:03 +02:00
14 changed files with 996 additions and 83 deletions
+292
View File
@@ -0,0 +1,292 @@
/**
* @fileoverview Agent Artifact Index — joins each subagent to the files it produced.
*
* The load-bearing piece behind the Agent Gallery view. It answers "which files did
* this subagent write?" by subscribing to {@link subagentWatcher} events rather than
* re-reading transcripts: every `subagent:tool_call` for a file-producing tool
* (Write/Edit/MultiEdit/NotebookEdit) carries the agent id, session id, and the full
* tool input — including `file_path` — so the join is exact and free of duplicate I/O.
*
* What it stores is METADATA ONLY (paths, kinds, write counts). It never reads or
* serves file contents — guard enforcement (realpath, sensitive-path, attachment-guard,
* extension allowlist) lives in the serving layer that turns these paths into
* attachment/artifact ids. Treat the recorded path as untrusted: it originates from a
* subagent transcript and must pass the guard chain before anything reads it.
*
* Key exports:
* - `AgentArtifactIndex` class — EventEmitter; `start(source?)` / `stop()` lifecycle
* - `agentArtifactIndex` — pre-instantiated singleton
* - `AgentArtifact`, `ArtifactKind`, `ArtifactSource`, `AgentArtifactIndexEvents` — types
*
* Extension point: `recordExternalArtifact()` lets a later image-watcher correlation
* attribute Bash-written screenshots (which never appear as a Write tool_use) to the
* agent that was active when the file landed. The transcript join below stands alone.
*
* @emits artifact:added (new path for an agent), artifact:updated (re-write of a known
* path), artifact:error
* @dependencies subagent-watcher (event source), config/map-limits (bounds)
* @consumedby web/server (SSE broadcast as `agent:artifact`), gallery route
* @module agent-artifact-index
*/
import { EventEmitter } from 'node:events';
import { basename, extname, isAbsolute, resolve } from 'node:path';
import { MAX_ARTIFACTS_PER_AGENT, MAX_TRACKED_AGENTS } from './config/map-limits.js';
import { subagentWatcher, type SubagentToolCall } from './subagent-watcher.js';
// ========== Types ==========
export type ArtifactKind = 'html' | 'image' | 'pdf' | 'document' | 'markdown' | 'text' | 'code' | 'other';
/** How the artifact was attributed to its agent. */
export type ArtifactSource = 'write' | 'edit' | 'notebook' | 'external';
export interface AgentArtifact {
agentId: string;
sessionId: string;
/** Absolute path, normalized (`resolve`d) when absolute; left raw otherwise. */
path: string;
/** `basename(path)`. */
filename: string;
/** Lowercased extension including the dot (e.g. `.html`); `''` when none. */
ext: string;
/** Render hint derived from {@link ext}. Drives the gallery's preview mode. */
kind: ArtifactKind;
source: ArtifactSource;
/** Epoch ms when this path was first attributed to the agent. */
firstSeenAt: number;
/** Epoch ms of the most recent write/edit of this path. */
lastWriteAt: number;
/** Number of write/edit calls observed against this path. */
writeCount: number;
}
/** Typed event map (Node 22 typed EventEmitter, matching {@link BashToolParser}). */
export interface AgentArtifactIndexEvents {
'artifact:added': [AgentArtifact];
'artifact:updated': [AgentArtifact];
'artifact:error': [Error];
}
/** Minimal structural type for the event source, for testability without the real watcher. */
type ToolCallEmitter = Pick<EventEmitter, 'on' | 'off'>;
// ========== Constants ==========
/** Tools whose successful call means the agent produced/changed a file. */
const FILE_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
const KIND_BY_EXT: Readonly<Record<string, ArtifactKind>> = {
'.html': 'html',
'.htm': 'html',
'.png': 'image',
'.jpg': 'image',
'.jpeg': 'image',
'.gif': 'image',
'.webp': 'image',
'.avif': 'image',
'.svg': 'image',
'.pdf': 'pdf',
'.docx': 'document',
'.pptx': 'document',
'.xlsx': 'document',
'.md': 'markdown',
'.markdown': 'markdown',
'.txt': 'text',
'.log': 'text',
'.csv': 'text',
'.js': 'code',
'.mjs': 'code',
'.cjs': 'code',
'.ts': 'code',
'.tsx': 'code',
'.jsx': 'code',
'.css': 'code',
'.json': 'code',
'.py': 'code',
'.sh': 'code',
'.yml': 'code',
'.yaml': 'code',
};
// ========== Helpers ==========
function classifyKind(ext: string): ArtifactKind {
return KIND_BY_EXT[ext] ?? 'other';
}
function sourceForTool(tool: string): ArtifactSource {
if (tool === 'Write') return 'write';
if (tool === 'NotebookEdit') return 'notebook';
return 'edit'; // Edit, MultiEdit
}
/**
* Resolve `..`/`.` for absolute paths so repeat writes dedupe to one artifact.
* Relative paths are left raw: `resolve()` would anchor them to the SERVER cwd, not the
* agent's, producing a wrong absolute path. Write requires absolute paths, so this is rare.
*/
function normalizePath(p: string): string {
return isAbsolute(p) ? resolve(p) : p;
}
/** ISO transcript timestamp → epoch ms, falling back to now. */
function timestampToMs(ts: string | undefined): number {
if (ts) {
const n = Date.parse(ts);
if (!Number.isNaN(n)) return n;
}
return Date.now();
}
/** Extract the target path from a file-tool's input (Write/Edit use `file_path`, NotebookEdit `notebook_path`). */
function extractFilePath(input: Record<string, unknown> | undefined): string | undefined {
const candidate = input?.file_path ?? input?.notebook_path;
return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined;
}
// ========== AgentArtifactIndex ==========
export class AgentArtifactIndex extends EventEmitter<AgentArtifactIndexEvents> {
/** agentId → (normalized path → artifact). Outer map is insertion-ordered for FIFO eviction. */
private readonly byAgent = new Map<string, Map<string, AgentArtifact>>();
private source: ToolCallEmitter | null = null;
private readonly onToolCall = (call: SubagentToolCall): void => {
try {
if (!FILE_WRITE_TOOLS.has(call.tool)) return;
const rawPath = extractFilePath(call.fullInput);
if (!rawPath) return;
this.record(call.agentId, call.sessionId, rawPath, sourceForTool(call.tool), timestampToMs(call.timestamp));
} catch (err) {
this.emit('artifact:error', err instanceof Error ? err : new Error(String(err)));
}
};
/**
* Begin attributing artifacts. Idempotent. Defaults to the real {@link subagentWatcher};
* tests pass a bare EventEmitter and drive `subagent:tool_call` directly.
*/
start(source: ToolCallEmitter = subagentWatcher): void {
if (this.source) {
if (this.source === source) return;
this.stop();
}
this.source = source;
source.on('subagent:tool_call', this.onToolCall as (...args: unknown[]) => void);
}
/** Detach from the event source. Retains recorded artifacts (use {@link clear} to drop them). */
stop(): void {
if (this.source) {
this.source.off('subagent:tool_call', this.onToolCall as (...args: unknown[]) => void);
this.source = null;
}
}
/**
* Attribute a file discovered outside the transcript — e.g. a screenshot written by a
* Bash `capture.mjs`, correlated by the image-watcher to the agent active when it landed.
* Marked `source: 'external'`. (Wired for a follow-up; not yet called by the image-watcher.)
*/
recordExternalArtifact(agentId: string, sessionId: string, absPath: string, atMs: number = Date.now()): void {
if (typeof absPath !== 'string' || absPath.length === 0) return;
this.record(agentId, sessionId, absPath, 'external', atMs);
}
private record(agentId: string, sessionId: string, rawPath: string, source: ArtifactSource, atMs: number): void {
const path = normalizePath(rawPath);
let bucket = this.byAgent.get(agentId);
if (!bucket) {
// Bound the number of tracked agents (FIFO eviction of the oldest agent).
if (this.byAgent.size >= MAX_TRACKED_AGENTS) {
const oldest = this.byAgent.keys().next().value;
if (oldest !== undefined) this.byAgent.delete(oldest);
}
bucket = new Map();
this.byAgent.set(agentId, bucket);
}
const existing = bucket.get(path);
if (existing) {
existing.lastWriteAt = Math.max(existing.lastWriteAt, atMs);
existing.firstSeenAt = Math.min(existing.firstSeenAt, atMs);
existing.writeCount += 1;
existing.sessionId = sessionId; // tolerate late/corrected session attribution
this.emit('artifact:updated', existing);
return;
}
// Bound artifacts per agent (FIFO eviction of the oldest path for this agent).
if (bucket.size >= MAX_ARTIFACTS_PER_AGENT) {
const oldest = bucket.keys().next().value;
if (oldest !== undefined) bucket.delete(oldest);
}
const ext = extname(path).toLowerCase();
const artifact: AgentArtifact = {
agentId,
sessionId,
path,
filename: basename(path),
ext,
kind: classifyKind(ext),
source,
firstSeenAt: atMs,
lastWriteAt: atMs,
writeCount: 1,
};
bucket.set(path, artifact);
this.emit('artifact:added', artifact);
}
/** Artifacts written by one agent, most-recently-written first. */
getArtifactsForAgent(agentId: string): AgentArtifact[] {
const bucket = this.byAgent.get(agentId);
if (!bucket) return [];
return [...bucket.values()].sort((a, b) => b.lastWriteAt - a.lastWriteAt);
}
/** Artifacts across every agent in a Codeman session, most-recently-written first. */
getArtifactsForSession(sessionId: string): AgentArtifact[] {
const out: AgentArtifact[] = [];
for (const bucket of this.byAgent.values()) {
for (const artifact of bucket.values()) {
if (artifact.sessionId === sessionId) out.push(artifact);
}
}
return out.sort((a, b) => b.lastWriteAt - a.lastWriteAt);
}
/** Every tracked artifact, most-recently-written first. */
getAllArtifacts(): AgentArtifact[] {
const out: AgentArtifact[] = [];
for (const bucket of this.byAgent.values()) out.push(...bucket.values());
return out.sort((a, b) => b.lastWriteAt - a.lastWriteAt);
}
getArtifact(agentId: string, path: string): AgentArtifact | undefined {
return this.byAgent.get(agentId)?.get(normalizePath(path));
}
/** Total number of tracked artifacts across all agents. */
count(): number {
let n = 0;
for (const bucket of this.byAgent.values()) n += bucket.size;
return n;
}
/** Drop all artifacts for one agent (e.g. when its subagent window is dismissed). */
clearAgent(agentId: string): void {
this.byAgent.delete(agentId);
}
/** Drop everything. */
clear(): void {
this.byAgent.clear();
}
}
/** Pre-instantiated singleton, mirroring `subagentWatcher`. Call `.start()` to attach. */
export const agentArtifactIndex = new AgentArtifactIndex();
+6
View File
@@ -62,6 +62,12 @@ export const MAX_TRACKED_AGENTS = 500;
*/ */
export const MAX_PENDING_TOOL_CALLS = 100; export const MAX_PENDING_TOOL_CALLS = 100;
/**
* Maximum distinct file artifacts tracked per subagent in the artifact index.
* Bounds memory for agents that write many files (FIFO eviction when exceeded).
*/
export const MAX_ARTIFACTS_PER_AGENT = 200;
/** /**
* TTL for orphaned pending tool calls (5 minutes). * TTL for orphaned pending tool calls (5 minutes).
* If no tool_result received, entry is cleaned up. * If no tool_result received, entry is cleaned up.
+2
View File
@@ -580,6 +580,7 @@ class CodemanApp {
const _kbSettings = this.loadAppSettingsFromStorage(); const _kbSettings = this.loadAppSettingsFromStorage();
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended'); if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
this.applyHeaderVisibilitySettings(); this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings(); this.applyTabWrapSettings();
this.applyMonitorVisibility(); this.applyMonitorVisibility();
// Remove mobile-init class now that JS has applied visibility settings. // Remove mobile-init class now that JS has applied visibility settings.
@@ -637,6 +638,7 @@ class CodemanApp {
// Load server-stored settings (async, re-applies visibility after load) // Load server-stored settings (async, re-applies visibility after load)
this.loadAppSettingsFromServer(settingsPromise).then(() => { this.loadAppSettingsFromServer(settingsPromise).then(() => {
this.applyHeaderVisibilitySettings(); this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings(); this.applyTabWrapSettings();
this.applyMonitorVisibility(); this.applyMonitorVisibility();
}); });
+1
View File
@@ -333,6 +333,7 @@ const SSE_EVENTS = {
SUBAGENT_MESSAGE: 'subagent:message', SUBAGENT_MESSAGE: 'subagent:message',
SUBAGENT_TOOL_RESULT: 'subagent:tool_result', SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
SUBAGENT_COMPLETED: 'subagent:completed', SUBAGENT_COMPLETED: 'subagent:completed',
AGENT_ARTIFACT: 'agent:artifact',
// Images // Images
IMAGE_DETECTED: 'image:detected', IMAGE_DETECTED: 'image:detected',
Binary file not shown.
Binary file not shown.
+22 -10
View File
@@ -8,7 +8,7 @@
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="description" content="Claude Code session manager with web interface"> <meta name="description" content="Claude Code session manager with web interface">
<meta name="theme-color" content="#0a0a0a"> <meta name="theme-color" content="#11151c">
<meta name="google" content="notranslate"> <meta name="google" content="notranslate">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -41,15 +41,17 @@
<script defer src="vendor/marked.min.js"></script> <script defer src="vendor/marked.min.js"></script>
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash --> <!-- 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> <script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) --> <!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style> <style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b} .loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
.skeleton-header{height:40px;background:rgba(19,19,22,0.85);border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;padding:0 12px} .skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7} .skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
.skeleton-tabs{display:flex;gap:4px;margin-left:16px} .skeleton-tabs{display:flex;gap:4px;margin-left:16px}
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px} .skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
.skeleton-terminal{flex:1;background:#0d0d0d} .skeleton-terminal{flex:1;background:#161b23}
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)} .skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
.app-loaded .loading-skeleton{display:none} .app-loaded .loading-skeleton{display:none}
</style> </style>
</head> </head>
@@ -178,8 +180,8 @@
<svg viewBox="0 0 36 36" class="ralph-ring-svg"> <svg viewBox="0 0 36 36" class="ralph-ring-svg">
<defs> <defs>
<linearGradient id="ralphGradientMini" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="ralphGradientMini" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" /> <stop offset="0%" stop-color="#3ec8ee" />
<stop offset="100%" stop-color="#22c55e" /> <stop offset="100%" stop-color="#2b8fd9" />
</linearGradient> </linearGradient>
</defs> </defs>
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" /> <circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
@@ -224,8 +226,8 @@
<svg viewBox="0 0 100 100" class="ralph-ring-svg-large"> <svg viewBox="0 0 100 100" class="ralph-ring-svg-large">
<defs> <defs>
<linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" /> <stop offset="0%" stop-color="#3ec8ee" />
<stop offset="100%" stop-color="#22c55e" /> <stop offset="100%" stop-color="#2b8fd9" />
</linearGradient> </linearGradient>
</defs> </defs>
<circle class="ralph-ring-track" cx="50" cy="50" r="42" /> <circle class="ralph-ring-track" cx="50" cy="50" r="42" />
@@ -918,6 +920,16 @@
<!-- Display Tab --> <!-- Display Tab -->
<div class="modal-tab-content" id="settings-display"> <div class="modal-tab-content" id="settings-display">
<div class="settings-grid"> <div class="settings-grid">
<!-- Appearance Section -->
<div class="settings-section-header">Appearance</div>
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
<span class="settings-item-label">Skin</span>
<select id="appSettingsSkin" class="form-select">
<option value="daylight-blue">Daylight Blue</option>
<option value="daylight-green">Daylight Green</option>
<option value="og">OG Codeman</option>
</select>
</div>
<!-- Input Section --> <!-- Input Section -->
<div class="settings-section-header">Input</div> <div class="settings-section-header">Input</div>
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections."> <div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
+1 -23
View File
@@ -1550,29 +1550,7 @@ Object.assign(CodemanApp.prototype, {
} }
const terminal = new Terminal({ const terminal = new Terminal({
theme: { theme: { ...window.codemanCurrentXtermTheme() },
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontSize: 12, fontSize: 12,
lineHeight: 1.2, lineHeight: 1.2,
+24 -1
View File
@@ -309,6 +309,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false; document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true; document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false; document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
@@ -1377,6 +1378,7 @@ Object.assign(CodemanApp.prototype, {
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked, extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
skin: document.getElementById('appSettingsSkin').value,
// Claude CLI settings // Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value, claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(), allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
@@ -1491,6 +1493,7 @@ Object.assign(CodemanApp.prototype, {
// Apply header visibility immediately // Apply header visibility immediately
this.applyHeaderVisibilitySettings(); this.applyHeaderVisibilitySettings();
this.applySkin();
this.applyTabWrapSettings(); this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change) this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility(); this.applyMonitorVisibility();
@@ -1505,7 +1508,7 @@ Object.assign(CodemanApp.prototype, {
// Save to server (includes notification prefs for cross-browser persistence) // Save to server (includes notification prefs for cross-browser persistence)
// Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform // Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform
const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, ...serverSettings } = settings; const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, extendedKeyboardBar: _ekb, skin: _skin, ...serverSettings } = settings;
try { try {
const res = await this._apiPut('/api/settings', { const res = await this._apiPut('/api/settings', {
...serverSettings, ...serverSettings,
@@ -1664,6 +1667,7 @@ Object.assign(CodemanApp.prototype, {
ralphTrackerEnabled: false, ralphTrackerEnabled: false,
tabTwoRows: false, tabTwoRows: false,
cjkInputEnabled: false, cjkInputEnabled: false,
skin: 'daylight-blue',
}; };
} }
// Desktop defaults - rely on ?? operators in apply functions // Desktop defaults - rely on ?? operators in apply functions
@@ -1701,6 +1705,24 @@ Object.assign(CodemanApp.prototype, {
} }
}, },
// Apply the chosen skin live: sets the html[data-skin] attribute, syncs BOTH
// localStorage locations (the standalone 'codeman:skin' key the pre-paint head
// script reads + the app-settings blob field written by saveAppSettingsToStorage),
// updates window.__codemanSkin, and re-themes any live terminals.
applySkin() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.documentElement.setAttribute('data-skin', skin);
window.__codemanSkin = skin;
try {
localStorage.setItem('codeman:skin', skin);
} catch (_e) {
/* private mode */
}
if (typeof this.applyTerminalSkin === 'function') this.applyTerminalSkin(skin);
},
applyHeaderVisibilitySettings() { applyHeaderVisibilitySettings() {
const settings = this.loadAppSettingsFromStorage(); const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings(); const defaults = this.getDefaultSettings();
@@ -1944,6 +1966,7 @@ Object.assign(CodemanApp.prototype, {
'showLifecycleLog', 'showResponseViewer', 'showLifecycleLog', 'showResponseViewer',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin',
]); ]);
// Merge settings: non-display keys always sync from server, // Merge settings: non-display keys always sync from server,
// display keys only seed from server when localStorage has no value // display keys only seed from server when localStorage has no value
+373 -26
View File
@@ -1,36 +1,65 @@
/* Codeman - Terminal-First UI */ /* Codeman - Terminal-First UI */
/* Self-hosted UI + mono fonts (Carbon Aurora · Daylight) — served from /fonts,
no external CDN, CSP-safe (font-src 'self'). Variable woff2 cover all weights. */
@font-face {
font-family: 'Manrope';
font-style: normal;
font-display: swap;
font-weight: 400 800;
src: url('fonts/manrope-variable.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-display: swap;
font-weight: 400 700;
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
}
:root { :root {
--bg-dark: #09090b; /* Carbon Aurora · Daylight — deep-but-not-black slate, surfaces step UP into the light */
--bg-card: #131316; --bg-dark: #11151c;
--bg-input: #1a1a1f; --bg-card: #1b222c;
--bg-hover: #1f1f26; --bg-input: #202833;
--border: #232329; --bg-hover: #2a323d;
--border-light: #2e2e38; --border: #2b333f;
--text: #ececf0; --border-light: #3a4350;
--text-dim: #8b8b97; --text: #f3f6fa;
--text-muted: #52525e; --text-dim: #98a2b1;
--accent: #3b82f6; --text-muted: #717b8c;
--accent-hover: #60a5fa; /* SINGLE cohesive accent — optimistic emerald → teal (replaces the old blue) */
--green: #22c55e; --accent: #38b6f0;
--yellow: #eab308; --accent-hover: #3ec8ee;
--red: #ef4444; /* calmer secondary green for non-primary pills/dots so Run + ring stay focal */
--green: #44b993;
/* warm, quiet "working" amber (no second saturated hue) */
--yellow: #f0c25a;
--red: #e06363;
/* Daylight accent extras — referenced by the v1.0 override block at end of file */
--accent-d: #2487c9;
--emerald: #3ec8ee;
--teal: #2b8fd9;
--accent-soft: #44b993;
--accent-ink: #04223a;
--warm: #e8c79a;
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
--header-height: 36px; --header-height: 36px;
--toolbar-height: 42px; --toolbar-height: 42px;
--glass-bg: rgba(19, 19, 22, 0.85); --glass-bg: rgba(31, 38, 48, 0.85);
--glass-border: rgba(255, 255, 255, 0.06); --glass-border: rgba(255, 255, 255, 0.08);
--subtle-shadow: 0 -1px 3px rgba(0, 0, 0, 0.3), 0 -4px 16px rgba(0, 0, 0, 0.15); --subtle-shadow: 0 -1px 3px rgba(0, 0, 0, 0.3), 0 -4px 16px rgba(0, 0, 0, 0.15);
--btn-radius: 6px; --btn-radius: 6px;
--transition-smooth: 0.2s cubic-bezier(0.4, 0, 0.2, 1); --transition-smooth: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
/* Session color palette */ /* Session color palette — retoned toward the Daylight system */
--session-red: #ef4444; --session-red: #e06363;
--session-orange: #f97316; --session-orange: #e8a45c;
--session-yellow: #eab308; --session-yellow: #f0c25a;
--session-green: #22c55e; --session-green: #44b993;
--session-blue: #3b82f6; --session-blue: #2b8fd9;
--session-purple: #a855f7; --session-purple: #a98fe0;
--session-pink: #ec4899; --session-pink: #e07db0;
/* Safe area insets for notched devices (iPhone X+, Dynamic Island) */ /* Safe area insets for notched devices (iPhone X+, Dynamic Island) */
--safe-area-top: env(safe-area-inset-top, 0px); --safe-area-top: env(safe-area-inset-top, 0px);
@@ -40,6 +69,78 @@
/* Touch target minimum (iOS Human Interface Guidelines) */ /* Touch target minimum (iOS Human Interface Guidelines) */
--touch-target-min: 44px; --touch-target-min: 44px;
/* UI font + accent/gradient/hover tokens referenced by the v1.0 override block.
These :root values equal the daylight-blue palette so the no-attribute fallback
matches current behavior. */
--ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--accent-rgb: 56, 182, 240;
--accent-grad-a: #3ec8ee;
--accent-grad-b: #2b8fd9;
--run-hover-a: #5cc4f5;
--run-hover-b: #3aa3e2;
--gear-hover: #2a93d0;
}
/* ===== Skin: Daylight Blue (current default) ===== */
html[data-skin="daylight-blue"] {
--bg-dark: #11151c; --bg-card: #1b222c; --bg-input: #202833; --bg-hover: #2a323d;
--border: #2b333f; --border-light: #3a4350;
--text: #f3f6fa; --text-dim: #98a2b1; --text-muted: #717b8c;
--accent: #38b6f0; --accent-hover: #3ec8ee;
--term-bg: #161b23;
--green: #44b993; --yellow: #f0c25a; --red: #e06363;
--glass-bg: rgba(31, 38, 48, 0.85); --glass-border: rgba(255, 255, 255, 0.08);
--accent-d: #2487c9; --emerald: #3ec8ee; --teal: #2b8fd9;
--accent-soft: #44b993; --accent-ink: #04223a;
--accent-rgb: 56, 182, 240;
--accent-grad-a: #3ec8ee; --accent-grad-b: #2b8fd9;
--run-hover-a: #5cc4f5; --run-hover-b: #3aa3e2; --gear-hover: #2a93d0;
--ring-glow: 0 0 12px -2px rgba(56, 182, 240, 0.55);
--session-red: #e06363; --session-orange: #e8a45c; --session-yellow: #f0c25a;
--session-green: #44b993; --session-blue: #2b8fd9; --session-purple: #a98fe0; --session-pink: #e07db0;
--ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* ===== Skin: Daylight Green ===== */
html[data-skin="daylight-green"] {
--bg-dark: #11151c; --bg-card: #1b222c; --bg-input: #202833; --bg-hover: #2a323d;
--border: #2b333f; --border-light: #3a4350;
--text: #f3f6fa; --text-dim: #98a2b1; --text-muted: #717b8c;
--accent: #2fd3aa; --accent-hover: #34d8a0;
--term-bg: #161b23;
--green: #44b993; --yellow: #f0c25a; --red: #e06363;
--glass-bg: rgba(31, 38, 48, 0.85); --glass-border: rgba(255, 255, 255, 0.08);
--accent-d: #19a987; --emerald: #34d8a0; --teal: #2bcbbb;
--accent-soft: #44b993; --accent-ink: #062019;
--accent-rgb: 47, 211, 170;
--accent-grad-a: #34d8a0; --accent-grad-b: #2bcbbb;
--run-hover-a: #3ee0aa; --run-hover-b: #34d4c4; --gear-hover: #1cbd97;
--ring-glow: 0 0 12px -2px rgba(47, 211, 170, 0.55);
--session-red: #e06363; --session-orange: #e8a45c; --session-yellow: #f0c25a;
--session-green: #44b993; --session-blue: #2bcbbb; --session-purple: #a98fe0; --session-pink: #e07db0;
--ui-font: 'Manrope', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* ===== Skin: OG Codeman (original look — no Daylight polish, system font) ===== */
html[data-skin="og"] {
--bg-dark: #09090b; --bg-card: #131316; --bg-input: #1a1a1f; --bg-hover: #1f1f26;
--border: #232329; --border-light: #2e2e38;
--text: #ececf0; --text-dim: #8b8b97; --text-muted: #52525e;
--accent: #3b82f6; --accent-hover: #60a5fa;
--term-bg: #0d0d0d;
--green: #22c55e; --yellow: #eab308; --red: #ef4444;
--glass-bg: rgba(19, 19, 22, 0.85); --glass-border: rgba(255, 255, 255, 0.06);
/* daylight-only extras left at sane neutral defaults (unused — override block does not apply to og) */
--accent-d: #3b82f6; --emerald: #3b82f6; --teal: #2563eb;
--accent-soft: #22c55e; --accent-ink: #0b1220;
--accent-rgb: 59, 130, 246;
--accent-grad-a: #3b82f6; --accent-grad-b: #2563eb;
--run-hover-a: #60a5fa; --run-hover-b: #3b82f6; --gear-hover: #2563eb;
--ring-glow: 0 0 12px -2px rgba(59, 130, 246, 0.55);
--session-red: #ef4444; --session-orange: #f97316; --session-yellow: #eab308;
--session-green: #22c55e; --session-blue: #3b82f6; --session-purple: #a855f7; --session-pink: #ec4899;
--ui-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -129,7 +230,7 @@ textarea:focus-visible {
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--ui-font);
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text); color: var(--text);
height: 100vh; height: 100vh;
@@ -2097,7 +2198,7 @@ body.solo-mode .btn-lifecycle-log {
.terminal-container { .terminal-container {
flex: 1; flex: 1;
background: #0d0d0d; background: var(--term-bg, #161b23);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
/* Performance: isolate layout and paint operations. /* Performance: isolate layout and paint operations.
@@ -3828,6 +3929,22 @@ body.solo-mode .btn-lifecycle-log {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Skin picker: full-width row (not squeezed into a half-width grid cell) with an
auto-width select, so the "Skin" label keeps its space instead of truncating. */
.settings-item-skin {
grid-column: 1 / -1;
gap: 0.75rem;
}
.settings-item-skin .settings-item-label {
flex: 0 0 auto;
}
.settings-item-skin .form-select {
width: auto;
min-width: 220px;
max-width: 320px;
flex: 0 1 auto;
}
.settings-item-multiline { .settings-item-multiline {
align-items: center; align-items: center;
} }
@@ -9057,3 +9174,233 @@ body.touch-device.cjk-input-visible .main {
pointer-events: none; pointer-events: none;
z-index: 100; z-index: 100;
} }
/* ============================================================
=== v1.0 Carbon Aurora · Daylight overrides ===
Single appended block — trivially removable. Retones the hardcoded
component colors that don't flow through :root: the active session
tab (#00FF66), the banners' literal gradients, the Ralph Material
palette, the Run/welcome/stop gradient buttons, the panels' glass,
and the welcome title. Calms heavy glows; the ONLY real saturation/
glow is reserved for the Run button + the progress ring.
============================================================ */
html:not([data-skin="og"]) {
/* ---- Brand wordmark: emerald, calm (kill the blue glow) ---- */
.logo:hover {
color: var(--accent-hover);
text-shadow: none;
filter: brightness(1.06);
}
/* ---- Active session tab: crisp emerald (replaces neon #00FF66) ---- */
.session-tab.active {
background: rgba(var(--accent-rgb), 0.13) !important;
border: 1px solid var(--accent) !important;
color: var(--text) !important;
box-shadow: none !important;
}
.session-tab:hover {
background: rgba(var(--accent-rgb), 0.06);
border-color: rgba(var(--accent-rgb), 0.2);
box-shadow: none;
}
.session-tab .tab-number {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.18);
color: var(--text-dim);
}
.session-tab.active .tab-number {
background: rgba(var(--accent-rgb), 0.2) !important;
border-color: rgba(var(--accent-rgb), 0.45) !important;
color: var(--accent-hover) !important;
}
/* per-color active tints: calm the glow, keep the identity hue as a quiet border */
.session-tab.active[data-color] {
box-shadow: none;
}
/* tab status dots: quiet, no halo */
.session-tab .tab-status.idle,
.session-tab .tab-status.busy {
box-shadow: none;
}
.tab-glow { animation: none; box-shadow: none; }
/* ---- Timer banner: emerald-teal, flat ---- */
.timer-banner {
background: linear-gradient(90deg, rgba(31, 38, 48, 0.92), rgba(22, 27, 35, 0.92));
border-bottom: 1px solid var(--glass-border);
}
.timer-value { color: var(--accent); }
.timer-progress-fill { background: linear-gradient(90deg, var(--accent-grad-a), var(--accent-grad-b)); }
/* ---- Respawn banner: lifted slate, calm green hairline ---- */
.respawn-banner {
background: linear-gradient(90deg, rgba(31, 38, 48, 0.9), rgba(22, 27, 35, 0.9));
border-bottom: 1px solid var(--glass-border);
}
.respawn-countdown-timer .respawn-timer-value,
.action-type { color: var(--accent); }
.respawn-timer-progress { background: var(--accent); }
.respawn-action-log { border-left-color: rgba(var(--accent-rgb), 0.18); }
.respawn-action-log::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb), 0.28); }
/* ---- Ralph panel: replace the blue->green ring gradient + Material badges ---- */
.ralph-ring-text {
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
-webkit-background-clip: text;
background-clip: text;
}
.ralph-ring-bg, .ralph-ring-track { stroke: rgba(255, 255, 255, 0.1); }
/* Ralph status badges: collapse the off-token Material palette onto the system */
.ralph-status-badge { color: var(--text-dim); }
.ralph-status-dot { box-shadow: none; }
.ralph-info { background: rgba(var(--accent-rgb), 0.05); }
/* ---- Bottom toolbar + neutral buttons ---- */
.toolbar { box-shadow: 0 -1px 3px rgba(0, 0, 0, 0.22), 0 -4px 14px rgba(0, 0, 0, 0.14); }
.btn-toolbar {
background: rgba(255, 255, 255, 0.045);
border-color: rgba(255, 255, 255, 0.09);
color: var(--text-dim);
}
.btn-toolbar:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.14);
color: var(--text);
box-shadow: none;
}
/* ---- RUN button: THE focal saturated accent (flat emerald->teal fill) ---- */
.btn-toolbar.btn-run,
.btn-toolbar.btn-run.mode-claude {
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
font-weight: 700;
text-shadow: none;
}
.btn-toolbar.btn-run:hover,
.btn-toolbar.btn-run.mode-claude:hover {
background: linear-gradient(135deg, var(--run-hover-a), var(--run-hover-b));
box-shadow: 0 0 14px -2px rgba(var(--accent-rgb), 0.5);
}
.btn-toolbar.btn-run.mode-opencode {
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
}
.btn-toolbar.btn-run.mode-opencode:hover { box-shadow: 0 0 14px -2px rgba(var(--accent-rgb), 0.45); }
.btn-toolbar.btn-run.mode-codex {
background: linear-gradient(135deg, var(--accent-grad-b), #5cc6e8);
border-color: var(--accent-grad-b);
color: #061c20;
}
.btn-toolbar.btn-run.mode-codex:hover { box-shadow: 0 0 14px -2px rgba(43, 203, 187, 0.45); }
.btn-toolbar.btn-run-gear {
background: var(--accent-d);
border-color: var(--accent);
color: var(--accent-ink);
}
.btn-toolbar.btn-run-gear:hover { background: var(--gear-hover); box-shadow: none; }
.run-mode-dot.claude { background: var(--accent); }
.run-mode-dot.opencode { background: var(--accent-soft); }
.run-mode-dot.codex { background: var(--accent-grad-b); }
/* ---- Shell button: quiet neutral with a calm green tint ---- */
.btn-toolbar.btn-shell {
background: rgba(68, 185, 147, 0.1);
border: 1px solid rgba(68, 185, 147, 0.28);
color: var(--green);
text-shadow: none;
}
.btn-toolbar.btn-shell:hover {
background: rgba(68, 185, 147, 0.16);
border-color: rgba(68, 185, 147, 0.4);
box-shadow: none;
}
/* ---- Stop button: quiet neutral control (no rose glow) ---- */
.btn-toolbar.btn-stop {
background: rgba(255, 255, 255, 0.045);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-dim);
text-shadow: none;
}
.btn-toolbar.btn-stop:hover {
background: rgba(224, 99, 99, 0.14);
border-color: rgba(224, 99, 99, 0.4);
color: #f0a0a0;
box-shadow: none;
}
.btn-toolbar.btn-stop.confirming {
background: linear-gradient(135deg, #5a4a1a, #6b5620);
border-color: rgba(240, 194, 90, 0.6);
color: var(--yellow);
}
/* ---- Case selector + add ---- */
.toolbar-select {
background: rgba(255, 255, 255, 0.045);
border-color: rgba(255, 255, 255, 0.09);
color: var(--text-dim);
}
.toolbar-select:hover { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.14); }
.toolbar-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.25);
}
.btn-case-add { color: var(--accent-soft); }
/* ---- Header system stats: cohesive accent meters ---- */
.stat-bar-mem { background: linear-gradient(90deg, var(--accent), var(--accent-hover)); }
.header-tokens.clickable:hover { background: rgba(var(--accent-rgb), 0.16); }
/* ---- Monitor + Subagents panels: lifted frosted slate ---- */
.monitor-panel,
.subagents-panel {
background: rgba(31, 38, 48, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* ---- Subagent progress ring: emerald, calm glow ---- */
.subagent-ring-progress,
.subagent-ring circle.progress { stroke: var(--accent); }
/* ---- PROGRESS RING (Ralph large): the one place a soft glow lives ---- */
.ralph-ring-fill { filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.45)); }
/* ---- Welcome overlay: emerald title + Daylight buttons ---- */
.welcome-title {
background: linear-gradient(135deg, var(--accent-grad-a) 0%, var(--accent-grad-b) 50%, var(--accent-grad-a) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.welcome-btn-claude {
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
}
.welcome-btn-claude:hover { box-shadow: 0 0 28px -4px rgba(var(--accent-rgb), 0.3); }
.welcome-btn-opencode {
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
border-color: rgba(var(--accent-rgb), 0.4);
color: var(--accent-ink);
}
.welcome-btn-opencode:hover { box-shadow: 0 0 28px -4px rgba(var(--accent-rgb), 0.25); }
.welcome-btn-tunnel {
background: linear-gradient(135deg, rgba(46, 55, 67, 0.9), rgba(56, 66, 79, 0.9));
border-color: var(--border-light);
color: var(--text);
}
.welcome-btn-tunnel:hover { box-shadow: none; }
.welcome-btn-tunnel.active {
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-a));
border-color: var(--accent);
color: var(--accent-ink);
}
/* ---- Focus rings (driven by --accent, now emerald) stay accessible ---- */
:focus-visible { outline-color: var(--accent); }
}
+40 -23
View File
@@ -28,11 +28,27 @@
return isTerminalQueryResponse(data); return isTerminalQueryResponse(data);
} }
// Per-skin xterm.js palettes. The 'daylight-blue' object equals the legacy hardcoded
// theme, so default behavior is unchanged. Shared at module scope and exported on the
// global so both terminal-ui.js (main terminal) and panels-ui.js (teammate terminals,
// a separate IIFE) can read the current skin's palette.
const CODEMAN_XTERM_THEMES = {
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
};
function currentXtermTheme() {
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
}
global.CodemanTerminalInput = { global.CodemanTerminalInput = {
isTerminalQueryResponse, isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse, shouldSuppressTerminalQueryResponse,
USER_SCROLL_STICKY_SUPPRESS_MS, USER_SCROLL_STICKY_SUPPRESS_MS,
}; };
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
})(window); })(window);
Object.assign(CodemanApp.prototype, { Object.assign(CodemanApp.prototype, {
@@ -47,29 +63,7 @@ Object.assign(CodemanApp.prototype, {
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK; const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
this.terminal = new Terminal({ this.terminal = new Terminal({
theme: { theme: { ...window.codemanCurrentXtermTheme() },
background: '#0d0d0d',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#0d0d0d',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#0d0d0d',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line) // Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14, fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
@@ -2182,4 +2176,27 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('dirDisplay').textContent = value || 'No directory'; document.getElementById('dirDisplay').textContent = value || 'No directory';
}, 100); }, 100);
}, },
// Re-theme all live xterm terminals (main + teammate) to the given skin's palette.
// Uses the xterm v5+ live setter (full object assignment triggers a repaint for both
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
applyTerminalSkin(skin) {
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
if (this.terminal) {
this.terminal.options.theme = theme;
try {
this.terminal.refresh(0, this.terminal.rows - 1);
} catch {}
}
if (this.teammateTerminals) {
for (const [, entry] of this.teammateTerminals) {
if (entry && entry.terminal) {
entry.terminal.options.theme = { ...theme };
try {
entry.terminal.refresh(0, entry.terminal.rows - 1);
} catch {}
}
}
}
},
}); });
+25
View File
@@ -59,6 +59,7 @@ import {
type SubagentMessage, type SubagentMessage,
type SubagentToolResult, type SubagentToolResult,
} from '../subagent-watcher.js'; } from '../subagent-watcher.js';
import { agentArtifactIndex, type AgentArtifact } from '../agent-artifact-index.js';
import { imageWatcher } from '../image-watcher.js'; import { imageWatcher } from '../image-watcher.js';
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js'; import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
import { TranscriptWatcher } from '../transcript-watcher.js'; import { TranscriptWatcher } from '../transcript-watcher.js';
@@ -248,6 +249,11 @@ export class WebServer extends EventEmitter {
completed: (info: SubagentInfo) => void; completed: (info: SubagentInfo) => void;
error: (error: Error, agentId?: string) => void; error: (error: Error, agentId?: string) => void;
} | null = null; } | null = null;
private artifactIndexHandlers: {
added: (artifact: AgentArtifact) => void;
updated: (artifact: AgentArtifact) => void;
error: (error: Error) => void;
} | null = null;
private imageWatcherHandlers: { private imageWatcherHandlers: {
detected: (event: ImageDetectedEvent) => void; detected: (event: ImageDetectedEvent) => void;
attachmentDetected: (event: AttachmentDetectedEvent) => void; attachmentDetected: (event: AttachmentDetectedEvent) => void;
@@ -412,6 +418,18 @@ export class WebServer extends EventEmitter {
subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message); subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message);
subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed); subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed);
subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error); subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error);
// Agent artifact index: attribute the files each subagent writes (from subagentWatcher's
// tool-call stream) and broadcast them as `agent:artifact` for the Agent Gallery view.
agentArtifactIndex.start(subagentWatcher);
this.artifactIndexHandlers = {
added: (artifact: AgentArtifact) => this.broadcast(SseEvent.AgentArtifact, artifact),
updated: (artifact: AgentArtifact) => this.broadcast(SseEvent.AgentArtifact, artifact),
error: (error: Error) => console.error('[AgentArtifactIndex] Error:', error.message),
};
agentArtifactIndex.on('artifact:added', this.artifactIndexHandlers.added);
agentArtifactIndex.on('artifact:updated', this.artifactIndexHandlers.updated);
agentArtifactIndex.on('artifact:error', this.artifactIndexHandlers.error);
} }
/** /**
@@ -429,6 +447,13 @@ export class WebServer extends EventEmitter {
subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error); subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error);
this.subagentWatcherHandlers = null; this.subagentWatcherHandlers = null;
} }
if (this.artifactIndexHandlers) {
agentArtifactIndex.off('artifact:added', this.artifactIndexHandlers.added);
agentArtifactIndex.off('artifact:updated', this.artifactIndexHandlers.updated);
agentArtifactIndex.off('artifact:error', this.artifactIndexHandlers.error);
this.artifactIndexHandlers = null;
}
agentArtifactIndex.stop();
} }
/** /**
+3
View File
@@ -212,6 +212,8 @@ export const SubagentProgress = 'subagent:progress' as const;
export const SubagentMessage = 'subagent:message' as const; export const SubagentMessage = 'subagent:message' as const;
/** Subagent finished. */ /** Subagent finished. */
export const SubagentCompleted = 'subagent:completed' as const; export const SubagentCompleted = 'subagent:completed' as const;
/** A file artifact was attributed to a subagent (newly added or re-written). Powers the Agent Gallery. */
export const AgentArtifact = 'agent:artifact' as const;
// ─── Scheduled Runs ────────────────────────────────────────────────────────── // ─── Scheduled Runs ──────────────────────────────────────────────────────────
@@ -442,6 +444,7 @@ export const SseEvent = {
SubagentProgress, SubagentProgress,
SubagentMessage, SubagentMessage,
SubagentCompleted, SubagentCompleted,
AgentArtifact,
// Scheduled runs // Scheduled runs
ScheduledCreated, ScheduledCreated,
+207
View File
@@ -0,0 +1,207 @@
/**
* Tests for AgentArtifactIndex — the subagent → produced-files join.
*
* Driven through a bare EventEmitter standing in for `subagentWatcher`, so no real
* transcript files, tmux, or server are involved.
*/
import { EventEmitter } from 'node:events';
import { describe, it, expect, beforeEach } from 'vitest';
import { AgentArtifactIndex, type AgentArtifact } from '../src/agent-artifact-index.js';
import { MAX_ARTIFACTS_PER_AGENT } from '../src/config/map-limits.js';
/** Build a `subagent:tool_call` payload like the watcher emits. */
function toolCall(over: {
agentId?: string;
sessionId?: string;
tool: string;
input: Record<string, unknown>;
timestamp?: string;
}) {
return {
agentId: over.agentId ?? 'agent-1',
sessionId: over.sessionId ?? 'sess-1',
timestamp: over.timestamp ?? '2026-06-13T05:44:00.000Z',
tool: over.tool,
input: over.input,
toolUseId: 'toolu_x',
fullInput: over.input,
};
}
describe('AgentArtifactIndex', () => {
let index: AgentArtifactIndex;
let source: EventEmitter;
beforeEach(() => {
source = new EventEmitter();
index = new AgentArtifactIndex();
index.start(source);
});
function emitCall(over: Parameters<typeof toolCall>[0]) {
source.emit('subagent:tool_call', toolCall(over));
}
it('records a Write as an artifact and classifies HTML', () => {
const added: AgentArtifact[] = [];
index.on('artifact:added', (a) => added.push(a));
emitCall({
tool: 'Write',
input: { file_path: '/home/arkon/proj/mockups/01-aurora.html', content: '<!doctype html>' },
});
expect(added).toHaveLength(1);
expect(added[0]).toMatchObject({
agentId: 'agent-1',
sessionId: 'sess-1',
path: '/home/arkon/proj/mockups/01-aurora.html',
filename: '01-aurora.html',
ext: '.html',
kind: 'html',
source: 'write',
writeCount: 1,
});
expect(index.getArtifactsForAgent('agent-1')).toHaveLength(1);
});
it('dedupes repeated writes/edits of the same path and counts them', () => {
const updated: AgentArtifact[] = [];
index.on('artifact:updated', (a) => updated.push(a));
emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '1' }, timestamp: '2026-06-13T05:00:00.000Z' });
emitCall({
tool: 'Edit',
input: { file_path: '/p/a.html', old_string: '1', new_string: '2' },
timestamp: '2026-06-13T05:01:00.000Z',
});
emitCall({ tool: 'Edit', input: { file_path: '/p/a.html' }, timestamp: '2026-06-13T05:02:00.000Z' });
const arts = index.getArtifactsForAgent('agent-1');
expect(arts).toHaveLength(1);
expect(arts[0].writeCount).toBe(3);
expect(arts[0].source).toBe('write'); // first attribution wins
expect(arts[0].lastWriteAt).toBe(Date.parse('2026-06-13T05:02:00.000Z'));
expect(arts[0].firstSeenAt).toBe(Date.parse('2026-06-13T05:00:00.000Z'));
expect(updated).toHaveLength(2);
});
it('normalizes ../. in absolute paths so they dedupe', () => {
emitCall({ tool: 'Write', input: { file_path: '/p/sub/../a.html', content: '' } });
emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '' } });
expect(index.getArtifactsForAgent('agent-1')).toHaveLength(1);
expect(index.getArtifactsForAgent('agent-1')[0].path).toBe('/p/a.html');
});
it('ignores non-file tools and writes missing a path', () => {
emitCall({ tool: 'Read', input: { file_path: '/p/a.html' } });
emitCall({ tool: 'Bash', input: { command: 'node capture.mjs' } });
emitCall({ tool: 'Write', input: { content: 'no path' } });
expect(index.count()).toBe(0);
});
it('handles NotebookEdit via notebook_path with notebook source', () => {
emitCall({ tool: 'NotebookEdit', input: { notebook_path: '/p/analysis.ipynb', new_source: 'x' } });
const arts = index.getArtifactsForAgent('agent-1');
expect(arts).toHaveLength(1);
expect(arts[0].source).toBe('notebook');
expect(arts[0].kind).toBe('other'); // .ipynb not in the render map
});
it.each([
['/p/x.png', 'image'],
['/p/x.PNG', 'image'],
['/p/x.svg', 'image'],
['/p/x.pdf', 'pdf'],
['/p/x.docx', 'document'],
['/p/x.pptx', 'document'],
['/p/x.md', 'markdown'],
['/p/x.txt', 'text'],
['/p/x.css', 'code'],
['/p/x.ts', 'code'],
['/p/x.bin', 'other'],
['/p/Makefile', 'other'],
])('classifies %s as %s', (file, kind) => {
emitCall({ tool: 'Write', input: { file_path: file, content: '' } });
expect(index.getAllArtifacts()[0].kind).toBe(kind);
});
it('groups artifacts by agent and by session', () => {
emitCall({ agentId: 'a1', sessionId: 's1', tool: 'Write', input: { file_path: '/p/1.html', content: '' } });
emitCall({ agentId: 'a2', sessionId: 's1', tool: 'Write', input: { file_path: '/p/2.html', content: '' } });
emitCall({ agentId: 'a3', sessionId: 's2', tool: 'Write', input: { file_path: '/p/3.html', content: '' } });
expect(index.getArtifactsForAgent('a1')).toHaveLength(1);
expect(
index
.getArtifactsForSession('s1')
.map((a) => a.agentId)
.sort()
).toEqual(['a1', 'a2']);
expect(index.getArtifactsForSession('s2')).toHaveLength(1);
expect(index.count()).toBe(3);
});
it('sorts results most-recently-written first', () => {
emitCall({
tool: 'Write',
input: { file_path: '/p/old.html', content: '' },
timestamp: '2026-06-13T01:00:00.000Z',
});
emitCall({
tool: 'Write',
input: { file_path: '/p/new.html', content: '' },
timestamp: '2026-06-13T09:00:00.000Z',
});
expect(index.getArtifactsForAgent('agent-1').map((a) => a.filename)).toEqual(['new.html', 'old.html']);
});
it('records external artifacts (image-watcher correlation hook)', () => {
index.recordExternalArtifact(
'agent-1',
'sess-1',
'/p/shots/01-desktop.png',
Date.parse('2026-06-13T05:45:00.000Z')
);
const arts = index.getArtifactsForAgent('agent-1');
expect(arts).toHaveLength(1);
expect(arts[0]).toMatchObject({ source: 'external', kind: 'image', filename: '01-desktop.png' });
});
it('bounds artifacts per agent with FIFO eviction', () => {
for (let i = 0; i < MAX_ARTIFACTS_PER_AGENT + 5; i++) {
emitCall({ tool: 'Write', input: { file_path: `/p/f${i}.html`, content: '' } });
}
expect(index.getArtifactsForAgent('agent-1')).toHaveLength(MAX_ARTIFACTS_PER_AGENT);
// Oldest (f0..f4) evicted; newest retained.
expect(index.getArtifact('agent-1', '/p/f0.html')).toBeUndefined();
expect(index.getArtifact('agent-1', `/p/f${MAX_ARTIFACTS_PER_AGENT + 4}.html`)).toBeDefined();
});
it('stop() detaches from the source; clear() drops artifacts', () => {
emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '' } });
index.stop();
emitCall({ tool: 'Write', input: { file_path: '/p/b.html', content: '' } });
expect(index.count()).toBe(1); // second call ignored after stop
index.clear();
expect(index.count()).toBe(0);
});
it('start() is idempotent — no double counting on repeat start with same source', () => {
index.start(source);
index.start(source);
const added: AgentArtifact[] = [];
index.on('artifact:added', (a) => added.push(a));
emitCall({ tool: 'Write', input: { file_path: '/p/a.html', content: '' } });
expect(added).toHaveLength(1);
});
it('clearAgent() drops only the named agent', () => {
emitCall({ agentId: 'a1', tool: 'Write', input: { file_path: '/p/1.html', content: '' } });
emitCall({ agentId: 'a2', tool: 'Write', input: { file_path: '/p/2.html', content: '' } });
index.clearAgent('a1');
expect(index.getArtifactsForAgent('a1')).toHaveLength(0);
expect(index.getArtifactsForAgent('a2')).toHaveLength(1);
});
});