Skip to content

[Architecture] oclite: Live Block rendering with log-update for tools, tasks, and todos #82

@randomm

Description

@randomm

Summary

Replace hand-rolled ANSI cursor overwrite logic with a Live Block architecture using log-update. This solves parallel tool display, task status updates, and todo list rendering in a single proven pattern.

Context

Problems with current approach

  • \x1b[1A (move up 1 line) only overwrites the immediately previous line
  • Parallel tools create orphaned start lines that never get their completion marker
  • No way to show running tasks or todo list as live-updating content
  • Every new live-updating feature (subagent panels, progress bars) hits the same wall

Research evidence

  • Docker/Podman uses this exact "live block" pattern for parallel layer downloads
  • AIChat and Aider use append-only (no live updates) — simpler but cant show parallel status
  • Claude Code uses React/Ink with a custom-rewritten renderer — too heavy for oclite
  • log-update (17.3M weekly downloads) is the battle-tested primitive for this pattern
  • OpenTUI (built for opencode.ai) is promising but alpha — not ready

Architecture decision

Evaluated 3 approaches:

  • Live Block (log-update) — ~120 LOC integration, Docker-proven ✅ CHOSEN
  • Content Panes (absolute positioning) — 250+ LOC, breaks with scrollback ❌
  • Buffered Frames (game engine diff) — 200+ LOC, overkill ❌

Design

Three-Zone Terminal Layout

┌──────────────────────────────────────────────┐
│ ZONE 1: Frozen output (append-only)           │
│ Text, completed tools, prose — never changes  │
│ Scrolls up naturally in terminal              │
│                                               │
├──────────────────────────────────────────────┤
│ ZONE 2: Live Block (rewrites in place)        │
│ Active tools: ◇ bash  git status              │
│ Active tasks: ⠋ @git-agent (12s)              │
│ Todos:        [✓] Research  [ ] Fix #57       │
│ ──────────────────────────────────────────    │
├──────────────────────────────────────────────┤
│ ZONE 3: Input (LineEditor)                    │
│ project-manager ❯ █                           │
└──────────────────────────────────────────────┘
  • Zone 1: Plain process.stdout.write() — append only, frozen forever
  • Zone 2: Managed by log-update — rewrites N lines on every state change
  • Zone 3: Existing LineEditor — handles user input

When Zone 2 has nothing active, it shrinks to zero. When everything completes, it freezes into Zone 1 via logUpdate.done().

Live Block State

The live block renders from a state object:

interface LiveState {
  tools: Array<{ id: string, name: string, summary: string, status: "running" | "done" | "error" }>
  tasks: Array<{ id: string, agent: string, description: string, elapsed: number, status: "running" | "done" }>
  todos: Array<{ content: string, status: "pending" | "in_progress" | "completed" }>
}

On ANY state change (tool start, tool end, task update, todo update), re-render the entire block via logUpdate(). The library handles cursor math internally.

Key Pattern: log-update

import logUpdate from "log-update"

// Render active state
logUpdate([
  "◇ bash  git status",
  "◇ rg  *.ts",
  "⠋ @git-agent: Get project state (12s)",
  "────────────",
  "[✓] Research  [ ] Fix #57",
].join("\n"))

// On tool completion — update entire block
logUpdate([
  "✓ bash  git status",       // changed
  "◇ rg  *.ts",
  "⠋ @git-agent: Get project state (15s)",
  "────────────",
  "[✓] Research  [ ] Fix #57",
].join("\n"))

// When everything done — freeze and move on
logUpdate.done()

Transition Logic

  1. Text chunk arrives → if live block is active, call logUpdate.done() to freeze it, then write text to Zone 1
  2. Tool/task/todo event arrives → update state, re-render live block via logUpdate()
  3. All tools+tasks completelogUpdate.done(), live block disappears
  4. New tool starts after text → create fresh live block

Dependencies

Package Purpose Size Downloads/week
log-update Live block rendering Small 17.3M

Optionally also:

  • nanospinner (20 KB) — if we want to replace our custom spinner
  • picocolors (7 KB) — if we want to replace our custom ANSI colors
  • ansi-escapes (20 KB) — for edge case escape sequences

Minimum viable: just log-update. Everything else we already have.

Bun Compatibility

log-update uses ansi-escapes and cli-cursor internally — both are pure JS, no native modules. Should work with Bun. Verify during implementation.

Implementation Plan

Phase 1: Core Live Block

  • Add log-update dependency
  • Create packages/opencode/src/cli/lite/liveblock.ts (~120 lines)
  • Manage tool state (Map of active tools by ID)
  • Render tool lines with status icons (◇ running, ✓ done, ✗ error)
  • Freeze block when all tools complete
  • Wire into handleMessage() and handleCustomCommand() in index.ts

Phase 2: Task + Todo Display

  • Add task state tracking (running subagent tasks)
  • Add todo list rendering from TodoWrite events
  • Render tasks with spinner + elapsed time
  • Render todos with checkbox icons

Phase 3: Polish

  • Truncate lines to terminal width
  • Handle terminal resize (SIGWINCH)
  • Dedup identical tool calls with counter (×N)
  • Transition animations (fade completed items)

Acceptance Criteria

  • Parallel tools update in-place correctly
  • Sequential tools still work
  • Tasks show live status with elapsed time
  • Todos display with checkbox state
  • Text output flows cleanly above live block
  • logUpdate.done() freezes block when section completes
  • No flickering or visual artifacts
  • bun run typecheck passes
  • bun test passes
  • Works in iTerm, Terminal.app, tmux

References

Supersedes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions