ZenTerminal Documentation

An Electrobun desktop app with a React main view, AI chat integration, and isolated artifact terminal sessions. Learn how to build cross-platform desktop applications with modern web technologies.

Electrobun 1.16.0 React 18.3 TypeScript 5.7

Introduction #

ZenTerminal is a desktop application built with Electrobun— a framework for creating cross-platform desktop apps using web technologies. Unlike Electron, Electrobun uses Bun as the runtime and offers a lightweight, modern approach to desktop development.

💡 What is Electrobun?

Electrobun is a desktop application framework that combines Bun's fast JavaScript runtime with web technologies (HTML, CSS, React). It's designed to be lightweight and modern, offering native performance without the overhead of Chromium.

Key Features

  • AI Chat Integration — Connect to Ollama-compatible providers for intelligent conversations
  • Terminal Artifacts — Create isolated PTY sessions for running shell commands
  • Hot Module Replacement — Fast development with Vite HMR support
  • Type-Safe RPC — Bidirectional communication between main and renderer processes
  • Effect-Based — Functional programming patterns with the Effect library

Quick Start #

  1. Install dependencies
    bun install
  2. Development with HMR (recommended)
    bun run dev:hmr

    This starts the Vite dev server on port 5173 and launches Electrobun with hot reloading.

  3. Build for production
    bun run build:prod
⚠️ Important

Electrobun is NOT Electron. Do not use Electron APIs or patterns. Refer to the Electrobun documentation for the correct APIs.

Project Structure #

├── src/
├── bun/ # Main process (Bun runtime)
├── index.ts # Entry point, window & RPC setup
└── artifact-command.ts # Bash command execution
├── mainview/ # Renderer process (React)
├── App.tsx # Main React component
├── main.tsx # React entry point
├── terminal-ui.tsx # Terminal UI components
├── terminal-program.ts # Effect-based program logic
├── hooks/ # Custom React hooks
├── ui/ # UI components
└── hocs/ # Higher-order components
└── shared/ # Shared between processes
├── types.ts # RPC type contracts
└── ollama.ts # Ollama API utilities
├── electrobun.config.ts # Electrobun configuration
├── vite.config.ts # Vite build configuration
├── tailwind.config.js # Tailwind CSS configuration
└── package.json

Architecture Overview #

ZenTerminal follows a multi-process architecture common to desktop applications. The main process (Bun) handles system operations, while the renderer process (React) manages the user interface. They communicate via a type-safe RPC system.

Main Process
Bun Runtime
  • Window management
  • PTY sessions
  • AI streaming
  • File system
Renderer Process
React + WebView
  • UI rendering
  • State management
  • User input
  • XTerm terminal UI
⟷ RPC Communication ⟷
Messages
One-way notifications
Requests
Call-response pattern
Streaming
Real-time data flow

Main Process (Bun) #

The main process runs in Bun and has access to system APIs. It's responsible for:

  • Creating and managing BrowserWindow instances
  • Spawning PTY sessions for terminal artifacts
  • Handling AI provider connections via Ollama
  • Managing application lifecycle events

Import Pattern

// Main process imports from
              'electrobun/bun'
            import { BrowserWindow, BrowserView } from "electrobun/bun";

            // Create a window
            const mainWindow = new BrowserWindow({
            title: "Terminal",
            url: "views://mainview/index.html",
            frame: {
            x: 200,
            y: 200,
            width: 900,
            height: 700,
            },
            });

Terminal Session Management

Terminal sessions are created using Bun's native Bun.Terminal API, which provides PTY functionality:

const terminal = new Bun.Terminal({
            cols: 80,
            rows: 24,
            name: "xterm-256color",
            data(term, data) {
            // Handle terminal output
            const decoded = textDecoder.decode(data);
            sendToWebview("terminalOutput", { artifactId, data: decoded });
            },
            exit(term, exitCode, signal) {
            // Handle process exit
            handleSessionClosed(session, exitCode, signal);
            },
            });

            const proc = Bun.spawn({
            cmd: ["/bin/bash", "+m"],
            terminal,
            env: { ...Bun.env, TERM: "xterm-256color" },
            });

Renderer Process (React) #

The renderer runs in a WebView and uses React for UI. It imports from electrobun/view to communicate with the main process.

Import Pattern

