diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eac16b..6563b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,65 @@ For releases before v1.3.35, see [GitHub Releases](https://github.com/VladoIvank > as the social-share summary (IFTTT → X/Bluesky), capped at 220 chars. > If omitted, the feed falls back to the first paragraph. +## [Unreleased] + +> TL;DR — `codeep --yolo -- ""` starts Codeep for a launcher with nothing left to answer, Omarchy's skills in `~/.agents/skills` load, and Codeep's colours follow the Omarchy theme. + +### Added + +- **`codeep -- `** starts a new session with `` as its first + message, as if it had been typed (also `-p ` and `--prompt + `). Everything after `--` is the prompt, so `codeep -- review` asks + the model and does not run `codeep review`. A missing or doubled prompt is + an error rather than a guess. +- **`codeep --yolo`** starts without stopping to ask, for this launch only: + agent actions run without confirmation, a vague task gets no clarifying + questions, the folder is used as the project with read & write access, and + a new session starts. Nothing is saved, so the next plain `codeep` asks as + before; changing Agent Confirmation or Agent Interactive Mode in + `/settings` ends `--yolo`'s hold on that setting. Writes to files that + decide what runs later are still confirmed, and a workspace's own MCP + servers still ask to be trusted. The status bar shows **YOLO** while + confirmations are off. +- **Skills in `~/.agents/skills` load**, the directory agent tools share and + where Omarchy links its skills. When two skills have the same name, the + project's `.codeep/skills` wins, then `~/.codeep/skills`, then + `~/.agents/skills`. `/skills bundles` names the directory each came from. +- **A skill can send the agent to the files beside it.** `invoke_skill` now + gives the skill's directory and reads a file from it (a guide such as + `reporting.md`), which `read_file` cannot reach for a skill outside the + project. Only files inside that directory are read. +- **Codeep follows the Omarchy theme.** On Omarchy, the colours Codeep + paints itself — the brand red, the greys of the chat, the code colours, + the warning orange and the **YOLO** badge — come from the current theme's + `colors.toml` and change when the theme does, in a session that is already + open, the way Claude Code, OpenCode and Pi do. A theme colour too faint to + read as text is darkened or lightened until it reads, so light themes stay + legible. **Follow Omarchy theme** in `/settings` (On by default, shown + only on Omarchy) turns it off. Codeep only reads Omarchy's state; it + writes nothing to your Omarchy config. Elsewhere every colour is exactly + what it was. + +### Fixed + +- **Startup asks its questions one at a time.** In a folder that is not a + repository but has its own MCP servers, the question whether to trust them + could replace "Set as Project?", leaving that one unanswered and the + startup stopped. It now comes after the startup questions, and a launch + prompt is sent after it. +- **Folded and literal descriptions.** A `SKILL.md` whose `description:` was + written as a `>` or `|` block, as Omarchy's are, loaded with ">" as its + description. +- **The agent sees a skill's whole description.** The catalog cut every entry + at 200 characters, mid-word, which dropped the part of a long description + that says when to use the skill. + +### Security + +- **Writing to `.agents/skills/` is confirmed in every mode**, as + `.codeep/skills/` already was: every Codeep session loads the skills in + `~/.agents/skills`. + ## [3.8.1] — 2026-09-30 > TL;DR — While a thinking model works between steps, the agent screen now says it is waiting for the model and for how long, not "Listing" a tool that already finished. Auto-verify's checks show as checks. diff --git a/Codeep.svg b/Codeep.svg index 48c5d01..e72eecc 100644 --- a/Codeep.svg +++ b/Codeep.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/README.md b/README.md index 39e5bfb..1a00bdc 100644 --- a/README.md +++ b/README.md @@ -680,10 +680,16 @@ Beyond the built-in skills and custom slash commands, Codeep now supports **structured skill bundles** — directory-based capability packs the agent discovers and invokes on its own. -A bundle is a directory `.codeep/skills//SKILL.md` (project-scoped) -or `~/.codeep/skills//SKILL.md` (global). The file is Markdown with -a YAML frontmatter header — the format is a **superset of Claude Code -skills**, so existing skills drop in unchanged: +A bundle is a directory with a `SKILL.md` in one of three places. When two +have the same name, the first one here wins: + +1. `.codeep/skills//SKILL.md` — project-scoped +2. `~/.codeep/skills//SKILL.md` — global, Codeep's own +3. `~/.agents/skills//SKILL.md` — global, shared with other agent + tools (Omarchy links its skills in here) + +The file is Markdown with a YAML frontmatter header — the format is a +**superset of Claude Code skills**, so existing skills drop in unchanged: ```markdown --- @@ -703,6 +709,11 @@ Run the test suite, build, then `npm run deploy:staging`. If the build fails, surface the error and stop — don't deploy a broken build. ``` +A long `description` can also be a folded (`>`) or literal (`|`) block. +Files a skill refers to, such as a `reporting.md` guide, sit next to its +`SKILL.md`: `invoke_skill` tells the agent the skill's directory and reads +them from there. + The agent gets the bundle catalog injected into its system prompt on every iteration and can invoke any bundle via the `invoke_skill` tool: @@ -1212,6 +1223,8 @@ In `dangerous` mode, configure which tools require confirmation via `/settings`: | `codeep account sync` | Pull API keys + personalities + commands from codeep.dev → local | | `codeep account push` | Push local API keys + personalities + commands → codeep.dev | | `codeep review [files…]` | Offline, deterministic code review for CI — markdown or `--json`, `--fail-on ` sets the exit code. No API key needed. | +| `codeep -- ` | Start a new session and send `` as its first message (also `-p ` / `--prompt `). Everything after `--` is the prompt, so `codeep -- review` asks the model rather than running `codeep review`. Startup's own questions come first: the project and access questions without `--yolo`, and with or without it, trusting a workspace's own MCP servers the first time. | +| `codeep --yolo` | Start without stopping to ask, for this launch only — for launchers that open Codeep unattended (`codeep --yolo -- ""`). Agent actions run without confirmation (Agent Confirmation: Never) and without clarifying questions first (Agent Interactive Mode: Off), the folder is used as the project with read & write access, and a new session starts. Nothing is saved: the next plain `codeep` asks as before, and changing either setting in `/settings` ends `--yolo`'s hold on it. Writes to files that decide what runs later (`.git/config`, git hooks, MCP server lists) are still confirmed, and a workspace's own MCP servers (`.mcp.json`) still ask to be trusted the first time, before the prompt is sent. The status bar shows **YOLO** while confirmations are off. | ### Authentication @@ -1343,6 +1356,7 @@ With write access enabled: | Agent Branch | Off | Create new branch for agent commits | | Agent Auto-Verify | Off | `Off`, `Build only`, `Typecheck only`, `Test only`, or `Build + Typecheck + Test` | | Agent Max Fix Attempts | 1 | Max attempts to auto-fix errors when Auto-Verify is enabled. More than 1 usually means the agent is stuck — bail and let the user decide. | +| Follow Omarchy theme | On | On [Omarchy](https://omarchy.org) only (the row is not shown elsewhere): paint Codeep's own colours — the brand red, the greys, the code colours — from the current Omarchy theme, and switch with it while Codeep runs. `Off` keeps Codeep's colours. Codeep only reads `~/.local/state/omarchy/current/theme/colors.toml`, and watches it for the next switch. | ## Usage Examples diff --git a/src/config/index.ts b/src/config/index.ts index 4ea3c79..c3b5984 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -157,6 +157,12 @@ export interface ConfigSchema { agentMaxDuration: number; // Max agent duration in minutes (default: 20) agentApiTimeout: number; // Base API timeout for agent in ms (default: 90000, dynamically adjusted) agentInteractive: boolean; // Show interactive mode (default: true) + /** On an Omarchy desktop, paint the TUI's truecolour roles (brand colour, + * greys, code colours) from the current Omarchy theme and follow it when + * it changes — renderer/omarchyTheme.ts. Default true: Omarchy users + * expect every app to switch with the desktop. Off keeps Codeep's own + * colours. Does nothing anywhere else. */ + followOmarchyTheme: boolean; projectPermissions: ProjectPermission[]; /** @deprecated Legacy PLAINTEXT key store. Kept only so the one-time * migration into secure storage can read it; emptied afterwards. New keys @@ -381,6 +387,7 @@ function createConfig(): Conf { agentMaxDuration: 480, agentApiTimeout: 600000, agentInteractive: true, + followOmarchyTheme: true, protocol: 'openai', plan: 'lite', language: 'en', diff --git a/src/renderer/App.palette.test.ts b/src/renderer/App.palette.test.ts new file mode 100644 index 0000000..035bf67 --- /dev/null +++ b/src/renderer/App.palette.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { App } from './App'; +import { resetPalette, setPalette } from './palette'; + +/** + * What a running App does when the palette changes under it — an Omarchy + * theme switch, or "Follow Omarchy theme" switched in /settings. The colours + * themselves are read at render time everywhere (palette.test.ts); the App's + * part is the transcript, which it formats once and caches with the ANSI + * baked in. + */ + +const TEAL = '\x1b[38;2;0;128;128m'; +const RED = '\x1b[38;2;240;42;48m'; + +function makeApp(options: { yolo?: boolean } = {}) { + const app = new App({ + onSubmit: async () => {}, + onCommand: () => {}, + onExit: () => {}, + getStatus: () => ({ + version: '3.8.1', provider: 'OpenAI', model: 'gpt-5.6-terra', agentMode: 'on', + projectPath: '/tmp/project', hasWriteAccess: true, sessionId: '', messageCount: 0, + }), + yolo: () => options.yolo ?? false, + }); + const internals = app as unknown as { + screen: Record unknown>; + input: Record unknown>; + getVisibleMessages: (height: number, width: number) => Array<{ text: string }>; + }; + // Detach the Screen's resize listener so these Apps don't pile up on stdout. + process.stdout.removeListener('resize', internals.screen.resizeHandler as () => void); + // addMessage() and a repaint schedule a real render; keep its frame off + // the test runner's stdout. + for (const method of ['init', 'cleanup', 'render', 'fullRender']) { + vi.spyOn(internals.screen, method).mockImplementation(() => {}); + } + const transcript = () => internals.getVisibleMessages(40, 80).map(l => l.text).join('\n'); + return { app, internals, transcript }; +} + +afterEach(() => { + resetPalette(); + vi.restoreAllMocks(); +}); + +describe('App and the palette', () => { + it('re-formats the cached transcript in the new colours', async () => { + const { app, transcript } = makeApp(); + app.addMessage({ role: 'user', content: 'fix the **build**' }); + expect(transcript()).toContain(RED); + + setPalette({ primary: [0, 128, 128] }); + // The cache still holds the message as it was first formatted… + expect(transcript()).toContain(RED); + app.repaintInNewPalette(); + // …until the repaint drops it. + expect(transcript()).toContain(TEAL); + expect(transcript()).not.toContain(RED); + // Let the renders this queued run now, against the stubbed Screen. + await new Promise(resolve => setImmediate(resolve)); + }); + + it('repaints on every palette change while started, and stops listening when stopped', async () => { + const { app, internals } = makeApp(); + for (const method of ['start', 'stop', 'onKey']) vi.spyOn(internals.input, method).mockImplementation(() => {}); + const repaint = vi.spyOn(app, 'repaintInNewPalette'); + + setPalette({ primary: [1, 1, 1] }); + expect(repaint).not.toHaveBeenCalled(); + + app.start(); + setPalette({ primary: [0, 128, 128] }); + expect(repaint).toHaveBeenCalledTimes(1); + + // Let the render start() and the repaint scheduled run while the Screen + // is still stubbed, rather than onto the test runner's stdout. + await new Promise(resolve => setImmediate(resolve)); + app.stop(); + resetPalette(); + expect(repaint).toHaveBeenCalledTimes(1); + }); + + it('paints the status bar in the current brand colour', () => { + const { app, internals } = makeApp(); + const styles: string[] = []; + vi.spyOn(internals.screen, 'write').mockImplementation((...args: unknown[]) => { + styles.push(String(args[2]) + String(args[3] ?? '')); + }); + setPalette({ primary: [0, 128, 128] }); + (app as unknown as { renderStatusBar: (y: number, w: number, s: boolean) => void }).renderStatusBar(0, 80, true); + expect(styles.join('')).toContain(TEAL + 'gpt-5.6-terra'); + }); + + it('paints the YOLO badge and a warning toast in the current palette, and in 208 orange without a theme', () => { + const statusBar = () => { + const { app, internals } = makeApp({ yolo: true }); + const writes: string[] = []; + vi.spyOn(internals.screen, 'write').mockImplementation((...args: unknown[]) => { + writes.push(String(args[3] ?? '') + String(args[2])); + }); + Object.assign(app, { notification: 'Rate limited, retrying in 4s', notificationIsWarn: true }); + (app as unknown as { renderStatusBar: (y: number, w: number, s: boolean) => void }).renderStatusBar(0, 80, true); + return writes.join(''); + }; + expect(statusBar()).toContain('\x1b[48;5;208m\x1b[30m\x1b[1m YOLO '); + expect(statusBar()).toContain('\x1b[38;5;208m Rate limited'); + + setPalette({ yoloBadge: [223, 142, 29], yoloBadgeText: [0, 0, 0], warningToast: [135, 104, 74] }); + expect(statusBar()).toContain('\x1b[48;2;223;142;29m\x1b[38;2;0;0;0m\x1b[1m YOLO '); + expect(statusBar()).toContain('\x1b[38;2;135;104;74m Rate limited'); + }); + + it('paints the prompt that points at an open picker in the attention colour', () => { + const { app, internals } = makeApp(); + const writes: string[] = []; + vi.spyOn(internals.screen, 'write').mockImplementation((...args: unknown[]) => { + writes.push(String(args[3] ?? '') + String(args[2])); + }); + const renderInput = () => (app as unknown as { renderInput: (y: number, w: number) => void }).renderInput(0, 80); + Object.assign(app, { menuOpen: true, menuItemsAll: [] }); + renderInput(); + expect(writes.join('')).toContain('\x1b[33mSelect an option below…'); + setPalette({ attention: [135, 104, 74] }); + renderInput(); + expect(writes.join('')).toContain('\x1b[38;2;135;104;74mSelect an option below…'); + }); +}); diff --git a/src/renderer/App.test.ts b/src/renderer/App.test.ts index 1f307e9..770612d 100644 --- a/src/renderer/App.test.ts +++ b/src/renderer/App.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest'; import { App } from './App'; import type { StatusInfo } from './components/Status'; import { stripAnsi } from './ansi'; +import { MASCOT_FRAMES, MASCOT_WIDTH, MASCOT_GAP } from './components/mascot'; +import { LOGO_LINES } from './components/uiConstants'; /** * Renders one row of the App's own Screen and returns what landed on it. @@ -136,3 +138,100 @@ describe('renderPersistentHeader', () => { expect(row).toContain('main'); }); }); + +describe('renderIntro', () => { + type Write = { x: number; y: number; text: string }; + + /** An App on a `width`×30 Screen that captures every write instead of drawing. */ + function introApp(width: number) { + const app = new App({ + onSubmit: async () => {}, + onCommand: () => {}, + onExit: () => {}, + getStatus: () => ({ + version: '3.8.1', provider: 'OpenAI', model: 'gpt-5.6-terra', agentMode: 'on', + projectPath: '/tmp/project', hasWriteAccess: true, sessionId: '', messageCount: 0, + }), + }); + const screen = (app as unknown as { screen: Record unknown> }).screen; + // Detach the Screen's resize listener so these Apps don't pile up on stdout. + process.stdout.removeListener('resize', screen.resizeHandler as () => void); + vi.spyOn(screen, 'getSize').mockReturnValue({ width, height: 30 }); + vi.spyOn(screen, 'fullRender').mockImplementation(() => {}); + const writes: Write[] = []; + vi.spyOn(screen, 'write').mockImplementation((...args: unknown[]) => { + writes.push({ x: args[0] as number, y: args[1] as number, text: stripAnsi(String(args[2])) }); + }); + return { app, screen, writes }; + } + + /** Render one intro frame at `width`×30 and capture every write. */ + function renderIntro(width: number, phase: 'init' | 'decrypt' | 'done', progress: number): Write[] { + const { app, writes } = introApp(width); + Object.assign(app as unknown as Record, { introPhase: phase, introProgress: progress }); + (app as unknown as { renderIntro: () => void }).renderIntro.call(app); + return writes; + } + + const TAGLINE = 'Deep into Code.'; + + it('draws the mascot left of the wordmark, wordmark on mascot lines 1..6', () => { + // 'done' so the wordmark is fully decrypted and comparable verbatim. + const writes = renderIntro(120, 'done', 1); + const mascot = writes.filter(w => (MASCOT_FRAMES.idle as readonly string[]).includes(w.text)); + expect(mascot.map(w => w.text)).toEqual(MASCOT_FRAMES.idle); + const mascotX = mascot[0].x; + const mascotTop = mascot[0].y; + expect(mascotX).toBe(Math.floor((120 - (MASCOT_WIDTH + MASCOT_GAP + LOGO_LINES[0].length)) / 2)); + + const logo = writes.filter(w => w.x !== mascotX && w.text !== TAGLINE); + expect(logo.map(w => w.text)).toEqual(LOGO_LINES); + expect(logo[0].x).toBe(mascotX + MASCOT_WIDTH + MASCOT_GAP); + expect(logo[0].y).toBe(mascotTop + 1); + }); + + it('glances left a little way into the decrypt', () => { + // 0.3 × 1500 ms = 450 ms → second step of centre → left → centre → right. + const writes = renderIntro(120, 'decrypt', 0.3); + expect(writes.map(w => w.text)).toContain(MASCOT_FRAMES.lookLeft[1]); + }); + + it('shows the plain idle mascot during the noise phase', () => { + const writes = renderIntro(120, 'init', 0.5); + expect(writes.map(w => w.text)).toEqual(expect.arrayContaining([...MASCOT_FRAMES.idle])); + }); + + it('starts the decrypt looking ahead and blinks exactly once over a real startIntro run', () => { + vi.useFakeTimers(); + // The last noise tick leaves a random introProgress behind; 0.95 would + // land the first decrypt frame on the blink if it leaked through. + const random = vi.spyOn(Math, 'random').mockReturnValue(0.95); + try { + const { app, screen, writes } = introApp(120); + // The chat screen drawn once the intro hands over. + vi.spyOn(screen, 'render').mockImplementation(() => {}); + let introWrites = -1; + app.startIntro(() => { introWrites = writes.length; }); + vi.advanceTimersByTime(3000); + expect(introWrites).toBeGreaterThan(0); + + // One pupil row per rendered frame; collapse repeats into the sequence. + const byPupils = new Map(Object.entries(MASCOT_FRAMES).map(([name, frame]) => [frame[1], name])); + const frames = writes.slice(0, introWrites).map(w => byPupils.get(w.text)).filter(Boolean); + const seq = frames.filter((name, i) => name !== frames[i - 1]); + expect(seq).toEqual(['idle', 'lookLeft', 'idle', 'lookRight', 'idle', 'blink']); + } finally { + random.mockRestore(); + vi.useRealTimers(); + } + }); + + it('falls back to the wordmark alone when the terminal is too narrow', () => { + const width = MASCOT_WIDTH + MASCOT_GAP + LOGO_LINES[0].length + 3; + const writes = renderIntro(width, 'done', 1).filter(w => w.text !== TAGLINE); + expect(writes.map(w => w.text)).toEqual(LOGO_LINES); + expect(writes[0].x).toBe(Math.floor((width - LOGO_LINES[0].length) / 2)); + // Same vertical centring as before the mascot: 6 logo lines + gap + tagline. + expect(writes[0].y).toBe(Math.floor((30 - LOGO_LINES.length - 2) / 2)); + }); +}); diff --git a/src/renderer/App.ts b/src/renderer/App.ts index 6126d49..ba1777f 100644 --- a/src/renderer/App.ts +++ b/src/renderer/App.ts @@ -8,6 +8,8 @@ import { Input, LineEditor, KeyEvent } from './Input'; import { fg, style } from './ansi'; import { getActionColor, formatActionTarget, getActionLabel } from './components/ActionFormatting'; import { PRIMARY_COLOR, SPINNER_FRAMES, LOGO_LINES } from './components/uiConstants'; +import { onPaletteChange, palette } from './palette'; +import { MASCOT_FRAMES, layoutLogoWithMascot, introMascotFrame } from './components/mascot'; import { bottomPanelHeight, chatLayout, messageOffsets, scrollOffsetForTarget, scrollWindow, formatTokenCount, statusBarRightHint, activePanel, computeInputDisplay, agentProgressBar, truncateNotification, shouldShowPasteDialog, buildPasteInfo, type LayoutSnapshot } from './layout'; import { buildAgentTimelineModel, @@ -120,8 +122,18 @@ export interface AppOptions { hasProjectContext?: () => boolean; /** Project root for `@mention` autocomplete suggestions. Falls back to cwd. */ getProjectRoot?: () => string; + /** True while `codeep --yolo` decides that agent actions run without + * asking, and the status bar says so throughout. A function, because + * choosing Agent Confirmation in /settings ends it mid-session. */ + yolo?: () => boolean; } +/** The status-bar badge for `--yolo`, in the orange the warning toasts use + * (on an Omarchy theme, its yellow). The style is read when painting, so a + * theme switch recolours it. */ +const YOLO_BADGE = ' YOLO '; +const yoloBadgeStyle = () => palette.yoloBadge + palette.yoloBadgeText + style.bold; + export class App { private screen: Screen; private input: Input; @@ -141,6 +153,8 @@ export class App { // Render scheduling private pendingRender = false; + /** Stops listening for palette changes; set while the App is started. */ + private unsubscribePalette: (() => void) | null = null; // Spinner animation state private spinnerFrame = 0; @@ -282,6 +296,8 @@ export class App { // Glitch characters for intro animation private static readonly GLITCH_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@#$%&*<>?/;:[]='; + // Length of the intro's decrypt phase (the mascot's glances are timed against it) + private static readonly INTRO_DECRYPT_MS = 1500; // The `/` autocomplete list, derived from COMMAND_DESCRIPTIONS so it IS // the registry — a command can no longer ship without a description (a @@ -310,14 +326,33 @@ export class App { this.messageCache = new Array(this.messages.length).fill(null); this.scheduleRender(); }); + this.unsubscribePalette = onPaletteChange(() => this.repaintInNewPalette()); this.scheduleRender(); } + + /** + * Redraw everything in the palette that is current now — an Omarchy theme + * switch, or "Follow Omarchy theme" switched in /settings. + * + * Everything the render pass draws reads its colours as it draws, so a + * render is most of it. The exception is the transcript: each message is + * formatted once and kept, ANSI and all, in messageCache, and a cached + * message would keep the old theme's colours for the rest of the session. + * The terminal itself needs no clearing — every recoloured cell now + * differs from what the diff renderer last wrote, so it is rewritten. + */ + repaintInNewPalette(): void { + this.messageCache = new Array(this.messages.length).fill(null); + this.scheduleRender(); + } /** * Stop the application */ stop(): void { + this.unsubscribePalette?.(); + this.unsubscribePalette = null; this.stopSpinner(); if (this.notificationTimeout) clearTimeout(this.notificationTimeout); if (this.introInterval) clearInterval(this.introInterval); @@ -840,8 +875,11 @@ export class App { // Phase 2: Decryption animation (1500ms) this.introPhase = 'decrypt'; + // Drop the noise phase's random progress: the render this tick just + // scheduled runs after this switch and would show it as decrypt time + this.introProgress = 0; const startTime = Date.now(); - const duration = 1500; + const duration = App.INTRO_DECRYPT_MS; this.introInterval = setInterval(() => { const elapsed = Date.now() - startTime; @@ -2204,10 +2242,13 @@ export class App { const inputValue = this.editor.getValue(); const cursorPos = this.editor.getCursorPos(); + // While a picker is open the input line points at it, in palette.attention: + // ANSI yellow, or on an Omarchy theme its yellow made readable as text. + // Session picker open - show different prompt if (this.sessionPickerOpen) { this.screen.write(0, y, '> ', fg.gray); - this.screen.write(2, y, 'Select a session below or press N for new...', fg.yellow); + this.screen.write(2, y, 'Select a session below or press N for new...', palette.attention); this.screen.showCursor(false); return; } @@ -2215,7 +2256,7 @@ export class App { // Permission dialog open if (this.permissionOpen) { this.screen.write(0, y, '> ', fg.gray); - this.screen.write(2, y, 'Select access level below...', fg.yellow); + this.screen.write(2, y, 'Select access level below...', palette.attention); this.screen.showCursor(false); return; } @@ -2223,7 +2264,7 @@ export class App { // Paste info open if (this.pasteDialog.open) { this.screen.write(0, y, '> ', fg.gray); - this.screen.write(2, y, 'Confirm paste action below...', fg.yellow); + this.screen.write(2, y, 'Confirm paste action below...', palette.attention); this.screen.showCursor(false); return; } @@ -2234,7 +2275,7 @@ export class App { const hint = this.menuItemsAll.length > 10 ? 'Type to filter, ↑↓ to navigate, Enter to pick…' : 'Select an option below…'; - this.screen.write(2, y, hint, fg.yellow); + this.screen.write(2, y, hint, palette.attention); this.screen.showCursor(false); return; } @@ -2242,7 +2283,7 @@ export class App { // Search open if (this.searchOpen) { this.screen.write(0, y, '> ', fg.gray); - this.screen.write(2, y, 'Navigate search results below...', fg.yellow); + this.screen.write(2, y, 'Navigate search results below...', palette.attention); this.screen.showCursor(false); return; } @@ -2250,7 +2291,7 @@ export class App { // Export open if (this.exportOpen) { this.screen.write(0, y, '> ', fg.gray); - this.screen.write(2, y, 'Select export format below...', fg.yellow); + this.screen.write(2, y, 'Select export format below...', palette.attention); this.screen.showCursor(false); return; } @@ -2258,7 +2299,7 @@ export class App { // Logout open if (this.logoutOpen) { this.screen.write(0, y, '> ', fg.gray); - this.screen.write(2, y, 'Select provider to logout...', fg.yellow); + this.screen.write(2, y, 'Select provider to logout...', palette.attention); this.screen.showCursor(false); return; } @@ -2269,7 +2310,7 @@ export class App { const msg = this.loginStep === 'provider' ? 'Select a provider below...' : 'Enter your API key below...'; - this.screen.write(2, y, msg, fg.yellow); + this.screen.write(2, y, msg, palette.attention); this.screen.showCursor(false); return; } @@ -2859,11 +2900,21 @@ export class App { // Clear the line first this.screen.writeLine(y, ''); + // --yolo leads the line in every state below, a toast included: a + // notification covering it would hide, for seconds at a time, the one + // thing on screen that says nothing is going to ask first. Everything + // else starts `left` columns in. + let left = 0; + if (this.options.yolo?.()) { + this.screen.write(0, y, YOLO_BADGE, yoloBadgeStyle()); + left = YOLO_BADGE.length; + } + if (this.notification) { - const notifColor = this.notificationIsWarn ? '\x1b[38;5;208m' : PRIMARY_COLOR; // orange for warn - const maxLen = width - 2; + const notifColor = this.notificationIsWarn ? palette.warningToast : PRIMARY_COLOR; // orange for warn, an Omarchy theme's yellow + const maxLen = width - 2 - left; const msg = truncateNotification(this.notification, maxLen); - this.screen.write(0, y, notifColor + ' ' + msg + style.reset); + this.screen.write(left, y, notifColor + ' ' + msg + style.reset); return; } @@ -2909,7 +2960,7 @@ export class App { leftParts.push(`effort ${status.reasoningEffort}`); } const leftText = leftParts.join(' · '); - this.screen.write(1, y, leftText, fg.gray); + this.screen.write(left + 1, y, leftText, fg.gray); // The right edge belongs to the hint: while streaming it reads // "Esc to stop", the only on-screen affordance for interrupting a run. @@ -2918,7 +2969,7 @@ export class App { // Same right-edge column as the compact fallback and the scroll badge, so // the hint doesn't shift by one when the terminal crosses 110 columns. let rightEdge = width; - if (rightText && width - rightText.length > leftText.length + 3) { + if (rightText && width - rightText.length > left + leftText.length + 3) { rightEdge = width - rightText.length; this.screen.write(rightEdge, y, rightText, fg.gray); } @@ -2926,7 +2977,7 @@ export class App { const impact = formatResourceImpact(estimateResourceImpact(totalTokens)); const impactText = `energy ${impact.energy} est · water ${impact.water} est`; const impactX = rightEdge - impactText.length - 3; - if (impactX > leftText.length + 3) { + if (impactX > left + leftText.length + 3) { this.screen.write(impactX, y, impactText, fg.gray); } } @@ -2940,7 +2991,7 @@ export class App { ? `${formatTokenCount(stats.totalTokens)} tok` : ''; - let leftX = 1; + let leftX = left + 1; if (modelName) { this.screen.write(leftX, y, PRIMARY_COLOR + modelName + style.reset); leftX += modelName.length + 2; @@ -3128,22 +3179,30 @@ export class App { const logoText = this.getDecryptedLogo(); const logoLines = logoText.split('\n'); - // Center logo vertically - const startY = Math.max(0, Math.floor((height - logoLines.length - 2) / 2)); - - // Center logo horizontally - const logoWidth = LOGO_LINES[0].length; - const startX = Math.max(0, Math.floor((width - logoWidth) / 2)); + // Mascot + logo centred as one block (logo alone on narrow terminals) + const layout = layoutLogoWithMascot(width); + const startY = Math.max(0, Math.floor((height - layout.height - 2) / 2)); + + // The mascot skips the decrypt effect: drawn plainly from the first + // frame, glancing around while the logo decrypts + if (layout.mascotX !== null) { + const mascot = this.introPhase === 'decrypt' + ? introMascotFrame(this.introProgress * App.INTRO_DECRYPT_MS, App.INTRO_DECRYPT_MS) + : MASCOT_FRAMES.idle; + for (let i = 0; i < mascot.length; i++) { + this.screen.write(layout.mascotX, startY + i, mascot[i], PRIMARY_COLOR + style.bold); + } + } for (let i = 0; i < logoLines.length; i++) { - this.screen.write(startX, startY + i, logoLines[i], PRIMARY_COLOR + style.bold); + this.screen.write(layout.logoX, startY + layout.logoDy + i, logoLines[i], PRIMARY_COLOR + style.bold); } // Tagline (only show when done) if (this.introPhase === 'done') { const tagline = 'Deep into Code.'; const taglineX = Math.floor((width - tagline.length) / 2); - this.screen.write(taglineX, startY + logoLines.length + 1, tagline, PRIMARY_COLOR); + this.screen.write(taglineX, startY + layout.height + 1, tagline, PRIMARY_COLOR); } this.screen.fullRender(); diff --git a/src/renderer/App.yolo.test.ts b/src/renderer/App.yolo.test.ts new file mode 100644 index 0000000..cb856b4 --- /dev/null +++ b/src/renderer/App.yolo.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest'; +import { App } from './App'; +import { stripAnsi } from './ansi'; + +/** + * The `--yolo` badge on the status bar. Its whole job is to be there for the + * whole run, so it is checked in every state the bar can be in — a toast + * covering it for three seconds is exactly when nobody would think to look. + */ + +type Write = { x: number; text: string; style: string }; + +/** One status-bar render at `width` columns, every write captured. */ +function statusBar(width: number, yolo: boolean, state: Record = {}): Write[] { + const app = new App({ + onSubmit: async () => {}, + onCommand: () => {}, + onExit: () => {}, + getStatus: () => ({ + version: '3.8.1', provider: 'OpenAI', model: 'gpt-5.6-terra', agentMode: 'on', + projectPath: '/tmp/project', hasWriteAccess: true, sessionId: 'abcdef1234', messageCount: 2, + tokenStats: { + totalTokens: 48_720, promptTokens: 37_140, completionTokens: 11_580, requestCount: 9, + estimatedCost: 0.2846, billableCost: 0.2846, hasFlatFeeUsage: false, + }, + }), + yolo: () => yolo, + }); + Object.assign(app as unknown as Record, state); + const screen = (app as unknown as { screen: Record unknown> }).screen; + // Detach the Screen's resize listener so these Apps don't pile up on stdout. + process.stdout.removeListener('resize', screen.resizeHandler as () => void); + const writes: Write[] = []; + vi.spyOn(screen, 'write').mockImplementation((...args: unknown[]) => { + writes.push({ x: args[0] as number, text: stripAnsi(String(args[2])), style: String(args[3] ?? '') }); + }); + (app as unknown as { renderStatusBar: (y: number, w: number, s: boolean) => void }).renderStatusBar(0, width, true); + return writes; +} + +const badge = (writes: Write[]) => writes.find(w => w.text === ' YOLO '); + +describe('the --yolo badge', () => { + const states: Array<[string, number, Record]> = [ + ['the wide footer', 140, {}], + ['the compact footer', 80, {}], + ['a notification', 140, { notification: 'MCP: 3 tool(s) from 1 server(s) ready. Type /mcp.' }], + ['a warning', 80, { notification: 'Telegram: webhook set on the bot', notificationIsWarn: true }], + ['the "new messages below" badge', 140, { scrollOffset: 4, unseenWhileScrolled: 2 }], + ]; + + for (const [name, width, state] of states) { + it(`leads the line over ${name}`, () => { + const writes = statusBar(width, true, state); + expect(badge(writes)).toMatchObject({ x: 0 }); + // In the warning orange, on its own background so it reads as a badge. + expect(badge(writes)?.style).toContain('\x1b[48;5;208m'); + // Nothing else is drawn over it. (The empty write is the line being + // cleared first.) + for (const w of writes) { + if (w !== badge(writes) && w.text !== '') expect(w.x).toBeGreaterThanOrEqual(' YOLO '.length); + } + }); + } + + it('keeps what the line said, moved over', () => { + const wide = statusBar(140, true).map(w => w.text).join(''); + expect(wide).toContain('runtime'); + expect(wide).toContain('/help'); + const toast = statusBar(80, true, { notification: 'Read & Write access granted' }).map(w => w.text).join(''); + expect(toast).toContain('Read & Write access granted'); + }); + + it('is not there without --yolo', () => { + for (const [, width, state] of states) { + expect(badge(statusBar(width, false, state))).toBeUndefined(); + } + }); +}); diff --git a/src/renderer/agentConfirmation.test.ts b/src/renderer/agentConfirmation.test.ts new file mode 100644 index 0000000..478d2ad --- /dev/null +++ b/src/renderer/agentConfirmation.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// `codeep --yolo` pins the confirmation mode, and turns clarifying questions +// off, for its own process. What is pinned here is that the pins win over the +// config in agentExecution's readers, and that they never reach the config +// itself. (main.ts has the third confirmation reader, for the answer to +// clarifying questions; main.launch.test.ts covers it.) The agent loop, the +// cloud and Telegram are replaced: what is left is the renderer's gate. +vi.mock('../utils/agent', () => ({ runAgent: vi.fn() })); +vi.mock('../utils/telegramCredentials', () => ({ loadTelegramCredentials: vi.fn(async () => null) })); +vi.mock('../utils/telegramInbox', () => ({ takeRunFromPhone: vi.fn(() => false) })); +vi.mock('../utils/codeepCloud', () => ({ + reportStats: vi.fn(), + syncSession: vi.fn(), + generateProjectId: vi.fn(() => 'project-id'), +})); +vi.mock('../utils/git', async (importOriginal) => ({ + ...(await importOriginal()), + isGitRepository: vi.fn(() => false), +})); + +import { agentConfirmationMode, pinAgentConfirmation, agentInteractiveMode, pinAgentInteractive } from './agentConfirmation'; +import { executeAgentTask, runAgentTask, type AppExecutionContext } from './agentExecution'; +import { runAgent } from '../utils/agent'; +import { config } from '../config/index'; +import type { App } from './App'; +import type { ProjectContext } from '../utils/project'; + +let root: string; +/** Titles of the dialogs the run put up, each answered yes. */ +let dialogs: string[]; + +function fakeApp(): App { + const app = { + getMessages: () => [], + getChatHistory: () => [], + showConfirm: (o: { title: string; onConfirm: () => void }) => { dialogs.push(o.title); o.onConfirm(); }, + }; + // Everything else the run touches is display state this test does not read. + return new Proxy(app, { + get: (target, key) => (key in target ? target[key as keyof typeof target] : () => {}), + }) as unknown as App; +} + +function makeCtx(): AppExecutionContext { + let running = false; + return { + app: fakeApp(), + projectPath: root, + projectContext: { root, name: 'demo', type: 'node' } as unknown as ProjectContext, + hasWriteAccess: true, + addedFiles: new Map(), + isAgentRunning: () => running, + setAgentRunning: (v) => { running = v; }, + abortController: null, + setAbortController: () => {}, + formatAddedFilesContext: () => '', + handleCommand: async () => {}, + }; +} + +/** Run one task whose agent makes `toolCall`, and return what it was answered. */ +async function answerFor(toolCall: { tool: string; parameters: Record }): Promise<{ answer: unknown; extraDangerousTools: unknown }> { + let answer: unknown; + let extraDangerousTools: unknown; + vi.mocked(runAgent).mockImplementation(async (_task, _context, opts) => { + extraDangerousTools = opts?.extraDangerousTools; + answer = await opts?.onRequestPermission?.(toolCall); + return { success: true, iterations: 1, actions: [], finalResponse: 'done' }; + }); + await executeAgentTask('go', false, makeCtx()); + return { answer, extraDangerousTools }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'codeep-confirmation-')); + dialogs = []; + config.set('agentConfirmation', 'always'); + config.set('agentInteractive', false); + config.set('agentAutoCommit', false); + vi.mocked(runAgent).mockReset(); +}); + +afterEach(() => { + pinAgentConfirmation(null); + pinAgentInteractive(null); + rmSync(root, { recursive: true, force: true }); +}); + +describe('agentConfirmationMode', () => { + it('is the config while nothing is pinned', () => { + expect(agentConfirmationMode()).toBe('always'); + config.set('agentConfirmation', 'never'); + expect(agentConfirmationMode()).toBe('never'); + }); + + it('falls back to dangerous, the default, when the config has no value', () => { + config.delete('agentConfirmation'); + // Conf hands back its default; either way, nothing reads as 'never'. + expect(agentConfirmationMode()).toBe('dangerous'); + }); + + it('is the pin once there is one, whatever the config says', () => { + pinAgentConfirmation('never'); + expect(agentConfirmationMode()).toBe('never'); + config.set('agentConfirmation', 'dangerous'); + expect(agentConfirmationMode()).toBe('never'); + }); + + it('never writes the pin to the config', () => { + // The config file is shared with the next plain `codeep` and with the + // ACP server; a saved 'never' would switch their confirmations off too. + const set = vi.spyOn(config, 'set'); + pinAgentConfirmation('never'); + agentConfirmationMode(); + expect(set).not.toHaveBeenCalled(); + expect(config.get('agentConfirmation')).toBe('always'); + set.mockRestore(); + }); + + it('goes back to the config when the pin is cleared', () => { + pinAgentConfirmation('never'); + pinAgentConfirmation(null); + expect(agentConfirmationMode()).toBe('always'); + }); +}); + +describe('agentInteractiveMode', () => { + it('is the config while nothing is pinned, and the pin once there is one', () => { + expect(agentInteractiveMode()).toBe(false); + config.set('agentInteractive', true); + expect(agentInteractiveMode()).toBe(true); + + pinAgentInteractive(false); + expect(agentInteractiveMode()).toBe(false); + expect(config.get('agentInteractive')).toBe(true); + + pinAgentInteractive(null); + expect(agentInteractiveMode()).toBe(true); + }); +}); + +describe('the terminal\'s readers honour the pin', () => { + it('starts the run without the "Confirm Agent Task" dialog', async () => { + vi.mocked(runAgent).mockResolvedValue({ success: true, iterations: 1, actions: [], finalResponse: 'done' }); + + await runAgentTask('go', false, makeCtx(), () => null, () => {}); + expect(dialogs).toEqual(['⚠️ Confirm Agent Task']); // the config's 'always', as a control + + dialogs = []; + pinAgentConfirmation('never'); + await runAgentTask('go', false, makeCtx(), () => null, () => {}); + await vi.waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2)); + expect(dialogs).toEqual([]); + }); + + it('answers an ordinary tool call itself, and asks the agent to add none to its list', async () => { + const write = { tool: 'write_file', parameters: { path: 'src/app.ts', content: 'x' } }; + + expect((await answerFor(write)).extraDangerousTools).toContain('write_file'); // control + expect(dialogs).toEqual(['⚠️ Confirm Action']); + + dialogs = []; + pinAgentConfirmation('never'); + const pinned = await answerFor(write); + expect(pinned.answer).toBe('allow_once'); + expect(pinned.extraDangerousTools).toBeUndefined(); + expect(dialogs).toEqual([]); + }); + + it('starts a vague task without stopping for clarifying questions', async () => { + // A launcher's prompt is often vague by this measure, and a run waiting + // for "proceed" waits for someone who is not there. + config.set('agentInteractive', true); + config.set('agentConfirmation', 'never'); + vi.mocked(runAgent).mockResolvedValue({ success: true, iterations: 1, actions: [], finalResponse: 'done' }); + const held: unknown[] = []; + const run = () => runAgentTask('refactor my waybar config', false, makeCtx(), () => null, v => { held.push(v); }); + + await run(); + expect(held).toHaveLength(1); // the config's questions, as a control + expect(runAgent).not.toHaveBeenCalled(); + + pinAgentInteractive(false); + await run(); + await vi.waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1)); + expect(held).toHaveLength(1); + }); + + it('still asks about a file that decides what runs later', async () => { + // Never mode asks about these too; --yolo is Never mode, not less. + pinAgentConfirmation('never'); + await answerFor({ tool: 'write_file', parameters: { path: '.git/config', content: '[core]' } }); + expect(dialogs).toEqual(['⚠️ Confirm Action']); + }); +}); diff --git a/src/renderer/agentConfirmation.ts b/src/renderer/agentConfirmation.ts new file mode 100644 index 0000000..8fde7b6 --- /dev/null +++ b/src/renderer/agentConfirmation.ts @@ -0,0 +1,57 @@ +/** + * Whether the agent stops to ask in this process — the one place the + * terminal asks for it: the confirmation mode, and whether a vague task gets + * clarifying questions before it runs. + * + * Usually those are the `agentConfirmation` and `agentInteractive` config. + * `codeep --yolo` pins them to 'never' and off for this process, and the pins + * are never written back: the config file is shared by every Codeep on the + * machine — the next plain `codeep`, the ACP server an editor starts — and a + * launch that saved its 'never' there would quietly switch the confirmations + * off in all of them, the leak acp/serverHandlers.ts's handleSetMode refuses + * to cause for the same reason. A reader that went to the config itself + * would ask when --yolo said nothing would, which is a run sitting at a + * prompt nobody is there to answer. + * + * /settings and saved profiles read the config directly, on purpose: they + * show and copy what is stored, not what this launch happens to run with. + * Choosing a value in /settings is a decision about this run as well, so it + * ends that setting's pin (Settings.ts writeSetting): someone who launched + * with --yolo and then picks Always is asked from then on, as the row says. + */ + +import { config, type ConfigSchema } from '../config/index'; + +export type AgentConfirmation = ConfigSchema['agentConfirmation']; + +let pinned: AgentConfirmation | null = null; +let pinnedInteractive: boolean | null = null; + +/** Run with `mode` for the rest of this process, whatever the config says, + * or go back to the config with null. Never persisted. */ +export function pinAgentConfirmation(mode: AgentConfirmation | null): void { + pinned = mode; +} + +/** The mode to act on now: the pin when there is one, else the config. */ +export function agentConfirmationMode(): AgentConfirmation { + return pinned ?? (config.get('agentConfirmation') || 'dangerous'); +} + +/** Whether a pin, not the config, decides the confirmation mode — what the + * status bar's YOLO badge shows. */ +export function isAgentConfirmationPinned(): boolean { + return pinned !== null; +} + +/** Ask clarifying questions before a run (true) or not (false) for the rest + * of this process, or go back to the config with null. Never persisted. */ +export function pinAgentInteractive(on: boolean | null): void { + pinnedInteractive = on; +} + +/** Whether a vague task gets clarifying questions first: the pin when there + * is one, else the config, which is on unless set off. */ +export function agentInteractiveMode(): boolean { + return pinnedInteractive ?? config.get('agentInteractive') !== false; +} diff --git a/src/renderer/agentExecution.ts b/src/renderer/agentExecution.ts index 4bcb794..d457dee 100644 --- a/src/renderer/agentExecution.ts +++ b/src/renderer/agentExecution.ts @@ -23,6 +23,7 @@ import { charWidth } from './ansi'; import { showControls } from '../utils/controlChars'; import { ProjectContext } from '../utils/project'; import { config, autoSaveSession, getCurrentSessionId } from '../config/index'; +import { agentConfirmationMode, agentInteractiveMode } from './agentConfirmation'; import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud'; import { getGitStatus, isGitRepository } from '../utils/git'; import { getCostBreakdown, getRecordCount } from '../utils/tokenTracker'; @@ -195,7 +196,7 @@ export async function runAgentTask( return; } - const interactiveMode = config.get('agentInteractive') !== false; + const interactiveMode = agentInteractiveMode(); if (interactiveMode) { const { analyzeForClarification, formatQuestions } = await import('../utils/interactive'); const interactiveContext = analyzeForClarification(task); @@ -208,7 +209,7 @@ export async function runAgentTask( } } - const confirmationMode = config.get('agentConfirmation') || 'dangerous'; + const confirmationMode = agentConfirmationMode(); if (confirmationMode === 'never' || dryRun) { execute(); return; @@ -303,7 +304,7 @@ export async function executeAgentTask( const rawIterations = config.get('agentMaxIterations') || 50; app.setAgentMaxIterations(Math.max(5, rawIterations)); - const confirmationMode = config.get('agentConfirmation') || 'dangerous'; + const confirmationMode = agentConfirmationMode(); // 'always' asks before every action that changes something: at least what // 'dangerous' asks about, plus writes, edits and new directories. const asksPerTool = confirmationMode === 'dangerous' || confirmationMode === 'always'; diff --git a/src/renderer/cliArgs.test.ts b/src/renderer/cliArgs.test.ts new file mode 100644 index 0000000..a35dea4 --- /dev/null +++ b/src/renderer/cliArgs.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { parseLaunchArgs } from './cliArgs'; + +const chat = (yolo: boolean, prompt: string | null) => ({ kind: 'chat', yolo, prompt }); + +describe('parseLaunchArgs — the subcommands that were there before', () => { + it('starts the chat with nothing on the command line', () => { + expect(parseLaunchArgs([])).toEqual(chat(false, null)); + }); + + it('hands review and hook everything after their name', () => { + expect(parseLaunchArgs(['review', '--json', 'src/a.ts'])).toEqual({ kind: 'review', args: ['--json', 'src/a.ts'] }); + expect(parseLaunchArgs(['hook', 'install', '--pre-push'])).toEqual({ kind: 'hook', args: ['install', '--pre-push'] }); + }); + + it('lets review and hook answer their own --help and --version', () => { + // `codeep review --help` shows the review usage, not the top-level help. + expect(parseLaunchArgs(['review', '--help'])).toEqual({ kind: 'review', args: ['--help'] }); + expect(parseLaunchArgs(['hook', '-h'])).toEqual({ kind: 'hook', args: ['-h'] }); + expect(parseLaunchArgs(['review', '-v'])).toEqual({ kind: 'review', args: ['-v'] }); + }); + + it('finds --version and --help anywhere before --, in both spellings', () => { + expect(parseLaunchArgs(['--version'])).toEqual({ kind: 'version' }); + expect(parseLaunchArgs(['-v'])).toEqual({ kind: 'version' }); + expect(parseLaunchArgs(['--help'])).toEqual({ kind: 'help' }); + expect(parseLaunchArgs(['-h'])).toEqual({ kind: 'help' }); + expect(parseLaunchArgs(['--yolo', '-h'])).toEqual({ kind: 'help' }); + // As before, --version wins over --help, and both over account and acp. + expect(parseLaunchArgs(['-h', '-v'])).toEqual({ kind: 'version' }); + expect(parseLaunchArgs(['account', '--help'])).toEqual({ kind: 'help' }); + expect(parseLaunchArgs(['acp', '-v'])).toEqual({ kind: 'version' }); + }); + + it('hands account its subcommand', () => { + expect(parseLaunchArgs(['account'])).toEqual({ kind: 'account', args: [] }); + expect(parseLaunchArgs(['account', 'sync'])).toEqual({ kind: 'account', args: ['sync'] }); + expect(parseLaunchArgs(['account', 'purge-keys'])).toEqual({ kind: 'account', args: ['purge-keys'] }); + }); + + it('leaves acp alone whatever follows it', () => { + // The editors start it; a flag they add later must not stop it starting. + expect(parseLaunchArgs(['acp'])).toEqual({ kind: 'acp' }); + expect(parseLaunchArgs(['acp', '--yolo', '-p'])).toEqual({ kind: 'acp' }); + }); + + it('only takes a subcommand from the first word', () => { + expect(parseLaunchArgs(['--yolo', 'review'])).toEqual(chat(true, null)); + expect(parseLaunchArgs(['--yolo', 'account', 'sync'])).toEqual(chat(true, null)); + }); + + it('ignores unknown flags and stray words, as it always has', () => { + expect(parseLaunchArgs(['--made-up'])).toEqual(chat(false, null)); + expect(parseLaunchArgs(['fix', 'the', 'bug'])).toEqual(chat(false, null)); + }); +}); + +describe('parseLaunchArgs — --yolo', () => { + it('is off unless asked for — a prompt alone still asks', () => { + expect(parseLaunchArgs(['-p', 'fix it'])).toEqual(chat(false, 'fix it')); + // Spelled exactly: a near miss falls back to asking, never the other way. + expect(parseLaunchArgs(['--YOLO'])).toEqual(chat(false, null)); + expect(parseLaunchArgs(['-y'])).toEqual(chat(false, null)); + }); + + it('is read before or after the prompt', () => { + expect(parseLaunchArgs(['--yolo'])).toEqual(chat(true, null)); + expect(parseLaunchArgs(['--yolo', '-p', 'fix it'])).toEqual(chat(true, 'fix it')); + expect(parseLaunchArgs(['-p', 'fix it', '--yolo'])).toEqual(chat(true, 'fix it')); + expect(parseLaunchArgs(['--yolo', '--', 'fix', 'it'])).toEqual(chat(true, 'fix it')); + }); + + it('is a prompt word, not a flag, after --', () => { + // Fails safe: the run asks before acting, as a plain launch does. + expect(parseLaunchArgs(['--', '--yolo'])).toEqual(chat(false, '--yolo')); + }); +}); + +describe('parseLaunchArgs — the launch prompt', () => { + it('joins everything after -- with spaces', () => { + expect(parseLaunchArgs(['--', 'add', 'a', 'dark', 'mode', 'toggle'])).toEqual(chat(false, 'add a dark mode toggle')); + // A launcher quotes it into one argument; that is the same prompt. + expect(parseLaunchArgs(['--', 'add a dark mode toggle'])).toEqual(chat(false, 'add a dark mode toggle')); + }); + + it('takes -p, --prompt and --prompt= alike', () => { + expect(parseLaunchArgs(['-p', 'explain src/a.ts'])).toEqual(chat(false, 'explain src/a.ts')); + expect(parseLaunchArgs(['--prompt', 'explain src/a.ts'])).toEqual(chat(false, 'explain src/a.ts')); + expect(parseLaunchArgs(['--prompt=explain src/a.ts'])).toEqual(chat(false, 'explain src/a.ts')); + }); + + it('keeps a subcommand name in the prompt a prompt', () => { + // Omarchy passes whatever the user typed; a one-word prompt naming a + // subcommand is how another agent's `-- "$prompt"` ended up running it. + expect(parseLaunchArgs(['--', 'review'])).toEqual(chat(false, 'review')); + expect(parseLaunchArgs(['--yolo', '--', 'review'])).toEqual(chat(true, 'review')); + expect(parseLaunchArgs(['--', 'hook', 'install'])).toEqual(chat(false, 'hook install')); + expect(parseLaunchArgs(['--', 'account', 'purge-keys'])).toEqual(chat(false, 'account purge-keys')); + expect(parseLaunchArgs(['--', 'acp'])).toEqual(chat(false, 'acp')); + expect(parseLaunchArgs(['-p', 'review'])).toEqual(chat(false, 'review')); + }); + + it('keeps a prompt that starts with a dash a prompt', () => { + expect(parseLaunchArgs(['--', '-v', 'is', 'verbose?'])).toEqual(chat(false, '-v is verbose?')); + expect(parseLaunchArgs(['--', '--help'])).toEqual(chat(false, '--help')); + expect(parseLaunchArgs(['-p', '-v explain'])).toEqual(chat(false, '-v explain')); + // The value is taken before it is looked at as a flag. + expect(parseLaunchArgs(['-p', '--help'])).toEqual(chat(false, '--help')); + }); + + it('treats an empty prompt as no prompt', () => { + expect(parseLaunchArgs(['--'])).toEqual(chat(false, null)); + expect(parseLaunchArgs(['--yolo', '--'])).toEqual(chat(true, null)); + expect(parseLaunchArgs(['--', ''])).toEqual(chat(false, null)); + expect(parseLaunchArgs(['--', ' ', ''])).toEqual(chat(false, null)); + expect(parseLaunchArgs(['-p', ''])).toEqual(chat(false, null)); + expect(parseLaunchArgs(['--prompt='])).toEqual(chat(false, null)); + }); + + it('trims the prompt, as the input box does', () => { + expect(parseLaunchArgs(['-p', ' fix it \n'])).toEqual(chat(false, 'fix it')); + }); + + it('refuses -p with nothing after it rather than starting without the prompt', () => { + expect(parseLaunchArgs(['-p'])).toEqual({ kind: 'error', message: '-p needs a value: codeep -p ""' }); + expect(parseLaunchArgs(['--yolo', '--prompt'])).toEqual({ kind: 'error', message: '--prompt needs a value: codeep --prompt ""' }); + // `--` is the separator, not a value anyone meant. + expect(parseLaunchArgs(['-p', '--', 'fix it'])).toMatchObject({ kind: 'error' }); + }); + + it('still answers --help and --version when the prompt is malformed', () => { + expect(parseLaunchArgs(['--help', '-p'])).toEqual({ kind: 'help' }); + expect(parseLaunchArgs(['-v', '-p'])).toEqual({ kind: 'version' }); + }); + + it('refuses two prompts rather than guess which was meant', () => { + const twice = { kind: 'error', message: 'Give the prompt once: either -p or -- ' }; + expect(parseLaunchArgs(['-p', 'fix it', '--', 'and test it'])).toEqual(twice); + expect(parseLaunchArgs(['-p', 'one', '--prompt', 'two'])).toEqual(twice); + // An empty one says nothing, so it conflicts with nothing. + expect(parseLaunchArgs(['-p', '', '--', 'fix it'])).toEqual(chat(false, 'fix it')); + expect(parseLaunchArgs(['-p', 'fix it', '--'])).toEqual(chat(false, 'fix it')); + }); +}); diff --git a/src/renderer/cliArgs.ts b/src/renderer/cliArgs.ts new file mode 100644 index 0000000..1ec6ed6 --- /dev/null +++ b/src/renderer/cliArgs.ts @@ -0,0 +1,106 @@ +/** + * What `codeep` was started to do, read off its command line. + * + * Pure, and separate from main() so every form is pinned by a test — above + * all the ones a launcher produces. A launcher that starts Codeep unattended + * (Omarchy's `omarchy agent`, a script, a keybinding) writes + * `codeep --yolo -- "$prompt"` and has no say over what the prompt contains: + * a prompt that is the single word `review`, or that starts with `-v`, must + * reach the model as a prompt and not run a subcommand or print the version. + * + * Grammar: + * + * codeep review [args…] args[0] only — the rest is review's own + * codeep hook [args…] args[0] only + * codeep … --version | -v anywhere before `--` + * codeep … --help | -h anywhere before `--` + * codeep account [sub] args[0] only + * codeep acp args[0] only + * codeep [--yolo] [-p | --prompt | --prompt=] [-- ] + * + * Everything after `--` is the prompt, joined with spaces. Unknown flags and + * stray words are ignored, as they always were — so a launcher that passes a + * flag a newer Codeep knows about still starts an older one. + */ + +export type LaunchArgs = + | { kind: 'review'; args: string[] } + | { kind: 'hook'; args: string[] } + | { kind: 'version' } + | { kind: 'help' } + | { kind: 'account'; args: string[] } + | { kind: 'acp' } + /** A command line that cannot mean what it was written to mean. */ + | { kind: 'error'; message: string } + | { + kind: 'chat'; + /** Run without stopping to ask, for this process only. */ + yolo: boolean; + /** Sent as the first message of a new session; null for none. */ + prompt: string | null; + }; + +/** Parse `process.argv.slice(2)`. Pure. */ +export function parseLaunchArgs(args: string[]): LaunchArgs { + // Checked before --help/--version, so `codeep review --help` shows the + // review usage rather than the top-level help. + if (args[0] === 'review') return { kind: 'review', args: args.slice(1) }; + if (args[0] === 'hook') return { kind: 'hook', args: args.slice(1) }; + + let version = false; + let help = false; + let yolo = false; + let missingValue: string | null = null; + const prompts: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--') { + // Joined even when empty: `codeep --` alone is a launcher with no + // prompt to pass, and is filtered out with the other empty prompts. + prompts.push(args.slice(i + 1).join(' ')); + break; + } + if (arg === '--version' || arg === '-v') { + version = true; + } else if (arg === '--help' || arg === '-h') { + help = true; + } else if (arg === '--yolo') { + yolo = true; + } else if (arg === '--prompt' || arg === '-p') { + const value = args[i + 1]; + // The value is taken whatever it looks like, so `-p "-v explain"` is a + // prompt. Only a missing one is an error — and `--`, which is the + // separator here and never a prompt anyone meant. + if (value === undefined || value === '--') { + missingValue = arg; + } else { + prompts.push(value); + i++; + } + } else if (arg.startsWith('--prompt=')) { + prompts.push(arg.slice('--prompt='.length)); + } + } + + if (version) return { kind: 'version' }; + if (help) return { kind: 'help' }; + if (args[0] === 'account') return { kind: 'account', args: args.slice(1) }; + if (args[0] === 'acp') return { kind: 'acp' }; + + // An error rather than starting without the prompt: the window would open + // looking like it was asked nothing, and whoever wrote the launcher would + // have no way to tell why. + if (missingValue) { + return { kind: 'error', message: `${missingValue} needs a value: codeep ${missingValue} ""` }; + } + // An empty prompt is no prompt, as an empty input box sends nothing. + const given = prompts.map(p => p.trim()).filter(p => p.length > 0); + if (given.length > 1) { + // Which one was meant, or whether they were meant joined, is a guess — + // and a wrong guess sends half an instruction to an agent that does not + // stop to ask. + return { kind: 'error', message: 'Give the prompt once: either -p or -- ' }; + } + return { kind: 'chat', yolo, prompt: given[0] ?? null }; +} diff --git a/src/renderer/commands/registry.ts b/src/renderer/commands/registry.ts index 511cece..bbd183d 100644 --- a/src/renderer/commands/registry.ts +++ b/src/renderer/commands/registry.ts @@ -609,7 +609,7 @@ export const HELP_LAYOUT: HelpCategorySpec[] = [ title: 'Extensions & MCP (2.0)', items: [ { key: '/mcp', description: 'List connected MCP servers + their tools' }, - { key: '/mcp browse [id]', description: 'Browse marketplace (12 servers) or show one' }, + { key: '/mcp browse [id]', description: 'Browse the MCP marketplace, or show one server' }, { key: '/mcp install [args]', description: 'Install a marketplace server into this project' }, { key: '/mcp add ', description: 'Add a custom MCP server (npx, binary, etc.)' }, { key: '/mcp remove ', description: 'Remove a project-scoped MCP server' }, diff --git a/src/renderer/components/Intro.ts b/src/renderer/components/Intro.ts index 9165d72..503e9de 100644 --- a/src/renderer/components/Intro.ts +++ b/src/renderer/components/Intro.ts @@ -5,6 +5,7 @@ import { Screen } from '../Screen'; import { style } from '../ansi'; import { PRIMARY_COLOR, LOGO_LINES as LOGO } from './uiConstants'; +import { MASCOT_FRAMES, layoutLogoWithMascot, introMascotFrame, type LogoLayout } from './mascot'; const TAGLINE = 'Deep into Code.'; export const GLITCH_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@#$%&*<>?/;:[]='; @@ -15,11 +16,11 @@ export const GLITCH_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@#$%&*<>?/;:[]= export async function showIntro(screen: Screen, duration: number = 1500): Promise { const { width, height } = screen.getSize(); - // Calculate center position - const logoWidth = LOGO[0].length; + // Mascot + logo centred as one block (logo alone on narrow terminals) + const layout = layoutLogoWithMascot(width); + const startX = layout.logoX; + const startY = Math.floor((height - layout.height) / 2) - 1 + layout.logoDy; const logoHeight = LOGO.length; - const startX = Math.floor((width - logoWidth) / 2); - const startY = Math.floor((height - logoHeight) / 2) - 1; screen.showCursor(false); @@ -27,6 +28,7 @@ export async function showIntro(screen: Screen, duration: number = 1500): Promis const noiseFrames = 10; for (let frame = 0; frame < noiseFrames; frame++) { screen.clear(); + drawMascot(screen, layout, startY, MASCOT_FRAMES.idle); for (let i = 0; i < LOGO.length; i++) { const noiseLine = generateNoiseLine(LOGO[i]); @@ -45,6 +47,7 @@ export async function showIntro(screen: Screen, duration: number = 1500): Promis const progress = (Date.now() - startTime) / decryptDuration; screen.clear(); + drawMascot(screen, layout, startY, introMascotFrame(Date.now() - startTime, decryptDuration)); for (let i = 0; i < LOGO.length; i++) { const decryptedLine = getDecryptedLine(LOGO[i], progress); @@ -63,6 +66,7 @@ export async function showIntro(screen: Screen, duration: number = 1500): Promis // Phase 3: Final state screen.clear(); + drawMascot(screen, layout, startY, MASCOT_FRAMES.idle); for (let i = 0; i < LOGO.length; i++) { screen.write(startX, startY + i, LOGO[i], PRIMARY_COLOR + style.bold); @@ -115,6 +119,17 @@ export function getDecryptedLine(original: string, progress: number): string { return result; } +/** + * Draw the mascot beside the logo (whose top line is `logoY`), if it fits. + * Never goes through the noise/decrypt effect. + */ +function drawMascot(screen: Screen, layout: LogoLayout, logoY: number, frame: readonly string[]): void { + if (layout.mascotX === null) return; + for (let i = 0; i < frame.length; i++) { + screen.write(layout.mascotX, logoY - layout.logoDy + i, frame[i], PRIMARY_COLOR + style.bold); + } +} + /** * Simple sleep helper */ @@ -128,12 +143,13 @@ function sleep(ms: number): Promise { export function showLogoStatic(screen: Screen): void { const { width, height } = screen.getSize(); - const logoWidth = LOGO[0].length; + const layout = layoutLogoWithMascot(width); + const startX = layout.logoX; + const startY = Math.floor((height - layout.height) / 2) - 1 + layout.logoDy; const logoHeight = LOGO.length; - const startX = Math.floor((width - logoWidth) / 2); - const startY = Math.floor((height - logoHeight) / 2) - 1; screen.clear(); + drawMascot(screen, layout, startY, MASCOT_FRAMES.idle); for (let i = 0; i < LOGO.length; i++) { screen.write(startX, startY + i, LOGO[i], PRIMARY_COLOR + style.bold); diff --git a/src/renderer/components/Login.ts b/src/renderer/components/Login.ts index f5a124f..b3379b4 100644 --- a/src/renderer/components/Login.ts +++ b/src/renderer/components/Login.ts @@ -9,9 +9,9 @@ import { createBox, centerBox } from './Box'; import { spawn } from 'child_process'; import clipboardy from 'clipboardy'; -// Primary color: #f02a30 (Codeep red) -const PRIMARY_COLOR = fg.rgb(240, 42, 48); -const PRIMARY_BRIGHT = fg.rgb(255, 80, 85); +// Codeep red and its bright variant, read live from the palette so an +// Omarchy theme switch recolours this screen too. +import { PRIMARY_COLOR, PRIMARY_BRIGHT } from './uiConstants'; export interface LoginOptions { onSubmit: (apiKey: string) => void; diff --git a/src/renderer/components/MessageFormatter.ts b/src/renderer/components/MessageFormatter.ts index f3ffa65..233f4fd 100644 --- a/src/renderer/components/MessageFormatter.ts +++ b/src/renderer/components/MessageFormatter.ts @@ -19,11 +19,10 @@ import { fg, style, stringWidth } from '../ansi'; import { SYNTAX, highlightCode } from '../highlight'; - -// Shared primary colour — same value as the one defined in App.ts. Kept -// here so the formatter is self-contained; the App re-exports it from -// the same literal to avoid drift. -const PRIMARY_COLOR = fg.rgb(240, 42, 48); +import { palette } from '../palette'; +// The shared brand colour, not a copy of its literal: a copy here would stay +// red after an Omarchy theme switch recoloured everything around it. +import { PRIMARY_COLOR } from './uiConstants'; export interface RenderedLine { text: string; @@ -64,7 +63,7 @@ export function applyInlineMarkdown(text: string): { formatted: string; hasForma const end = text.indexOf('`', i + 1); if (end !== -1) { const code = text.slice(i + 1, end); - result += fg.rgb(209, 154, 102) + code + '\x1b[0m'; + result += palette.inlineCode + code + '\x1b[0m'; hasFormatting = true; i = end + 1; continue; @@ -115,7 +114,7 @@ export function applyInlineMarkdown(text: string): { formatted: string; hasForma const end = text.indexOf('~~', i + 2); if (end !== -1) { const inner = text.slice(i + 2, end); - result += '\x1b[9m' + fg.rgb(140, 140, 140) + inner + '\x1b[0m'; + result += '\x1b[9m' + palette.strikethrough + inner + '\x1b[0m'; hasFormatting = true; i = end + 2; continue; @@ -211,7 +210,7 @@ export function formatTextLines( if (headingMatch) { const level = headingMatch[1].length; const headingText = headingMatch[2]; - const headingColor = level <= 2 ? fg.rgb(97, 175, 239) : fg.rgb(198, 120, 221); + const headingColor = level <= 2 ? palette.heading : palette.subheading; lines.push({ text: prefix + headingColor + style.bold + headingText + '\x1b[0m', style: prefixStyle, @@ -242,7 +241,7 @@ export function formatTextLines( const body = hasFormatting ? formatted : quoteText; // Vertical bar + space, then dim grey body. Skip if the body is // empty so an isolated `>` renders cleanly. - const barred = PRIMARY_COLOR + '│' + '\x1b[0m' + (body ? ' ' + fg.rgb(160, 160, 160) + body + '\x1b[0m' : ''); + const barred = PRIMARY_COLOR + '│' + '\x1b[0m' + (body ? ' ' + palette.secondaryText + body + '\x1b[0m' : ''); lines.push({ text: prefix + indent + barred, style: prefixStyle, @@ -400,7 +399,7 @@ export function formatMessage( if (role === 'user') { firstPrefix = PRIMARY_COLOR + '\u258c ' + style.reset; } else if (role === 'assistant') { - lines.push({ text: PRIMARY_COLOR + '\u254c\u254c' + style.reset + fg.rgb(120, 120, 120) + ' codeep' + style.reset, style: '', raw: true }); + lines.push({ text: PRIMARY_COLOR + '\u254c\u254c' + style.reset + palette.assistantLabel + ' codeep' + style.reset, style: '', raw: true }); firstPrefix = ' '; } else { firstPrefix = PRIMARY_COLOR + '\u25b8 ' + style.reset; diff --git a/src/renderer/components/Modal.ts b/src/renderer/components/Modal.ts index 67bac2b..14c25b7 100644 --- a/src/renderer/components/Modal.ts +++ b/src/renderer/components/Modal.ts @@ -7,9 +7,10 @@ import { Screen } from '../Screen'; import { fg, style } from '../ansi'; import { createBox, centerBox, BoxStyle } from './Box'; -// Primary color: #f02a30 (Codeep red) -const PRIMARY_COLOR = fg.rgb(240, 42, 48); -const PRIMARY_BRIGHT = fg.rgb(255, 80, 85); +// Codeep red and its bright variant, read live from the palette so an +// Omarchy theme switch recolours this screen too. +import { PRIMARY_COLOR, PRIMARY_BRIGHT } from './uiConstants'; +import { palette } from '../palette'; export interface ModalOptions { title: string; @@ -182,8 +183,10 @@ export function renderConfirmModal( height: modalHeight, style: 'rounded', title, - borderColor: fg.yellow, - titleColor: fg.yellow + style.bold, + // ANSI yellow, or an Omarchy theme's yellow made readable as text: the + // terminal's own is 2–2.3:1 on Omarchy's light themes. + borderColor: palette.attention, + titleColor: palette.attention + style.bold, }); for (const line of boxLines) { diff --git a/src/renderer/components/Search.ts b/src/renderer/components/Search.ts index 0851d8d..4aa7fc4 100644 --- a/src/renderer/components/Search.ts +++ b/src/renderer/components/Search.ts @@ -6,8 +6,9 @@ import { Screen } from '../Screen'; import { fg, style } from '../ansi'; import { KeyEvent } from '../Input'; -// Primary color: #f02a30 (Codeep red) -const PRIMARY_COLOR = fg.rgb(240, 42, 48); +// Codeep red, read live from the palette so an Omarchy theme switch +// recolours this panel too. +import { PRIMARY_COLOR } from './uiConstants'; export interface SearchResult { role: string; diff --git a/src/renderer/components/SelectScreen.ts b/src/renderer/components/SelectScreen.ts index 4e65aea..9f90106 100644 --- a/src/renderer/components/SelectScreen.ts +++ b/src/renderer/components/SelectScreen.ts @@ -6,9 +6,9 @@ import { Screen } from '../Screen'; import { fg, style } from '../ansi'; -// Primary color: #f02a30 (Codeep red) -const PRIMARY_COLOR = fg.rgb(240, 42, 48); -const PRIMARY_BRIGHT = fg.rgb(255, 80, 85); +// Codeep red and its bright variant, read live from the palette so an +// Omarchy theme switch recolours this screen too. +import { PRIMARY_COLOR, PRIMARY_BRIGHT } from './uiConstants'; export interface SelectItem { key: string; diff --git a/src/renderer/components/Settings.omarchy.test.ts b/src/renderer/components/Settings.omarchy.test.ts new file mode 100644 index 0000000..6b3dd53 --- /dev/null +++ b/src/renderer/components/Settings.omarchy.test.ts @@ -0,0 +1,80 @@ +/** + * The "Follow Omarchy theme" row, on a machine that looks like Omarchy. + * + * Settings decides when it is imported whether to offer the row, so the + * Omarchy state directory has to exist under this worker's throwaway HOME + * (vitest.setup.ts) before the import — hence the dynamic import below. The + * plain Settings.test.ts runs without one and checks the row is absent. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { config } from '../../config/index'; +import { followOmarchyTheme, omarchyStateDir, type OmarchyThemeWatch } from '../omarchyTheme'; +import { DEFAULT_PALETTE, getPalette, palette, resetPalette } from '../palette'; + +const stateDir = omarchyStateDir(); +mkdirSync(join(stateDir, 'theme'), { recursive: true }); +writeFileSync(join(stateDir, 'theme', 'colors.toml'), [ + 'accent = "#7aa2f7"', + 'background = "#1a1b26"', + 'foreground = "#a9b1d6"', +].join('\n')); + +const { SETTINGS, handleSettingsKey } = await import('./Settings'); + +const TOKYO_ACCENT = '\x1b[38;2;122;162;247m'; +let watch: OmarchyThemeWatch | null = null; + +const row = () => SETTINGS.findIndex(s => s.key === 'followOmarchyTheme'); +const state = () => ({ selectedIndex: row(), editing: false, editValue: '' }); + +afterAll(() => { + rmSync(join(stateDir, '..'), { recursive: true, force: true }); +}); + +// Omarchy is Linux-only, and so is the row: off Linux it is never offered. +describe.skipIf(process.platform !== 'linux')('Follow Omarchy theme', () => { + beforeAll(() => { + // As main.ts starts it. + watch = followOmarchyTheme({ stateDir, enabled: () => config.get('followOmarchyTheme') !== false }); + }); + + afterAll(() => { + watch?.stop(); + resetPalette(); + }); + + beforeEach(() => { + config.set('followOmarchyTheme', true); + watch?.reload(); + }); + + it('is offered on Omarchy, and on by default', () => { + expect(row()).toBeGreaterThanOrEqual(0); + expect(SETTINGS[row()].getValue()).toBe(true); + expect(palette.primary).toBe(TOKYO_ACCENT); + }); + + it('turning it off puts Codeep\'s own colours back at once', () => { + const result = handleSettingsKey('enter', false, state()); + expect(result.notify).toBe("Follow Omarchy theme: Off (Codeep's own colours)"); + expect(config.get('followOmarchyTheme')).toBe(false); + expect(getPalette()).toEqual(DEFAULT_PALETTE); + }); + + it('turning it back on applies the theme at once', () => { + handleSettingsKey('right', false, state()); + expect(getPalette()).toEqual(DEFAULT_PALETTE); + handleSettingsKey('left', false, state()); + expect(config.get('followOmarchyTheme')).toBe(true); + expect(palette.primary).toBe(TOKYO_ACCENT); + }); + + it('offers its own current value, in one type', () => { + const setting = SETTINGS[row()]; + const values = (setting.options ?? []).map(o => o.value); + expect(values).toContain(setting.getValue()); + expect(new Set(values.map(v => typeof v))).toEqual(new Set(['boolean'])); + }); +}); diff --git a/src/renderer/components/Settings.test.ts b/src/renderer/components/Settings.test.ts index 2db9d1e..7ff035f 100644 --- a/src/renderer/components/Settings.test.ts +++ b/src/renderer/components/Settings.test.ts @@ -17,9 +17,16 @@ * the value-shape contract asserted here will fail. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { handleSettingsKey, SETTINGS, type SettingsState } from './Settings'; import { config } from '../../config/index'; +import { + agentConfirmationMode, + agentInteractiveMode, + isAgentConfirmationPinned, + pinAgentConfirmation, + pinAgentInteractive, +} from '../agentConfirmation'; const initialState = (overrides: Partial = {}): SettingsState => ({ selectedIndex: 0, @@ -151,6 +158,43 @@ describe('handleSettingsKey — select cycling', () => { }); }); +describe('handleSettingsKey — after `codeep --yolo`', () => { + // --yolo pins both for the process. Picking a value in the row is a + // decision about this run too: the row must not say Always while nothing + // asks until the process exits. + beforeEach(() => { + config.set('agentConfirmation', 'dangerous'); + config.set('agentInteractive', false); + pinAgentConfirmation('never'); + pinAgentInteractive(false); + }); + + afterEach(() => { + pinAgentConfirmation(null); + pinAgentInteractive(null); + }); + + it('ends the pin of the setting that was changed, and only that one', () => { + // Dangerous → Always. + handleSettingsKey('enter', false, initialState({ selectedIndex: indexOf('agentConfirmation') })); + expect(config.get('agentConfirmation')).toBe('always'); + expect(agentConfirmationMode()).toBe('always'); + expect(isAgentConfirmationPinned()).toBe(false); + expect(agentInteractiveMode()).toBe(false); + + // Off → On. + handleSettingsKey('right', false, initialState({ selectedIndex: indexOf('agentInteractive') })); + expect(agentInteractiveMode()).toBe(true); + }); + + it('keeps both pins through a change to any other setting', () => { + config.set('agentMode', 'on'); + handleSettingsKey('right', false, initialState({ selectedIndex: indexOf('agentMode') })); + expect(agentConfirmationMode()).toBe('never'); + expect(agentInteractiveMode()).toBe(false); + }); +}); + describe('handleSettingsKey — rate-limit side effect', () => { // The handler calls `updateRateLimits()` after writing to rateLimitApi or // rateLimitCommands. We don't assert on the rate limiter state (that's @@ -178,6 +222,14 @@ describe('handleSettingsKey — rate-limit side effect', () => { }); }); +describe('the Omarchy row', () => { + it('is not offered off Omarchy, where it would switch nothing', () => { + // This worker's HOME has no ~/.local/state/omarchy; Settings.omarchy.test.ts + // covers the row where there is one. + expect(SETTINGS.some(s => s.key === 'followOmarchyTheme')).toBe(false); + }); +}); + describe('select options match the values they read back', () => { /** * A select whose current value is not among its own options is broken, and diff --git a/src/renderer/components/Settings.ts b/src/renderer/components/Settings.ts index 65c1a30..e416182 100644 --- a/src/renderer/components/Settings.ts +++ b/src/renderer/components/Settings.ts @@ -4,6 +4,8 @@ import { config, type ConfigSchema } from '../../config/index'; import { updateRateLimits } from '../../utils/ratelimit'; +import { pinAgentConfirmation, pinAgentInteractive } from '../agentConfirmation'; +import { isOmarchy, reapplyOmarchyTheme } from '../omarchyTheme'; // Primary color: #f02a30 (Codeep red) @@ -38,8 +40,33 @@ export interface SettingItem { */ function writeSetting(setting: SettingItem, value: string | number | boolean): void { config.set(setting.key, value as ConfigSchema[typeof setting.key]); + // A value chosen here is a decision about this run too, so it ends what + // `codeep --yolo` pinned for it (agentConfirmation.ts). Otherwise the row + // would say Always while nothing asks until the process exits. + if (setting.key === 'agentConfirmation') pinAgentConfirmation(null); + if (setting.key === 'agentInteractive') pinAgentInteractive(null); + // Switching it shows at once: the palette is re-read and the App repaints + // (it listens for palette changes), rather than waiting for the next + // theme switch or a restart. + if (setting.key === 'followOmarchyTheme') reapplyOmarchyTheme(); } +/** + * The Omarchy row, offered only on an Omarchy desktop — anywhere else it + * would be a switch that does nothing. Decided once, when the settings + * screen's module loads: Omarchy does not come and go during a session. + */ +const OMARCHY_SETTINGS: SettingItem[] = isOmarchy() ? [{ + key: 'followOmarchyTheme', + label: 'Follow Omarchy theme', + getValue: () => config.get('followOmarchyTheme') !== false, + type: 'select', + options: [ + { value: true, label: 'On' }, + { value: false, label: "Off (Codeep's own colours)" }, + ], +}] : []; + export const SETTINGS: SettingItem[] = [ { key: 'temperature', @@ -348,6 +375,7 @@ export const SETTINGS: SettingItem[] = [ { value: true, label: 'On (push/sync to codeep.dev)' }, ], }, + ...OMARCHY_SETTINGS, ]; export interface SettingsState { diff --git a/src/renderer/components/WelcomeFormatter.ts b/src/renderer/components/WelcomeFormatter.ts index 8f5ae05..97e821c 100644 --- a/src/renderer/components/WelcomeFormatter.ts +++ b/src/renderer/components/WelcomeFormatter.ts @@ -5,7 +5,8 @@ * rules can be unit-tested without instantiating the full renderer. * Called once per render for messages whose `role` is `'welcome'`. */ -import { fg, style } from '../ansi'; +import { style } from '../ansi'; +import { palette } from '../palette'; import { PRIMARY_COLOR } from './uiConstants'; export interface FormattedLine { @@ -29,8 +30,10 @@ export interface FormattedLine { */ export function formatWelcomeMessage(content: string): FormattedLine[] { const lines: FormattedLine[] = []; - const DIM = fg.rgb(80, 80, 80); - const LABEL = fg.rgb(100, 100, 100); + // Read here, per call, not at module scope: the palette can change while + // Codeep runs (an Omarchy theme switch) and the next format must see it. + const DIM = palette.separator; + const LABEL = palette.label; const SEP = DIM + ' · ' + style.reset; for (const line of content.split('\n')) { @@ -43,8 +46,8 @@ export function formatWelcomeMessage(content: string): FormattedLine[] { if (line.startsWith('Codeep ')) { const parts = line.split(' · '); const colored = PRIMARY_COLOR + style.bold + (parts[0] || '') + style.reset - + SEP + fg.rgb(180, 180, 180) + (parts[1] || '') + style.reset - + SEP + fg.rgb(130, 130, 130) + (parts[2] || '') + style.reset; + + SEP + palette.providerName + (parts[1] || '') + style.reset + + SEP + palette.modelName + (parts[2] || '') + style.reset; lines.push({ text: colored, style: '', raw: true }); continue; } @@ -52,7 +55,7 @@ export function formatWelcomeMessage(content: string): FormattedLine[] { // Project line if (/^\s+Project\s/.test(line)) { const value = line.replace(/^\s+Project\s+/, ''); - lines.push({ text: LABEL + ' Project ' + style.reset + fg.rgb(100, 180, 220) + value + style.reset, style: '', raw: true }); + lines.push({ text: LABEL + ' Project ' + style.reset + palette.path + value + style.reset, style: '', raw: true }); continue; } @@ -60,8 +63,8 @@ export function formatWelcomeMessage(content: string): FormattedLine[] { if (/^\s+Access\s/.test(line)) { const value = line.replace(/^\s+Access\s+/, ''); const parts = value.split(' · '); - const accessColored = fg.rgb(100, 200, 120) + style.bold + (parts[0] || '') + style.reset; - const rest = parts.slice(1).map(p => fg.rgb(80, 160, 100) + p + style.reset).join(SEP); + const accessColored = palette.success + style.bold + (parts[0] || '') + style.reset; + const rest = parts.slice(1).map(p => palette.successDetail + p + style.reset).join(SEP); lines.push({ text: LABEL + ' Access ' + style.reset + accessColored + (rest ? SEP + rest : ''), style: '', raw: true }); continue; } @@ -69,20 +72,20 @@ export function formatWelcomeMessage(content: string): FormattedLine[] { // Mode line if (/^\s+Mode\s/.test(line)) { const value = line.replace(/^\s+Mode\s+/, ''); - lines.push({ text: LABEL + ' Mode ' + style.reset + fg.rgb(160, 160, 160) + value + style.reset, style: '', raw: true }); + lines.push({ text: LABEL + ' Mode ' + style.reset + palette.secondaryText + value + style.reset, style: '', raw: true }); continue; } // Agent Mode warning if (line.includes('⚠')) { - lines.push({ text: ' ' + fg.rgb(220, 160, 40) + line.trim() + style.reset, style: '', raw: true }); + lines.push({ text: ' ' + palette.warning + line.trim() + style.reset, style: '', raw: true }); continue; } // Shortcuts line if (line.includes('/help')) { const parts = line.trim().split(' · '); - const colored = parts.map(p => fg.rgb(150, 150, 150) + p.trim() + style.reset).join(DIM + ' · ' + style.reset); + const colored = parts.map(p => palette.hint + p.trim() + style.reset).join(DIM + ' · ' + style.reset); lines.push({ text: ' ' + colored, style: '', raw: true }); continue; } diff --git a/src/renderer/components/mascot.test.ts b/src/renderer/components/mascot.test.ts new file mode 100644 index 0000000..7d8e558 --- /dev/null +++ b/src/renderer/components/mascot.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { + MASCOT_WIDTH, + MASCOT_HEIGHT, + MASCOT_GAP, + MASCOT_FRAMES, + layoutLogoWithMascot, + introMascotFrame, +} from './mascot'; +import { LOGO_LINES, LOGO_HEIGHT } from './uiConstants'; +import { visibleLength } from '../ansi'; + +const LOGO_WIDTH = LOGO_LINES[0].length; +const PAIR_WIDTH = MASCOT_WIDTH + MASCOT_GAP + LOGO_WIDTH; + +describe('MASCOT_FRAMES', () => { + it('is 16 columns × 7 lines', () => { + expect(MASCOT_WIDTH).toBe(16); + expect(MASCOT_HEIGHT).toBe(7); + }); + + it.each(Object.entries(MASCOT_FRAMES))('%s has 7 lines of exactly 16 columns', (_name, frame) => { + expect(frame).toHaveLength(MASCOT_HEIGHT); + for (const line of frame) { + // Code units AND display columns: callers use `.length` as the width. + expect(line.length).toBe(MASCOT_WIDTH); + expect(visibleLength(line)).toBe(MASCOT_WIDTH); + } + }); + + it.each(['lookLeft', 'lookRight', 'blink'] as const)('%s differs from idle only on line 1 (the pupils)', (name) => { + const frame = MASCOT_FRAMES[name]; + for (let i = 0; i < MASCOT_HEIGHT; i++) { + if (i === 1) expect(frame[i]).not.toBe(MASCOT_FRAMES.idle[i]); + else expect(frame[i]).toBe(MASCOT_FRAMES.idle[i]); + } + }); + + it('has four distinct pupil rows', () => { + const pupils = Object.values(MASCOT_FRAMES).map(f => f[1]); + expect(new Set(pupils).size).toBe(4); + }); +}); + +describe('layoutLogoWithMascot', () => { + it('puts the mascot left of the wordmark with a 3-column gap, centred as a pair', () => { + const layout = layoutLogoWithMascot(120); + expect(layout.mascotX).toBe(Math.floor((120 - PAIR_WIDTH) / 2)); + expect(layout.logoX - layout.mascotX!).toBe(MASCOT_WIDTH + 3); + // Equal margins either side (± 1 for odd leftovers). + const right = 120 - (layout.logoX + LOGO_WIDTH); + expect(Math.abs(right - layout.mascotX!)).toBeLessThanOrEqual(1); + }); + + it('sits the wordmark on mascot lines 1..6', () => { + const layout = layoutLogoWithMascot(120); + expect(layout.logoDy).toBe(1); + expect(layout.logoDy + LOGO_HEIGHT).toBe(MASCOT_HEIGHT); + expect(layout.height).toBe(MASCOT_HEIGHT); + }); + + it('keeps the mascot at exactly mascot + gap + wordmark + 4 columns', () => { + const layout = layoutLogoWithMascot(PAIR_WIDTH + 4); + expect(layout.mascotX).toBe(2); + expect(layout.logoX).toBe(2 + MASCOT_WIDTH + MASCOT_GAP); + }); + + it('drops the mascot one column narrower and centres the wordmark alone', () => { + const width = PAIR_WIDTH + 3; + expect(layoutLogoWithMascot(width)).toEqual({ + mascotX: null, + logoX: Math.floor((width - LOGO_WIDTH) / 2), + logoDy: 0, + height: LOGO_HEIGHT, + }); + }); + + it('never places the wordmark at a negative column', () => { + expect(layoutLogoWithMascot(30).logoX).toBe(0); + }); +}); + +describe('introMascotFrame', () => { + const DECRYPT_MS = 1500; + + it('steps the pupils centre → left → centre → right about every 250 ms', () => { + expect(introMascotFrame(0, DECRYPT_MS)).toBe(MASCOT_FRAMES.idle); + expect(introMascotFrame(300, DECRYPT_MS)).toBe(MASCOT_FRAMES.lookLeft); + expect(introMascotFrame(600, DECRYPT_MS)).toBe(MASCOT_FRAMES.idle); + expect(introMascotFrame(800, DECRYPT_MS)).toBe(MASCOT_FRAMES.lookRight); + expect(introMascotFrame(1100, DECRYPT_MS)).toBe(MASCOT_FRAMES.idle); + }); + + it('blinks just before the decrypt completes, then opens its eyes', () => { + expect(introMascotFrame(DECRYPT_MS - 50, DECRYPT_MS)).toBe(MASCOT_FRAMES.blink); + expect(introMascotFrame(DECRYPT_MS, DECRYPT_MS)).toBe(MASCOT_FRAMES.idle); + }); + + // 1000 ms is Intro.ts's default decrypt: the steps scale, so it still holds + // centre between the right glance and the blink. + it.each([1000, DECRYPT_MS])('plays the whole sequence once, with a single blink, over a ~60 FPS %i ms run', (decryptMs) => { + const seq: Array = []; + for (let t = 0; t < decryptMs; t += 16) { + const frame = introMascotFrame(t, decryptMs); + if (seq[seq.length - 1] !== frame) seq.push(frame); + } + const { idle, lookLeft, lookRight, blink } = MASCOT_FRAMES; + expect(seq).toEqual([idle, lookLeft, idle, lookRight, idle, blink]); + }); + + it('is idle outside the decrypt phase', () => { + expect(introMascotFrame(-1, DECRYPT_MS)).toBe(MASCOT_FRAMES.idle); + expect(introMascotFrame(DECRYPT_MS + 500, DECRYPT_MS)).toBe(MASCOT_FRAMES.idle); + }); +}); diff --git a/src/renderer/components/mascot.ts b/src/renderer/components/mascot.ts new file mode 100644 index 0000000..394a417 --- /dev/null +++ b/src/renderer/components/mascot.ts @@ -0,0 +1,92 @@ +/** + * The Codeep mascot, for the terminal. + * + * The pixel creature from `Codeep Maskot.svg`, redrawn with half blocks + * (`▀ ▄ ▌ ▐ █`): one terminal cell holds two pixels stacked vertically, so the + * whole creature fits in 16 columns × 7 lines. Line 0 is the top of the eyes; + * line 1 holds the pupils and is the ONLY line the frames change, which is + * what lets the intro make it glance around and blink by swapping one row. + * + * Every line is exactly MASCOT_WIDTH code units and columns (all glyphs are + * single-width BMP characters), so `.length` doubles as the display width. + */ +import { LOGO_LINES } from './uiConstants'; + +export const MASCOT_WIDTH = 16; +export const MASCOT_HEIGHT = 7; + +/** Columns between the mascot and the wordmark when drawn side by side. */ +export const MASCOT_GAP = 3; + +const IDLE: readonly string[] = [ + ' █▀▀▀▀█ █▀▀▀▀█ ', + ' █ ██ ████ ██ █ ', + ' █▄▄▄▄████▄▄▄▄█ ', + '████████████████', + '▀▀▀██████████▀▀▀', + ' ██████████ ', + ' ███ ███ ', +]; + +/** The idle body with a different pupil row (line 1). */ +const withPupils = (pupils: string): readonly string[] => [IDLE[0], pupils, ...IDLE.slice(2)]; + +/** Mascot frames, one string per terminal line. */ +export const MASCOT_FRAMES = { + idle: IDLE, + lookLeft: withPupils(' █▐█▌ ████▐█▌ █ '), + lookRight: withPupils(' █ ▐█▌████ ▐█▌█ '), + blink: withPupils(' █ ▄▄ ████ ▄▄ █ '), +} as const; + +/** + * Horizontal placement of the wordmark, with the mascot to its left when it + * fits. Shared by the intro and the static logo so both lay out the same way. + */ +export interface LogoLayout { + /** Column of the mascot, or null when the terminal is too narrow for it. */ + mascotX: number | null; + /** Column of the CODEEP wordmark. */ + logoX: number; + /** Line of the wordmark below the top of the block (1 beside the mascot, so + * its 6 lines sit on mascot lines 1..6; 0 without it). */ + logoDy: number; + /** Height of the whole block in lines. */ + height: number; +} + +/** + * Lay out the mascot + wordmark pair centred as one unit in `width` columns. + * + * Below `mascot + gap + wordmark + 4` columns the mascot is dropped and the + * wordmark is centred on its own, exactly as before the mascot existed. + */ +export function layoutLogoWithMascot(width: number): LogoLayout { + const logoWidth = LOGO_LINES[0].length; + const pairWidth = MASCOT_WIDTH + MASCOT_GAP + logoWidth; + if (width < pairWidth + 4) { + return { mascotX: null, logoX: Math.max(0, Math.floor((width - logoWidth) / 2)), logoDy: 0, height: LOGO_LINES.length }; + } + const mascotX = Math.floor((width - pairWidth) / 2); + return { mascotX, logoX: mascotX + MASCOT_WIDTH + MASCOT_GAP, logoDy: 1, height: MASCOT_HEIGHT }; +} + +/** Each glance holds 1/GLANCE_STEPS of the decrypt phase: 250 ms of the App's 1.5 s. */ +const GLANCE_STEPS = 6; +/** How long the closing blink lasts. */ +const BLINK_MS = 150; +const GLANCES = [MASCOT_FRAMES.idle, MASCOT_FRAMES.lookLeft, MASCOT_FRAMES.idle, MASCOT_FRAMES.lookRight]; + +/** + * Mascot frame at `elapsedMs` into the intro's decrypt phase: pupils step + * centre → left → centre → right, hold centre, then one blink in the last + * BLINK_MS as the wordmark finishes decrypting. Idle outside the phase. + * + * Steps scale with `decryptMs`, so a shorter decrypt still gets its centre + * hold before the blink instead of cutting the right glance short. + */ +export function introMascotFrame(elapsedMs: number, decryptMs: number): readonly string[] { + if (elapsedMs < 0 || elapsedMs >= decryptMs) return MASCOT_FRAMES.idle; + if (elapsedMs >= decryptMs - BLINK_MS) return MASCOT_FRAMES.blink; + return GLANCES[Math.floor((elapsedMs * GLANCE_STEPS) / decryptMs)] ?? MASCOT_FRAMES.idle; +} diff --git a/src/renderer/components/uiConstants.ts b/src/renderer/components/uiConstants.ts index 6e847d6..1be8780 100644 --- a/src/renderer/components/uiConstants.ts +++ b/src/renderer/components/uiConstants.ts @@ -6,10 +6,17 @@ * import them without re-declaring (which would let the palette drift * between files). */ -import { fg } from '../ansi'; -/** Brand red — used for the logo, the agent-panel title, and accents. */ -export const PRIMARY_COLOR = fg.rgb(240, 42, 48); +/** + * Brand red — used for the logo, the agent-panel title, and accents — and + * its bright variant for selected rows and modal titles. + * + * They live in ../palette now, which can swap them for an Omarchy theme's + * accent while Codeep runs; re-exported here because a hundred-odd call + * sites already import them from this module. A re-export is a live binding, + * so each read sees the current colour, not the one at import time. + */ +export { PRIMARY_COLOR, PRIMARY_BRIGHT } from '../palette'; /** Spinner frames for the agent progress panel (8-step rotation). */ export const SPINNER_FRAMES = ['▖', '▘', '▝', '▗', '▌', '▀', '▐', '▄']; diff --git a/src/renderer/highlight.ts b/src/renderer/highlight.ts index 07414d3..3ee2c94 100644 --- a/src/renderer/highlight.ts +++ b/src/renderer/highlight.ts @@ -4,19 +4,29 @@ */ import { fg } from './ansi'; +import { palette } from './palette'; +/** + * The colour of each token kind. Getters over the palette, so a code block + * formatted after an Omarchy theme switch is painted in the new theme's + * colours; a plain object of strings would have kept One Dark forever. + * `variable` is basic ANSI white, which the terminal already themes by + * itself. `punctuation` is ANSI bright black until a theme gives it a + * colour: on Omarchy that index is `muted`, a decoration colour too faint + * for braces and semicolons. + */ export const SYNTAX = { - keyword: fg.rgb(198, 120, 221), - string: fg.rgb(152, 195, 121), - number: fg.rgb(209, 154, 102), - comment: fg.rgb(92, 99, 112), - function: fg.rgb(97, 175, 239), - type: fg.rgb(229, 192, 123), - operator: fg.rgb(86, 182, 194), + get keyword() { return palette.syntaxKeyword; }, + get string() { return palette.syntaxString; }, + get number() { return palette.syntaxNumber; }, + get comment() { return palette.syntaxComment; }, + get function() { return palette.syntaxFunction; }, + get type() { return palette.syntaxType; }, + get operator() { return palette.syntaxOperator; }, variable: fg.white, - punctuation: fg.gray, - codeFrame: fg.rgb(100, 105, 115), - codeLang: fg.rgb(150, 155, 165), + get punctuation() { return palette.syntaxPunctuation; }, + get codeFrame() { return palette.codeFrame; }, + get codeLang() { return palette.codeLang; }, }; const KEYWORDS: Record = { @@ -48,7 +58,7 @@ export function highlightCode(code: string, lang: string): string { if (line.startsWith('+++') || line.startsWith('---')) return SYNTAX.codeLang + line + '\x1b[0m'; if (line.startsWith('@@')) return SYNTAX.operator + line + '\x1b[0m'; if (line.startsWith('+')) return SYNTAX.string + line + '\x1b[0m'; // green — additions - if (line.startsWith('-')) return fg.rgb(224, 108, 117) + line + '\x1b[0m'; // red — removals + if (line.startsWith('-')) return palette.syntaxRemoved + line + '\x1b[0m'; // red — removals return line; }).join('\n'); } diff --git a/src/renderer/main.launch.test.ts b/src/renderer/main.launch.test.ts new file mode 100644 index 0000000..5a747d9 --- /dev/null +++ b/src/renderer/main.launch.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { analyzeForClarification } from '../utils/interactive'; + +// `codeep --yolo` and the launch prompt, run the way the binary runs them: +// main() reads process.argv and goes through the whole startup. The App is a +// recorder, so what the startup asked the user — and in what order the +// prompt went in — can be read back. Nothing here reaches the network, the +// keychain or a model. +const ui = vi.hoisted(() => ({ + options: null as null | Record, + calls: [] as Array<{ method: string; args: unknown[] }>, + /** What runAgentTask does beyond being recorded; see the mock below. */ + onRun: null as null | ((...args: unknown[]) => unknown), +})); +vi.mock('./App', () => ({ + App: class { + constructor(options: Record) { + ui.options = options; + return new Proxy({}, { + get: (_target, key) => { + // Not a thenable: something awaiting the app must not hang on it. + if (key === 'then') return undefined; + return (...args: unknown[]) => { + ui.calls.push({ method: String(key), args }); + return key === 'getMessages' || key === 'getChatHistory' ? [] : undefined; + }; + }, + }); + } + }, +})); +vi.mock('../utils/git', async (importOriginal) => ({ + ...(await importOriginal()), + getGitStatus: vi.fn(() => ({ isRepo: false })), +})); +vi.mock('../utils/logger', async (importOriginal) => ({ + ...(await importOriginal()), + logAppError: vi.fn(), +})); +vi.mock('../config/index', async (importOriginal) => ({ + ...(await importOriginal()), + loadAllApiKeys: vi.fn(async () => {}), + loadApiKey: vi.fn(async () => 'test-key'), + fetchOllamaModels: vi.fn(async () => null), + setProjectPermission: vi.fn(), + initializeAsProject: vi.fn(), +})); +vi.mock('../utils/codeepCloud', async (importOriginal) => ({ + ...(await importOriginal()), + ensureDeviceRegistered: vi.fn(), +})); +vi.mock('../utils/update', async (importOriginal) => ({ + ...(await importOriginal()), + checkForUpdates: vi.fn(async () => ({ hasUpdate: false })), +})); +vi.mock('../utils/telegramCredentials', async (importOriginal) => ({ + ...(await importOriginal()), + loadTelegramInboxCredentials: vi.fn(async () => null), +})); +vi.mock('../utils/mcpRegistry', async (importOriginal) => ({ + ...(await importOriginal()), + registerSessionServers: vi.fn(async () => ({ registered: [], errors: [] })), +})); +// Where a submitted prompt lands with Agent Mode on (the default). `ui.onRun` +// lets a test hold the run for clarifying questions the way the real one +// does, and executeAgentTask is where the answer to them goes. +vi.mock('./agentExecution', async (importOriginal) => ({ + ...(await importOriginal()), + runAgentTask: vi.fn(async (...args: unknown[]) => { await ui.onRun?.(...args); }), + executeAgentTask: vi.fn(async () => 'success'), +})); +// Recorded, so a test can tell when startup has read the MCP config. +vi.mock('../utils/mcpConfig', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, selectSessionMcpServers: vi.fn(actual.selectSessionMcpServers) }; +}); + +const savedArgv = process.argv; +const savedCwd = process.cwd(); +let folder: string; + +/** Start main.ts as `codeep ` in a fresh folder that is not a repository. */ +async function launch(...args: string[]) { + process.argv = ['node', 'codeep', ...args]; + vi.resetModules(); + const config = await import('../config/index'); + const pins = await import('./agentConfirmation'); + const { runAgentTask, executeAgentTask } = await import('./agentExecution'); + const { selectSessionMcpServers } = await import('../utils/mcpConfig'); + vi.mocked(config.setProjectPermission).mockClear(); + vi.mocked(config.initializeAsProject).mockClear(); + vi.mocked(runAgentTask).mockClear(); + vi.mocked(executeAgentTask).mockClear(); + const set = vi.spyOn(config.config, 'set'); + const { main } = await import('./main'); + await main(); + return { + config, + set, + pins, + agentConfirmationMode: pins.agentConfirmationMode, + runAgentTask: vi.mocked(runAgentTask), + executeAgentTask: vi.mocked(executeAgentTask), + selectSessionMcpServers: vi.mocked(selectSessionMcpServers), + }; +} + +const called = (method: string) => ui.calls.filter(c => c.method === method); +const titles = () => called('showConfirm').map(c => (c.args[0] as { title: string }).title); +const userMessages = () => called('addMessage') + .map(c => c.args[0] as { role: string; content: string }) + .filter(m => m.role === 'user') + .map(m => m.content); + +beforeEach(() => { + // realpath: main.ts reads process.cwd(), which resolves a symlinked TMPDIR. + folder = realpathSync(mkdtempSync(join(tmpdir(), 'codeep-launch-'))); + process.chdir(folder); + ui.options = null; + ui.calls = []; + ui.onRun = null; + // The 30-second Ollama health check must not outlive the test. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); +}); + +afterEach(() => { + vi.useRealTimers(); + process.chdir(savedCwd); + process.argv = savedArgv; + vi.restoreAllMocks(); + rmSync(folder, { recursive: true, force: true }); +}); + +describe('codeep --yolo', () => { + it('starts without asking anything, and saves none of the answers it gave', async () => { + const { config: stored, saveSession } = await import('../config/index'); + const before = stored.get('agentConfirmation'); + // A session to offer, so skipping the picker is a decision and not an + // empty list. + saveSession('yesterday', [{ role: 'user', content: 'hello' }], folder); + const { config, set, agentConfirmationMode, pins } = await launch('--yolo'); + + // No "Set as Project?", no access dialog, no session picker. + expect(titles()).toEqual([]); + expect(called('showPermission')).toEqual([]); + expect(called('showSessionPicker')).toEqual([]); + + // Read & write for this process — and none of it written anywhere. + expect(ui.options?.hasWriteAccess).toBeTypeOf('function'); + expect((ui.options?.hasWriteAccess as () => boolean)()).toBe(true); + expect((ui.options?.hasProjectContext as () => boolean)()).toBe(true); + expect(config.setProjectPermission).not.toHaveBeenCalled(); + expect(config.initializeAsProject).not.toHaveBeenCalled(); + expect(existsSync(join(folder, '.codeep', 'project.json'))).toBe(false); + + // Never mode and no clarifying questions for this process; the config + // still says what it said. + expect(agentConfirmationMode()).toBe('never'); + expect(pins.agentInteractiveMode()).toBe(false); + // (Conf's overloads type the key as an object; it is the string here.) + expect(set.mock.calls.map(([key]) => key as unknown)).not.toContain('agentConfirmation'); + expect(set.mock.calls.map(([key]) => key as unknown)).not.toContain('agentInteractive'); + expect(config.config.get('agentConfirmation')).toBe(before); + + // And the status bar is told, so it can say so — for as long as it holds. + expect((ui.options?.yolo as () => boolean)()).toBe(true); + pins.pinAgentConfirmation(null); + expect((ui.options?.yolo as () => boolean)()).toBe(false); + }); + + it('sends a prompt after -- into a new session, as if it had been typed', async () => { + const { runAgentTask } = await launch('--yolo', '--', 'add', 'a', 'README'); + + await vi.waitFor(() => expect(runAgentTask).toHaveBeenCalledTimes(1)); + expect(userMessages()).toEqual(['add a README']); + expect(called('setLoading').map(c => c.args[0])).toEqual([true]); + expect(runAgentTask).toHaveBeenCalledTimes(1); + expect(runAgentTask.mock.calls[0][0]).toBe('add a README'); + expect(titles()).toEqual([]); + }); + + it('keeps a prompt that names a subcommand a prompt', async () => { + const { runAgentTask } = await launch('--yolo', '--', 'review'); + await vi.waitFor(() => expect(runAgentTask).toHaveBeenCalledTimes(1)); + expect(runAgentTask.mock.calls[0][0]).toBe('review'); + }); +}); + +describe('a launch prompt without --yolo', () => { + it('waits for the usual questions, then sends the prompt instead of offering a session', async () => { + const { config, agentConfirmationMode, runAgentTask } = await launch('-p', 'add a README'); + + // Nothing has been sent while the first question is still open. + expect(titles()).toEqual(['Set as Project?']); + expect(runAgentTask).not.toHaveBeenCalled(); + expect(userMessages()).toEqual([]); + + (called('showConfirm')[0].args[0] as { onConfirm: () => void }).onConfirm(); + expect(config.initializeAsProject).toHaveBeenCalledWith(folder); + expect(runAgentTask).not.toHaveBeenCalled(); + + // The access dialog is answered the ordinary way, and saved as before. + const [, , answer] = called('showPermission')[0].args as [string, boolean, (level: string) => void]; + answer('write'); + expect(config.setProjectPermission).toHaveBeenCalledWith(folder, true, true); + + await vi.waitFor(() => expect(runAgentTask).toHaveBeenCalledTimes(1)); + expect(userMessages()).toEqual(['add a README']); + expect(runAgentTask.mock.calls[0][0]).toBe('add a README'); + expect(called('showSessionPicker')).toEqual([]); + // Without --yolo the run asks as configured. + expect(agentConfirmationMode()).toBe(config.config.get('agentConfirmation') || 'dangerous'); + expect((ui.options?.yolo as () => boolean)()).toBe(false); + }); + + it('still offers the saved sessions to a plain `codeep`', async () => { + const { saveSession } = await import('../config/index'); + saveSession('yesterday', [{ role: 'user', content: 'hello' }], folder); + const { runAgentTask } = await launch(); + + (called('showConfirm')[0].args[0] as { onConfirm: () => void }).onConfirm(); + (called('showPermission')[0].args[2] as (level: string) => void)('write'); + + expect(called('showSessionPicker')).toHaveLength(1); + expect(runAgentTask).not.toHaveBeenCalled(); + }); +}); + +describe('a launch prompt in a workspace with its own MCP servers', () => { + const writeWorkspaceServers = () => writeFileSync(join(folder, '.mcp.json'), + JSON.stringify({ mcpServers: { tools: { command: 'tools-cmd' } } })); + + // App.showConfirm replaces an open confirm without answering it. Sent beside + // the trust question, the run's own "Potentially Dangerous Task" took its + // place or lost its own, and the prompt never ran. + for (const args of [['-p', 'remove the unused files'], ['--yolo', '--', 'remove the unused files']]) { + it(`is sent once the trust question is answered: codeep ${args.join(' ')}`, async () => { + const { config: stored } = await import('../config/index'); + writeWorkspaceServers(); + // Access already granted, so the trust question is the only one asked. + stored.set('projectPermissions', [ + { path: folder, readPermission: true, writePermission: true, grantedAt: new Date().toISOString() }, + ]); + const { runAgentTask } = await launch(...args); + + await vi.waitFor(() => expect(titles()).toEqual(['Trust workspace MCP servers?'])); + expect(runAgentTask).not.toHaveBeenCalled(); + expect(userMessages()).toEqual([]); + + (called('showConfirm')[0].args[0] as { onCancel: () => void }).onCancel(); + await vi.waitFor(() => expect(runAgentTask).toHaveBeenCalledTimes(1)); + expect(userMessages()).toEqual(['remove the unused files']); + }); + } + + it('asks the trust question after the startup questions, not over them', async () => { + // Raised while "Set as Project?" was up, it took that question's place, + // and the startup waiting on that answer never went on. + writeWorkspaceServers(); + const { runAgentTask, selectSessionMcpServers } = await launch('-p', 'add a README'); + + // The MCP config is read and the trust question is ready to ask. + await vi.waitFor(() => expect(selectSessionMcpServers).toHaveBeenCalled()); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(titles()).toEqual(['Set as Project?']); + + (called('showConfirm')[0].args[0] as { onConfirm: () => void }).onConfirm(); + (called('showPermission')[0].args[2] as (level: string) => void)('write'); + await vi.waitFor(() => expect(titles()).toEqual(['Set as Project?', 'Trust workspace MCP servers?'])); + expect(runAgentTask).not.toHaveBeenCalled(); + + // Trusted: sent once their servers are started, so the run has their tools. + (called('showConfirm')[1].args[0] as { onConfirm: () => void }).onConfirm(); + await vi.waitFor(() => expect(runAgentTask).toHaveBeenCalledTimes(1)); + expect(userMessages()).toEqual(['add a README']); + }); +}); + +describe('the answer to clarifying questions', () => { + it('runs without "Confirm Agent Task" under --yolo, as the run itself would', async () => { + // Held for questions the way runAgentTask holds a vague task. Under --yolo + // that takes Agent Interactive Mode switched back on in /settings, which + // ends that pin and leaves the confirmation one in force. + ui.onRun = (task, _dryRun, _ctx, _getPending, setPending) => { + (setPending as (v: unknown) => void)({ + originalTask: task, context: analyzeForClarification(task as string), dryRun: false, + }); + }; + const { config, pins, runAgentTask, executeAgentTask } = await launch('--yolo'); + const before = config.config.get('agentConfirmation'); + config.config.set('agentConfirmation', 'always'); + const submit = ui.options?.onSubmit as (text: string) => Promise; + + try { + await submit('refactor my waybar config'); + expect(runAgentTask).toHaveBeenCalledTimes(1); + await submit('proceed'); + expect(executeAgentTask).toHaveBeenCalledTimes(1); + expect(titles()).toEqual([]); + + // The stored 'always' once nothing is pinned, as a control. + pins.pinAgentConfirmation(null); + await submit('refactor my waybar config'); + await submit('proceed'); + expect(titles()).toEqual(['⚠️ Confirm Agent Task']); + expect(executeAgentTask).toHaveBeenCalledTimes(1); + } finally { + config.config.set('agentConfirmation', before); + } + }); +}); + +describe('a command line that cannot mean what it says', () => { + it('exits with the reason instead of starting without the prompt', async () => { + const errors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.join(' ')); }); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { throw new Error(`exit ${code}`); }) as never); + + await expect(launch('--yolo', '-p')).rejects.toThrow('exit 1'); + expect(errors.join('\n')).toContain('-p needs a value'); + expect(ui.options).toBeNull(); // the TUI never started + }); +}); diff --git a/src/renderer/main.ts b/src/renderer/main.ts index c30711b..e3fe2dd 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -59,6 +59,9 @@ import { } from './agentExecution'; import type { McpServer } from '../acp/protocol'; import { symlinkedCodeepNotice } from '../utils/projectPaths'; +import { parseLaunchArgs } from './cliArgs'; +import { agentConfirmationMode, pinAgentConfirmation, pinAgentInteractive, isAgentConfirmationPinned } from './agentConfirmation'; +import { followOmarchyTheme, type OmarchyThemeWatch } from './omarchyTheme'; // ─── Global state ───────────────────────────────────────────────────────────── @@ -200,6 +203,8 @@ export function startTrustedWorkspaceMcp( let isAgentRunningFlag = false; /** Set once the phone is allowed to send instructions; null when it is not. */ let telegramInbox: { stop: () => void; drain: () => void } | null = null; +/** Following the Omarchy desktop theme; null when this is not Omarchy. */ +let omarchyTheme: OmarchyThemeWatch | null = null; let agentAbortController: AbortController | null = null; let pendingInteractiveContext: PendingInteractiveContext | null = null; @@ -299,7 +304,7 @@ async function handleSubmit(message: string): Promise { const dryRun = pendingInteractiveContext.dryRun; pendingInteractiveContext = null; - const confirmationMode = config.get('agentConfirmation') || 'dangerous'; + const confirmationMode = agentConfirmationMode(); if (confirmationMode === 'never' || dryRun) { executeAgentTask(enhancedTask, dryRun, ctx); return; @@ -466,6 +471,22 @@ async function handleSubmit(message: string): Promise { } } +/** + * Submit `text` as if it had been typed into the input box: in the transcript + * as the user's message, with the loading state on, and a failure shown + * rather than swallowed. App.submitInput does the same for the input box; the + * phone and a launch prompt come through here, so neither is a different, + * quieter path into the same run. + */ +function submitAsTyped(text: string): void { + app.addMessage({ role: 'user', content: text }); + app.setLoading(true); + void handleSubmit(text).catch(err => { + app.notify(`Error: ${err.message}`); + app.setLoading(false); + }); +} + // ─── Command bridge ─────────────────────────────────────────────────────────── async function handleCommand(command: string, args: string[]): Promise { @@ -602,31 +623,36 @@ function showSessionPickerInline(): void { /** The CLI entry point. Exported so tests can drive a command without the * module starting the app on import. */ export async function main(): Promise { - const args = process.argv.slice(2); - - // Headless, deterministic code review for CI (no API key, no TUI). Handled - // before the global --help/--version checks so `codeep review --help` shows - // the review usage rather than the top-level help. - if (args[0] === 'review') { + // Which of these runs is decided in one pure place (cliArgs.ts), so a + // prompt that happens to say `review` or start with `-v` stays a prompt. + const launch = parseLaunchArgs(process.argv.slice(2)); + + // Headless, deterministic code review for CI (no API key, no TUI). The + // parser takes it before the global --help/--version checks so + // `codeep review --help` shows the review usage rather than the top-level help. + if (launch.kind === 'review') { const { runHeadlessReview } = await import('../utils/headlessReview.js'); - process.exit(await runHeadlessReview(args.slice(1))); + process.exit(await runHeadlessReview(launch.args)); } - if (args[0] === 'hook') { + if (launch.kind === 'hook') { const { runHookCommand } = await import('../utils/gitHookInstaller.js'); - process.exit(runHookCommand(args.slice(1))); + process.exit(runHookCommand(launch.args)); } - if (args.includes('--version') || args.includes('-v')) { + if (launch.kind === 'version') { console.log(`Codeep v${getCurrentVersion()}`); process.exit(0); } - if (args.includes('--help') || args.includes('-h')) { + if (launch.kind === 'help') { console.log(` Codeep - AI-powered coding assistant TUI Usage: codeep Start interactive chat + codeep -- Start a new session and send as its first message + codeep -p The same, with the prompt as one argument (also --prompt) + codeep --yolo Start without stopping to ask, for this launch only (see below) codeep account Link CLI to your codeep.dev dashboard codeep account sync Pull personalities + commands + profile (+ keys if cloud key sync is on) codeep account push Push personalities + commands + profile (+ keys if cloud key sync is on) @@ -638,6 +664,16 @@ Usage: codeep --version Show version codeep --help Show this help +With --yolo (e.g. codeep --yolo -- ): + Agent actions run without asking (Agent Confirmation: Never) and without + clarifying questions first (Agent Interactive Mode: Off), the folder is + used as the project with read & write access, and a new session starts. + Nothing is saved: the next plain \`codeep\` asks as before, and changing + either setting in /settings ends --yolo's hold on it. Writes to files that + decide what runs later (.git/config, git hooks, MCP server lists) are still + confirmed, and a workspace's own MCP servers still ask to be trusted the + first time, before the prompt is sent. + Commands (in chat): /help Show all available commands /status Show current status @@ -648,8 +684,8 @@ Commands (in chat): } // Account / dashboard link flow - if (args[0] === 'account') { - const sub = args[1]; + if (launch.kind === 'account') { + const sub = launch.args[0]; if (sub === 'sync' || sub === 'pull') { const { getSyncToken, setApiKey, loadAllApiKeys: loadKeys, isKeySyncEnabled } = await import('../config/index.js'); @@ -810,13 +846,35 @@ Commands (in chat): } // ACP server mode — started by Zed via Agent Client Protocol - if (args[0] === 'acp') { + if (launch.kind === 'acp') { await loadAllApiKeys(); const { startAcpServer } = await import('../acp/server.js'); await startAcpServer(); return; } + if (launch.kind === 'error') { + console.error(`${launch.message}\nRun codeep --help for usage.`); + process.exit(1); + } + + // Pinned before anything can read it, and pinned rather than written to the + // config: see agentConfirmation.ts for why --yolo must never outlive this + // process. Clarifying questions are pinned off with the confirmations: a + // run that stops to ask what was meant is stopped all the same. + if (launch.yolo) { + pinAgentConfirmation('never'); + pinAgentInteractive(false); + } + + // On Omarchy, paint in the desktop theme's colours and follow it when it + // changes (omarchyTheme.ts). Only here, where the TUI starts: acp, review, + // hook and account have all returned by now, and a server or a CI run has + // no business holding a watch on the desktop's theme. Before the login + // screen, so a first run is in the theme from its first frame. + omarchyTheme = followOmarchyTheme({ enabled: () => config.get('followOmarchyTheme') !== false }); + if (omarchyTheme) process.on('exit', () => omarchyTheme?.stop()); + await loadAllApiKeys(); // Re-announce this device to the dashboard. Cheap, fire-and-forget, and it // repairs a machine whose one registration at link time happened to fail — @@ -836,9 +894,22 @@ Commands (in chat): const isProject = isProjectDirectory(projectPath); const hasRead = hasReadPermission(projectPath); - const needsPermissionDialog = !hasRead; - - if (hasRead) { + // --yolo answers the access dialog itself: there is nobody at the terminal + // to answer it, and a launcher started from $HOME lands in a folder (~/Work) + // that is usually not a repository and would also ask "Set as Project?". + const needsPermissionDialog = !hasRead && !launch.yolo; + + if (launch.yolo) { + // Read & write for this process only. Nothing is saved — no + // setProjectPermission, no .codeep/project.json marker — so the next + // plain `codeep` here asks exactly as it would have. + hasWriteAccess = true; + projectContext = getProjectContext(projectPath); + if (projectContext) { + projectContext.hasWriteAccess = true; + setProjectContext(projectContext); + } + } else if (hasRead) { hasWriteAccess = hasWritePermission(projectPath); projectContext = getProjectContext(projectPath); if (projectContext) { @@ -877,6 +948,7 @@ Commands (in chat): hasWriteAccess: () => hasWriteAccess, hasProjectContext: () => projectContext !== null, getProjectRoot: () => projectContext?.root || projectPath || process.cwd(), + yolo: isAgentConfirmationPinned, }); const provider = getCurrentProvider(); @@ -899,6 +971,10 @@ Commands (in chat): welcomeLines.push(''); welcomeLines.push(' ⚠ Agent Mode ON — messages auto-execute as agent tasks'); } + if (launch.yolo) { + welcomeLines.push(''); + welcomeLines.push(' ⚠ YOLO — agent actions run without asking, for this launch only'); + } welcomeLines.push(''); const githubId = getGithubId(); welcomeLines.push(githubId @@ -969,16 +1045,11 @@ Commands (in chat): telegramInbox = attachTelegramInbox(credentials, { isBusy: () => isAgentRunningFlag, submit: (text) => { - // Through the same door the input box uses, and shown in the transcript - // the same way — a run started from the phone must not be invisible to - // whoever is sitting at the terminal. - app.addMessage({ role: 'user', content: text }); + // Shown in the transcript like a typed message — a run started from + // the phone must not be invisible to whoever is sitting at the terminal. + // The notice goes first so a notice the run itself raises wins. app.notify('Telegram: running an instruction from your phone'); - app.setLoading(true); - void handleSubmit(text).catch(err => { - app.notify(`Error: ${err.message}`); - app.setLoading(false); - }); + submitAsTyped(text); }, reply: (text) => { void sendTelegramNotice(credentials, text); }, }); @@ -992,12 +1063,24 @@ Commands (in chat): }); })(); + // Startup asks one question at a time. App.showConfirm replaces an open + // confirm without answering it: the trust question below, raised while + // "Set as Project?" is up, would take that question's place, and a launch + // prompt's own "Confirm Agent Task", raised while the trust question is up, + // would take the trust question's — with neither one's callbacks ever run, + // so startup stalls or the prompt is dropped. The trust question waits for + // the startup questions (finishStartup settles `startupAnswered`), and a + // launch prompt waits for the MCP step. + let settleStartupAnswered = () => {}; + const startupAnswered = new Promise(resolve => { settleStartupAnswered = resolve; }); + let mcpStartup: Promise = Promise.resolve(); + // Spawn MCP servers in the background. They register against the fixed // session id `codeep-tui` that runAgentTask passes into runAgent's // `mcpSessionId` — so the agent picks up `.codeep/mcp_servers.json` // entries (project + global) the same way an ACP client would. if (projectPath) { - (async () => { + mcpStartup = (async () => { try { const { trustWorkspaceMcp } = await import('../utils/mcpConfig'); const workspaceServers = await startTuiMcpServers(projectPath, app); @@ -1010,7 +1093,8 @@ Commands (in chat): const preview = workspaceServers.slice(0, 5).map(s => ` ${s.name}: ${s.command ? [s.command, ...(s.args ?? [])].join(' ') : s.url ?? ''}`); if (workspaceServers.length > 5) preview.push(` …and ${workspaceServers.length - 5} more`); - app.showConfirm({ + await startupAnswered; + await new Promise(resolve => app.showConfirm({ title: 'Trust workspace MCP servers?', message: [ `This workspace defines ${workspaceServers.length} MCP server(s) that run as local processes:`, @@ -1022,12 +1106,14 @@ Commands (in chat): cancelLabel: 'Not now', onConfirm: () => { trustWorkspaceMcp(projectPath); - void startTrustedWorkspaceMcp(projectPath, app); + // Waited for, so a launch prompt sent next has their tools. + void startTrustedWorkspaceMcp(projectPath, app).then(resolve); }, onCancel: () => { app.notify('Workspace MCP servers skipped. Run /mcp trust to enable them.'); + resolve(); }, - }); + })); } catch { // Loading MCP must never block the TUI. } @@ -1095,6 +1181,24 @@ Commands (in chat): const showIntroAnimation = process.stdout.rows >= 20; + // The last step of startup, after whichever gates still ran. A launch + // prompt means a new conversation: offering yesterday's session first and + // then sending today's prompt into whatever was picked is not what was + // asked, and --yolo has nobody at the terminal to pick at all. + const finishStartup = () => { + settleStartupAnswered(); + if (launch.prompt === null && !launch.yolo) { + showSessionPickerInline(); + return; + } + sessionId = startNewSession(); + // After the MCP step, whose trust question the run's own confirmation + // would otherwise replace unanswered (see startupAnswered) — and so the + // run starts with the MCP tools registered rather than racing them. + const prompt = launch.prompt; + if (prompt !== null) void mcpStartup.then(() => submitAsTyped(prompt)); + }; + const showPermissionAndContinue = () => { app.showPermission(projectPath, isProject, (permission) => { if (permission === 'read') { @@ -1112,7 +1216,7 @@ Commands (in chat): } else { app.notify('No project access - chat only mode'); } - showSessionPickerInline(); + finishStartup(); }); }; @@ -1130,12 +1234,12 @@ Commands (in chat): confirmLabel: 'Yes, set as project', cancelLabel: 'No, chat only', onConfirm: () => { initializeAsProject(projectPath); app.notify('Folder initialized as project'); showPermissionAndContinue(); }, - onCancel: () => { app.notify('Chat only mode - no project context'); showSessionPickerInline(); }, + onCancel: () => { app.notify('Chat only mode - no project context'); finishStartup(); }, }); } else if (needsPermissionDialog) { showPermissionAndContinue(); } else { - showSessionPickerInline(); + finishStartup(); } }; @@ -1160,6 +1264,10 @@ async function gracefulShutdown() { }); } + // Stop following the theme before the App goes: nothing is left to + // recolour. + omarchyTheme?.stop(); + // Now restore terminal after agent has fully stopped if (app) app.stop(); process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); diff --git a/src/renderer/omarchyTheme.test.ts b/src/renderer/omarchyTheme.test.ts new file mode 100644 index 0000000..ae7e1b7 --- /dev/null +++ b/src/renderer/omarchyTheme.test.ts @@ -0,0 +1,499 @@ +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + applyOmarchyTheme, + contrastRatio, + followOmarchyTheme, + greyContrast, + isOmarchy, + loadOmarchyPalette, + mix, + omarchyStateDir, + paletteFromOmarchy, + parseColorsToml, + parseHexColor, + reapplyOmarchyTheme, + type OmarchyThemeWatch, +} from './omarchyTheme'; +import { DEFAULT_PALETTE, TERMINAL_DEFAULTS, getPalette, palette, resetPalette, type PaletteRole, type Rgb } from './palette'; + +/** A dark theme whose colours all read on its background, so the mapping is + * visible without the legibility nudge in the way. */ +const DARK = ` +mode = "dark" +accent = "#7aa2f7" +background = "#101010" +foreground = "#e0e0e0" +dark_foreground = "#707070" +red = "#f7768e" +yellow = "#e0af68" +orange = "#ff9e64" +green = "#9ece6a" +cyan = "#449dab" +blue = "#7aa2f7" +magenta = "#ad8ee6" +`; + +/** catppuccin-latte's palette: a light theme with yellow, magenta and + * dark_foreground all near 2.3:1 on its background. */ +const LIGHT = ` +mode = "light" +accent = "#1e66f5" +background = "#eff1f5" +foreground = "#4c4f69" +dark_foreground = "#9ca0b0" +red = "#d20f39" +yellow = "#df8e1d" +orange = "#d84e2b" +green = "#40a02b" +cyan = "#179299" +blue = "#1e66f5" +magenta = "#ea76cb" +`; + +const hex = (h: string): Rgb => parseHexColor(h)!; +/** The roles that are text someone reads, as opposed to separators and frames. */ +const TEXT_ROLES = (Object.keys(DEFAULT_PALETTE) as PaletteRole[]) + .filter(r => r !== 'separator' && r !== 'codeFrame' && r !== 'label'); +/** The text read as sentences rather than as a word or a token. */ +const SENTENCE_ROLES = ['hint', 'secondaryText', 'warning', 'warningToast', 'attention'] as const; +/** The greys, in the order they get brighter in the default palette. */ +const GREYS: PaletteRole[] = [ + 'separator', 'label', 'assistantLabel', 'modelName', 'strikethrough', 'hint', 'secondaryText', 'providerName', +]; +const ALL_ROLES = [...Object.keys(DEFAULT_PALETTE), ...Object.keys(TERMINAL_DEFAULTS)].sort(); + +afterEach(() => { + resetPalette(); +}); + +describe('parseColorsToml', () => { + it('reads key = "value" lines', () => { + expect(parseColorsToml('accent = "#7aa2f7"\nbackground = "#1a1b26"')).toEqual({ + accent: '#7aa2f7', + background: '#1a1b26', + }); + }); + + it('skips comments and blank lines, and ignores a comment after the value', () => { + const text = '# Tokyo Night\n\n # indented comment\naccent = "#7aa2f7" # the blue\n'; + expect(parseColorsToml(text)).toEqual({ accent: '#7aa2f7' }); + }); + + it('takes any spacing, single quotes, CRLF line ends and a quoted key', () => { + const text = "accent=\"#111111\"\r\n red = '#222222' \r\n\"green\" = \"#333333\"\r\n"; + expect(parseColorsToml(text)).toEqual({ accent: '#111111', red: '#222222', green: '#333333' }); + }); + + it('takes a bare value up to the first space, as Omarchy\'s own reader does', () => { + expect(parseColorsToml('mode = light\naccent = #123456 # bare')).toEqual({ mode: 'light', accent: '#123456' }); + }); + + it('ignores tables, arrays, inline tables and lines that are not key = value', () => { + const text = [ + '[colors]', + 'accent = "#7aa2f7"', + 'stops = ["#000000", "#ffffff"]', + 'nested = { a = "#000000" }', + 'no equals sign here', + '= "#000000"', + 'bad key! = "#000000"', + 'unterminated = "#00', + 'empty =', + ].join('\n'); + expect(parseColorsToml(text)).toEqual({ accent: '#7aa2f7' }); + }); + + it('never throws, whatever the file holds', () => { + const garbage = Buffer.from(Array.from({ length: 512 }, (_, i) => (i * 37) % 256)).toString('latin1'); + expect(() => parseColorsToml(garbage)).not.toThrow(); + expect(() => parseColorsToml('')).not.toThrow(); + }); +}); + +describe('parseHexColor', () => { + it('reads #rrggbb in either case, and #rgb', () => { + expect(parseHexColor('#7aa2f7')).toEqual([122, 162, 247]); + expect(parseHexColor('#FFFCF0')).toEqual([255, 252, 240]); + expect(parseHexColor('#0af')).toEqual([0, 170, 255]); + }); + + it('refuses anything else', () => { + for (const value of ['7aa2f7', '#7aa2f', '#7aa2f7ff', 'rgb(1,2,3)', 'blue', '', undefined]) { + expect(parseHexColor(value)).toBeNull(); + } + }); +}); + +describe('paletteFromOmarchy — a dark theme', () => { + const roles = paletteFromOmarchy(parseColorsToml(DARK)); + const bg = hex('#101010'); + + it('takes the brand colour from accent, and its bright variant 35% toward the foreground', () => { + expect(roles.primary).toEqual(hex('#7aa2f7')); + expect(roles.primaryBright).toEqual([158, 184, 239]); + }); + + it('mixes the greys from background toward foreground, faintest first', () => { + // On a near-black background with a light grey foreground they land + // next to Codeep's own greys, which were chosen for exactly that. + expect(roles.separator).toEqual([76, 76, 76]); + expect(roles.label).toEqual([94, 94, 94]); + expect(roles.assistantLabel).toEqual([115, 115, 115]); + expect(roles.hint).toEqual([147, 147, 147]); + expect(roles.providerName).toEqual([192, 192, 192]); + }); + + it('paints code punctuation a readable grey instead of bright black', () => { + // Pi's syntaxPunctuation is its muted text, the hint grey. + expect(roles.syntaxPunctuation).toEqual([153, 153, 153]); + expect(contrastRatio(roles.syntaxPunctuation!, bg)).toBeGreaterThanOrEqual(3); + }); + + it('paints code the way Omarchy\'s other templates do', () => { + expect(roles.syntaxKeyword).toEqual(hex('#ad8ee6')); + expect(roles.syntaxString).toEqual(hex('#9ece6a')); + expect(roles.syntaxNumber).toEqual(hex('#ff9e64')); + expect(roles.syntaxComment).toEqual(hex('#707070')); + expect(roles.syntaxFunction).toEqual(hex('#7aa2f7')); + expect(roles.syntaxType).toEqual(hex('#e0af68')); + expect(roles.syntaxOperator).toEqual(hex('#449dab')); + expect(roles.syntaxRemoved).toEqual(hex('#f7768e')); + }); + + it('colours the welcome banner and markdown from the same keys', () => { + expect(roles.path).toEqual(hex('#449dab')); + expect(roles.success).toEqual(hex('#9ece6a')); + expect(roles.successDetail).toEqual(mix(hex('#9ece6a'), bg, 0.25)); + expect(roles.warning).toEqual(hex('#e0af68')); + expect(roles.inlineCode).toEqual(hex('#ff9e64')); + expect(roles.heading).toEqual(hex('#7aa2f7')); + expect(roles.subheading).toEqual(hex('#ad8ee6')); + }); + + it('takes the toast, the confirm modal and the picker prompts from yellow', () => { + expect(roles.warningToast).toEqual(hex('#e0af68')); + expect(roles.attention).toEqual(hex('#e0af68')); + }); + + it('fills the YOLO badge with yellow, under whichever text reads best on it', () => { + expect(roles.yoloBadge).toEqual(hex('#e0af68')); + // Black beats the theme's near-black background and its light foreground. + expect(roles.yoloBadgeText).toEqual([0, 0, 0]); + }); + + it('defines every role', () => { + expect(Object.keys(roles).sort()).toEqual(ALL_ROLES); + }); +}); + +describe('greyContrast', () => { + it('gives a theme like the terminal the greys were picked on each default back', () => { + const roles = paletteFromOmarchy({ background: '#1e1e1e', foreground: '#cccccc' }); + for (const role of GREYS) expect(roles[role], role).toEqual(DEFAULT_PALETTE[role]); + }); + + it('keeps a grey at its floor or above, and never past the body text', () => { + const hint = DEFAULT_PALETTE.hint; + expect(greyContrast(hint, 4.5, 6.66)).toBeGreaterThanOrEqual(4.5); + expect(greyContrast(hint, 4.5, 6.66)).toBeLessThan(6.66); + // A theme without the contrast for the floor gets its foreground. + expect(greyContrast(hint, 4.5, 3.2)).toBe(3.2); + }); +}); + +describe('paletteFromOmarchy — a light theme', () => { + const roles = paletteFromOmarchy(parseColorsToml(LIGHT)); + const bg = hex('#eff1f5'); + + it('keeps every text role readable on the light background', () => { + for (const role of TEXT_ROLES) { + expect(contrastRatio(roles[role]!, bg), role).toBeGreaterThanOrEqual(3); + } + expect(contrastRatio(roles.label!, bg)).toBeGreaterThanOrEqual(2.5); + expect(contrastRatio(roles.syntaxPunctuation!, bg)).toBeGreaterThanOrEqual(3); + }); + + it('holds what is read as a sentence to 4.5:1', () => { + for (const role of SENTENCE_ROLES) { + expect(contrastRatio(roles[role]!, bg), role).toBeGreaterThanOrEqual(4.5); + } + }); + + it('keeps the greys in order and apart, rather than pushing several onto one floor', () => { + // A fixed mix share reads fainter on a light background, and 40–60% of + // the way to catppuccin-latte's foreground all fall under 3:1. + const contrasts = GREYS.map(role => contrastRatio(roles[role]!, bg)); + for (let i = 1; i < contrasts.length; i++) { + expect(contrasts[i], GREYS[i]).toBeGreaterThan(contrasts[i - 1] + 0.2); + } + }); + + it('puts black, not the near-white background, on the YOLO badge', () => { + expect(roles.yoloBadge).toEqual(hex('#df8e1d')); + expect(roles.yoloBadgeText).toEqual([0, 0, 0]); + expect(contrastRatio(roles.yoloBadgeText!, roles.yoloBadge!)).toBeGreaterThanOrEqual(4.5); + }); + + it('darkens the greys rather than reusing the dark-terminal ones', () => { + // rgb(180,180,180) on near-white is 1.9:1; the provider name must not be. + expect(roles.providerName).not.toEqual(DEFAULT_PALETTE.providerName); + expect(contrastRatio(roles.providerName!, bg)).toBeGreaterThan(contrastRatio(DEFAULT_PALETTE.providerName, bg)); + }); + + it('moves a colour too faint to read toward the foreground, and leaves one that reads alone', () => { + const yellow = hex('#df8e1d'); + expect(contrastRatio(yellow, bg)).toBeLessThan(3); + expect(roles.syntaxType).not.toEqual(yellow); + expect(contrastRatio(roles.syntaxType!, bg)).toBeGreaterThanOrEqual(3); + expect(roles.syntaxFunction).toEqual(hex('#1e66f5')); + expect(roles.syntaxRemoved).toEqual(hex('#d20f39')); + }); +}); + +describe('paletteFromOmarchy — odd themes', () => { + it('moves the brand colour to blue when the accent is the foreground (kanagawa)', () => { + // Otherwise the selected row of every menu is the colour of the others. + const roles = paletteFromOmarchy({ accent: '#dcd7ba', foreground: '#dcd7ba', background: '#1f1f28', blue: '#7e9cd8' }); + expect(roles.primary).toEqual(hex('#7e9cd8')); + expect(roles.primaryBright).toEqual(mix(hex('#7e9cd8'), hex('#dcd7ba'), 0.35)); + }); + + it('keeps an accent that only shares the foreground\'s brightness, as Claude Code does', () => { + // tokyo-night: blue against grey-blue, apart by hue if not by contrast. + const roles = paletteFromOmarchy({ accent: '#7aa2f7', foreground: '#a9b1d6', background: '#1a1b26', blue: '#2ac3de' }); + expect(roles.primary).toEqual(hex('#7aa2f7')); + expect(roles.primaryBright).toEqual(mix(hex('#7aa2f7'), hex('#a9b1d6'), 0.35)); + }); + + it('writes the YOLO badge in white on a dark "yellow" (matte-black\'s is red)', () => { + const roles = paletteFromOmarchy({ background: '#121212', foreground: '#bebebe', yellow: '#b91c1c' }); + expect(roles.yoloBadgeText).toEqual([255, 255, 255]); + }); +}); + +describe('paletteFromOmarchy — incomplete themes', () => { + it('leaves out every role whose key is missing, so it keeps its default', () => { + expect(paletteFromOmarchy({ accent: '#123456' })).toEqual({ + primary: [0x12, 0x34, 0x56], + primaryBright: [0x12, 0x34, 0x56], + }); + }); + + it('cannot mix greys without both background and foreground', () => { + const roles = paletteFromOmarchy({ background: '#000000', green: '#00ff00' }); + expect(roles.separator).toBeUndefined(); + expect(roles.syntaxString).toEqual([0, 255, 0]); + }); + + it('ignores a key whose value is not a hex colour', () => { + const roles = paletteFromOmarchy({ accent: 'rgb(1,2,3)', red: 'red', blue: '#0000ff' }); + expect(roles.primary).toBeUndefined(); + expect(roles.syntaxRemoved).toBeUndefined(); + expect(roles.syntaxFunction).toEqual([0, 0, 255]); + }); + + it('falls back to yellow for orange, as Omarchy does', () => { + const roles = paletteFromOmarchy({ yellow: '#e0af68' }); + expect(roles.syntaxNumber).toEqual(hex('#e0af68')); + expect(roles.inlineCode).toEqual(hex('#e0af68')); + }); + + it('reads the legacy short names Omarchy still accepts', () => { + const roles = paletteFromOmarchy({ bg: '#000000', fg: '#ffffff', color1: '#ff0000', purple: '#aa00ff', color8: '#888888' }); + expect(roles.syntaxRemoved).toEqual([255, 0, 0]); + expect(roles.syntaxKeyword).toEqual([170, 0, 255]); + expect(roles.syntaxComment).toEqual([136, 136, 136]); + expect(roles.separator).toEqual(paletteFromOmarchy({ background: '#000000', foreground: '#ffffff' }).separator); + }); +}); + +// ─── Files on disk ─────────────────────────────────────────────────────────── + +let root: string; +let stateDir: string; +let watch: OmarchyThemeWatch | null = null; + +function writeTheme(dir: string, toml: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'colors.toml'), toml); +} + +/** What `omarchy-theme-set` does: stage beside, delete, move into place. */ +function switchTheme(toml: string): void { + const next = join(stateDir, 'next-theme'); + rmSync(next, { recursive: true, force: true }); + writeTheme(next, toml); + rmSync(join(stateDir, 'theme'), { recursive: true, force: true }); + renameSync(next, join(stateDir, 'theme')); + writeFileSync(join(stateDir, 'theme.name'), 'next\n'); +} + +async function waitFor(condition: () => boolean, ms = 3000): Promise { + const until = Date.now() + ms; + while (!condition()) { + if (Date.now() > until) throw new Error('timed out waiting for the palette'); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +const DARK_ACCENT = '\x1b[38;2;122;162;247m'; +const LIGHT_ACCENT = '\x1b[38;2;30;102;245m'; +const DEFAULT_PRIMARY = '\x1b[38;2;240;42;48m'; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'codeep-omarchy-')); + stateDir = join(root, 'current'); + writeTheme(join(stateDir, 'theme'), DARK); +}); + +afterEach(() => { + watch?.stop(); + watch = null; + rmSync(root, { recursive: true, force: true }); +}); + +describe('loadOmarchyPalette', () => { + it('reads current/theme/colors.toml', () => { + expect(loadOmarchyPalette(stateDir)?.primary).toEqual(hex('#7aa2f7')); + }); + + it('is null when there is no colors.toml', () => { + rmSync(join(stateDir, 'theme', 'colors.toml')); + expect(loadOmarchyPalette(stateDir)).toBeNull(); + expect(loadOmarchyPalette(join(root, 'nowhere'))).toBeNull(); + }); + + it('is null when colors.toml holds nothing it can use', () => { + writeFileSync(join(stateDir, 'theme', 'colors.toml'), '\u0000\u0001 not toml [[[ = = =\naccent = "blue"\n'); + expect(loadOmarchyPalette(stateDir)).toBeNull(); + }); + + it('puts Codeep\'s own colours back for a missing or malformed file', () => { + applyOmarchyTheme(stateDir, true); + expect(palette.primary).toBe(DARK_ACCENT); + writeFileSync(join(stateDir, 'theme', 'colors.toml'), 'garbage'); + applyOmarchyTheme(stateDir, true); + expect(getPalette()).toEqual(DEFAULT_PALETTE); + }); + + it('finds the state directory where omarchy-theme-set writes it', () => { + expect(omarchyStateDir('/home/u')).toBe(join('/home/u', '.local', 'state', 'omarchy', 'current')); + }); +}); + +/** + * How many listeners each signal the config store's exit hook (when-exit) + * also catches has. A handler of Codeep's own that kept the process alive + * would leave that hook spent, and SIGTERM and SIGHUP would then no longer + * end the session. + */ +const SIGNALS = ['SIGUSR2', 'SIGTERM', 'SIGHUP', 'SIGINT'] as const; +const signalListeners = () => SIGNALS.map(signal => process.listenerCount(signal)); + +describe('followOmarchyTheme off Linux', () => { + it('does nothing', () => { + expect(followOmarchyTheme({ stateDir, platform: 'darwin' })).toBeNull(); + expect(palette.primary).toBe(DEFAULT_PRIMARY); + expect(isOmarchy('darwin', stateDir)).toBe(false); + }); +}); + +// Omarchy is Linux, and these exercise what Codeep watches it with there +// (inotify through fs.watch); other platforms' fs.watch reports a directory +// being replaced differently. The parsing and mapping above run everywhere. +describe.skipIf(process.platform !== 'linux')('followOmarchyTheme', () => { + it('does nothing when there is no Omarchy state directory', () => { + expect(followOmarchyTheme({ stateDir: join(root, 'absent'), platform: 'linux' })).toBeNull(); + expect(isOmarchy('linux', join(root, 'absent'))).toBe(false); + }); + + it('applies the current theme as soon as it starts', () => { + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 }); + expect(watch).not.toBeNull(); + expect(palette.primary).toBe(DARK_ACCENT); + }); + + it('handles no signal, so SIGTERM and SIGHUP still end the session', () => { + const before = signalListeners(); + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 }); + expect(signalListeners()).toEqual(before); + }); + + it('follows a switch, which replaces the theme directory, and the one after it', async () => { + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 }); + switchTheme(LIGHT); + await waitFor(() => palette.primary === LIGHT_ACCENT); + expect(palette.syntaxFunction).toBe(LIGHT_ACCENT); + // The watch on current/ outlives a switch, so it sees this one too. + switchTheme(DARK); + await waitFor(() => palette.primary === DARK_ACCENT); + }); + + it('follows an edit to colors.toml in place', async () => { + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 }); + writeFileSync(join(stateDir, 'theme', 'colors.toml'), LIGHT); + await waitFor(() => palette.primary === LIGHT_ACCENT); + }); + + it('follows an edit in place after a switch, so the theme/ watch was made again on the new directory', async () => { + // current/ does not see a file inside theme/ change; only a watch on the + // theme/ that the switch moved into place does. + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 }); + switchTheme(LIGHT); + await waitFor(() => palette.primary === LIGHT_ACCENT); + writeFileSync(join(stateDir, 'theme', 'colors.toml'), DARK); + await waitFor(() => palette.primary === DARK_ACCENT); + }); + + it('survives the state directory disappearing, and picks it up again by itself when it is back', async () => { + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 }); + rmSync(stateDir, { recursive: true, force: true }); + await waitFor(() => palette.primary === DEFAULT_PRIMARY); + + // Seen from the directory above, which is watched while current/ is gone. + writeTheme(join(stateDir, 'theme'), LIGHT); + await waitFor(() => palette.primary === LIGHT_ACCENT); + // Re-armed on the new directory: the next switch is seen too. + switchTheme(DARK); + await waitFor(() => palette.primary === DARK_ACCENT); + }); + + it('picks it up again when the directories above it went too', async () => { + const deep = join(root, 'omarchy', 'current'); + writeTheme(join(deep, 'theme'), DARK); + watch = followOmarchyTheme({ stateDir: deep, platform: 'linux', debounceMs: 20 }); + rmSync(join(root, 'omarchy'), { recursive: true, force: true }); + await waitFor(() => palette.primary === DEFAULT_PRIMARY); + + mkdirSync(join(root, 'omarchy')); + await new Promise(resolve => setTimeout(resolve, 100)); + writeTheme(join(deep, 'theme'), LIGHT); + await waitFor(() => palette.primary === LIGHT_ACCENT); + }); + + it('keeps Codeep\'s own colours while the setting is off, and applies the theme when it is turned on', () => { + let follow = false; + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20, enabled: () => follow }); + expect(getPalette()).toEqual(DEFAULT_PALETTE); + follow = true; + reapplyOmarchyTheme(); + expect(palette.primary).toBe(DARK_ACCENT); + follow = false; + reapplyOmarchyTheme(); + expect(getPalette()).toEqual(DEFAULT_PALETTE); + }); + + it('stop() closes the watches', async () => { + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 })!; + watch.stop(); + + switchTheme(LIGHT); + await new Promise(resolve => setTimeout(resolve, 200)); + expect(palette.primary).toBe(DARK_ACCENT); + reapplyOmarchyTheme(); // nothing running: a no-op, not a throw + expect(palette.primary).toBe(DARK_ACCENT); + }); +}); diff --git a/src/renderer/omarchyTheme.ts b/src/renderer/omarchyTheme.ts new file mode 100644 index 0000000..4e60872 --- /dev/null +++ b/src/renderer/omarchyTheme.ts @@ -0,0 +1,533 @@ +/** + * Follow the Omarchy desktop theme. + * + * Omarchy (Arch + Hyprland) switches every app's colours at once with + * `omarchy-theme-set`. The terminal is one of them, so Codeep's basic ANSI + * colours already follow; what does not is everything Codeep paints in + * truecolour — the brand red, the greys of the chat chrome, the One Dark code + * colours. This module reads the theme Omarchy has selected and hands those + * roles to the palette, and keeps doing it while Codeep runs, so switching + * the theme recolours an open session the way it does Claude Code, OpenCode, + * Pi and Hermes. + * + * Only ever READS Omarchy's state. Omarchy also runs user hooks from + * ~/.config/omarchy/hooks/theme-set.d/, but writing one would mean editing + * the user's desktop config to suit one CLI; watching the directory needs + * nothing from them. + * + * Where the theme lives — `~/.local/state/omarchy/current/theme/colors.toml` + * (CURRENT_THEME_PATH in bin/omarchy-theme-set) — and what a switch does to + * it decide how this watches it: the switch stages the new theme beside the + * old one and then `rm -rf current/theme; mv next-theme current/theme`. A + * watch on colors.toml, or on theme/, would be watching an inode that is + * deleted on the first switch. So the watch is on `current/`, which a switch + * never replaces, and the one on `theme/` is re-made after each change. + */ +import { readFileSync, statSync, watch, type FSWatcher } from 'fs'; +import { homedir } from 'os'; +import { dirname, join } from 'path'; +import { DEFAULT_PALETTE, setPalette, type PaletteOverrides, type PaletteRole, type Rgb } from './palette'; + +// ─── colors.toml ───────────────────────────────────────────────────────────── + +/** + * Read the `key = "value"` lines of a colors.toml. + * + * Not a TOML parser, and deliberately so: a colors.toml is a flat list of + * quoted hex strings, and Omarchy's own reader (bin/omarchy-theme-color) is a + * shell `read` loop that understands exactly this much. Comments and blank + * lines are skipped, a value may be single- or double-quoted with a comment + * after it, a bare value is taken up to the first space, and anything else — + * a `[table]`, an array, a line with no `=` — is ignored rather than + * reported. Never throws: a theme Codeep cannot read is a theme it leaves + * alone, not a reason to stop starting. + */ +export function parseColorsToml(text: string): Record { + const values: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim().replace(/^(["'])(.*)\1$/, '$2'); + if (!/^[A-Za-z0-9_-]+$/.test(key)) continue; + const rest = line.slice(eq + 1).trim(); + let value: string | undefined; + const quoted = /^(["'])(.*?)\1/.exec(rest); + if (quoted) value = quoted[2]; + else if (rest && !/^["'[{]/.test(rest)) value = rest.split(/\s/)[0]; + if (value !== undefined) values[key] = value; + } + return values; +} + +/** `#rrggbb` or `#rgb`, either case; null for anything else. */ +export function parseHexColor(value: string | undefined): Rgb | null { + if (!value) return null; + const long = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(value); + if (long) return [parseInt(long[1], 16), parseInt(long[2], 16), parseInt(long[3], 16)]; + const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(value); + if (short) return [parseInt(short[1] + short[1], 16), parseInt(short[2] + short[2], 16), parseInt(short[3] + short[3], 16)]; + return null; +} + +// ─── Colour arithmetic ─────────────────────────────────────────────────────── + +/** + * `{{ mix a b 30% }}` from Omarchy's templates: 70% of `a`, 30% of `b`, + * rounded the way its awk does it, so a role derived here lands on the same + * colour the theme's other apps derived. + */ +export function mix(a: Rgb, b: Rgb, amount: number): Rgb { + const channel = (i: number) => Math.floor(a[i] * (1 - amount) + b[i] * amount + 0.5); + return [channel(0), channel(1), channel(2)]; +} + +/** + * Whether two colours are close enough (under 32 apart in sRGB, an eighth + * of the range) to be taken for one another side by side. + */ +function looksLike(a: Rgb, b: Rgb): boolean { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) < 32; +} + +/** WCAG relative luminance. */ +function luminance(rgb: Rgb): number { + const [r, g, b] = rgb.map(c => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** WCAG contrast ratio, 1 (none) to 21 (black on white). */ +export function contrastRatio(a: Rgb, b: Rgb): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +/** + * The least share of `toward` that, mixed into `from`, reads at + * `minContrast` on `background`: 0 when `from` already does, 1 when even + * `toward` does not. + * + * Omarchy palettes are tuned for their terminals, and a few of their colours + * sit close to their own background when used as TEXT: catppuccin-latte's + * yellow and magenta are 2.3:1 on its near-white, everforest's + * dark_foreground 1.7:1. Codeep paints keywords, types and comments in those, + * so a light theme would otherwise get code it cannot read. Moving toward the + * foreground keeps the hue's family and works the same on dark and light + * themes, and a colour that already reads is not moved at all. The least + * share, found by bisection rather than in fixed steps, so a colour lands on + * the contrast asked of it rather than a step past — which is also what + * places each grey at the contrast greyContrast() picks for it. + */ +function leastMixToRead(from: Rgb, toward: Rgb, background: Rgb, minContrast: number): number { + if (contrastRatio(from, background) >= minContrast) return 0; + if (contrastRatio(toward, background) < minContrast) return 1; + let lo = 0; + let hi = 1; + for (let i = 0; i < 16; i++) { + const mid = (lo + hi) / 2; + if (contrastRatio(mix(from, toward, mid), background) >= minContrast) hi = mid; + else lo = mid; + } + return hi; +} + +/** + * The terminal Codeep's greys were picked on: a stock dark one (VS Code's, + * #cccccc on #1e1e1e). Each grey's default is measured against it to find + * where that grey sits between barely readable and body text. + */ +const REFERENCE_BACKGROUND: Rgb = [30, 30, 30]; +const REFERENCE_FOREGROUND: Rgb = [204, 204, 204]; + +/** + * The contrast a grey should have on a theme whose body text is + * `bodyContrast`: the same place, between `floor` and the body text, that + * `reference` has on the reference terminal — measured in log contrast, + * because that is how far apart two greys look. + * + * A fixed sRGB mix (40% of the way to the foreground, 66%…) was tuned on a + * dark terminal, and on a light one the same share reads much fainter: on + * catppuccin-latte 40–60% all fall under 3:1, so the floor pushed the label, + * the model name and struck-through text onto one grey. Going by contrast + * instead keeps both the greys' order and their spacing on every theme, and + * a theme like the reference terminal gets back each default's own contrast. + * Never past the body text, which a theme with little contrast to spare may + * leave below a floor: there the grey is the foreground. + */ +export function greyContrast(reference: Rgb, floor: number, bodyContrast: number): number { + const referenceBody = contrastRatio(REFERENCE_FOREGROUND, REFERENCE_BACKGROUND); + const place = Math.log(contrastRatio(reference, REFERENCE_BACKGROUND) / floor) / Math.log(referenceBody / floor); + const share = Math.min(1, Math.max(0, place)); + return Math.min(bodyContrast, floor * (bodyContrast / floor) ** share); +} + +// ─── Theme → palette ───────────────────────────────────────────────────────── + +/** + * The palette roles a theme defines, following the conventions Omarchy's own + * templates use for Claude Code and Pi (default/themed/claude.json.tpl, + * pi.json.tpl) so Codeep sits beside them in the same colours: + * + * accent → the brand colour (Claude's `claude`, Pi's `accent`), + * or blue for a theme whose accent is its foreground + * brand + 35% fg → its bright variant (Claude's `claudeShimmer`) + * bg → fg → the greys, each at the contrast greyContrast() gives + * it, code punctuation among them (Pi's + * `syntaxPunctuation` is its muted text, the hint grey) + * magenta, green, orange, blue, yellow, cyan, red, dark_foreground + * → keyword, string, number, function, type, operator, + * removed line, comment + * cyan / green / yellow → the welcome path / access level / warning + * yellow → warning toasts, the confirm modal, the picker + * prompts, and the YOLO badge (Claude's `warning` and + * `autoAccept`) + * + * The greys are mixes of the theme's own background and foreground rather + * than fixed values because Codeep's defaults assume a dark terminal: on a + * light theme a fixed rgb(80,80,80) separator is near-black and the + * hierarchy inverts. A mix keeps its place between background and + * foreground in either mode, which is why `mode` never needs reading. + * + * Every colour that is text has a floor it is moved up to if the theme puts + * it below: 4.5:1 for what is read as sentences (the warning, the shortcut + * hints, blockquotes), 3:1 for code and short tokens, 2.5:1 for the welcome + * labels, 1.5:1 for separators that are only decoration. + * + * A role whose source key is missing or unparseable is left out, so it keeps + * its default. The legacy short names Omarchy still accepts for these keys + * (bg, fg, dark_fg, color0–8, purple) are honoured the way its resolver + * honours them. + */ +export function paletteFromOmarchy(values: Record): PaletteOverrides { + const pick = (...keys: string[]): Rgb | null => { + for (const key of keys) { + const rgb = parseHexColor(values[key]); + if (rgb) return rgb; + } + return null; + }; + const background = pick('background', 'bg', 'color0'); + const foreground = pick('foreground', 'fg', 'color7'); + const accent = pick('accent'); + const red = pick('red', 'color1'); + const green = pick('green', 'color2'); + const yellow = pick('yellow', 'color3'); + const blue = pick('blue', 'color4'); + const magenta = pick('magenta', 'purple', 'color5'); + const cyan = pick('cyan', 'color6'); + // Omarchy's resolver falls back to yellow for a theme with no orange. + const orange = pick('orange') ?? yellow; + const darkForeground = pick('dark_foreground', 'dark_fg', 'color8'); + + const out: PaletteOverrides = {}; + /** A theme colour as text: moved toward the foreground if it would not read. */ + const set = (role: keyof PaletteOverrides, color: Rgb | null, minContrast = 3) => { + if (!color) return; + out[role] = background && foreground + ? mix(color, foreground, leastMixToRead(color, foreground, background, minContrast)) + : color; + }; + /** + * A grey from background toward foreground, at the contrast greyContrast() + * gives a grey whose default is `reference`. + */ + const grey = (role: keyof PaletteOverrides, reference: Rgb, minContrast: number) => { + if (!background || !foreground) return; + const target = greyContrast(reference, minContrast, contrastRatio(foreground, background)); + out[role] = mix(background, foreground, leastMixToRead(background, foreground, background, target)); + }; + + // Kanagawa's accent IS its foreground. Codeep marks the selected row of + // every menu, list and settings screen with its brand colour, so in body + // text colour the selection would be told apart only by bold and the ► + // marker; the brand pair moves to blue (Claude's `permission`) instead. + const brand = accent && foreground && blue && looksLike(accent, foreground) && !looksLike(blue, foreground) + ? blue + : accent; + set('primary', brand); + set('primaryBright', brand && foreground ? mix(brand, foreground, 0.35) : brand); + + const greyAt = (role: PaletteRole, minContrast: number) => grey(role, DEFAULT_PALETTE[role], minContrast); + greyAt('separator', 1.5); + greyAt('codeFrame', 1.5); + greyAt('label', 2.5); + greyAt('assistantLabel', 3); + greyAt('modelName', 3); + greyAt('strikethrough', 3); + greyAt('hint', 4.5); + greyAt('codeLang', 3); + greyAt('secondaryText', 4.5); + greyAt('providerName', 3); + grey('syntaxPunctuation', DEFAULT_PALETTE.hint, 3); + + set('path', cyan); + set('success', green); + set('successDetail', green && background ? mix(green, background, 0.25) : green); + set('warning', yellow, 4.5); + set('inlineCode', orange); + set('heading', blue); + set('subheading', magenta); + + set('syntaxKeyword', magenta); + set('syntaxString', green); + set('syntaxNumber', orange); + set('syntaxComment', darkForeground); + set('syntaxFunction', blue); + set('syntaxType', yellow); + set('syntaxOperator', cyan); + set('syntaxRemoved', red); + + // The terminal-coloured roles: a toast and a prompt are read like the + // welcome warning, so they take its colour, floor and all. + if (out.warning) { + out.warningToast = out.warning; + out.attention = out.warning; + } + // The badge is a fill, so it takes the theme's yellow as it is, and for + // its text whichever of the theme's two colours, black or white reads best + // on it. ANSI black, its text by default, is the theme's background: near- + // white on a light theme, and matte-black's "yellow" is a dark red. + if (yellow) { + const candidates: Rgb[] = [[0, 0, 0], [255, 255, 255]]; + if (background) candidates.unshift(background); + if (foreground) candidates.unshift(foreground); + out.yoloBadge = yellow; + out.yoloBadgeText = candidates.reduce((best, c) => (contrastRatio(c, yellow) > contrastRatio(best, yellow) ? c : best)); + } + return out; +} + +// ─── Reading the current theme ─────────────────────────────────────────────── + +/** `~/.local/state/omarchy/current` — Omarchy's path, which ignores XDG_STATE_HOME. */ +export function omarchyStateDir(home: string = homedir()): string { + return join(home, '.local', 'state', 'omarchy', 'current'); +} + +/** + * The palette the current Omarchy theme asks for, or null when there is no + * readable colors.toml (not Omarchy, a theme without one, a permissions + * problem) — null meaning "Codeep's own colours". + */ +export function loadOmarchyPalette(stateDir: string = omarchyStateDir()): PaletteOverrides | null { + let text: string; + try { + text = readFileSync(join(stateDir, 'theme', 'colors.toml'), 'utf8'); + } catch { + return null; + } + const roles = paletteFromOmarchy(parseColorsToml(text)); + return Object.keys(roles).length > 0 ? roles : null; +} + +/** + * Put the palette where the setting says: the current theme's colours when + * following is on, Codeep's own when it is off. Returns whether anything + * changed (setPalette only repaints then). + */ +export function applyOmarchyTheme(stateDir: string, follow: boolean): boolean { + return setPalette(follow ? loadOmarchyPalette(stateDir) : null); +} + +// ─── Following it live ─────────────────────────────────────────────────────── + +export interface OmarchyThemeOptions { + /** Defaults to omarchyStateDir(); tests point it at a temp directory. */ + stateDir?: string; + /** Whether to follow the theme now — the /settings switch. Asked on every + * reload, so turning it off or on needs no restart. Default: always. */ + enabled?: () => boolean; + /** Defaults to process.platform. */ + platform?: NodeJS.Platform; + /** How long a burst of file events must go quiet before reloading. */ + debounceMs?: number; +} + +export interface OmarchyThemeWatch { + /** Read the theme and the setting again now. */ + reload(): void; + /** Close the watches. */ + stop(): void; +} + +/** The running watch, for reapplyOmarchyTheme(). One per process. */ +let active: OmarchyThemeWatch | null = null; + +/** A watch, and the directory inode it was made on. */ +type DirWatch = { watcher: FSWatcher; dir: string; ino: number }; + +/** + * Apply the current Omarchy theme and follow it until stop(). + * + * Returns null — doing nothing at all — off Linux or when there is no + * Omarchy state directory, so every other machine runs exactly as before. + * + * The watch on `current/` sees every switch by itself. Omarchy also tells + * OpenCode with SIGUSR2 (bin/omarchy-restart-opencode), but by its process + * name, so Codeep is never sent it — and must not handle it anyway. The + * config store's exit hook (when-exit, which conf's atomically installs) + * catches SIGUSR2 with SIGTERM, SIGHUP and the rest, runs its cleanup once, + * and raises the signal again to end the process. A handler that kept the + * process alive would leave that hook spent, and the next SIGTERM or SIGHUP + * would then not end the session. + * + * Watches are non-persistent and the timer unref'd, so following a theme + * never keeps the process alive. Every fs call is guarded: the directory can + * vanish (Omarchy reinstalled, state wiped) and come back, and neither may + * crash a session. While it is gone, the nearest directory above it that is + * still there is watched instead, so its return is seen too. + */ +export function followOmarchyTheme(options: OmarchyThemeOptions = {}): OmarchyThemeWatch | null { + const stateDir = options.stateDir ?? omarchyStateDir(); + if (!isOmarchy(options.platform, stateDir)) return null; + + const enabled = options.enabled ?? (() => true); + const debounceMs = options.debounceMs ?? 150; + // `state` watches current/; `ancestor` the nearest directory above it + // while current/ is not there. + const watches: Record<'state' | 'ancestor', DirWatch | null> = { state: null, ancestor: null }; + let themeWatcher: FSWatcher | null = null; + let timer: ReturnType | null = null; + let stopped = false; + + const closeQuietly = (watcher: FSWatcher | null | undefined) => { + try { watcher?.close(); } catch { /* already gone */ } + }; + + /** + * Keep a live watch on `dir` in `slot`: dropped when `dir` is gone, or was + * removed and made again (which leaves the old watch on a dead inode), and + * made anew while it is there. A null `dir` drops it. + */ + const keepWatching = (slot: keyof typeof watches, dir: string | null) => { + const ino = dir === null ? null : inodeOf(dir); + const current = watches[slot]; + if (current && (current.dir !== dir || current.ino !== ino)) { + closeQuietly(current.watcher); + watches[slot] = null; + } + if (!watches[slot] && dir !== null && ino !== null) { + const watcher = watchQuietly(dir, schedule, () => { + if (watches[slot]?.watcher === watcher) watches[slot] = null; + }); + if (watcher) watches[slot] = { watcher, dir, ino }; + } + }; + + const arm = () => { + if (stopped) return; + keepWatching('state', stateDir); + // `current/` is gone (or could not be watched): watch the nearest + // directory above it that is there, where `current/` — or a directory + // on the way to it — coming back is a change. + keepWatching('ancestor', watches.state ? null : nearestExistingAncestor(stateDir)); + // theme/ is replaced on every switch, so the previous watch is on a + // deleted directory by now. This one catches an edit to colors.toml in + // place, which `current/` does not see. + closeQuietly(themeWatcher); + const watcher = watchQuietly(join(stateDir, 'theme'), schedule, () => { + if (themeWatcher === watcher) themeWatcher = null; + }); + themeWatcher = watcher; + }; + + const reload = () => { + if (stopped) return; + arm(); + try { + applyOmarchyTheme(stateDir, enabled()); + } catch { + // Reading a theme must never take the session down with it. + } + }; + + // A switch is a burst — stage next-theme, rm theme, mv, write theme.name, + // relink the background — and reading in the middle of it would catch the + // moment theme/ does not exist. Waiting for the burst to go quiet reads + // once, after the swap. + function schedule() { + if (stopped) return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => { timer = null; reload(); }, debounceMs); + timer.unref?.(); + } + + const handle: OmarchyThemeWatch = { + reload, + stop: () => { + if (stopped) return; + stopped = true; + if (timer) clearTimeout(timer); + timer = null; + closeQuietly(watches.state?.watcher); + closeQuietly(watches.ancestor?.watcher); + closeQuietly(themeWatcher); + watches.state = null; + watches.ancestor = null; + themeWatcher = null; + if (active === handle) active = null; + }, + }; + active?.stop(); + active = handle; + reload(); + return handle; +} + +/** + * Re-read the theme and the setting now, if a watch is running. The settings + * screen calls it when "Follow Omarchy theme" is switched, so the change + * shows at once. Without a watch — not Omarchy — the palette is already + * Codeep's own and there is nothing to do. + */ +export function reapplyOmarchyTheme(): void { + active?.reload(); +} + +/** + * Whether this looks like an Omarchy desktop: Linux, with Omarchy's state + * directory in place. Decides whether /settings offers the switch at all — + * on every other machine it would be a row that does nothing. + */ +export function isOmarchy(platform: NodeJS.Platform = process.platform, stateDir: string = omarchyStateDir()): boolean { + return platform === 'linux' && inodeOf(stateDir) !== null; +} + +/** The closest directory above `dir` that exists, or null if none does. */ +function nearestExistingAncestor(dir: string): string | null { + for (let parent = dirname(dir); ; parent = dirname(parent)) { + if (inodeOf(parent) !== null) return parent; + if (dirname(parent) === parent) return null; + } +} + +function inodeOf(dir: string): number | null { + try { + const stats = statSync(dir); + return stats.isDirectory() ? stats.ino : null; + } catch { + return null; + } +} + +/** + * fs.watch, or null when the directory is not there to watch. `onGone` runs + * if the watch fails later (the directory removed under it, on some + * kernels), so the next arm() makes a new one instead of trusting a dead one. + */ +function watchQuietly(dir: string, onEvent: () => void, onGone: () => void): FSWatcher | null { + try { + const watcher = watch(dir, { persistent: false }, () => onEvent()); + watcher.on('error', () => { + try { watcher.close(); } catch { /* already gone */ } + onGone(); + }); + return watcher; + } catch { + return null; + } +} diff --git a/src/renderer/palette.defaults.test.ts b/src/renderer/palette.defaults.test.ts new file mode 100644 index 0000000..5217577 --- /dev/null +++ b/src/renderer/palette.defaults.test.ts @@ -0,0 +1,211 @@ +/** + * Without an Omarchy theme, every colour Codeep paints is the one it painted + * before the palette existed — byte for byte. + * + * The palette moved ~150 colour reads behind roles, and most of them are a + * call site choosing which role to read. A wrong choice there is invisible to + * the per-role table in palette.test.ts (each role still holds the right + * value; the Mode line just reads the hint grey instead of its own), and to + * the eye (130 vs 150 grey). So this file renders a corpus through the REAL + * formatters and components and pins the colours they emitted, in order. + * + * Only the colours: the words, wrapping and box drawing around them are free + * to change. The table was taken from the tree before the palette was + * introduced (cabba11, where every colour was still an inline literal), by + * running this same corpus against it. A failure names the entry and shows + * where its colours went different. + */ +import { describe, it, expect } from 'vitest'; +import type { Screen } from './Screen'; +import { formatWelcomeMessage } from './components/WelcomeFormatter'; +import { formatMessage } from './components/MessageFormatter'; +import { highlightCode, SYNTAX } from './highlight'; +import { renderModal, renderHelpModal, renderConfirmModal, renderListModal } from './components/Modal'; +import { renderSelectScreen } from './components/SelectScreen'; +import { renderSearchPanel } from './components/Search'; +import { renderProviderSelect } from './components/Login'; +import { PRIMARY_COLOR } from './components/uiConstants'; + +/** A Screen that remembers what it was asked to draw, in order. */ +function recordingScreen(calls: unknown[]): Screen { + return new Proxy({}, { + get: (_target, method) => { + if (method === 'getSize') return () => ({ width: 100, height: 30 }); + return (...args: unknown[]) => { calls.push([String(method), ...args]); }; + }, + }) as Screen; +} + +const WELCOME = [ + 'Codeep v3.8.1 · Z.AI · glm-5.3', + '', + ' Project /home/user/project', + ' Access Read & Write · Agent enabled', + ' Mode Chat only · no project context', + '', + ' ⚠ Agent Mode ON — messages auto-execute as agent tasks', + '', + ' /help · Ctrl+L clear · Esc cancel', +].join('\n'); + +const MARKDOWN = [ + '# Heading one', + '### Heading three', + 'Some **bold**, *italic*, ***both***, `inline code` and ~~gone~~ text.', + '> a quoted line with **bold** inside', + '- a list item with `code`', + '1. a numbered item', + '---', + 'A plain paragraph long enough that it will have to wrap at the narrow width this corpus formats at.', +].join('\n'); + +const CODE: Array<[string, string]> = [ + ['ts', 'const answer: Answer = compute(42, "forty-two"); // the answer\n/* block */ type X = 0x1F;'], + ['py', 'def f(x):\n return None # nothing'], + ['go', 'func main() { fmt.Println(`hi`) }'], + ['sh', 'if [ -f x ]; then echo "yes"; fi # done'], + ['html', '
'], + ['css', '/* c */ .btn > a { color: red; }'], + ['diff', '--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old line\n+new line\n context'], +]; + +function renderCorpus(): Record { + const counter = { current: 0 }; + const fenced = CODE.map(([lang, code]) => '```' + lang + '\n' + code + '\n```').join('\n'); + const out: Record = { + primary: PRIMARY_COLOR, + syntax: { ...SYNTAX }, + welcome: formatWelcomeMessage(WELCOME), + user: formatMessage('user', 'Fix the **build** please', 60, counter), + assistant: formatMessage('assistant', MARKDOWN + '\n' + fenced + '\nDone.', 60, counter), + system: formatMessage('system', 'Session saved', 60, counter), + highlighted: CODE.map(([lang, code]) => highlightCode(code, lang)), + }; + + const screens: Record = {}; + const draw = (name: string, paint: (screen: Screen) => void) => { + const calls: unknown[] = []; + paint(recordingScreen(calls)); + screens[name] = calls; + }; + draw('modal', s => renderModal(s, { title: 'Title', content: ['one', 'two'] })); + draw('helpModal', s => renderHelpModal(s, 'Help', [{ key: 'Esc', description: 'close' }], 'footer')); + draw('confirmYes', s => renderConfirmModal(s, 'Sure?', ['Really?'], 'yes')); + draw('confirmNo', s => renderConfirmModal(s, 'Sure?', ['Really?'], 'no')); + draw('listModal', s => renderListModal(s, 'Pick', ['a', 'b', 'c'], 1, 'Enter select')); + draw('select', s => renderSelectScreen(s, 'Model', [ + { key: 'a', label: 'Alpha', description: 'first' }, + { key: 'b', label: 'Beta' }, + { key: 'c', label: 'Gamma' }, + ], { selectedIndex: 0 }, 'b')); + draw('search', s => renderSearchPanel(s, 0, 100, 20, { + searchOpen: true, + searchQuery: 'build', + searchResults: [ + { role: 'user', messageIndex: 0, matchedText: 'fix the build' }, + { role: 'assistant', messageIndex: 1, matchedText: 'the build is fixed' }, + ], + searchIndex: 0, + searchCallback: null, + })); + draw('providers', s => renderProviderSelect(s, [ + { id: 'z.ai', name: 'Z.AI', description: 'GLM' }, + { id: 'ollama', name: 'Ollama' }, + ], 0)); + return { ...out, ...screens }; +} + +/** Every string in a value, in order: formatted lines, Screen call arguments. */ +function strings(value: unknown, into: string[] = []): string[] { + if (typeof value === 'string') into.push(value); + else if (Array.isArray(value)) for (const item of value) strings(item, into); + else if (value && typeof value === 'object') for (const item of Object.values(value)) strings(item, into); + return into; +} + +/** + * The colours `value` paints, in order, as SGR parameters ('38;2;240;42;48', + * '90'): every escape that sets a foreground or background colour, with a + * colour repeated straight after itself counted once, so a line that wraps + * on in the same colour is not a change. + */ +function coloursOf(value: unknown): string[] { + const colours: string[] = []; + for (const text of strings(value)) { + for (const [, params] of text.matchAll(/\x1b\[([0-9;]*)m/g)) { + const code = Number(params.split(';')[0]); + const isColour = (code >= 30 && code <= 49) || (code >= 90 && code <= 107); + if (isColour && colours[colours.length - 1] !== params) colours.push(params); + } + } + return colours; +} + +/** coloursOf() each entry of renderCorpus(), at cabba11, before the palette. */ +const COLOURS_BEFORE_PALETTE: Record = { + primary: ['38;2;240;42;48'], + syntax: [ + '38;2;198;120;221', '38;2;152;195;121', '38;2;209;154;102', '38;2;92;99;112', '38;2;97;175;239', + '38;2;229;192;123', '38;2;86;182;194', '37', '90', '38;2;100;105;115', '38;2;150;155;165', + ], + welcome: [ + '38;2;240;42;48', '38;2;80;80;80', '38;2;180;180;180', '38;2;80;80;80', '38;2;130;130;130', + '38;2;100;100;100', '38;2;100;180;220', '38;2;100;100;100', '38;2;100;200;120', '38;2;80;80;80', + '38;2;80;160;100', '38;2;100;100;100', '38;2;160;160;160', '38;2;220;160;40', + '38;2;150;150;150', '38;2;80;80;80', '38;2;150;150;150', '38;2;80;80;80', '38;2;150;150;150', + ], + user: ['38;2;240;42;48'], + assistant: [ + '38;2;240;42;48', '38;2;120;120;120', '38;2;97;175;239', '38;2;198;120;221', '38;2;240;42;48', + '38;2;209;154;102', '38;2;140;140;140', '38;2;240;42;48', '38;2;160;160;160', '38;2;240;42;48', + '90', '38;2;209;154;102', '90', '38;2;150;155;165', '38;2;198;120;221', '38;2;86;182;194', + '38;2;229;192;123', '38;2;86;182;194', '38;2;97;175;239', '90', '38;2;209;154;102', '90', + '38;2;152;195;121', '90', '38;2;92;99;112', '38;2;198;120;221', '38;2;229;192;123', + '38;2;86;182;194', '38;2;209;154;102', '90', '38;2;150;155;165', '38;2;198;120;221', + '38;2;97;175;239', '90', '38;2;86;182;194', '38;2;198;120;221', '38;2;92;99;112', + '38;2;150;155;165', '38;2;198;120;221', '38;2;97;175;239', '90', '38;2;97;175;239', '90', + '38;2;152;195;121', '90', '38;2;150;155;165', '38;2;198;120;221', '90', '38;2;86;182;194', '90', + '38;2;198;120;221', '38;2;152;195;121', '90', '38;2;198;120;221', '38;2;92;99;112', + '38;2;150;155;165', '90', '38;2;198;120;221', '38;2;97;175;239', '38;2;86;182;194', + '38;2;152;195;121', '38;2;97;175;239', '38;2;86;182;194', '38;2;152;195;121', '90', + '38;2;92;99;112', '90', '38;2;198;120;221', '90', '38;2;150;155;165', '38;2;92;99;112', + '38;2;198;120;221', '38;2;97;175;239', '38;2;152;195;121', '38;2;150;155;165', + '38;2;86;182;194', '38;2;224;108;117', '38;2;152;195;121', + ], + system: ['38;2;240;42;48'], + highlighted: [ + '38;2;198;120;221', '38;2;86;182;194', '38;2;229;192;123', '38;2;86;182;194', '38;2;97;175;239', + '90', '38;2;209;154;102', '90', '38;2;152;195;121', '90', '38;2;92;99;112', '38;2;198;120;221', + '38;2;229;192;123', '38;2;86;182;194', '38;2;209;154;102', '90', '38;2;198;120;221', + '38;2;97;175;239', '90', '38;2;86;182;194', '38;2;198;120;221', '38;2;92;99;112', + '38;2;198;120;221', '38;2;97;175;239', '90', '38;2;97;175;239', '90', '38;2;152;195;121', '90', + '38;2;198;120;221', '90', '38;2;86;182;194', '90', '38;2;198;120;221', '38;2;152;195;121', '90', + '38;2;198;120;221', '38;2;92;99;112', '90', '38;2;198;120;221', '38;2;97;175;239', + '38;2;86;182;194', '38;2;152;195;121', '38;2;97;175;239', '38;2;86;182;194', '38;2;152;195;121', + '90', '38;2;92;99;112', '90', '38;2;198;120;221', '90', '38;2;92;99;112', '38;2;198;120;221', + '38;2;97;175;239', '38;2;152;195;121', '38;2;150;155;165', '38;2;86;182;194', + '38;2;224;108;117', '38;2;152;195;121', + ], + modal: ['38;2;255;80;85', '38;2;240;42;48'], + helpModal: ['38;2;255;80;85', '38;2;240;42;48', '33', '37', '90'], + confirmYes: ['33', '37', '38;2;255;80;85', '90'], + confirmNo: ['33', '37', '90', '38;2;255;80;85', '90'], + listModal: ['38;2;255;80;85', '38;2;240;42;48', '37', '38;2;255;80;85', '37', '90'], + select: ['38;2;240;42;48', '38;2;255;80;85', '90', '32', '37', '90'], + search: [ + '38;2;240;42;48', '37', '36', '90', '38;2;240;42;48', '32', '90', '37', '34', '90', '37', '90', + ], + providers: ['38;2;240;42;48', '37', '38;2;240;42;48', '38;2;255;80;85', '37', '90'], +}; + +describe('the default palette', () => { + const corpus = renderCorpus(); + + it('renders the same entries the table was taken from', () => { + expect(Object.keys(corpus)).toEqual(Object.keys(COLOURS_BEFORE_PALETTE)); + }); + + it.each(Object.keys(COLOURS_BEFORE_PALETTE))('paints %s in exactly the colours Codeep painted before the palette existed', name => { + expect(coloursOf(corpus[name])).toEqual(COLOURS_BEFORE_PALETTE[name]); + }); +}); diff --git a/src/renderer/palette.test.ts b/src/renderer/palette.test.ts new file mode 100644 index 0000000..82b2ef1 --- /dev/null +++ b/src/renderer/palette.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + DEFAULT_PALETTE, + PALETTE_ROLES, + PRIMARY_COLOR as PALETTE_PRIMARY, + TERMINAL_ROLES, + getPalette, + onPaletteChange, + palette, + resetPalette, + setPalette, + type Rgb, +} from './palette'; +import { PRIMARY_COLOR, PRIMARY_BRIGHT } from './components/uiConstants'; +import { SYNTAX, highlightCode } from './highlight'; +import { formatWelcomeMessage } from './components/WelcomeFormatter'; +import { formatMessage } from './components/MessageFormatter'; + +afterEach(() => { + resetPalette(); +}); + +describe('the default palette', () => { + /** + * Each role against the literal it replaced, written out as the escape + * sequence rather than through fg.rgb() so a change to either side shows. + * palette.defaults.test.ts covers which role each call site reads. + */ + it.each([ + ['primary', '\x1b[38;2;240;42;48m'], + ['primaryBright', '\x1b[38;2;255;80;85m'], + ['separator', '\x1b[38;2;80;80;80m'], + ['label', '\x1b[38;2;100;100;100m'], + ['assistantLabel', '\x1b[38;2;120;120;120m'], + ['modelName', '\x1b[38;2;130;130;130m'], + ['strikethrough', '\x1b[38;2;140;140;140m'], + ['hint', '\x1b[38;2;150;150;150m'], + ['secondaryText', '\x1b[38;2;160;160;160m'], + ['providerName', '\x1b[38;2;180;180;180m'], + ['path', '\x1b[38;2;100;180;220m'], + ['success', '\x1b[38;2;100;200;120m'], + ['successDetail', '\x1b[38;2;80;160;100m'], + ['warning', '\x1b[38;2;220;160;40m'], + ['inlineCode', '\x1b[38;2;209;154;102m'], + ['heading', '\x1b[38;2;97;175;239m'], + ['subheading', '\x1b[38;2;198;120;221m'], + ['syntaxKeyword', '\x1b[38;2;198;120;221m'], + ['syntaxString', '\x1b[38;2;152;195;121m'], + ['syntaxNumber', '\x1b[38;2;209;154;102m'], + ['syntaxComment', '\x1b[38;2;92;99;112m'], + ['syntaxFunction', '\x1b[38;2;97;175;239m'], + ['syntaxType', '\x1b[38;2;229;192;123m'], + ['syntaxOperator', '\x1b[38;2;86;182;194m'], + ['syntaxRemoved', '\x1b[38;2;224;108;117m'], + ['codeFrame', '\x1b[38;2;100;105;115m'], + ['codeLang', '\x1b[38;2;150;155;165m'], + ] as const)('%s is the colour Codeep has always painted it', (role, escape) => { + expect(palette[role]).toBe(escape); + }); + + it('has a pinned value for every role', () => { + // A role added without a line in the table above would go unpinned. + expect(PALETTE_ROLES).toHaveLength(27); + expect(TERMINAL_ROLES).toHaveLength(5); + }); + + /** The roles that are the terminal's own colours until a theme sets them. */ + it.each([ + ['syntaxPunctuation', '\x1b[90m'], + ['attention', '\x1b[33m'], + ['warningToast', '\x1b[38;5;208m'], + ['yoloBadge', '\x1b[48;5;208m'], + ['yoloBadgeText', '\x1b[30m'], + ] as const)('%s keeps the terminal colour it has always been', (role, escape) => { + expect(palette[role]).toBe(escape); + }); + + it('exports the brand pair as the same strings, from both modules', () => { + expect(PRIMARY_COLOR).toBe('\x1b[38;2;240;42;48m'); + expect(PRIMARY_BRIGHT).toBe('\x1b[38;2;255;80;85m'); + expect(PALETTE_PRIMARY).toBe(PRIMARY_COLOR); + }); +}); + +describe('setPalette', () => { + const blue: Rgb = [10, 20, 200]; + + it('recolours the role it is given and leaves the rest at their defaults', () => { + setPalette({ hint: blue }); + expect(palette.hint).toBe('\x1b[38;2;10;20;200m'); + expect(palette.label).toBe('\x1b[38;2;100;100;100m'); + expect(getPalette().hint).toEqual(blue); + }); + + it('moves PRIMARY_COLOR for every importer, uiConstants included', () => { + // The live binding is what lets ~117 call sites keep `PRIMARY_COLOR` + // and still follow a theme switch. + setPalette({ primary: [1, 2, 3], primaryBright: [4, 5, 6] }); + expect(PRIMARY_COLOR).toBe('\x1b[38;2;1;2;3m'); + expect(PRIMARY_BRIGHT).toBe('\x1b[38;2;4;5;6m'); + resetPalette(); + expect(PRIMARY_COLOR).toBe('\x1b[38;2;240;42;48m'); + }); + + it('starts from the defaults each time, so a role a new theme lacks does not keep the old theme\'s colour', () => { + setPalette({ hint: blue, label: blue }); + setPalette({ hint: blue }); + expect(palette.label).toBe('\x1b[38;2;100;100;100m'); + }); + + it('ignores a value that is not three 0–255 integers', () => { + setPalette({ + hint: [256, 0, 0], + label: [1.5, 2, 3] as unknown as Rgb, + path: [1, 2] as unknown as Rgb, + warning: 'red' as unknown as Rgb, + }); + expect(getPalette()).toEqual(DEFAULT_PALETTE); + }); + + it('gives a terminal role an RGB value, in the layer it paints, and takes it back', () => { + setPalette({ syntaxPunctuation: blue, yoloBadge: blue }); + expect(palette.syntaxPunctuation).toBe('\x1b[38;2;10;20;200m'); + // The badge is a fill: a background colour. + expect(palette.yoloBadge).toBe('\x1b[48;2;10;20;200m'); + expect(getPalette().yoloBadge).toEqual(blue); + expect(getPalette()).not.toEqual(DEFAULT_PALETTE); + resetPalette(); + expect(palette.syntaxPunctuation).toBe('\x1b[90m'); + expect(palette.yoloBadge).toBe('\x1b[48;5;208m'); + expect(getPalette()).toEqual(DEFAULT_PALETTE); + }); + + it('counts a change to a terminal role alone as a change', () => { + const listener = vi.fn(); + const unsubscribe = onPaletteChange(listener); + try { + expect(setPalette({ attention: blue })).toBe(true); + expect(setPalette({ attention: blue })).toBe(false); + expect(setPalette(null)).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + } finally { + unsubscribe(); + } + }); + + it('tells listeners only when a colour actually changed', () => { + const listener = vi.fn(); + const unsubscribe = onPaletteChange(listener); + try { + expect(setPalette({ hint: blue })).toBe(true); + expect(setPalette({ hint: blue })).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + expect(resetPalette()).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + } finally { + unsubscribe(); + } + setPalette({ hint: blue }); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('still tells every listener when one of them throws', () => { + const second = vi.fn(); + const offA = onPaletteChange(() => { throw new Error('repaint failed'); }); + const offB = onPaletteChange(second); + try { + setPalette({ hint: blue }); + expect(second).toHaveBeenCalledTimes(1); + expect(palette.hint).toBe('\x1b[38;2;10;20;200m'); + } finally { + offA(); + offB(); + } + }); +}); + +describe('colours are read when painting, not when importing', () => { + // Each of these modules was imported above, under the default palette. + // A colour baked into a module-level constant would still be red here. + it('SYNTAX and highlightCode', () => { + setPalette({ syntaxKeyword: [1, 1, 1], syntaxRemoved: [2, 2, 2], syntaxPunctuation: [3, 3, 3] }); + expect(SYNTAX.keyword).toBe('\x1b[38;2;1;1;1m'); + expect(highlightCode('const x', 'ts')).toContain('\x1b[38;2;1;1;1mconst'); + expect(highlightCode('-gone', 'diff')).toContain('\x1b[38;2;2;2;2m-gone'); + expect(SYNTAX.punctuation).toBe('\x1b[38;2;3;3;3m'); + expect(highlightCode('f();', 'ts')).toContain('\x1b[38;2;3;3;3m('); + }); + + it('the welcome banner', () => { + setPalette({ primary: [3, 3, 3], path: [4, 4, 4] }); + const out = formatWelcomeMessage('Codeep v1 · P · M\n Project /p').map(l => l.text).join('\n'); + expect(out).toContain('\x1b[38;2;3;3;3m'); + expect(out).toContain('\x1b[38;2;4;4;4m/p'); + }); + + it('chat messages', () => { + setPalette({ primary: [5, 5, 5], inlineCode: [6, 6, 6], heading: [7, 7, 7] }); + const out = formatMessage('user', '# Title\nrun `npm test`', 80, { current: 0 }).map(l => l.text).join('\n'); + expect(out).toContain('\x1b[38;2;5;5;5m'); + expect(out).toContain('\x1b[38;2;6;6;6mnpm test'); + expect(out).toContain('\x1b[38;2;7;7;7m'); + }); +}); diff --git a/src/renderer/palette.ts b/src/renderer/palette.ts new file mode 100644 index 0000000..6acbe03 --- /dev/null +++ b/src/renderer/palette.ts @@ -0,0 +1,235 @@ +/** + * The renderer's truecolour palette. + * + * Codeep paints with two kinds of colour. The 16 basic ANSI colours + * (`fg.red`, `fg.gray`, …) are indices into the terminal's own palette, so + * they already follow whatever theme the terminal has — Omarchy recolours the + * terminal and those change with it. Everything painted with `fg.rgb(…)` is a + * literal, and stays the same red and the same One Dark syntax colours on a + * light theme, a Gruvbox theme, any theme. This module is where those + * literals live now, under the role each one plays, so something can swap + * them (omarchyTheme.ts) and every surface picks the new value up — along + * with the few terminal colours that a terminal theme does not make + * readable (TERMINAL_DEFAULTS). + * + * Every read happens at RENDER time: `palette.` is a getter, and + * `PRIMARY_COLOR` / `PRIMARY_BRIGHT` are live ES bindings that setPalette() + * reassigns. A colour copied into a module-level constant + * (`const X = PRIMARY_COLOR + '…'`) would be baked at import and never + * change again — don't. + * + * The defaults are the exact values Codeep painted before this module + * existed, so without a theme every escape sequence is byte-identical to what + * it was (pinned by palette.defaults.test.ts). + */ +import { fg, bg } from './ansi'; + +export type Rgb = readonly [number, number, number]; + +/** + * Every truecolour role, with the colour it has when nothing overrides it. + * + * Grouped the way they are derived from a theme (see omarchyTheme.ts): the + * brand pair, the neutral greys of the chat chrome, the coloured accents of + * the welcome banner and markdown, and the syntax colours of code blocks. + */ +export const DEFAULT_PALETTE = Object.freeze({ + // ── Brand ──────────────────────────────────────────────────────────────── + /** Brand red: logo, borders, prompts, the user bar, every panel title. */ + primary: [240, 42, 48], + /** The selected row of a list or modal, and modal titles. */ + primaryBright: [255, 80, 85], + + // ── Neutral greys, faintest first ──────────────────────────────────────── + /** The `·` separators in the welcome banner. */ + separator: [80, 80, 80], + /** Welcome field labels: Project, Access, Mode. */ + label: [100, 100, 100], + /** The `codeep` label above each assistant message. */ + assistantLabel: [120, 120, 120], + /** The model name in the welcome version line. */ + modelName: [130, 130, 130], + /** ~~Struck-through~~ markdown. */ + strikethrough: [140, 140, 140], + /** The welcome shortcuts line (`/help · Ctrl+L clear · …`). */ + hint: [150, 150, 150], + /** Secondary body text: the welcome Mode value, blockquote bodies. */ + secondaryText: [160, 160, 160], + /** The provider name in the welcome version line. */ + providerName: [180, 180, 180], + + // ── Coloured accents ───────────────────────────────────────────────────── + /** The project path in the welcome banner. */ + path: [100, 180, 220], + /** The welcome access level (Read & Write / Read Only). */ + success: [100, 200, 120], + /** What follows the access level (`Agent enabled`). */ + successDetail: [80, 160, 100], + /** Welcome-banner warnings (⚠ lines). */ + warning: [220, 160, 40], + /** `inline code` in markdown. */ + inlineCode: [209, 154, 102], + /** Markdown `#` and `##` headings. */ + heading: [97, 175, 239], + /** Markdown `###` and deeper headings. */ + subheading: [198, 120, 221], + + // ── Code blocks (One Dark) ─────────────────────────────────────────────── + syntaxKeyword: [198, 120, 221], + syntaxString: [152, 195, 121], + syntaxNumber: [209, 154, 102], + syntaxComment: [92, 99, 112], + syntaxFunction: [97, 175, 239], + syntaxType: [229, 192, 123], + syntaxOperator: [86, 182, 194], + /** Removed lines in a ```diff block (added lines use syntaxString). */ + syntaxRemoved: [224, 108, 117], + /** SYNTAX.codeFrame. Nothing in the TUI draws a code frame today; the role + * is kept so the exported SYNTAX shape is unchanged. */ + codeFrame: [100, 105, 115], + /** A code block's language label, and a diff's ---/+++ file lines. */ + codeLang: [150, 155, 165], +} satisfies Record); + +/** + * The roles Codeep paints in the TERMINAL's colours — a basic ANSI index, or + * an xterm-256 one — with the escape each one is painted with when nothing + * overrides it. + * + * Most terminal colours are left to the terminal: Omarchy recolours it, so + * they follow already. These are the ones that do not follow well enough to + * be left there. Bright black (code punctuation) is Omarchy's `muted`, a + * decoration colour, below 3:1 on 20 of its 22 themes; yellow is 2–2.3:1 on + * its light themes; and index 208, the warning orange, is set by no theme at + * all, so it stays #ff8700 everywhere. Only a theme gives these an RGB value; without + * one they keep the escape they had, so the bytes do not change. + */ +export const TERMINAL_DEFAULTS = Object.freeze({ + /** Code punctuation: `( ) { } ; , . :`. ANSI bright black. */ + syntaxPunctuation: fg.gray, + /** The confirm modal's border and title, and the input line's "Select … + * below" prompts while a picker is open. ANSI yellow. */ + attention: fg.yellow, + /** Warning toasts on the status bar: errors, retries. xterm 208. */ + warningToast: fg.color256(208), + /** The `--yolo` badge's fill (a BACKGROUND colour), xterm 208… */ + yoloBadge: bg.color256(208), + /** …and its text, ANSI black. */ + yoloBadgeText: fg.black, +} satisfies Record); + +export type PaletteRole = keyof typeof DEFAULT_PALETTE; +export type TerminalRole = keyof typeof TERMINAL_DEFAULTS; +export type PaletteColors = Record; +/** What a theme hands setPalette(): an RGB value for any role of either kind. */ +export type PaletteOverrides = Partial>; + +export const PALETTE_ROLES = Object.keys(DEFAULT_PALETTE) as PaletteRole[]; +export const TERMINAL_ROLES = Object.keys(TERMINAL_DEFAULTS) as TerminalRole[]; + +/** The terminal roles that colour a cell's background rather than its text. */ +const BACKGROUND_ROLES: ReadonlySet = new Set(['yoloBadge']); + +function escapesFor(colors: PaletteColors, terminal: Partial>): Record { + const out = {} as Record; + for (const role of PALETTE_ROLES) out[role] = fg.rgb(...colors[role]); + for (const role of TERMINAL_ROLES) { + const rgb = terminal[role]; + out[role] = !rgb ? TERMINAL_DEFAULTS[role] : BACKGROUND_ROLES.has(role) ? bg.rgb(...rgb) : fg.rgb(...rgb); + } + return out; +} + +function isRgb(value: unknown): value is Rgb { + return Array.isArray(value) + && value.length === 3 + && value.every(c => Number.isInteger(c) && c >= 0 && c <= 255); +} + +let colors: PaletteColors = { ...DEFAULT_PALETTE }; +/** The terminal roles a theme has given a colour; absent means its escape. */ +let terminalColors: Partial> = {}; +// The escape strings, built once per palette change rather than once per +// cell: the render loop reads these thousands of times a frame. +let escapes = escapesFor(colors, terminalColors); + +/** Brand red, as an escape sequence. A live binding: setPalette() reassigns it. */ +export let PRIMARY_COLOR = escapes.primary; +/** The bright brand variant, as an escape sequence. Live, like PRIMARY_COLOR. */ +export let PRIMARY_BRIGHT = escapes.primaryBright; + +/** + * The current foreground escape for every role, read at the moment it is + * used: `palette.hint + text + style.reset`. + */ +export const palette = {} as { readonly [R in PaletteRole | TerminalRole]: string }; +for (const role of [...PALETTE_ROLES, ...TERMINAL_ROLES]) { + Object.defineProperty(palette, role, { get: () => escapes[role], enumerable: true }); +} + +/** + * The current colour of every role, and of each terminal role a theme has + * given one — so with no theme it equals DEFAULT_PALETTE exactly. + */ +export function getPalette(): Readonly>> { + return { ...colors, ...terminalColors }; +} + +const listeners = new Set<() => void>(); + +/** + * Run `listener` after every palette change that changed a colour. Returns + * the unsubscribe. The App uses it to drop its formatted-message cache and + * repaint, which is the whole of what a theme switch needs from it. + */ +export function onPaletteChange(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} + +/** + * Replace the palette: every role in `overrides` takes that colour, every + * other role its default. `null` (or `{}`) is the default palette. + * + * Returns whether any colour changed, and only then tells the listeners — + * a theme switch touches several files and can land here more than once + * with the same colours, and each of those must not cost a full repaint. + * A value that is not three 0–255 integers is ignored, not trusted: the + * overrides come from a file on disk. + */ +export function setPalette(overrides: PaletteOverrides | null): boolean { + const next: PaletteColors = { ...DEFAULT_PALETTE }; + for (const role of PALETTE_ROLES) { + const value = overrides?.[role]; + if (isRgb(value)) next[role] = [value[0], value[1], value[2]]; + } + const nextTerminal: Partial> = {}; + for (const role of TERMINAL_ROLES) { + const value = overrides?.[role]; + if (isRgb(value)) nextTerminal[role] = [value[0], value[1], value[2]]; + } + const same = (a: Rgb | undefined, b: Rgb | undefined) => a === b || (!!a && !!b && a.every((c, i) => c === b[i])); + const changed = PALETTE_ROLES.some(role => !same(next[role], colors[role])) + || TERMINAL_ROLES.some(role => !same(nextTerminal[role], terminalColors[role])); + if (!changed) return false; + + colors = next; + terminalColors = nextTerminal; + escapes = escapesFor(colors, terminalColors); + PRIMARY_COLOR = escapes.primary; + PRIMARY_BRIGHT = escapes.primaryBright; + for (const listener of [...listeners]) { + try { + listener(); + } catch { + // A repaint that fails must not stop the others, or leave the palette + // half-announced. + } + } + return true; +} + +/** Back to the colours Codeep ships with. */ +export function resetPalette(): boolean { + return setPalette(null); +} diff --git a/src/utils/agent.ts b/src/utils/agent.ts index b1a4b84..b949c2b 100644 --- a/src/utils/agent.ts +++ b/src/utils/agent.ts @@ -664,6 +664,7 @@ export async function runAgent( type: 'object', properties: { name: { type: 'string', description: 'Skill name from the catalog (e.g. "deploy").' }, + file: { type: 'string', description: 'Optional. A file in the skill\'s directory that its SKILL.md refers to (e.g. "reporting.md"), returned instead of SKILL.md.' }, }, required: ['name'], }, diff --git a/src/utils/skillBundles.integration.test.ts b/src/utils/skillBundles.integration.test.ts index f5d98d7..c4d8253 100644 --- a/src/utils/skillBundles.integration.test.ts +++ b/src/utils/skillBundles.integration.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -// Real fs, with readFileSync recorded so a test can prove a file was never read. +// Real fs, with readFileSync recorded so a test can prove a file was never +// read, and readdirSync so one can list a directory in an order of its own. vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, readFileSync: vi.fn(actual.readFileSync) }; + return { ...actual, readFileSync: vi.fn(actual.readFileSync), readdirSync: vi.fn(actual.readdirSync) }; }); -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, readFileSync } from 'fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, readFileSync, readdirSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -42,16 +43,23 @@ afterEach(() => { }); function writeSkill(opts: { - root: 'project' | 'global'; + /** `agents` is `~/.agents/skills`, the directory agent harnesses share. */ + root: 'project' | 'global' | 'agents'; name: string; body: string; }): void { - const base = opts.root === 'project' ? workspaceRoot : fakeHome; - const dir = join(base, '.codeep', 'skills', opts.name); + const dir = join(skillsDir(opts.root), opts.name); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'SKILL.md'), opts.body); } +function skillsDir(root: 'project' | 'global' | 'agents'): string { + if (root === 'agents') return join(fakeHome, '.agents', 'skills'); + return join(root === 'project' ? workspaceRoot : fakeHome, '.codeep', 'skills'); +} + +const SKILL = (name: string, description: string) => `---\nname: ${name}\ndescription: ${description}\n---\nBody`; + const FRONT = (body: string) => `---\nname: my-skill\ndescription: Test skill\n---\n${body}`; describe('loadSkillBundles', () => { @@ -160,6 +168,83 @@ Body`; writeSkill({ root: 'project', name: 'big', body: huge }); expect(loadSkillBundles(workspaceRoot)).toEqual([]); }); + + it('loads global bundles from ~/.agents/skills too', () => { + writeSkill({ root: 'agents', name: 'crash', body: SKILL('crash', 'Shared') }); + const bundles = loadSkillBundles(workspaceRoot); + expect(bundles).toHaveLength(1); + expect(bundles[0].scope).toBe('global'); + expect(bundles[0].source).toBe(join(fakeHome, '.agents', 'skills', 'crash')); + }); + + it('is fine without ~/.agents/skills', () => { + writeSkill({ root: 'global', name: 'deploy', body: SKILL('deploy', 'Global') }); + expect(loadSkillBundles(workspaceRoot).map(b => b.source)).toEqual([join(fakeHome, '.codeep', 'skills', 'deploy')]); + }); + + it('prefers .codeep/skills, then ~/.codeep/skills, then ~/.agents/skills for the same name', () => { + writeSkill({ root: 'agents', name: 'deploy', body: SKILL('deploy', 'Shared deploy') }); + writeSkill({ root: 'global', name: 'deploy', body: SKILL('deploy', 'Global deploy') }); + expect(loadSkillBundles(workspaceRoot).map(b => b.description)).toEqual(['Global deploy']); + + writeSkill({ root: 'project', name: 'deploy', body: SKILL('deploy', 'Project deploy') }); + expect(loadSkillBundles(workspaceRoot).map(b => b.description)).toEqual(['Project deploy']); + // No workspace: the project directory is not in the running. + expect(loadSkillBundles().map(b => b.description)).toEqual(['Global deploy']); + }); + + it('keeps the first directory, by name, when two in one place claim the same name', () => { + writeSkill({ root: 'agents', name: 'b-copy', body: SKILL('deploy', 'From b-copy') }); + writeSkill({ root: 'agents', name: 'a-copy', body: SKILL('deploy', 'From a-copy') }); + // Listed out of order, as Windows may list it. Node sorts a listing on + // Linux and macOS already, so a real one there could not show the sort. + vi.mocked(readdirSync).mockReturnValueOnce(['b-copy', 'a-copy'] as unknown as ReturnType); + expect(loadSkillBundles(workspaceRoot).map(b => b.description)).toEqual(['From a-copy']); + expect(vi.mocked(readdirSync)).toHaveBeenCalledWith(skillsDir('agents')); + }); + + it('reads a folded or literal description as one line', () => { + writeSkill({ root: 'agents', name: 'folded', body: '---\nname: folded\ndescription: >\n Diagnose a crash.\n Use on a core dump.\n---\nBody' }); + writeSkill({ root: 'agents', name: 'literal', body: '---\nname: literal\ndescription: |\n First.\n Second.\n---\nBody' }); + const bundles = loadSkillBundles(workspaceRoot); + expect(bundles.find(b => b.name === 'folded')?.description).toBe('Diagnose a crash. Use on a core dump.'); + expect(bundles.find(b => b.name === 'literal')?.description).toBe('First. Second.'); + }); +}); + +describe('linked bundles', () => { + // Stands in for $OMARCHY_PATH: neither in the project nor under ~/.agents. + let elsewhere: string; + + beforeEach(() => { + elsewhere = mkdtempSync(join(tmpdir(), 'codeep-skills-elsewhere-')); + mkdirSync(join(elsewhere, 'crash')); + writeFileSync(join(elsewhere, 'crash', 'SKILL.md'), SKILL('crash', 'Linked in')); + }); + + afterEach(() => { + rmSync(elsewhere, { recursive: true, force: true }); + }); + + it('loads a global bundle that is a symlink, as Omarchy installs its skills', () => { + mkdirSync(skillsDir('agents'), { recursive: true }); + symlinkSync(join(elsewhere, 'crash'), join(skillsDir('agents'), 'crash')); + const bundles = loadSkillBundles(workspaceRoot); + expect(bundles.map(b => b.description)).toEqual(['Linked in']); + expect(bundles[0].source).toBe(join(fakeHome, '.agents', 'skills', 'crash')); + }); + + it('loads ~/.agents/skills when it is a symlink itself', () => { + mkdirSync(join(fakeHome, '.agents')); + symlinkSync(elsewhere, skillsDir('agents')); + expect(loadSkillBundles(workspaceRoot).map(b => b.name)).toEqual(['crash']); + }); + + it('still refuses a project bundle that links outside the project', () => { + mkdirSync(skillsDir('project'), { recursive: true }); + symlinkSync(join(elsewhere, 'crash'), join(skillsDir('project'), 'crash')); + expect(loadSkillBundles(workspaceRoot)).toEqual([]); + }); }); describe('findSkillBundle', () => { @@ -183,6 +268,19 @@ describe('formatBundleList', () => { expect(md).toMatch(/SKILL\.md/); }); + it('names the directory each global bundle came from, in precedence order', () => { + writeSkill({ root: 'agents', name: 'crash', body: SKILL('crash', 'Shared') }); + writeSkill({ root: 'global', name: 'deploy', body: SKILL('deploy', 'Mine') }); + const md = formatBundleList(loadSkillBundles(workspaceRoot)); + expect(md).toContain([ + '**Global** — `~/.codeep/skills`', + '- **deploy** — Mine', + '', + '**Global** — `~/.agents/skills`', + '- **crash** — Shared', + ].join('\n')); + }); + it('groups by scope', () => { const project = { name: 'p', description: 'PP', source: '', scope: 'project' as const, allowedTools: [], triggers: [], requiresMcp: [], frontmatterRaw: {}, body: '' }; const global = { name: 'g', description: 'GG', source: '', scope: 'global' as const, allowedTools: [], triggers: [], requiresMcp: [], frontmatterRaw: {}, body: '' }; @@ -205,6 +303,24 @@ describe('formatBundlesForSysprompt', () => { expect(out).toMatch(/\*\*deploy\*\* — Deploy to staging/); expect(out).toMatch(/triggers: ship, release/); }); + + const bundle = (name: string, description: string) => ({ name, description, source: '', scope: 'global' as const, allowedTools: [], triggers: [], requiresMcp: [], frontmatterRaw: {}, body: '' }); + + it('keeps a long description whole, with the part that says when to use it', () => { + // Omarchy's run to 590 characters and end on their triggers and what they + // exclude, which the catalog's old 200-character lines cut off. + const description = `${'Customize the desktop, its bar and its theme. '.repeat(11)}Triggers: waybar, hyprland. Excludes Omarchy source development.`; + expect(description.length).toBeGreaterThan(560); + const out = formatBundlesForSysprompt(['diagnose-crash', 'omarchy', 'omarchy-app'].map(n => bundle(n, description))); + expect(out.split('\n').filter(l => l.endsWith(description))).toHaveLength(3); + }); + + it('cuts a line past the cap at a word, not inside one', () => { + const out = formatBundlesForSysprompt([bundle('long', 'abcdefghij '.repeat(200).trim())]); + const line = out.split('\n').find(l => l.startsWith('- **long**'))!; + expect(line).toMatch(/ abcdefghij…$/); + expect(line.length).toBeLessThanOrEqual(1201); + }); }); describe('summarizeBundles (welcome banner)', () => { diff --git a/src/utils/skillBundles.test.ts b/src/utils/skillBundles.test.ts index 8c36a30..8deb2b8 100644 --- a/src/utils/skillBundles.test.ts +++ b/src/utils/skillBundles.test.ts @@ -143,6 +143,104 @@ describe('parseFrontmatter', () => { }); }); +// ─── parseFrontmatter: block scalars ──────────────────────────────────────── +describe('parseFrontmatter block scalars', () => { + // The shape of Omarchy's skills (default/agents/skills/*/SKILL.md): a long + // folded description, then the body. + const FOLDED = [ + '---', + 'name: tidy-desk', + 'description: >', + ' Tidy the desktop the way this machine expects it. Use when', + ' asked to clean up windows, workspaces or the bar.', + ' Triggers: tidy, declutter, "clean up".', + '---', + '', + '# Tidy', + ].join('\n'); + const FOLDED_DESCRIPTION = + 'Tidy the desktop the way this machine expects it. Use when asked to clean up windows, workspaces or the bar. Triggers: tidy, declutter, "clean up".\n'; + + it('folds a `>` block into one line that ends in a line break', () => { + const out = parseFrontmatter(FOLDED); + expect(out.meta.name).toBe('tidy-desk'); + expect(out.meta.description).toBe(FOLDED_DESCRIPTION); + expect(out.body).toBe('# Tidy'); + }); + + it('keeps the line breaks of a `|` block', () => { + const out = parseFrontmatter('---\ndescription: |\n one\n two\n---\n'); + expect(out.meta.description).toBe('one\ntwo\n'); + }); + + it('keeps a blank line in a `>` block as a line break', () => { + const out = parseFrontmatter('---\ndescription: >\n one\n two\n\n three\n---\n'); + expect(out.meta.description).toBe('one two\nthree\n'); + }); + + it('does not fold the breaks around a more-indented line', () => { + const out = parseFrontmatter('---\ndescription: >\n one\n two\n three\n---\n'); + expect(out.meta.description).toBe('one\n two\nthree\n'); + }); + + it('drops the trailing line break with `-`', () => { + expect(parseFrontmatter('---\ndescription: >-\n one\n two\n---\n').meta.description).toBe('one two'); + expect(parseFrontmatter('---\ndescription: |-\n one\n two\n---\n').meta.description).toBe('one\ntwo'); + }); + + it('keeps every trailing line break with `+`', () => { + expect(parseFrontmatter('---\ndescription: >+\n one\n two\n\n\nname: x\n---\n').meta.description).toBe('one two\n\n\n'); + expect(parseFrontmatter('---\ndescription: |+\n one\n\nname: x\n---\n').meta.description).toBe('one\n\n'); + }); + + it('reads the keys after a block', () => { + const out = parseFrontmatter('---\ndescription: >\n one\n two\n\nname: x\ntriggers:\n - a\n - b\n---\nbody'); + expect(out.meta.description).toBe('one two\n'); + expect(out.meta.name).toBe('x'); + expect(out.meta.triggers).toEqual(['a', 'b']); + expect(out.body).toBe('body'); + }); + + it('ends a block at the closing ---', () => { + const out = parseFrontmatter('---\nname: x\ndescription: |\n one\n two\n---\nbody'); + expect(out.meta.description).toBe('one\ntwo\n'); + expect(out.body).toBe('body'); + }); + + it('reads a block from a CRLF file', () => { + const out = parseFrontmatter(FOLDED.replace(/\n/g, '\r\n')); + expect(out.meta.description).toBe(FOLDED_DESCRIPTION); + expect(out.body).toBe('# Tidy'); + }); + + it('takes the indentation from the header when it gives one', () => { + const out = parseFrontmatter('---\ndescription: |2\n deeper\n flush\n---\n'); + expect(out.meta.description).toBe(' deeper\nflush\n'); + }); + + it('allows a comment after the header', () => { + const out = parseFrontmatter('---\ndescription: >- # one line\n one\n two\n---\n'); + expect(out.meta.description).toBe('one two'); + }); + + it('reads `- item` lines inside a block as text, not as a list', () => { + const out = parseFrontmatter('---\ndescription: |\n - one\n - two\n---\n'); + expect(out.meta.description).toBe('- one\n- two\n'); + }); + + it('reads a block with no lines as an empty string', () => { + const out = parseFrontmatter('---\ndescription: >\nname: x\n---\n'); + expect(out.meta.description).toBe(''); + expect(out.meta.name).toBe('x'); + }); + + it('leaves a quoted `>` as a plain value', () => { + const out = parseFrontmatter('---\ndescription: ">"\nname: x\n---\n'); + expect(out.meta.description).toBe('>'); + expect(out.meta.name).toBe('x'); + }); +}); + // ─── formatBundlesForSysprompt ────────────────────────────────────────────── describe('formatBundlesForSysprompt', () => { it('returns an empty string when no bundles are installed', () => { @@ -168,11 +266,12 @@ describe('formatBundlesForSysprompt', () => { expect(out).not.toContain('triggers:'); }); - it('caps the per-line length at 200 chars', () => { - const long = bundle({ description: 'x'.repeat(300) }); + it('caps the per-line length at 1200 chars', () => { + // Room for a description at the Agent Skills limit of 1024, and no more. + const long = bundle({ description: 'x'.repeat(1300) }); const out = formatBundlesForSysprompt([long]); const line = out.split('\n').find((l) => l.startsWith('- **'))!; - expect(line.length).toBeLessThanOrEqual(201); // 200 + ellipsis + expect(line.length).toBe(1201); // 1200 + ellipsis: one long word is cut, not dropped expect(line.endsWith('…')).toBe(true); }); diff --git a/src/utils/skillBundles.ts b/src/utils/skillBundles.ts index c4e85ac..22418a4 100644 --- a/src/utils/skillBundles.ts +++ b/src/utils/skillBundles.ts @@ -8,10 +8,13 @@ * * /.codeep/skills//SKILL.md (project-scoped) * ~/.codeep/skills//SKILL.md (global) + * ~/.agents/skills//SKILL.md (global, shared by agents) * - * Project bundles shadow global ones with the same name. The bundle dir + * Project bundles shadow global ones with the same name, and + * `~/.codeep/skills` shadows `~/.agents/skills`. The bundle dir * may also contain auxiliary files (`assets/`, `scripts/`, …) that the - * SKILL.md body refers to — we don't enforce any sub-structure. + * SKILL.md body refers to — we don't enforce any sub-structure, and the + * agent reads them through invoke_skill (readSkillFile). * * The SKILL.md format is a deliberate superset of Claude Code's skills * format so existing skills can be dropped in unchanged. Frontmatter @@ -34,8 +37,8 @@ * reads when it decides to invoke the skill. */ -import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; -import { join } from 'path'; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'fs'; +import { dirname, isAbsolute, join, relative, sep } from 'path'; import { homedir } from 'os'; import { leadsOutsideProject } from './projectPaths'; @@ -76,9 +79,12 @@ interface ParsedFrontmatter { /** * Tolerant YAML-frontmatter parser — handles `key: value`, `key: [a, b]`, - * and `key:` followed by `- item` block-list lines. Quoted strings are - * unquoted. We don't ship a real YAML dep for this — the keys we care - * about are scalars or simple arrays. + * `key:` followed by `- item` block-list lines, and `key: >` / `key: |` + * block scalars. Quoted strings are unquoted. Not js-yaml, although it is + * a dependency (reviewConfig.ts): it throws on frontmatter that skills are + * written with and other agents read, like an unquoted + * `description: Deploy: build, then ship`, and one such line would drop + * the whole skill. The keys we care about are scalars or simple arrays. */ export function parseFrontmatter(raw: string): ParsedFrontmatter { // BOM + CRLF normalisation. Real-world files copy/paste from various @@ -92,8 +98,8 @@ export function parseFrontmatter(raw: string): ParsedFrontmatter { const lines = match[1].split('\n'); let currentList: string[] | null = null; - for (const rawLine of lines) { - const line = rawLine.replace(/\s+$/, ''); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].replace(/\s+$/, ''); if (!line.trim()) { currentList = null; continue; } // Block-list item: ` - foo` @@ -116,6 +122,18 @@ export function parseFrontmatter(raw: string): ParsedFrontmatter { continue; } + // Block scalar: the value is the indented lines below. Long + // descriptions are written this way (Omarchy's skills all use + // `description: >`); read as a plain value they'd be just `>`. + const header = value.match(BLOCK_SCALAR_HEADER); + if (header) { + const block = readBlockScalar(lines, i + 1, header[1], header[2]); + meta[key] = block.value; + currentList = null; + i = block.end - 1; + continue; + } + // Inline list: `[a, b, c]` const inline = value.match(/^\[(.*)\]$/); if (inline) { @@ -133,13 +151,73 @@ export function stripQuotes(s: string): string { return s.replace(/^["']|["']$/g, ''); } +/** `>` or `|`, then a chomping (`-`, `+`) and/or indentation (`2`) indicator. */ +const BLOCK_SCALAR_HEADER = /^([>|])([1-9][+-]?|[+-][1-9]?)?(?:\s+#.*)?$/; + +/** + * Read a block scalar's lines, starting at `start` (the line after the + * header), the way YAML does. The block runs while lines are blank or + * indented: the first non-blank line sets the depth, unless the header + * gives it, and the first line less indented than that ends it. `|` keeps + * the line breaks; `>` folds each into a space, except that blank lines + * stay line breaks and so do the breaks around a more-indented line. + * Chomping decides the trailing breaks: `-` drops them, `+` keeps them all, + * and with neither the value ends in exactly one. Returns the value and the + * index of the first line after the block. + */ +function readBlockScalar( + lines: string[], + start: number, + style: string, + indicators = '', +): { value: string; end: number } { + const folded = style === '>'; + const chomping = indicators.includes('-') ? 'strip' : indicators.includes('+') ? 'keep' : 'clip'; + let indent = Number(indicators.replace(/[+-]/, '')) || 0; + + let value = ''; + let blankLines = 0; + let hasContent = false; + let afterMoreIndented = false; + let i = start; + for (; i < lines.length; i++) { + const line = lines[i].replace(/\s+$/, ''); + if (!line) { blankLines++; continue; } + const depth = line.match(/^[ \t]*/)![0].length; + // Keys sit at column 0, so a line there is the next key, never content. + if (depth === 0 || depth < indent) break; + if (!indent) indent = depth; + + const text = line.slice(indent); + const moreIndented = /^[ \t]/.test(text); + if (!folded || moreIndented || afterMoreIndented) { + value += '\n'.repeat(hasContent ? blankLines + 1 : blankLines); + } else if (blankLines > 0) { + value += '\n'.repeat(blankLines); + } else if (hasContent) { + value += ' '; + } + value += text; + hasContent = true; + blankLines = 0; + afterMoreIndented = folded && moreIndented; + } + + if (chomping === 'keep') value += '\n'.repeat(hasContent ? blankLines + 1 : blankLines); + else if (chomping === 'clip' && hasContent) value += '\n'; + return { value, end: i }; +} + /** Largest SKILL.md we'll read (256 KB). */ const MAX_SKILL_FILE_BYTES = 256 * 1024; function loadFromDir(dir: string, scope: 'project' | 'global', projectRoot?: string): SkillBundle[] { if (!existsSync(dir)) return []; let entries: string[]; - try { entries = readdirSync(dir); } catch { return []; } + // Sorted, so which of two directories claiming the same `name:` wins does + // not depend on the order the filesystem lists them in. Node sorts on + // Linux and macOS already; on Windows the listing is the filesystem's own. + try { entries = readdirSync(dir).sort(); } catch { return []; } const bundles: SkillBundle[] = []; for (const entry of entries) { @@ -166,10 +244,10 @@ function loadFromDir(dir: string, scope: 'project' | 'global', projectRoot?: str } catch { continue; } const { meta, body } = parseFrontmatter(raw); - const name = typeof meta.name === 'string' && meta.name ? meta.name : entry; + const name = oneLine(meta.name) || entry; if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) continue; // sanitise - const description = typeof meta.description === 'string' ? meta.description : ''; + const description = oneLine(meta.description); if (!description) continue; // catalog entry without a description is noise bundles.push({ @@ -190,6 +268,15 @@ function loadFromDir(dir: string, scope: 'project' | 'global', projectRoot?: str return bundles; } +/** + * A scalar as the single line the catalog, `/skills bundles` and the + * invoke_skill header print it on. A block scalar (`description: >`) ends + * in a line break, and a literal one (`|`) has more inside. + */ +function oneLine(v: unknown): string { + return typeof v === 'string' ? v.replace(/\s*\n\s*/g, ' ').trim() : ''; +} + export function asStringArray(v: unknown): string[] | null { if (Array.isArray(v)) return v.filter(x => typeof x === 'string') as string[]; if (typeof v === 'string') return [v]; @@ -197,21 +284,70 @@ export function asStringArray(v: unknown): string[] | null { } /** - * Load all skill bundles available in this workspace. Project entries - * shadow global entries with the same name. + * The user's own skill directories, the one that wins a name first. + * `~/.agents/skills` is shared by every agent harness on the machine — + * Omarchy links its skills in there — so a bundle written for Codeep + * alone in `~/.codeep/skills` overrides one of the same name from it. + * Both are the user's: a link in either may lead anywhere. + */ +function globalSkillDirs(): string[] { + return [join(homedir(), '.codeep', 'skills'), join(homedir(), '.agents', 'skills')]; +} + +/** + * Load all skill bundles available in this workspace. When two share a + * name, the project's `.codeep/skills` wins, then `~/.codeep/skills`, + * then `~/.agents/skills`; the others are dropped. */ export function loadSkillBundles(workspaceRoot?: string): SkillBundle[] { - const global = loadFromDir(join(homedir(), '.codeep', 'skills'), 'global'); const project = workspaceRoot ? loadFromDir(join(workspaceRoot, '.codeep', 'skills'), 'project', workspaceRoot) : []; + const global = globalSkillDirs().flatMap(dir => loadFromDir(dir, 'global')); const byName = new Map(); - for (const b of global) byName.set(b.name, b); - for (const b of project) byName.set(b.name, b); // project wins + for (const b of [...project, ...global]) { + if (!byName.has(b.name)) byName.set(b.name, b); // the first to claim a name keeps it + } return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); } +/** + * A file from a bundle's directory: a guide its SKILL.md sends the agent to + * ("read reporting.md before filing"). Omarchy's skills keep most of their + * instructions in files like that, and read_file cannot reach them — a global + * bundle is outside the project. + * + * Only inside the bundle: the name is relative and has no `..`, and where it + * resolves through links is checked as well, so a link in the bundle cannot + * hand out a file from somewhere else. The size cap and the regular-file + * check are SKILL.md's own, for the same reasons (see loadFromDir). + */ +export function readSkillFile(bundle: SkillBundle, file: string): { content: string } | { error: string } { + const segments = file.split(/[\\/]+/).filter(Boolean); + if (segments.length === 0 || isAbsolute(file) || segments.includes('..')) { + return { error: `"${file}" is not a path inside the skill's directory. Give it relative to that directory, e.g. "guide.md".` }; + } + let target: string; + try { + target = realpathSync(join(bundle.source, ...segments)); + const inside = relative(realpathSync(bundle.source), target); + if (inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) { + return { error: `"${file}" leads outside the skill's directory.` }; + } + } catch { + return { error: `"${file}" was not found in the skill's directory (${bundle.source}).` }; + } + try { + const stat = statSync(target); + if (!stat.isFile()) return { error: `"${file}" is not a file.` }; + if (stat.size > MAX_SKILL_FILE_BYTES) return { error: `"${file}" is larger than ${MAX_SKILL_FILE_BYTES / 1024} KB.` }; + return { content: readFileSync(target, 'utf-8') }; + } catch (err) { + return { error: `"${file}" could not be read: ${(err as Error).message}` }; + } +} + /** Find a single bundle by name (case-insensitive). */ export function findSkillBundle(name: string, workspaceRoot?: string): SkillBundle | null { const lower = name.toLowerCase(); @@ -226,7 +362,11 @@ export function findSkillBundle(name: string, workspaceRoot?: string): SkillBund */ export function formatBundlesForSysprompt(bundles: SkillBundle[]): string { if (bundles.length === 0) return ''; - const CAP_PER_LINE = 200; + // Room for a description at the Agent Skills limit of 1024 characters. The + // end of one is often what picks the skill: Omarchy's run 470–590 and close + // on when to use them and what they exclude. CAP_TOTAL is what keeps a + // hundred skills out of the prompt. + const CAP_PER_LINE = 1200; const CAP_TOTAL = 4000; const lines: string[] = [ '## Available skill bundles', @@ -239,7 +379,9 @@ export function formatBundlesForSysprompt(bundles: SkillBundle[]): string { for (const b of bundles) { const triggerHint = b.triggers.length > 0 ? ` _(triggers: ${b.triggers.slice(0, 3).join(', ')})_` : ''; const line = `- **${b.name}** — ${b.description}${triggerHint}`; - const truncated = line.length > CAP_PER_LINE ? line.slice(0, CAP_PER_LINE) + '…' : line; + // At a word boundary, so the model is not handed half a word as a hint + // — unless the "word" is so long that the line would lose it whole. + const truncated = line.length > CAP_PER_LINE ? line.slice(0, CAP_PER_LINE).replace(/\s+\S{0,40}$/, '') + '…' : line; if (used + truncated.length + 1 > CAP_TOTAL) { skipped++; continue; } lines.push(truncated); used += truncated.length + 1; @@ -254,7 +396,7 @@ export function formatBundleList(bundles: SkillBundle[]): string { return [ '_No skill bundles installed yet._', '', - 'Create one with `/skills create-bundle ` (project) or drop a directory into `~/.codeep/skills//` (global). Each needs a `SKILL.md` with at least `name` and `description` in the frontmatter.', + 'Create one with `/skills create-bundle ` (project) or drop a directory into `~/.codeep/skills//` or `~/.agents/skills//` (global). Each needs a `SKILL.md` with at least `name` and `description` in the frontmatter.', ].join('\n'); } const project = bundles.filter(b => b.scope === 'project'); @@ -265,11 +407,24 @@ export function formatBundleList(bundles: SkillBundle[]): string { for (const b of project) lines.push(`- **${b.name}** ${b.version ? `\`v${b.version}\` ` : ''}— ${b.description}`); lines.push(''); } - if (global.length) { - lines.push('**Global**'); - for (const b of global) lines.push(`- **${b.name}** ${b.version ? `\`v${b.version}\` ` : ''}— ${b.description}`); + // Global bundles come from more than one directory. Each gets a heading + // naming its own, in precedence order, so the user can tell which file + // to edit — and that a skill they didn't write came from ~/.agents/skills. + const globalDirs = [...new Set([...globalSkillDirs(), ...global.map(b => dirname(b.source))])]; + for (const dir of globalDirs) { + const here = global.filter(b => dirname(b.source) === dir); + if (!here.length) continue; + lines.push(`**Global** — \`${displayDir(dir)}\``); + for (const b of here) lines.push(`- **${b.name}** ${b.version ? `\`v${b.version}\` ` : ''}— ${b.description}`); + lines.push(''); } - return lines.join('\n'); + return lines.join('\n').trimEnd(); +} + +/** `dir` with the home directory written as `~`, the way the user types it. */ +function displayDir(dir: string): string { + const home = homedir(); + return dir.startsWith(home + sep) ? `~${dir.slice(home.length)}` : dir; } /** diff --git a/src/utils/skillBundlesCloud.ts b/src/utils/skillBundlesCloud.ts index 9f9bc28..704bdd7 100644 --- a/src/utils/skillBundlesCloud.ts +++ b/src/utils/skillBundlesCloud.ts @@ -37,11 +37,12 @@ export interface RemoteSkill { /** * Publish a skill bundle to codeep.dev. The bundle may be project-scoped * (`/.codeep/skills//SKILL.md`) or global - * (`~/.codeep/skills//SKILL.md`) — either is publishable. The - * `--public` flag (translated into `opts.isPublic`) is the user's - * explicit consent gate; we don't gate further on bundle scope. If both - * exist with the same slug, the project copy wins (mirrors the rest of - * the bundle-loading flow). + * (`~/.codeep/skills//SKILL.md` or `~/.agents/skills//SKILL.md`) + * — any is publishable. The `--public` flag (translated into + * `opts.isPublic`) is the user's explicit consent gate; we don't gate + * further on bundle scope. If more than one exists with the same slug, + * the one loadSkillBundles keeps wins (project, then `~/.codeep/skills`, + * then `~/.agents/skills`). */ export async function publishBundle( workspaceRoot: string, @@ -55,7 +56,7 @@ export async function publishBundle( if (!bundle) { return { ok: false, - error: `Skill bundle "${slug}" not found in either \`.codeep/skills/${slug}/\` (project) or \`~/.codeep/skills/${slug}/\` (global). Run \`/skills create-bundle ${slug}\` to scaffold one, or check \`/skills bundles\` to see what's available.`, + error: `Skill bundle "${slug}" not found in \`.codeep/skills/${slug}/\` (project), \`~/.codeep/skills/${slug}/\` or \`~/.agents/skills/${slug}/\` (global). Run \`/skills create-bundle ${slug}\` to scaffold one, or check \`/skills bundles\` to see what's available.`, }; } diff --git a/src/utils/toolExecution.test.ts b/src/utils/toolExecution.test.ts index d1f24c3..5403014 100644 --- a/src/utils/toolExecution.test.ts +++ b/src/utils/toolExecution.test.ts @@ -468,6 +468,69 @@ describe('hooks + MCP integration', () => { }); }); +describe('invoke_skill — the files beside SKILL.md', () => { + // Installed the way Omarchy installs its skills: `~/.agents/skills/` + // is a link to a directory outside both the home and the project, and the + // SKILL.md sends the agent to a guide next to it. + const savedHome = process.env.HOME; + let home: string; + let elsewhere: string; + let bundleDir: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'codeep-skill-home-')); + elsewhere = mkdtempSync(join(tmpdir(), 'codeep-skill-elsewhere-')); + process.env.HOME = home; + bundleDir = join(elsewhere, 'crash'); + mkdirSync(bundleDir); + writeFileSync(join(bundleDir, 'SKILL.md'), '---\nname: crash\ndescription: Diagnose a crash\n---\nRead [reporting.md](reporting.md) before filing.'); + writeFileSync(join(bundleDir, 'reporting.md'), 'File it upstream.'); + mkdirSync(join(home, '.agents', 'skills'), { recursive: true }); + symlinkSync(bundleDir, join(home, '.agents', 'skills', 'crash')); + }); + + afterEach(() => { + process.env.HOME = savedHome; + rmSync(home, { recursive: true, force: true }); + rmSync(elsewhere, { recursive: true, force: true }); + }); + + const invoke = (parameters: Record) => + executeTool(makeCall('invoke_skill', { name: 'crash', ...parameters }), tmpRoot); + + it('says where the skill is, since its SKILL.md names files relative to itself', async () => { + const result = await invoke({}); + expect(result.success).toBe(true); + expect(result.output).toContain(`Skill directory: ${join(home, '.agents', 'skills', 'crash')}`); + expect(result.output).toContain('"file": "guide.md"'); + expect(result.output).toContain('Read [reporting.md](reporting.md) before filing.'); + }); + + it('reads a file beside SKILL.md, which read_file cannot reach from the project', async () => { + expect((await invoke({ file: 'reporting.md' })).output).toContain('File it upstream.'); + const outside = await executeTool(makeCall('read_file', { path: join(home, '.agents', 'skills', 'crash', 'reporting.md') }), tmpRoot); + expect(outside.success).toBe(false); + }); + + it('reads nothing outside the skill\'s directory', async () => { + writeFileSync(join(elsewhere, 'secret.md'), 'not the skill\'s'); + symlinkSync(join(elsewhere, 'secret.md'), join(bundleDir, 'linked.md')); + for (const file of ['../secret.md', 'sub/../../secret.md', join(elsewhere, 'secret.md'), 'linked.md']) { + const result = await invoke({ file }); + expect(result.success, file).toBe(false); + expect(result.output, file).toBe(''); + } + }); + + it('reads only regular files, and not past SKILL.md\'s own size cap', async () => { + mkdirSync(join(bundleDir, 'assets')); + writeFileSync(join(bundleDir, 'huge.md'), 'x'.repeat(256 * 1024 + 1)); + expect((await invoke({ file: 'assets' })).error).toMatch(/not a file/); + expect((await invoke({ file: 'huge.md' })).error).toMatch(/larger than 256 KB/); + expect((await invoke({ file: 'missing.md' })).error).toMatch(/not found/); + }); +}); + describe('trustBearingWrite — the files that decide what runs later', () => { const reasonFor = (tool: string, path: unknown) => trustBearingWrite(makeCall(tool, { path }), tmpRoot)?.reason ?? null; @@ -514,6 +577,11 @@ describe('trustBearingWrite — the files that decide what runs later', () => { expect(reasonFor('write_file', '.codeep/config.json')).toMatch(/Codeep's own configuration/); // A skill's steps are commands Codeep runs when the skill is used. expect(reasonFor('write_file', '.codeep/skills/deploy/SKILL.md')).toMatch(/steps are commands/); + // The directory agent tools share: every Codeep session loads + // `~/.agents/skills`, which is this path in a project rooted at home. + expect(reasonFor('write_file', '.agents/skills/omarchy/SKILL.md')).toMatch(/every Codeep session loads it/); + expect(reasonFor('create_directory', '.agents/skills')).toMatch(/every Codeep session loads it/); + expect(reasonFor('write_file', '.agents/notes.md')).toBeNull(); // A sub-agent definition: its `tools:` REPLACES the parent's allowlist for // the delegated run and its `model:` picks the provider that run's context // is sent to, both the next time anything delegates to that name. That is diff --git a/src/utils/toolExecution.ts b/src/utils/toolExecution.ts index c2addb9..7f12b88 100644 --- a/src/utils/toolExecution.ts +++ b/src/utils/toolExecution.ts @@ -108,6 +108,10 @@ export function validatePath(path: string, projectRoot: string): { valid: boolea // - `.codeep/hooks/` — scripts Codeep itself runs around every tool call. // - `.codeep/skills/` — a skill's steps are commands Codeep runs when the // skill is used (see skillBundles.ts, which loads project skills). +// - `.agents/skills/` — the same, in the directory agent tools share. +// Codeep loads `~/.agents/skills` in every session, and that is where a +// project rooted at the home directory writes; other agent tools read a +// repository's own `.agents/skills` as well. // - `.codeep/agents/` — a sub-agent definition. Its `tools:` is the // allowlist the nested run is checked against and REPLACES the parent's, // so it can hand a delegated run a tool this run was restricted from; its @@ -169,6 +173,7 @@ const HOOKS_AT_ROOT_REASON = 'from here by name — writing one can install a hook that runs on your next commit.'; const CODEEP_HOOK_REASON = 'This file runs on every tool call.'; const SKILL_REASON = 'This is a skill — its steps are commands Codeep runs whenever the skill is used.'; +const SHARED_SKILL_REASON = 'This is a skill for agent tools — its steps are commands an agent runs whenever the skill is used, and from ~/.agents/skills every Codeep session loads it.'; const MCP_SERVERS_REASON = 'This file starts MCP servers — every entry is a command Codeep spawns.'; const AGENT_REASON = 'This file defines a sub-agent — the tools it may use and the model it runs on, every time something delegates to it.'; const CODEEP_CONFIG_REASON = "This file is Codeep's own configuration for this project."; @@ -311,6 +316,7 @@ function reasonForSegments(relativePath: string, hooksDir: () => HooksDirectory) const last = segments[segments.length - 1]; if (last === '.mcp.json') return MCP_SERVERS_REASON; for (let i = 0; i < segments.length - 1; i++) { + if (segments[i] === '.agents' && segments[i + 1] === 'skills') return SHARED_SKILL_REASON; if (segments[i] !== '.codeep') continue; if (segments[i + 1] === 'hooks') return CODEEP_HOOK_REASON; if (segments[i + 1] === 'skills') return SKILL_REASON; @@ -639,20 +645,38 @@ export async function executeTool( // invoke_skill — agent-driven skill bundle invocation. Returns the // bundle's SKILL.md body so the agent can read its instructions in - // the next iteration. Project-scoped bundles win over global. + // the next iteration, or with `file` one of the files beside it. + // Project-scoped bundles win over global. if (rawTool === 'invoke_skill') { const name = String((parameters as Record).name ?? '').trim(); if (!name) { return { success: false, output: '', error: 'invoke_skill requires a `name` argument', tool: rawTool, parameters }; } try { - const { findSkillBundle } = await import('./skillBundles'); + const { findSkillBundle, readSkillFile } = await import('./skillBundles'); const bundle = findSkillBundle(name, projectRoot); if (!bundle) { return { success: false, output: '', error: `Skill "${name}" not found. Use the catalog in your system prompt or ask the user to /skills bundles.`, tool: rawTool, parameters }; } - // Prefix the body with a header so the model sees clear framing. - const output = `# Skill: ${bundle.name}\n_${bundle.description}_\n\n${bundle.body}`; + const file = String((parameters as Record).file ?? '').trim(); + if (file) { + const read = readSkillFile(bundle, file); + if ('error' in read) return { success: false, output: '', error: read.error, tool: rawTool, parameters }; + return { success: true, output: `# Skill: ${bundle.name} — ${file}\n\n${read.content}`, tool: rawTool, parameters }; + } + // Prefix the body with a header so the model sees clear framing, and + // with the bundle's directory: a SKILL.md names its other files + // relative to itself, and a global bundle's are outside the project, + // where read_file cannot go. + const output = [ + `# Skill: ${bundle.name}`, + `_${bundle.description}_`, + '', + `Skill directory: ${bundle.source}`, + `Read a file this skill refers to with invoke_skill and its path in that directory, e.g. {"name": "${bundle.name}", "file": "guide.md"}.`, + '', + bundle.body, + ].join('\n'); return { success: true, output, tool: rawTool, parameters }; } catch (err) { return { success: false, output: '', error: (err as Error).message, tool: rawTool, parameters };