Canonical project handbook for pi-deck, read by both humans and AI coding agents.
A friendly desktop and web client for the pi coding agent. pi-deck does not implement an agent loop itself. It embeds pi's AgentSession SDK and renders the result.
- Runtime: Bun (package manager + script runner) and Node 24+ (for things Bun doesn't yet do well, like
node-pty). - Language: TypeScript everywhere. No JavaScript except generated config.
- UI: React 19 + Vite. CSS variables + Tailwind v4 for styling.
- Desktop shell: Electron.
- Agent embedding:
@earendil-works/pi-coding-agentAgentSessionAPI, one Node subprocess per active session. - Renderer & backend transport: WebSocket over localhost. Same protocol works for the standalone web target.
- Diffs:
@pierre/diffs(built on Shiki). - PTY:
@lydell/node-pty(native module, runs in the host / Electron main). Ships per-(os, cpu)prebuilt N-API binaries (no source compile), but only the build host's arch installs — so cross-arch packaging needs a native runner, which is why macOS releases are Apple-Silicon-only (see the release matrix).node-ptyis a runtime fallback. Externalized in the main build +asarUnpacked for packaging. - Terminal renderer:
ghostty-web(WASM) behind a single adapter. - Lint / format: Biome.
- Tests:
bun testfor unit. Playwright for end-to-end (not wired yet).
pi-deck/
├── packages/
│ ├── core/ Shared backend logic: session worker, protocol, git, fs, providers, extensions
│ ├── ui/ Shared React UI (components, stores, theme system)
│ └── desktop/ Electron main process + thin bootstrap
├── plans/ Numbered .md feature plans — execute in order
├── scripts/ Repo automation (release, version bump, etc.)
└── .github/ CI/CD workflows
- pi's own data: sessions stay at pi's default location (
~/.pi/agent/sessions/). pi-deck reads but does not edit these. - pi-deck data:
~/.config/pi-deck/(or platform equivalent via Electron'sapp.getPath("userData")):projects/<project-id>/metadata.json— pi-deck's view of a project (display name, pinned sessions, last opened, sessions map) referencing pi session IDs.themes/— installed JSON themes.providers.json— custom provider endpoints (OpenRouter keys, LM Studio URL, etc.).settings.json— global app preferences.
- Provider data inside
~/.pi/— two documented exceptions where pi-deck writes inside pi's directory:~/.pi/agent/models.json(custom provider registry materialised viamodels-json.ts) and~/.pi/agent/auth.json(API keys via pi'sAuthStorageAPI). Everywhere else, go through pi's API.
The following list of commands are used during the regular development process
bun install # All workspaces
bun run check # Lint + format check + type-check (must be green before commit)
bun run lint
bun run type-check
bun run test
bun run desktop:dev # Electron in dev mode
bun run build # Production buildbun run check is wired into the pre-commit hook via Husky. Don't disable it.
- TypeScript: no
any, no blind casts. If you have to escape the type system, leave a// FIXME(types): whycomment. - Control flow: prefer early returns over nested ternaries. Switch over chained
else iffor finite discriminated unions.
- Components: function components + hooks. Class components only for error boundaries.
- State: Zustand for global stores.
useState/useReducerfor local. No Redux. - Stores: one Zustand store per domain (
useXStore). Stores expose actions and selectors; UI uses selectors only. Never mutate store state from outside the store's actions.
- Absolute imports rooted at the package via tsconfig paths, not deep relative
../../...
- Tailwind v4 utility classes. Custom CSS only for things Tailwind can't express (animations, complex grid).
- Never hardcode colours. Always reference CSS variables from
packages/ui/src/theme/tokens.css. Adding a colour means proposing a new token. - The token list is small on purpose — reuse an existing surface token before adding a new shade.
- VS Code theme compatibility is best-effort, not pixel-perfect. Map to the closest existing token rather than adding tokens to match VS Code-specific keys.
- Compat aliases (
--color-*→--bg-*/--ink-*) intokens.cssare temporary. Remove each alias as the legacy file referencing it migrates.
- kebab-case for files, PascalCase for React components (
SessionsList.tsx), camelCase for hooks (useSessions.ts).
- Route all assistant text through the chat
Markdowncomponent (packages/ui/src/features/chat/messages/Markdown.tsx). It owns Shiki highlighting, autolinking, and the GFM dialect. Don't render raw markdown ad-hoc.
- Import from
packages/ui/src/components/iconsonly. This is the swap point if we change icon libraries. - Extend
GlyphKindinpackages/ui/src/components/glyph/kinds.tsxfor icons not present inlucide-react. If an icon cannot be found, drop in a dot-grid placeholder andTODOit. - File-type icons come from
@pierre/trees' built-in set, for parity with the file tree. The tree renders them itself; light-DOM surfaces (the git sidebar'sChangeRow) usePidPierreFileIcon(packages/ui/src/components/icons/PidPierreFileIcon.tsx), which resolves the path → sprite symbol and colours it viatheme/pierre-file-icons.css(Pierre's palette, vendored + scoped to.pid-pierre-icon; re-extract on upgrade). The oldiconForFile+@iconify-json/material-icon-thememapping was removed.
- All go through
packages/core/src/git/runner.ts. Direct spawning ofgitelsewhere is forbidden. Errors are typed (GitNotFoundError,NotARepoError,GitCommandError).
- Pin exact versions (no
^/ no~) in everypackage.json. The lockfile (bun.lock) must travel in the same commit as any dependency change. - Avoid adding dependencies without a clear reason — especially anything that adds a transitive native module, since those slow down install and Electron packaging.
- No
console.login committed code. Use the structured logger.
- Unit tests run on
bun test.mock.moduleis process-global and can't be reverted — a module one test file mocks stays mocked for every later file in the run, andbun test's file order differs by OS (sorted on Windows, readdir on Linux/CI), so an order-dependent leak can pass locally yet fail in CI. Mock the narrowest seam; if a test needs the real module that a sibling test mocks, import a fresh, separately-keyed copy with a cache-busting query —(await import(${path}?real))— seepackages/ui/test/features/terminal/TerminalRenderer.theme.test.ts.
- Modify pi's source or wrap its protocol in incompatible ways. We are building client on the top of original Pi, not a fork.
- Touch
~/.pi/directly except for the two documented provider files (see Where data lives). Everywhere else, go through pi's API. - Log API keys, OAuth tokens, or session content to disk outside the documented locations. Never log provider credentials in any form — even truncated.
- Send API keys to the renderer. The renderer can request "is provider X authenticated?" but the secret itself stays in the host.
- Hardcode model IDs anywhere outside
packages/core/src/providers/. The registry is the only place that knows model strings; the UI fetches them throughprovider.models. - Expose raw LSP methods to the renderer without the host-side allowlist in
packages/core/src/lsp/server-defs.ts. New methods must be added there explicitly.
- Three processes. Electron main (host), BrowserWindow (renderer), one Node subprocess per active session (worker). Worker is spawned in production via
process.execPathwithELECTRON_RUN_AS_NODE=1. - Renderer & host transport. WebSocket bound to
127.0.0.1only, authenticated with a per-launch token surfaced through the preload bridge. Protocol is versioned (packages/core/src/protocol/version.ts); renderer and host always ship together. - Host & worker transport. LF-delimited JSONL on stdio. Internal and unversioned — workers are spawned from the same binary so they're always in sync.
- Provider system. Built-in providers come from pi-ai's
ModelRegistry. Custom OpenAI-compatible providers are stored in~/.config/pi-deck/providers.jsonand materialised to~/.pi/agent/models.jsonso pi picks them up natively. Secrets live in pi's~/.pi/agent/auth.jsonviaAuthStorage— never copied into pi-deck's directories or the renderer.
- Rails are drag-resizable. Defaults are 264px left / 360px right; users drag the boundary between rail and center (or center and right pane). Widths floor at 200px (left) / 336px (right — the right pane also hosts the IDE-mode docked chat composer); there is no fixed max — each side is clamped against the live window so it can grow but never squeezes the center below
MIN_CENTER_WIDTH(360px).PidAppShellre-clamps on window resize viaclampToWindow(). Persisted viauseRailState(packages/ui/src/layout/use-rail-state.ts, localStorage keypi-deck:rails). The--rail-wand--rightpane-wCSS vars are driven by the store fromPidAppShellso the topbar and body grids stay in sync. - Overlays must portal. Every overlay-shaped component (
Dialog,DropdownMenu,ContextMenu,Tooltip, command palette) renders into adocument.bodyportal so it sits above the.pid-app::beforegrain (z-index: 999,mix-blend-mode: overlay). Non-portaled overlays render below the grain and look muddy. - The footer's screen switcher (
PidScreenSwitcher) toggles the center screen:[SESSION][EDITOR][DIFF][BLANK]in Agent view,[EDITOR][DIFF][BLANK]in IDE view (the docked session tab makes the SESSION button redundant). It readsuseNavStore.screen+usePreferencesStore.viewMode. - View mode (Agent vs IDE).
usePreferencesStore.viewMode("agent"default /"ide") is a global, persisted Appearance preference (Settings → Appearance → View). Agent is the linear session → editor → diff flow. IDE docks the chat as the first right-pane tab ([Session | Git | Context]) beside a center-resident editor, viaPidSessionPane(the shared chat surface, also used by the center router'ssessionroute). NB: distinct from pi's executionagentMode(ask/accept-edits/plan). packages/desktop/src/main/window.tscontrols native title bar config. Don't changetitleBarStyleortitleBarOverlaywithout re-verifying the topbar drag region on all three OSes.
Append new entry points under the matching sub-heading. Keep entries to one line plus a path; multi-paragraph implementation notes belong in code comments, not here.
- Root configs —
package.json,tsconfig.base.json,biome.json,bunfig.toml. All commands flow through Bun. - Pre-commit hook —
.husky/pre-commitrunsbun run check. - CI/CD —
.github/workflows/ci.yml(PR gate) and.github/workflows/release.yml(tag-triggered electron-builder matrix: Windows x64, Linux x64, macOS arm64). Release versioning viascripts/version.ts. - Electron packaging —
packages/desktop/electron-builder.yml.
- Main process —
packages/desktop/src/main/. Dev:bun run desktop:dev. Build:bun run --filter @pi-deck/desktop dist. Window bounds persist touserData/window-state.json. - Preload bridge —
packages/desktop/src/preload/. The renderer's only path to anything OS-level.window.bridge.connect()returns{ url, token }for the host WS. - Security policy —
packages/desktop/src/main/security.tsattaches the strict CSP in packaged builds. Adding a new outbound origin requires editing this file.
- Protocol schemas —
packages/core/src/protocol/.Frame,Command, event topic types and zod schemas. Protocol version constant inversion.ts. Any new command or event updates this folder. - Transport client (renderer) —
packages/ui/src/lib/transport/. WS client + typed protocol client. All renderer-side calls to the host go through here. - Event router —
packages/ui/src/lib/transport/event-router.ts. The singlerouteEventfunction is the only event subscriber and dispatches into the stores.
- Host entry —
packages/core/src/host/. Owns the WS server, session manager, and metadata store. Entry:startHost(). Auth token is generated per app launch and passed via the preload bridge. - Session persistence —
packages/core/src/host/metadata-store.ts. Per-projectmetadata.jsoncarries theProjectrecord and asessionsmap (SessionMetadataper id: title, branch snapshot, archived flag, lastActivityAt, sessionFile).SessionManager.rehydrateProjectbuilds stub records on firstsession.listso the rail paints without spawning workers. - Plan file watcher —
packages/core/src/host/plan-file-watcher.ts. Watches${projectPath}/.pi-deck/plans/${sessionId}.md; emitsEVENT_PLAN_FILE_CHANGED. Distinct from the fs-tree watcher because it must react to content-only changes thatfs.tree.changeddeliberately ignores. - Git watch manager —
packages/core/src/host/git-watch-manager.ts. Per-project watchers broadcastinggit.status.changed. - FS watch manager —
packages/core/src/host/fs-watch-manager.ts. Broadcastsfs.tree.changeddeltas. - Turn tracker —
packages/core/src/host/turn-tracker.ts. Records file paths against the active session whenever a file-mutating tool succeeds (in-memory; cleared onsession.deactivateand worker exit). Consumed by the git sidebar's recent-touch dot. - Artefacts tracker —
packages/core/src/host/artefacts-tracker.ts. Narrower than the turn tracker — only fires forwrite/create/create_file/file_write, and uses anexistsBeforesnapshot on tool-call start to filter out edits of existing files. Emitssession.artefacts.changed. - Theme storage —
packages/core/src/host/themes/. Bundled JSON inbundled/; user themes at<userData>/themes/. Chokidar emitstheme.changed; if the active theme's spec changed on disk, the new spec ships in the event payload so the renderer re-applies without a round-trip. VS Code colour themes are translated to a full pi-deckThemeSpecviavscode-adapter.tson disk-read (the raw VS Code JSON is preserved so Shiki can use it directly). - Provider manager —
packages/core/src/host/provider-manager.ts. Orchestratespackages/core/src/providers/.
- Session worker —
packages/core/src/worker/. One Node subprocess per active session, hosting pi'sAgentSession. Bundled topackages/desktop/dist/worker.js. - Agent bridge —
packages/core/src/worker/agent-bridge.ts.forwardEventextractsmessage.usageand callssession.getContextUsage()for usage events.
- Registry —
packages/core/src/providers/. Built-ins come from pi-ai'sModelRegistry. Custom OpenAI-compatible providers materialise to~/.pi/agent/models.jsonviamodels-json.ts. - Auth bridge —
packages/core/src/providers/auth-bridge.ts. API keys live in pi's~/.pi/agent/auth.jsonviaAuthStorage. pi-deck never persists keys itself, never sends them to the renderer, and only materialises them at request time when pi's session resolves a provider. The renderer only ever sees anAuthState(authenticated/needs-key/unreachable).
- Shell components —
packages/ui/src/layout/PidAppShell.tsx(+PidTopBar,PidBody,PidLeftRail,PidRightPane,PidFooter). Layout CSS inpackages/ui/src/theme/shell.css. Resizable rails per App shell rules. The previous shell is preserved atpackages/ui/src/layout/AppShell.legacy.tsx(+ siblings) and is unwired. - Center router —
packages/ui/src/layout/PidCenterRouter.tsx. ReadsuseNavStore.screenand renders overview / session (chat or inline-intro) / editor / git-diff. Thesessionroute delegates toPidSessionPane(packages/ui/src/layout/PidSessionPane.tsx— the shared chat surface: composer when no active session, else loading / inline-intro /ChatView). Theeditorscreen renders the CodeMirror editor (see Code editor);git-diffthe diff view. In IDE view mode the router coerces asessionscreen toeditor(the session is docked in the right pane instead). - Right pane —
packages/ui/src/layout/PidRightPane.tsx. Tabs persisted inuse-right-pane.ts(useRightPaneStore, keypi-deck:rightpane). Agent view:[Git | Context]. IDE view: a leadingSessiontab (rendersPidSessionPane) →[Session | Git | Context], defaulting to Session and refocusing it when the active session changes. - Nav store —
packages/ui/src/lib/useNavStore.ts. Single source of truth for the center screen + per-project expand state. Persists underpi-deck:nav:v1. Transient screens coerce back tosessionon rehydrate. Settings stays a modal — it is not a nav route.
- Root —
packages/ui/src/features/chat/.ChatViewownsMessageList+MessageInput. Messages live inuseMessagesStorekeyed by session id. - Composer state —
packages/ui/src/features/chat/composer/useComposerStore.ts(persisted Zustand underpi-deck:composer) holds the execution mode. Model + thinking level moved touseProvidersStore(per-session). Bottom-bar controls:ExecutionModeMenu.tsx,ModelMenu.tsx,ContextUsageIndicator.tsx. - Image attachments — clipboard paste (Ctrl/Cmd+V), drag-drop, and an "Attach image…" popover entry stage images alongside file/folder chips. Pipeline in
packages/ui/src/features/chat/composer/useImagePaste.ts(paste/drop, 10 MB cap, 256 px thumbnail via OffscreenCanvas); lightbox atImagePreviewDialog.tsx. Full bytes travel viaSessionPromptRequest.images; only the downscaled thumbnail is persisted ontoUserMessageEntry.images. Filesystem-sourced images flow throughwindow.bridge.readImage(path)(Electron main IPC atbridge:readImage). - MessageList auto-scroll — pin to latest uses
virtualizer.scrollToIndex(last, { align: "end" }), notel.scrollTop = el.scrollHeight(the latter lands above the real bottom because@tanstack/react-virtualreports a stalegetTotalSize()before dynamic rows are measured). The rawscrollTopwrite is reserved for restoring a saved offset. - Tool-call rendering —
packages/ui/src/features/chat/tools/. Renderers register intoToolRendererRegistryviaregisterToolRenderer(name, component). Built-in renderers cover all pi default tools. - Markdown extensions —
packages/ui/src/features/chat/markdown/CheckboxItem.tsx. Swaps GFM checkboxes into the shared<Markdown>component (powers plan-mode checklists). - Message context menu —
packages/ui/src/features/chat/messages/MessageContextMenu.tsx. RadixContextMenu. Actions: Copy text, Copy as Markdown, Attach selection to next prompt (viauseDraftStore). Tool-call cards are intentionally not selectable.
- Overview —
packages/ui/src/features/sessions/overview/.PidSessionsOverviewis the default landing screen; sections lazy-load viauseSessionsStore.loadProjectSessions(projectId). Cards (PidSessionCard) link back to the session route. - Rail —
packages/ui/src/features/sessions/PidSessionsList.tsxand friends (PidSessionRow,PidProjectSwitcher,PidNewSessionButton). Project expand state lives inuseNavStore.expandedProjectsRail. Rows rendersession.branch(snapshot taken at create time viacurrentBranch) and accept right-click for Archive / Delete. Archived sessions aggregate into a syntheticARCHIVEgroup at the bottom vialoadArchivedSessions(). - Stores —
useProjectsStore,useSessionsStore,useMessagesStoreunderpackages/ui/src/features/sessions/andpackages/ui/src/features/chat/. - Lifecycle commands —
session.archive/session.unarchive/session.delete/session.listArchivedinpackages/core/src/protocol/commands.ts. Delete aborts the worker, removes the in-memory record, drops the session fromProject.sessionIds, deletes pi'ssessionFilefrom disk, and clearsactiveSessionIdin the UI if it was the open session. - New-session shortcut —
packages/ui/src/features/sessions/useNewSessionShortcut.ts. Global Cmd/Ctrl+N, suppressed inside editable elements. Mounted once inApp.tsx.
- PlanCard —
packages/ui/src/features/chat/messages/PlanCard.tsx. Renders plan-shaped assistant messages, detected byagentModeAtTurn === "plan"(stamped on bubble creation from the composer store; falls back to the session's persisted mode for resumed sessions) plus a GFM- [ ] / - [x]checkbox in the body. Footer is two controls (deliberately not a split-button): aModeTargetPickerpill that updatesusePlanStore.lastApprovalwithout approving, and a primaryApprove & executebutton that firessession.approvePlanwith the persisted selection. Stale plans (any assistant turn that isn't the latest) hide the footer. - Plan-card review comments — right-click a text selection in the latest plan card → "Comment the selection" (added to
MessageContextMenuvia itscommentTargetprop) opens an inline composer anchored to the selection. Comments batch in the in-memoryusePlanCommentsStore(keyed by sessionId, tagged with the anchor messageId; not persisted) and stay highlighted via the CSS Custom Highlight API (PlanCommentLayer+planCommentAnchoroffset helpers — anchored by character offsets into the[data-plan-card-body]textContent, not DOM nodes). They do not approve the plan: the footer'sRequest changes (N)button sends them as one plan-mode reply (composeCommentsMessage→sendPrompt(text, { agentMode: "plan" })), which makes the agent revise the plan file, then clears the comments.Approve & executestays independent. - PlanPanel —
packages/ui/src/features/plan-panel/PlanPanel.tsx. Built and live-updates from the store but not currently mounted — slated for Context-tab integration with "open in file manager" + "download as markdown" affordances. - Approval pills — inline tool approvals (for
askmode andaccept-editsallowlist misses) attach aspendingApproval?onToolCallEntryand render viaApprovalPillon the tool-call card, resolved throughsession.toolApproval.
- Model picker —
packages/ui/src/features/models/. Opened from the chat-headerModelBadge(aPidChip-style button onChatHeader.tsx) or the composer'sModelMenu.ModelPicker.tsxportals a two-column modal (providers left, models right) using.pid-modal-backdrop+.pid-modalchrome fromcomponents.css. - Per-session selection —
useProvidersStore.sessionSelectionstores model + thinking level, persisted toproviders.json, forwarded to the live worker viasession.setModel/session.setThinkingLevelcommands. - Usage tracking —
packages/ui/src/features/chat/useUsageStore.tsreads per-turnusage+contextUsagefromEVENT_SESSION_TURN_END. The per-category breakdown inContextUsageIndicatoris derived renderer-side from the messages store because pi'sContextUsageis aggregate-only.
- Tab —
packages/ui/src/features/context/. Right-pane tab surfacing (1) the segmented context-window bar driven bycontextBreakdown.ts(shared with the composer'sContextUsageIndicatorring tooltip), (2) "in scope" — deduped attachments aggregated fromUserMessageEntry.attachmentsin the current session, (3) "artefacts produced" —useArtefactsStorerows plus the session's plan-mode markdown when present. Row actions route towindow.bridge.openPath(system default app) andwindow.bridge.showItemInFolder(reveal in file manager).
- Tokens —
packages/ui/src/theme/tokens.css. Descriptive names (--bg-0..3,--ink-0..3,--accent*,--add/del/mod,--diff-*). - Loader & Shiki bridge —
packages/ui/src/theme/loader.tsapplies the active theme as inline custom properties on<html>.shiki-bridge.tskeeps syntax highlighting aligned: when a VS Code theme is active the bridge feeds Shiki the original VS Code JSON for key-for-key tokenisation; otherwise it picks a bundled Shiki theme by light/dark kind. - Bundled palettes — four self-contained flavours: Forge (orange dark, default), Obsidian (dark), Almanac (light), Sandstone (light). Each theme JSON specifies its full palette inline. Canonical Zod schema lives in
packages/core/src/protocol/theme.tsand is re-exported via@pi-deck/core. - Renderer prefs —
packages/ui/src/theme/usePreferencesStore.ts. Density (compact/cozy), font-pair (default/sans-only/mono-only), andviewMode(agent/ide — see App shell rules). Persisted to localStorage underpi-deck:prefs. Density + fonts are hydrated pre-mount via the inline script inpackages/desktop/index.htmlso the first paint matches the user's preference;viewModeis React-structural so it hydrates with the store (no inline script).
- Overlay —
packages/ui/src/features/settings/. Opens via Cmd/Ctrl+, (useSettingsHotkey) or the topbar gear (PidTopBar). State inuseSettingsStore(open/section, not persisted). Appearance section wired againstuseThemeStore/usePreferencesStore. Theme import uses thetheme.importhost command +bridge:openFileIPC.
- Screens —
packages/ui/src/features/intro/. Two near-identical surfaces share thetemplates.tsset:PidComposerScreenis theblankroute (and the no-active-session fallback) wired byPidCenterRouter;PidIntroScreenis the empty-state landing (PidSessionsOverview,variant="fullscreen") and the inline empty-session view (variant="inline-empty-session"). Both render the hero/composer (bound touseIntroComposerStore), 6 template cards, and a recents strip. NB: theblankroute isPidComposerScreen, NOTPidIntroScreen— changes to the template cards must be made in both components. - Editable templates — defaults live in
templates.ts; per-id overrides (title / blurb / body) persist viauseTemplatesStore(localStoragepi-deck:templates:v1) and merge throughresolveTemplate. Implemented in bothPidComposerScreenandPidIntroScreen. Each card opensEditTemplateDialogtwo ways: a hover-revealed pencil button (.pid-composer-template-edit/.pid-intro-template-edit, the reliable primary affordance — a real sibling button, not nested in the card) and a right-clickContextMenu("Edit template…" / "Reset to default" when overridden). The 6 slots are fixed — no add/remove.
- Operations —
packages/core/src/git/. All git operations go throughrunner.ts(no JS-git library). Repo detection viadetect.ts, status viastatus.ts, recent commits vialog.ts, file watching viawatcher.ts, init viainit.ts.files.tsfileAtHead(commandgit.fileBaseline) returns a path's HEAD contents — the code editor's diff-gutter baseline;nullfor untracked / non-repo. - Sidebar UI —
packages/ui/src/features/git/. Subscribes togit.status.changed/git.turnTouches.changed.GitSidebarcomposesBranchHeader(Radix dropdown that runs checkout),ChangesList(flat list + diffbar),RecentCommitsList, and theEmptyStateInit button. Per-project state extendsuseGitStorewithstatusByProject,commitsByProject,touchesBySession.
- Walker & watcher —
packages/core/src/fs/. Walker (walker.ts) + watcher (watcher.ts) feed the files-tab tree. The fs watcher is retained on purpose — it keeps the tree live while the agent mutates files mid-turn (Pierre doesn't poll disk; the renderer pushes watcher deltas into the model). Respects.gitignore+.git/info/exclude. CRUD ops inops.ts(createFile/createFolder/rename/move/trashPaths); deletes go throughshell.trashItem(injected from the desktop bridge viasetTrashImpl). All paths validated against project root to block traversal. The code editor reads/writes file contents viaops.tsreadTextFile/writeTextFile(commandsfs.readFile/fs.writeFile):readTextFiledetects EOL, strips a UTF-8 BOM, LF-normalises, and flags binary (NUL sniff) / oversized files;writeTextFilere-applies the stored EOL. - File tree UI —
packages/ui/src/features/files/. Rendered by@pierre/trees(PidFileTree.tsxbuilds auseFileTreemodel; the library owns rendering, virtualization, keyboard nav, search, file-type icons, inline rename, and in-tree drag-to-move). pi-deck is the data feed + glue:useFileTreeStoreloads the host walk and applies watcher deltas,pierreTreeAdapters.tsflattensFsNode[]→ path list / maps git status tosetGitStatus/ bridges the theme viathemeToTreeStyles, and mutations route to the host (fs.rename/fs.move/fs.createFile/fs.createFolder/fs.delete). The row context menu (PidTreeContextMenu) and filter input (PidTreeSearch, backed by Pierre search — notfuse.js) are pi-deck-styled; the "Attach to chat" action feeds the composer attachment pipeline (application/x-pideck-pathsMIME viauseComposerPathDrop, shared byMessageInput.tsxandPidComposerScreen.tsx).
- Editor view —
packages/ui/src/features/editor/. A real, editable CodeMirror 6 editor on nav screeneditor.PidEditorViewcomposes the tab strip (PidEditorTabBar), styled breadcrumb (PidEditorBreadcrumb), and theCodeMirrorEditorhost. Files open from a single-click in the file tree (PidFileTree'sonSelectionChange→useEditorStore.openFile+setScreen("editor")). - State —
useEditorStore.ts: open tabs + active id + per-tab content/baseline/eol/dirty/cursor/readOnly/indent. Loads viafs.readFile+git.fileBaseline; saves viafs.writeFile(Ctrl/Cmd+S). The store holds tab metadata only — CodeMirrorEditorStates are cached per tab insideCodeMirrorEditorso switching tabs preserves undo history, selection, and scroll. - Theme + highlighting —
editorTheme.tsmaps CodeMirror chrome + aHighlightStyle(lezer tags) onto theme tokens, so syntax colours track the active pi-deck theme (thedarkflag is reconfigured via a compartment on light/dark flips). Languages by extension inlanguages.ts(also the tab type-badge). - Diff gutter —
diffExtension.tspaints live add/mod/del line tints against the git HEAD baseline using@codemirror/merge'sChunkdiff primitive (no inline-original merge UI). Recomputes as you type;nullbaseline (untracked / no repo) shows no tints. Hovering a block's gutter thickens its bar; clicking the gutter opens a pinned floating toolbar (PidDiffBlockToolbar) — prev/next change, revert-block (undoable buffer edit viarevertDiffChunk), open-in-Diff — dismissed on outside-click / Escape / scroll / edit / tab switch. Inline per-block commit is deferred — it needs hunk-level git staging. - Status bar —
PidEditorStatus.tsx, rendered inPidFooteronly on theeditorscreen: cursor Ln/Col + selection, indentation, UTF-8, LF/CRLF, language.
Pid*primitives —PidButton,PidIconButton,PidChip,PidKbdinpackages/ui/src/components/{buttons,chip,kbd}/. Style rules inpackages/ui/src/theme/components.css. Other primitives (inputs, selects, table rows) ship as later plans need them — no speculative scaffolding.- Radix wrappers —
Dialog,Tabs,Tooltip,DropdownMenu,ContextMenu,Spinnerkeep their current implementation; restyle via className rather than rewriting. PreviousButton/IconButtonare renamedButton.legacy.tsx/IconButton.legacy.tsx— new code must use thePid*primitives.
- Backend —
packages/core/src/terminal/.TerminalManager(index.ts) owns the live PTYs (one perterminalId) in the host / Electron main, independent of pi sessions;pty.tsloads@lydell/node-pty(fallbacknode-pty);shells.tsdoes OS-aware shell detection (Windows pwsh/PowerShell/Git Bash/cmd with PATHEXT + WSL handling; macOS/Linux from$SHELL+ defaults);buffer.tsis a byte-capped output ring for repaint. Output is batched (~8 ms) and flow-controlled (pause/resumepast a high-water mark, with a[output throttled]hint).terminal.*commands route throughhost/router.ts;terminal.output/terminal.exitevents broadcast like any other.shutdownAll()(called fromhost.close()) kills every PTY on quit — no zombies. - UI —
packages/ui/src/features/terminal/. A toggleable, vertically-resizable bottom dock (TerminalDock→TerminalPane→TerminalView), not a main-panel tab.TerminalRenderer.tsis the ghostty-web adapter;terminalOutput.tsis a pub/sub that writes PTY bytes straight into the emulator (bypassing React state);useGhosttyTheme.tsmaps the active pi-deck theme to the emulator palette. Toggle via the left-rail Terminal button orCtrl/⌘+\``. Panel open/height + isolated tab set persist **per pi-deck session** (useTerminalStore, runtimeterminalIdstripped on persist). New terminals open in the active session's project root, inheriting its git branch. Settings live infeatures/settings/sections/TerminalSection.tsx+useTerminalSettingsStore`. - Deferred: pi ↔ terminal chat mirroring (a follow-up; plan-mode behaviour will be mirror-read-only + approve in the chat PlanCard).
- Host manager —
packages/core/src/lsp/.LanguageServerManager(manager.ts) spawns one server per(projectId, serverId)in the host, JSON-RPC over stdio (vscode-jsonrpc). Nothing is bundled: servers are detected on PATH (environment.ts, async probes, cached per app run); projects rooted at\\wsl.localhost\<distro>detect and spawn inside the distro viawsl.exe -d <distro> -- sh -lc. Idle-GC'd after the lastdidClose; restarted transparently when a reloaded renderer re-initializes;shutdownAll()runs fromhost.close()— no orphans. - Protocol passthrough —
lsp.status/lsp.ensure/lsp.request/lsp.notify/lsp.shutdowncommands +lsp.message/lsp.diagnostics/lsp.serverStatusevents (packages/core/src/protocol/lsp.ts). The renderer's@codemirror/lsp-clientowns the LSP session (initialize handshake, document sync); the host is a method-allowlisted pipe that pinsrootUri/workspaceFolderson the in-flightinitializeand intercepts$/cancelRequest(host request ids differ from the renderer's). URIs stay server-form end to end; the renderer maps deck paths ↔ URIs viapackages/core/src/lsp/uri.tsusing themappingfromlsp.ensure. - CodeMirror client —
packages/ui/src/features/editor/lsp/.useLspStorelazily ensures a server per tab language and owns theLSPClients; the per-tablspCompartment(extension.ts) swaps built-in completion ↔ the LSP feature set (server completion, hover, signature help,@codemirror/lintdiagnostics + gutter, rename/definition/references keymaps).workspace.tsroutes cross-file go-to-definition intouseEditorStore.openFile. Missing server → footer hint + Settings → Editor install hint; crash → one notification, silent fallback to built-in completion. Per-server enable toggles persist inuseLspSettingsStore(localStorage).
The renderer & host protocol is versioned (packages/core/src/protocol/version.ts). When you change a command or event payload shape:
- Bump the protocol version.
- Update the schema in
protocol/frames.tsor sibling files. - The renderer sends the version on connect; the host rejects mismatches with a clear error. This means renderer and host must always ship together (no skew across an auto-update).
- The host & worker stdio protocol is internal and unversioned — workers are spawned from the same binary so they're always in sync.
contextIsolation: true,nodeIntegration: false,sandbox: true.- The renderer never has direct Node access. Anything OS-level goes through the preload bridge.
- A localhost-only WebSocket is the renderer ↔ backend transport. The server binds to
127.0.0.1only and authenticates with a short-lived token generated at app start. - No remote content loaded into the main BrowserWindow. External links open in the OS browser via
shell.openExternal. - Content Security Policy. In packaged builds the main process attaches a strict CSP via
session.defaultSession.webRequest.onHeadersReceived(packages/desktop/src/main/security.ts). No'unsafe-eval';connect-srcwhitelists only127.0.0.1;frame-ancestors 'none'. Vite HMR needs'unsafe-eval'in dev so we suppress Electron's security warning (the warning itself notes it never fires in packaged builds). Adding a new outbound origin requires editingsecurity.ts.
When pi adds a new built-in provider (or we surface one that pi supports but we previously hid):
- Add a
BuiltInProviderDefentry toBUILT_IN_PROVIDERSinpackages/core/src/providers/built-ins.tswith the rightauthJsonKey(matches pi's~/.pi/agent/auth.jsonkey) andenvVar(matches pi-ai'senv-api-keys.ts). - Add a small monochrome
<svg>glyph inpackages/ui/src/features/models/icons/index.tsx. - Smoke-test by authenticating, picking a model, sending a prompt.
No code generation, no manifest dance. Custom OpenAI-compatible endpoints don't need any of this — users add them at runtime via the picker.
- Add the token to every bundled theme JSON in
packages/core/src/host/themes/bundled/. - Add a fallback to the VS Code adapter's translation table in
packages/core/src/host/themes/vscode-adapter.tsso imported themes still resolve.
- Create a component under
packages/ui/src/features/chat/tools/. - Call
registerToolRenderer(name, component)so it lands inToolRendererRegistry.
Before opening a PR:
bun run checkis green — lint, format, and type-check. The pre-commit hook already runs this; don't bypass it.- Plan alignment — the change matches the file-by-file outline of the relevant plan.
- Acceptance criteria are hand-verified. CI green is necessary but not sufficient.
- Dependency hygiene — any new dependency is pinned exact-version (no
^/ no~), andbun.lockis committed in the same change. - Stage explicit paths. When concurrent agent sessions are plausible, prefer
git add <path>overgit add .so an unrelated worktree change doesn't slip into the commit.