19 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project state
Phase 1 is scaffolded. An electron-vite + React 18 + TypeScript app now lives at the repo root (src/main, src/preload, src/renderer). The prototype has been ported faithfully and renders against the mock data — full UI, git panel, the view modes, search, and the simulated terminals are all working. IBM Plex Mono + IBM Plex Sans are bundled locally via @fontsource/ibm-plex-mono and @fontsource/ibm-plex-sans; Prism is wired with the correct markup-templating→php load order; the renderer↔main clipboard bridge is in place (src/preload/index.ts).
Phase 2 (real integrations) — complete; DESIGN.md fully implemented. The app is feature-complete against the functional spec (the only intentional exception is the separate Go-to-File overlay — ⌘P aliases the unified search instead, per the resolved decision). Editor is writable (save · autosave · dirty tabs · discard); session restore brings back open tabs/active/view modes; close-dirty prompts Save/Don't-Save/Cancel. There's a vitest suite (npm test, 51 tests) + ESLint + electron-builder packaging. All over IPC through the preload bridge (src/preload/index.ts):
-
Filesystem — tree + in-memory content index,
chokidarwatch (src/main/fs-service.ts). The tree/index/search share one source of truth:rg --files(honors gitignore +files.exclude, includes dotfiles), with a recursive-walk fallback when ripgrep is unavailable. -
Git —
simple-git: status→A/M/D/R, the diff views from HEAD-vs-worktree pairs, stage/unstage/commit/discard (src/main/git-service.ts). -
Terminals — real PTYs via
node-pty(src/main/pty-service.ts) rendered with@xterm/xterm(src/renderer/src/terminals.tsx). Agent pane is a shell that auto-launchesclaude; bottom pane is a plain shell. Pass-on-to-Agent writes bracketed paste (\x1b[200~ … \x1b[201~) to the agent PTY. node-pty is native —npm run rebuild(also apostinstall) rebuilds it for Electron; it's N-API so the binary is portable. -
Config —
.helder/per project (src/main/config.ts):config.default.jsonregenerated on launch (full defaults / live docs), sparseconfig.jsondeep-merged over it, andtheme.css(created once, never overwritten) injected over the built-in dark theme. The code font + size are CSS vars (--code-font/--code-size/--term-size) the editor + xterm read, overridable fromtheme.css. ai command/autoLaunch + shell flow from config into the PTYs; a.helderfile watcher hot-reloads config/theme. -
Search — ripgrep (
@vscode/ripgrep, bundled binary) for content (--json, fixed-string smart-case) and the file-name list (--files), viasrc/main/search-service.ts.SearchModalcalls it debounced and falls back to the in-memory index whenwindow.helderis absent.
The renderer consumes FS/git/config/search via the store in src/renderer/src/project.tsx (useProject / useProjectActions), which falls back to the mock + default config when window.helder is absent (browser preview). src/main/index.ts registers all IPC handlers + the debounced watchers.
- Editing — the
code/updatedmodes are a writable buffer: a transparent textarea over a Prism-highlighted<pre>with a scroll-synced gutter (CodeEditorineditor.tsx).⌘Ssaves to disk (fs:write),editor.autoSavedebounce-saves on change, tabs show the dirty dot, and the git-row context menu has Discard changes gated bygit.confirmDiscard. Original stays a read-only review view; Actual marks the changed lines in place, and Diff is the full-screen side-by-side view.
README steps 1–7 plus config + editing are all implemented for real. The editable overlay keeps the caret in view (the textarea is overflow-hidden under the scroller, so CodeEditor scrolls the container on input/keyup/click).
Not yet done: packaging (electron-builder → .app/.dmg) — out/ is dev build output only, there is no distributable yet.
The two handoff documents remain the contract:
DESIGN.md— functional/UX source of truth. Every panel, interaction, state, and edge case at the behavior level. Read this for what the app does.design_handoff_helder_workbench/README.md— technical source of truth. Structure, design tokens, recommended stack, real-integration mechanics, and the suggested implementation order. Read this for how to build it.
The prototype in design_handoff_helder_workbench/design/ (React 18 + Babel from CDN, all mock data) is a visual/interaction reference only — do not ship it as-is. The HTML is canonical for look and feel; design/styles.css's :root block is the canonical design-token list. The design/src/*.jsx files map directly to the components to build, but their mock data (data.js) and simulated terminals/agent must be replaced with real integrations.
What Helder is
A dark-only (no light mode, no theme toggle) Electron desktop code workbench for reviewing code written by an AI agent. One project per window. Four resizable columns left→right: Source Control (git), Explorer (file tree), Editor (tabs + diff), Right column (Claude agent terminal stacked over a shell terminal). Plus a top title bar and bottom status bar. The defining feature is the Copy reference / Pass on to Agent flow that pushes path:line references into the agent's input.
Recommended stack (no codebase exists — follow README)
- Electron (latest stable), main + renderer + preload bridge with
contextIsolation: true. - Renderer: React 18 + TypeScript + Vite (
electron-vitescaffold). Prototype is already React, so component structure ports directly. - Syntax highlighting: Prism 1.29 (or swap to Shiki/CodeMirror 6; token→color mapping is documented in README).
- Fonts: UI = IBM Plex Sans; code/mono/labels = IBM Plex Mono. Both bundled locally (never Google Fonts CDN in Electron).
Architecture rules and gotchas (these will bite if ignored)
- Renderer never touches the filesystem, git, or PTYs directly. All FS (
fs+chokidar), git (git/simple-git), search (rg+ fuzzy), terminals (node-pty+xterm.js), and clipboard go through the main process via IPC / the preload bridge. The prototype keeps all state in the topAppcomponent; in the real app, lift FS/git/terminal state into main and stream over IPC. - Prism PHP load order:
prism-phprequiresprism-markup-templatingto be loaded first, or everyPrism.highlightcall throws and silently falls back to plain text. - Preload must be CommonJS
index.cjsandmainmust load../preload/index.cjs(seeelectron.vite.config.tspreloadrollupOptions.output). If they mismatch (or you let it build as.mjs), Electron silently loads no preload,window.helderis undefined, and the renderer silently falls back to mock data + the "terminal not available" message. Keep the filename and themainpath in sync. - chokidar is pinned to v3 on purpose — do NOT bump to v4/v5. chokidar ≥4 dropped the
fseventsaddon and watches recursively via libuv's nativefs.watch({recursive:true}). On macOS that recursive watcher poisons the process's file descriptors, so every laterchild_process.spawn(i.e. everygitcall) fails withspawn EBADF(errno -9) and the git column silently stops updating. v3 uses thefseventsnative addon instead and has no such conflict. If you must move to v4+, switch the main project watcher tousePolling: true(the only other config proven to avoid the EBADF here). - Word wrap swaps the buffer's layout, it is not just a CSS switch (
editor.wordWrap:markdowndefault /on/off). Unwrapped,CodeEditorrenders one highlighted blob and a separate gutter column that follows the scroll. Wrapped, a fixed 20px-per-line gutter no longer lines up, so each line becomes a.ce-lineblock and the number is a CSS counter on::before— that keeps it on the first visual row and leaves folded rows blank. Two things bite here:.ce-innermust dropwidth:max-content(and.editoritsmax-contentgrid track), or a folded line still measures its full unfolded width and never breaks; and the textarea and the<pre>must fold identically — same width, padding, font,white-space:pre-wrap,overflow-wrap:break-word— or the caret drifts off the text. The full-screen Diff never wraps on purpose: its two panes align row by row. The hover original panel must fold at the same points as the editor, or the old line does not stay level with the current line. - Pass on to Agent uses bracketed paste. Write inserts to the agent PTY wrapped in
\x1b[200~ … \x1b[201~so theclaudeCLI treats it as pasted, unsubmitted input. Insert must never submit — it lands as a new line so the user can stack several references before sending. - The three view modes (Actual / Original / Diff), plus Preview for markdown, all derive from one original-text + updated-text pair per changed file. The prototype computes the pair with an LCS line diff (
buildDiff()indesign/src/data.js); production should prefer realgit diffoutput. Actual is the writable buffer, and it marks the changed lines in place. A marked row takes the teal--addtint, a 2px--addleft rule, and a teal line number. There is no+glyph, no sign column, and no second row. The removed lines appear on hover:mouseenteron a marked line opens a 496px panel with a 2px--delleft border over the agent + terminal column, andmouseleavecloses it. The panel holds the whole original file and scrolls so the previous version of the hovered line sits level with the hovered line. There is no click, no pin, and no animation. A pure deletion has no current line to mark, so the neighbouring current line takes a 2px--delrule on its edge and opens the same panel. Diff is the full-screen side-by-side view: it covers the whole application, original left, updated right, lines aligned, andEscreturns to the previous mode. The old separate Split button is gone, and theDiffsegment opens that view instead.git.defaultDiffModedefaults to'updated'(Actual), so a click on a changed git row opens the file in Actual. The git context menu's Open diff is the explicit way into the full-screen view. The colour language is token-based everywhere: teal--addis what the file holds now, amber-deep--delis what it held before — never green, never red. Syntax highlighting stays on in all modes. The design source for the marked lines and the hover panel isdesign_handoff_helder_inline_diff/(README.md +03b-in-pane-diff.html). - The agent pane is just a terminal running the
claudeCLI (ai.command, defaultclaude, auto-launched whenai.autoLaunchis on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (agentSeed,runAgent,bootAgentinterminals.jsx) exists only to show the visual style — keep the styling, drop the fakery. - Chrome budget: title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal.
console.*is not a log — use the logger. Helder runs one process per project window, and every window past the first is spawned byspawnInstance()withstdio: 'ignore'; launched from Finder there's no terminal either. Console output is therefore discarded in real use. Log throughsrc/main/logger.ts(main) orsrc/renderer/src/log.ts→rlog(renderer, forwarded over IPC to the same file). Never add a barecatch {}on an IPC/FS/git path: log the cause, then handle it.
Confirmed decisions (the "Open assumptions" in DESIGN.md are resolved — do not re-ask)
- Search overlay layout: content matches left (70%), file-name matches right (30%) — keep as designed.
- Tabs show an unsaved indicator (a dot in place of the close control) because auto-save defaults off (
editor.autoSave). - Explorer right-click offers a file-level Copy reference (project-relative path only), consistent with the editor's Copy reference — in scope.
Scope boundaries (this version)
- Git covers staging, unstaging, committing, and discarding only. Push, pull, fetch, and branch switching are explicitly out of scope. The branch summary bar and status bar are display-only.
- One agent terminal and one shell terminal — no additional tabs or sessions.
- The breadcrumb and status-bar items are display only (not clickable, do not navigate).
- Discard is the only destructive git action and must confirm first (
git.confirmDiscard, default on). Staging/unstaging do not confirm by default.
Configuration
Settings are project-scoped, living in a .helder/ folder in the opened project's root:
.helder/config.json— sparse; only user-overridden values..helder/config.default.json— full defaults, regenerated on launch from built-in defaults (live documentation of every setting; the app never reads user edits from it).- Effective value =
config.jsonif present, elseconfig.default.json, merged key by key. .helder/theme.css— custom CSS theme applied over the built-in dark theme; code font and font size live here, not in the config files.
Logging & crash diagnostics
One file, ~/Library/Logs/Helder/helder.log (rotates at 2 MB, keeps 3), written synchronously so a line survives the process dying right after it. Reachable from Help → Open Log and from the crash panel's Open Log button. Main and renderer both write to it, so a failure reads as one chronological story.
src/main/logger.ts— the sink. Deliberately imports NO electron so it stays unit-testable (test/logger.test.ts);initLogger({dir})is handed the path by the caller. Every process logs its pid, since sibling project windows share the file.src/main/diagnostics.ts—initDiagnostics()runs beforeapp.whenReady()(crashReporter must start early;app.setNamemust precedeapp.getPath('logs')or logs land in~/Library/Logs/Electron). HooksuncaughtException,unhandledRejection,render-process-gone(the blank-window crash),child-process-gone,preload-error,unresponsive, and renderer console warnings/errors. Native minidumps (node-pty can segfault) go toapp.getPath('crashDumps'), local only — nothing is uploaded.src/main/index.ts— thehandle()/on()wrappers aroundipcMain: every IPC failure is logged with channel + args, then rethrown so renderer behaviour is unchanged. Calls over 1 s log aslowwarning. Note both wrappers must callipcMain.handle/ipcMain.on— a rename that rewrites those lines makes the wrappers infinitely recursive and silently registers no handlers at all (every IPC then fails with "No handler registered").src/renderer/src/log.ts—rlog+installErrorLogging()(windowerror,unhandledrejection). Called frommain.tsxbefore first render.ErrorBoundarylogs the component stack, which exists nowhere else.
Keep warnings honest: an expected event must not log as WARN (see killing in pty-service.ts — deliberate kills log INFO). A log full of false alarms is a log nobody reads.
Design tokens
Canonical source is docs/design/README.md plus the board docs/design/Helder IDE.dc.html (all screens at real pixel size) and docs/design/screenshots/. The tokens live in the :root block of src/renderer/src/styles.css.
Four surfaces only: --bg-0 editor/terminal #101720 · --bg-2 panels/chrome #18202B · --sel/--border #232C39 · --hover #1E2733. Text ramp #F4F5F4 → #E4E7E6 → #BAC0C0 → #6C7783 → #3A424C. One accent, amber #E8913A (deep #C4741F, soft #F0B476), reserved for action, state and signal — never a decorative fill. Diff is teal #8FBFB4 added and amber-deep #C4741F removed: no green, no red anywhere. Six muted syntax colours, all weight 400.
Radii: 2px controls · 4px panels/menus · 8px overlays only. Row heights: 24px explorer · 26px source control · 28px status bar · 30px menu item · 34px view strip · 44px title bar. Hairlines are always 1px #232C39; the selection marker is a 2px amber rule.
Row states: hover fills one step and does nothing else; selected takes --sel plus the 2px amber rule. Focus is always the 2px amber border plus a 2px 22% ring. Motion is colour-only, 160ms; the one exception is blink (stepped 1.1s) on the caret, the terminal cursor and the period in the wordmark. Respect prefers-reduced-motion.
The older design_handoff_helder_workbench/ prototype is superseded by docs/design — read it only for behaviour that the new board does not cover.
Suggested implementation order (from README)
- Electron shell + frameless dark window; port tokens to CSS vars; bundle IBM Plex.
- Static layout: four resizable columns + title/status bars.
- Real file tree + open files into tabs (read-only) with Prism highlighting.
- Git panel from
git status(read-only) → staging + commit → the three view modes, the hover original panel, and the full-screen Diff. - Search (ripgrep + fuzzy).
- Terminals via node-pty + xterm.js; run
claudein the agent pane. - Copy reference + Pass-on-to-Agent (clipboard + bracketed-paste into the agent PTY).
Commands
npm run dev— launch the app in Electron with HMR (electron-vite dev).npm run build— type-stripped production build intoout/(electron-vite build). A frontend change is not done until this succeeds.npm run preview/npm start— run the built app (electron-vite preview).npm run typecheck—tsc --noEmitover the renderer (tsconfig.web.json) and main/preload (tsconfig.node.json). The build itself uses esbuild and does NOT type-check, so run this separately to catch type errors.npm test— vitest suite intest/(pure logic + node-side services: diff, fuzzy, highlight, config, fs, git).npm run test:watchfor watch mode.npm run lint— ESLint (flat config ineslint.config.js)..prettierrc.jsondefines formatting (not auto-applied).npm run pack— unpacked app intodist/(electron-builder, unsigned).npm run dist/dist:macfor distributables. App icon comes frombuild/icon.png. Nativenode-pty+rgare asar-unpacked so they load when packaged.
The mac build must be ad-hoc signed — build/adhoc-sign.cjs (the afterPack hook) does this. mac.identity: null skips signing, which leaves the .app carrying only the linker signature Apple put on the prebuilt Electron binary: it reports Identifier=Electron, seals no resources, and does not bind our Info.plist. macOS reads that as a tampered bundle and kills it with "Malware Blocked and Moved to Trash". A real ad-hoc signature over the whole bundle (with build/entitlements.mac.plist for JIT + library validation) fixes it. Still not notarized, so a copy opened from the DMG carries a quarantine flag — clear it with xattr -dr com.apple.quarantine /Applications/Helder.app or ship a Developer ID build.
Keep all five green (typecheck · lint · test · build, and pack when touching main/packaging) when changing code.