ai newsSa., 31. Okt.
EilmeldungOpenAI Signals Shows Information Seeking Nearly Matches Writing in ChatGPT

Building a Local Agent Menu Bar: Routing Claude, Codex, and Open-Source Models Without Lock-In

Originally published on tamiz.pro . Most AI coding agents are great at what they do, but they’re also silos. Claude Code lives in its own terminal, Codex in another, and your local Ollama models are a separate world. If you want to switch between them without losing context, you end up copy-pasting

dev.toSamstag 19:02 · 31.10.13 Min. Lesezeit
Bild: dev.to

Posted on Oct 31 • Originally published at tamiz.pro

Most AI coding agents are great at what they do, but they’re also silos. Claude Code lives in its own terminal, Codex in another, and your local Ollama models are a separate world. If you want to switch between them without losing context, you end up copy-pasting prompts and losing the thread. I built a small menu bar app that routes a single prompt to any agent through a unified interface — and you can build the same thing in about an hour. Here’s the full walkthrough.

The app is an Electron menu bar utility with three layers:

The key design decision is a provider interface. Every agent (Claude, Codex, Ollama) implements the same contract:

interface AgentProvider { id: string; // 'claude' | 'codex' | 'ollama' label: string; buildCommand ( prompt: string, opts: RunOptions ): string []; parseOutput ( chunk: string ): string; // normalize streamed text } Enter fullscreen mode Exit fullscreen mode This keeps the UI completely agnostic. Adding a new agent means writing one provider file — no changes to the renderer or the tray logic.

Create a new directory and initialize a minimal Electron + Vite + React setup:

mkdir agent-menu-bar && cd agent-menu-bar npm init -y npm install --save-dev electron electron-builder vite @vitejs/plugin-react typescript npm install react react-dom npx tsc --init Enter fullscreen mode Exit fullscreen mode Set up the folder structure:

agent-menu-bar/ ├── src/ │ ├── main/ │ │ ├── index.ts │ │ ├── tray.ts │ │ ├── providers/ │ │ │ ├── index.ts │ │ │ ├── claude.ts │ │ │ ├── codex.ts │ │ │ └── ollama.ts │ │ └── runner.ts │ ├── preload/ │ │ └── index.ts │ └── renderer/ │ ├── index.html │ ├── main.tsx │ └── App.tsx ├── package.json └── tsconfig.json Enter fullscreen mode Exit fullscreen mode 4. Step 2: Build the Provider Abstraction Create the shared types and the provider registry. First, src/main/providers/index.ts:

export interface RunOptions { cwd: string; model?: string; // for Ollama systemPrompt?: string; maxTokens?: number; } export interface AgentProvider { id: string; label: string; buildCommand ( prompt: string, opts: RunOptions ): string []; parseOutput ( chunk: string ): string; } Enter fullscreen mode Exit fullscreen mode Now implement claude.ts. Claude Code accepts a prompt via -p and streams to stdout. We use --output-format stream-json for structured streaming, then parse each JSON line:

import { AgentProvider, RunOptions } from './index '; export const claudeProvider: AgentProvider = { id: ' claude ', label: ' Claude Code ', buildCommand ( prompt: string, opts: RunOptions ): string [] { return [ ' claude ', ' -p ', prompt, ' --output-format ', ' stream-json ', ' --verbose ' ]; }, parseOutput ( chunk: string ): string { // Claude stream-json emits lines like: {"type":"assistant","message":{"content":"..."}} const lines = chunk. split ( ' \n ' ). filter ( Boolean ); return lines. map ( line => { try { const obj = JSON. parse ( line ); if ( obj. type === ' assistant ' && obj. message?. content ) { return obj. message. content; } return ''; } catch { return ''; } }). join ( '' ); } }; Enter fullscreen mode Exit fullscreen mode Next, codex.ts. Codex has a non-interactive mode with exec:

import { AgentProvider, RunOptions } from './index '; export const codexProvider: AgentProvider = { id: ' codex ', label: ' Codex CLI ', buildCommand ( prompt: string, opts: RunOptions ): string [] { return [ ' codex ', ' exec ', ' --full-auto ', prompt ]; }, parseOutput ( chunk: string ): string { // Codex outputs plain text to stdout return chunk; } }; Enter fullscreen mode Exit fullscreen mode Finally, ollama.ts. Ollama isn’t a CLI agent in the same sense — it’s a local HTTP server. But we can wrap it as a provider by calling its REST API via curl or by using the ollama run command. I’ll use ollama run with --format json for clean streaming:

import { AgentProvider, RunOptions } from './index '; export const ollamaProvider: AgentProvider = { id: ' ollama ', label: ' Ollama (Local) ', buildCommand ( prompt: string, opts: RunOptions ): string [] { const model = opts. model?? ' llama3.2 '; return [ ' ollama ', ' run ', model, prompt, ' --format ', ' json ' ]; }, parseOutput ( chunk: string ): string { // Ollama JSON streaming emits lines like: {"response":"...","done":false} const lines = chunk. split ( ' \n ' ). filter ( Boolean ); return lines. map ( line => { try { const obj = JSON. parse ( line ); return obj. response?? ''; } catch { return ''; } }). join ( '' ); } }; Enter fullscreen mode Exit fullscreen mode Now create a registry to look up providers by ID:

import { claudeProvider } from './claude '; import { codexProvider } from './codex '; import { ollamaProvider } from './ollama '; import { AgentProvider } from './index '; export const providers: Record < string, AgentProvider > = { claude: claudeProvider, codex: codexProvider, ollama: ollamaProvider }; export function getProvider ( id: string ): AgentProvider { const p = providers [ id ]; if (! p ) throw new Error ( `Unknown provider: ${ id } ` ); return p; } Enter fullscreen mode Exit fullscreen mode 5. Step 3: Create the Menu Bar Tray In src/main/tray.ts, create a Tray with an icon and a context menu. The tray is the primary interaction point — clicking it opens the agent window, and the menu offers quick actions:

import { Tray, Menu, nativeImage, app } from ' electron '; import { BrowserWindow } from ' electron '; export function createTray ( win: BrowserWindow ): Tray { // Use a 16x16 or 32x32 PNG. For production, bundle a proper template image. const icon = nativeImage. createFromPath ( app. isPackaged? ` ${ process. resourcesPath } /trayTemplate.png`: ' assets/trayTemplate.png ' ); icon. setTemplateImage ( true ); // macOS: adapts to light/dark menu bar const tray = new Tray ( icon ); tray. setToolTip ( ' Agent Router ' ); const contextMenu = Menu. buildFromTemplate ([ { label: ' Open Agent Router ', click: () => win. show () }, { type: ' separator ' }, { label: ' Quit ', click: () => app. quit () } ]); tray. setContextMenu ( contextMenu ); // Left-click toggles the window (Windows/Linux) tray. on ( ' click ', () => { if ( win. isVisible ()) { win. hide (); } else { win. show (); win. focus (); } }); return tray; } Enter fullscreen mode Exit fullscreen mode 6. Step 4: Wire Up IPC and Process Spawning Now the heart of the app — src/main/runner.ts.

This module spawns the provider’s command, streams stdout/stderr back to the renderer, and supports cancellation:

import { spawn, ChildProcess } from ' child_process '; import { ipcMain, BrowserWindow } from ' electron '; import { getProvider } from './providers '; import { RunOptions } from './providers '; let activeProcess: ChildProcess | null = null; export function registerRunnerIPC ( win: BrowserWindow ) { ipcMain. handle ( ' agent:run ', async ( _event, { providerId, prompt, options }) => { if ( activeProcess ) { throw new Error ( ' An agent is already running. Cancel it first. ' ); } const provider = getProvider ( providerId ); const args = provider. buildCommand ( prompt, options as RunOptions ); // Spawn the process with stdio pipes for streaming activeProcess = spawn ( args [ 0 ], args. slice ( 1 ), { cwd: options?. cwd?? process. cwd (), env: {... process. env, FORCE_COLOR: ' 0 ' } }); activeProcess. stdout. on ( ' data ', ( data: Buffer ) => { const text = provider. parseOutput ( data. toString ()); if ( text ) { win. webContents. send ( ' agent:output ', text ); } }); activeProcess. stderr. on ( ' data ', ( data: Buffer ) => { const text = data. toString (); win. webContents. send ( ' agent:error ', text ); }); activeProcess. on ( ' close ', ( code ) => { win. webContents. send ( ' agent:done ', code ); activeProcess = null; }); return { ok: true }; }); ipcMain. handle ( ' agent:cancel ', () => { if ( activeProcess ) { activeProcess. kill ( ' SIGTERM ' ); activeProcess = null; return { ok: true }; } return { ok: false }; }); } Enter fullscreen mode Exit fullscreen mode Now the preload script src/preload/index.ts — expose a typed API to the renderer:

import { contextBridge, ipcRenderer } from ' electron '; contextBridge. exposeInMainWorld ( ' agentAPI ', { run: ( payload: { providerId: string; prompt: string; options?: any }) => ipcRenderer. invoke ( ' agent:run ', payload ), cancel: () => ipcRenderer. invoke ( ' agent:cancel ' ), onOutput: ( callback: ( text: string ) => void ) => { const listener = ( _: any, text: string ) => callback ( text ); ipcRenderer. on ( ' agent:output ', listener ); return () => ipcRenderer. removeListener ( ' agent:output ', listener ); }, onError: ( callback: ( text: string ) => void ) => { const listener = ( _: any, text: string ) => callback ( text ); ipcRenderer. on ( ' agent:error ', listener ); return () => ipcRenderer. removeListener ( ' agent:error ', listener ); }, onDone: ( callback: ( code: number ) => void ) => { const listener = ( _: any, code: number ) => callback ( code ); ipcRenderer. on ( ' agent:done ', listener ); return () => ipcRenderer. removeListener ( ' agent:done ', listener ); } }); Enter fullscreen mode Exit fullscreen mode 7. Step 5: Build the React UI In src/renderer/App.tsx, create the main component. It manages provider selection, prompt input, streaming state, and cancellation:

import { useEffect, useRef, useState } from ' react '; const PROVIDERS = [ { id: ' claude ', label: ' Claude Code ' }, { id: ' codex ', label: ' Codex CLI ' }, { id: ' ollama ', label: ' Ollama (Local) ' } ]; export default function App () { const [ providerId, setProviderId ] = useState ( ' claude ' ); const [ prompt, setPrompt ] = useState ( '' ); const [ output, setOutput ] = useState ( '' ); const [ running, setRunning ] = useState ( false ); const outputRef = useRef < HTMLDivElement > ( null ); useEffect (() => { const offOutput = window. agentAPI. onOutput (( text ) => { setOutput ( prev => prev + text ); }); const offError = window. agentAPI. onError (( text ) => { setOutput ( prev => prev + `\n[ERROR] ${ text } ` ); }); const offDone = window. agentAPI. onDone (( code ) => { setRunning ( false ); setOutput ( prev => prev + `\n\n--- Exited with code ${ code } ---` ); }); return () => { offOutput (); offError (); offDone (); }; }, []); useEffect (() => { if ( outputRef. current ) { outputRef. current. scrollTop = outputRef. current. scrollHeight; } }, [ output ]); const handleRun = async () => { if (! prompt. trim () || running ) return; setRunning ( true ); setOutput ( '' ); await window. agentAPI. run ({ providerId, prompt, options: { cwd: process. cwd () } }); }; const handleCancel = async () => { await window. agentAPI. cancel (); setRunning ( false ); }; return ( < div className = "app" > < header > < select value = { providerId } onChange = { e => setProviderId ( e. target. value ) } > { PROVIDERS. map ( p => ( < option key = { p. id } value = { p. id } > { p. label } </ option > )) } </ select > < button onClick = { running? handleCancel: handleRun } disabled = {! prompt. trim () } > { running? ' Cancel ': ' Run ' } </ button > </ header > < textarea value = { prompt } onChange = { e => setPrompt ( e. target. value ) } placeholder = "Type your prompt..." rows = { 4 } /> < div className = "output" ref = { outputRef } > { output || ' Output will appear here... ' } </ div > </ div > ); } Enter fullscreen mode Exit fullscreen mode 8. Step 6: Streaming Output and Cancellation Streaming works through the IPC events we set up. The renderer accumulates chunks into state, and the main process forwards every parsed chunk.

Cancellation kills the child process with SIGTERM, which is the cleanest way to stop a CLI agent mid-stream. Note that some agents (especially Claude Code) may need SIGINT instead — you can make the kill signal configurable in the provider interface if you need that granularity.

One subtle bug to avoid: if the user switches providers mid-run, the old process keeps streaming. The activeProcess guard in runner.ts prevents concurrent runs, but you should also clear the output when the provider changes. Add this effect:

useEffect (() => { if (! running ) { setOutput ( '' ); } }, [ providerId ]); Enter fullscreen mode Exit fullscreen mode 9. Step 7: Global Hotkey and Packaging Register a global shortcut so you can summon the window from anywhere. In src/main/index.ts:

import { globalShortcut } from ' electron '; app. whenReady (). then (() => { //... create window and tray globalShortcut. register ( ' CommandOrControl+Shift+Space ', () => { if ( win. isVisible ()) { win. hide (); } else { win. show (); win. focus (); } }); }); Enter fullscreen mode Exit fullscreen mode For packaging, add a build script to package.json using electron-builder. Here’s a minimal config:

{ "scripts": { "dev": "vite", "build": "tsc && vite build", "dist": "npm run build && electron-builder" }, "build": { "appId": "com.example.agentmenubar", "productName": "Agent Menu Bar", "files": [ "dist/**/*", "src/main/**/*", "src/preload/**/*" ], "mac": { "category": "public.app-category.developer-tools" }, "win": { "target": "nsis" }, "linux": { "target": "AppImage" } } } Enter fullscreen mode Exit fullscreen mode 10. Production Best Practices Validate provider availability: Before running, check if the CLI binary exists ( which claude, which codex, which ollama ) and show a friendly error in the UI. Persist settings: Use electron-store to remember the last provider and working directory.

Handle long-running processes: Add a timeout (e.g. 10 minutes) and kill the process if it exceeds the limit. Secure IPC: Never pass arbitrary shell commands from the renderer. The provider interface whitelists commands; the renderer only sends providerId and prompt.

Cross-platform paths: Use path.join() instead of string concatenation for the tray icon path. Logging: Write agent stderr to a log file ( app.getPath('userData') + '/logs' ) for debugging. 11.

Frequently Asked Questions Q: Can I add a new agent without touching the UI?

Yes. Implement the AgentProvider interface in a new file, register it in providers/index.ts, and add it to the PROVIDERS array in App.tsx. That’s it — the UI and IPC logic remain unchanged.

Q: Why not just use a terminal multiplexer like tmux?

A menu bar app gives you a persistent, always-available surface with a native feel — hotkey access, tray icon, and a clean UI. tmux is great for terminal workflows but doesn’t integrate with the OS menu bar or global shortcuts without extra tooling.

Q: How do I handle agents that require interactive TTY?

Some agents (like older versions of Claude Code) need a pseudo-terminal. You can spawn them with node-pty instead of child_process.spawn. The provider interface stays the same; only the runner changes. This is a good extension point if you hit TTY-related issues.

This pattern — a provider abstraction, a thin IPC layer, and a tray-based UI — gives you a genuinely useful tool that treats every AI agent as a pluggable backend. No more copy-pasting between terminals. You can find the full source code for this project on Tamiz's Insights or explore more developer tooling guides at tamiz.pro.

Quelle: dev.to (dev.to). Original öffnen