Skip to content

xiaozisong/CenturyCode

Repository files navigation

CentryCode

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.

Quick start

# 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 build

The 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.

Architecture

┌──────────────────────────────────────────────────────────────────┐
│ 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          │
└──────────────────────────────────────────────────────────────────┘

Packages

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

Built-in tools

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.

OpLog & undo/redo

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.

UI features

  • Markdown renderingmarked + marked-shiki, with a Shiki highlighter lazily created for bash, typescript, javascript, python, markdown, css, go, rust, json, html using the github-dark theme.
  • 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 (running amber, pending-confirm orange, failed red, success green). pending-confirm status surfaces Apply/Reject buttons that resolve the IPC Deferred<boolean>. On success, the first 200 chars of JSON.stringify(result) are shown.
  • File attachments — drop a text file onto the textarea and its contents are queued as a FileContent context part; paste an image and its base64 is queued as an ImageAttachment. Both are appended to the next user message.
  • Auto-scroll — the message list scrolls to bottom on messages.length change.

Tech stack

  • Desktop: Electron 42, electron-vite 5
  • UI: React 18, CSS Modules, lucide-react icons
  • Paradigm: Effect — Context.Tag services, Layer composition, Stream, Schema, Deferred
  • AI: openai 6 streaming chat completions
  • Markdown: marked 18 + marked-shiki 1.2 + shiki 4 (theme github-dark)
  • Diffs: diff 9 (createTwoFilesPatch exported from @minimal/core as computeFileDiff)
  • Types: TypeScript 5.6, tsgo --noEmit for project checks

Configuration

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_MODEL and BASE_URL are currently inert — openai-provider.ts only reads OPENAI_API_KEY. Wire them into new OpenAI({ apiKey, baseURL }) and model: req.model ?? process.env.OPENAI_MODEL ?? "gpt-4o" to actually use them.

Known limitations

  • No persisted sessions — SessionService is a stub; list() returns [], get() always fails, update() is a no-op. Chat history lives only in React state.
  • delete_file is in the high-risk set but has no registered handler — a model that calls it will receive a "Tool not registered" error.
  • ContextService and buildProjectContext are exported from @minimal/core but not provided in makeMainRuntime, so the agent never scans the project on its own.
  • The renderer transmits "pending-confirm" and "rejected" status strings that are not in Part.ToolCallPart's schema literal ("running" | "success" | "failed"); the renderer casts via as any to extend it.

License

MIT

About

Code Agent + IDE

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors