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.
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.
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 #
-
Install dependencies
bun install -
Development with HMR (recommended)
bun run dev:hmrThis starts the Vite dev server on port 5173 and launches Electrobun with hot reloading.
-
Build for production
bun run build:prod
Electrobun is NOT Electron. Do not use Electron APIs or patterns. Refer to the Electrobun documentation for the correct APIs.
Project Structure #
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.
- Window management
- PTY sessions
- AI streaming
- File system
- UI rendering
- State management
- User input
- XTerm terminal UI
Messages
Requests
Streaming
Main Process (Bun) #
The main process runs in Bun and has access to system APIs. It's responsible for:
- Creating and managing
BrowserWindowinstances - 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
artifactStartedterminalOutputterminalClosedaiChatResponse
artifactCreateartifactInputaiChatMessageaiSetProvider
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);
},
});
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
- Create — WebView sends
artifactCreatemessage - Spawn — Main process spawns shell with PTY
- Stream — Output flows back to WebView via
terminalOutput - Input — User input sent via
artifactInput - Close — Process exits,
terminalClosednotification 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,
})
);
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 |
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
};
}