Minimal OpenCode-style AI coding assistant built with Electron + React + Effect.
CentryCode is a desktop AI pair-programmer: a chat window connected to an OpenAI-compatible LLM. The model can read and write files in your workspace, list directories, and run shell commands. Risky operations are gated behind a two-phase user confirmation, and every write_file is recorded in an in-memory OpLog so you can undo/redo from the UI.
# Install dependencies (npm workspaces resolve @minimal/* automatically)
npm install
# Provide credentials via env
export OPENAI_API_KEY=sk-...
export OPENAI_MODEL=gpt-4o # optional, defaults to gpt-4o
export BASE_URL=https://api.openai.com/v1 # optional, defaults to OpenAI
# Run the desktop app in dev mode
npm run dev
# Type-check the whole monorepo
npm run typecheck
# Build production artifacts
npm run buildThe Electron main process reads WORKSPACE_ROOT (defaults to process.cwd()) and pins the agent's file tools to that directory. Set WORKSPACE_ROOT=/path/to/project to scope file operations to a specific project.
┌──────────────────────────────────────────────────────────────────┐
│ Electron main (src/main/index.ts) │
│ ├── makeMainRuntime(WORKSPACE_ROOT) │
│ │ OpenAIProvider + ToolServiceLayer(provided w/ OpLog) │
│ │ + OpLog + stubSessionService │
│ ├── IPC: chat:stream → runAgentChat (effet runtime) │
│ │ chat:cancel → resolves cancel promise │
│ │ chat:confirm → resolves Deferred<boolean> │
│ │ session:undo → OpLog.popUndo + reverseOp + applyOp │
│ │ session:redo → OpLog.popRedo + reverseOp + applyOp │
└──────────────────────────────────────────────────────────────────┘
▲ IPC "chat:chunk" / "chat:done"
▼
┌──────────────────────────────────────────────────────────────────┐
│ Preload (src/preload/index.ts) │
│ contextBridge → window.api │
│ streamChat(prompt) : AsyncIterable<AgentStreamChunk> │
│ cancelStream() : void │
│ confirmTool(approved) : void │
└──────────────────────────────────────────────────────────────────┘
▲ window.api
▼
┌──────────────────────────────────────────────────────────────────┐
│ Renderer (src/renderer, React 18) │
│ App → ChatUI │
│ ├─ MessageList → MessageBubble → PartRenderer │
│ │ TextPartView (Markdown + Shiki) │
│ │ ToolCallPartView (Apply/Reject when pending) │
│ │ ReasoningPartView (collapsible, shimmer on empty) │
│ │ FilePartView (inline image / path label) │
│ └─ Composer → ChatTextarea + ComposerActions │
└──────────────────────────────────────────────────────────────────┘
| Package | Purpose |
|---|---|
@minimal/schema |
Effect Schema definitions: Session, Message, Part, Tool, Context |
@minimal/core |
runAgentChat loop, LLMService (OpenAI), ToolService (builtins + OpLog wrapping), OpLog undo/redo, buildProjectContext scanner, computeFileDiff |
@minimal/app |
Solid.js alt UI — declared but NOT wired into the build; safe to ignore |
The agent loop exposes four file/shell tools to the model. All paths are resolved under WORKSPACE_ROOT and rejected if they escape that root (safePath).
| Tool | Risk | Confirmation behavior |
|---|---|---|
read_file |
low | none |
write_file |
medium | emits tool-confirm-required with { path, oldContent, newContent } diff |
list_dir |
low | none |
run_command |
high | emits tool-confirm-required; user must Apply before spawn runs |
delete_file |
high | listed in HIGH_RISK_TOOLS, but currently has no registered handler |
The agent runs up to maxIterations = 5 rounds. On rejection, a synthetic user message (Tool "X" was rejected by the user.) is pushed so the LLM can recover gracefully. On success, a synthetic user message containing the JSON-encoded tool result is pushed so the next round sees the outcome.
ToolService wraps the write_file builtin: before delegating to the underlying handler it calls snapshotFile(resolved) to capture the prior contents (or null if the file didn't exist). After the write succeeds, an Op is recorded:
type Op =
| { kind: "write"; path: string; oldContent: string | null; newContent: string }
| { kind: "delete"; path: string; oldContent: string }session:undo pops the undo stack, computes reverseOp(op), applies it, and pushes the original onto the redo stack. session:redo does the inverse. The renderer can call these to revert any write_file the agent performed.
- Markdown rendering —
marked+marked-shiki, with a Shiki highlighter lazily created forbash, typescript, javascript, python, markdown, css, go, rust, json, htmlusing thegithub-darktheme. - Reasoning cards — LLM reasoning parts render in a collapsible amber card. While the model is still thinking (empty content) the header shows a shimmering "Thinking..." label.
- Tool call cards — color-coded by status (
runningamber,pending-confirmorange,failedred,successgreen).pending-confirmstatus surfaces Apply/Reject buttons that resolve the IPCDeferred<boolean>. On success, the first 200 chars ofJSON.stringify(result)are shown. - File attachments — drop a text file onto the textarea and its contents are queued as a
FileContentcontext part; paste an image and its base64 is queued as anImageAttachment. Both are appended to the next user message. - Auto-scroll — the message list scrolls to bottom on
messages.lengthchange.
- Desktop: Electron 42, electron-vite 5
- UI: React 18, CSS Modules,
lucide-reacticons - Paradigm: Effect —
Context.Tagservices,Layercomposition,Stream,Schema,Deferred - AI:
openai6 streaming chat completions - Markdown:
marked18 +marked-shiki1.2 +shiki4 (themegithub-dark) - Diffs:
diff9 (createTwoFilesPatchexported from@minimal/coreascomputeFileDiff) - Types: TypeScript 5.6,
tsgo --noEmitfor project checks
| Env var | Used by | Default |
|---|---|---|
OPENAI_API_KEY |
OpenAIProvider |
— (falls back to a stub stream that prints a hint) |
WORKSPACE_ROOT |
src/main/index.ts |
process.cwd() |
OPENAI_MODEL |
not yet read by OpenAIProvider |
provider falls back to gpt-4o |
BASE_URL |
not yet read by OpenAIProvider |
OpenAI SDK default |
Note:
OPENAI_MODELandBASE_URLare currently inert —openai-provider.tsonly readsOPENAI_API_KEY. Wire them intonew OpenAI({ apiKey, baseURL })andmodel: req.model ?? process.env.OPENAI_MODEL ?? "gpt-4o"to actually use them.
- No persisted sessions —
SessionServiceis a stub;list()returns[],get()always fails,update()is a no-op. Chat history lives only in React state. delete_fileis in the high-risk set but has no registered handler — a model that calls it will receive a "Tool not registered" error.ContextServiceandbuildProjectContextare exported from@minimal/corebut not provided inmakeMainRuntime, so the agent never scans the project on its own.- The renderer transmits
"pending-confirm"and"rejected"status strings that are not inPart.ToolCallPart's schema literal ("running" | "success" | "failed"); the renderer casts viaas anyto extend it.
MIT