Documentation Index
Fetch the complete documentation index at: https://mintlify.com/jaypopat/cf_ai_duet/llms.txt
Use this file to discover all available pages before exploring further.
Duet’s terminal sharing is built on two key technologies: pseudo-terminals (PTY) for shell I/O and vt10x for terminal state emulation.
Terminal Architecture
The Terminal type in /internal/terminal/terminal.go wraps a PTY with terminal emulation:
type Terminal struct {
vt vt10x.Terminal
ptmx *os.File
cmd *exec.Cmd
mu sync.Mutex
width int
height int
workDir string
subscribers map[chan struct{}]struct{}
subMu sync.RWMutex
closed bool
lastRender string
dirty bool
}
Component Breakdown
- vt: Terminal emulator that parses ANSI/VT100 sequences
- ptmx: PTY master file handle (connected to shell process)
- cmd: Shell process (bash, zsh, etc.)
- width/height: Terminal dimensions in columns/rows
- workDir: Working directory for shell (room’s workspace)
- subscribers: Channels for broadcasting updates to clients
- lastRender: Cached string output (optimization)
- dirty: Flag indicating render cache needs refresh
Pseudo-Terminal (PTY) Basics
A PTY creates a master-slave pair:
┌─────────────┐ ┌─────────────┐
│ Terminal │ │ Shell │
│ (Master) │ ◄─────► │ (Slave) │
│ /dev/ptmx │ │ /dev/pts/N │
└─────────────┘ └─────────────┘
- Master: Application writes input, reads output
- Slave: Shell process thinks it’s a real terminal
This allows capturing and sharing shell I/O between multiple clients.
Terminal Initialization
func New(width, height int, workDir string) *Terminal {
if width < 1 {
width = 80
}
if height < 1 {
height = 24
}
if workDir == "" {
workDir = "/app"
}
return &Terminal{
width: width,
height: height,
workDir: workDir,
subscribers: make(map[chan struct{}]struct{}),
}
}
Starting the Terminal
func (t *Terminal) Start() error {
t.mu.Lock()
defer t.mu.Unlock()
// Create vt10x emulator
t.vt = vt10x.New(vt10x.WithSize(t.width, t.height))
// Get shell from environment or default to /bin/sh
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
// Create command for shell
t.cmd = exec.Command(shell)
t.cmd.Dir = t.workDir
t.cmd.Env = append(os.Environ(),
"TERM=xterm-256color",
)
// Start PTY with specified dimensions
var err error
t.ptmx, err = pty.StartWithSize(t.cmd, &pty.Winsize{
Rows: uint16(t.height),
Cols: uint16(t.width),
})
if err != nil {
return err
}
// Start background goroutine to read PTY output
go t.readLoop()
return nil
}
Key Steps:
- Create vt10x emulator with terminal size
- Determine shell executable (
$SHELL or /bin/sh)
- Set working directory to room’s workspace
- Set
TERM=xterm-256color environment variable
- Start PTY with
creack/pty library
- Launch background goroutine to read shell output
Data Flow
func (t *Terminal) Write(data []byte) (int, error) {
t.mu.Lock()
ptmx := t.ptmx
t.mu.Unlock()
if ptmx == nil {
return 0, nil
}
return ptmx.Write(data)
}
When a client types:
- Bubble Tea converts keystroke to bytes (e.g.,
"a" → []byte{0x61}, Enter → []byte("\r"))
terminal.Write(data) sends bytes to PTY master
- PTY slave (shell) receives input as if from a real terminal
- Shell processes command and writes output
Output Flow (Shell → Clients)
func (t *Terminal) readLoop() {
buf := make([]byte, 4096)
for {
n, err := t.ptmx.Read(buf)
if err != nil {
// Shell process exited
t.mu.Lock()
t.closed = true
t.mu.Unlock()
return
}
t.mu.Lock()
if t.vt != nil {
t.vt.Write(buf[:n])
t.dirty = true
}
closed := t.closed
t.mu.Unlock()
if !closed {
t.broadcast()
}
}
}
Processing Steps:
- Read up to 4096 bytes from PTY master
- Feed bytes to vt10x emulator (
t.vt.Write(buf[:n]))
- Mark render cache as dirty
- Broadcast update notification to all subscribers
vt10x Terminal Emulator
The vt10x emulator:
- Parses ANSI/VT100 escape sequences (colors, cursor movement, etc.)
- Maintains a 2D grid of cells (each with a character and style)
- Tracks cursor position and visibility
- Handles terminal modes (insert, wrap, etc.)
This allows converting raw shell output into a renderable terminal state.
Publisher-Subscriber Pattern
Subscription Management
func (t *Terminal) Subscribe() chan struct{} {
ch := make(chan struct{}, 1)
t.subMu.Lock()
t.subscribers[ch] = struct{}{}
t.subMu.Unlock()
return ch
}
func (t *Terminal) Unsubscribe(ch chan struct{}) {
t.subMu.Lock()
delete(t.subscribers, ch)
t.subMu.Unlock()
}
Each client subscribes when joining a room:
m.termUpdateCh = m.terminal.Subscribe()
Broadcasting Updates
func (t *Terminal) broadcast() {
t.subMu.RLock()
defer t.subMu.RUnlock()
for ch := range t.subscribers {
select {
case ch <- struct{}{}:
default:
}
}
}
Non-Blocking Design:
The select with default ensures slow clients don’t block the readLoop. If a client’s channel buffer is full, the update is skipped (client will get the next one).
Client Update Loop
In the Bubble Tea model:
func (m *Model) waitForTerminalUpdate() tea.Cmd {
if m.terminal == nil || m.termUpdateCh == nil {
return nil
}
return func() tea.Msg {
<-m.termUpdateCh
return terminalUpdateMsg{}
}
}
When terminalUpdateMsg is received:
case terminalUpdateMsg:
if m.terminal != nil {
m.termContent = m.terminal.Render()
}
return m, m.waitForTerminalUpdate()
This creates a loop where the client:
- Waits for terminal update notification
- Calls
terminal.Render() to get latest output
- Updates UI model
- Starts waiting again
Rendering
Render Method
func (t *Terminal) Render() string {
t.mu.Lock()
defer t.mu.Unlock()
if t.vt == nil {
return ""
}
// Return cached render if not dirty
if !t.dirty && t.lastRender != "" {
return t.lastRender
}
cols, rows := t.vt.Size()
cursor := t.vt.Cursor()
cursorVisible := t.vt.CursorVisible()
var sb strings.Builder
sb.Grow(cols * rows * 2)
var prevFG, prevBG vt10x.Color
var inStyle bool
for y := 0; y < rows; y++ {
prevFG, prevBG = 0, 0
inStyle = false
for x := range cols {
cell := t.vt.Cell(x, y)
char := cell.Char
if char == 0 {
char = ' '
}
isCursor := cursorVisible && x == cursor.X && y == cursor.Y
fg := cell.FG
bg := cell.BG
if isCursor {
// Swap fg/bg for cursor (reverse video effect)
fg, bg = bg, fg
}
needsColorChange := fg != prevFG || bg != prevBG || (isCursor && !inStyle)
if needsColorChange {
if inStyle {
sb.WriteString("\x1b[0m")
inStyle = false
}
if fg != 0 && fg < 256 {
sb.WriteString(fgColor(fg))
inStyle = true
}
if bg != 0 && bg < 256 {
sb.WriteString(bgColor(bg))
inStyle = true
}
if isCursor && !inStyle {
sb.WriteString("\x1b[7m")
inStyle = true
}
prevFG, prevBG = fg, bg
}
sb.WriteRune(char)
}
if inStyle {
sb.WriteString("\x1b[0m")
inStyle = false
}
if y < rows-1 {
sb.WriteString("\n")
}
}
t.lastRender = sb.String()
t.dirty = false
return t.lastRender
}
Rendering Optimizations
1. Caching:
if !t.dirty && t.lastRender != "" {
return t.lastRender
}
If nothing changed since last render, return cached string.
2. Run-Length Encoding:
needsColorChange := fg != prevFG || bg != prevBG || (isCursor && !inStyle)
Only emit ANSI color codes when colors actually change, reducing output size.
3. Pre-Allocated Buffer:
var sb strings.Builder
sb.Grow(cols * rows * 2)
Pre-allocate buffer to avoid repeated allocations.
Color Conversion
func fgColor(c vt10x.Color) string {
if c < 8 {
return fmt.Sprintf("\x1b[%dm", 30+c)
} else if c < 16 {
return fmt.Sprintf("\x1b[%dm", 90+(c-8))
}
return fmt.Sprintf("\x1b[38;5;%dm", c)
}
func bgColor(c vt10x.Color) string {
if c < 8 {
return fmt.Sprintf("\x1b[%dm", 40+c)
} else if c < 16 {
return fmt.Sprintf("\x1b[%dm", 100+(c-8))
}
return fmt.Sprintf("\x1b[48;5;%dm", c)
}
Color Ranges:
- 0-7: Standard colors (black, red, green, yellow, blue, magenta, cyan, white)
- 8-15: Bright colors
- 16-255: Extended 256-color palette
Cursor Rendering
if isCursor {
// Swap fg/bg for cursor (reverse video effect)
fg, bg = bg, fg
}
The cursor is rendered by reversing foreground and background colors at the cursor position, creating a visual highlight effect.
Window Resizing
func (t *Terminal) Resize(width, height int) {
t.mu.Lock()
defer t.mu.Unlock()
if width < 1 || height < 1 {
return
}
t.width = width
t.height = height
t.dirty = true
t.lastRender = ""
if t.vt != nil {
t.vt.Resize(width, height)
}
if t.ptmx != nil {
pty.Setsize(t.ptmx, &pty.Winsize{
Rows: uint16(height),
Cols: uint16(width),
})
}
}
Resize Synchronization:
- Update internal dimensions
- Invalidate render cache
- Resize vt10x emulator grid
- Send SIGWINCH to shell via
pty.Setsize()
This ensures programs running in the shell (vim, less, etc.) detect the new terminal size.
Client Window Size Handling
When client terminal resizes:
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
_, terminalW, aiSidebarW, mainH := m.roomLayout()
if m.terminal != nil {
m.terminal.Resize(terminalW, mainH-4)
}
The terminal is resized to fit the client’s window, accounting for sidebars and UI chrome.
Terminal Cleanup
func (t *Terminal) Close() error {
t.mu.Lock()
t.closed = true
t.mu.Unlock()
// Close all subscriber channels
t.subMu.Lock()
for ch := range t.subscribers {
close(ch)
}
t.subscribers = nil
t.subMu.Unlock()
t.mu.Lock()
defer t.mu.Unlock()
if t.ptmx != nil {
t.ptmx.Close()
t.ptmx = nil
}
if t.cmd != nil && t.cmd.Process != nil {
t.cmd.Process.Kill()
}
return nil
}
Cleanup Sequence:
- Mark terminal as closed
- Close all subscriber channels (notifies clients)
- Close PTY master file descriptor
- Kill shell process
This ensures clean shutdown when the last client leaves a room.
Shared Terminal State
All clients in a room share:
- Same vt10x instance: Single source of truth for terminal state
- Same PTY: Input from any client goes to the same shell
- Same render output: All clients see identical terminal content
Client-Specific:
- Subscription channels: Each client has its own notification channel
- Render timing: Clients render independently based on their update loop
Memory Usage
- vt10x grid:
width × height × sizeof(Cell) ≈ 80 × 24 × 16 bytes = 30 KB
- Render cache:
width × height × 4 ≈ 80 × 24 × 4 = 7.6 KB (ANSI sequences add overhead)
- Read buffer: 4096 bytes per terminal
Latency
- Input latency: Direct write to PTY (< 1ms)
- Output latency:
- PTY read: kernel buffering (< 1ms)
- vt10x parse: O(n) in output bytes (< 1ms for typical output)
- Broadcast: O(clients), non-blocking
- Client render: Cached if no changes (< 1ms)
Total round-trip latency: < 5ms for typical interactions
Scalability
Per room:
- Terminal overhead: ~40 KB + shell process
- Per-client overhead: ~16 bytes (channel in subscriber map)
- Broadcast complexity: O(n) where n = number of clients
With 100 clients in one room, broadcast is still < 1ms.
Special key mappings in /internal/ui/model.go:
switch key {
case "enter":
data = []byte("\r")
case "backspace":
data = []byte{127}
case "tab":
data = []byte("\t")
case "up":
data = []byte("\x1b[A")
case "down":
data = []byte("\x1b[B")
case "right":
data = []byte("\x1b[C")
case "left":
data = []byte("\x1b[D")
case "home":
data = []byte("\x1b[H")
case "end":
data = []byte("\x1b[F")
case "delete":
data = []byte("\x1b[3~")
case "esc":
data = []byte("\x1b")
default:
if len(key) == 1 {
data = []byte(key)
} else if len(msg.Runes) > 0 {
data = []byte(string(msg.Runes))
}
}
These mappings convert Bubble Tea key events to the ANSI sequences shells expect.
Error Handling
Shell Exit Detection
n, err := t.ptmx.Read(buf)
if err != nil {
// Shell process exited
t.mu.Lock()
t.closed = true
t.mu.Unlock()
return
}
When the shell exits, readLoop terminates gracefully.
Write Failures
if ptmx == nil {
return 0, nil
}
return ptmx.Write(data)
Writes to a closed terminal are silently ignored (returns 0 bytes written).
Future Enhancements
Potential improvements:
- Selective rendering: Only send diffs to clients instead of full frames
- Replay buffer: Store terminal history for late joiners
- Input queuing: Buffer input during network lag
- Compression: Compress render output for slow connections