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:
TerminalSurfaceis a canvas over any process. ATerminalAdaptercarries the output and the keys.- The terminal area sits over a
TerminalHost. A store holds each scope's terminals.TerminalPanelshows a tab strip and the active terminal.TerminalDrawerputs that panel under the chat,TerminalToggleshows or hides the drawer from the header, andTerminalPanelTabis 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#
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.
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.
| Action | Gives |
|---|---|
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), drawerProps | Your 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#
| Host | From | Runs |
|---|---|---|
createServerTerminalHost({ client }) | zidane-ui/adapters | zidane-server's terminal.* methods (an admin token). It has project and draft terminals when the server offers them (projectTerminals). |
createFixtureTerminalHost(options) | zidane-ui/terminal/testing | Memory: echoing shells, for demos and tests |
| Your own | zidane-ui/terminal/headless | Anything that has a PTY |
| Member | Gives |
|---|---|
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, ack | One 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 |
layouts | Where 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.