This document describes Redline's current architecture, data flow, and main design decisions.
Redline is a terminal-based plan annotation tool that integrates with Claude Code via the hook system. The runtime has three major parts: hook bridging, plan parsing/feedback, and a custom fullscreen terminal renderer.
┌─────────────────────────────────────────────────────────────┐
│ Claude Code │
│ │
│ Plan mode -> ExitPlanMode -> PermissionRequest hook fires │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ redline-hook.sh │ │
│ │ - Saves hook JSON to temp file │ │
│ │ - Opens a terminal tab │ │
│ │ - Waits for output file │ │
│ │ - Relays response to stdout │ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ┌────────────▼────────────────┐ │
│ │ New terminal tab with TTY │ │
│ │ │ │
│ │ node dist/bin/index.js │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ hookIO.ts │ │ │
│ │ │ read hook JSON │ │ │
│ │ └───────────┬───────────┘ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ parsePlan.ts │ │ │
│ │ │ Markdown -> steps │ │ │
│ │ └───────────┬───────────┘ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ src/engine │ │ │
│ │ │ custom React renderer │ │ │
│ │ │ scroll + selection UI │ │ │
│ │ └───────────┬───────────┘ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ hookIO.ts │ │ │
│ │ │ write hook response │ │ │
│ │ └───────────────────────┘ │ │
│ └─────────────────────────────┘ │
│ │ │
│ ▼ │
│ Hook wrapper reads output file -> stdout -> Claude Code │
│ │
│ allow -> Claude proceeds │
│ deny -> Claude receives feedback and revises the plan │
└─────────────────────────────────────────────────────────────┘
redline/
├── src/
│ ├── bin/
│ │ └── index.ts # CLI entry point
│ ├── engine/
│ │ ├── app.tsx # Redline review UI state and interactions
│ │ ├── root.ts # createRoot/render public entry points
│ │ ├── runtime.tsx # runtime lifecycle, input, mouse, resize, frames
│ │ ├── reconciler.ts # React host config
│ │ ├── dom.ts # lightweight host tree nodes
│ │ ├── layout/yoga.ts # Yoga layout bridge
│ │ ├── renderer.ts # host tree -> screen buffer
│ │ ├── screen.ts # cell/style frame memory
│ │ ├── log-update.ts # frame diffing
│ │ ├── terminal.ts # ANSI serialization and terminal modes
│ │ ├── markdownRows.ts # Markdown tokens -> rendered plan rows
│ │ ├── selection.ts # point selection -> markdown source ranges
│ │ ├── mouse.ts # SGR mouse decoding
│ │ ├── components/ # Box, Text, ScrollBox, Divider, AlternateScreen
│ │ └── hooks/ # useInput, useMouse, useTerminalSize
│ ├── utils/
│ │ ├── hookIO.ts # stdin/stdout/file I/O for hook integration
│ │ └── parsePlan.ts # Markdown -> PlanStep[] parser + feedback format
│ └── types.ts # shared PlanStep/Annotation types
├── redline-hook.sh # wrapper script for Claude Code hook
├── hooks.json # reference hook config
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── ARCHITECTURE.md
Claude Code calls ExitPlanMode when a plan is ready. This fires a PermissionRequest event. The configured hook runs redline-hook.sh.
The hook receives JSON on stdin:
{
"session_id": "abc-123",
"tool_name": "ExitPlanMode",
"tool_input": {
"plan": "# Plan Title\n## Step 1\n...",
"planFilePath": "/Users/.../.claude/plans/some-plan.md"
},
"permission_mode": "plan",
"hook_event_name": "PermissionRequest"
}Claude Code hooks run without a controlling terminal. Redline's fullscreen renderer needs a TTY for raw keyboard input, SGR mouse reporting, alternate-screen mode, and ANSI output.
redline-hook.sh handles this bridge:
- Saves stdin JSON to a temp file.
- Sets
REDLINE_OUTPUT_FILEto a response temp file. - Opens a terminal tab and runs the Node process there.
- Polls for the response file.
- Writes the response JSON to stdout for Claude Code.
The Node process also handles direct-pipe testing by reading piped stdin first, reopening /dev/tty, and replacing process.stdin when possible.
parsePlan.ts converts the Markdown plan into PlanStep[]. The parser splits on headings and list items that Redline treats as selectable review units. Continuation lines are grouped with the preceding step.
interface PlanStep {
id: number;
content: string;
depth: number;
annotations: Annotation[];
}Markdown rendering is separate from step parsing. markdownRows.ts lexes each step with marked, renders Markdown tokens into styled row segments, preserves block spacing, wraps rows to the viewport, and attaches internal row metadata for selection hit testing.
The current renderer is not the public ink npm package. It is a small custom engine built for Redline's fullscreen needs.
The pipeline is:
React components
-> react-reconciler host tree
-> Yoga layout
-> screen buffer
-> previous/next frame diff
-> ANSI patch write
Important pieces:
root.tsexposescreateRoot()andrender().runtime.tsxowns raw input, mouse events, terminal resize, frame scheduling, and cleanup.reconciler.tsmounts React components into the lightweight host tree fromdom.ts.layout/yoga.tscomputes box layout and text measurement.renderer.ts,output.ts, andscreen.tspaint the tree into an in-memory screen buffer.log-update.tsproduces small patch operations instead of clearing and redrawing the screen.terminal.tsserializes patches and manages alt-screen, cursor, and mouse modes.
app.tsx renders the review UI:
<AlternateScreen>
<Box column>
header
divider
<ScrollBox>
Markdown-rendered plan rows
</ScrollBox>
divider
footer or annotation input
</Box>
</AlternateScreen>
The primary workflow is scroll and select:
- Mouse wheel scrolls the
ScrollBox. - PageUp, PageDown, Home, and End are keyboard scroll fallbacks.
- Dragging inside the plan body creates an app-managed point selection.
- Shift-click extends the current point selection.
selection.tsresolves selected rendered cells back to Markdown source ranges per parsed step.- Annotation keys apply to selected source ranges, with whole-step fallback when the full rendered step is selected.
When the user presses Enter with annotations present, formatFeedback() in parsePlan.ts assembles a structured message:
Plan feedback from redline review:
On step: "### 2. Update `package.json`"
💬 Comment: Use pnpm --version to get the exact installed version
On step: "## Verification"
🗑️ Remove this step
Please revise the plan addressing the above annotations, then present the updated plan.
This is sent as a deny decision. If there are no annotations, Redline emits an allow decision.
Output is written via hookIO.ts.
- If
REDLINE_OUTPUT_FILEis set, Redline writes JSON to that file soredline-hook.shcan relay it. - If it is not set, Redline writes to
process.stdoutfor direct testing.
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": { "behavior": "allow" }
}
}{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "Plan feedback from redline review:\n\n..."
}
}
}The old implementation used the public Ink package. That made early iteration fast, but Redline needed tighter control over flicker, terminal mouse mode, scroll clipping, and row-level selection metadata. The current renderer keeps the React authoring model while owning the terminal pipeline directly.
The no-TTY hook environment is not solvable from inside the hook subprocess. Opening a terminal tab keeps the product terminal-native while giving the renderer the input and output streams it needs.
The fullscreen renderer needs stdout for terminal drawing. The hook response therefore goes through a separate file, which the wrapper script relays back to Claude Code.
Rendered cells carry Markdown source offsets from parsing through token rendering and wrapping. Redline uses those offsets to return exact selected excerpts in feedback while preserving whole-step annotation formatting for full-step selections and older annotations.
Native terminal selection is not reliably observable by the app. Redline enables SGR mouse reporting and renders its own selection highlight so annotation shortcuts can target the selected rows deterministically.
pnpm install
pnpm build
pnpm devtsup builds a single ESM CLI entry at dist/bin/index.js. React and the reconciler are bundled to avoid dynamic require("react") calls in Node's ESM runtime.
# Pure renderer and input tests
pnpm exec tsx src/engine/markdownRows.test.ts
pnpm exec tsx src/engine/mouse.test.ts
pnpm exec tsx src/engine/selection.test.ts
# Typecheck and build
pnpm exec tsc --noEmit
pnpm build
# Demo mode
node dist/bin/index.js
# Simulated hook input
jq -n '{session_id:"test",tool_name:"ExitPlanMode",tool_input:{plan:"# Plan\n## Step 1\nDo X\n## Step 2\nDo Y"}}' | node dist/bin/index.js