JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog

Build a Code Editor Like VS Code, Part 4: The Terminal

AdminJune 25, 20267 min read
Build a Code Editor Like VS Code, Part 4: The Terminal

On this page

  • The two halves of a terminal
  • Installing the pieces
  • A new IPC shape: streaming, not asking
  • The main process: spawning a shell
  • The preload bridge: a subscription, not a value
  • The renderer: drawing the terminal
  • A word on React strict mode
  • Try it
  • Where this leaves us

On this page

  • The two halves of a terminal
  • Installing the pieces
  • A new IPC shape: streaming, not asking
  • The main process: spawning a shell
  • The preload bridge: a subscription, not a value
  • The renderer: drawing the terminal
  • A word on React strict mode
  • Try it
  • Where this leaves us
PreviousBuild a Code Editor Like VS Code, Part 3: The File ExplorerNextBuild a Code Editor Like VS Code, Part 5: Saving, Dirty State, and Tabs

Build a Code Editor Like VS Code, Part 4: The Terminal

The terminal is the part that makes this feel like a real editor instead of a fancy text box. In this part we fill the bottom pane with a working shell: type commands, run programs, see colored output, all inside our app.

This is the most involved part of the series so far, for two reasons. We introduce a native module, node-pty, which behaves differently from a normal npm package and needs an extra build step. And we move past the request-and-response IPC from Part 3 into a genuine two-way stream, because a terminal is a conversation, not a single question.

The two halves of a terminal

A terminal in an app is really two cooperating pieces.

The first is a pseudoterminal, or PTY. This is an operating system feature: a fake terminal device that a shell process like bash or zsh or PowerShell attaches to, believing it's talking to a real terminal. It's what lets programs do things like detect width for wrapping, or show a progress bar that updates in place. In Node we get one through node-pty, and because spawning processes is privileged, this lives in the main process.

The second is the renderer, the visible grid of characters that draws output and captures your keystrokes. That's xterm.js, the same terminal frontend VS Code uses, and it runs in the UI.

The two are wired together by a stream of bytes. Whatever you type goes from xterm.js to the PTY's input. Whatever the shell prints comes back from the PTY to xterm.js, which draws it. Our job is to run that stream across the IPC bridge.

Installing the pieces

xterm.js ships as scoped packages now, the core terminal plus a fit addon that sizes it to its container.

bash
npm install @xterm/xterm @xterm/addon-fit
npm install node-pty

node-pty is a native module. It contains C++ that has to be compiled against the exact version of Node that Electron embeds, which is usually not the same as the Node on your machine. So after installing it, rebuild it for Electron.

bash
npm install --save-dev @electron/rebuild
npx electron-rebuild -f -w node-pty

If you skip this step, the app will start and then throw an error the moment it tries to load node-pty, complaining about a wrong NODE_MODULE_VERSION. That error message is the signature of a native module built for the wrong runtime, and the rebuild command is the fix.

Two more things make native modules behave. node-pty must be in dependencies, not devDependencies, so it gets packaged with the app later. And it must not be bundled by Vite, since you can't bundle a compiled binary. The good news is that the electron-vite template already handles the second point: its config uses externalizeDepsPlugin() for the main process, which leaves everything in dependencies external instead of trying to bundle it. As long as node-pty is a real dependency, it's left alone.

A new IPC shape: streaming, not asking

In Part 3 every interaction was a question with one answer, which is what ipcMain.handle and ipcRenderer.invoke are built for. A terminal doesn't fit that mold. The shell sends output whenever it wants, including long after you typed anything, so there's no single response to wait for.

For that we use the other half of Electron IPC: send and on. These are fire-and-forget messages that flow in one direction and can be sent any number of times. We use them in both directions:

  • Renderer to main: pty:start, pty:input, pty:resize.
  • Main to renderer: pty:data for output, sent over and over as the shell prints.

That pair of one-way channels is what turns IPC into a duplex stream.

