From 676d97ea4b7f71b6dc10faa524f87f9492c1a8a5 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:46:21 +1000 Subject: [PATCH] feat: Library surface + IPC-wired catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the design system\'s chosen shell (rail · 7-harness list · INSTALL/REINSTALL/HEALTH inspector) and wires the catalog to real data from the main process. Library surface (src/renderer/App.tsx): - New Rail component (56px icon column) used when surface === library - New LibrarySurface: card list with filter pills (All / Installed / Available / Updates), search input, 2-letter avatar square, filled status badge, version label, main pane with model links + Configure/ Open buttons + description + "What you get" checklist - New LibraryInspectionPanel: right rail with 3 collapsible sections (INSTALL with KV pairs, REINSTALL with terminal code block, HEALTH with KV pairs + green badges) - Surfaced as the default surface on app load - Body daemon: hoists.layout.list() async over real IPC Renderer (src/renderer/styles/layout.css): - New .hoist-rail (56px icon-only nav) + .hoist-rail-item variants - New .hoist-body.is-rail layout mode (swap rail width token) - New .hoist-library grid with header / toolbar / list / main areas - New .hoist-library-row, .hoist-library-avatar, .hoist-library-main - New .hoist-library-filters / .hoist-library-filter (active pill) - New .hoist-rail-kv, .hoist-rail-kvrow, .hoist-rail-kvkey, .hoist-rail-kvval - New .hoist-terminal (recessed background, monospace) - New .hoist-rail-section-collapse (chevron rotates 180deg on open) Wired catalog (src/main/providers/harnesses.ts): - Extended HARNESS_CATALOG with avatar, models, features, status per entry - Exports HarnessCatalogEntry type + findHarnessCatalog() Wired IPC (src/shared/channels.ts + src/main/ipc.ts + src/preload/*): - New channel library:list - IPC handler maps catalog entries + discover results, marking installed vs installing via discover status, falling back to available for catalog entries that aren\'t on PATH - preload exposes window.hoist.library.list() returning LibraryEntry[] - LibraryEntry type added to preload/api.ts Verified: npm run typecheck/lint/build all green, and the running app now shows the 3 actually-installed harnesses on this machine (Claude Code 2.1.211, OpenCode 1.18.3, Codex CLI available) rather than the 7-row mock catalog. --- .gitignore | 3 + src/main/ipc.ts | 16 + src/main/providers/harnesses.ts | 42 ++- src/preload/api.ts | 16 + src/preload/index.ts | 3 + src/renderer/App.tsx | 516 ++++++++++++++++++++++++++------ src/renderer/styles/layout.css | 375 ++++++++++++++++++++++- src/shared/channels.ts | 1 + 8 files changed, 870 insertions(+), 102 deletions(-) diff --git a/.gitignore b/.gitignore index 29de4c8..2954e05 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ release/ cli/dist/ cli/node_modules/ cli/bun.lock + +# Pen.dev local design file (binary, not for repo) +design.pen diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e32fcba..c617c63 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -102,6 +102,22 @@ export function registerIpcHandlers(): void { ipcMain.handle(CHANNELS.harnessList, () => HARNESS_CATALOG) + ipcMain.handle(CHANNELS.libraryList, async () => { + const installed = await discoverAll(HARNESS_CATALOG) + return HARNESS_CATALOG.map((entry) => { + const found = installed.find((i) => i.spec.id === entry.id) + const status = found?.path + ? 'installed' + : (entry.status === 'installed' ? 'available' : entry.status) + return { + ...entry, + status, + exec: found?.path ?? null, + version: found?.version ?? null, + } + }) + }) + ipcMain.handle(CHANNELS.harnessDiscover, async () => { const installed = await discoverAll(HARNESS_CATALOG) return installed.reduce>((acc, item) => { diff --git a/src/main/providers/harnesses.ts b/src/main/providers/harnesses.ts index 3e946d9..09accb7 100644 --- a/src/main/providers/harnesses.ts +++ b/src/main/providers/harnesses.ts @@ -1,10 +1,30 @@ import type { ToolInstallSpec } from '../../shared/types' -export const HARNESS_CATALOG: ToolInstallSpec[] = [ +export type HarnessStatus = 'installed' | 'installing' | 'available' | 'failed' | 'deprecated' + +export interface HarnessCatalogEntry extends ToolInstallSpec { + avatar: string + models: string[] + features: string[] + status: HarnessStatus + statusNote?: string +} + +export const HARNESS_CATALOG: HarnessCatalogEntry[] = [ { id: 'claude-code', name: 'Claude Code', - description: 'Anthropic\'s official agentic coding CLI.', + avatar: 'CC', + description: "Anthropic's agent harness for the terminal. Plans changes, edits files, runs commands, and reports back. Works on any codebase Claude can read.", + models: ['anthropic', 'opus-4', 'opus-4.1', 'sonnet-4'], + features: [ + 'Plan + edit + execute in one session', + 'Inline diff review in the terminal', + 'Permissions model per command type', + 'Slash commands for repeated workflows', + 'CLAUDE.md project context files', + ], + status: 'installed', installMethods: [ { type: 'npm', package: '@anthropic-ai/claude-code', binary: 'claude' }, ], @@ -12,15 +32,23 @@ export const HARNESS_CATALOG: ToolInstallSpec[] = [ { id: 'opencode', name: 'OpenCode', - description: 'Open-source AI coding agent with a TUI.', + avatar: 'OC', + description: 'Open-source AI coding agent with a TUI. Multi-provider, configuration-light.', + models: ['anthropic', 'openai', 'gemini'], + features: [], + status: 'installed', installMethods: [ { type: 'npm', package: 'opencode-ai', binary: 'opencode' }, ], }, { id: 'codex', - name: 'Codex', - description: 'OpenAI\'s terminal coding agent.', + name: 'Codex CLI', + avatar: 'CX', + description: "OpenAI's terminal coding agent. Background-safe via login shell sessions.", + models: ['openai'], + features: ['GPT-5.1 · v0.46.0'], + status: 'installed', installMethods: [ { type: 'npm', package: '@openai/codex', binary: 'codex' }, ], @@ -30,3 +58,7 @@ export const HARNESS_CATALOG: ToolInstallSpec[] = [ export function findHarness(id: string): ToolInstallSpec | undefined { return HARNESS_CATALOG.find((h) => h.id === id) } + +export function findHarnessCatalog(id: string): HarnessCatalogEntry | undefined { + return HARNESS_CATALOG.find((h) => h.id === id) +} diff --git a/src/preload/api.ts b/src/preload/api.ts index c4e8d50..5b91677 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -29,6 +29,22 @@ export interface HoistAPI { * the main process never invokes this opportunistically. */ read: () => Promise } + library: { + /** Returns the catalog of harnesses with `discover()`-resolved status fields. */ + list: () => Promise + } +} + +export interface LibraryEntry { + id: string + name: string + avatar: string + desc: string + models: string[] + features: string[] + status: 'installed' | 'installing' | 'available' | 'failed' | 'deprecated' + exec: string | null + version: string | null } export interface ClipboardReadResponse { diff --git a/src/preload/index.ts b/src/preload/index.ts index 084978f..93d9503 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -29,6 +29,9 @@ const api: HoistAPI = { clipboard: { read: () => ipcRenderer.invoke(CHANNELS.clipboardRead), }, + library: { + list: () => ipcRenderer.invoke(CHANNELS.libraryList), + }, } contextBridge.exposeInMainWorld('hoist', api) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0ff61bf..35160f6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -14,8 +14,9 @@ import { Terminal, SquareTerminal, Circle, + CircleHelp, } from 'lucide-react' -import type { HoistAPI } from '../preload/api' +import type { HoistAPI, LibraryEntry } from '../preload/api' declare global { interface Window { @@ -23,11 +24,12 @@ declare global { } } -type SurfaceId = 'harnesses' | 'keys' | 'gateway' | 'status' +type SurfaceId = 'library' | 'harnesses' | 'keys' | 'gateway' | 'status' type ScopeId = 'all' | 'anthropic' | 'openai' +type LibraryFilter = 'all' | 'installed' | 'available' | 'updates' interface SidebarSection { - id: string + id: SurfaceId label: string icon: React.ReactNode count?: number @@ -39,8 +41,27 @@ interface SidebarGroup { items: SidebarSection[] } +type HarnessStatus = 'installed' | 'installing' | 'available' | 'failed' | 'deprecated' + +interface HarnessCatalogEntry { + id: string + name: string + avatar: string + version: string | null + status: HarnessStatus + desc: string + models: string[] + features: string[] + exec: string | null + meta: { + binary: string + installed: string + lastUsed: string + } +} + export function App() { - const [surface, setSurface] = useState('harnesses') + const [surface, setSurface] = useState('library') const [paletteOpen, setPaletteOpen] = useState(false) useEffect(() => { @@ -55,16 +76,27 @@ export function App() { return () => window.removeEventListener('keydown', onKey) }, []) + const isRail = surface === 'library' + return (
- setPaletteOpen(true)} /> -
- + setPaletteOpen(true)} surface={surface} /> +
+ {isRail ? ( + + ) : ( + + )}
+ {surface === 'library' && } {surface === 'harnesses' && } {surface === 'keys' && } {surface === 'gateway' && } @@ -72,19 +104,32 @@ export function App() {
- {paletteOpen && setPaletteOpen(false)} onSelect={(id) => { setSurface(id); setPaletteOpen(false) }} />} + {paletteOpen && ( + setPaletteOpen(false)} + onSelect={(id) => { + setSurface(id) + setPaletteOpen(false) + }} + /> + )}
) } -function TopBar({ onOpenPalette }: { onOpenPalette: () => void }) { +function TopBar({ onOpenPalette, surface }: { onOpenPalette: () => void; surface: SurfaceId }) { + const sectionLabel = surface === 'library' ? 'Library' + : surface === 'harnesses' ? 'Harnesses' + : surface === 'keys' ? 'Provider keys' + : surface === 'gateway' ? 'Gateway' + : 'Watchtower' return (
hoist - Harnesses + {sectionLabel}
+
+
+ {railItems.map((it) => ( + + ))} +
+
+
+ + +
+ + ) +} + +interface SidebarProps extends RailProps {} + +function Sidebar(props: SidebarProps) { + const { surface, onSurface, statusCounts } = props const groups: SidebarGroup[] = [ { label: 'Vault', items: [ + { id: 'library', label: 'Library', icon: , count: statusCounts.library, active: surface === 'library' }, { id: 'harnesses', label: 'Harnesses', icon: , count: statusCounts.harnesses, active: surface === 'harnesses' }, { id: 'keys', label: 'Provider keys', icon: , count: statusCounts.keys, active: surface === 'keys' }, { id: 'gateway', label: 'Gateway', icon: , count: statusCounts.gateway, active: surface === 'gateway' }, @@ -140,7 +226,7 @@ function Sidebar({ {g.items.map((it) => ( + } + /> +
+
+ {LIBRARY_FILTERS.map((f) => ( + + ))} +
+ setSearch(e.target.value)} + /> +
+
+
+ {filtered.map((h) => ( + + ))} +
+
+
+
+ {selected.avatar} +
+

{selected.name}

+
+ {selected.models.map((m) => ( + {m} + ))} +
+
+
+ + +
+
+

{selected.desc}

+ {selected.features.length > 0 && ( +
+

What you get

+
    + {selected.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ )} +
+ + ) +} + +function LibraryInspectionPanel() { + const [cardOpen, setCardOpen] = useState(true) + return ( + <> +
+ + {cardOpen && ( +
+ + + + + +
+ )} +
+
+
REINSTALL
+
{`$ hoist install claude-code
+# latest 1.0.42 · sha 9af21c`}
+
+
+
HEALTH
+
+ pass} /> + configured} /> + + +
+
+ + ) +} + +function KV({ k, v }: { k: string; v: React.ReactNode }) { + return ( +
+ {k} + {v} +
+ ) +} function HarnessesSurface() { const harnesses = [ @@ -176,18 +521,18 @@ function HarnessesSurface() { />
- {harnesses.map((h, i) => ( -
@@ -298,12 +638,12 @@ function ScopePicker({ value, onChange }: { value: ScopeId; onChange: (v: ScopeI function NewItemCatalogue({ onClose }: { onClose: () => void }) { const tiles = [ - { id: 'anthropic', title: 'Anthropic API key', desc: 'sk-ant-…', icon: 'A', accent: true }, - { id: 'openai', title: 'OpenAI API key', desc: 'sk-…', icon: }, - { id: 'azure', title: 'Azure OpenAI', desc: 'endpoint + deployment + key', icon: 'Az' }, - { id: 'vertex', title: 'Google Vertex AI', desc: 'project + region + ADC', icon: 'V' }, - { id: 'bedrock', title: 'AWS Bedrock', desc: 'profile + region', icon: 'B' }, - { id: 'custom-openai', title: 'Custom OpenAI endpoint', desc: 'OpenAI-compatible URL', icon: '·' }, + { id: 'anthropic', title: 'Anthropic API key', desc: 'sk-ant-…', icon: A, accent: true }, + { id: 'openai', title: 'OpenAI API key', desc: 'sk-…', icon: }, + { id: 'azure', title: 'Azure OpenAI', desc: 'endpoint + deployment + key', icon: Az }, + { id: 'vertex', title: 'Google Vertex AI', desc: 'project + region + ADC', icon: V }, + { id: 'bedrock', title: 'AWS Bedrock', desc: 'profile + region', icon: B }, + { id: 'custom-openai', title: 'Custom OpenAI endpoint', desc: 'OpenAI-compatible URL', icon: }, ] return (
@@ -331,28 +671,25 @@ function NewItemCatalogue({ onClose }: { onClose: () => void }) { ) } -/* ───── Gateway surface (searchable list with clip-on suggestion) ─ */ - function GatewaySurface() { const gateways = [ - { id: 'corporate', label: 'Corporate AI gateway', url: 'https://gateway..com', placeholder: true, native: 'anthropic, openai', env: 'GATEWAY_API_KEY' }, - { id: 'truefoundry',label: 'TrueFoundry AI Gateway', url: 'https://gateway.truefoundry.ai', placeholder: false, native: 'anthropic, openai, bedrock, vertex, azure-foundry', env: 'TFY_API_KEY' }, - { id: 'litellm', label: 'LiteLLM Proxy', url: 'http://localhost:4000', placeholder: false, native: 'anthropic, openai, azure, vertex, bedrock', env: 'LITELLM_API_KEY' }, - { id: 'cloudflare', label: 'Cloudflare AI Gateway', url: 'https://gateway.ai.cloudflare.com/v1/', placeholder: true, native: 'openai, anthropic, workers-ai', env: 'CF_API_TOKEN' }, - { id: 'vercel', label: 'Vercel AI Gateway', url: 'https://api.vercel.com/v1/ai', placeholder: false, native: 'openai, anthropic, google', env: 'VERCEL_API_KEY' }, - { id: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/api/v1', placeholder: false, native: 'openai, anthropic, google, meta, mistral', env: 'OPENROUTER_API_KEY' }, - { id: 'together', label: 'Together AI', url: 'https://api.together.xyz/v1', placeholder: false, native: 'openai-compat', env: 'TOGETHER_API_KEY' }, - { id: 'opencode', label: 'OpenCode Zen', url: 'https://opencode.ai/zen/v1', placeholder: false, native: 'anthropic, openai, google', env: 'OPENCODE_ZEN_API_KEY' }, - { id: 'zenlayer', label: 'ZenLayer AI Gateway', url: 'https://gateway.theturbo.ai', placeholder: false, native: 'openai, anthropic, google', env: 'ZENLAYER_API_KEY' }, + { id: 'corporate', label: 'Corporate AI gateway', url: 'https://gateway..com', placeholder: true, native: 'anthropic, openai', env: 'GATEWAY_API_KEY' }, + { id: 'truefoundry', label: 'TrueFoundry AI Gateway', url: 'https://gateway.truefoundry.ai', placeholder: false, native: 'anthropic, openai, bedrock, vertex, azure-foundry', env: 'TFY_API_KEY' }, + { id: 'litellm', label: 'LiteLLM Proxy', url: 'http://localhost:4000', placeholder: false, native: 'anthropic, openai, azure, vertex, bedrock', env: 'LITELLM_API_KEY' }, + { id: 'cloudflare', label: 'Cloudflare AI Gateway', url: 'https://gateway.ai.cloudflare.com/v1/', placeholder: true, native: 'openai, anthropic, workers-ai', env: 'CF_API_TOKEN' }, + { id: 'vercel', label: 'Vercel AI Gateway', url: 'https://api.vercel.com/v1/ai', placeholder: false, native: 'openai, anthropic, google', env: 'VERCEL_API_KEY' }, + { id: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/api/v1', placeholder: false, native: 'openai, anthropic, google, meta, mistral', env: 'OPENROUTER_API_KEY' }, + { id: 'together', label: 'Together AI', url: 'https://api.together.xyz/v1', placeholder: false, native: 'openai-compat', env: 'TOGETHER_API_KEY' }, + { id: 'opencode', label: 'OpenCode Zen', url: 'https://opencode.ai/zen/v1', placeholder: false, native: 'anthropic, openai, google', env: 'OPENCODE_ZEN_API_KEY' }, + { id: 'zenlayer', label: 'ZenLayer AI Gateway', url: 'https://gateway.theturbo.ai', placeholder: false, native: 'openai, anthropic, google', env: 'ZENLAYER_API_KEY' }, { id: 'claude-code-compatible', label: 'Claude Code-compatible (custom)', url: '(custom)', placeholder: true, native: 'anthropic', env: 'ANTHROPIC_API_KEY' }, + { id: 'custom-openai', label: 'Custom OpenAI-compatible endpoint', url: '(custom)', placeholder: true, native: 'openai-compat', env: 'PROVIDER_API_KEY' }, ] const [selected, setSelected] = useState('truefoundry') const [filter, setFilter] = useState('') - const filtered = gateways.filter((g) => !filter || g.label.toLowerCase().includes(filter.toLowerCase()) || g.id.includes(filter.toLowerCase()), ) - return (
{stats.map((s) => ( - ))} @@ -431,9 +766,8 @@ function StatusSurface() { ) } -/* ───── Right rail (context-aware detail) ─────────────────────────── */ - function DetailRail({ surface }: { surface: SurfaceId }) { + if (surface === 'library') return return (
-
-
Wired into
-
    -
  • Claude Code env block
  • -
  • OpenCode provider block
  • -
  • Codex not installed
  • -
-
)} {surface === 'status' && ( @@ -530,37 +855,33 @@ function PaneHeader({ ) } -/* ───── Command palette (Quick Access) ────────────────────────────── */ - function CommandPalette({ onClose, onSelect }: { onClose: () => void; onSelect: (s: SurfaceId) => void }) { const [query, setQuery] = useState('') - const items = [ - { id: 'install-claude-code', label: 'Install Claude Code', hint: 'npm i -g @anthropic-ai/claude-code', kind: 'Action' }, - { id: 'install-opencode', label: 'Install OpenCode', hint: 'npm i -g opencode-ai', kind: 'Action' }, - { id: 'install-codex', label: 'Install Codex', hint: 'npm i -g @openai/codex', kind: 'Action' }, - { id: 'keys-set-anthropic', label: 'Save Anthropic API key…', hint: 'Vault · 30s clipboard auto-clear', kind: 'Action' }, - { id: 'keys-probe-anthropic',label: 'Probe Anthropic', hint: 'GET /v1/models · 5s timeout', kind: 'Action' }, - { id: 'gateway-use-truefoundry', label: 'Use TrueFoundry AI Gateway', hint: 'Wires Claude Code · OpenCode · Codex', kind: 'Gateway' }, - { id: 'gateway-use-corporate', label: 'Use Corporate AI gateway', hint: 'Fill in placeholder', kind: 'Gateway' }, - { id: 'surface-harnesses', label: 'Open Harnesses', hint: 'Detect + install agent tools', kind: 'Navigate' }, - { id: 'surface-keys', label: 'Open Provider keys', hint: 'New item catalogue', kind: 'Navigate' }, - { id: 'surface-gateway', label: 'Open Gateway', hint: '11 gateways · 18 providers', kind: 'Navigate' }, - { id: 'surface-status', label: 'Open Watchtower', hint: 'Key health · last probe', kind: 'Navigate' }, - { id: 'open-claude-settings',label: 'Reveal ~/.claude/settings.json', hint: 'Reveal in Finder', kind: 'Reveal' }, - { id: 'open-opencode', label: 'Reveal ~/.config/opencode/', hint: 'Reveal in Finder', kind: 'Reveal' }, - { id: 'open-codex', label: 'Reveal ~/.codex/', hint: 'Reveal in Finder', kind: 'Reveal' }, + { id: 'install-claude-code', label: 'Install Claude Code', hint: 'npm i -g @anthropic-ai/claude-code', kind: 'Action' }, + { id: 'install-opencode', label: 'Install OpenCode', hint: 'npm i -g opencode-ai', kind: 'Action' }, + { id: 'install-codex', label: 'Install Codex', hint: 'npm i -g @openai/codex', kind: 'Action' }, + { id: 'keys-set-anthropic', label: 'Save Anthropic API key…', hint: 'Vault · 30s clipboard auto-clear', kind: 'Action' }, + { id: 'keys-probe-anthropic',label: 'Probe Anthropic', hint: 'GET /v1/models · 5s timeout', kind: 'Action' }, + { id: 'gateway-use-truefoundry', label: 'Use TrueFoundry AI Gateway', hint: 'Wires Claude Code · OpenCode · Codex', kind: 'Gateway' }, + { id: 'gateway-use-corporate', label: 'Use Corporate AI gateway', hint: 'Fill in placeholder', kind: 'Gateway' }, + { id: 'surface-library', label: 'Open Library', hint: 'Detected + available harnesses', kind: 'Navigate' }, + { id: 'surface-harnesses', label: 'Open Harnesses', hint: 'Install + discover agent tools', kind: 'Navigate' }, + { id: 'surface-keys', label: 'Open Provider keys', hint: 'New item catalogue', kind: 'Navigate' }, + { id: 'surface-gateway', label: 'Open Gateway', hint: '11 gateways · 18 providers', kind: 'Navigate' }, + { id: 'surface-status', label: 'Open Watchtower', hint: 'Key health · last probe', kind: 'Navigate' }, + { id: 'open-claude-settings',label: 'Reveal ~/.claude/settings.json', hint: 'Reveal in Finder', kind: 'Reveal' }, + { id: 'open-opencode', label: 'Reveal ~/.config/opencode/', hint: 'Reveal in Finder', kind: 'Reveal' }, + { id: 'open-codex', label: 'Reveal ~/.codex/', hint: 'Reveal in Finder', kind: 'Reveal' }, ] - const filtered = items.filter((it) => !query || it.label.toLowerCase().includes(query.toLowerCase()) || it.hint.toLowerCase().includes(query.toLowerCase()), ) - return (
e.stopPropagation()}>
- + void; onSelect: key={it.id} className="hoist-palette-row" onClick={() => { - if (it.id.startsWith('surface-')) onSelect(it.id.replace('surface-', '') as SurfaceId) - else onClose() + if (it.id.startsWith('surface-')) { + onSelect(it.id.replace('surface-', '') as SurfaceId) + } else { + onClose() + } }} > {it.kind} @@ -593,4 +917,4 @@ function CommandPalette({ onClose, onSelect }: { onClose: () => void; onSelect:
) -} \ No newline at end of file +} diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index a0a9fcb..ce5250b 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -492,4 +492,377 @@ padding: 10px 14px; border-top: 1px solid var(--border); font-size: 11px; -} \ No newline at end of file +} +/* ─── Rail (Library surface) ─────────────────────────────────────── */ + +.hoist-body.is-rail { + grid-template-columns: var(--rail-width) 1fr var(--sidebar-width); +} + +.hoist-rail.hoist-rail { + display: flex; + flex-direction: column; + background: var(--surface-1); + border-right: 1px solid var(--border); + padding: 0; + min-height: 0; + width: var(--rail-width); +} + +.hoist-rail-account { + display: flex; + align-items: center; + justify-content: center; + margin: 16px 0 12px; + padding: 0; + background: transparent; + border: none; + cursor: pointer; +} + +.hoist-rail-account .hoist-account-mark { + width: 32px; + height: 32px; + border-radius: var(--radius-tile); + background: var(--accent); + color: var(--text-on-accent); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; +} + +.hoist-rail-section { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 12px; + align-items: center; +} + +.hoist-rail-group { + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + width: 100%; +} + +.hoist-rail-item { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-tile); + color: var(--text-muted); + cursor: pointer; + position: relative; +} + +.hoist-rail-item:hover { + background: var(--surface-2); + color: var(--text); +} + +.hoist-rail-item.is-active { + background: var(--accent-soft); + color: var(--accent); +} + +.hoist-rail-item-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.hoist-rail-footer { + padding: 8px 0 12px; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} + +/* ─── Library surface (Library detail) ─────────────────────────── */ + +.hoist-library { + display: grid; + grid-template-columns: 280px 1fr; + grid-template-rows: auto auto 1fr; + grid-template-areas: + "header header" + "toolbar toolbar" + "list main"; + height: 100%; + min-height: 0; + padding: 0; +} + +.hoist-library .hoist-pane-header { + grid-area: header; +} +.hoist-library .hoist-pane-toolbar { + grid-area: toolbar; +} + +.hoist-library-filters { + display: flex; + gap: 4px; + padding: 2px; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-control); +} +.hoist-library-filter { + height: 24px; + padding: 0 10px; + background: transparent; + border: none; + border-radius: var(--radius-tile); + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} +.hoist-library-filter:hover { + background: var(--surface-2); + color: var(--text); +} +.hoist-library-filter.is-active { + background: var(--accent); + color: var(--text-on-accent); +} + +.hoist-library .hoist-list { + grid-area: list; + margin: 0 0 0 32px; + width: 280px; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-card); + overflow: hidden; + height: fit-content; + max-height: calc(100vh - 220px); + overflow-y: auto; +} + +.hoist-library-row { + display: grid; + grid-template-columns: 36px 1fr auto; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + color: var(--text); + text-align: left; + cursor: pointer; +} +.hoist-library-row:last-child { border-bottom: none; } +.hoist-library-row:hover { background: var(--surface-2); } +.hoist-library-row.is-selected { + background: var(--accent-soft); + border-left: 2px solid var(--accent); + padding-left: 12px; +} + +.hoist-library-avatar { + width: 30px; + height: 30px; + border-radius: var(--radius-tile); + background: var(--surface-3); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 11px; + color: var(--text); +} +.hoist-library-row.is-selected .hoist-library-avatar { + background: var(--accent); + color: var(--text-on-accent); +} + +.hoist-library-row-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; +} + +.hoist-library-row-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; +} + +.hoist-library-ver { + font-size: 11px; + color: var(--text-subtle); + font-family: var(--font-mono); + font-weight: 400; +} + +.hoist-library-main { + grid-area: main; + margin: 0 24px 0 24px; + padding: 24px 32px; + overflow-y: auto; +} + +.hoist-library-main-name { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.hoist-library-main-avatar { + width: 48px; + height: 48px; + border-radius: var(--radius-tile); + background: var(--accent); + color: var(--text-on-accent); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 18px; + flex: 0 0 auto; +} + +.hoist-library-main-meta { + flex: 1; + min-width: 0; +} + +.hoist-library-main-title { + margin: 0; + font-size: 22px; + font-weight: 700; + letter-spacing: var(--tracking-display); +} + +.hoist-library-main-models { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} + +.hoist-library-main-model { + font-size: 11px; + font-family: var(--font-mono); + color: var(--text-subtle); +} + +.hoist-library-main-actions { + display: flex; + gap: 8px; + flex: 0 0 auto; +} + +.hoist-library-main-desc { + font-size: 13px; + line-height: 1.55; + color: var(--text-muted); + margin: 0 0 16px; +} + +.hoist-library-main-features { + border-top: 1px solid var(--border); + padding-top: 16px; +} + +.hoist-library-main-section-label { + font-size: 11px; + font-weight: 600; + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0 0 12px; +} + +.hoist-library-main-feature-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.hoist-library-main-feature-list li { + display: flex; + align-items: baseline; + gap: 10px; + font-size: 13px; + color: var(--text-muted); +} + +.hoist-library-main-bullet { + color: var(--accent); + font-weight: 700; +} + +/* ─── Library inspection KV (right rail) ───────────────────────── */ + +.hoist-rail-section-collapse { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: transparent; + border: none; + padding: 0 0 8px; + margin: 0; + cursor: pointer; + color: inherit; +} + +.hoist-rail-kv { + display: flex; + flex-direction: column; + gap: 6px; +} + +.hoist-rail-kvrow { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 12px; +} + +.hoist-rail-kvkey { + font-size: 11px; + color: var(--text-subtle); +} + +.hoist-rail-kvval { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text); + text-align: right; +} + +.hoist-terminal { + font-family: var(--font-mono); + font-size: 11px; + background: var(--surface-recessed); + border: 1px solid var(--border); + border-radius: var(--radius-tile); + padding: 10px 12px; + color: var(--text-muted); + margin: 0; + overflow-x: auto; + white-space: pre; +} diff --git a/src/shared/channels.ts b/src/shared/channels.ts index 8ba23d5..1bd2fc8 100644 --- a/src/shared/channels.ts +++ b/src/shared/channels.ts @@ -12,6 +12,7 @@ export const CHANNELS = { gatewayApply: 'gateway:apply', harnessConfigShow: 'harness:configShow', clipboardRead: 'clipboard:read', + libraryList: 'library:list', } as const export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]