From b38af9a071e8c9bc29730deab231823f13df609b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:41:21 +0000 Subject: [PATCH 1/8] fix(brand): README logo with the full-size inner chevrons Codeep.svg (the README logo) was the variant whose four inner chevrons were scaled to ~84% and thinned from 104 to 65 units, so they no longer matched the corner brackets. It is now the same geometry as codeep.dev's logo and the Mac app icon: brackets 312 long and 104 thick, chevrons 104 thick with their tips on the outer edge, on a clean 960 grid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bp1pRXzJVR8Rb2CsNWzzmo --- Codeep.svg | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Codeep.svg b/Codeep.svg index 48c5d01..e72eecc 100644 --- a/Codeep.svg +++ b/Codeep.svg @@ -1,6 +1,6 @@ - - - - - + + + + + From 0e651c22792a3ba8f92212855ec8c183bc129019 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:02:32 +0000 Subject: [PATCH 2/8] feat(intro): the mascot glances around while CODEEP decrypts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/mascot.ts: the pixel creature from Codeep Maskot.svg in half blocks (one cell = two pixels), 16 columns x 7 lines. Idle, look-left, look-right and blink frames differ only on the pupil row; ▐█▌ moves the pupils by half a cell, so they never touch the eye's rim. - The startup intro (App.renderIntro) draws it left of the wordmark, 3 columns apart, the pair centred, with the wordmark on mascot lines 1..6. The mascot skips the noise/decrypt effect: it is idle from the first frame, then glances centre, left, centre, right, holds centre, and blinks once as the wordmark finishes. The steps scale with the decrypt length (250 ms each over the App's 1.5 s). Below 72 columns the layout is exactly the old wordmark-only one. - The decrypt phase now starts at progress 0: the noise phase's last tick scheduled a render after the switch, which read its random progress as decrypt time (a random first mascot frame, and a glitched first wordmark frame that predates this change). - Intro.ts (showIntro/showLogoStatic, currently unused) follows the same layout so the two don't drift. - No timers added (it reuses the intro's intervals); SPINNER_FRAMES, the agent panel, ACP/Zed and non-TUI output are untouched. - Tests: frames are 7x16 and differ only on line 1; layout centring, gap and the 72/71-column fallback; the glance/blink sequence at 1000 and 1500 ms; and the real startIntro under fake timers yields idle, left, idle, right, idle, blink. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bp1pRXzJVR8Rb2CsNWzzmo --- src/renderer/App.test.ts | 99 +++++++++++++++++++++ src/renderer/App.ts | 32 +++++-- src/renderer/components/Intro.ts | 30 +++++-- src/renderer/components/mascot.test.ts | 115 +++++++++++++++++++++++++ src/renderer/components/mascot.ts | 92 ++++++++++++++++++++ 5 files changed, 352 insertions(+), 16 deletions(-) create mode 100644 src/renderer/components/mascot.test.ts create mode 100644 src/renderer/components/mascot.ts 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..4ce2e1e 100644 --- a/src/renderer/App.ts +++ b/src/renderer/App.ts @@ -8,6 +8,7 @@ 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 { 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, @@ -282,6 +283,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 @@ -840,8 +843,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; @@ -3128,22 +3134,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/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/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; +} From 6ca395494a2eb27206d41a2c5e0238f1d7ae70dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:36:53 +0000 Subject: [PATCH 3/8] fix(help): /mcp browse no longer promises 12 servers The marketplace was trimmed to 9 entries when the catalog was synced (src/utils/mcpMarketplace.ts), but the /help line still said "12 servers", and codeep.dev's command reference, generated from this registry, repeats it next to the site's own "Browse all 9 servers". The description no longer carries a count that can go stale. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bp1pRXzJVR8Rb2CsNWzzmo --- src/renderer/commands/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' }, From 66672a3b01255340d4aeb18adf73dfdabd7444d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:23:13 +0000 Subject: [PATCH 4/8] feat(cli): --yolo and a launch prompt, so a launcher can start Codeep unattended Launchers like Omarchy start every coding agent unattended, optionally with a task: 'crush --yolo', 'claude --permission-mode auto -- ""'. Codeep only took subcommands, so it could not be one of them. - cliArgs.ts parses the launch line in one pure function. `-- ` is the prompt (so `-- review` asks the model instead of running `codeep review`); `-p` / `--prompt` take the next argument. A missing or doubled prompt is an error, not a guess. The subcommands, --help and --version route as before. - --yolo pins Agent Confirmation to Never and Agent Interactive Mode to Off for this process only (agentConfirmation.ts). Every runtime reader goes through that pin; nothing is written to the config. It also skips "Set as Project?", the access dialog (read & write, unsaved) and the session picker. Changing either setting in /settings releases the pin. - A YOLO badge leads the status bar for as long as confirmations are off. - A launch prompt goes in through the same path as a phone instruction (submitAsTyped): it shows as the user's message, after every startup question, including the workspace MCP trust question, which no longer replaces "Set as Project?" in plain folders. - Still confirmed under --yolo: writes to files that decide what runs later, and a workspace's own MCP servers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bp1pRXzJVR8Rb2CsNWzzmo --- src/renderer/App.ts | 32 ++- src/renderer/App.yolo.test.ts | 79 ++++++ src/renderer/agentConfirmation.test.ts | 199 ++++++++++++++ src/renderer/agentConfirmation.ts | 57 ++++ src/renderer/agentExecution.ts | 7 +- src/renderer/cliArgs.test.ts | 144 ++++++++++ src/renderer/cliArgs.ts | 106 ++++++++ src/renderer/components/Settings.test.ts | 46 +++- src/renderer/components/Settings.ts | 6 + src/renderer/main.launch.test.ts | 326 +++++++++++++++++++++++ src/renderer/main.ts | 161 ++++++++--- 11 files changed, 1118 insertions(+), 45 deletions(-) create mode 100644 src/renderer/App.yolo.test.ts create mode 100644 src/renderer/agentConfirmation.test.ts create mode 100644 src/renderer/agentConfirmation.ts create mode 100644 src/renderer/cliArgs.test.ts create mode 100644 src/renderer/cliArgs.ts create mode 100644 src/renderer/main.launch.test.ts diff --git a/src/renderer/App.ts b/src/renderer/App.ts index 4ce2e1e..c284dec 100644 --- a/src/renderer/App.ts +++ b/src/renderer/App.ts @@ -5,7 +5,7 @@ import { Screen } from './Screen'; import { Input, LineEditor, KeyEvent } from './Input'; -import { fg, style } from './ansi'; +import { fg, bg, style } from './ansi'; import { getActionColor, formatActionTarget, getActionLabel } from './components/ActionFormatting'; import { PRIMARY_COLOR, SPINNER_FRAMES, LOGO_LINES } from './components/uiConstants'; import { MASCOT_FRAMES, layoutLogoWithMascot, introMascotFrame } from './components/mascot'; @@ -121,8 +121,16 @@ 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. */ +const YOLO_BADGE = ' YOLO '; +const YOLO_BADGE_STYLE = bg.color256(208) + fg.black + style.bold; + export class App { private screen: Screen; private input: Input; @@ -2865,11 +2873,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, YOLO_BADGE_STYLE); + 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 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; } @@ -2915,7 +2933,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. @@ -2924,7 +2942,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); } @@ -2932,7 +2950,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); } } @@ -2946,7 +2964,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; 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/components/Settings.test.ts b/src/renderer/components/Settings.test.ts index 2db9d1e..08ec3ea 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 diff --git a/src/renderer/components/Settings.ts b/src/renderer/components/Settings.ts index 65c1a30..e00dda0 100644 --- a/src/renderer/components/Settings.ts +++ b/src/renderer/components/Settings.ts @@ -4,6 +4,7 @@ import { config, type ConfigSchema } from '../../config/index'; import { updateRateLimits } from '../../utils/ratelimit'; +import { pinAgentConfirmation, pinAgentInteractive } from '../agentConfirmation'; // Primary color: #f02a30 (Codeep red) @@ -38,6 +39,11 @@ 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); } export const SETTINGS: SettingItem[] = [ 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..e0b0781 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -59,6 +59,8 @@ 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'; // ─── Global state ───────────────────────────────────────────────────────────── @@ -299,7 +301,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 +468,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 +620,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 +661,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 +681,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 +843,27 @@ 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); + } + 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 +883,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 +937,7 @@ Commands (in chat): hasWriteAccess: () => hasWriteAccess, hasProjectContext: () => projectContext !== null, getProjectRoot: () => projectContext?.root || projectPath || process.cwd(), + yolo: isAgentConfirmationPinned, }); const provider = getCurrentProvider(); @@ -899,6 +960,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 +1034,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 +1052,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 +1082,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 +1095,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 +1170,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 +1205,7 @@ Commands (in chat): } else { app.notify('No project access - chat only mode'); } - showSessionPickerInline(); + finishStartup(); }); }; @@ -1130,12 +1223,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(); } }; From 4142ccc5ce45651187c772ea69fd2b10f0f02e33 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:23:13 +0000 Subject: [PATCH 5/8] feat(skills): load ~/.agents/skills, read folded descriptions and skill files Omarchy links its skills (desktop customisation, crash diagnosis, app building) into ~/.agents/skills, the directory agent tools share. Codeep only read .codeep/skills and ~/.codeep/skills, and would have dropped those skills anyway. - ~/.agents/skills is a third, global source. Project .codeep/skills wins a name, then ~/.codeep/skills, then ~/.agents/skills. Global symlinks load; the project rule against links leading out of the repo is unchanged. /skills bundles groups them by directory. - parseFrontmatter reads YAML block scalars for any key (`>` folded, `|` literal, chomping and indentation indicators). Omarchy's descriptions are `description: >` and parsed as ">", so the skill was skipped. js-yaml stays out: it rejects plain values like "Deploy: build, then ship" that SKILL.md files in the wild use. - invoke_skill names the skill's directory and can read a file beside SKILL.md (Omarchy's point at hooks.md, theming.md...), only inside that directory, with the same size and regular-file checks. - The catalog keeps whole descriptions (it cut at 200 characters, mid-word, losing "Triggers:"), and writes to .agents/skills/ are confirmed in every mode, as .codeep/skills/ already were. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bp1pRXzJVR8Rb2CsNWzzmo --- src/utils/agent.ts | 1 + src/utils/skillBundles.integration.test.ts | 128 ++++++++++++- src/utils/skillBundles.test.ts | 105 ++++++++++- src/utils/skillBundles.ts | 203 ++++++++++++++++++--- src/utils/skillBundlesCloud.ts | 13 +- src/utils/toolExecution.test.ts | 68 +++++++ src/utils/toolExecution.ts | 32 +++- 7 files changed, 507 insertions(+), 43 deletions(-) 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 }; From cabba11bc18f2fdb344e9051c981fbd73fc7a708 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:23:13 +0000 Subject: [PATCH 6/8] docs: --yolo, the launch prompt and ~/.agents/skills README: two rows in the CLI table and the three skill locations in their precedence order. CHANGELOG: an [Unreleased] entry, per CONTRIBUTING. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bp1pRXzJVR8Rb2CsNWzzmo --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 21 +++++++++++++++++---- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eac16b..76d41e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,55 @@ 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, and skills in `~/.agents/skills` load, so Omarchy's own skills work in Codeep. + +### 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. + +### 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/README.md b/README.md index 39e5bfb..cd14f72 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 From ca7a862a51b9bb75fc872cdd12e02b8fa47ac7f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:00:33 +0000 Subject: [PATCH 7/8] feat(tui): follow the Omarchy theme, live On Omarchy Linux, Claude Code, OpenCode, Pi and Hermes recolour themselves when the user switches the desktop theme; Codeep kept its own truecolor red and One Dark syntax colours. The basic 16 ANSI colours already followed (Omarchy recolours the terminal); only truecolor did not. - palette.ts: one semantic palette for every truecolor use (brand, greys by role, syntax roles), replacing the literals and the five components' own copies of PRIMARY_COLOR. Defaults are today's exact values, pinned byte for byte by a test, so nothing changes off Omarchy. - omarchyTheme.ts reads ~/.local/state/omarchy/current/theme/colors.toml (a minimal, never-throwing TOML reader) and maps it the way Omarchy's own Claude Code template does: accent for the brand, greys mixed from foreground and background so light themes stay readable. - Live: it watches the theme directory (Omarchy swaps it with rm + mv) and handles SIGUSR2 like OpenCode, re-rendering the running TUI. Only the TUI starts it; acp, review, hook and account do not. - Settings: "Follow Omarchy theme" (on by default, applies immediately). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bp1pRXzJVR8Rb2CsNWzzmo --- CHANGELOG.md | 10 + README.md | 1 + src/config/index.ts | 7 + src/renderer/App.palette.test.ts | 88 ++++ src/renderer/App.ts | 22 + src/renderer/components/Login.ts | 6 +- src/renderer/components/MessageFormatter.ts | 19 +- src/renderer/components/Modal.ts | 6 +- src/renderer/components/Search.ts | 5 +- src/renderer/components/SelectScreen.ts | 6 +- .../components/Settings.omarchy.test.ts | 80 ++++ src/renderer/components/Settings.test.ts | 8 + src/renderer/components/Settings.ts | 22 + src/renderer/components/WelcomeFormatter.ts | 25 +- src/renderer/components/uiConstants.ts | 13 +- src/renderer/highlight.ts | 28 +- src/renderer/main.ts | 16 + src/renderer/omarchyTheme.test.ts | 409 ++++++++++++++++ src/renderer/omarchyTheme.ts | 441 ++++++++++++++++++ src/renderer/palette.defaults.test.ts | 128 +++++ src/renderer/palette.test.ts | 163 +++++++ src/renderer/palette.ts | 182 ++++++++ 22 files changed, 1640 insertions(+), 45 deletions(-) create mode 100644 src/renderer/App.palette.test.ts create mode 100644 src/renderer/components/Settings.omarchy.test.ts create mode 100644 src/renderer/omarchyTheme.test.ts create mode 100644 src/renderer/omarchyTheme.ts create mode 100644 src/renderer/palette.defaults.test.ts create mode 100644 src/renderer/palette.test.ts create mode 100644 src/renderer/palette.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d41e6..be79264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,16 @@ For releases before v1.3.35, see [GitHub Releases](https://github.com/VladoIvank `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 — + 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 diff --git a/README.md b/README.md index cd14f72..ea2689e 100644 --- a/README.md +++ b/README.md @@ -1356,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`; it also reloads on `SIGUSR2`, as OpenCode does. | ## 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..30fba37 --- /dev/null +++ b/src/renderer/App.palette.test.ts @@ -0,0 +1,88 @@ +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() { + 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 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); + 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', () => { + 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); + }); + + it('repaints on every palette change while started, and stops listening when stopped', async () => { + const { app, internals } = makeApp(); + for (const method of ['init', 'cleanup', 'render']) vi.spyOn(internals.screen, method).mockImplementation(() => {}); + 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'); + }); +}); diff --git a/src/renderer/App.ts b/src/renderer/App.ts index c284dec..7fbfb05 100644 --- a/src/renderer/App.ts +++ b/src/renderer/App.ts @@ -8,6 +8,7 @@ import { Input, LineEditor, KeyEvent } from './Input'; import { fg, bg, style } from './ansi'; import { getActionColor, formatActionTarget, getActionLabel } from './components/ActionFormatting'; import { PRIMARY_COLOR, SPINNER_FRAMES, LOGO_LINES } from './components/uiConstants'; +import { onPaletteChange } 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 { @@ -150,6 +151,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; @@ -321,14 +324,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); 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..966ab6b 100644 --- a/src/renderer/components/Modal.ts +++ b/src/renderer/components/Modal.ts @@ -7,9 +7,9 @@ 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'; export interface ModalOptions { title: string; 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 08ec3ea..7ff035f 100644 --- a/src/renderer/components/Settings.test.ts +++ b/src/renderer/components/Settings.test.ts @@ -222,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 e00dda0..e416182 100644 --- a/src/renderer/components/Settings.ts +++ b/src/renderer/components/Settings.ts @@ -5,6 +5,7 @@ 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) @@ -44,8 +45,28 @@ function writeSetting(setting: SettingItem, value: string | number | boolean): v // 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', @@ -354,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/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..22ffde2 100644 --- a/src/renderer/highlight.ts +++ b/src/renderer/highlight.ts @@ -4,19 +4,27 @@ */ 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` and `punctuation` are basic ANSI colours, which the terminal + * already themes by itself. + */ 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 codeFrame() { return palette.codeFrame; }, + get codeLang() { return palette.codeLang; }, }; const KEYWORDS: Record = { @@ -48,7 +56,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.ts b/src/renderer/main.ts index e0b0781..7867ad3 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -61,6 +61,7 @@ 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 ───────────────────────────────────────────────────────────── @@ -202,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; @@ -864,6 +867,14 @@ Commands (in chat): 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 — @@ -1253,6 +1264,11 @@ async function gracefulShutdown() { }); } + // Stop following the theme before the App goes: nothing is left to + // recolour, and the watches and the SIGUSR2 handler are the process's to + // give back. + 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..12baccf --- /dev/null +++ b/src/renderer/omarchyTheme.test.ts @@ -0,0 +1,409 @@ +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, + isOmarchy, + loadOmarchyPalette, + mix, + omarchyStateDir, + paletteFromOmarchy, + parseColorsToml, + parseHexColor, + reapplyOmarchyTheme, + type OmarchyThemeWatch, +} from './omarchyTheme'; +import { DEFAULT_PALETTE, 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 greys, in the order they get brighter in the default palette. */ +const GREYS: PaletteRole[] = [ + 'separator', 'label', 'assistantLabel', 'modelName', 'strikethrough', 'hint', 'secondaryText', 'providerName', +]; + +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'); + const fg = hex('#e0e0e0'); + + 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', () => { + expect(roles.separator).toEqual(mix(bg, fg, 0.30)); + expect(roles.label).toEqual(mix(bg, fg, 0.40)); + expect(roles.assistantLabel).toEqual(mix(bg, fg, 0.48)); + expect(roles.hint).toEqual(mix(bg, fg, 0.66)); + expect(roles.providerName).toEqual(mix(bg, fg, 0.82)); + // On a near-black background with a light grey foreground that lands + // next to Codeep's own greys, which were chosen for exactly that. + expect(roles.separator).toEqual([78, 78, 78]); + expect(roles.label).toEqual([99, 99, 99]); + }); + + 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('defines every role', () => { + expect(Object.keys(roles).sort()).toEqual(Object.keys(DEFAULT_PALETTE).sort()); + }); +}); + +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); + }); + + it('keeps the greys in order: each at least as far from the background as the one before', () => { + const contrasts = GREYS.map(role => contrastRatio(roles[role]!, bg)); + for (let i = 1; i < contrasts.length; i++) { + expect(contrasts[i], GREYS[i]).toBeGreaterThanOrEqual(contrasts[i - 1]); + } + }); + + 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 — 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(mix([0, 0, 0], [255, 255, 255], 0.3)); + }); +}); + +// ─── 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')); + }); +}); + +describe('followOmarchyTheme off Linux', () => { + it('does nothing, not even install a signal handler', () => { + const before = process.listenerCount('SIGUSR2'); + expect(followOmarchyTheme({ stateDir, platform: 'darwin' })).toBeNull(); + expect(process.listenerCount('SIGUSR2')).toBe(before); + 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', () => { + const before = process.listenerCount('SIGUSR2'); + expect(followOmarchyTheme({ stateDir: join(root, 'absent'), platform: 'linux' })).toBeNull(); + expect(process.listenerCount('SIGUSR2')).toBe(before); + 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('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 theme/ was on the directory that was just deleted; this + // only arrives if the watches were re-made. + 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('reloads on SIGUSR2, the signal Omarchy sends OpenCode', () => { + // A debounce longer than the test, so only the signal can be what reloads. + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 60_000 }); + writeFileSync(join(stateDir, 'theme', 'colors.toml'), LIGHT); + expect(palette.primary).toBe(DARK_ACCENT); + process.emit('SIGUSR2', 'SIGUSR2'); + expect(palette.primary).toBe(LIGHT_ACCENT); + }); + + it('survives the state directory disappearing, and picks it up again when it is back', async () => { + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 }); + rmSync(stateDir, { recursive: true, force: true }); + await waitFor(() => palette.primary === DEFAULT_PRIMARY); + + writeTheme(join(stateDir, 'theme'), LIGHT); + process.emit('SIGUSR2', 'SIGUSR2'); + expect(palette.primary).toBe(LIGHT_ACCENT); + // Re-armed on the new directory: a switch is seen without another signal. + switchTheme(DARK); + await waitFor(() => palette.primary === DARK_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 and gives SIGUSR2 back', async () => { + const before = process.listenerCount('SIGUSR2'); + const running = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 })!; + expect(process.listenerCount('SIGUSR2')).toBe(before + 1); + running.stop(); + expect(process.listenerCount('SIGUSR2')).toBe(before); + + 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..1a5d594 --- /dev/null +++ b/src/renderer/omarchyTheme.ts @@ -0,0 +1,441 @@ +/** + * 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 { join } from 'path'; +import { setPalette, 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)]; +} + +/** 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 two greys pushed + * up to the same floor land on the same grey instead of overtaking each + * other. + */ +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; +} + +// ─── 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`) + * accent + 35% fg → its bright variant (Claude's `claudeShimmer`) + * bg → fg mixes → the greys, faintest first: 30% (Pi's `border`), + * 40, 48 (Pi's `dimText`), 55, 60 (Claude's + * `inactive`), 66 (Pi's `mutedText`), 72, 82% + * 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 + * + * 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 — with the legibility floor catching the light + * themes where an sRGB mix lands a little faint — which is why `mode` never + * needs reading. + * + * 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): Partial> { + 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: Partial> = {}; + /** A theme colour as text: moved toward the foreground if it would not read. */ + const set = (role: PaletteRole, color: Rgb | null) => { + if (!color) return; + out[role] = background && foreground + ? mix(color, foreground, leastMixToRead(color, foreground, background, 3)) + : color; + }; + /** + * A grey `amount` of the way from background to foreground — or further, + * to the least amount that reads at `minContrast`. Taking the larger of the + * two keeps the greys in the order Codeep's defaults have them on every + * theme: a faint role can be pushed up to a brighter one's level, never + * past it. + */ + const grey = (role: PaletteRole, amount: number, minContrast = 3) => { + if (!background || !foreground) return; + out[role] = mix(background, foreground, Math.max(amount, leastMixToRead(background, foreground, background, minContrast))); + }; + + set('primary', accent); + set('primaryBright', accent && foreground ? mix(accent, foreground, 0.35) : accent); + + // Decorative separators and frames may stay faint; text may not. + grey('separator', 0.30, 1.5); + grey('codeFrame', 0.40, 1.5); + grey('label', 0.40, 2.5); + grey('assistantLabel', 0.48); + grey('modelName', 0.55); + grey('strikethrough', 0.60); + grey('hint', 0.66); + grey('codeLang', 0.66); + grey('secondaryText', 0.72); + grey('providerName', 0.82); + + set('path', cyan); + set('success', green); + set('successDetail', green && background ? mix(green, background, 0.25) : green); + set('warning', yellow); + 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); + 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()): Partial> | 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 and drop the SIGUSR2 handler. */ + stop(): void; +} + +/** The running watch, for reapplyOmarchyTheme(). One per process. */ +let active: OmarchyThemeWatch | null = null; + +/** + * Apply the current Omarchy theme and follow it until stop(). + * + * Returns null — doing nothing at all, not even installing the signal + * handler — off Linux or when there is no Omarchy state directory, so every + * other machine runs exactly as before. + * + * Two ways a change arrives: + * - the watch on `current/`, which sees every switch by itself; + * - SIGUSR2, the signal Omarchy sends OpenCode for the same purpose + * (bin/omarchy-restart-opencode), for anyone who would rather wire a + * theme-set hook (`pkill -USR2 -f codeep`) or whose filesystem does not + * deliver inotify events. It also means a stray SIGUSR2 reloads the + * theme instead of killing the session, which is Node's default. + * + * 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. + */ +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; + let stateWatch: { watcher: FSWatcher; ino: number } | null = null; + let themeWatcher: FSWatcher | null = null; + let timer: ReturnType | null = null; + let stopped = false; + + const closeQuietly = (watcher: FSWatcher | null) => { + try { watcher?.close(); } catch { /* already gone */ } + }; + + const arm = () => { + if (stopped) return; + // `current/` itself was removed (or removed and made again, which leaves + // the old watch on a dead inode): drop that watch and make a new one if + // the directory is back. + const ino = inodeOf(stateDir); + if (stateWatch && stateWatch.ino !== ino) { + closeQuietly(stateWatch.watcher); + stateWatch = null; + } + if (!stateWatch && ino !== null) { + const watcher = watchQuietly(stateDir, schedule, () => { + if (stateWatch?.watcher === watcher) stateWatch = null; + }); + if (watcher) stateWatch = { watcher, ino }; + } + // 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 onSignal = () => reload(); + process.on('SIGUSR2', onSignal); + + const handle: OmarchyThemeWatch = { + reload, + stop: () => { + if (stopped) return; + stopped = true; + if (timer) clearTimeout(timer); + timer = null; + closeQuietly(stateWatch?.watcher ?? null); + closeQuietly(themeWatcher); + stateWatch = null; + themeWatcher = null; + process.removeListener('SIGUSR2', onSignal); + 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; +} + +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..1a3092d --- /dev/null +++ b/src/renderer/palette.defaults.test.ts @@ -0,0 +1,128 @@ +/** + * 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 a digest of everything they emitted. + * + * The digest was taken from the tree before the palette was introduced + * (cabba11, where every colour was still an inline `fg.rgb(…)` literal), by + * running this same corpus against it. If it fails, something now paints a + * different escape sequence than Codeep did then: diff `renderCorpus()` + * against that commit to find which line. + */ +import { describe, it, expect } from 'vitest'; +import { createHash } from 'node:crypto'; +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(): unknown { + 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)); + out.screens = screens; + return out; +} + +/** sha256 of renderCorpus() at cabba11, before the palette. */ +const DIGEST_BEFORE_PALETTE = 'ce4bab2738f06e0829bc20fda1c58c8725604def6003b88978191bb1667e97a9'; + +describe('the default palette', () => { + it('paints exactly what Codeep painted before the palette existed', () => { + const digest = createHash('sha256').update(JSON.stringify(renderCorpus())).digest('hex'); + expect(digest).toBe(DIGEST_BEFORE_PALETTE); + }); +}); diff --git a/src/renderer/palette.test.ts b/src/renderer/palette.test.ts new file mode 100644 index 0000000..17d5b90 --- /dev/null +++ b/src/renderer/palette.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + DEFAULT_PALETTE, + PALETTE_ROLES, + PRIMARY_COLOR as PALETTE_PRIMARY, + 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); + }); + + 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('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] }); + 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'); + }); + + 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..64a32ee --- /dev/null +++ b/src/renderer/palette.ts @@ -0,0 +1,182 @@ +/** + * 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. + * + * 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 } 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); + +export type PaletteRole = keyof typeof DEFAULT_PALETTE; +export type PaletteColors = Record; + +export const PALETTE_ROLES = Object.keys(DEFAULT_PALETTE) as PaletteRole[]; + +function escapesFor(colors: PaletteColors): Record { + const out = {} as Record; + for (const role of PALETTE_ROLES) out[role] = fg.rgb(...colors[role]); + 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 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); + +/** 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]: string }; +for (const role of PALETTE_ROLES) { + Object.defineProperty(palette, role, { get: () => escapes[role], enumerable: true }); +} + +/** The current colour of every role. */ +export function getPalette(): Readonly { + return colors; +} + +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: Partial> | 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 changed = PALETTE_ROLES.some(role => next[role].some((c, i) => c !== colors[role][i])); + if (!changed) return false; + + colors = next; + escapes = escapesFor(colors); + 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); +} From fc3a57934ede8083bb78da4f58eb857093d943d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:47:20 +0000 Subject: [PATCH 8/8] fix(tui): Omarchy theme review fixes - Drop the SIGUSR2 handler. conf's exit hook (when-exit) catches SIGUSR2 with SIGTERM/SIGHUP and re-raises it; keeping the process alive left the hook spent, so the next SIGTERM or SIGHUP no longer ended the session. The directory watch already sees every switch, and Omarchy signals only OpenCode by name. README no longer suggests `pkill -USR2`. - While current/ is gone, watch the nearest existing directory above it, so it is picked up again when it comes back without a signal. - Theme the last fixed colours: code punctuation, the YOLO badge (theme yellow with the most readable text colour), the warning toast and the confirm modal / picker prompts. - Place each grey by target contrast instead of a fixed sRGB mix, so light themes keep the greys' order and spacing; sentence text (hint, blockquote, warning) gets a 4.5:1 floor. - A theme whose accent equals its foreground (kanagawa) uses blue for the brand pair, so selected rows still stand out. - Tests: no frame written to the runner output, a theme/ re-watch test, recovery tests without a signal, readable per-entry colour fixture instead of a sha256, shared handle in the stop() test. - CHANGELOG: TL;DR covers the theme; stray blank line removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bp1pRXzJVR8Rb2CsNWzzmo --- CHANGELOG.md | 20 +-- README.md | 2 +- src/renderer/App.palette.test.ts | 47 ++++- src/renderer/App.ts | 33 ++-- src/renderer/components/Modal.ts | 7 +- src/renderer/highlight.ts | 8 +- src/renderer/main.ts | 3 +- src/renderer/omarchyTheme.test.ts | 166 ++++++++++++++---- src/renderer/omarchyTheme.ts | 238 ++++++++++++++++++-------- src/renderer/palette.defaults.test.ts | 113 ++++++++++-- src/renderer/palette.test.ts | 43 ++++- src/renderer/palette.ts | 79 +++++++-- 12 files changed, 584 insertions(+), 175 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be79264..6563b19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ For releases before v1.3.35, see [GitHub Releases](https://github.com/VladoIvank ## [Unreleased] -> TL;DR — `codeep --yolo -- ""` starts Codeep for a launcher, with nothing left to answer, and skills in `~/.agents/skills` load, so Omarchy's own skills work in Codeep. +> 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 @@ -39,16 +39,16 @@ For releases before v1.3.35, see [GitHub Releases](https://github.com/VladoIvank 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 — - 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. + 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 diff --git a/README.md b/README.md index ea2689e..1a00bdc 100644 --- a/README.md +++ b/README.md @@ -1356,7 +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`; it also reloads on `SIGUSR2`, as OpenCode does. | +| 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/renderer/App.palette.test.ts b/src/renderer/App.palette.test.ts index 30fba37..035bf67 100644 --- a/src/renderer/App.palette.test.ts +++ b/src/renderer/App.palette.test.ts @@ -13,7 +13,7 @@ import { resetPalette, setPalette } from './palette'; const TEAL = '\x1b[38;2;0;128;128m'; const RED = '\x1b[38;2;240;42;48m'; -function makeApp() { +function makeApp(options: { yolo?: boolean } = {}) { const app = new App({ onSubmit: async () => {}, onCommand: () => {}, @@ -22,6 +22,7 @@ function makeApp() { 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>; @@ -30,6 +31,11 @@ function makeApp() { }; // 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 }; } @@ -40,7 +46,7 @@ afterEach(() => { }); describe('App and the palette', () => { - it('re-formats the cached transcript in the new colours', () => { + 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); @@ -52,11 +58,12 @@ describe('App and the palette', () => { // …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 ['init', 'cleanup', 'render']) vi.spyOn(internals.screen, method).mockImplementation(() => {}); for (const method of ['start', 'stop', 'onKey']) vi.spyOn(internals.input, method).mockImplementation(() => {}); const repaint = vi.spyOn(app, 'repaintInNewPalette'); @@ -85,4 +92,38 @@ describe('App and the palette', () => { (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.ts b/src/renderer/App.ts index 7fbfb05..ba1777f 100644 --- a/src/renderer/App.ts +++ b/src/renderer/App.ts @@ -5,10 +5,10 @@ import { Screen } from './Screen'; import { Input, LineEditor, KeyEvent } from './Input'; -import { fg, bg, style } from './ansi'; +import { fg, style } from './ansi'; import { getActionColor, formatActionTarget, getActionLabel } from './components/ActionFormatting'; import { PRIMARY_COLOR, SPINNER_FRAMES, LOGO_LINES } from './components/uiConstants'; -import { onPaletteChange } from './palette'; +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 { @@ -128,9 +128,11 @@ export interface AppOptions { yolo?: () => boolean; } -/** The status-bar badge for `--yolo`, in the orange the warning toasts use. */ +/** 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 YOLO_BADGE_STYLE = bg.color256(208) + fg.black + style.bold; +const yoloBadgeStyle = () => palette.yoloBadge + palette.yoloBadgeText + style.bold; export class App { private screen: Screen; @@ -2240,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; } @@ -2251,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; } @@ -2259,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; } @@ -2270,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; } @@ -2278,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; } @@ -2286,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; } @@ -2294,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; } @@ -2305,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; } @@ -2901,12 +2906,12 @@ export class App { // else starts `left` columns in. let left = 0; if (this.options.yolo?.()) { - this.screen.write(0, y, YOLO_BADGE, YOLO_BADGE_STYLE); + 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 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(left, y, notifColor + ' ' + msg + style.reset); diff --git a/src/renderer/components/Modal.ts b/src/renderer/components/Modal.ts index 966ab6b..14c25b7 100644 --- a/src/renderer/components/Modal.ts +++ b/src/renderer/components/Modal.ts @@ -10,6 +10,7 @@ import { createBox, centerBox, BoxStyle } from './Box'; // 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/highlight.ts b/src/renderer/highlight.ts index 22ffde2..3ee2c94 100644 --- a/src/renderer/highlight.ts +++ b/src/renderer/highlight.ts @@ -10,8 +10,10 @@ 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` and `punctuation` are basic ANSI colours, which the terminal - * already themes by itself. + * `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 = { get keyword() { return palette.syntaxKeyword; }, @@ -22,7 +24,7 @@ export const SYNTAX = { get type() { return palette.syntaxType; }, get operator() { return palette.syntaxOperator; }, variable: fg.white, - punctuation: fg.gray, + get punctuation() { return palette.syntaxPunctuation; }, get codeFrame() { return palette.codeFrame; }, get codeLang() { return palette.codeLang; }, }; diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 7867ad3..e3fe2dd 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -1265,8 +1265,7 @@ async function gracefulShutdown() { } // Stop following the theme before the App goes: nothing is left to - // recolour, and the watches and the SIGUSR2 handler are the process's to - // give back. + // recolour. omarchyTheme?.stop(); // Now restore terminal after agent has fully stopped diff --git a/src/renderer/omarchyTheme.test.ts b/src/renderer/omarchyTheme.test.ts index 12baccf..ae7e1b7 100644 --- a/src/renderer/omarchyTheme.test.ts +++ b/src/renderer/omarchyTheme.test.ts @@ -6,6 +6,7 @@ import { applyOmarchyTheme, contrastRatio, followOmarchyTheme, + greyContrast, isOmarchy, loadOmarchyPalette, mix, @@ -16,7 +17,7 @@ import { reapplyOmarchyTheme, type OmarchyThemeWatch, } from './omarchyTheme'; -import { DEFAULT_PALETTE, getPalette, palette, resetPalette, type PaletteRole, type Rgb } from './palette'; +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. */ @@ -56,10 +57,13 @@ 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(); @@ -126,7 +130,6 @@ describe('parseHexColor', () => { describe('paletteFromOmarchy — a dark theme', () => { const roles = paletteFromOmarchy(parseColorsToml(DARK)); const bg = hex('#101010'); - const fg = hex('#e0e0e0'); it('takes the brand colour from accent, and its bright variant 35% toward the foreground', () => { expect(roles.primary).toEqual(hex('#7aa2f7')); @@ -134,15 +137,19 @@ describe('paletteFromOmarchy — a dark theme', () => { }); it('mixes the greys from background toward foreground, faintest first', () => { - expect(roles.separator).toEqual(mix(bg, fg, 0.30)); - expect(roles.label).toEqual(mix(bg, fg, 0.40)); - expect(roles.assistantLabel).toEqual(mix(bg, fg, 0.48)); - expect(roles.hint).toEqual(mix(bg, fg, 0.66)); - expect(roles.providerName).toEqual(mix(bg, fg, 0.82)); - // On a near-black background with a light grey foreground that lands + // 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([78, 78, 78]); - expect(roles.label).toEqual([99, 99, 99]); + 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', () => { @@ -166,8 +173,34 @@ describe('paletteFromOmarchy — a dark theme', () => { 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(Object.keys(DEFAULT_PALETTE).sort()); + 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); }); }); @@ -180,15 +213,30 @@ describe('paletteFromOmarchy — a light theme', () => { expect(contrastRatio(roles[role]!, bg), role).toBeGreaterThanOrEqual(3); } expect(contrastRatio(roles.label!, bg)).toBeGreaterThanOrEqual(2.5); + expect(contrastRatio(roles.syntaxPunctuation!, bg)).toBeGreaterThanOrEqual(3); }); - it('keeps the greys in order: each at least as far from the background as the one before', () => { + 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]).toBeGreaterThanOrEqual(contrasts[i - 1]); + 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); @@ -205,6 +253,27 @@ describe('paletteFromOmarchy — a light theme', () => { }); }); +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({ @@ -237,7 +306,7 @@ describe('paletteFromOmarchy — incomplete themes', () => { 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(mix([0, 0, 0], [255, 255, 255], 0.3)); + expect(roles.separator).toEqual(paletteFromOmarchy({ background: '#000000', foreground: '#ffffff' }).separator); }); }); @@ -315,11 +384,18 @@ describe('loadOmarchyPalette', () => { }); }); +/** + * 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, not even install a signal handler', () => { - const before = process.listenerCount('SIGUSR2'); + it('does nothing', () => { expect(followOmarchyTheme({ stateDir, platform: 'darwin' })).toBeNull(); - expect(process.listenerCount('SIGUSR2')).toBe(before); expect(palette.primary).toBe(DEFAULT_PRIMARY); expect(isOmarchy('darwin', stateDir)).toBe(false); }); @@ -330,9 +406,7 @@ describe('followOmarchyTheme off Linux', () => { // 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', () => { - const before = process.listenerCount('SIGUSR2'); expect(followOmarchyTheme({ stateDir: join(root, 'absent'), platform: 'linux' })).toBeNull(); - expect(process.listenerCount('SIGUSR2')).toBe(before); expect(isOmarchy('linux', join(root, 'absent'))).toBe(false); }); @@ -342,13 +416,18 @@ describe.skipIf(process.platform !== 'linux')('followOmarchyTheme', () => { 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 theme/ was on the directory that was just deleted; this - // only arrives if the watches were re-made. + // The watch on current/ outlives a switch, so it sees this one too. switchTheme(DARK); await waitFor(() => palette.primary === DARK_ACCENT); }); @@ -359,28 +438,42 @@ describe.skipIf(process.platform !== 'linux')('followOmarchyTheme', () => { await waitFor(() => palette.primary === LIGHT_ACCENT); }); - it('reloads on SIGUSR2, the signal Omarchy sends OpenCode', () => { - // A debounce longer than the test, so only the signal can be what reloads. - watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 60_000 }); - writeFileSync(join(stateDir, 'theme', 'colors.toml'), LIGHT); - expect(palette.primary).toBe(DARK_ACCENT); - process.emit('SIGUSR2', 'SIGUSR2'); - expect(palette.primary).toBe(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 when it is back', async () => { + 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); - process.emit('SIGUSR2', 'SIGUSR2'); - expect(palette.primary).toBe(LIGHT_ACCENT); - // Re-armed on the new directory: a switch is seen without another signal. + 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 }); @@ -393,12 +486,9 @@ describe.skipIf(process.platform !== 'linux')('followOmarchyTheme', () => { expect(getPalette()).toEqual(DEFAULT_PALETTE); }); - it('stop() closes the watches and gives SIGUSR2 back', async () => { - const before = process.listenerCount('SIGUSR2'); - const running = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 })!; - expect(process.listenerCount('SIGUSR2')).toBe(before + 1); - running.stop(); - expect(process.listenerCount('SIGUSR2')).toBe(before); + it('stop() closes the watches', async () => { + watch = followOmarchyTheme({ stateDir, platform: 'linux', debounceMs: 20 })!; + watch.stop(); switchTheme(LIGHT); await new Promise(resolve => setTimeout(resolve, 200)); diff --git a/src/renderer/omarchyTheme.ts b/src/renderer/omarchyTheme.ts index 1a5d594..4e60872 100644 --- a/src/renderer/omarchyTheme.ts +++ b/src/renderer/omarchyTheme.ts @@ -25,8 +25,8 @@ */ import { readFileSync, statSync, watch, type FSWatcher } from 'fs'; import { homedir } from 'os'; -import { join } from 'path'; -import { setPalette, type PaletteRole, type Rgb } from './palette'; +import { dirname, join } from 'path'; +import { DEFAULT_PALETTE, setPalette, type PaletteOverrides, type PaletteRole, type Rgb } from './palette'; // ─── colors.toml ───────────────────────────────────────────────────────────── @@ -83,6 +83,14 @@ export function mix(a: Rgb, b: Rgb, amount: number): Rgb { 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 => { @@ -110,9 +118,9 @@ export function contrastRatio(a: Rgb, b: Rgb): number { * 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 two greys pushed - * up to the same floor land on the same grey instead of overtaking each - * other. + * 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; @@ -127,6 +135,36 @@ function leastMixToRead(from: Rgb, toward: Rgb, background: Rgb, minContrast: nu 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 ───────────────────────────────────────────────────────── /** @@ -134,30 +172,37 @@ function leastMixToRead(from: Rgb, toward: Rgb, background: Rgb, minContrast: nu * 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`) - * accent + 35% fg → its bright variant (Claude's `claudeShimmer`) - * bg → fg mixes → the greys, faintest first: 30% (Pi's `border`), - * 40, 48 (Pi's `dimText`), 55, 60 (Claude's - * `inactive`), 66 (Pi's `mutedText`), 72, 82% + * 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 — with the legibility floor catching the light - * themes where an sRGB mix lands a little faint — which is why `mode` never - * needs reading. + * 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): Partial> { +export function paletteFromOmarchy(values: Record): PaletteOverrides { const pick = (...keys: string[]): Rgb | null => { for (const key of keys) { const rgb = parseHexColor(values[key]); @@ -178,45 +223,51 @@ export function paletteFromOmarchy(values: Record): Partial> = {}; + const out: PaletteOverrides = {}; /** A theme colour as text: moved toward the foreground if it would not read. */ - const set = (role: PaletteRole, color: Rgb | null) => { + const set = (role: keyof PaletteOverrides, color: Rgb | null, minContrast = 3) => { if (!color) return; out[role] = background && foreground - ? mix(color, foreground, leastMixToRead(color, foreground, background, 3)) + ? mix(color, foreground, leastMixToRead(color, foreground, background, minContrast)) : color; }; /** - * A grey `amount` of the way from background to foreground — or further, - * to the least amount that reads at `minContrast`. Taking the larger of the - * two keeps the greys in the order Codeep's defaults have them on every - * theme: a faint role can be pushed up to a brighter one's level, never - * past it. + * A grey from background toward foreground, at the contrast greyContrast() + * gives a grey whose default is `reference`. */ - const grey = (role: PaletteRole, amount: number, minContrast = 3) => { + const grey = (role: keyof PaletteOverrides, reference: Rgb, minContrast: number) => { if (!background || !foreground) return; - out[role] = mix(background, foreground, Math.max(amount, leastMixToRead(background, foreground, background, minContrast))); + const target = greyContrast(reference, minContrast, contrastRatio(foreground, background)); + out[role] = mix(background, foreground, leastMixToRead(background, foreground, background, target)); }; - set('primary', accent); - set('primaryBright', accent && foreground ? mix(accent, foreground, 0.35) : accent); - - // Decorative separators and frames may stay faint; text may not. - grey('separator', 0.30, 1.5); - grey('codeFrame', 0.40, 1.5); - grey('label', 0.40, 2.5); - grey('assistantLabel', 0.48); - grey('modelName', 0.55); - grey('strikethrough', 0.60); - grey('hint', 0.66); - grey('codeLang', 0.66); - grey('secondaryText', 0.72); - grey('providerName', 0.82); + // 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); + set('warning', yellow, 4.5); set('inlineCode', orange); set('heading', blue); set('subheading', magenta); @@ -229,6 +280,24 @@ export function paletteFromOmarchy(values: Record): Partial (contrastRatio(c, yellow) > contrastRatio(best, yellow) ? c : best)); + } return out; } @@ -244,7 +313,7 @@ export function omarchyStateDir(home: string = homedir()): string { * readable colors.toml (not Omarchy, a theme without one, a permissions * problem) — null meaning "Codeep's own colours". */ -export function loadOmarchyPalette(stateDir: string = omarchyStateDir()): Partial> | null { +export function loadOmarchyPalette(stateDir: string = omarchyStateDir()): PaletteOverrides | null { let text: string; try { text = readFileSync(join(stateDir, 'theme', 'colors.toml'), 'utf8'); @@ -281,32 +350,36 @@ export interface OmarchyThemeOptions { export interface OmarchyThemeWatch { /** Read the theme and the setting again now. */ reload(): void; - /** Close the watches and drop the SIGUSR2 handler. */ + /** 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, not even installing the signal - * handler — off Linux or when there is no Omarchy state directory, so every - * other machine runs exactly as before. + * Returns null — doing nothing at all — off Linux or when there is no + * Omarchy state directory, so every other machine runs exactly as before. * - * Two ways a change arrives: - * - the watch on `current/`, which sees every switch by itself; - * - SIGUSR2, the signal Omarchy sends OpenCode for the same purpose - * (bin/omarchy-restart-opencode), for anyone who would rather wire a - * theme-set hook (`pkill -USR2 -f codeep`) or whose filesystem does not - * deliver inotify events. It also means a stray SIGUSR2 reloads the - * theme instead of killing the session, which is Node's default. + * 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. + * 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(); @@ -314,31 +387,44 @@ export function followOmarchyTheme(options: OmarchyThemeOptions = {}): OmarchyTh const enabled = options.enabled ?? (() => true); const debounceMs = options.debounceMs ?? 150; - let stateWatch: { watcher: FSWatcher; ino: number } | null = null; + // `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) => { + const closeQuietly = (watcher: FSWatcher | null | undefined) => { try { watcher?.close(); } catch { /* already gone */ } }; - const arm = () => { - if (stopped) return; - // `current/` itself was removed (or removed and made again, which leaves - // the old watch on a dead inode): drop that watch and make a new one if - // the directory is back. - const ino = inodeOf(stateDir); - if (stateWatch && stateWatch.ino !== ino) { - closeQuietly(stateWatch.watcher); - stateWatch = null; + /** + * 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 (!stateWatch && ino !== null) { - const watcher = watchQuietly(stateDir, schedule, () => { - if (stateWatch?.watcher === watcher) stateWatch = null; + if (!watches[slot] && dir !== null && ino !== null) { + const watcher = watchQuietly(dir, schedule, () => { + if (watches[slot]?.watcher === watcher) watches[slot] = null; }); - if (watcher) stateWatch = { watcher, ino }; + 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. @@ -370,9 +456,6 @@ export function followOmarchyTheme(options: OmarchyThemeOptions = {}): OmarchyTh timer.unref?.(); } - const onSignal = () => reload(); - process.on('SIGUSR2', onSignal); - const handle: OmarchyThemeWatch = { reload, stop: () => { @@ -380,11 +463,12 @@ export function followOmarchyTheme(options: OmarchyThemeOptions = {}): OmarchyTh stopped = true; if (timer) clearTimeout(timer); timer = null; - closeQuietly(stateWatch?.watcher ?? null); + closeQuietly(watches.state?.watcher); + closeQuietly(watches.ancestor?.watcher); closeQuietly(themeWatcher); - stateWatch = null; + watches.state = null; + watches.ancestor = null; themeWatcher = null; - process.removeListener('SIGUSR2', onSignal); if (active === handle) active = null; }, }; @@ -413,6 +497,14 @@ export function isOmarchy(platform: NodeJS.Platform = process.platform, stateDir 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); diff --git a/src/renderer/palette.defaults.test.ts b/src/renderer/palette.defaults.test.ts index 1a3092d..5217577 100644 --- a/src/renderer/palette.defaults.test.ts +++ b/src/renderer/palette.defaults.test.ts @@ -7,16 +7,15 @@ * 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 a digest of everything they emitted. + * formatters and components and pins the colours they emitted, in order. * - * The digest was taken from the tree before the palette was introduced - * (cabba11, where every colour was still an inline `fg.rgb(…)` literal), by - * running this same corpus against it. If it fails, something now paints a - * different escape sequence than Codeep did then: diff `renderCorpus()` - * against that commit to find which line. + * 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 { createHash } from 'node:crypto'; import type { Screen } from './Screen'; import { formatWelcomeMessage } from './components/WelcomeFormatter'; import { formatMessage } from './components/MessageFormatter'; @@ -70,7 +69,7 @@ const CODE: Array<[string, string]> = [ ['diff', '--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old line\n+new line\n context'], ]; -function renderCorpus(): unknown { +function renderCorpus(): Record { const counter = { current: 0 }; const fenced = CODE.map(([lang, code]) => '```' + lang + '\n' + code + '\n```').join('\n'); const out: Record = { @@ -113,16 +112,100 @@ function renderCorpus(): unknown { { id: 'z.ai', name: 'Z.AI', description: 'GLM' }, { id: 'ollama', name: 'Ollama' }, ], 0)); - out.screens = screens; - return out; + return { ...out, ...screens }; } -/** sha256 of renderCorpus() at cabba11, before the palette. */ -const DIGEST_BEFORE_PALETTE = 'ce4bab2738f06e0829bc20fda1c58c8725604def6003b88978191bb1667e97a9'; +/** 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', () => { - it('paints exactly what Codeep painted before the palette existed', () => { - const digest = createHash('sha256').update(JSON.stringify(renderCorpus())).digest('hex'); - expect(digest).toBe(DIGEST_BEFORE_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 index 17d5b90..82b2ef1 100644 --- a/src/renderer/palette.test.ts +++ b/src/renderer/palette.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_PALETTE, PALETTE_ROLES, PRIMARY_COLOR as PALETTE_PRIMARY, + TERMINAL_ROLES, getPalette, onPaletteChange, palette, @@ -60,6 +61,18 @@ describe('the default palette', () => { 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', () => { @@ -105,6 +118,32 @@ describe('setPalette', () => { 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); @@ -140,10 +179,12 @@ 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] }); + 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', () => { diff --git a/src/renderer/palette.ts b/src/renderer/palette.ts index 64a32ee..6acbe03 100644 --- a/src/renderer/palette.ts +++ b/src/renderer/palette.ts @@ -8,7 +8,9 @@ * 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. + * 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() @@ -20,7 +22,7 @@ * existed, so without a theme every escape sequence is byte-identical to what * it was (pinned by palette.defaults.test.ts). */ -import { fg } from './ansi'; +import { fg, bg } from './ansi'; export type Rgb = readonly [number, number, number]; @@ -89,14 +91,52 @@ export const DEFAULT_PALETTE = Object.freeze({ 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): Record { - const out = {} as Record; +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; } @@ -107,9 +147,11 @@ function isRgb(value: unknown): value is Rgb { } 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); +let escapes = escapesFor(colors, terminalColors); /** Brand red, as an escape sequence. A live binding: setPalette() reassigns it. */ export let PRIMARY_COLOR = escapes.primary; @@ -120,14 +162,17 @@ 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]: string }; -for (const role of PALETTE_ROLES) { +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. */ -export function getPalette(): Readonly { - return colors; +/** + * 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>(); @@ -152,17 +197,25 @@ export function onPaletteChange(listener: () => void): () => void { * 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: Partial> | null): boolean { +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 changed = PALETTE_ROLES.some(role => next[role].some((c, i) => c !== colors[role][i])); + 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; - escapes = escapesFor(colors); + terminalColors = nextTerminal; + escapes = escapesFor(colors, terminalColors); PRIMARY_COLOR = escapes.primary; PRIMARY_BRIGHT = escapes.primaryBright; for (const listener of [...listeners]) {