Skip to content

Embedded terminals

Real terminals in your app over a TerminalHost. A drawer under the chat, a side-panel tab, and a view that attaches to a terminal, replays its history and streams its output.

zidane-ui/terminal draws terminals with @tahul/ghostty-web, which loads on first use. Two layers build on it:

  • TerminalSurface is a canvas over any process. A TerminalAdapter carries the output and the keys.
  • The terminal area sits over a TerminalHost. A store holds each scope's terminals. TerminalPanel shows a tab strip and the active terminal. TerminalDrawer puts that panel under the chat, TerminalToggle shows or hides the drawer from the header, and TerminalPanelTab is the side panel's Terminal tab.

A scope is whose terminals they are: a session's (they run in its environment), a project folder's, or a new-chat draft's. The desktop app runs them in its daemon. The demo below runs on the fixture host: its shells echo what you type.

Mount it#

TSX
import { createServerClient, createServerTerminalHost } from 'zidane-ui/adapters'
import { TerminalDrawer, TerminalProvider, TerminalToggle } from 'zidane-ui/terminal'

const client = createServerClient({ url, token })
const terminals = createServerTerminalHost({ client })

export function Chat({ sessionId, cwd }: { sessionId: string, cwd: string }) {
  const scope = { root: cwd, cwd, session: sessionId }
  return (
    <TerminalProvider host={terminals} actions={{ openUrl: (_scope, url) => window.open(url, '_blank') }}>
      <header><TerminalToggle scope={scope} /></header>
      <Conversation />
      <TerminalDrawer scope={scope} />
    </TerminalProvider>
  )
}

AppHostProvider (zidane-ui/app) puts host.terminal in a TerminalProvider for you. If code outside React also drives terminals (a keybinding that opens the drawer, a draft handed to its session), create the store yourself with createTerminalStore(host) and pass it as store. Then store.toggleDrawer(scope), store.openInDrawer(scope), store.revealTerminal(scope, id) and store.adoptDraft(draft, session) work from anywhere.

States#

The panel says why there are no terminals. That can be the host's reason, a link that is still coming up (waiting), or a shell that failed to start. The header toggle shows a dot when the agent starts a terminal while nothing shows the session's terminals.

Unavailable: the host says why
Waiting for the daemon
A shell that failed to start
Header toggle: closed, open

What the app does#

TerminalProvider's actions hold what only your app can do. Leave one out and the control that needs it is hidden.

ActionGives
openUrl(scope, url, external, cwd)Where web links open. Default: a new window.
openFile(scope, path, event, at)Where file paths open (src/app.ts:12:5). Without it, paths don't link.
addToChat(scope, excerpt), canAddToChat(scope)The right-click menu's Add to chat. It gets the selection with its line range.
openSettings()The unavailable state's Open Settings button
remoteName(scope)The machine a scope's terminals run on when it isn't this one. Its loopback links are named and never opened.
registerTabScope(element, handlers), drawerPropsYour keyboard model: step between terminals, close one, mark the drawer as a focus region

enabled={false} removes the drawer and the toggle. symbolsFontUrl serves the Nerd Font symbols face (SYMBOLS_FONT) before a terminal measures its cells.

Pick a host#

HostFromRuns
createServerTerminalHost({ client })zidane-ui/adapterszidane-server's terminal.* methods (an admin token). It has project and draft terminals when the server offers them (projectTerminals).
createFixtureTerminalHost(options)zidane-ui/terminal/testingMemory: echoing shells, for demos and tests
Your ownzidane-ui/terminal/headlessAnything that has a PTY
MemberGives
state(scope), open(scope, options), close(id)A scope's terminals, opening one, closing one (an agent's: ends its task)
attach, detach, write, resize, ackOne view's link to one terminal: history, typing, its grid size, flow control
listen(sink)Lists that changed, output, resets, and relinks (every scope reloads, every view attaches again)
restart(id), setHidden(id, hidden)Restart a process; hide a session's terminal from its agent
adoptDraft(draftId, sessionId), releaseDraft(draftId)A draft's terminals move into its session, or back to the project
layoutsWhere the drawer, the tab order and the agent filter are kept per scope. Default: the page.

describeTerminalHostContract (zidane-ui/terminal/testing/contract) is the suite every host runs: yours can run it too.

How a view draws#

  • Output never goes through React state. The store hands it straight to each view. A view keeps its own cursor and acknowledges what it wrote.
  • The grid always has the PTY's size. A view only asks for what fits its container. The view being looked at gets that size.
  • A view stays transparent until it has its history and its size, so a half-drawn terminal never shows. Big replays are parsed in 8 ms slices.
  • Two views of one terminal are fine (the drawer and the panel tab). Each one attaches with its own client id.