mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36693b922e | ||
|
|
95a59b0e9f | ||
|
|
dae4d8fbee |
@@ -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();
|
||||
@@ -62,6 +62,12 @@ export const MAX_TRACKED_AGENTS = 500;
|
||||
*/
|
||||
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).
|
||||
* If no tool_result received, entry is cleaned up.
|
||||
|
||||
@@ -580,6 +580,7 @@ class CodemanApp {
|
||||
const _kbSettings = this.loadAppSettingsFromStorage();
|
||||
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applySkin();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
// 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)
|
||||
this.loadAppSettingsFromServer(settingsPromise).then(() => {
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applySkin();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
});
|
||||
|
||||
@@ -333,6 +333,7 @@ const SSE_EVENTS = {
|
||||
SUBAGENT_MESSAGE: 'subagent:message',
|
||||
SUBAGENT_TOOL_RESULT: 'subagent:tool_result',
|
||||
SUBAGENT_COMPLETED: 'subagent:completed',
|
||||
AGENT_ARTIFACT: 'agent:artifact',
|
||||
|
||||
// Images
|
||||
IMAGE_DETECTED: 'image:detected',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+22
-10
@@ -8,7 +8,7 @@
|
||||
<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="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="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
@@ -41,15 +41,17 @@
|
||||
<script defer src="vendor/marked.min.js"></script>
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- 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) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#09090b}
|
||||
.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-brand{color:#60a5fa;font-size:14px;font-weight:700;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.7}
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
|
||||
.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:#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-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
|
||||
.skeleton-terminal{flex:1;background:#0d0d0d}
|
||||
.skeleton-toolbar{height:42px;background:rgba(19,19,22,0.85);border-top:1px solid rgba(255,255,255,0.06)}
|
||||
.skeleton-terminal{flex:1;background:#161b23}
|
||||
.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}
|
||||
</style>
|
||||
</head>
|
||||
@@ -178,8 +180,8 @@
|
||||
<svg viewBox="0 0 36 36" class="ralph-ring-svg">
|
||||
<defs>
|
||||
<linearGradient id="ralphGradientMini" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
<stop offset="0%" stop-color="#3ec8ee" />
|
||||
<stop offset="100%" stop-color="#2b8fd9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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">
|
||||
<defs>
|
||||
<linearGradient id="ralphGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6" />
|
||||
<stop offset="100%" stop-color="#22c55e" />
|
||||
<stop offset="0%" stop-color="#3ec8ee" />
|
||||
<stop offset="100%" stop-color="#2b8fd9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle class="ralph-ring-track" cx="50" cy="50" r="42" />
|
||||
@@ -918,6 +920,16 @@
|
||||
<!-- Display Tab -->
|
||||
<div class="modal-tab-content" id="settings-display">
|
||||
<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 -->
|
||||
<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.">
|
||||
|
||||
@@ -1550,29 +1550,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
const terminal = new Terminal({
|
||||
theme: {
|
||||
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',
|
||||
},
|
||||
theme: { ...window.codemanCurrentXtermTheme() },
|
||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.2,
|
||||
|
||||
@@ -309,6 +309,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowCost').checked = settings.showCost ?? defaults.showCost ?? false;
|
||||
document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? true;
|
||||
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('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
|
||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
@@ -1377,6 +1378,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
skin: document.getElementById('appSettingsSkin').value,
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||
@@ -1491,6 +1493,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Apply header visibility immediately
|
||||
this.applyHeaderVisibilitySettings();
|
||||
this.applySkin();
|
||||
this.applyTabWrapSettings();
|
||||
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
|
||||
this.applyMonitorVisibility();
|
||||
@@ -1505,7 +1508,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Save to server (includes notification prefs for cross-browser persistence)
|
||||
// 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 {
|
||||
const res = await this._apiPut('/api/settings', {
|
||||
...serverSettings,
|
||||
@@ -1664,6 +1667,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
cjkInputEnabled: false,
|
||||
skin: 'daylight-blue',
|
||||
};
|
||||
}
|
||||
// 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() {
|
||||
const settings = this.loadAppSettingsFromStorage();
|
||||
const defaults = this.getDefaultSettings();
|
||||
@@ -1944,6 +1966,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showLifecycleLog', 'showResponseViewer',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin',
|
||||
]);
|
||||
// Merge settings: non-display keys always sync from server,
|
||||
// display keys only seed from server when localStorage has no value
|
||||
|
||||
+373
-26
@@ -1,36 +1,65 @@
|
||||
/* 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 {
|
||||
--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;
|
||||
--green: #22c55e;
|
||||
--yellow: #eab308;
|
||||
--red: #ef4444;
|
||||
/* Carbon Aurora · Daylight — deep-but-not-black slate, surfaces step UP into the light */
|
||||
--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;
|
||||
/* SINGLE cohesive accent — optimistic emerald → teal (replaces the old blue) */
|
||||
--accent: #38b6f0;
|
||||
--accent-hover: #3ec8ee;
|
||||
/* 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;
|
||||
--toolbar-height: 42px;
|
||||
--glass-bg: rgba(19, 19, 22, 0.85);
|
||||
--glass-border: rgba(255, 255, 255, 0.06);
|
||||
--glass-bg: rgba(31, 38, 48, 0.85);
|
||||
--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);
|
||||
--btn-radius: 6px;
|
||||
--transition-smooth: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Session color palette */
|
||||
--session-red: #ef4444;
|
||||
--session-orange: #f97316;
|
||||
--session-yellow: #eab308;
|
||||
--session-green: #22c55e;
|
||||
--session-blue: #3b82f6;
|
||||
--session-purple: #a855f7;
|
||||
--session-pink: #ec4899;
|
||||
/* Session color palette — retoned toward the Daylight system */
|
||||
--session-red: #e06363;
|
||||
--session-orange: #e8a45c;
|
||||
--session-yellow: #f0c25a;
|
||||
--session-green: #44b993;
|
||||
--session-blue: #2b8fd9;
|
||||
--session-purple: #a98fe0;
|
||||
--session-pink: #e07db0;
|
||||
|
||||
/* Safe area insets for notched devices (iPhone X+, Dynamic Island) */
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
@@ -40,6 +69,78 @@
|
||||
|
||||
/* Touch target minimum (iOS Human Interface Guidelines) */
|
||||
--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; }
|
||||
@@ -129,7 +230,7 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
@@ -2097,7 +2198,7 @@ body.solo-mode .btn-lifecycle-log {
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
background: #0d0d0d;
|
||||
background: var(--term-bg, #161b23);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
/* Performance: isolate layout and paint operations.
|
||||
@@ -3828,6 +3929,22 @@ body.solo-mode .btn-lifecycle-log {
|
||||
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 {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -9057,3 +9174,233 @@ body.touch-device.cjk-input-visible .main {
|
||||
pointer-events: none;
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -28,11 +28,27 @@
|
||||
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 = {
|
||||
isTerminalQueryResponse,
|
||||
shouldSuppressTerminalQueryResponse,
|
||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
})(window);
|
||||
|
||||
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;
|
||||
|
||||
this.terminal = new Terminal({
|
||||
theme: {
|
||||
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',
|
||||
},
|
||||
theme: { ...window.codemanCurrentXtermTheme() },
|
||||
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)
|
||||
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
|
||||
@@ -2182,4 +2176,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('dirDisplay').textContent = value || 'No directory';
|
||||
}, 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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
type SubagentMessage,
|
||||
type SubagentToolResult,
|
||||
} from '../subagent-watcher.js';
|
||||
import { agentArtifactIndex, type AgentArtifact } from '../agent-artifact-index.js';
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { attachmentRegistry, registerExternalAttachment } from '../attachment-registry.js';
|
||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||
@@ -248,6 +249,11 @@ export class WebServer extends EventEmitter {
|
||||
completed: (info: SubagentInfo) => void;
|
||||
error: (error: Error, agentId?: string) => void;
|
||||
} | null = null;
|
||||
private artifactIndexHandlers: {
|
||||
added: (artifact: AgentArtifact) => void;
|
||||
updated: (artifact: AgentArtifact) => void;
|
||||
error: (error: Error) => void;
|
||||
} | null = null;
|
||||
private imageWatcherHandlers: {
|
||||
detected: (event: ImageDetectedEvent) => void;
|
||||
attachmentDetected: (event: AttachmentDetectedEvent) => void;
|
||||
@@ -412,6 +418,18 @@ export class WebServer extends EventEmitter {
|
||||
subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message);
|
||||
subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed);
|
||||
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);
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -212,6 +212,8 @@ export const SubagentProgress = 'subagent:progress' as const;
|
||||
export const SubagentMessage = 'subagent:message' as const;
|
||||
/** Subagent finished. */
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -442,6 +444,7 @@ export const SseEvent = {
|
||||
SubagentProgress,
|
||||
SubagentMessage,
|
||||
SubagentCompleted,
|
||||
AgentArtifact,
|
||||
|
||||
// Scheduled runs
|
||||
ScheduledCreated,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user