The main process: spawning a shell

Add a terminal module that owns the PTY. It listens for a start message, spawns the right shell for the platform, and then pipes the PTY's output back to whoever started it.

src/main/terminal.ts:

ts
import { ipcMain } from 'electron'
import * as pty from 'node-pty'
import os from 'os'
 
const shell =
  os.platform() === 'win32' ? 'powershell.exe' : process.env.SHELL || 'bash'
 
export function registerTerminal(): void {
  let term: pty.IPty | null = null
 
  ipcMain.on('pty:start', (event, size: { cols: number; rows: number }) => {
    if (term) return // already running for this session
 
    term = pty.spawn(shell, [], {
      name: 'xterm-color',
      cols: size.cols,
      rows: size.rows,
      cwd: os.homedir(),
      env: process.env as Record<string, string>
    })
 
    // Every chunk the shell prints gets pushed to the renderer.
    term.onData((data) => event.sender.send('pty:data', data))
    term.onExit(() => {
      term = null
    })
  })
 
  ipcMain.on('pty:input', (_event, data: string) => {
    term?.write(data)
  })
 
  ipcMain.on('pty:resize', (_event, size: { cols: number; rows: number }) => {
    term?.resize(size.cols, size.rows)
  })
}

Then call registerTerminal() once in the app.whenReady() block in src/main/index.ts, next to the file handlers from Part 3.

A note on scope. We keep a single PTY for the session, guarded so a duplicate start message is ignored. That's all we need for one terminal in one window. Supporting many terminals later means keeping a map of PTYs keyed by an id instead of a single variable, but the channels stay the same.

The preload bridge: a subscription, not a value

The file handlers in Part 3 each returned a promise. The terminal is different, because pty:data arrives repeatedly. So instead of returning a value, the bridge exposes a way to subscribe to output, and it hands back an unsubscribe function so the renderer can stop listening when the terminal unmounts.

There's also a security detail. The raw IPC event object carries references we don't want to leak into the UI, so the bridge unwraps it and passes the renderer only the data string, never the event itself.

Extend the api object in src/preload/index.ts:

ts
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import type { DirEntry } from '../shared/types'
 
type Size = { cols: number; rows: number }
 
const api = {
  // ...the file functions from Part 3 stay here...
  openFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:openFolder'),
  readDir: (path: string): Promise<DirEntry[]> => ipcRenderer.invoke('fs:readDir', path),
  readFile: (path: string): Promise<string> => ipcRenderer.invoke('fs:readFile', path),
 
  pty: {
    start: (size: Size): void => ipcRenderer.send('pty:start', size),
    input: (data: string): void => ipcRenderer.send('pty:input', data),
    resize: (size: Size): void => ipcRenderer.send('pty:resize', size),
    onData: (callback: (data: string) => void): (() => void) => {
      const listener = (_event: unknown, data: string): void => callback(data)
      ipcRenderer.on('pty:data', listener)
      // Unwrap the event; never hand the raw IPC event to the renderer.
      return () => ipcRenderer.removeListener('pty:data', listener)
    }
  }
}
 
if (process.contextIsolated) {
  contextBridge.exposeInMainWorld('electron', electronAPI)
  contextBridge.exposeInMainWorld('api', api)
} else {
  // @ts-ignore
  window.electron = electronAPI
  // @ts-ignore
  window.api = api
}

Update the type declaration in src/preload/index.d.ts so the renderer sees the new surface:

ts
import { ElectronAPI } from '@electron-toolkit/preload'
import type { DirEntry } from '../shared/types'
 
type Size = { cols: number; rows: number }
 
declare global {
  interface Window {
    electron: ElectronAPI
    api: {
      openFolder: () => Promise<string | null>
      readDir: (path: string) => Promise<DirEntry[]>
      readFile: (path: string) => Promise<string>
      pty: {
        start: (size: Size) => void
        input: (data: string) => void
        resize: (size: Size) => void
        onData: (callback: (data: string) => void) => () => void
      }
    }
  }
}

