Skip to main content

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:
  1. Create vt10x emulator with terminal size
  2. Determine shell executable ($SHELL or /bin/sh)
  3. Set working directory to room’s workspace
  4. Set TERM=xterm-256color environment variable
  5. Start PTY with creack/pty library
  6. Launch background goroutine to read shell output

Data Flow

Input Flow (Client → Shell)

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:
  1. Bubble Tea converts keystroke to bytes (e.g., "a"[]byte{0x61}, Enter → []byte("\r"))
  2. terminal.Write(data) sends bytes to PTY master
  3. PTY slave (shell) receives input as if from a real terminal
  4. 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:
  1. Read up to 4096 bytes from PTY master
  2. Feed bytes to vt10x emulator (t.vt.Write(buf[:n]))
  3. Mark render cache as dirty
  4. 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:
  1. Waits for terminal update notification
  2. Calls terminal.Render() to get latest output
  3. Updates UI model
  4. 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:
  1. Update internal dimensions
  2. Invalidate render cache
  3. Resize vt10x emulator grid
  4. 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:
  1. Mark terminal as closed
  2. Close all subscriber channels (notifies clients)
  3. Close PTY master file descriptor
  4. 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

Performance Characteristics

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:
    1. PTY read: kernel buffering (< 1ms)
    2. vt10x parse: O(n) in output bytes (< 1ms for typical output)
    3. Broadcast: O(clients), non-blocking
    4. 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.

Keyboard Input Handling

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

Build docs developers (and LLMs) love