// Renderer imports from
              'electrobun/view'
            import { Electroview } from "electrobun/view";

            // Create RPC connection
            const electroview = new Electroview({
            rpc: Electroview.defineRPC<TerminalRPC>({
            handlers: {
            messages: {
            "*": (messageName, payload) => {
            // Handle messages from main process
            }
            }
            }
            }),
            });

Custom Hooks

ZenTerminal uses several custom hooks to manage state and RPC:

Hook Purpose
useElectroview Manages RPC connection to main process
useMessages Handles chat message state and streaming
useArtifacts Manages terminal artifact state
useProvider Handles AI provider connection

RPC System #

The RPC (Remote Procedure Call) system enables bidirectional communication between the main process and renderer. It's fully typed using TypeScript.

Message Types

Main → Renderer
artifactStarted
terminalOutput
terminalClosed
aiChatResponse
Renderer → Main
artifactCreate
artifactInput
aiChatMessage
aiSetProvider

Type Contract

Types are defined in src/shared/types.ts and shared between both processes:

export type TerminalRPC = {
            bun: {
            messages: {
            artifactStarted: {
            artifactId: string;
            command: string;
            createdAt: number
            };
            terminalOutput: {
            artifactId: string;
            data: string
            };
            terminalClosed: {
            artifactId: string;
            exitCode?: number;
            signal?: string
            };
            };
            requests: {
            streamAIChat: {
            params: {
            history: AIChatHistoryMessage[];
            model: string;
            apiUrl: string;
            };
            response: { success: boolean; error?: string
            };
            };
            };
            };
            webview: {
            messages: {
            artifactCreate: {
            artifactId: string;
            command: string;
            cols: number;
            rows: number
            };
            aiChatMessage: {
            history: AIChatHistoryMessage[];
            timestamp: number
            };
            };
            };
            };

AI Integration #

ZenTerminal integrates with Ollama-compatible AI providers using the ai SDK and ollama-ai-provider-v2. The AI can execute bash commands through the runBash tool.

AI Provider Setup

import { createOllama } from "ollama-ai-provider-v2";
            import { streamText, tool } from "ai";

            const ollama = createOllama({
            baseURL: "http://localhost:11434/api",
            });

            const result = streamText({
            model: ollama.chat("llama3.2"),
            messages: history,
            system: "You are ZenTerminal, an assistant running inside a local desktop terminal
              app.",
            tools: { runBash },
            });

The runBash Tool

The AI can execute bash commands in isolated terminal artifacts:

