diff --git a/.gitignore b/.gitignore
index 614945b..0d0df28 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Local agent/runtime state
.omc/
.omx/
+.superpowers/
# macOS / OS metadata
.DS_Store
diff --git a/DESIGN.md b/DESIGN.md
index e21f7a9..63500dc 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -2,7 +2,7 @@
## Source of truth
- Status: Active
-- Last refreshed: 2026-06-21
+- Last refreshed: 2026-07-23
- Primary product surfaces: Workbranch Companion macOS menu bar popover.
- Evidence reviewed:
- `docs/plans/0032-companion-tauri-react-rewrite.md`
@@ -12,11 +12,13 @@
- `apps/companion/src/App.tsx`
- `apps/companion/src/ui/TaskRow.tsx`
- `apps/companion/src/style.css`
+ - `https://brainless.swerdlow.dev/components`
+ - Brainless `claude-header`, `claude-message`, `codex-header`, and `codex-message` registry sources reviewed on 2026-07-20
## Brand
- Personality: fast, focused, terminal-native, command-line HUD.
- Trust signals: fast refresh, clear current step, visible repo dirty/branch state, fixed-width readability, restrained terminal accents.
-- Avoid: marketing hero layouts, oversized cards, large SaaS rows, generic dashboard cards, decorative animation, glossy neon chrome. Tasteful elevation and a sans/mono type hierarchy are welcome.
+- Avoid: marketing hero layouts, oversized cards, large SaaS rows, generic dashboard cards, decorative animation, glossy neon chrome, soft-elevation card stacks, and mixed UI typography that weakens the terminal identity.
## Product goals
- Goals:
@@ -24,6 +26,7 @@
- Make the current plan step feel like the selected command/result in a launcher.
- Keep repo branch/dirty state visible without competing with task progress.
- Keep actions discoverable but visually secondary.
+ - Let the user choose a Claude Code or Codex CLI experience that applies to Main, Activity, and Settings.
- Non-goals:
- Task lifecycle mutation UI.
- Full activity report implementation.
@@ -43,15 +46,15 @@
- Key contexts of use: quick menu bar glance while coding, before switching tasks, during AI-agent execution.
## Information architecture
-- Primary navigation: sticky floating bottom view bar with three destinations: Main, Activity, Setting.
-- Core screens: Main task list, Activity report stub, Setting preferences view.
+- Primary navigation: an inset floating terminal tab bar anchored to the viewport bottom with three destinations: Main, Activity, Settings.
+- Core screens: Main task list, Activity report, Settings preferences view.
- Content hierarchy:
- 1. Global inventory + status rollup and icon-only refresh.
+ 1. Compact global inventory (`projects · tasks`) and icon-only refresh.
2. Active view content.
3. Main view: project group header, task name/status/progress/notification, repo branch/dirty state, active plan/current step, actions.
- 4. Activity view: future report stub only in this slice.
- 5. Setting view: launch-at-login, font, and theme controls.
- 6. Screen-reader live status in the top toolbar and bottom view navigation; routine `Updated`/`Ready` text stays out of the visible top line.
+ 4. Activity view: existing day/three-day calendar, session selection, and reload behavior inside the agent shell.
+ 5. Settings view: launch-at-login, font, and agent theme controls.
+ 6. Screen-reader live status in the agent shell; routine `Updated`/`Ready` text stays out of the visible header.
## Design principles
- Principle 1: Status is a launcher signal, not a paragraph. Use compact dots, counts, and labels.
@@ -60,42 +63,48 @@
- Tradeoffs: density is preferred over spaciousness, but tap/click targets remain at least 32px high where practical.
## Visual language
-- Color: Settings exposes one curated companion palette rather than parallel color families. Dark mode resolves to Catppuccin-inspired surfaces (`#1e1e2e`, `#181825`) with lavender/pink accents (`#cba6f7`, `#f5c2e7`) because it is the preferred dark look. Light mode is white/neutral rather than yellow: near-white surfaces (`#ffffff`, `#fbfcff`, `#f4f6fb`) with dark slate text (`#242633`) and a subtle lavender accent (`#7c3aed`). The visible setting is only `Light | Dark | System`; legacy theme families and concrete theme values collapse to this Companion palette while preserving mode.
-- Typography: dual-axis. UI chrome (task/project names, headings, button and nav labels, settings controls) uses a system sans stack for hierarchy and readability; developer data (repo/branch/path, counts, plan label) stays monospace. The user-selectable font preference controls the monospace data stack.
-- Spacing/layout rhythm: compact terminal/preferences rhythm, 8px grid, row-first grouping, prompt-like separators, subtle grid or border lines, no large cards.
-- Shape/radius/elevation: one fixed radius scale (`6px` controls, `10px` rows, `14px` shell); depth from clearly stepped tonal surfaces, soft elevation shadows on cards, and hairline borders; cards lift on hover. Avoid heavy gradients and glossy neon glow.
+- Color: Settings exposes two fixed-dark agent themes. Claude Code uses `#cd694a` only for small identity and interaction signals such as prompt markers, active underlines, focus rings, and narrow selected-state boundaries. Structural borders and broad selected/current surfaces use the same neutral graphite family as Codex, while `#c0caf5` remains the primary terminal text and `#949494` remains muted text. Codex uses `#ededed` for primary text, `#949494` for secondary text, `#3a3a3a` for borders, and `#5cc2e0` only for command-like actions and links. Existing Companion, Light, Dark, System, and legacy family values migrate to Claude Code.
+- Typography: the agent shell, navigation, content panels, form controls, task metadata, and activity labels use the user-selected monospace stack. Weight, contrast, spacing, and rules create hierarchy instead of a sans/mono split.
+- Spacing/layout rhythm: compact terminal rhythm, 8px grid, row-first grouping, prompt markers, and thin rules. Main, Activity, and Settings use the same expanded agent header so switching tabs does not shift the content vertically.
+- Shape/radius/elevation: agent headers use a restrained `6px` radius from the Brainless reference. Panels and rows use square or near-square corners, flat tonal separation, and hairline borders. The bottom navigation and progress count may use pill radii, and the floating navigation may use one restrained shadow to separate it from scrolling content. Do not use card hover lift, gradient, or decorative shadows elsewhere.
- Motion: 120ms press/reveal feedback only; respect reduced motion.
- Imagery/iconography: status state uses quiet color dots with accessible labels; avoid status checkmark glyphs in task headers. Checklist rows use a separate aligned marker column instead of checkmark or checkbox text prefixes; depth 0 uses a small quiet square marker, depth 1+ keeps circular markers, and completed markers use neutral muted tones rather than loud green. No decorative illustration.
## Components
-- Existing components to reuse: `TaskRow`, action buttons, native `details/summary` disclosure.
+- Existing components to reuse: `TaskRow`, action buttons, native `details/summary` disclosure, activity calendar behavior, and settings preference controls.
- New/changed components:
- - global inventory/status summary,
+ - shared `AgentShell` that applies `claude` or `codex` to all views,
+ - shared expanded `AgentHeader` for Main, Activity, and Settings with one theme-neutral text anatomy,
+ - `AgentTabs` as an inset floating bottom terminal navigation,
+ - shared `TerminalPanel`, `PromptLine`, and `StatusToken` primitives,
+ - compact global inventory summary limited to project and task counts,
- project group header,
- top toolbar with icon-only refresh/quit controls and screen-reader-only live status,
- - sticky floating bottom view navigation for Main, Activity, and Setting,
- - Setting view preferences panel,
- - Activity report stub view,
+ - Settings view preferences panel,
+ - Settings preference sections always use the Claude Code `fieldset`/`legend` anatomy in both themes; the selected theme still owns colors and control state,
+ - Activity report view,
- switch row for launch-at-login,
- font select row,
- - theme mode segmented control (`Light`, `Dark`, `System`) with no visible color-family grid,
- - launcher-like task summary line,
+ - agent theme segmented control (`Claude Code`, `Codex`) with Claude Code as the default and migration target,
+ - launcher-like task summary line with a compact progress pill,
- current-step strip,
- detail header row containing repo chips and a full-width IDE/Terminal/Finder action bar split into equal thirds above current step and checklist content,
- nested step tree with aligned status markers separate from step text: small depth 0 square, depth 1 circle, deeper levels smaller circles.
-- Variants and states: todo, planning, in-progress, review, blocked, done, notification present, dirty repo.
+- Variants and states: todo, planning, in-progress, review, blocked, done, notification present, dirty repo. In-progress identity uses the compact status marker rather than recoloring the full Task perimeter.
+- Selected Task: the expanded native `details[open]` row uses a stronger neutral surface and border while collapsed tasks remain quiet. Its summary uses the theme-owned `--task-selected-summary-bg` low-opacity tint plus a `2px` inline-start accent boundary; the accent must not fill the entire summary row at full strength.
+- Shared header anatomy: Claude Code and Codex use the same text-only title block: `Workbranch Companion` above `projects · tasks`. The top banner contains no Workbranch mark, product icon, or Claude/Codex prompt prefix; theme identity comes from surrounding color tokens rather than different header geometry. Task/current-step rows may retain their theme-specific prompt and action accents.
- Token/component ownership: `style.css` is the CSS import manifest; `src/styles/base.css`, `themes.css`, `chrome.css`, `task-details.css`, `status-groups.css`, `settings.css`, and `motion.css` own CSS custom properties and component classes by surface.
## Accessibility
- Target standard: keyboard-operable popover controls and readable contrast.
- Keyboard/focus behavior: buttons and `summary` expose clear focus rings.
- Contrast/readability: status text and current step must pass practical dark-mode contrast; disabled action may be muted but legible.
-- Screen-reader semantics: preserve button `aria-label`s; bottom view buttons expose destination labels and `aria-current`; settings controls use associated labels, switch state text, and the toolbar keeps a screen-reader-only `role="status"` region with polite live updates.
+- Screen-reader semantics: preserve button `aria-label`s; agent tab buttons expose destination labels and `aria-current`; settings controls use associated labels, switch state text, and the agent shell keeps a screen-reader-only `role="status"` region with polite live updates.
- Reduced motion and sensory considerations: disable transform transitions under `prefers-reduced-motion: reduce`.
## Responsive behavior
- Supported breakpoints/devices: narrow menu popover around 360-460px width.
-- Layout adaptations: the detail header keeps repo chips above a full-width launch-action row; IDE, Terminal, and Finder each occupy one third of that row above steps; repo chips wrap; long task/step names truncate with accessible full text via `title`.
+- Layout adaptations: the shared expanded header collapses its internal columns consistently on every tab. The detail header keeps repo chips above a full-width launch-action row; IDE, Terminal, and Finder each occupy one-third of that row above steps; repo chips wrap; long task/step names truncate with accessible full text via `title`.
- Touch/hover differences: hover is enhancement only; core state is visible without hover.
## Interaction states
@@ -104,6 +113,7 @@
- Error: root-scoped error row with red accent; operation failures such as refresh/action/preference errors also render a visible alert row while routine Ready/Updated statuses stay screen-reader-only.
- Success: routine `Updated` / `Action complete` messages are not shown as a visible top-line chip; they remain available to assistive tech.
- Disabled: disabled action has muted text and no press transform.
+- Theme selection: Settings is the only visible theme switch surface. Selection applies to every view immediately and persists through the existing preference store. Unsupported and legacy theme values migrate to Claude Code.
- Offline/slow network: not applicable; CLI/local filesystem driven.
## Content voice
@@ -112,14 +122,15 @@
- Microcopy rules: prefer short row action labels (`IDE`, `Terminal`, `Finder`) over sentences; omit `Copy`/`Memo`/`Noti`/`Clear` row vocabulary because those companion actions are removed, not hidden.
## Implementation constraints
-- Framework/styling system: React 18 + plain CSS; no Tailwind/shadcn in this refresh. Primary reference is a modern developer HUD with a terminal core (mono data) and a modern shell (sans chrome, stepped surfaces, soft elevation); Raycast remains a secondary compact-popover cue.
+- Framework/styling system: React 18 + plain CSS. Adapt the structure and accessibility behavior of the Brainless Claude and Codex components into local reusable primitives. Do not add Tailwind, shadcn, or a new runtime package.
- Design-token constraints: CSS custom properties in `style.css`.
- Performance constraints: no extra runtime package; no animation loops; preserve 0033 responsiveness fixes.
- Compatibility constraints: no CLI/contract/Rust port changes.
-- Test/screenshot expectations: Vitest static markup tests plus `pnpm --filter @workbranch/companion tauri build`; run a browser/app screenshot review before final handoff.
+- Scope constraints: do not add keyboard shortcuts or display shortcut hints for behavior that does not exist.
+- Test/screenshot expectations: cover preference migration, persistence, both theme variants, and the Main/Activity/Settings shell contracts with Vitest. Run typecheck, lint, Vite build, Tauri build, then inspect both themes on all three tabs at 360px and 460px before final handoff.
## Open questions
-- [ ] Whether a later v2 should add shadcn/Radix primitives after the plain CSS refresh proves the needed component gaps.
+- [ ] Whether a later release should restore a light appearance as a separate axis after the two fixed-dark agent themes ship.
## Direction revision
@@ -134,3 +145,12 @@
- 2026-06-22 (toolbar): Removed the visible top toolbar status chip after review; the top line now keeps task inventory plus icon controls only. A screen-reader-only polite live region preserves status announcements without showing routine `Updated` text in the popover chrome.
- 2026-06-23: Light mode shifted from yellow/warm Breakfast parchment to a whiter neutral palette with subtle lavender accents after visual review.
+- 2026-07-20: Replaced the Companion `Light | Dark | System` direction with two fixed-dark agent themes: Claude Code and Codex. Both themes apply to Main, Activity, and Settings and change component anatomy as well as tokens. The app uses an Agent Shell with terminal tabs and local React/plain-CSS primitives adapted from the Brainless component semantics. Claude Code is the default and migration target. Existing behavior and Tauri/Rust contracts remain unchanged.
+- 2026-07-23: Removed the visible `Claude Code` legend from the expanded Main header so neither theme displays an agent product name as a banner label. The Settings theme-picker labels remain unchanged.
+- 2026-07-23 (tab stability): Main, Activity, and Settings now share the same expanded `AgentHeader` anatomy and size. The compact secondary-view bar was removed so tab changes preserve a stable top-banner footprint.
+- 2026-07-23 (floating navigation): Moved `AgentTabs` from below the header to an inset floating bottom bar while preserving the terminal theme. Expanded tasks now use selected-row background emphasis and progress is shown in a compact pill.
+- 2026-07-23 (selected Task tone): Reduced the expanded Task summary from the broad `--emphasis-soft` fill to a theme-owned low-opacity surface tint with a narrow `2px` inline-start accent. The selected state remains visible through the boundary, prompt marker, border, and progress pill without creating a wide saturated band.
+- 2026-07-23 (Claude accent restraint): Replaced Claude's orange structural borders and broad selected/current fills with Codex-like graphite borders and cool neutral translucent surfaces. Claude orange remains only on compact identity, focus, prompt, and active-state signals.
+- 2026-07-23 (header inventory): Limited both theme headers to `projects · tasks`, removed Codex-only model/directory metadata, and aligned the Claude/Codex banner footprint.
+- 2026-07-23 (text-only header): Removed the Workbranch mark and Claude/Codex prompt prefixes from the top banner. Both themes now share the exact `Workbranch Companion` plus `projects · tasks` text structure and header geometry.
+- 2026-07-23 (Settings anatomy): Standardized Startup, Font, and Theme sections on the Claude Code `fieldset`/`legend` structure in both themes while preserving each theme's color tokens and selected state.
diff --git a/apps/companion/src/App.tsx b/apps/companion/src/App.tsx
index 5b5bf93..71ebcf8 100644
--- a/apps/companion/src/App.tsx
+++ b/apps/companion/src/App.tsx
@@ -2,9 +2,7 @@ import { isTauri } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityCalendarView } from "./activity/ActivityCalendarView";
import { createActivityRefresh } from "./application/activity";
-import { resolvedCompanionTheme } from "./application/preferences";
import { buildMenuModel, type MenuModel } from "./application/state";
-import { useSystemThemeMode } from "./application/systemThemeMode";
import { useCompanionSettings } from "./application/useCompanionSettings";
import type { GlobalState, Task } from "./domain/model";
import {
@@ -18,13 +16,12 @@ import {
watchRoots,
} from "./infrastructure/tauriClient";
import { startWorkspaceMonitor } from "./infrastructure/workspaceMonitor";
-import { AppSummary } from "./ui/AppSummary";
-import { AppToolbar } from "./ui/AppToolbar";
+import { AgentHeader } from "./ui/AgentHeader";
+import { AgentTabs, type CompanionView } from "./ui/AgentTabs";
import { ProjectGroup } from "./ui/ProjectGroup";
import { SettingsView } from "./ui/SettingsView";
import { StatusAlert } from "./ui/StatusAlert";
import type { TaskActionKind } from "./ui/TaskRow";
-import { type CompanionView, ViewNav } from "./ui/ViewNav";
const EMPTY_STATE: GlobalState = { projects: [], errors: [] };
const TAURI_RUNTIME_UNAVAILABLE = "Tauri runtime unavailable";
@@ -57,7 +54,6 @@ export function App() {
const [status, setStatus] = useState("Ready");
const [visibleError, setVisibleError] = useState();
const [currentView, setCurrentView] = useState("main");
- const systemThemeMode = useSystemThemeMode();
const model: MenuModel = buildMenuModel(state);
const tauriRuntimeAvailable = isTauri();
const refreshWithActivity = useMemo(
@@ -92,7 +88,7 @@ export function App() {
updateLaunchAtLogin,
updatePreferences,
} = useCompanionSettings({ onError: showError, onStatus: showStatus });
- const activeTheme = resolvedCompanionTheme(preferences, systemThemeMode);
+ const activeTheme = preferences.theme;
const applyState = useCallback(
(next: GlobalState) => {
@@ -188,14 +184,13 @@ export function App() {
return (
-
-
- void refresh()}
- onQuit={handleQuit}
- status={status}
- />
-
+ void refresh()}
+ onQuit={handleQuit}
+ />
{currentView === "main" ? (
@@ -206,6 +201,7 @@ export function App() {
void handleTaskAction(root, task, kind)
}
@@ -228,7 +224,6 @@ export function App() {
{currentView === "settings" ? (
void updateLaunchAtLogin(enabled)}
@@ -240,7 +235,7 @@ export function App() {
{error.root}: {error.message}
))}
-
+
);
}
diff --git a/apps/companion/src/activity/activity-calendar.css b/apps/companion/src/activity/activity-calendar.css
index 71aafb5..7e3bd7b 100644
--- a/apps/companion/src/activity/activity-calendar.css
+++ b/apps/companion/src/activity/activity-calendar.css
@@ -2,7 +2,7 @@
align-items: stretch;
background: var(--surface-1);
border: 1px solid var(--line);
- border-radius: var(--radius-row);
+ border-radius: 6px;
display: block;
padding: 10px;
text-align: left;
@@ -82,37 +82,37 @@
.cal-chip[data-color="0"],
.cal-session[data-color="0"] {
- --cal-bg: rgba(203, 166, 247, 0.18);
+ --cal-bg: var(--cal-bg-1);
--cal-color: var(--cal-1);
}
.cal-chip[data-color="1"],
.cal-session[data-color="1"] {
- --cal-bg: rgba(137, 180, 250, 0.18);
+ --cal-bg: var(--cal-bg-2);
--cal-color: var(--cal-2);
}
.cal-chip[data-color="2"],
.cal-session[data-color="2"] {
- --cal-bg: rgba(166, 227, 161, 0.18);
+ --cal-bg: var(--cal-bg-3);
--cal-color: var(--cal-3);
}
.cal-chip[data-color="3"],
.cal-session[data-color="3"] {
- --cal-bg: rgba(249, 226, 175, 0.18);
+ --cal-bg: var(--cal-bg-4);
--cal-color: var(--cal-4);
}
.cal-chip[data-color="4"],
.cal-session[data-color="4"] {
- --cal-bg: rgba(245, 194, 231, 0.18);
+ --cal-bg: var(--cal-bg-5);
--cal-color: var(--cal-5);
}
.cal-chip[data-color="5"],
.cal-session[data-color="5"] {
- --cal-bg: rgba(148, 226, 213, 0.18);
+ --cal-bg: var(--cal-bg-6);
--cal-color: var(--cal-6);
}
@@ -134,7 +134,7 @@
}
.cal-hour-label {
- color: var(--faint);
+ color: var(--muted);
font-size: 10px;
line-height: 1;
padding-top: 1px;
@@ -150,7 +150,7 @@
.cal-day-column {
background: var(--surface-2);
border: 1px solid var(--line);
- border-radius: 10px;
+ border-radius: 4px;
min-width: 0;
overflow: hidden;
position: relative;
@@ -188,8 +188,7 @@
background: var(--cal-bg);
border-color: var(--cal-color, var(--accent));
border-left: 3px solid var(--cal-color, var(--accent));
- border-radius: 7px;
- box-shadow: none;
+ border-radius: 4px;
color: var(--text);
display: grid;
gap: 1px;
@@ -217,6 +216,10 @@
background: var(--cal-bg);
}
+.cal-session[data-selected="true"] {
+ border-color: var(--emphasis);
+}
+
@supports (background: color-mix(in srgb, black, white)) {
.cal-session {
background: color-mix(
@@ -282,7 +285,7 @@
.cal-detail {
background: var(--surface-2);
border: 1px solid var(--line);
- border-radius: 10px;
+ border-radius: 4px;
display: grid;
gap: 5px;
padding: 8px;
diff --git a/apps/companion/src/application/preferences.ts b/apps/companion/src/application/preferences.ts
index e4a4222..2f485c8 100644
--- a/apps/companion/src/application/preferences.ts
+++ b/apps/companion/src/application/preferences.ts
@@ -1,32 +1,15 @@
import { load } from "@tauri-apps/plugin-store";
import {
- COMPANION_THEME_MODE_OPTIONS,
COMPANION_THEME_OPTIONS,
- type CompanionResolvedThemeMode,
type CompanionTheme,
- type CompanionThemeFamily,
- type CompanionThemeMode,
- isCompanionThemeFamily,
- isCompanionThemeMode,
- isLegacyCompanionTheme,
- resolvedThemeValue,
- themeFamilyFromLegacyTheme,
- themeModeFromLegacyTheme,
+ isCompanionTheme,
} from "./themePreferences";
export type {
- CompanionResolvedThemeMode,
CompanionTheme,
- CompanionThemeFamily,
- CompanionThemeMode,
- CompanionThemeModeOption,
CompanionThemeOption,
} from "./themePreferences";
-export {
- COMPANION_THEME_MODE_OPTIONS,
- COMPANION_THEME_OPTIONS,
- resolvedThemeValue,
-};
+export { COMPANION_THEME_OPTIONS };
export const COMPANION_PREFERENCES_STORE_FILE = "companion-preferences.json";
@@ -42,8 +25,7 @@ export type CompanionFont = (typeof COMPANION_FONT_VALUES)[number];
export type CompanionPreferences = {
readonly font: CompanionFont;
- readonly themeFamily: CompanionThemeFamily;
- readonly themeMode: CompanionThemeMode;
+ readonly theme: CompanionTheme;
};
export type CompanionFontOption = {
@@ -59,8 +41,7 @@ export type PreferenceSanitizationResult = {
export type PreferenceStoreEntries = {
readonly font: CompanionFont;
- readonly themeFamily: CompanionThemeFamily;
- readonly themeMode: CompanionThemeMode;
+ readonly theme: CompanionTheme;
};
export type CompanionPreferenceStore = {
@@ -71,8 +52,7 @@ export type CompanionPreferenceStore = {
export const DEFAULT_COMPANION_PREFERENCES: CompanionPreferences = {
font: "system-mono",
- themeFamily: "companion",
- themeMode: "system",
+ theme: "claude",
};
export const COMPANION_FONT_OPTIONS: readonly CompanionFontOption[] = [
@@ -119,54 +99,23 @@ export function isCompanionFont(value: unknown): value is CompanionFont {
}
}
-function migrateRemovedThemeFamily(
- value: unknown,
-): CompanionThemeFamily | undefined {
- switch (value) {
- case "breakfast":
- case "solarized":
- case "dracula":
- case "catppuccin":
- case "github":
- case "gruvbox":
- case "nord":
- return "companion";
- default:
- return undefined;
- }
-}
-
export function sanitizeCompanionPreferences(input: {
readonly font?: unknown;
+ readonly theme?: unknown;
readonly themeFamily?: unknown;
readonly themeMode?: unknown;
- readonly theme?: unknown;
}): PreferenceSanitizationResult {
const font = isCompanionFont(input.font)
? input.font
: DEFAULT_COMPANION_PREFERENCES.font;
- const legacyTheme = isLegacyCompanionTheme(input.theme)
+ const theme = isCompanionTheme(input.theme)
? input.theme
- : undefined;
- const removedThemeFamily = migrateRemovedThemeFamily(input.themeFamily);
- const themeFamily = isCompanionThemeFamily(input.themeFamily)
- ? input.themeFamily
- : removedThemeFamily
- ? removedThemeFamily
- : legacyTheme
- ? themeFamilyFromLegacyTheme(legacyTheme)
- : DEFAULT_COMPANION_PREFERENCES.themeFamily;
- const themeMode = isCompanionThemeMode(input.themeMode)
- ? input.themeMode
- : legacyTheme
- ? themeModeFromLegacyTheme(legacyTheme)
- : DEFAULT_COMPANION_PREFERENCES.themeMode;
+ : isCompanionTheme(input.themeFamily)
+ ? input.themeFamily
+ : DEFAULT_COMPANION_PREFERENCES.theme;
return {
- preferences: { font, themeFamily, themeMode },
- sanitized:
- font !== input.font ||
- themeFamily !== input.themeFamily ||
- themeMode !== input.themeMode,
+ preferences: { font, theme },
+ sanitized: font !== input.font || theme !== input.theme,
};
}
@@ -174,11 +123,7 @@ export function shouldRestoreFailedPreferenceUpdate(
current: CompanionPreferences,
attempted: CompanionPreferences,
): boolean {
- return (
- current.font === attempted.font &&
- current.themeFamily === attempted.themeFamily &&
- current.themeMode === attempted.themeMode
- );
+ return current.font === attempted.font && current.theme === attempted.theme;
}
export type PreferenceSaveOperation = () => Promise;
@@ -195,15 +140,14 @@ export function preferencesToStoreEntries(
): PreferenceStoreEntries {
return {
font: preferences.font,
- themeFamily: preferences.themeFamily,
- themeMode: preferences.themeMode,
+ theme: preferences.theme,
};
}
export async function loadCompanionPreferenceStore(): Promise {
return load(COMPANION_PREFERENCES_STORE_FILE, {
autoSave: false,
- defaults: preferencesToStoreEntries(DEFAULT_COMPANION_PREFERENCES),
+ defaults: {},
});
}
@@ -212,9 +156,9 @@ export async function readCompanionPreferences(
): Promise {
return sanitizeCompanionPreferences({
font: await store.get("font"),
+ theme: await store.get("theme"),
themeFamily: await store.get("themeFamily"),
themeMode: await store.get("themeMode"),
- theme: await store.get("theme"),
});
}
@@ -224,16 +168,6 @@ export async function writeCompanionPreferences(
): Promise {
const entries = preferencesToStoreEntries(preferences);
await store.set("font", entries.font);
- await store.set("themeFamily", entries.themeFamily);
- await store.set("themeMode", entries.themeMode);
+ await store.set("theme", entries.theme);
await store.save();
}
-
-export function resolvedCompanionTheme(
- preferences: CompanionPreferences,
- systemMode: CompanionResolvedThemeMode,
-): CompanionTheme {
- const mode =
- preferences.themeMode === "system" ? systemMode : preferences.themeMode;
- return resolvedThemeValue(preferences.themeFamily, mode);
-}
diff --git a/apps/companion/src/application/systemThemeMode.ts b/apps/companion/src/application/systemThemeMode.ts
deleted file mode 100644
index 798a8c6..0000000
--- a/apps/companion/src/application/systemThemeMode.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useEffect, useState } from "react";
-import type { CompanionResolvedThemeMode } from "./themePreferences";
-
-export function useSystemThemeMode(): CompanionResolvedThemeMode {
- const [systemThemeMode, setSystemThemeMode] =
- useState("dark");
-
- useEffect(() => {
- const query = window.matchMedia("(prefers-color-scheme: light)");
- const applySystemTheme = () => {
- setSystemThemeMode(query.matches ? "light" : "dark");
- };
- applySystemTheme();
- query.addEventListener("change", applySystemTheme);
- return () => {
- query.removeEventListener("change", applySystemTheme);
- };
- }, []);
-
- return systemThemeMode;
-}
diff --git a/apps/companion/src/application/themePreferences.ts b/apps/companion/src/application/themePreferences.ts
index 939454f..e529132 100644
--- a/apps/companion/src/application/themePreferences.ts
+++ b/apps/companion/src/application/themePreferences.ts
@@ -1,155 +1,19 @@
-const COMPANION_THEME_VALUES = ["catppuccin-dark", "breakfast-light"] as const;
-
-const PREVIOUS_COMPANION_THEME_VALUES = [
- "terminal-dark",
- "amber-crt",
- "green-mono",
- "high-contrast",
- "gruvbox-dark",
- "gruvbox-light",
- "nord-dark",
- "nord-light",
- "breakfast-dark",
- "solarized-dark",
- "dracula-dark",
- "github-dark",
- "solarized-light",
- "dracula-light",
- "catppuccin-light",
- "github-light",
-] as const;
-
-const COMPANION_THEME_FAMILY_VALUES = ["companion"] as const;
-const COMPANION_THEME_MODE_VALUES = ["light", "dark", "system"] as const;
-const RESOLVED_THEME_MODE_VALUES = ["dark", "light"] as const;
+const COMPANION_THEME_VALUES = ["claude", "codex"] as const;
export type CompanionTheme = (typeof COMPANION_THEME_VALUES)[number];
-type PreviousCompanionTheme = (typeof PREVIOUS_COMPANION_THEME_VALUES)[number];
-type LegacyCompanionTheme = CompanionTheme | PreviousCompanionTheme;
-export type CompanionThemeFamily =
- (typeof COMPANION_THEME_FAMILY_VALUES)[number];
-export type CompanionThemeMode = (typeof COMPANION_THEME_MODE_VALUES)[number];
-export type CompanionResolvedThemeMode =
- (typeof RESOLVED_THEME_MODE_VALUES)[number];
export type CompanionThemeOption = {
readonly value: CompanionTheme;
- readonly family: CompanionThemeFamily;
- readonly familyLabel: string;
readonly label: string;
- readonly mode: CompanionResolvedThemeMode;
-};
-
-export type CompanionThemeModeOption = {
- readonly value: CompanionThemeMode;
- readonly label: string;
-};
-
-const COMPANION_THEME_SET = new Set(COMPANION_THEME_VALUES);
-const PREVIOUS_COMPANION_THEME_SET = new Set(
- PREVIOUS_COMPANION_THEME_VALUES,
-);
-const COMPANION_THEME_FAMILY_SET = new Set(
- COMPANION_THEME_FAMILY_VALUES,
-);
-const COMPANION_THEME_MODE_SET = new Set(COMPANION_THEME_MODE_VALUES);
-
-const THEME_BY_MODE: Record = {
- dark: "catppuccin-dark",
- light: "breakfast-light",
-};
-
-const THEME_MODE_BY_LEGACY_THEME: Record<
- LegacyCompanionTheme,
- CompanionResolvedThemeMode
-> = {
- "terminal-dark": "dark",
- "amber-crt": "dark",
- "green-mono": "dark",
- "high-contrast": "dark",
- "gruvbox-dark": "dark",
- "gruvbox-light": "light",
- "nord-dark": "dark",
- "nord-light": "light",
- "breakfast-dark": "dark",
- "breakfast-light": "light",
- "solarized-dark": "dark",
- "solarized-light": "light",
- "dracula-dark": "dark",
- "dracula-light": "light",
- "catppuccin-dark": "dark",
- "catppuccin-light": "light",
- "github-dark": "dark",
- "github-light": "light",
};
export const COMPANION_THEME_OPTIONS: readonly CompanionThemeOption[] = [
- {
- value: "catppuccin-dark",
- family: "companion",
- familyLabel: "Companion",
- label: "Catppuccin Dark",
- mode: "dark",
- },
- {
- value: "breakfast-light",
- family: "companion",
- familyLabel: "Companion",
- label: "White Light",
- mode: "light",
- },
+ { value: "claude", label: "Claude Code" },
+ { value: "codex", label: "Codex" },
];
-export const COMPANION_THEME_MODE_OPTIONS: readonly CompanionThemeModeOption[] =
- [
- { value: "light", label: "Light" },
- { value: "dark", label: "Dark" },
- { value: "system", label: "System" },
- ];
+const COMPANION_THEME_SET = new Set(COMPANION_THEME_VALUES);
export function isCompanionTheme(value: unknown): value is CompanionTheme {
return COMPANION_THEME_SET.has(value);
}
-
-function isPreviousCompanionTheme(
- value: unknown,
-): value is PreviousCompanionTheme {
- return PREVIOUS_COMPANION_THEME_SET.has(value);
-}
-
-export function isLegacyCompanionTheme(
- value: unknown,
-): value is LegacyCompanionTheme {
- return isCompanionTheme(value) || isPreviousCompanionTheme(value);
-}
-
-export function isCompanionThemeFamily(
- value: unknown,
-): value is CompanionThemeFamily {
- return COMPANION_THEME_FAMILY_SET.has(value);
-}
-
-export function isCompanionThemeMode(
- value: unknown,
-): value is CompanionThemeMode {
- return COMPANION_THEME_MODE_SET.has(value);
-}
-
-export function themeFamilyFromLegacyTheme(
- _theme: LegacyCompanionTheme,
-): CompanionThemeFamily {
- return "companion";
-}
-
-export function themeModeFromLegacyTheme(
- theme: LegacyCompanionTheme,
-): CompanionResolvedThemeMode {
- return THEME_MODE_BY_LEGACY_THEME[theme];
-}
-
-export function resolvedThemeValue(
- _family: CompanionThemeFamily,
- mode: CompanionResolvedThemeMode,
-): CompanionTheme {
- return THEME_BY_MODE[mode];
-}
diff --git a/apps/companion/src/style.css b/apps/companion/src/style.css
index 57ba082..e30fec3 100644
--- a/apps/companion/src/style.css
+++ b/apps/companion/src/style.css
@@ -1,6 +1,7 @@
@import "./styles/base.css";
@import "./styles/themes.css";
@import "./styles/chrome.css";
+@import "./styles/agent-shell.css";
@import "./styles/task-details.css";
@import "./styles/task-actions.css";
@import "./styles/status-groups.css";
diff --git a/apps/companion/src/styles/agent-shell.css b/apps/companion/src/styles/agent-shell.css
new file mode 100644
index 0000000..bbd7811
--- /dev/null
+++ b/apps/companion/src/styles/agent-shell.css
@@ -0,0 +1,198 @@
+.agent-header {
+ background: var(--surface-1);
+ border: 1px solid var(--line-strong);
+ border-radius: 6px;
+ margin: 0 0 8px;
+ min-width: 0;
+}
+
+.agent-header {
+ padding: 10px;
+}
+
+.terminal-panel legend {
+ color: var(--emphasis);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ padding: 0 5px;
+ text-transform: uppercase;
+}
+
+.agent-header-row {
+ align-items: center;
+ display: flex;
+ gap: 10px;
+ justify-content: space-between;
+}
+
+.agent-header-copy {
+ display: grid;
+ flex: 1 1 auto;
+ gap: 4px;
+ min-width: 0;
+}
+
+.agent-header h1 {
+ color: var(--text);
+ font-size: 14px;
+ line-height: 1.25;
+}
+
+.agent-inventory {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.agent-controls {
+ align-items: center;
+ display: flex;
+ flex: 0 0 auto;
+ gap: 4px;
+ justify-content: flex-end;
+}
+
+.agent-control {
+ align-items: center;
+ color: var(--muted);
+ display: inline-flex;
+ font-size: 15px;
+ justify-content: center;
+ min-width: 32px;
+ padding: 4px;
+}
+
+.agent-control-quit:hover:not(:disabled) {
+ border-color: var(--blocked);
+ color: var(--blocked);
+}
+
+.agent-tabs {
+ background: var(--surface-0);
+ border: 1px solid var(--line);
+ border-radius: var(--floating-tabs-radius);
+ bottom: calc(var(--floating-tabs-bottom) + env(safe-area-inset-bottom));
+ box-shadow: var(--floating-tabs-shadow);
+ display: grid;
+ gap: 0;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ left: var(--floating-tabs-inset);
+ margin: 0;
+ overflow: hidden;
+ position: fixed;
+ right: var(--floating-tabs-inset);
+ z-index: 20;
+}
+
+.agent-tab {
+ background: transparent;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ border-radius: 0;
+ color: var(--muted);
+ font-size: var(--floating-tabs-font-size);
+ min-height: var(--floating-tabs-height);
+ min-width: 0;
+}
+
+.agent-tab + .agent-tab {
+ border-left: 1px solid var(--line);
+}
+
+.agent-tab[data-active="true"] {
+ background: var(--surface-2);
+ border-bottom-color: var(--emphasis);
+ color: var(--emphasis);
+ font-weight: 700;
+}
+
+.terminal-panel {
+ background: var(--surface-1);
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ margin: 0;
+ min-width: 0;
+ padding: 9px;
+}
+
+.terminal-panel-heading {
+ border-bottom: 1px solid var(--line);
+ color: var(--emphasis);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+ margin: -2px 0 8px;
+ padding-bottom: 6px;
+ text-transform: uppercase;
+}
+
+.prompt-line {
+ align-items: baseline;
+ display: grid;
+ gap: 7px;
+ grid-template-columns: 10px minmax(0, 1fr);
+ min-width: 0;
+}
+
+.prompt-line[data-current="true"] {
+ background: var(--current-step-bg);
+ border-left: 1px solid var(--emphasis);
+ padding: 5px 7px;
+}
+
+.prompt-marker {
+ color: var(--emphasis);
+}
+
+.prompt-content {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.status-token {
+ align-items: center;
+ color: var(--muted);
+ display: inline-flex;
+ font-size: 10px;
+ gap: 5px;
+}
+
+.status-token-marker {
+ background: var(--faint);
+ border-radius: 50%;
+ height: 6px;
+ width: 6px;
+}
+
+.status-token[data-status="in-progress"] .status-token-marker {
+ background: var(--emphasis);
+}
+
+.status-token[data-status="blocked"] .status-token-marker {
+ background: var(--blocked);
+}
+
+.status-token[data-status="review"] .status-token-marker {
+ background: var(--review);
+}
+
+.status-token[data-status="done"] .status-token-marker {
+ background: var(--done);
+}
+
+@media (max-width: 400px) {
+ .agent-header-row {
+ align-items: center;
+ flex-wrap: nowrap;
+ }
+
+ .agent-header h1 {
+ font-size: 13px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .agent-inventory {
+ display: block;
+ }
+}
diff --git a/apps/companion/src/styles/base.css b/apps/companion/src/styles/base.css
index 34d7b11..ab39a18 100644
--- a/apps/companion/src/styles/base.css
+++ b/apps/companion/src/styles/base.css
@@ -1,9 +1,9 @@
:root {
- color: #faf6ef;
- background: #15110d;
+ background: #0e0e0e;
+ color: #c0caf5;
font-family:
- -apple-system, "SF Pro Text", "Segoe UI", system-ui, "Helvetica Neue",
- arial, sans-serif;
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
+ monospace;
-webkit-font-smoothing: antialiased;
text-rendering: optimizelegibility;
}
@@ -13,89 +13,39 @@
}
body {
+ background: #0e0e0e;
margin: 0;
- min-width: 360px;
- background: #15110d;
-}
-
-body:has(main[data-theme$="-light"]) {
- background: #ffffff;
}
main {
- --ui-font-family:
- -apple-system, "SF Pro Text", "Segoe UI", system-ui, "Helvetica Neue",
- arial, sans-serif;
--app-font-family:
- ui-monospace, "SFMono-Regular", "Menlo", "Monaco", "Consolas",
- "Liberation Mono", monospace;
- --surface-0: #15110d;
- --surface-1: #201913;
- --surface-2: #2a2118;
- --surface-3: #3a2b1e;
- --line: rgba(255, 215, 154, 0.12);
- --line-strong: rgba(255, 215, 154, 0.26);
- --text: #faf6ef;
- --muted: #b4aca2;
- --faint: #8a837a;
- --accent: #f59e0b;
- --accent-strong: #ffd79a;
- --accent-soft: rgba(245, 158, 11, 0.18);
- --blocked: #e5484d;
- --blocked-soft: rgba(229, 72, 77, 0.16);
- --review: #c9c2ff;
- --review-soft: rgba(201, 194, 255, 0.16);
- --done: #1ca86a;
- --done-soft: rgba(28, 168, 106, 0.16);
- --notify: #ffc24b;
- --notify-soft: rgba(255, 194, 75, 0.17);
- --shell-bg:
- radial-gradient(120% 120px at 50% 0, rgba(245, 158, 11, 0.13), transparent),
- radial-gradient(
- 90% 160px at 100% 12%,
- rgba(255, 138, 30, 0.08),
- transparent
- ),
- var(--surface-0);
- --shell-topline: linear-gradient(
- 90deg,
- transparent,
- var(--accent),
- transparent
- );
- --task-bg: var(--surface-2);
- --task-bg-hover: var(--surface-3);
- --button-bg: rgba(255, 215, 154, 0.07);
- --button-bg-hover: rgba(255, 215, 154, 0.13);
- --current-step-bg:
- linear-gradient(135deg, var(--accent-soft), transparent 70%),
- var(--surface-3);
- --active-rail-bg: var(--accent-soft);
- --shadow-soft: rgba(0, 0, 0, 0.32);
- --shadow-strong: rgba(0, 0, 0, 0.52);
- --shadow-card:
- 0 1px 2px var(--shadow-soft), 0 8px 20px -10px var(--shadow-strong);
- --shadow-card-hover:
- 0 2px 4px var(--shadow-soft), 0 14px 28px -10px var(--shadow-strong);
- --shadow-pop: 0 14px 36px var(--shadow-strong);
- --radius-row: 11px;
- --radius-shell: 14px;
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
+ monospace;
+ --floating-tabs-bottom: 10px;
+ --floating-tabs-content-clearance: 64px;
+ --floating-tabs-font-size: 11px;
+ --floating-tabs-height: 36px;
+ --floating-tabs-inset: 12px;
+ --floating-tabs-radius: 999px;
+ --floating-tabs-shadow: 0 8px 24px rgba(0, 0, 0, 0.46);
+ --progress-pill-font-size: 10px;
+ --progress-pill-padding: 3px 6px;
+ --progress-pill-radius: 999px;
+ --task-selected-accent-width: 2px;
- position: relative;
- max-width: 100%;
- width: 100vw;
- background: var(--shell-bg);
+ background: var(--surface-0);
color: var(--text);
display: flex;
flex-direction: column;
font-family: var(--app-font-family);
+ max-width: 100%;
min-height: 100vh;
padding: 12px;
-}
-
-@supports (background: color-mix(in srgb, black, white)) {
- main {
- --button-bg: color-mix(in srgb, var(--text) 6%, transparent);
- --button-bg-hover: color-mix(in srgb, var(--text) 12%, transparent);
- }
+ /* biome-ignore format: Stylelint requires the continued operator at line start. */
+ padding-bottom: calc(
+ var(--floating-tabs-content-clearance)
+ + env(safe-area-inset-bottom)
+ );
+ position: relative;
+ width: 100vw;
}
diff --git a/apps/companion/src/styles/chrome.css b/apps/companion/src/styles/chrome.css
index fd0016a..d982ec2 100644
--- a/apps/companion/src/styles/chrome.css
+++ b/apps/companion/src/styles/chrome.css
@@ -1,12 +1,3 @@
-header {
- align-items: flex-start;
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- justify-content: space-between;
- margin-bottom: 10px;
-}
-
h1,
h2,
p {
@@ -24,7 +15,7 @@ select {
appearance: none;
background: var(--button-bg);
border: 1px solid var(--line);
- border-radius: 7px;
+ border-radius: 6px;
color: var(--text);
min-height: 32px;
padding: 6px 10px;
@@ -40,27 +31,10 @@ button:focus-visible,
select:focus-visible,
input:focus-visible,
summary:focus-visible {
- outline: 2px solid var(--accent);
+ outline: 2px solid var(--emphasis);
outline-offset: 2px;
}
-.toolbar {
- align-items: center;
- display: inline-flex;
- flex: 1 1 180px;
- flex-wrap: wrap;
- gap: 6px;
- justify-content: flex-end;
- min-width: 0;
-}
-
-.toolbar-button {
- color: var(--muted);
- font-size: 11px;
- min-height: 30px;
- padding: 5px 8px;
-}
-
.toolbar-status-sr {
border: 0;
clip-path: inset(50%);
@@ -73,16 +47,10 @@ summary:focus-visible {
width: 1px;
}
-.toolbar-divider {
- background: var(--line-strong);
- height: 18px;
- width: 1px;
-}
-
.app-error {
background: var(--blocked-soft);
border: 1px solid var(--blocked);
- border-radius: 8px;
+ border-radius: 6px;
color: var(--blocked);
font-size: 12px;
font-weight: 600;
@@ -93,118 +61,13 @@ summary:focus-visible {
.view-panel {
flex: 1 1 auto;
margin-bottom: 12px;
- padding-bottom: 52px;
}
.activity-view {
background: var(--surface-1);
- border: 1px dashed var(--line-strong);
- border-radius: var(--radius-row);
- display: grid;
- gap: 6px;
- padding: 18px 12px;
- place-items: center;
- text-align: center;
-}
-
-.activity-view h2 {
- color: var(--text);
- font-size: 13px;
- font-weight: 700;
-}
-
-.activity-view p {
- color: var(--faint);
- font-size: 11px;
-}
-
-.toolbar-icon,
-.view-nav-icon {
- fill: none;
- stroke: currentcolor;
- stroke-linecap: round;
- stroke-linejoin: round;
- stroke-width: 1.8;
-}
-
-.toolbar-icon {
- height: 16px;
- width: 16px;
-}
-
-.refresh-button,
-.quit-button {
- align-items: center;
- display: inline-flex;
- justify-content: center;
- min-width: 32px;
- padding: 5px;
-}
-
-.quit-button:hover:not(:disabled) {
- border-color: var(--blocked);
- color: var(--blocked);
-}
-
-.view-nav {
- align-items: center;
- background: var(--surface-1);
- border: 1px solid var(--line-strong);
- border-radius: 16px;
- bottom: 10px;
- box-shadow: var(--shadow-pop);
- display: grid;
- gap: 4px;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- margin-top: 8px;
- padding: 5px;
- position: sticky;
- width: 100%;
- z-index: 3;
-}
-
-@supports (background: color-mix(in srgb, black, white)) {
- .view-nav {
- background: color-mix(in srgb, var(--surface-1) 94%, transparent);
- }
-}
-
-.view-nav-button {
- align-items: center;
- background: transparent;
- border-color: transparent;
- border-radius: 12px;
- color: var(--faint);
- display: inline-flex;
- font-size: 10px;
- gap: 7px;
- justify-content: center;
- justify-self: stretch;
- line-height: 1;
- min-height: 40px;
- min-width: 0;
- padding: 6px 8px;
-}
-
-.view-nav-button:hover:not(:disabled) {
- background: var(--button-bg-hover);
- border-color: var(--line);
-}
-
-.view-nav-button[data-active="true"] {
- background: var(--accent-soft);
- border-color: var(--accent);
- color: var(--accent);
-}
-
-.view-nav-icon {
- flex: 0 0 auto;
- height: 18px;
- width: 18px;
-}
-
-.view-nav-button span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ display: block;
+ padding: 10px;
+ text-align: left;
}
diff --git a/apps/companion/src/styles/settings.css b/apps/companion/src/styles/settings.css
index bdaafa5..58693b3 100644
--- a/apps/companion/src/styles/settings.css
+++ b/apps/companion/src/styles/settings.css
@@ -1,19 +1,14 @@
.settings-panel {
- background: var(--surface-1);
- border: 1px solid var(--line);
- border-radius: var(--radius-row);
- box-shadow: var(--shadow-card);
display: grid;
- gap: 10px;
+ gap: 9px;
margin-bottom: 12px;
- padding: 10px;
}
.settings-panel-header {
align-items: start;
display: flex;
- justify-content: space-between;
gap: 10px;
+ justify-content: space-between;
}
.settings-panel h2 {
@@ -24,24 +19,8 @@
.settings-panel p,
.settings-hint {
- color: var(--faint);
- font-size: 11px;
-}
-
-.settings-section {
- border: 1px solid var(--line);
- border-radius: 8px;
- margin: 0;
- padding: 9px;
-}
-
-.settings-section legend {
- color: var(--accent);
+ color: var(--muted);
font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.08em;
- padding: 0 4px;
- text-transform: uppercase;
}
.settings-row {
@@ -53,11 +32,11 @@
.settings-row label {
color: var(--text);
- font-size: 12px;
+ font-size: 11px;
}
.settings-row input[type="checkbox"] {
- accent-color: var(--accent);
+ accent-color: var(--emphasis);
height: 18px;
width: 18px;
}
@@ -89,14 +68,14 @@
.font-preview {
background: var(--surface-2);
border: 1px solid var(--line);
- border-radius: 8px;
+ border-radius: 4px;
color: var(--text);
display: grid;
- font-size: 12px;
- gap: 6px;
+ font-size: 11px;
+ gap: 5px;
line-height: 1.45;
margin-top: 8px;
- padding: 9px;
+ padding: 8px;
}
.font-preview code {
@@ -105,39 +84,37 @@
}
.font-preview-label {
- color: var(--faint);
- font-size: 10px;
+ color: var(--muted);
+ font-size: 9px;
}
-.theme-mode-toggle {
- background: var(--surface-2);
+.agent-theme-picker {
border: 1px solid var(--line);
- border-radius: 9px;
+ border-radius: 4px;
display: grid;
- gap: 4px;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- margin: 4px 0 9px;
- padding: 4px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ margin-bottom: 8px;
}
-.theme-mode-button {
+.agent-theme-button {
background: transparent;
- border: 1px solid transparent;
- border-radius: 7px;
+ border: 0;
+ border-radius: 0;
color: var(--muted);
- font-size: 11px;
+ font-size: 10px;
font-weight: 700;
- min-height: 30px;
- padding: 6px;
}
-.theme-mode-button:hover:not(:disabled) {
+.agent-theme-button + .agent-theme-button {
+ border-left: 1px solid var(--line);
+}
+
+.agent-theme-button:hover:not(:disabled) {
background: var(--button-bg-hover);
color: var(--text);
}
-.theme-mode-button[data-active="true"] {
- background: var(--accent-soft);
- border-color: var(--accent);
- color: var(--text);
+.agent-theme-button[data-active="true"] {
+ background: var(--emphasis-soft);
+ color: var(--emphasis);
}
diff --git a/apps/companion/src/styles/status-groups.css b/apps/companion/src/styles/status-groups.css
index 3673c97..057ac97 100644
--- a/apps/companion/src/styles/status-groups.css
+++ b/apps/companion/src/styles/status-groups.css
@@ -1,79 +1,7 @@
-.app-summary {
- align-items: baseline;
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- min-width: 0;
-}
-
-.app-inventory {
- color: var(--muted);
- font-family: var(--app-font-family);
- font-size: 12px;
- font-weight: 600;
-}
-
-.app-badges {
- display: inline-flex;
- gap: 6px;
-}
-
-.badge {
- border: 1px solid var(--line);
- border-radius: 999px;
- font-family: var(--app-font-family);
- font-size: 11px;
- font-weight: 600;
- line-height: 1;
- padding: 3px 7px;
-}
-
-.badge-active {
- background: var(--accent-soft);
- border-color: var(--accent);
- color: var(--accent);
-}
-
-.badge-blocked {
- background: var(--blocked-soft);
- border-color: var(--blocked);
- color: var(--blocked);
-}
-
-.badge-noti {
- background: var(--notify-soft);
- border-color: var(--notify);
- color: var(--notify);
-}
-
.project-group {
- margin-bottom: 14px;
-}
-
-.project-group-header {
- align-items: baseline;
- border-left: 1px solid var(--accent);
- display: flex;
- gap: 8px;
- margin-bottom: 8px;
- padding-left: 8px;
-}
-
-.project-group-name {
- color: var(--text);
- font-size: 13px;
- font-weight: 700;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.project-group-count {
- color: var(--faint);
- font-size: 11px;
- white-space: nowrap;
+ margin-bottom: 10px;
}
-.project-group .task {
- margin-left: 8px;
+.project-group:last-child {
+ margin-bottom: 0;
}
diff --git a/apps/companion/src/styles/task-actions.css b/apps/companion/src/styles/task-actions.css
index aebbaeb..44c191e 100644
--- a/apps/companion/src/styles/task-actions.css
+++ b/apps/companion/src/styles/task-actions.css
@@ -1,44 +1,45 @@
.task-actions {
- align-items: center;
- background: var(--surface-1);
+ align-items: stretch;
border: 1px solid var(--line);
- border-radius: 8px;
+ border-radius: 4px;
display: flex;
flex: 1 1 100%;
- gap: 2px;
- margin-top: 0;
min-height: 32px;
- width: 100%;
overflow: visible;
- padding: 3px;
+ width: 100%;
}
.task-action {
align-items: center;
background: transparent;
- border-color: transparent;
- border-radius: 6px;
- color: var(--muted);
+ border: 0;
+ border-radius: 0;
+ color: var(--accent);
display: inline-flex;
flex: 1 1 0;
- font-size: 11px;
- gap: 5px;
+ font-size: 10px;
justify-content: center;
- min-height: 24px;
+ min-height: 30px;
min-width: 0;
- padding: 4px 7px;
+ padding: 5px 7px;
touch-action: manipulation;
- transition-duration: 120ms;
- transition-property: background-color, border-color, color, transform;
- transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ transition:
+ background-color 120ms ease,
+ color 120ms ease,
+ transform 120ms ease;
+}
+
+.task-action + .task-action {
+ border-left: 1px solid var(--line);
}
.task-action:hover:not(:disabled) {
- color: var(--text);
+ background: var(--button-bg-hover);
+ color: var(--accent-strong);
}
.task-action:active:not(:disabled) {
- transform: scale(0.96);
+ transform: scale(0.98);
}
.task-action:disabled {
@@ -46,17 +47,3 @@
cursor: not-allowed;
opacity: 0.55;
}
-
-.task-action-icon {
- fill: none;
- height: 13px;
- stroke: currentcolor;
- stroke-linecap: round;
- stroke-linejoin: round;
- stroke-width: 1.9;
- width: 13px;
-}
-
-.task-action-separator {
- display: none;
-}
diff --git a/apps/companion/src/styles/task-details.css b/apps/companion/src/styles/task-details.css
index 00dc2ba..4f68867 100644
--- a/apps/companion/src/styles/task-details.css
+++ b/apps/companion/src/styles/task-details.css
@@ -1,145 +1,107 @@
.task {
- background: var(--task-bg);
+ background: var(--surface-1);
border: 1px solid var(--line);
- border-radius: var(--radius-row);
- box-shadow: var(--shadow-card);
- margin-bottom: 8px;
+ border-radius: 4px;
+ margin-bottom: 6px;
overflow: hidden;
- transition-duration: 140ms;
- transition-property: box-shadow, border-color, transform;
- transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.task:last-child {
+ margin-bottom: 0;
}
.task:hover {
+ background: var(--surface-2);
border-color: var(--line-strong);
- box-shadow: var(--shadow-card-hover);
- transform: translateY(-1px);
}
-.task-in-progress {
- border-color: color-mix(in srgb, var(--accent) 38%, var(--line-strong));
+.task[open] {
+ background: var(--surface-2);
+ border-color: var(--line-strong);
}
summary {
cursor: default;
list-style: none;
- padding: 9px 10px;
+ padding: 7px 8px;
}
summary::-webkit-details-marker {
display: none;
}
-.task-summary-line {
- align-items: center;
- display: grid;
- gap: 8px;
- grid-template-columns: 16px minmax(0, 1fr) auto auto;
-}
-
-.task-status-rail {
- align-items: center;
- background: var(--surface-2);
- border-radius: 999px;
- display: inline-flex;
- height: 16px;
- justify-content: center;
- width: 16px;
-}
-
-.task-status-dot {
- background: var(--faint);
- border-radius: 999px;
- box-shadow: 0 0 0 2px var(--surface-2);
- display: block;
- height: 6px;
- width: 6px;
-}
-
-.task-in-progress .task-status-dot {
- background: var(--accent);
- box-shadow: 0 0 0 4px var(--active-rail-bg);
-}
-
-.task-blocked .task-status-dot {
- background: var(--blocked);
- box-shadow: 0 0 0 4px var(--blocked-soft);
+.task[open] > summary {
+ background: var(--task-selected-summary-bg);
+ border-inline-start: var(--task-selected-accent-width) solid var(--emphasis);
}
-.task-review .task-status-dot {
- background: var(--review);
- box-shadow: 0 0 0 4px var(--review-soft);
+.task-summary-line .prompt-line {
+ width: 100%;
}
-.task-done .task-status-dot {
- background: var(--done);
- box-shadow: 0 0 0 4px var(--done-soft);
+.task-summary-line .prompt-content {
+ align-items: center;
+ display: grid;
+ gap: 8px;
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
}
.task-name {
color: var(--text);
- font-size: 13px;
+ font-size: 12px;
font-weight: 600;
- letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.progress-pill,
-.noti-pill {
- border: 1px solid var(--line);
- border-radius: 999px;
- font-family: var(--app-font-family);
- font-size: 11px;
- font-weight: 600;
- line-height: 1;
- padding: 4px 7px;
-}
-
-.progress-pill {
+.task-progress,
+.task-notification {
color: var(--muted);
+ font-variant-numeric: tabular-nums;
}
-.task-done .progress-pill {
- background: var(--done-soft);
- border-color: var(--done);
- color: var(--done);
+.task-progress {
+ background: var(--surface-3);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--progress-pill-radius);
+ color: var(--text);
+ font-size: var(--progress-pill-font-size);
+ line-height: 1;
+ padding: var(--progress-pill-padding);
}
-.noti-pill {
- background: var(--notify-soft);
- border-color: var(--notify);
+.task-notification {
color: var(--notify);
+ font-size: 10px;
}
.task-detail {
border-top: 1px solid var(--line);
- padding: 8px 10px 9px;
+ padding: 8px;
}
.task-detail-header {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
- gap: 8px;
+ gap: 7px;
justify-content: space-between;
}
.current-step-strip {
- background: var(--surface-2);
- border: 1px solid var(--line);
- border-radius: 8px;
- box-shadow: inset 1px 0 0 var(--accent);
+ border-block: 1px solid var(--line);
+ margin-top: 8px;
+ padding-block: 5px;
+}
+
+.current-step-strip .prompt-content {
display: grid;
gap: 2px;
- margin-top: 8px;
- padding: 6px 8px 6px 10px;
}
.plan-title {
- color: var(--accent);
- font-family: var(--app-font-family);
+ color: var(--emphasis);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.06em;
@@ -148,15 +110,14 @@ summary::-webkit-details-marker {
.current-step {
color: var(--text);
- font-size: 12px;
+ font-size: 11px;
overflow-wrap: anywhere;
- white-space: normal;
}
.repo-chips {
display: flex;
flex-wrap: wrap;
- gap: 6px;
+ gap: 5px;
list-style: none;
margin: 0;
padding: 0;
@@ -169,16 +130,14 @@ summary::-webkit-details-marker {
.repo-chip {
align-items: center;
- background: var(--surface-2);
border: 1px solid var(--line);
- border-radius: 7px;
+ border-radius: 4px;
color: var(--muted);
display: inline-flex;
- font-family: var(--app-font-family);
- font-size: 11px;
+ font-size: 10px;
gap: 5px;
max-width: 100%;
- padding: 4px 6px;
+ padding: 3px 5px;
}
.repo-dirty {
@@ -194,7 +153,7 @@ summary::-webkit-details-marker {
}
.repo-branch {
- color: var(--faint);
+ color: var(--muted);
}
.repo-dot {
@@ -205,10 +164,10 @@ summary::-webkit-details-marker {
.steps {
border-left: 1px solid var(--line);
color: var(--muted);
- font-size: 12px;
+ font-size: 11px;
line-height: 1.45;
list-style: none;
- margin: 9px 0 0 7px;
+ margin: 8px 0 0 6px;
padding: 0 0 0 8px;
}
@@ -221,6 +180,7 @@ summary::-webkit-details-marker {
}
.step-marker {
+ border: 1px solid var(--line);
border-radius: 999px;
box-sizing: border-box;
display: inline-block;
@@ -230,12 +190,10 @@ summary::-webkit-details-marker {
.step-depth-0 .step-marker {
border-radius: 2px;
height: 8px;
- margin-top: 5px;
width: 8px;
}
.step-depth-1 .step-marker {
- border-radius: 999px;
height: 7px;
width: 7px;
}
@@ -247,36 +205,20 @@ summary::-webkit-details-marker {
width: 5px;
}
-.step-marker-done {
- background: var(--faint);
- box-shadow: 0 0 0 2px var(--line);
-}
-
+.step-marker-done,
.step-depth-0.step-item-done .step-marker {
background: var(--faint);
- box-shadow: 0 0 0 2px var(--line);
-}
-
-.step-depth-1.step-item-done .step-marker {
- box-shadow: 0 0 0 2px var(--line);
-}
-
-.step-depth-2.step-item-done .step-marker {
- box-shadow: 0 0 0 1px var(--line);
}
.step-marker-todo {
background: var(--surface-2);
- border: 1px solid var(--line-strong);
-}
-
-.step-depth-0.step-item-todo .step-marker {
border-color: var(--line-strong);
- box-shadow: 0 0 0 2px var(--line);
}
.step-text {
- overflow-wrap: anywhere;
+ overflow-wrap: break-word;
+ text-wrap: balance;
+ word-break: keep-all;
}
.step-item-done .step-text {
@@ -287,20 +229,21 @@ summary::-webkit-details-marker {
color: var(--text);
}
+.error,
+.empty {
+ border-radius: 4px;
+ font-size: 12px;
+ padding: 8px;
+}
+
.error {
background: var(--blocked-soft);
border: 1px solid var(--blocked);
- border-radius: 8px;
color: var(--blocked);
- font-size: 12px;
- padding: 8px;
}
.empty {
border: 1px dashed var(--line-strong);
- border-radius: var(--radius-row);
color: var(--muted);
- font-size: 12px;
- padding: 14px;
text-align: center;
}
diff --git a/apps/companion/src/styles/themes.css b/apps/companion/src/styles/themes.css
index 4884ed1..bc4cfa2 100644
--- a/apps/companion/src/styles/themes.css
+++ b/apps/companion/src/styles/themes.css
@@ -1,99 +1,83 @@
-main[data-theme="catppuccin-dark"] {
- --surface-0: #1e1e2e;
- --surface-1: #181825;
- --surface-2: #313244;
- --surface-3: #45475a;
- --line: rgba(205, 214, 244, 0.12);
- --line-strong: rgba(205, 214, 244, 0.24);
- --text: #cdd6f4;
- --muted: #a6adc8;
- --faint: #7f849c;
- --accent: #cba6f7;
- --accent-strong: #ddb6f9;
- --accent-soft: rgba(203, 166, 247, 0.18);
- --blocked: #f38ba8;
- --blocked-soft: rgba(243, 139, 168, 0.18);
- --review: #f5c2e7;
- --review-soft: rgba(245, 194, 231, 0.16);
- --done: #a6e3a1;
- --done-soft: rgba(166, 227, 161, 0.16);
- --notify: #f9e2af;
- --notify-soft: rgba(249, 226, 175, 0.16);
- --button-bg: rgba(205, 214, 244, 0.07);
- --button-bg-hover: rgba(205, 214, 244, 0.13);
- --shell-bg:
- radial-gradient(120% 80px at 50% 0, rgba(203, 166, 247, 0.1), transparent),
- var(--surface-0);
- --shell-topline: linear-gradient(
- 90deg,
- transparent,
- #cba6f7,
- #f5c2e7,
- transparent
- );
- --current-step-bg:
- linear-gradient(135deg, var(--accent-soft), transparent 70%),
- var(--surface-3);
- --cal-1: #cba6f7;
- --cal-2: #89b4fa;
- --cal-3: #a6e3a1;
- --cal-4: #f9e2af;
- --cal-5: #f5c2e7;
- --cal-6: #94e2d5;
+main[data-theme="claude"] {
+ --surface-0: #0e0e0e;
+ --surface-1: #131313;
+ --surface-2: #1b1b1b;
+ --surface-3: #252525;
+ --line: #3a3a3a;
+ --line-strong: #565656;
+ --text: #c0caf5;
+ --muted: #949494;
+ --faint: #626262;
+ --accent: #cd694a;
+ --accent-strong: #e08567;
+ --accent-soft: rgba(205, 105, 74, 0.14);
+ --emphasis: #cd694a;
+ --emphasis-soft: rgba(192, 202, 245, 0.08);
+ --task-selected-summary-bg: rgba(192, 202, 245, 0.06);
+ --blocked: #f7768e;
+ --blocked-soft: rgba(247, 118, 142, 0.14);
+ --review: #bb9af7;
+ --review-soft: rgba(187, 154, 247, 0.14);
+ --done: #87d787;
+ --done-soft: rgba(135, 215, 135, 0.14);
+ --notify: #e0af68;
+ --notify-soft: rgba(224, 175, 104, 0.14);
+ --button-bg: rgba(192, 202, 245, 0.06);
+ --button-bg-hover: rgba(192, 202, 245, 0.12);
+ --current-step-bg: rgba(192, 202, 245, 0.08);
+ --cal-1: #cd694a;
+ --cal-2: #7dcfff;
+ --cal-3: #87d787;
+ --cal-4: #e0af68;
+ --cal-5: #bb9af7;
+ --cal-6: #f7768e;
+ --cal-bg-1: rgba(205, 105, 74, 0.18);
+ --cal-bg-2: rgba(125, 207, 255, 0.18);
+ --cal-bg-3: rgba(135, 215, 135, 0.18);
+ --cal-bg-4: rgba(224, 175, 104, 0.18);
+ --cal-bg-5: rgba(187, 154, 247, 0.18);
+ --cal-bg-6: rgba(247, 118, 142, 0.18);
}
-main[data-theme="breakfast-light"] {
- --surface-0: #ffffff;
- --surface-1: #fbfcff;
- --surface-2: #f4f6fb;
- --surface-3: #e9edf5;
- --line: rgba(36, 38, 51, 0.1);
- --line-strong: rgba(36, 38, 51, 0.2);
- --text: #242633;
- --muted: #555b6f;
- --faint: #81879a;
- --accent: #7c3aed;
- --accent-strong: #5b21b6;
- --accent-soft: rgba(124, 58, 237, 0.12);
- --blocked: #d20f39;
- --blocked-soft: rgba(210, 15, 57, 0.12);
- --review: #8839ef;
- --review-soft: rgba(136, 57, 239, 0.12);
- --done: #2f9e44;
- --done-soft: rgba(47, 158, 68, 0.12);
- --notify: #d97706;
- --notify-soft: rgba(217, 119, 6, 0.1);
- --button-bg: rgba(36, 38, 51, 0.04);
- --button-bg-hover: rgba(36, 38, 51, 0.08);
- --shell-bg:
- radial-gradient(120% 120px at 50% 0, rgba(124, 58, 237, 0.08), transparent),
- radial-gradient(
- 90% 160px at 100% 10%,
- rgba(203, 166, 247, 0.08),
- transparent
- ),
- var(--surface-0);
- --shell-topline: linear-gradient(
- 90deg,
- transparent,
- #c4b5fd,
- #7c3aed,
- transparent
- );
- --current-step-bg:
- linear-gradient(135deg, var(--accent-soft), transparent 72%),
- var(--surface-3);
- --cal-1: #7c3aed;
- --cal-2: #1d4ed8;
- --cal-3: #2f9e44;
- --cal-4: #d97706;
- --cal-5: #db2777;
- --cal-6: #0d9488;
-}
-
-main[data-theme$="-light"] {
- --shadow-soft: rgba(30, 35, 50, 0.1);
- --shadow-strong: rgba(30, 35, 50, 0.16);
+main[data-theme="codex"] {
+ --surface-0: #0f0f0f;
+ --surface-1: #141414;
+ --surface-2: #1c1c1c;
+ --surface-3: #262626;
+ --line: #3a3a3a;
+ --line-strong: #565656;
+ --text: #ededed;
+ --muted: #949494;
+ --faint: #5d5d5d;
+ --accent: #5cc2e0;
+ --accent-strong: #8bdcf2;
+ --accent-soft: rgba(92, 194, 224, 0.14);
+ --emphasis: #ededed;
+ --emphasis-soft: rgba(237, 237, 237, 0.1);
+ --task-selected-summary-bg: rgba(237, 237, 237, 0.06);
+ --blocked: #f2a0a0;
+ --blocked-soft: rgba(242, 160, 160, 0.14);
+ --review: #bb9af7;
+ --review-soft: rgba(187, 154, 247, 0.14);
+ --done: #abdfa7;
+ --done-soft: rgba(171, 223, 167, 0.14);
+ --notify: #f6e2b7;
+ --notify-soft: rgba(246, 226, 183, 0.14);
+ --button-bg: rgba(237, 237, 237, 0.06);
+ --button-bg-hover: rgba(237, 237, 237, 0.12);
+ --current-step-bg: var(--emphasis-soft);
+ --cal-1: #5cc2e0;
+ --cal-2: #f6e2b7;
+ --cal-3: #abdfa7;
+ --cal-4: #a7a7a7;
+ --cal-5: #f2a0a0;
+ --cal-6: #bb9af7;
+ --cal-bg-1: rgba(92, 194, 224, 0.18);
+ --cal-bg-2: rgba(246, 226, 183, 0.18);
+ --cal-bg-3: rgba(171, 223, 167, 0.18);
+ --cal-bg-4: rgba(167, 167, 167, 0.18);
+ --cal-bg-5: rgba(242, 160, 160, 0.18);
+ --cal-bg-6: rgba(187, 154, 247, 0.18);
}
main[data-font="sf-mono"] {
@@ -114,14 +98,3 @@ main[data-font="jetbrains-mono"] {
"JetBrains Mono", ui-monospace, "SFMono-Regular", "Menlo", "Monaco",
"Consolas", monospace;
}
-
-main::before {
- background: var(--shell-topline);
- content: "";
- display: block;
- height: 1px;
- left: 12px;
- position: absolute;
- right: 12px;
- top: 0;
-}
diff --git a/apps/companion/src/ui/AgentHeader.tsx b/apps/companion/src/ui/AgentHeader.tsx
new file mode 100644
index 0000000..645d2e7
--- /dev/null
+++ b/apps/companion/src/ui/AgentHeader.tsx
@@ -0,0 +1,76 @@
+import type { CompanionTheme } from "../application/preferences";
+import type { MenuSummary } from "../application/state";
+
+export type AgentHeaderProps = {
+ readonly theme: CompanionTheme;
+ readonly summary: MenuSummary;
+ readonly status: string;
+ readonly onRefresh: () => void;
+ readonly onQuit: () => void;
+};
+
+function AgentInventory({ summary }: { readonly summary: MenuSummary }) {
+ return (
+
+ {summary.projectCount}{" "}
+ {summary.projectCount === 1 ? "project" : "projects"} ·{" "}
+ {summary.taskCount} {summary.taskCount === 1 ? "task" : "tasks"}
+
+ );
+}
+
+function AgentControls({
+ status,
+ onRefresh,
+ onQuit,
+}: Pick) {
+ return (
+
+
+ {status}
+
+
+ ↻
+
+
+ ⏻
+
+
+ );
+}
+
+export function AgentHeader({
+ theme,
+ summary,
+ status,
+ onRefresh,
+ onQuit,
+}: AgentHeaderProps) {
+ const controls = { status, onRefresh, onQuit };
+
+ return (
+
+
+
+
Workbranch Companion
+
+
+
+
+
+ );
+}
diff --git a/apps/companion/src/ui/AgentTabs.tsx b/apps/companion/src/ui/AgentTabs.tsx
new file mode 100644
index 0000000..ea159cd
--- /dev/null
+++ b/apps/companion/src/ui/AgentTabs.tsx
@@ -0,0 +1,36 @@
+export type CompanionView = "main" | "activity" | "settings";
+
+export type AgentTabsProps = {
+ readonly currentView: CompanionView;
+ readonly onViewChange: (view: CompanionView) => void;
+};
+
+type AgentTab = {
+ readonly view: CompanionView;
+ readonly label: string;
+};
+
+const AGENT_TABS: readonly AgentTab[] = [
+ { view: "main", label: "Main" },
+ { view: "activity", label: "Activity" },
+ { view: "settings", label: "Settings" },
+];
+
+export function AgentTabs({ currentView, onViewChange }: AgentTabsProps) {
+ return (
+
+ {AGENT_TABS.map(({ view, label }) => (
+ onViewChange(view)}
+ type="button"
+ >
+ {label}
+
+ ))}
+
+ );
+}
diff --git a/apps/companion/src/ui/AgentThemePicker.tsx b/apps/companion/src/ui/AgentThemePicker.tsx
new file mode 100644
index 0000000..4eb7c1d
--- /dev/null
+++ b/apps/companion/src/ui/AgentThemePicker.tsx
@@ -0,0 +1,32 @@
+import {
+ COMPANION_THEME_OPTIONS,
+ type CompanionTheme,
+} from "../application/preferences";
+
+type AgentThemePickerProps = {
+ readonly value: CompanionTheme;
+ readonly onChange: (theme: CompanionTheme) => void;
+};
+
+export function AgentThemePicker({ value, onChange }: AgentThemePickerProps) {
+ return (
+
+ {COMPANION_THEME_OPTIONS.map((option) => {
+ const selected = option.value === value;
+ return (
+ onChange(option.value)}
+ type="button"
+ >
+ {option.label}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/companion/src/ui/AppSummary.tsx b/apps/companion/src/ui/AppSummary.tsx
deleted file mode 100644
index 99a1124..0000000
--- a/apps/companion/src/ui/AppSummary.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { MenuSummary } from "../application/state";
-
-function plural(count: number, word: string): string {
- return count === 1 ? word : `${word}s`;
-}
-
-type Props = {
- readonly summary: MenuSummary;
-};
-
-export function AppSummary({ summary }: Props) {
- const { projectCount, taskCount, active, blocked, notifications } = summary;
- return (
-
-
- {taskCount === 0
- ? "No tasks"
- : `${projectCount} ${plural(projectCount, "project")} · ${taskCount} ${plural(taskCount, "task")}`}
-
-
- {active > 0 ? (
-
- RUN {active}
-
- ) : null}
- {blocked > 0 ? (
-
- BLK {blocked}
-
- ) : null}
- {notifications > 0 ? (
-
- NOTI {notifications}
-
- ) : null}
-
-
- );
-}
diff --git a/apps/companion/src/ui/AppToolbar.tsx b/apps/companion/src/ui/AppToolbar.tsx
deleted file mode 100644
index 2ec585a..0000000
--- a/apps/companion/src/ui/AppToolbar.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-type Props = {
- readonly onRefresh: () => void;
- readonly onQuit: () => void;
- readonly status: string;
-};
-
-export function AppToolbar({ onRefresh, onQuit, status }: Props) {
- return (
-
-
- {status}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/companion/src/ui/ProjectGroup.tsx b/apps/companion/src/ui/ProjectGroup.tsx
index 19a43ce..2e81463 100644
--- a/apps/companion/src/ui/ProjectGroup.tsx
+++ b/apps/companion/src/ui/ProjectGroup.tsx
@@ -1,33 +1,32 @@
+import type { CompanionTheme } from "../application/preferences";
import type { ProjectGroup as ProjectGroupModel } from "../application/state";
-import type { Task } from "../domain/model";
-import { type TaskActionKind, TaskRow } from "./TaskRow";
+import { type TaskActionHandler, TaskRow } from "./TaskRow";
+import { TerminalPanel } from "./TerminalPanel";
-type Props = {
+type ProjectGroupProps = {
readonly group: ProjectGroupModel;
- readonly onAction: (root: string, task: Task, kind: TaskActionKind) => void;
+ readonly theme: CompanionTheme;
+ readonly onAction: TaskActionHandler;
};
-export function ProjectGroup({ group, onAction }: Props) {
+export function ProjectGroup({ group, theme, onAction }: ProjectGroupProps) {
const count = group.rows.length;
return (
-
-
-
- {group.project}
-
-
- {count} {count === 1 ? "task" : "tasks"}
-
-
+
{group.rows.map((row) => (
))}
-
+
);
}
diff --git a/apps/companion/src/ui/PromptLine.tsx b/apps/companion/src/ui/PromptLine.tsx
new file mode 100644
index 0000000..f2d7990
--- /dev/null
+++ b/apps/companion/src/ui/PromptLine.tsx
@@ -0,0 +1,27 @@
+import type { ReactNode } from "react";
+import type { CompanionTheme } from "../application/preferences";
+
+export type PromptLineProps = {
+ readonly theme: CompanionTheme;
+ readonly current?: boolean;
+ readonly children: ReactNode;
+};
+
+export function PromptLine({
+ theme,
+ current = false,
+ children,
+}: PromptLineProps) {
+ return (
+
+
+ {theme === "claude" ? "❯" : "›"}
+
+ {children}
+
+ );
+}
diff --git a/apps/companion/src/ui/SettingsPanel.tsx b/apps/companion/src/ui/SettingsPanel.tsx
index 6c4b4ff..ff4295d 100644
--- a/apps/companion/src/ui/SettingsPanel.tsx
+++ b/apps/companion/src/ui/SettingsPanel.tsx
@@ -1,16 +1,13 @@
-import type {
- CompanionPreferences,
- CompanionResolvedThemeMode,
-} from "../application/preferences";
+import type { CompanionPreferences } from "../application/preferences";
import {
COMPANION_FONT_OPTIONS,
- COMPANION_THEME_MODE_OPTIONS,
isCompanionFont,
} from "../application/preferences";
+import { AgentThemePicker } from "./AgentThemePicker";
+import { TerminalPanel } from "./TerminalPanel";
type Props = {
readonly preferences: CompanionPreferences;
- readonly systemThemeMode: CompanionResolvedThemeMode;
readonly launchAtLogin: boolean;
readonly launchAtLoginLoading: boolean;
readonly onLaunchAtLoginChange: (enabled: boolean) => void;
@@ -19,7 +16,6 @@ type Props = {
export function SettingsPanel({
preferences,
- systemThemeMode,
launchAtLogin,
launchAtLoginLoading,
onLaunchAtLoginChange,
@@ -30,10 +26,6 @@ export function SettingsPanel({
);
const fontFamily = fontOption?.cssFamily ?? preferences.font;
const fontName = fontOption?.label ?? preferences.font;
- const modeHint =
- preferences.themeMode === "system"
- ? `System (${systemThemeMode === "dark" ? "Dark" : "Light"} now)`
- : `${preferences.themeMode === "dark" ? "Dark" : "Light"} mode`;
return (
@@ -43,8 +35,7 @@ export function SettingsPanel({
Companion preferences
-
- Startup
+
Open at Login
-
-
- Font
+
+
Font
1234567890 · RUN 1 · 21/21
Current font: {fontName}
-
-
- Theme
-
- {COMPANION_THEME_MODE_OPTIONS.map((option) => {
- const selected = option.value === preferences.themeMode;
- return (
-
- onPreferencesChange({
- ...preferences,
- themeFamily: "companion",
- themeMode: option.value,
- })
- }
- type="button"
- >
- {option.label}
-
- );
- })}
-
+
+
+ onPreferencesChange({ ...preferences, theme })}
+ />
- {modeHint} · Palette: Catppuccin Dark / White Light
+ Applies immediately across Main, Activity, and Settings.
-
+
);
}
diff --git a/apps/companion/src/ui/SettingsView.tsx b/apps/companion/src/ui/SettingsView.tsx
index d36e8a6..331c105 100644
--- a/apps/companion/src/ui/SettingsView.tsx
+++ b/apps/companion/src/ui/SettingsView.tsx
@@ -1,12 +1,8 @@
-import type {
- CompanionPreferences,
- CompanionResolvedThemeMode,
-} from "../application/preferences";
+import type { CompanionPreferences } from "../application/preferences";
import { SettingsPanel } from "./SettingsPanel";
type Props = {
readonly preferences: CompanionPreferences;
- readonly systemThemeMode: CompanionResolvedThemeMode;
readonly launchAtLogin: boolean;
readonly launchAtLoginLoading: boolean;
readonly onLaunchAtLoginChange: (enabled: boolean) => void;
@@ -15,7 +11,6 @@ type Props = {
export function SettingsView({
preferences,
- systemThemeMode,
launchAtLogin,
launchAtLoginLoading,
onLaunchAtLoginChange,
@@ -25,7 +20,6 @@ export function SettingsView({
= {
+ todo: "TODO",
+ planning: "PLAN",
+ "in-progress": "RUN",
+ review: "REVIEW",
+ blocked: "BLOCKED",
+ done: "DONE",
+};
+
+export function StatusToken({ status }: StatusTokenProps) {
+ return (
+
+
+ {STATUS_LABELS[status]}
+
+ );
+}
diff --git a/apps/companion/src/ui/TaskActionIcon.tsx b/apps/companion/src/ui/TaskActionIcon.tsx
deleted file mode 100644
index 64acd55..0000000
--- a/apps/companion/src/ui/TaskActionIcon.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { TaskActionKind } from "./TaskRow";
-
-export function TaskActionIcon({ kind }: { readonly kind: TaskActionKind }) {
- switch (kind) {
- case "ide":
- return (
-
-
-
-
-
- );
- case "terminal":
- return (
-
-
-
-
- );
- case "finder":
- return (
-
-
-
-
- );
- }
-}
diff --git a/apps/companion/src/ui/TaskRow.tsx b/apps/companion/src/ui/TaskRow.tsx
index ab97a86..6bcc84f 100644
--- a/apps/companion/src/ui/TaskRow.tsx
+++ b/apps/companion/src/ui/TaskRow.tsx
@@ -1,17 +1,10 @@
import { Fragment } from "react";
+import type { CompanionTheme } from "../application/preferences";
import { currentItem } from "../application/state";
import type { Step, Task } from "../domain/model";
import { activePlan, taskProgress, taskStatus } from "../domain/model";
-import { TaskActionIcon } from "./TaskActionIcon";
-
-const STATUS_META = {
- todo: { label: "Todo" },
- planning: { label: "Planning" },
- "in-progress": { label: "In progress" },
- review: { label: "Review" },
- blocked: { label: "Blocked" },
- done: { label: "Done" },
-} as const;
+import { PromptLine } from "./PromptLine";
+import { StatusToken } from "./StatusToken";
const TASK_ACTION_KINDS = ["ide", "terminal", "finder"] as const;
@@ -30,11 +23,18 @@ const TASK_ACTION_LABELS: Record = {
finder: "Finder",
} as const;
-type Props = {
+export type TaskActionHandler = (
+ root: string,
+ task: Task,
+ kind: TaskActionKind,
+) => void;
+
+type TaskRowProps = {
readonly root: string;
readonly task: Task;
+ readonly theme: CompanionTheme;
readonly expanded: boolean;
- readonly onAction: (root: string, task: Task, kind: TaskActionKind) => void;
+ readonly onAction: TaskActionHandler;
};
type StepItemsProps = {
@@ -88,53 +88,54 @@ function StepItems({ steps, keyPrefix }: StepItemsProps) {
type TaskSummaryProps = {
readonly task: Task;
+ readonly theme: CompanionTheme;
readonly status: ReturnType;
readonly progress: ReturnType;
};
-function TaskSummary({ task, status, progress }: TaskSummaryProps) {
- const meta = STATUS_META[status];
+function TaskSummary({ task, theme, status, progress }: TaskSummaryProps) {
return (
-
-
-
-
- {task.name}
-
- {progress.total > 0 ? (
-
- {progress.done}/{progress.total}
+
+
+ {task.name}
- ) : null}
- {task.notiCount > 0 ? (
-
- +{task.notiCount}
-
- ) : null}
+
+ {progress.total > 0 ? (
+
+ {progress.done}/{progress.total}
+
+ ) : null}
+ {task.notiCount > 0 ? (
+
+ +{task.notiCount}
+
+ ) : null}
+
);
}
type CurrentStepProps = {
+ readonly theme: CompanionTheme;
readonly planTitle: string;
readonly currentItem: string;
};
-function CurrentStep({ planTitle, currentItem }: CurrentStepProps) {
+function CurrentStep({ theme, planTitle, currentItem }: CurrentStepProps) {
return (
-
- {planTitle}
-
-
- {currentItem}
-
+
+
+ {planTitle}
+
+
+ {currentItem}
+
+
);
}
@@ -168,7 +169,13 @@ function RepoChips({ repos }: RepoChipsProps) {
);
}
-export function TaskRow({ root, task, expanded, onAction }: Props) {
+export function TaskRow({
+ root,
+ task,
+ theme,
+ expanded,
+ onAction,
+}: TaskRowProps) {
const status = taskStatus(task);
const progress = taskProgress(task);
const now = currentItem(task);
@@ -176,35 +183,33 @@ export function TaskRow({ root, task, expanded, onAction }: Props) {
return (
-
+
- {taskActionsFor(task).map((action, index) => (
-
- {index > 0 ? (
-
- ·
-
- ) : null}
- onAction(root, task, action.kind)}
- type="button"
- >
-
- {action.label}
-
-
+ {taskActionsFor(task).map((action) => (
+ onAction(root, task, action.kind)}
+ type="button"
+ >
+ {action.label}
+
))}
{plan && now ? (
-
+
) : null}
{plan ? (
diff --git a/apps/companion/src/ui/TerminalPanel.tsx b/apps/companion/src/ui/TerminalPanel.tsx
new file mode 100644
index 0000000..ce17807
--- /dev/null
+++ b/apps/companion/src/ui/TerminalPanel.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from "react";
+import type { CompanionTheme } from "../application/preferences";
+
+export type TerminalPanelProps = {
+ readonly theme: CompanionTheme;
+ readonly anatomy?: CompanionTheme;
+ readonly label: string;
+ readonly className?: string;
+ readonly children: ReactNode;
+};
+
+export function TerminalPanel({
+ theme,
+ anatomy = theme,
+ label,
+ className,
+ children,
+}: TerminalPanelProps) {
+ const classes = ["terminal-panel", className].filter(Boolean).join(" ");
+
+ if (anatomy === "claude") {
+ return (
+
+ {label}
+ {children}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/companion/src/ui/ViewNav.tsx b/apps/companion/src/ui/ViewNav.tsx
deleted file mode 100644
index 7031854..0000000
--- a/apps/companion/src/ui/ViewNav.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-export type CompanionView = "main" | "activity" | "settings";
-
-type ViewNavItem = {
- readonly view: CompanionView;
- readonly label: string;
- readonly ariaLabel: string;
-};
-
-type ViewNavProps = {
- readonly currentView: CompanionView;
- readonly onViewChange: (view: CompanionView) => void;
-};
-
-const VIEW_NAV_ITEMS: readonly ViewNavItem[] = [
- { view: "main", label: "Main", ariaLabel: "Open Main View" },
- {
- view: "activity",
- label: "Activity",
- ariaLabel: "Open Activity report",
- },
- { view: "settings", label: "Settings", ariaLabel: "Open Settings" },
-] as const;
-
-function ViewIcon({ view }: { readonly view: CompanionView }) {
- switch (view) {
- case "main":
- return (
-
-
-
-
-
- );
- case "activity":
- return (
-
-
-
-
-
-
- );
- case "settings":
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-export function ViewNav({ currentView, onViewChange }: ViewNavProps) {
- return (
-
- {VIEW_NAV_ITEMS.map((item) => {
- const selected = item.view === currentView;
- return (
- onViewChange(item.view)}
- type="button"
- >
-
- {item.label}
-
- );
- })}
-
- );
-}
diff --git a/apps/companion/tests/activity-calendar.test.tsx b/apps/companion/tests/activity-calendar.test.tsx
index af34a53..542d4b4 100644
--- a/apps/companion/tests/activity-calendar.test.tsx
+++ b/apps/companion/tests/activity-calendar.test.tsx
@@ -527,8 +527,9 @@ describe("CalendarTimeline", () => {
);
});
- it("provides static project-colored fallbacks before color-mix session colors", () => {
+ it("provides theme-owned project fallbacks before color-mix session colors", () => {
const css = readFileSync("src/activity/activity-calendar.css", "utf8");
+ const themes = readFileSync("src/styles/themes.css", "utf8");
const sessionBlock = css.match(/\.cal-session\s*\{(?[^}]*)\}/)?.groups
?.body;
@@ -537,8 +538,9 @@ describe("CalendarTimeline", () => {
}
expect(css).toMatch(
- /\.cal-session\[data-color="0"\]\s*\{[^}]*--cal-bg:\s*rgba/s,
+ /\.cal-chip\[data-color="0"\],\s*\.cal-session\[data-color="0"\]\s*\{[^}]*--cal-bg:\s*var\(--cal-bg-1\)/s,
);
+ expect(themes).toMatch(/--cal-bg-1:\s*rgba\(/);
expect(sessionBlock).toMatch(/background:\s*var\(--cal-bg\)/);
expect(sessionBlock).toMatch(
/border-color:\s*var\(--cal-color,\s*var\(--accent\)\)/,
@@ -549,6 +551,23 @@ describe("CalendarTimeline", () => {
);
});
+ it("uses flat fixed-dark terminal tokens for calendar surfaces", () => {
+ const css = readFileSync("src/activity/activity-calendar.css", "utf8");
+
+ expect(css).toContain("var(--surface-1)");
+ expect(css).toContain("var(--surface-2)");
+ expect(css).toContain("var(--line)");
+ for (const index of [1, 2, 3, 4, 5, 6]) {
+ expect(css).toContain(`var(--cal-${index})`);
+ }
+ const nonCircularCss = css.replace(/border-radius:\s*999px/g, "");
+ expect(css).not.toMatch(/gradient\(/);
+ expect(css).not.toContain("box-shadow");
+ expect(nonCircularCss).not.toMatch(
+ /border-radius:\s*(?:[7-9]|[1-9][0-9]+)px/,
+ );
+ });
+
it("separates visually overlapping compact blocks before rendering", () => {
const lanes = assignDisplayLanes(
sessionsFromEvents([
diff --git a/apps/companion/tests/agent-primitives.test.tsx b/apps/companion/tests/agent-primitives.test.tsx
new file mode 100644
index 0000000..440c7d3
--- /dev/null
+++ b/apps/companion/tests/agent-primitives.test.tsx
@@ -0,0 +1,127 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import type { MenuSummary } from "../src/application/state";
+import { AgentHeader } from "../src/ui/AgentHeader";
+import { AgentTabs } from "../src/ui/AgentTabs";
+import { PromptLine } from "../src/ui/PromptLine";
+import { StatusToken } from "../src/ui/StatusToken";
+import { TerminalPanel } from "../src/ui/TerminalPanel";
+
+const summary: MenuSummary = {
+ projectCount: 2,
+ taskCount: 3,
+ active: 1,
+ blocked: 1,
+ notifications: 4,
+};
+
+const controls = {
+ status: "Ready",
+ onRefresh: () => undefined,
+ onQuit: () => undefined,
+};
+
+describe("agent primitives", () => {
+ it("renders the same text-only expanded header anatomy for each theme", () => {
+ const claude = renderToStaticMarkup(
+ ,
+ );
+ const codex = renderToStaticMarkup(
+ ,
+ );
+
+ expect(
+ claude.replace('data-agent-header="claude"', 'data-agent-header="theme"'),
+ ).toBe(
+ codex.replace('data-agent-header="codex"', 'data-agent-header="theme"'),
+ );
+ for (const html of [claude, codex]) {
+ expect(html).toContain("Workbranch Companion");
+ expect(html).not.toContain(" {
+ const singular = renderToStaticMarkup(
+ ,
+ );
+ const zero = renderToStaticMarkup(
+ ,
+ );
+
+ expect(singular).toContain("1 project · 1 task");
+ expect(singular).not.toContain("1 projects");
+ expect(singular).not.toContain("1 tasks");
+ expect(zero).toContain("0 projects · 0 tasks");
+ });
+
+ it("renders three text-only accessible terminal tabs", () => {
+ const html = renderToStaticMarkup(
+ undefined} />,
+ );
+
+ expect(html.match(/Main ");
+ expect(html).toContain(">Activity");
+ expect(html).toContain(">Settings");
+ expect(html.match(/aria-current="page"/g)).toHaveLength(1);
+ expect(html).not.toContain(" {
+ const claude = renderToStaticMarkup(
+
+ Content
+ ,
+ );
+ const codex = renderToStaticMarkup(
+
+ Content
+ ,
+ );
+
+ expect(claude).toContain("Projects");
+ expect(codex).toContain("Projects");
+ });
+
+ it("renders theme-specific prompt markers in the markup", () => {
+ expect(
+ renderToStaticMarkup(
+ Current step ,
+ ),
+ ).toContain("❯");
+ expect(
+ renderToStaticMarkup(Current step ),
+ ).toContain("›");
+ });
+
+ it("keeps status text visible alongside its marker", () => {
+ const html = renderToStaticMarkup( );
+
+ expect(html).toContain("RUN");
+ expect(html).toContain('aria-hidden="true"');
+ });
+});
diff --git a/apps/companion/tests/view-nav.test.tsx b/apps/companion/tests/agent-tabs.test.tsx
similarity index 62%
rename from apps/companion/tests/view-nav.test.tsx
rename to apps/companion/tests/agent-tabs.test.tsx
index 9585088..1cf120a 100644
--- a/apps/companion/tests/view-nav.test.tsx
+++ b/apps/companion/tests/agent-tabs.test.tsx
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
import { Children, isValidElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
-import { type CompanionView, ViewNav } from "../src/ui/ViewNav";
+import { AgentTabs, type CompanionView } from "../src/ui/AgentTabs";
type ButtonProps = {
readonly "aria-label"?: string;
@@ -37,53 +37,63 @@ function readCssContract(path: string): string {
return `${css}\n${importedCss}`;
}
-describe("ViewNav", () => {
- it("renders a mobile-style view menu with the current view marked", () => {
+describe("AgentTabs", () => {
+ it("renders a terminal view menu with the current view marked", () => {
// Given the settings view is active
// When the view navigation is rendered
const html = renderToStaticMarkup(
- {}} />,
+ undefined} />,
);
// Then all view destinations are visible and the active one is exposed
expect(html).toContain('aria-label="Companion views"');
- expect(html).toContain('aria-label="Open Main View"');
- expect(html).toContain('aria-label="Open Activity report"');
- expect(html).toContain('aria-label="Open Settings"');
expect(html).toContain('aria-current="page"');
expect(html).toContain("Main");
expect(html).toContain("Activity");
- expect(html).toContain("Setting");
+ expect(html).toContain("Settings");
+ expect(html).not.toContain(" {
+ it("renders a floating bottom tab row as three equal-width controls", () => {
// Given the companion stylesheet for the bottom view navigation
// When the nav button layout contract is inspected
const css = readCssContract("src/style.css");
// Then each menu item keeps the icon before the text with visible spacing
expect(css).toMatch(
- /\.view-nav-button\s*\{[^}]*display:\s*inline-flex[^}]*gap:\s*7px/s,
+ /\.agent-tabs\s*\{[^}]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/s,
+ );
+ expect(css).toMatch(/\.agent-tabs\s*\{[^}]*position:\s*fixed/s);
+ expect(css).toMatch(
+ /main\s*\{[^}]*--floating-tabs-radius:\s*999px[^}]*--floating-tabs-shadow:/s,
+ );
+ expect(css).toMatch(
+ /\.agent-tabs\s*\{[^}]*bottom:\s*calc\(var\(--floating-tabs-bottom\)/s,
+ );
+ expect(css).toMatch(
+ /\.agent-tabs\s*\{[^}]*border-radius:\s*var\(--floating-tabs-radius\)/s,
+ );
+ expect(css).toMatch(
+ /\.agent-tabs\s*\{[^}]*box-shadow:\s*var\(--floating-tabs-shadow\)/s,
+ );
+ expect(css).toMatch(/main\s*\{[^}]*padding-bottom:\s*[^;]+/s);
+ expect(css).toMatch(
+ /\.agent-tab\s*\{[^}]*font-size:\s*var\(--floating-tabs-font-size\)[^}]*min-height:\s*var\(--floating-tabs-height\)[^}]*min-width:\s*0/s,
);
- expect(css).toMatch(/\.view-nav-button\s*\{[^}]*min-height:\s*40px/s);
});
it("delegates clicks as view changes", () => {
// Given a rendered view nav with an event sink
const calls: CompanionView[] = [];
- const element = ViewNav({
+ const element = AgentTabs({
currentView: "main",
onViewChange: (view) => calls.push(view),
});
// When the Activity and Settings buttons are clicked
const buttons = collectButtons(element);
- buttons
- .find((button) => button["aria-label"] === "Open Activity report")
- ?.onClick?.();
- buttons
- .find((button) => button["aria-label"] === "Open Settings")
- ?.onClick?.();
+ buttons[1]?.onClick?.();
+ buttons[2]?.onClick?.();
// Then the parent shell receives route-level view changes
expect(calls).toEqual(["activity", "settings"]);
diff --git a/apps/companion/tests/app-shell.test.tsx b/apps/companion/tests/app-shell.test.tsx
index 7b4bf76..e16c221 100644
--- a/apps/companion/tests/app-shell.test.tsx
+++ b/apps/companion/tests/app-shell.test.tsx
@@ -54,33 +54,54 @@ describe("App shell settings wiring", () => {
}
});
- it("renders terminal preference data attributes, icon controls, screen-reader status, and bottom view nav", () => {
+ it("renders the shared text-only agent header and terminal tabs on Main", () => {
// Given the companion app shell at its initial render
// When static markup is rendered
const html = renderToStaticMarkup( );
// Then the settings-capable shell contract is present before effects run
- expect(html).toContain('data-theme="catppuccin-dark"');
+ expect(html).toContain('data-theme="claude"');
expect(html).toContain('data-font="system-mono"');
+ expect(html).toContain('data-agent-header="claude"');
+ expect(html).toContain("Workbranch Companion ");
+ expect(html).not.toContain("Ready");
expect(html).not.toContain("Refresh");
- expect(html).not.toContain(">Quit");
- expect(html).toContain('class="toolbar-icon"');
expect(html).toContain('aria-label="Companion views"');
- expect(html).toContain('aria-label="Open Main View"');
- expect(html).toContain('aria-label="Open Activity report"');
- expect(html).toContain('aria-label="Open Settings"');
+ expect(html).toContain(">Main");
+ expect(html).toContain(">Activity");
+ expect(html).toContain(">Settings");
expect(html).toContain('role="status"');
expect(html).toContain('aria-live="polite"');
expect(html).not.toContain('class="app-error"');
expect(html).not.toContain('role="alert"');
});
+ it("uses the expanded agent header across every view without legacy theme or navigation code", () => {
+ const source = readFileSync("src/App.tsx", "utf8");
+ const tabsIndex = source.indexOf(" {
// Given a failed companion operation status
// When the visible status alert is rendered
@@ -119,15 +140,16 @@ describe("App shell settings wiring", () => {
expect(css).not.toMatch(/\.toolbar-status\s*\{/s);
});
- it("keeps toolbar status available to assistive tech without a visible top-line chip", () => {
+ it("keeps agent status available to assistive tech without a visible chip", () => {
// Given the companion toolbar stylesheet
// When the status chip CSS contract is inspected
const css = readCssContract("src/style.css");
// Then status updates remain announced while the visible top line stays focused on controls
- expect(css).toMatch(/header\s*\{[^}]*flex-wrap:\s*wrap/s);
- expect(css).toMatch(/\.toolbar\s*\{[^}]*flex:\s*1 1 180px/s);
- expect(css).toMatch(/\.toolbar\s*\{[^}]*justify-content:\s*flex-end/s);
+ expect(css).toMatch(/\.agent-controls\s*\{[^}]*display:\s*flex/s);
+ expect(css).toMatch(
+ /\.agent-controls\s*\{[^}]*justify-content:\s*flex-end/s,
+ );
expect(css).toMatch(/\.toolbar-status-sr\s*\{[^}]*position:\s*absolute/s);
expect(css).toMatch(
/\.toolbar-status-sr\s*\{[^}]*clip-path:\s*inset\(50%\)/s,
@@ -149,23 +171,43 @@ describe("App shell settings wiring", () => {
expect(css).toContain('--app-font-family: "Menlo"');
});
- it("keeps static button background fallbacks outside color-mix supports", () => {
+ it("keeps fixed dark button backgrounds in each theme contract", () => {
// Given the companion stylesheet is built for chrome105 and safari13 WebViews
// When the button background token contract is inspected
const css = readCssContract("src/style.css");
- const [cssBeforeColorMixSupports = ""] = css.split(
- "@supports (background: color-mix(in srgb, black, white))",
+ expect(css).toMatch(
+ /main\[data-theme="claude"\]\s*\{[^}]*--button-bg:\s*rgba\(192,\s*202,\s*245,\s*0\.06\);[^}]*--button-bg-hover:\s*rgba\(192,\s*202,\s*245,\s*0\.12\);/s,
+ );
+ expect(css).toMatch(
+ /main\[data-theme="codex"\]\s*\{[^}]*--button-bg:\s*rgba\(237,\s*237,\s*237,\s*0\.06\);[^}]*--button-bg-hover:\s*rgba\(237,\s*237,\s*237,\s*0\.12\);/s,
);
+ });
+
+ it("keeps Claude structural surfaces neutral and reserves orange for accents", () => {
+ const css = readCssContract("src/style.css");
- // Then unsupported color-mix tokens cannot invalidate default button backgrounds
expect(css).toMatch(
- /main\s*\{[^}]*--button-bg:\s*rgba\(255,\s*215,\s*154,\s*0\.07\);[^}]*--button-bg-hover:\s*rgba\(255,\s*215,\s*154,\s*0\.13\);/s,
+ /main\[data-theme="claude"\]\s*\{[^}]*--line:\s*#3a3a3a;[^}]*--line-strong:\s*#565656;/s,
+ );
+ expect(css).toMatch(
+ /main\[data-theme="claude"\]\s*\{[^}]*--emphasis-soft:\s*rgba\(192,\s*202,\s*245,\s*0\.08\);[^}]*--task-selected-summary-bg:\s*rgba\(192,\s*202,\s*245,\s*0\.06\);/s,
+ );
+ expect(css).toMatch(
+ /main\[data-theme="claude"\]\s*\{[^}]*--current-step-bg:\s*rgba\(192,\s*202,\s*245,\s*0\.08\);/s,
);
+ });
+
+ it("keeps both theme headers on the same compact row at narrow widths", () => {
+ const css = readCssContract("src/style.css");
+
+ expect(css).not.toContain("fieldset.agent-header");
+ expect(css).not.toContain(".workbranch-mark");
+ expect(css).toMatch(/\.agent-header-copy\s*\{[^}]*flex:\s*1 1 auto/s);
expect(css).toMatch(
- /@supports\s*\(background:\s*color-mix\(in srgb,\s*black,\s*white\)\)\s*\{\s*main\s*\{[^}]*--button-bg:\s*color-mix\(in srgb,\s*var\(--text\) 6%,\s*transparent\);[^}]*--button-bg-hover:\s*color-mix\(in srgb,\s*var\(--text\) 12%,\s*transparent\);/s,
+ /@media \(max-width: 400px\)\s*\{[\s\S]*?\.agent-header-row\s*\{[^}]*flex-wrap:\s*nowrap/s,
);
- expect(cssBeforeColorMixSupports).not.toMatch(
- /--button-bg:\s*color-mix\(in srgb,\s*var\(--text\)/,
+ expect(css).toMatch(
+ /@media \(max-width: 400px\)\s*\{[\s\S]*?\.agent-header h1\s*\{[^}]*font-size:\s*13px[^}]*text-overflow:\s*ellipsis[^}]*white-space:\s*nowrap/s,
);
});
@@ -194,10 +236,16 @@ describe("App shell settings wiring", () => {
// Then the current step reads as a low-profile status strip, not a nested card
expect(css).toMatch(
- /\.current-step-strip\s*\{[^}]*background:\s*var\(--surface-2\)/s,
+ /\.current-step-strip\s*\{[^}]*border-block:\s*1px solid var\(--line\)/s,
+ );
+ expect(css).toMatch(
+ /\.current-step-strip\s+\.prompt-content\s*\{[^}]*display:\s*grid/s,
);
expect(css).toMatch(
- /\.current-step-strip\s*\{[^}]*box-shadow:\s*inset 1px 0 0 var\(--accent\)/s,
+ /\.prompt-line\[data-current="true"\]\s*\{[^}]*background:\s*var\(--current-step-bg\)/s,
+ );
+ expect(css).not.toMatch(
+ /\.current-step-strip\s*\{[^}]*(?:background|box-shadow|border-radius):/s,
);
expect(css).not.toMatch(
/\.current-step-strip\s*\{[^}]*var\(--shadow-card\)/s,
@@ -220,7 +268,7 @@ describe("App shell settings wiring", () => {
expect(css).toMatch(/\.task-action\s*\{[^}]*display:\s*inline-flex/s);
expect(css).toMatch(/\.task-action\s*\{[^}]*flex:\s*1 1 0/s);
expect(css).toMatch(/\.task-action\s*\{[^}]*min-width:\s*0/s);
- expect(css).toMatch(/\.task-action-separator\s*\{[^}]*display:\s*none/s);
+ expect(css).not.toContain(".task-action-separator");
expect(css).not.toMatch(/\.task-actions\s*\{[^}]*grid-template-columns/s);
});
@@ -239,25 +287,65 @@ describe("App shell settings wiring", () => {
/\.step-depth-0\s+\.step-marker\s*\{[^}]*border-radius:\s*2px[^}]*height:\s*8px[^}]*width:\s*8px/s,
);
expect(css).toMatch(
- /\.step-depth-1\s+\.step-marker\s*\{[^}]*border-radius:\s*999px[^}]*height:\s*7px[^}]*width:\s*7px/s,
+ /\.step-depth-1\s+\.step-marker\s*\{[^}]*height:\s*7px[^}]*width:\s*7px/s,
);
expect(css).toMatch(
/\.step-depth-2\s+\.step-marker\s*\{[^}]*height:\s*5px[^}]*width:\s*5px/s,
);
expect(css).toMatch(
- /\.step-marker-done\s*\{[^}]*background:\s*var\(--faint\)/s,
+ /\.step-marker-done,\s*\.step-depth-0\.step-item-done\s+\.step-marker\s*\{[^}]*background:\s*var\(--faint\)/s,
);
expect(css).toMatch(
- /\.step-depth-0\.step-item-done\s+\.step-marker\s*\{[^}]*background:\s*var\(--faint\)[^}]*box-shadow:\s*0 0 0 2px var\(--line\)/s,
+ /\.step-depth-0\.step-item-done\s+\.step-marker\s*\{[^}]*background:\s*var\(--faint\)/s,
);
+ expect(css).not.toMatch(/\.step-marker[^}]*box-shadow:/s);
expect(css).not.toMatch(
/\.step-marker-done\s*\{[^}]*background:\s*var\(--done\)/s,
);
expect(css).toMatch(
- /\.step-marker-todo\s*\{[^}]*border:\s*1px solid var\(--line-strong\)/s,
+ /\.step-marker-todo\s*\{[^}]*border-color:\s*var\(--line-strong\)/s,
);
});
+ it("keeps Korean checklist words intact while allowing long paths to wrap", () => {
+ const css = readCssContract("src/style.css");
+
+ expect(css).toMatch(
+ /\.step-text\s*\{[^}]*overflow-wrap:\s*break-word[^}]*word-break:\s*keep-all/s,
+ );
+ expect(css).not.toMatch(/\.step-text\s*\{[^}]*overflow-wrap:\s*anywhere/s);
+ });
+
+ it("uses a readable text token for small secondary copy", () => {
+ const css = readCssContract("src/style.css");
+
+ expect(css).toContain("--muted: #949494");
+ expect(css).toMatch(
+ /\.settings-panel p,\s*\.settings-hint\s*\{[^}]*color:\s*var\(--muted\)/s,
+ );
+ expect(css).toMatch(
+ /\.font-preview-label\s*\{[^}]*color:\s*var\(--muted\)/s,
+ );
+ expect(css).toMatch(/\.cal-hour-label\s*\{[^}]*color:\s*var\(--muted\)/s);
+ expect(css).toMatch(/\.repo-branch\s*\{[^}]*color:\s*var\(--muted\)/s);
+ });
+
+ it("keeps theme tokens in the theme contract and calendar fills theme-aware", () => {
+ const baseCss = readFileSync("src/styles/base.css", "utf8");
+ const themeCss = readFileSync("src/styles/themes.css", "utf8");
+ const activityCss = readFileSync(
+ "src/activity/activity-calendar.css",
+ "utf8",
+ );
+
+ expect(baseCss).not.toContain("--surface-0:");
+ expect(themeCss).toContain("--cal-bg-1:");
+ expect(themeCss).toContain("--cal-bg-6:");
+ expect(activityCss).toContain("--cal-bg: var(--cal-bg-1)");
+ expect(activityCss).toContain("--cal-bg: var(--cal-bg-6)");
+ expect(activityCss).not.toMatch(/--cal-bg:\s*rgba\(/);
+ });
+
it("renders settings inside the flexible view panel so bottom nav stays at the shell bottom", () => {
// Given the app shell source owns view-level layout wrappers
// When the settings branch is inspected
@@ -271,60 +359,62 @@ describe("App shell settings wiring", () => {
);
});
- it("keeps the bottom view menu floating without covering task content", () => {
+ it("lays out a floating bottom terminal tab row without obscuring content", () => {
// Given the companion shell stylesheet
// When the bottom navigation CSS contract is inspected
const css = readCssContract("src/style.css");
- // Then the command bar stays visible as a sticky floating affordance
expect(css).toMatch(/main\s*\{[^}]*padding:\s*12px/s);
expect(css).toMatch(/main\s*\{[^}]*max-width:\s*100%/s);
expect(css).toMatch(/main\s*\{[^}]*width:\s*100vw/s);
expect(css).toMatch(/main\s*\{[^}]*display:\s*flex/s);
expect(css).toMatch(/main\s*\{[^}]*flex-direction:\s*column/s);
expect(css).toMatch(/\.view-panel\s*\{[^}]*flex:\s*1 1 auto/s);
- expect(css).toMatch(/\.view-panel\s*\{[^}]*padding-bottom:\s*52px/s);
- expect(css).not.toMatch(/main\s*\{[^}]*padding:[^;}]*78px/s);
- expect(css).toMatch(/\.view-nav\s*\{[^}]*bottom:\s*10px/s);
- expect(css).toMatch(/\.view-nav\s*\{[^}]*position:\s*sticky/s);
- expect(css).toMatch(/\.view-nav\s*\{[^}]*z-index:\s*3/s);
+ expect(css).toMatch(/main\s*\{[^}]*padding-bottom:\s*[^;]+/s);
expect(css).toMatch(
- /\.view-nav\s*\{[^}]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/s,
+ /\.agent-tabs\s*\{[^}]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\)/s,
);
- expect(css).toMatch(/\.view-nav-button\s*\{[^}]*min-width:\s*0/s);
- expect(css).toMatch(/\.view-nav-button\s*\{[^}]*justify-self:\s*stretch/s);
- expect(css).not.toMatch(/\.view-nav-button\s*\{[^}]*width:\s*100%/s);
- expect(css).not.toMatch(/\.view-nav\s*\{[^}]*position:\s*fixed/s);
- expect(css).not.toMatch(/\.view-nav\s*\{[^}]*position:\s*static/s);
+ expect(css).toMatch(/\.agent-tabs\s*\{[^}]*position:\s*fixed/s);
+ expect(css).toMatch(/\.agent-tabs\s*\{[^}]*bottom:\s*[^;]+/s);
+ expect(css).not.toContain(".view-nav");
+ expect(css).toMatch(/\.agent-tabs\s*\{[^}]*box-shadow/s);
});
- it("defines only the curated dark and light theme token selectors", () => {
+ it("gives the active agent tab an unmistakable terminal selection state", () => {
+ const css = readCssContract("src/style.css");
+
+ expect(css).toMatch(
+ /\.agent-tabs\s*\{[^}]*background:\s*var\(--surface-0\)[^}]*border:\s*1px solid var\(--line\)[^}]*border-radius:\s*var\(--floating-tabs-radius\)[^}]*overflow:\s*hidden/s,
+ );
+ expect(css).toMatch(
+ /\.agent-tab\s*\{[^}]*border-bottom:\s*2px solid transparent[^}]*min-height:\s*var\(--floating-tabs-height\)/s,
+ );
+ expect(css).toMatch(
+ /\.agent-tab\[data-active="true"\]\s*\{[^}]*background:\s*var\(--surface-2\)[^}]*border-bottom-color:\s*var\(--emphasis\)[^}]*color:\s*var\(--emphasis\)[^}]*font-weight:\s*700/s,
+ );
+ });
+
+ it("does not force horizontal overflow when the 360px window gains a scrollbar", () => {
+ const baseCss = readFileSync("src/styles/base.css", "utf8");
+
+ expect(baseCss).not.toMatch(/body\s*\{[^}]*min-width:/s);
+ expect(baseCss).toMatch(/main\s*\{[^}]*max-width:\s*100%/s);
+ });
+
+ it("defines only the fixed-dark Claude Code and Codex theme tokens", () => {
// Given the companion stylesheet
// When theme selector contracts are inspected
const css = readCssContract("src/style.css");
- // Then the app exposes one dark and one light palette with no swatch-card CSS
- expect(css).toContain('main[data-theme="catppuccin-dark"]');
- expect(css).toContain('main[data-theme="breakfast-light"]');
- expect(css).toContain("#1e1e2e");
- expect(css).toContain("#cba6f7");
- expect(css).toContain("#ffffff");
- expect(css).toContain("#7c3aed");
- for (const removedTheme of [
- "breakfast-dark",
- "solarized-dark",
- "dracula-dark",
- "github-dark",
- "solarized-light",
- "dracula-light",
- "catppuccin-light",
- "github-light",
- "nord-dark",
- "gruvbox-dark",
- ]) {
- expect(css).not.toContain(`main[data-theme="${removedTheme}"]`);
- }
- expect(css).not.toContain(".theme-swatch-");
- expect(css).not.toContain("--theme-swatch-accent");
+ expect(css).toContain('main[data-theme="claude"]');
+ expect(css).toContain('main[data-theme="codex"]');
+ expect(css).toContain("--accent: #cd694a");
+ expect(css).toContain("--emphasis: #cd694a");
+ expect(css).toContain("--text: #c0caf5");
+ expect(css).toContain("--accent: #5cc2e0");
+ expect(css).toContain("--emphasis: #ededed");
+ expect(css).toContain("--text: #ededed");
+ expect(css).not.toContain('data-theme$="-light"');
+ expect(css).not.toMatch(/gradient\(/);
});
});
diff --git a/apps/companion/tests/preferences.test.ts b/apps/companion/tests/preferences.test.ts
index 3130352..0813242 100644
--- a/apps/companion/tests/preferences.test.ts
+++ b/apps/companion/tests/preferences.test.ts
@@ -1,129 +1,98 @@
import assert from "node:assert/strict";
-import { describe, expect, it } from "vitest";
+import { load } from "@tauri-apps/plugin-store";
+import { describe, expect, it, vi } from "vitest";
import {
COMPANION_FONT_OPTIONS,
+ COMPANION_PREFERENCES_STORE_FILE,
COMPANION_THEME_OPTIONS,
+ type CompanionPreferenceStore,
DEFAULT_COMPANION_PREFERENCES,
enqueuePreferenceSave,
+ loadCompanionPreferenceStore,
preferencesToStoreEntries,
- resolvedCompanionTheme,
sanitizeCompanionPreferences,
shouldRestoreFailedPreferenceUpdate,
+ writeCompanionPreferences,
} from "../src/application/preferences";
+vi.mock("@tauri-apps/plugin-store", () => ({ load: vi.fn() }));
+
describe("companion preferences", () => {
- it("defaults to the Companion palette with system mode and system-mono", () => {
- // Given no stored preference values
- // When the default preference contract is read
- // Then the HUD follows the OS theme with one curated palette
+ it("defaults to the Claude Code theme with system-mono", () => {
expect(DEFAULT_COMPANION_PREFERENCES).toEqual({
font: "system-mono",
- themeFamily: "companion",
- themeMode: "system",
+ theme: "claude",
});
});
- it("sanitizes invalid stored font, theme family, and theme mode values to defaults", () => {
- // Given corrupted persisted preference values
- const stored = {
- font: "comic-sans",
- themeFamily: "rainbow",
- themeMode: "sepia",
- };
-
- // When preferences are sanitized at the store boundary
- const result = sanitizeCompanionPreferences(stored);
-
- // Then defaults are applied and sanitization is reported
- expect(result).toEqual({
+ it("sanitizes invalid stored values to the fixed-dark defaults", () => {
+ expect(
+ sanitizeCompanionPreferences({
+ font: "comic-sans",
+ theme: "rainbow",
+ }),
+ ).toEqual({
preferences: DEFAULT_COMPANION_PREFERENCES,
sanitized: true,
});
});
- it("migrates a legacy concrete theme value into Companion family and mode preferences", () => {
- // Given a previous settings file stored one concrete dark/light theme value
- const stored = { font: "menlo", theme: "nord-light" };
-
- // When preferences are sanitized at the store boundary
- const result = sanitizeCompanionPreferences(stored);
-
- // Then the legacy color family collapses to Companion while preserving mode
- expect(result).toEqual({
- preferences: {
+ it("migrates legacy appearance fields to Claude Code", () => {
+ expect(
+ sanitizeCompanionPreferences({
font: "menlo",
themeFamily: "companion",
themeMode: "light",
- },
+ }),
+ ).toEqual({
+ preferences: { font: "menlo", theme: "claude" },
sanitized: true,
});
});
- it("migrates previous theme enum values before falling back to defaults", () => {
- // Given settings files stored earlier companion theme enum values
- const migrations = [
+ it("loads raw stored values so missing theme keys trigger migration", async () => {
+ await loadCompanionPreferenceStore();
+
+ expect(load).toHaveBeenCalledWith(COMPANION_PREFERENCES_STORE_FILE, {
+ autoSave: false,
+ defaults: {},
+ });
+ });
+
+ it("migrates every former concrete theme to Claude Code", () => {
+ const legacyThemes = [
"terminal-dark",
"amber-crt",
- "green-mono",
- "high-contrast",
+ "nord-light",
+ "catppuccin-dark",
+ "breakfast-light",
] as const;
- for (const theme of migrations) {
- // When preferences are sanitized at the store boundary
- const result = sanitizeCompanionPreferences({
- font: "monaco",
- theme,
- });
-
- // Then previous dark choices converge to the Companion dark palette
- expect(result).toEqual({
- preferences: {
- font: "monaco",
- themeFamily: "companion",
- themeMode: "dark",
- },
+ for (const theme of legacyThemes) {
+ expect(sanitizeCompanionPreferences({ font: "monaco", theme })).toEqual({
+ preferences: { font: "monaco", theme: "claude" },
sanitized: true,
});
}
});
- it("migrates removed stored theme families before falling back to defaults", () => {
- // Given stored family values from removed themes
- const gruvboxResult = sanitizeCompanionPreferences({
- font: "sf-mono",
- themeFamily: "gruvbox",
- themeMode: "dark",
- });
- const nordResult = sanitizeCompanionPreferences({
- font: "sf-mono",
- themeFamily: "nord",
- themeMode: "light",
+ it("preserves a valid Codex theme without sanitization", () => {
+ expect(
+ sanitizeCompanionPreferences({ font: "menlo", theme: "codex" }),
+ ).toEqual({
+ preferences: { font: "menlo", theme: "codex" },
+ sanitized: false,
});
+ });
- // When preferences are sanitized at the store boundary
- // Then removed families collapse to Companion and preserve mode
- expect(gruvboxResult).toEqual({
- preferences: {
- font: "sf-mono",
- themeFamily: "companion",
- themeMode: "dark",
- },
- sanitized: true,
- });
- expect(nordResult).toEqual({
- preferences: {
- font: "sf-mono",
- themeFamily: "companion",
- themeMode: "light",
- },
- sanitized: true,
- });
+ it("exposes only Claude Code and Codex themes", () => {
+ expect(COMPANION_THEME_OPTIONS).toEqual([
+ { value: "claude", label: "Claude Code" },
+ { value: "codex", label: "Codex" },
+ ]);
});
it("exposes only fixed-width font choices", () => {
- // Given the font option list used by the settings panel
- // When option CSS stacks are inspected
- // Then each stack ends in a monospace fallback and no arbitrary font input is present
expect(COMPANION_FONT_OPTIONS.map((option) => option.value)).toEqual([
"system-mono",
"sf-mono",
@@ -138,84 +107,19 @@ describe("companion preferences", () => {
).toBe(true);
});
- it("exposes only Catppuccin Dark and White Light theme variants", () => {
- // Given the active theme option list
- // When option values are read
- // Then Settings has one curated dark/light palette instead of multiple families
- expect(COMPANION_THEME_OPTIONS).toEqual([
- {
- value: "catppuccin-dark",
- family: "companion",
- familyLabel: "Companion",
- label: "Catppuccin Dark",
- mode: "dark",
- },
- {
- value: "breakfast-light",
- family: "companion",
- familyLabel: "Companion",
- label: "White Light",
- mode: "light",
- },
- ]);
- });
-
- it("resolves system theme mode using the current OS color scheme", () => {
- // Given preferences in explicit and system modes
- const explicitLight = {
- font: "system-mono",
- themeFamily: "companion",
- themeMode: "light",
- } as const;
- const systemCompanion = {
- font: "system-mono",
- themeFamily: "companion",
- themeMode: "system",
- } as const;
-
- // When concrete data-theme values are resolved
- // Then system mode follows the supplied system mode while explicit mode wins
- expect(resolvedCompanionTheme(explicitLight, "dark")).toBe(
- "breakfast-light",
- );
- expect(resolvedCompanionTheme(systemCompanion, "light")).toBe(
- "breakfast-light",
- );
- expect(resolvedCompanionTheme(systemCompanion, "dark")).toBe(
- "catppuccin-dark",
- );
- });
+ it("restores a failed optimistic update only while it is current", () => {
+ const failedAttempt = { font: "menlo", theme: "claude" } as const;
+ const newerCurrent = { font: "menlo", theme: "codex" } as const;
- it("restores a failed optimistic preference update only when no newer update won", () => {
- // Given one failed optimistic update and a later successful update
- const failedAttempt = {
- font: "menlo",
- themeFamily: "companion",
- themeMode: "system",
- } as const;
- const newerCurrent = {
- font: "menlo",
- themeFamily: "companion",
- themeMode: "light",
- } as const;
-
- // When deciding whether the failed request may roll back local state
- const staleRollbackAllowed = shouldRestoreFailedPreferenceUpdate(
- newerCurrent,
- failedAttempt,
- );
- const currentRollbackAllowed = shouldRestoreFailedPreferenceUpdate(
- failedAttempt,
- failedAttempt,
- );
-
- // Then only the still-current failed attempt can restore the previous state
- expect(staleRollbackAllowed).toBe(false);
- expect(currentRollbackAllowed).toBe(true);
+ expect(
+ shouldRestoreFailedPreferenceUpdate(newerCurrent, failedAttempt),
+ ).toBe(false);
+ expect(
+ shouldRestoreFailedPreferenceUpdate(failedAttempt, failedAttempt),
+ ).toBe(true);
});
it("runs preference saves sequentially so the latest write lands last", async () => {
- // Given two preference writes where the first write is still pending
const writes: string[] = [];
let releaseFirstWrite: (() => void) | undefined;
const firstWrite = new Promise((resolve) => {
@@ -223,7 +127,6 @@ describe("companion preferences", () => {
});
let saveQueue = Promise.resolve();
- // When a newer write is enqueued before the first write resolves
saveQueue = enqueuePreferenceSave(saveQueue, async () => {
writes.push("font");
await firstWrite;
@@ -234,7 +137,6 @@ describe("companion preferences", () => {
await Promise.resolve();
await Promise.resolve();
- // Then the newer write waits and lands after the older write
expect(writes).toEqual(["font"]);
assert.ok(releaseFirstWrite, "releaseFirstWrite must be initialized");
releaseFirstWrite();
@@ -242,21 +144,38 @@ describe("companion preferences", () => {
expect(writes).toEqual(["font", "theme"]);
});
- it("serializes only font, theme family, and theme mode store keys", () => {
- // Given a sanitized preference pair
- // When the pair is converted for persistence
+ it("serializes only font and theme store keys", () => {
const entries = preferencesToStoreEntries({
font: "menlo",
- themeFamily: "companion",
- themeMode: "light",
+ theme: "codex",
});
- // Then launch-at-login and other app state are excluded from the store contract
- expect(entries).toEqual({
- font: "menlo",
- themeFamily: "companion",
- themeMode: "light",
+ expect(entries).toEqual({ font: "menlo", theme: "codex" });
+ expect(Object.keys(entries)).toEqual(["font", "theme"]);
+ });
+
+ it("writes only font and theme to the preference store", async () => {
+ const writes: Array = [];
+ let saveCount = 0;
+ const store: CompanionPreferenceStore = {
+ get: async (_key: string): Promise => undefined,
+ set: async (key, value) => {
+ writes.push([key, value]);
+ },
+ save: async () => {
+ saveCount += 1;
+ },
+ };
+
+ await writeCompanionPreferences(store, {
+ font: "sf-mono",
+ theme: "claude",
});
- expect(Object.keys(entries)).toEqual(["font", "themeFamily", "themeMode"]);
+
+ expect(writes).toEqual([
+ ["font", "sf-mono"],
+ ["theme", "claude"],
+ ]);
+ expect(saveCount).toBe(1);
});
});
diff --git a/apps/companion/tests/project-group.test.tsx b/apps/companion/tests/project-group.test.tsx
index 7de9002..273b181 100644
--- a/apps/companion/tests/project-group.test.tsx
+++ b/apps/companion/tests/project-group.test.tsx
@@ -34,16 +34,24 @@ const group: ProjectGroupModel = {
};
describe("ProjectGroup", () => {
- it("renders the project header with task count above its task rows", () => {
- const html = renderToStaticMarkup(
- {}} />,
+ it("renders theme-specific terminal panels around project tasks", () => {
+ const claudeHtml = renderToStaticMarkup(
+ undefined} />,
);
- expect(html).toContain("project-group-header");
- expect(html).toContain("acme");
- expect(html).toContain("2 tasks");
- expect(html).toContain("feat-a");
- expect(html.indexOf("project-group-header")).toBeLessThan(
- html.indexOf("feat-a"),
+ const codexHtml = renderToStaticMarkup(
+ undefined} />,
+ );
+
+ expect(claudeHtml).toContain('data-terminal-panel="claude"');
+ expect(claudeHtml).toContain(" void;
};
-type ButtonProps = {
- readonly "aria-label"?: string;
- readonly "aria-pressed"?: boolean;
- readonly onClick?: () => void;
-};
-
type TraversableProps = {
readonly children?: ReactNode;
};
+type ThemePickerProps = {
+ readonly value: CompanionTheme;
+ readonly onChange: (theme: CompanionTheme) => void;
+};
+
function collectByType(
node: ReactNode,
typeName: "button" | "input" | "select",
@@ -53,40 +53,51 @@ function collectByType(
return props;
}
+function findThemePicker(node: ReactNode): ThemePickerProps | undefined {
+ let result: ThemePickerProps | undefined;
+ const visit = (child: ReactNode): void => {
+ if (!isValidElement(child)) {
+ return;
+ }
+ if (child.type === AgentThemePicker) {
+ result = child.props;
+ }
+ Children.forEach(child.props.children, visit);
+ };
+ visit(node);
+ return result;
+}
+
const preferences: CompanionPreferences = {
font: "system-mono",
- themeFamily: "companion",
- themeMode: "system",
+ theme: "claude",
};
function renderSettingsPanel({
currentPreferences = preferences,
- systemThemeMode = "dark",
+ launchAtLoginLoading = false,
}: {
readonly currentPreferences?: CompanionPreferences;
- readonly systemThemeMode?: CompanionResolvedThemeMode;
+ readonly launchAtLoginLoading?: boolean;
} = {}): string {
return renderToStaticMarkup(
{}}
- onPreferencesChange={() => {}}
+ launchAtLoginLoading={launchAtLoginLoading}
+ onLaunchAtLoginChange={() => undefined}
+ onPreferencesChange={() => undefined}
/>,
);
}
describe("SettingsPanel", () => {
- it("renders labeled fieldsets for startup, font, and mode controls", () => {
- // Given current companion preferences and launch-at-login status
- // When the settings panel is rendered
+ it("renders Claude terminal panels and exactly two agent themes", () => {
const html = renderSettingsPanel();
- // Then semantic groups, labels, and fixed choices are visible
+ expect(html).toContain('data-terminal-panel="claude"');
+ expect(html).toContain('data-terminal-panel-anatomy="claude"');
expect(html).toContain("Settings");
expect(html).toContain("Startup ");
expect(html).toContain("Font ");
expect(html).toContain("Theme ");
@@ -95,73 +106,55 @@ describe("SettingsPanel", () => {
expect(html).toContain("Open at Login");
expect(html).toContain("System Mono");
expect(html).toContain("JetBrains Mono");
- expect(html).toContain("Light");
- expect(html).toContain("Dark");
- expect(html).toContain("System");
- expect(html).toContain("System (Dark now)");
- expect(html).toContain("Palette: Catppuccin Dark / White Light");
- expect(html).not.toContain('id="companion-theme"');
+ expect(html).toContain("Claude Code");
+ expect(html).toContain("Codex");
+ expect(html.match(/Light ");
+ expect(html).not.toContain(">Dark");
+ expect(html).not.toContain(">System");
+ expect(html).not.toContain("theme mode");
});
- it("shows the selected font in a live preview sample", () => {
- // Given Menlo is the selected companion font
+ it("renders Codex settings with Claude fieldset sections", () => {
const html = renderSettingsPanel({
- currentPreferences: {
- font: "menlo",
- themeFamily: "companion",
- themeMode: "system",
- },
+ currentPreferences: { font: "menlo", theme: "codex" },
});
- // When the settings panel is rendered
- // Then the sample text uses the selected font family
- expect(html).toContain('class="font-preview"');
- expect(html).toContain("font-family:Menlo");
- expect(html).toContain("workbranch feat/update-0619");
- expect(html).toContain("1234567890");
+ expect(html).toContain('data-terminal-panel="codex"');
+ expect(html).toContain('data-terminal-panel-anatomy="claude"');
+ expect(html).toContain("Startup");
+ expect(html).toContain("Font ");
+ expect(html).toContain("Theme ");
+ expect(html).not.toContain('class="terminal-panel-heading"');
+ expect(html).toContain('aria-label="Use Codex theme"');
+ expect(html).toContain('aria-pressed="true"');
});
- it("renders only the theme mode toggle without color family cards", () => {
- // Given explicit light mode is selected
+ it("shows the selected font in a live preview sample", () => {
const html = renderSettingsPanel({
- currentPreferences: {
- font: "system-mono",
- themeFamily: "companion",
- themeMode: "light",
- },
+ currentPreferences: { font: "menlo", theme: "claude" },
});
- // When settings are rendered
- // Then the color-family grid and swatches are absent
- expect(html).toContain("Light mode");
- expect(html).toContain("Palette: Catppuccin Dark / White Light");
- expect(html).not.toContain('class="theme-button-grid"');
- expect(html).not.toContain('class="theme-button"');
- expect(html).not.toContain("theme-swatch");
- expect(html).not.toContain("Use GitHub theme");
- expect(html).not.toContain("Current theme:");
+ expect(html).toContain('class="font-preview"');
+ expect(html).toContain("font-family:Menlo");
+ expect(html).toContain("workbranch feat/update-0619");
+ expect(html).toContain("1234567890");
});
- it("renders system mode using the current system dark or light mode", () => {
- // Given system mode is selected while the OS is currently light
- const html = renderSettingsPanel({
- currentPreferences: preferences,
- systemThemeMode: "light",
- });
+ it("disables launch-at-login while its state is loading", () => {
+ const html = renderSettingsPanel({ launchAtLoginLoading: true });
- // When settings are rendered
- // Then mode state is visible without exposing color-family choices
- expect(html).toContain("System (Light now)");
- expect(html).not.toContain("theme-swatch");
+ expect(html).toContain('id="launch-at-login"');
+ expect(html).toContain('disabled=""');
+ expect(html).toContain("Checking login item state");
});
- it("delegates launch, font, and mode updates to app-shell callbacks", () => {
- // Given a rendered settings panel with callback spies
+ it("delegates launch, font, and theme updates to app-shell callbacks", () => {
const launchCalls: boolean[] = [];
const preferenceCalls: CompanionPreferences[] = [];
const element = SettingsPanel({
preferences,
- systemThemeMode: "dark",
launchAtLogin: false,
launchAtLoginLoading: false,
onLaunchAtLoginChange: (enabled) => {
@@ -171,28 +164,24 @@ describe("SettingsPanel", () => {
preferenceCalls.push(next);
},
});
-
- // When controls change
const launchToggle = collectByType(element, "input").find(
(input) => input.id === "launch-at-login",
);
- const selects = collectByType(element, "select");
- const buttons = collectByType(element, "button");
- const fontSelect = selects.find((select) => select.id === "companion-font");
- const modeButton = buttons.find(
- (button) => button["aria-label"] === "Use Light theme mode",
+ const fontSelect = collectByType(element, "select").find(
+ (select) => select.id === "companion-font",
);
+ const themePicker = findThemePicker(element);
+
launchToggle?.onChange?.({ currentTarget: { checked: true } });
fontSelect?.onChange?.({
currentTarget: { value: "menlo" satisfies CompanionFont },
});
- modeButton?.onClick?.();
+ themePicker?.onChange("codex");
- // Then the app shell receives every change without the panel swallowing failures
expect(launchCalls).toEqual([true]);
expect(preferenceCalls).toEqual([
- { font: "menlo", themeFamily: "companion", themeMode: "system" },
- { font: "system-mono", themeFamily: "companion", themeMode: "light" },
+ { font: "menlo", theme: "claude" },
+ { font: "system-mono", theme: "codex" },
]);
});
});
diff --git a/apps/companion/tests/task-row.test.tsx b/apps/companion/tests/task-row.test.tsx
index 640133b..1d61ab7 100644
--- a/apps/companion/tests/task-row.test.tsx
+++ b/apps/companion/tests/task-row.test.tsx
@@ -1,3 +1,4 @@
+import { readFileSync } from "node:fs";
import { Children, isValidElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
@@ -140,11 +141,15 @@ const doneFixtureTask: Task = {
],
};
-function renderTaskRow(task: Task): string {
+function renderTaskRow(
+ task: Task,
+ theme: "claude" | "codex" = "claude",
+): string {
return renderToStaticMarkup(
{}}
/>,
@@ -170,10 +175,11 @@ describe("TaskRow", () => {
expect(html).not.toContain("☐ Generated Plan");
expect(html).not.toContain("☐ Implement change");
});
- it("renders a Raycast-style task summary with current step and repo state", () => {
+ it("renders a terminal task summary with current step and repo state", () => {
const html = renderTaskRow(linearFixtureTask);
- expect(html).toContain("task-status-rail");
+ expect(html).toContain("❯");
+ expect(html).toContain("REVIEW");
expect(html).toContain("Companion UI refresh");
expect(html).toContain("Review screenshot");
expect(html).toContain("2/4");
@@ -181,21 +187,68 @@ describe("TaskRow", () => {
expect(html).toContain("workbranch");
expect(html).toContain("feat/update-0617");
});
- it("renders task status as a quiet dot without a check glyph", () => {
+ it("highlights the expanded task and renders progress as a compact pill", () => {
+ const html = renderTaskRow(linearFixtureTask);
+ const inProgressHtml = renderTaskRow(nestedChecklistTask);
+ const baseCss = readFileSync("src/styles/base.css", "utf8");
+ const css = readFileSync("src/styles/task-details.css", "utf8");
+ const themeCss = readFileSync("src/styles/themes.css", "utf8");
+
+ expect(html).toContain('');
+ expect(html).toContain('2/4 ');
+ expect(inProgressHtml).toMatch(
+ /task-summary-line[\s\S]*?data-current="false"/,
+ );
+ expect(inProgressHtml).toMatch(
+ /current-step-strip[\s\S]*?data-current="true"/,
+ );
+ expect(css).toMatch(
+ /\.task\[open\]\s*\{[^}]*background:\s*var\(--surface-2\)/s,
+ );
+ expect(baseCss).toContain("--task-selected-accent-width: 2px");
+ expect(themeCss).toMatch(
+ /main\[data-theme="claude"\]\s*\{[^}]*--task-selected-summary-bg:\s*rgba\(192,\s*202,\s*245,\s*0\.06\)/s,
+ );
+ expect(themeCss).toMatch(
+ /main\[data-theme="codex"\]\s*\{[^}]*--task-selected-summary-bg:\s*rgba\(237,\s*237,\s*237,\s*0\.06\)/s,
+ );
+ expect(css).toMatch(
+ /\.task\[open\] > summary\s*\{[^}]*background:\s*var\(--task-selected-summary-bg\)[^}]*border-inline-start:\s*var\(--task-selected-accent-width\) solid var\(--emphasis\)/s,
+ );
+ expect(css).not.toMatch(
+ /\.task\[open\] > summary\s*\{[^}]*background:\s*var\(--emphasis-soft\)/s,
+ );
+ expect(css).not.toMatch(
+ /\.task-in-progress\s*\{[^}]*border-color:\s*var\(--emphasis\)/s,
+ );
+ expect(css).toMatch(
+ /\.task-progress\s*\{[^}]*border-radius:\s*var\(--progress-pill-radius\)[^}]*font-size:\s*var\(--progress-pill-font-size\)[^}]*padding:\s*var\(--progress-pill-padding\)/s,
+ );
+ });
+ it("balances narrow CJK checklist lines to preserve semantic phrases", () => {
+ const css = readFileSync("src/styles/task-details.css", "utf8");
+
+ expect(css).toMatch(/\.step-text\s*\{[^}]*text-wrap:\s*balance/s);
+ });
+ it("renders task status as visible text with a quiet marker", () => {
const html = renderTaskRow(doneFixtureTask);
- expect(html).toContain('aria-label="Done"');
- expect(html).toContain('class="task-status-dot"');
- expect(html).not.toContain('class="task-status-rail">✓');
+ expect(html).toContain('data-status="done"');
+ expect(html).toContain('class="status-token-marker"');
+ expect(html).toContain('class="status-token-label">DONE');
});
it("exposes only IDE, Terminal, and Finder actions", () => {
const html = renderTaskRow(nestedChecklistTask);
+ expect(html).toContain("RUN");
expect(html).toContain('aria-label="open generated-task in IDE"');
expect(html).toContain('aria-label="open generated-task in terminal"');
expect(html).toContain('aria-label="open generated-task in Finder"');
- expect(html).toContain('class="task-action-icon"');
- expect(html).toContain('class="task-action-separator"');
+ expect(html).toContain(">IDE");
+ expect(html).toContain(">Terminal");
+ expect(html).toContain(">Finder");
+ expect(html).not.toContain(" {
expect(actionIndex).toBeLessThan(stepsIndex);
});
+ it("renders the Codex prompt marker through semantic markup", () => {
+ const html = renderTaskRow(linearFixtureTask, "codex");
+
+ expect(html).toContain('data-prompt-theme="codex"');
+ expect(html).toContain("›");
+ expect(html).not.toContain("❯");
+ });
+
it("renders checklist status as visual markers outside the step text", () => {
// Given a task with completed and pending checklist rows
const html = renderTaskRow(linearFixtureTask);
@@ -257,6 +318,7 @@ describe("TaskRow", () => {
const element = TaskRow({
root: "/tmp/workbranch",
task: nestedChecklistTask,
+ theme: "claude",
expanded: true,
onAction: (root, task, kind) => {
calls.push({ root, taskName: task.name, kind });
diff --git a/docs/plans/0045-companion-agent-shell-claude-codex-themes.md b/docs/plans/0045-companion-agent-shell-claude-codex-themes.md
new file mode 100644
index 0000000..f72046a
--- /dev/null
+++ b/docs/plans/0045-companion-agent-shell-claude-codex-themes.md
@@ -0,0 +1,860 @@
+# Companion Agent Shell Claude Code / Codex Themes Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Use `superpowers:test-driven-development` for every behavior or markup contract change and `omo:visual-qa` for the final UI gate.
+
+**Goal:** Replace the current Companion light/dark appearance system with two fixed-dark Claude Code and Codex themes, then apply a Brainless-inspired Agent Shell to Main, Activity, and Settings without changing task, calendar, action, or Tauri behavior.
+
+**Architecture:** `CompanionPreferences` stores one `theme: "claude" | "codex"` value; old `themeFamily`, `themeMode`, and legacy concrete themes migrate to `claude`. `App` renders the same expanded theme-specific `AgentHeader` above Main, Activity, and Settings, view content built from shared `TerminalPanel`, `PromptLine`, and `StatusToken` primitives, and an inset floating-bottom `AgentTabs`. Claude and Codex share data flow and component interfaces but may render different semantic markup and markers; they are not palette-only variants.
+
+**Tech Stack:** Tauri v2, React 18, TypeScript 5.9, Vite 7, Vitest 4, Biome 2, plain CSS. Do not add Tailwind, shadcn, Radix, Brainless packages, or another runtime dependency.
+
+---
+
+## Source of truth and fixed decisions
+
+- Design contract: `DESIGN.md`, revision dated `2026-07-20`.
+- Visual reference: [Brainless Components](https://brainless.swerdlow.dev/components), specifically `claude-header`, `claude-message`, `codex-header`, and `codex-message`.
+- Theme choices are exactly `Claude Code` and `Codex`.
+- Both themes are fixed dark. Remove the visible and internal `Light | Dark | System` mode axis.
+- Claude Code is the default for new installs and the migration target for all unsupported or legacy theme settings.
+- The theme picker appears only in Settings and persists through the existing Tauri preference store.
+- Main, Activity, and Settings use the same expanded header so tab changes preserve the top-banner footprint.
+- Navigation uses an inset floating bottom terminal tab bar with protected content padding.
+- Expanded `details[open]` tasks receive selected-row background emphasis and progress uses a compact pill.
+- Do not add keyboard shortcuts, a shortcut hint bar, or labels that imply shortcuts.
+- Preserve `details/summary`, action callbacks, workspace refresh, activity reload/selection, launch-at-login, and IPC contracts.
+- Do not modify `apps/cli/**`, `packages/contract/**`, Companion Rust commands, activity storage, or domain models.
+- `docs/plans/0044-companion-agent-shell-claude-codex-themes.md` is an untracked conflicting draft and is not an input to this plan. Execute this `0045` plan only.
+
+## File structure
+
+### Create
+
+- `apps/companion/src/ui/AgentHeader.tsx`: shared expanded header with distinct Claude and Codex anatomy.
+- `apps/companion/src/ui/AgentTabs.tsx`: accessible floating bottom navigation for Main, Activity, and Settings.
+- `apps/companion/src/ui/TerminalPanel.tsx`: Claude fieldset/legend and Codex ruled-section variants behind one interface.
+- `apps/companion/src/ui/PromptLine.tsx`: theme-specific `❯` or `›` prompt row.
+- `apps/companion/src/ui/StatusToken.tsx`: text-plus-marker state indicator for todo/planning/in-progress/review/blocked/done.
+- `apps/companion/src/ui/AgentThemePicker.tsx`: Settings-only Claude Code/Codex segmented control.
+- `apps/companion/src/styles/agent-shell.css`: shared Agent Header, Agent Bar, tabs, panel, prompt, and status primitive styles.
+- `apps/companion/tests/agent-primitives.test.tsx`: static semantic contracts for the new primitives.
+
+### Modify
+
+- `apps/companion/src/application/themePreferences.ts`: fixed `claude | codex` theme model.
+- `apps/companion/src/application/preferences.ts`: new store shape and legacy migration.
+- `apps/companion/src/App.tsx`: Agent Shell composition and removal of system-mode resolution.
+- `apps/companion/src/ui/ProjectGroup.tsx`: render project content through `TerminalPanel`.
+- `apps/companion/src/ui/TaskRow.tsx`: `PromptLine`, `StatusToken`, flat metadata/actions, unchanged disclosure behavior.
+- `apps/companion/src/ui/SettingsView.tsx`: pass the active theme into the settings shell surface.
+- `apps/companion/src/ui/SettingsPanel.tsx`: replace mode buttons with `AgentThemePicker`.
+- `apps/companion/src/style.css`: import the new `agent-shell.css` stylesheet.
+- `apps/companion/src/styles/base.css`: fixed-dark base and monospace hierarchy.
+- `apps/companion/src/styles/themes.css`: exact Claude and Codex token blocks.
+- `apps/companion/src/styles/chrome.css`: shared expanded header and shell chrome.
+- `apps/companion/src/styles/task-details.css`: prompt rows, panels, details, steps, metadata.
+- `apps/companion/src/styles/task-actions.css`: flat text actions.
+- `apps/companion/src/styles/status-groups.css`: terminal project and status hierarchy.
+- `apps/companion/src/styles/settings.css`: terminal form controls and theme picker.
+- `apps/companion/src/styles/motion.css`: reduced-motion coverage for new selectors.
+- `apps/companion/src/activity/activity-calendar.css`: token-driven fixed-dark calendar surfaces.
+- `apps/companion/tests/preferences.test.ts`: default, migration, persistence, and failure rollback.
+- `apps/companion/tests/settings-panel.test.tsx`: two-theme picker contract.
+- `apps/companion/tests/app-shell.test.tsx`: shared header/tabs/view composition and theme attributes.
+- `apps/companion/tests/project-group.test.tsx`: theme-aware panel contract.
+- `apps/companion/tests/task-row.test.tsx`: prompt and status primitive contracts.
+- `apps/companion/tests/activity-calendar.test.tsx`: preserve layout fallbacks while checking theme-token usage.
+
+### Remove after references reach zero
+
+- `apps/companion/src/application/systemThemeMode.ts`.
+- `apps/companion/src/ui/ViewNav.tsx`.
+- `apps/companion/src/ui/AppSummary.tsx`.
+- `apps/companion/src/ui/AppToolbar.tsx`.
+- `apps/companion/src/ui/TaskActionIcon.tsx` if all three task actions become accessible text buttons.
+
+Do not remove any file until `rg` proves that no production or test import remains.
+
+---
+
+### Task 1: Lock the fixed-theme preference contract
+
+**Files:**
+- Modify: `apps/companion/tests/preferences.test.ts`
+- Modify: `apps/companion/src/application/themePreferences.ts`
+- Modify: `apps/companion/src/application/preferences.ts`
+- Remove after green: `apps/companion/src/application/systemThemeMode.ts`
+
+- [x] **Step 1: Write failing tests for the new type and migration contract**
+
+Add or replace assertions so the test file proves this exact public shape:
+
+```ts
+expect(COMPANION_THEME_OPTIONS).toEqual([
+ { value: "claude", label: "Claude Code" },
+ { value: "codex", label: "Codex" },
+]);
+expect(DEFAULT_COMPANION_PREFERENCES).toEqual({
+ font: "system-mono",
+ theme: "claude",
+});
+expect(
+ sanitizeCompanionPreferences({
+ font: "menlo",
+ themeFamily: "companion",
+ themeMode: "light",
+ }),
+).toEqual({
+ preferences: { font: "menlo", theme: "claude" },
+ sanitized: true,
+});
+expect(
+ sanitizeCompanionPreferences({ font: "menlo", theme: "codex" }),
+).toEqual({
+ preferences: { font: "menlo", theme: "codex" },
+ sanitized: false,
+});
+```
+
+Also assert that `preferencesToStoreEntries()` returns only `{ font, theme }`, `writeCompanionPreferences()` writes only `font` and `theme`, and the failed-save rollback comparison uses those two fields.
+
+- [x] **Step 2: Run the focused test and confirm RED**
+
+Run:
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/preferences.test.ts
+```
+
+Expected: failures mention the missing `theme` property, old `themeFamily/themeMode` fields, and the old two concrete palette values.
+
+- [x] **Step 3: Replace `themePreferences.ts` with the fixed two-theme model**
+
+Implement this interface and remove mode/family/resolution exports:
+
+```ts
+const COMPANION_THEME_VALUES = ["claude", "codex"] as const;
+
+export type CompanionTheme = (typeof COMPANION_THEME_VALUES)[number];
+
+export type CompanionThemeOption = {
+ readonly value: CompanionTheme;
+ readonly label: string;
+};
+
+export const COMPANION_THEME_OPTIONS: readonly CompanionThemeOption[] = [
+ { value: "claude", label: "Claude Code" },
+ { value: "codex", label: "Codex" },
+];
+
+const COMPANION_THEME_SET = new Set(COMPANION_THEME_VALUES);
+
+export function isCompanionTheme(value: unknown): value is CompanionTheme {
+ return COMPANION_THEME_SET.has(value);
+}
+```
+
+- [x] **Step 4: Simplify `CompanionPreferences` and migrate old values**
+
+Use the new stored shape:
+
+```ts
+export type CompanionPreferences = {
+ readonly font: CompanionFont;
+ readonly theme: CompanionTheme;
+};
+
+export const DEFAULT_COMPANION_PREFERENCES: CompanionPreferences = {
+ font: "system-mono",
+ theme: "claude",
+};
+
+export function sanitizeCompanionPreferences(input: {
+ readonly font?: unknown;
+ readonly theme?: unknown;
+ readonly themeFamily?: unknown;
+ readonly themeMode?: unknown;
+}): PreferenceSanitizationResult {
+ const font = isCompanionFont(input.font)
+ ? input.font
+ : DEFAULT_COMPANION_PREFERENCES.font;
+ const theme = isCompanionTheme(input.theme)
+ ? input.theme
+ : isCompanionTheme(input.themeFamily)
+ ? input.themeFamily
+ : DEFAULT_COMPANION_PREFERENCES.theme;
+ return {
+ preferences: { font, theme },
+ sanitized: font !== input.font || theme !== input.theme,
+ };
+}
+```
+
+`readCompanionPreferences()` must read `theme`, `themeFamily`, and `themeMode`; the latter two are migration inputs only. `writeCompanionPreferences()` must write `font` and `theme`. Do not attempt to delete old keys from the store because the current store abstraction has no delete contract and valid `theme` takes precedence on future reads.
+
+- [x] **Step 5: Run focused tests and typecheck**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/preferences.test.ts
+pnpm --filter @workbranch/companion typecheck
+```
+
+Expected: preference tests pass; typecheck now reports only downstream consumers that still reference the removed mode/family fields.
+
+Evidence (2026-07-20): `pnpm --filter @workbranch/companion exec vitest run tests/preferences.test.ts` passed 11/11. `typecheck` now reports only the planned downstream references in `App`, `SettingsPanel`, `SettingsView`, and `systemThemeMode.ts`; Tasks 4 and 6 remove them.
+
+- [x] **Step 6: Remove system-mode code after downstream references are handled in Task 4**
+
+After `rg -n "useSystemThemeMode|CompanionThemeMode|CompanionResolvedThemeMode" apps/companion/src apps/companion/tests` returns only the files being removed or updated, delete `apps/companion/src/application/systemThemeMode.ts`.
+
+Evidence (2026-07-22 UTC): `systemThemeMode.ts` and every production reference were removed. Source-only residue checks for `useSystemThemeMode` and former light/system selectors returned no matches.
+
+- [ ] **Step 7: Commit gate**
+
+Only with explicit user authorization:
+
+```bash
+git add apps/companion/src/application apps/companion/tests/preferences.test.ts
+git commit -m "refactor(companion): replace appearance modes with agent themes"
+```
+
+---
+
+### Task 2: Define the Claude and Codex token contracts
+
+**Files:**
+- Modify: `apps/companion/tests/app-shell.test.tsx`
+- Modify: `apps/companion/src/styles/themes.css`
+- Modify: `apps/companion/src/styles/base.css`
+
+- [x] **Step 1: Write failing CSS contract assertions**
+
+Read `src/styles/themes.css` and assert exactly two theme selectors, no light selector, and the identity anchors:
+
+```ts
+expect(css).toContain('main[data-theme="claude"]');
+expect(css).toContain('main[data-theme="codex"]');
+expect(css).toContain("--accent: #cd694a");
+expect(css).toContain("--emphasis: #cd694a");
+expect(css).toContain("--text: #c0caf5");
+expect(css).toContain("--accent: #5cc2e0");
+expect(css).toContain("--emphasis: #ededed");
+expect(css).toContain("--text: #ededed");
+expect(css).not.toContain('data-theme$="-light"');
+expect(css).not.toMatch(/gradient\(/);
+```
+
+- [x] **Step 2: Run the focused test and confirm RED**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/app-shell.test.tsx
+```
+
+Expected: failures name `catppuccin-dark`, `breakfast-light`, light selectors, and gradients.
+
+- [x] **Step 3: Replace theme blocks with complete fixed-dark tokens**
+
+Define every existing semantic token in both blocks. Use these exact identity anchors and keep status colors readable:
+
+```css
+main[data-theme="claude"] {
+ --surface-0: #0e0e0e;
+ --surface-1: #131313;
+ --surface-2: #1b1b1b;
+ --surface-3: #252525;
+ --line: rgba(205, 105, 74, 0.32);
+ --line-strong: #cd694a;
+ --text: #c0caf5;
+ --muted: #949494;
+ --faint: #626262;
+ --accent: #cd694a;
+ --accent-strong: #e08567;
+ --accent-soft: rgba(205, 105, 74, 0.14);
+ --emphasis: #cd694a;
+ --emphasis-soft: rgba(205, 105, 74, 0.14);
+ --blocked: #f7768e;
+ --blocked-soft: rgba(247, 118, 142, 0.14);
+ --review: #bb9af7;
+ --review-soft: rgba(187, 154, 247, 0.14);
+ --done: #87d787;
+ --done-soft: rgba(135, 215, 135, 0.14);
+ --notify: #e0af68;
+ --notify-soft: rgba(224, 175, 104, 0.14);
+ --button-bg: rgba(192, 202, 245, 0.06);
+ --button-bg-hover: rgba(192, 202, 245, 0.12);
+ --current-step-bg: var(--emphasis-soft);
+ --cal-1: #cd694a;
+ --cal-2: #7dcfff;
+ --cal-3: #87d787;
+ --cal-4: #e0af68;
+ --cal-5: #bb9af7;
+ --cal-6: #f7768e;
+}
+
+main[data-theme="codex"] {
+ --surface-0: #0f0f0f;
+ --surface-1: #141414;
+ --surface-2: #1c1c1c;
+ --surface-3: #262626;
+ --line: #3a3a3a;
+ --line-strong: #565656;
+ --text: #ededed;
+ --muted: #7a7a7a;
+ --faint: #5d5d5d;
+ --accent: #5cc2e0;
+ --accent-strong: #8bdcf2;
+ --accent-soft: rgba(92, 194, 224, 0.14);
+ --emphasis: #ededed;
+ --emphasis-soft: rgba(237, 237, 237, 0.1);
+ --blocked: #f2a0a0;
+ --blocked-soft: rgba(242, 160, 160, 0.14);
+ --review: #bb9af7;
+ --review-soft: rgba(187, 154, 247, 0.14);
+ --done: #abdfa7;
+ --done-soft: rgba(171, 223, 167, 0.14);
+ --notify: #f6e2b7;
+ --notify-soft: rgba(246, 226, 183, 0.14);
+ --button-bg: rgba(237, 237, 237, 0.06);
+ --button-bg-hover: rgba(237, 237, 237, 0.12);
+ --current-step-bg: var(--emphasis-soft);
+ --cal-1: #5cc2e0;
+ --cal-2: #f6e2b7;
+ --cal-3: #abdfa7;
+ --cal-4: #a7a7a7;
+ --cal-5: #f2a0a0;
+ --cal-6: #bb9af7;
+}
+```
+
+Preserve the font selectors at the bottom of `themes.css`. Remove `main::before`, `--shell-topline`, light-only shadow overrides, and all gradients.
+
+- [x] **Step 4: Make Claude the fixed-dark base fallback**
+
+In `base.css`, set `:root`, `body`, and the `main` fallback to the Claude values. Remove `body:has(main[data-theme$="-light"])`, `--ui-font-family`, shadow tokens, `--radius-shell`, and `--radius-row`. Set `main` to `background: var(--surface-0)` and `font-family: var(--app-font-family)`.
+
+- [x] **Step 5: Run the focused test and CSS residue checks**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/app-shell.test.tsx
+rg -n 'catppuccin|breakfast|data-theme\$="-light"|gradient\(|shell-topline' apps/companion/src/styles apps/companion/src/activity
+```
+
+Expected: test passes; `rg` returns no production CSS matches.
+
+Evidence (2026-07-20): the named fixed-theme CSS contract in `app-shell.test.tsx` passed and the production residue search returned no matches. The whole `app-shell.test.tsx` remains intentionally staged until Tasks 4 and 5 replace its obsolete App/bottom-nav/card assertions.
+
+- [ ] **Step 6: Commit gate**
+
+Only with explicit user authorization:
+
+```bash
+git add apps/companion/src/styles apps/companion/tests/app-shell.test.tsx
+git commit -m "feat(companion): add claude and codex theme tokens"
+```
+
+---
+
+### Task 3: Build the reusable Agent primitives
+
+**Files:**
+- Create: `apps/companion/src/ui/AgentHeader.tsx`
+- Create: `apps/companion/src/ui/AgentTabs.tsx`
+- Create: `apps/companion/src/ui/TerminalPanel.tsx`
+- Create: `apps/companion/src/ui/PromptLine.tsx`
+- Create: `apps/companion/src/ui/StatusToken.tsx`
+- Create: `apps/companion/tests/agent-primitives.test.tsx`
+
+- [x] **Step 1: Write failing semantic rendering tests**
+
+Use `renderToStaticMarkup` to prove these contracts:
+
+```tsx
+const controls = {
+ status: "Ready",
+ onRefresh: () => {},
+ onQuit: () => {},
+};
+
+expect(renderToStaticMarkup( ))
+ .toContain(" ))
+ .toContain("Claude Code");
+expect(renderToStaticMarkup( ))
+ .toContain(">_ Workbranch Companion");
+expect(renderToStaticMarkup(Current step ))
+ .toContain("❯");
+expect(renderToStaticMarkup(Current step ))
+ .toContain("›");
+```
+
+For `AgentTabs`, assert three buttons, visible labels, `aria-current="page"` only on the active view, and no SVG. For `TerminalPanel`, assert Claude renders `fieldset/legend` and Codex renders `section` with a visible heading. For `StatusToken`, assert the text label remains visible instead of relying on color.
+
+- [x] **Step 2: Run the focused test and confirm RED**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/agent-primitives.test.tsx
+```
+
+Expected: module resolution fails for the six new primitive files.
+
+- [x] **Step 3: Implement the exact primitive interfaces**
+
+Use these exported props so later tasks do not invent parallel APIs:
+
+```ts
+export type AgentHeaderProps = {
+ readonly theme: CompanionTheme;
+ readonly summary: MenuSummary;
+ readonly status: string;
+ readonly onRefresh: () => void;
+ readonly onQuit: () => void;
+};
+
+export type CompanionView = "main" | "activity" | "settings";
+
+export type AgentTabsProps = {
+ readonly currentView: CompanionView;
+ readonly onViewChange: (view: CompanionView) => void;
+};
+
+export type TerminalPanelProps = {
+ readonly theme: CompanionTheme;
+ readonly label: string;
+ readonly className?: string;
+ readonly children: React.ReactNode;
+};
+
+export type PromptLineProps = {
+ readonly theme: CompanionTheme;
+ readonly current?: boolean;
+ readonly children: React.ReactNode;
+};
+
+export type StatusTokenProps = {
+ readonly status: PlanStatus;
+};
+```
+
+Keep refresh and quit as real `` elements with `aria-label`s. Use text glyphs `↻` and `⏻`; do not add an icon package. The Claude expanded header uses a semantic `fieldset` with the neutral accessible name `Workbranch Companion`, no visible agent product-name legend, a locally drawn pixel-style Workbranch SVG mark, and `❯`. The Codex header uses a bordered `section`, `>_ Workbranch Companion`, and model/directory-style metadata rows. Do not copy Claude or OpenAI logos.
+
+- [x] **Step 4: Run primitive tests and typecheck**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/agent-primitives.test.tsx
+pnpm --filter @workbranch/companion typecheck
+```
+
+Expected: primitive tests pass; typecheck may still report old App/Settings imports until Tasks 4 and 6.
+
+Evidence (2026-07-20): primitive tests passed 6/6 and targeted Biome passed after formatting. Typecheck reports only the staged App/Settings/system-mode references identified by the plan.
+
+- [ ] **Step 5: Commit gate**
+
+Only with explicit user authorization:
+
+```bash
+git add apps/companion/src/ui/Agent*.tsx apps/companion/src/ui/TerminalPanel.tsx apps/companion/src/ui/PromptLine.tsx apps/companion/src/ui/StatusToken.tsx apps/companion/tests/agent-primitives.test.tsx
+git commit -m "feat(companion): add agent shell primitives"
+```
+
+---
+
+### Task 4: Compose the global Agent Shell
+
+**Files:**
+- Modify: `apps/companion/src/App.tsx`
+- Modify: `apps/companion/tests/app-shell.test.tsx`
+- Modify: `apps/companion/src/styles/chrome.css`
+- Create: `apps/companion/src/styles/agent-shell.css`
+- Modify: `apps/companion/src/style.css`
+- Remove after green: `apps/companion/src/ui/ViewNav.tsx`
+- Remove after green: `apps/companion/src/ui/AppSummary.tsx`
+- Remove after green: `apps/companion/src/ui/AppToolbar.tsx`
+- Remove after green: `apps/companion/src/application/systemThemeMode.ts`
+
+- [x] **Step 1: Write failing shell composition tests**
+
+Assert source and static contracts for:
+
+```tsx
+
+
+
+ {/* active view */}
+
+
+```
+
+The tests must reject `useSystemThemeMode`, `resolvedCompanionTheme`, `ViewNav`, `AgentBar`, `HintBar`, `onKeyDown`, and shortcut labels. They must assert every view uses the shared `AgentHeader` and `AgentTabs` follows the active view as floating viewport navigation.
+
+- [x] **Step 2: Run the focused shell tests and confirm RED**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/app-shell.test.tsx tests/agent-primitives.test.tsx
+```
+
+Expected: old imports and bottom navigation assertions fail.
+
+- [x] **Step 3: Recompose `App` without changing application behavior**
+
+Set `const activeTheme = preferences.theme`. Pass the current theme to the headers and project groups. Keep `refresh`, `handleQuit`, `handleTaskAction`, workspace-monitor effects, `activityReloadToken`, error rendering, and all Tauri calls byte-for-byte equivalent except for moved props.
+
+Render order:
+
+```tsx
+
+ void refresh()}
+ onQuit={handleQuit}
+ />
+
+ {/* existing active view blocks */}
+
+
+```
+
+- [x] **Step 4: Implement shell CSS and floating-bottom navigation**
+
+`agent-shell.css` owns `.agent-header`, `.agent-tabs`, `.terminal-panel`, `.prompt-line`, and `.status-token`. `chrome.css` keeps only generic shell/control rules that are still used. Use `--emphasis` for selection/current-row boundaries and `--accent` for command-like actions and links. Use 1px borders, restrained radii, no gradient, 32px minimum control height, and visible `:focus-visible` rings. The floating tab bar is the only shell element allowed a functional separation shadow and pill radius.
+
+- [x] **Step 5: Remove obsolete modules after reference checks**
+
+```bash
+rg -n 'ViewNav|AppSummary|AppToolbar|useSystemThemeMode|resolvedCompanionTheme' apps/companion/src apps/companion/tests
+```
+
+Expected before deletion: only obsolete files and tests being replaced. Delete the obsolete files, update imports, then rerun the same command and expect no matches.
+
+- [x] **Step 6: Run shell tests, typecheck, and lint**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/app-shell.test.tsx tests/agent-primitives.test.tsx
+pnpm --filter @workbranch/companion typecheck
+pnpm --filter @workbranch/companion lint
+```
+
+Expected: all commands pass, apart from any already-known informational Biome diagnostic that does not produce a nonzero exit.
+
+Evidence (2026-07-20): `app-shell`, `agent-primitives`, and `agent-tabs` passed 26/26; full Companion lint exited 0 with pre-existing informational `parseContract.ts` diagnostics. Production references to the removed modules are zero. Full typecheck remains staged on the Task 6 Settings conversion only.
+
+- [ ] **Step 7: Commit gate**
+
+Only with explicit user authorization:
+
+```bash
+git add apps/companion/src/App.tsx apps/companion/src/ui apps/companion/src/styles apps/companion/src/style.css apps/companion/tests
+git commit -m "feat(companion): apply agent shell across views"
+```
+
+---
+
+### Task 5: Convert Main to theme-aware terminal components
+
+**Files:**
+- Modify: `apps/companion/src/ui/ProjectGroup.tsx`
+- Modify: `apps/companion/src/ui/TaskRow.tsx`
+- Modify: `apps/companion/tests/project-group.test.tsx`
+- Modify: `apps/companion/tests/task-row.test.tsx`
+- Modify: `apps/companion/src/styles/task-details.css`
+- Modify: `apps/companion/src/styles/task-actions.css`
+- Modify: `apps/companion/src/styles/status-groups.css`
+- Remove after green: `apps/companion/src/ui/TaskActionIcon.tsx`
+
+- [x] **Step 1: Write failing Main markup tests**
+
+Update fixtures to pass `theme="claude"` or `theme="codex"`. Assert:
+
+```tsx
+expect(claudeHtml).toContain('data-terminal-panel="claude"');
+expect(claudeHtml).toContain("/`, repo/branch/dirty information, notifications, current step, checklist depth, and action callbacks.
+
+- [x] **Step 2: Run focused tests and confirm RED**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/project-group.test.tsx tests/task-row.test.tsx
+```
+
+Expected: missing `theme` props and new primitive selectors fail.
+
+- [x] **Step 3: Pass theme through `ProjectGroup` and `TaskRow`**
+
+Use these props:
+
+```ts
+type ProjectGroupProps = {
+ readonly group: ProjectGroupModel;
+ readonly theme: CompanionTheme;
+ readonly onAction: TaskActionHandler;
+};
+
+export type TaskActionHandler = (
+ root: string,
+ task: Task,
+ kind: TaskActionKind,
+) => void;
+
+type TaskRowProps = {
+ readonly root: string;
+ readonly task: Task;
+ readonly theme: CompanionTheme;
+ readonly expanded: boolean;
+ readonly onAction: TaskActionHandler;
+};
+```
+
+Wrap each project with `TerminalPanel`. Use `StatusToken` for the visible state, `PromptLine` for task/current-step emphasis, and plain text buttons for IDE/Terminal/Finder. Keep the existing action kind union and callbacks unchanged.
+
+- [x] **Step 4: Flatten Main CSS without removing information**
+
+Remove card shadows, hover lift, pill-only metadata, and large radii. Keep the aligned checklist marker column and depth indentation from the current implementation. Use theme-specific prompt markers through rendered markup, not CSS-only `content`, so static tests and assistive review can verify them. Mark decorative prompt glyphs `aria-hidden="true"`; keep state text visible.
+
+- [x] **Step 5: Run focused tests and static residue checks**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/project-group.test.tsx tests/task-row.test.tsx
+rg -n 'box-shadow|translateY|border-radius:\s*(1[0-9]|[7-9])px' apps/companion/src/styles/task-details.css apps/companion/src/styles/task-actions.css apps/companion/src/styles/status-groups.css
+```
+
+Expected: tests pass; residue search returns no Main-card elevation or large-radius declarations.
+
+Evidence (2026-07-20): `project-group`, `task-row`, and `agent-primitives` passed 16/16. Main CSS residue search found no box shadow, hover lift, or 7px+ non-circular radius in the scoped files.
+
+- [ ] **Step 6: Commit gate**
+
+Only with explicit user authorization:
+
+```bash
+git add apps/companion/src/ui/ProjectGroup.tsx apps/companion/src/ui/TaskRow.tsx apps/companion/src/ui/TaskActionIcon.tsx apps/companion/src/styles apps/companion/tests/project-group.test.tsx apps/companion/tests/task-row.test.tsx
+git commit -m "feat(companion): render main tasks as agent terminal panels"
+```
+
+---
+
+### Task 6: Replace Settings appearance mode with the agent theme picker
+
+**Files:**
+- Create: `apps/companion/src/ui/AgentThemePicker.tsx`
+- Modify: `apps/companion/src/ui/SettingsPanel.tsx`
+- Modify: `apps/companion/src/ui/SettingsView.tsx`
+- Modify: `apps/companion/tests/settings-panel.test.tsx`
+- Modify: `apps/companion/src/styles/settings.css`
+
+- [x] **Step 1: Write failing Settings tests**
+
+Assert the picker has exactly two buttons and sends the complete new preference shape:
+
+```tsx
+expect(html).toContain("Claude Code");
+expect(html).toContain("Codex");
+expect(html).not.toContain("Light");
+expect(html).not.toContain("Dark");
+expect(html).not.toContain("System");
+
+onPreferencesChange({
+ ...preferences,
+ theme: "codex",
+});
+```
+
+Keep assertions for launch-at-login, loading/disabled state, font selection, and font preview.
+
+- [x] **Step 2: Run the focused test and confirm RED**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/settings-panel.test.tsx
+```
+
+Expected: old theme mode labels and old preference fields fail.
+
+- [x] **Step 3: Implement `AgentThemePicker` and replace the mode section**
+
+Use this interface:
+
+```ts
+type AgentThemePickerProps = {
+ readonly value: CompanionTheme;
+ readonly onChange: (theme: CompanionTheme) => void;
+};
+```
+
+Render a labelled group with two `` controls, `aria-pressed`, and visible focus. `SettingsPanel` calls `onPreferencesChange({ ...preferences, theme })`. Remove `systemThemeMode`, `modeHint`, `COMPANION_THEME_MODE_OPTIONS`, and all mode copy.
+
+- [x] **Step 4: Apply terminal form styling**
+
+Keep native checkbox and select semantics. Use a `TerminalPanel` for Startup, Font, and Theme sections. Claude uses fieldset legends; Codex uses ruled headings. Do not hide labels or replace native inputs with non-semantic divs.
+
+- [x] **Step 5: Run Settings tests and typecheck**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/settings-panel.test.tsx tests/preferences.test.ts
+pnpm --filter @workbranch/companion typecheck
+```
+
+Expected: both files pass and no mode/family type remains in Settings code.
+
+Evidence (2026-07-20): Settings and preference tests passed 16/16 and Companion typecheck exited 0. The old mode/family/system-mode Settings contract is removed. The generic `System Mono` font label remains by design; only the appearance `System` option was removed.
+
+- [ ] **Step 6: Commit gate**
+
+Only with explicit user authorization:
+
+```bash
+git add apps/companion/src/ui/AgentThemePicker.tsx apps/companion/src/ui/SettingsPanel.tsx apps/companion/src/ui/SettingsView.tsx apps/companion/src/styles/settings.css apps/companion/tests/settings-panel.test.tsx
+git commit -m "feat(companion): add claude and codex theme picker"
+```
+
+---
+
+### Task 7: Restyle Activity without changing calendar behavior
+
+**Files:**
+- Modify: `apps/companion/src/activity/activity-calendar.css`
+- Modify only if a semantic wrapper is needed: `apps/companion/src/activity/ActivityCalendarView.tsx`
+- Modify: `apps/companion/tests/activity-calendar.test.tsx`
+
+- [x] **Step 1: Add failing CSS contract coverage without weakening behavior tests**
+
+Keep all current session, lane, loading, selection, and fallback assertions. Add checks that calendar CSS uses `var(--surface-*)`, `var(--line)`, and `var(--cal-1)` through `var(--cal-6)`, while the main calendar containers contain no gradient, box shadow, or radius above 6px.
+
+- [x] **Step 2: Run Activity tests and confirm RED only for new visual contracts**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/activity-calendar.test.tsx
+```
+
+Expected: existing behavioral tests stay green; only the new flat-terminal CSS assertions fail.
+
+- [x] **Step 3: Restyle the existing calendar classes**
+
+Keep the current DOM, day/three-day modes, lane calculations, minimum session height, project color fallback, `color-mix` enhancement, reload policy, and selected session behavior. Change only CSS: flat background, 1px rules, 0–6px radii, theme calendar tokens, monospace labels, and token-driven selected/focus state.
+
+- [x] **Step 4: Run Activity tests and focused typecheck**
+
+```bash
+pnpm --filter @workbranch/companion vitest run tests/activity-calendar.test.tsx
+pnpm --filter @workbranch/companion typecheck
+```
+
+Expected: all Activity tests and typecheck pass.
+
+Evidence (2026-07-20): Activity tests passed 37/37 and Companion typecheck exited 0. Session/lane/layout/fallback behavior stayed covered while the new flat token contract passed.
+
+- [ ] **Step 5: Commit gate**
+
+Only with explicit user authorization:
+
+```bash
+git add apps/companion/src/activity apps/companion/tests/activity-calendar.test.tsx
+git commit -m "feat(companion): align activity with agent themes"
+```
+
+---
+
+### Task 8: Close the implementation with regression and visual proof
+
+**Files:**
+- Modify if implementation details changed the contract: `DESIGN.md`
+- Update progress only: `../TASK-WORKBRANCH.md`
+
+- [x] **Step 1: Run the full automated proof set**
+
+From the repository root:
+
+```bash
+pnpm --filter @workbranch/companion test
+pnpm --filter @workbranch/companion typecheck
+pnpm --filter @workbranch/companion lint
+pnpm --filter @workbranch/companion build
+pnpm --filter @workbranch/companion tauri build
+git diff --check
+```
+
+Expected: every command exits 0. If a pre-existing informational diagnostic remains, record the exact output and confirm the command still exits 0.
+
+Evidence (2026-07-22 UTC): full Vitest passed 15 files / 116 tests; Companion typecheck, lint, Vite build, Tauri release build, and `git diff --check` all exited 0. Lint retained 42 existing informational `useLiteralKeys` diagnostics and no errors. The macOS app bundle was generated at `apps/companion/src-tauri/target/release/bundle/macos/WorkbranchCompanion.app`.
+
+- [x] **Step 2: Prove removed concepts and forbidden additions are absent**
+
+```bash
+rg -n 'themeFamily|themeMode|Light|System|useSystemThemeMode|data-theme\$="-light"' apps/companion/src apps/companion/tests
+rg -n 'HintBar|shortcut|⌘1|onKeyDown|addEventListener\("keydown"' apps/companion/src apps/companion/tests
+rg -n 'tailwind|shadcn|@radix|brainless' apps/companion/package.json pnpm-lock.yaml
+```
+
+Expected: no production/test matches for removed appearance modes or shortcuts. Package searches return no newly introduced dependency; documentation comments may mention Brainless only where provenance requires it.
+
+Evidence (2026-07-22 UTC): production source contains no system-theme hook, former light selector, shortcut bar, or QA instrumentation residue. `themeFamily` and `themeMode` remain only as the planned read-only migration inputs plus migration fixtures. `package.json` and `pnpm-lock.yaml` have no implementation diff.
+
+- [x] **Step 3: Run the native visual QA gate in both themes**
+
+Start Companion:
+
+```bash
+pnpm companion:dev
+```
+
+Inspect these six states at approximately 360px and 460px widths:
+
+1. Claude Code / Main: semantic fieldset header without a visible agent product-name legend, pixel-style Workbranch mark, `❯` prompt rows, readable project panels.
+2. Claude Code / Activity: same expanded header footprint as Main, readable day and three-day modes, session selection and details.
+3. Claude Code / Settings: same expanded header footprint as Main, accessible controls, Claude selected in the two-choice picker.
+4. Codex / Main: `>_ Workbranch Companion` launch card, `›` prompt rows, monochrome selection/current-step emphasis, and cyan limited to command-like actions and links.
+5. Codex / Activity: same expanded header footprint as Main, monochrome calendar with tokenized session colors and visible selection.
+6. Codex / Settings: same expanded header footprint as Main, Codex selected, restart persistence confirmed.
+
+For every state, verify keyboard focus, task disclosure, IDE/Terminal/Finder actions, refresh, quit, launch-at-login, font change, error display, long text truncation, and reduced motion. Do not claim native visual QA if the Tauri window was not observed.
+
+Evidence (2026-07-22 UTC): the native Tauri app was observed across Claude/Codex × Main/Activity/Settings at 360px and 460px. The 12 final full-window states passed independent visual-design and visual-fidelity reviews with HIGH confidence. The user-reported tab ambiguity was resolved with active fill, 700-weight label, and a 2px theme indicator; CJK wrapping, helper contrast, focus, actions, refresh, login toggle, font, and reduced-motion states were also exercised.
+
+- [x] **Step 4: Record migration proof**
+
+Start once with an existing preference store containing `themeFamily: "companion"` and any former `themeMode`; verify Claude Code loads and the next save writes `theme: "claude"`. Restart after selecting Codex and verify Codex remains selected.
+
+Evidence (2026-07-22 UTC): a legacy store loaded Claude and wrote the canonical `theme: "claude"`; selecting Codex and restarting preserved `theme: "codex"`.
+
+- [x] **Step 5: Update task state and archive only after all gates pass**
+
+Set `../TASK-WORKBRANCH.md` to `status: done` only after automated and native visual proof passes. Keep `DESIGN.md` as the current contract. Archive the active plan according to the workbranch done workflow; do not archive on test-only evidence when visual QA remains open.
+
+Evidence (2026-07-22 UTC): automated proof, native visual proof, migration proof, dependency checks, and cleanup checks passed. Commit gates were intentionally skipped because no explicit commit authorization was given; the branch and worktree remain available for review.
+
+---
+
+## Acceptance criteria
+
+- Settings offers exactly `Claude Code | Codex`; no Light, Dark, or System mode remains.
+- New installs and every unsupported/legacy theme value resolve to Claude Code.
+- Theme selection applies immediately to Main, Activity, and Settings and persists after restart.
+- Main, Activity, and Settings share the same theme-specific expanded header and preserve its footprint across tab changes.
+- Floating bottom terminal tabs remain visible above scrolling content without covering the final row.
+- Expanded tasks use selected-row background emphasis and show progress in a compact pill.
+- Claude and Codex use different header, panel, and prompt anatomy through shared interfaces.
+- No shortcut bar or keyboard shortcut is added.
+- Main preserves task disclosure, status, progress, repository metadata, current step, nested checklist, notifications, and actions.
+- Activity preserves day/three-day layout, reload behavior, selection, session details, overlap handling, and static color fallbacks.
+- Settings preserves launch-at-login and font behavior.
+- No CLI, contract, domain, Rust command, watcher, or activity-store behavior changes.
+- No new runtime dependency is added.
+- Tests, typecheck, lint, Vite build, Tauri build, diff check, and native visual QA pass.
+
+## Out of scope
+
+- Light appearance, system appearance following, user-defined colors, and third themes.
+- Keyboard shortcuts, command palette, vim navigation, or shortcut hints.
+- New task lifecycle mutations or Activity features.
+- CLI output/schema changes, tray icon changes, Rust command changes, and package contract changes.
+- Copying Claude or OpenAI trademarks, logos, or product copy. Use Workbranch identity with the referenced terminal grammar.