The renderer: drawing the terminal

Now the visible half. xterm.js is an imperative library: you create a Terminal, open it into a DOM node, and call methods on it. React's job is just to give it a container and manage its lifecycle, which is a textbook case for a single useEffect that sets everything up on mount and tears it down on unmount.

The wiring inside the effect mirrors the byte stream exactly. Keystrokes from xterm go out through pty.input. Output from pty.onData gets written into xterm. A ResizeObserver keeps the PTY's dimensions matched to the pane, so the shell always knows how wide it can draw.

src/renderer/src/terminal/TerminalPane.tsx:

tsx
import { useEffect, useRef } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
 
export default function TerminalPane() {
  const hostRef = useRef<HTMLDivElement>(null)
 
  useEffect(() => {
    const host = hostRef.current
    if (!host) return
 
    const term = new Terminal({
      fontSize: 13,
      fontFamily: 'ui-monospace, monospace',
      cursorBlink: true,
      theme: { background: '#181818' }
    })
    const fit = new FitAddon()
    term.loadAddon(fit)
    term.open(host)
    fit.fit()
 
    // Ask the main process to spawn a shell sized to the current pane.
    window.api.pty.start({ cols: term.cols, rows: term.rows })
 
    // Shell output -> screen.
    const stopData = window.api.pty.onData((data) => term.write(data))
    // Keystrokes -> shell input.
    const keySub = term.onData((data) => window.api.pty.input(data))
 
    // Keep the PTY's size in step with the pane.
    const observer = new ResizeObserver(() => {
      fit.fit()
      window.api.pty.resize({ cols: term.cols, rows: term.rows })
    })
    observer.observe(host)
 
    return () => {
      stopData()
      keySub.dispose()
      observer.disconnect()
      term.dispose()
    }
  }, [])
 
  return <div ref={hostRef} className="terminal-host" />
}

Drop it into the layout by replacing the terminal placeholder in App.tsx:

tsx
import TerminalPane from './terminal/TerminalPane'
 
// ...inside the terminal section:
<section className="terminal">
  <header className="pane-title">Terminal</header>
  <div className="terminal-body">
    <TerminalPane />
  </div>
</section>

And give the host real dimensions in App.css, since xterm needs a sized container to lay out its character grid:

css
.terminal-body { padding: 4px; }
.terminal-host { width: 100%; height: 100%; }

A word on React strict mode

In development, React strict mode mounts effects twice on purpose, to flush out cleanup bugs. That means our effect runs, tears down, and runs again. Our cleanup disposes the xterm instance and unsubscribes from output, so no listeners leak. The PTY in the main process survives, because the pty:start guard ignores the second start and the shell keeps running. The freshly mounted terminal simply re-subscribes to the same pty:data stream. If you ever see two prompts in production it would point to a missing guard, but in dev this is expected and harmless.

Try it

Rebuild node-pty if you haven't, then run npm run dev. The bottom pane now shows a live shell prompt. Run ls, start top, run git status in a repo, resize the window and watch the output reflow. It's a real terminal, talking to a real shell, rendered by the same component VS Code uses.

You now have all three panes alive at once: a file tree on the left, a Monaco editor top right, and a working terminal beneath it. That is the full skeleton of a code editor.

Where this leaves us

The terminal pushed us into new territory in two ways that will keep paying off. We handled our first native module, which is the same dance any serious Electron feature needs, from native git bindings to SQLite. And we built a duplex IPC stream out of two one-way channels, which is the pattern behind anything live: file watchers, language servers, debuggers.

The structure now matches a real editor. What's missing is the connective tissue that makes it usable day to day. Right now edits live only in memory.

In Part 5 we close that gap. We add saving to disk, track which files have unsaved changes with a dirty indicator, and introduce tabs so more than one file can be open at once. The per-file Monaco models we set up in Part 3 are about to earn their keep.