const runBash = tool({
            description: "Run a bash command in a fresh interactive terminal artifact",
            inputSchema: z.object({
            command: z.string().min(1).describe("The
              bash command to execute."),
            }),
            execute: async ({ command }, {
            abortSignal }) => {
            return runArtifactCommand(command,
            abortSignal);
            },
            });
🎯 Usage Pattern

Messages without a ! prefix are sent to the AI provider. Messages with a ! prefix create isolated terminal artifact sessions with their own PTYs.

Terminal Sessions #

Terminal sessions are isolated PTY (pseudo-terminal) instances that run shell commands. Each artifact gets its own session with independent I/O.

Session Lifecycle

  1. Create — WebView sends artifactCreate message
  2. Spawn — Main process spawns shell with PTY
  3. Stream — Output flows back to WebView via terminalOutput
  4. Input — User input sent via artifactInput
  5. Close — Process exits, terminalClosed notification sent

TerminalSession Interface

interface TerminalSession {
            artifactId: string;
            process: Subprocess | null;
            terminal: Bun.Terminal | null;
            isRunning: boolean;
            pid: number | null;
            inputQueue: string[]; // Queued input before
              ready
            outputBuffer: string; // Accumulated output
            observers: Set<TerminalSessionObserver>;
            }

Observer Pattern

Sessions support observers for decoupled event handling:

type TerminalSessionObserver = {
            onOutput?: (data: string) => void;
            onClosed?: (info: { exitCode?: number;
            signal?: string }) => void;
            onError?: (message: string) => void;
            };

            // Usage in AI tool execution
            const observer: TerminalSessionObserver = {
            onClosed: ({ exitCode }) => {
            const transcript = truncateArtifactOutput(session?.outputBuffer ?? "");
            finish({ command, exitCode, output: transcript.output });
            },
            };

Artifact System #

Artifacts represent isolated command executions. They're displayed as cards in the UI and can be opened in a modal terminal view.

Artifact Model

interface Artifact {
            id: string; // Unique identifier
            command: string; // The command being
              executed
            outputBuffer: string; // Accumulated terminal
              output
            status: "running" | "completed" | "error";
            createdAt: number; // Timestamp
            }

useArtifacts Hook

function useArtifacts() {
            const [artifacts, setArtifacts] = useState<Record<string, Artifact>>({});

            // Generate unique artifact ID
            const generateArtifactId = useCallback(() =>
            `artifact-${artifactIdRef.current++}`,
            []);

            // Update artifact output and optionally status
            const updateArtifact = useCallback((artifactId, output, status?) => {
            setArtifacts((prev) => {
            const artifact = prev[artifactId];
            if (!artifact) return prev;
            return {
            ...prev,
            [artifactId]: {
            ...artifact,
            outputBuffer: artifact.outputBuffer + output,
            ...(status && { status }),
            },
            };
            });
            }, []);

            // Disconnect and clean up
            const disconnectArtifact = useCallback((artifactId, ...) => {
            // Send close message to main process
            sendFn("artifactClose", { artifactId });
            // Update local state
            setArtifacts((prev) => ({ ...update... }));
            }, []);

            return { artifacts, updateArtifact, disconnectArtifact, ... };
            }

State Management #

ZenTerminal uses React hooks for state management. Messages and artifacts are stored in separate hooks that work together in the main App component.

Message Types

type MessageKind = "user" | "system" | "agent" | "artifact" | "thinking";

            interface Message {
            id: number;
            kind: MessageKind;
            content: string;
            thinking?: string; // AI reasoning content
            thinkingActive?: boolean; // Is AI currently
              thinking
            artifactId?: string; // Link to artifact
            command?: string; // Command for artifacts
            status?: "running" | "completed" | "error";
            }

useMessages Hook

Handles chat history with support for streaming AI responses:

function useMessages() {
            const [messages, setMessages] = useState<Message[]>([]);
            const streamMessageRefs = useRef({
            agent: null, thinking: null });

            // Add a complete message
            const addMessage = useCallback((content, kind = "system") => {
            setMessages((prev) => [...prev, { id: logIdRef.current++, kind, content
            }]);
            }, []);

            // Append to streaming message (for AI responses)
            const appendStreamingMessage = useCallback((kind, { delta, content }) => {
            setMessages((prev) => {
            const activeId = streamMessageRefs.current[kind];
            const activeIndex = prev.findIndex(m =>
            m.id === activeId);

            if (activeIndex >= 0) {
            // Update existing streaming message
            const next = [...prev];
            next[activeIndex] = { ...next[activeIndex], content: content ?? next[activeIndex].content + delta };
            return next;
            }

            // Create new streaming message
            const newId = logIdRef.current++;
            streamMessageRefs.current[kind] = newId;
            return [...prev, { id: newId, kind, content: content ?? delta }];
            });
            }, []);

            return { messages, addMessage, appendStreamingMessage, ... };
            }

Effect Patterns #

ZenTerminal uses the Effect library for functional programming patterns. Effect provides type-safe error handling, composable effects, and better async flow control.

Basic Effect Usage

import { Effect, Data } from "effect";

            // Define error types
            class EmptySubmissionError extends Data.TaggedError("EmptySubmissionError")<{}> {}
            class EmptyArtifactCommandError extends Data.TaggedError("EmptyArtifactCommandError")<{}> {}

            // Create effect
            const parseSubmission = (rawInput: string):
            Effect.Effect<
            Submission,
            EmptySubmissionError | EmptyArtifactCommandError,
            never
            > => {
            const trimmed = rawInput.trim();
            if (!trimmed) {
            return Effect.fail(new EmptySubmissionError());
            }
            return Effect.succeed({ _tag: "ShellCommand", input: trimmed, kind: "user"
            });
            };

            // Run effect
            Effect.runSync(parseSubmission(input));

Pipelines

Effects compose using pipelines for clean, readable async flows:

export const submitChatInput =
            (options): Effect.Effect<void, never, never> =>
            parseSubmission(options.rawInput).pipe(
            Effect.flatMap((submission) =>
            Effect.sync(() => {
            if (submission._tag === "ArtifactCommand") {
            const artifact = options.createArtifact(submission.command);
            options.startArtifactSession({ artifactId: artifact.id, ... });
            } else {
            options.addMessage(submission.input, submission.kind);
            }
            })
            ),
            Effect.catchTags({
            // Handle specific error types
            EmptySubmissionError: () => Effect.void,
            EmptyArtifactCommandError: () => Effect.void,
            })
            );
📚 Learn More

Effect is a powerful library for building type-safe, composable applications. Visit effect.website for comprehensive documentation and examples.

Electrobun Basics #

Electrobun is the framework that powers ZenTerminal. Understanding its core concepts is essential for extending the application.

Configuration

App configuration lives in electrobun.config.ts:

import type { ElectrobunConfig } from "electrobun";

            export default {
            app: {
            name: "react-tailwind-vite",
            identifier: "reacttailwindvite.electrobun.dev",
            version: "0.0.1",
            },
            build: {
            // Copy Vite output to Electrobun bundle
            copy: {
            "dist/index.html": "views/mainview/index.html",
            "dist/assets": "views/mainview/assets",
            },
            // Ignore dist in watch mode (HMR handles separately)
            watchIgnore: ["dist/**"],
            mac: { bundleCEF: false },
            linux: { bundleCEF: false },
            },
            } satisfies ElectrobunConfig;

URL Schemes

  • views:// — Load bundled view assets (e.g., views://mainview/index.html)
  • http://localhost:5173 — Vite dev server for HMR development

Development Modes

Command Mode Hot Reload
bun run dev Production bundle No (manual rebuild)
bun run dev:hmr Vite dev server Yes (instant)

Ollama Setup #

ZenTerminal connects to Ollama-compatible AI providers. The provider URL is normalized to ensure compatibility.

URL Normalization

export function resolveOllamaApiConfig(input: string): OllamaApiConfig {
            const url = new URL(input.trim());

            const normalizedPath = url.pathname.replace(/\/+$/, "");
            if (!normalizedPath || normalizedPath === "/") {
            url.pathname = "/api";
            } else if (!normalizedPath.endsWith("/api")) {
            url.pathname = `${normalizedPath}/api`;
            }

            return {
            ok: true,
            normalizedUrl: url.toString().replace(/\/$/, ""),
            tagsUrl: `${normalizedUrl}/tags`,
            };
            }

Examples

Input Normalized
http://localhost:11434 http://localhost:11434/api
https://api.example.com/v1 https://api.example.com/v1/api
🔌 Provider Support

Any Ollama-compatible API works, including Ollama itself, OpenRouter, and other providers that implement the Ollama chat API format.

Hooks API Reference #

useElectroview

Manages RPC connection between renderer and main process.

const { sendMessage } = useElectroview({
            onArtifactStarted: (payload) => { ... },
            onTerminalOutput: (payload) => { ... },
            onTerminalClosed: (payload) => { ... },
            onTerminalError: (payload) => { ... },
            onAiChatResponse: (payload) => { ... },
            });

useMessages

Manages chat message history with streaming support.

const {
            messages,
            addMessage,
            appendStreamingMessage,
            breakStreamingMessage
            } = useMessages();

useArtifacts

Manages terminal artifact state.

const {
            artifacts,
            updateArtifact,
            disconnectArtifact,
            generateArtifactId
            } = useArtifacts();

useProvider

Manages AI provider connection state.

const {
            connectedProvider,
            connectProvider,
            disconnectProvider
            } = useProvider();

UI Components #

Components are located in src/mainview/ui/ and follow a terminal-inspired design aesthetic.

Component Purpose
ArtifactCard Displays terminal artifact with status and preview
ConnectButton Provider connection status indicator
ProviderModal Modal for configuring AI provider
StreamMessage Renders chat messages with syntax highlighting
CodeBlock Syntax-highlighted code display
StatusBadge Visual status indicators (running, completed, error)

Utilities #

ANSI Handling

Terminal output often contains ANSI escape codes for colors and formatting. The src/shared/ansi.ts module provides utilities for working with ANSI sequences.

Artifact Output Truncation

When sending terminal output to the AI, long outputs are truncated to avoid token limits:

export function truncateArtifactOutput(
            output: string,
            maxLength: number = 8000
            ): { output: string; truncated: boolean } {
            if (output.length <= maxLength) { return
              { output, truncated: false };
              }
              return {
              output: output.slice(0, maxLength) +
              "\n[...output truncated]",
              truncated: true
              };
              }