Skip to content

Chapter 11: REPL & Interactive Session

What You'll Learn

By the end of this chapter, you will be able to:

  • Read src/screens/REPL.tsx with confidence, understanding how its ~3000 lines decompose into a handful of cooperating sub-components and why the top-level component tree is structured the way it is
  • Trace a message from the moment the QueryEngine emits a StreamEvent through batching, normalization, and virtual-list rendering to the characters that actually appear on screen
  • Explain how PromptInput manages multi-line editing, history navigation, @-file expansion, and the bracketed-paste guard, and how useTextInput underpins all of these
  • Describe the typeahead completion pipeline: what triggers it, how FuzzyPicker filters candidates, and how the completion overlay is positioned relative to the input
  • Walk through the permission dialog system from the moment a tool calls checkPermissions to the moment the user presses a key to allow or deny
  • Understand how the Task Panel and Teammate Views adapt the single-session REPL design to support background tasks and concurrent multi-agent execution
  • Describe how transcript search works: the Ctrl+R trigger, the real-time fuzzy filter, and the virtual-scroll jump to matched messages

11.1 REPL.tsx in Context

Chapter 10 built the rendering infrastructure: the custom React reconciler targeting terminal output, the Yoga WASM layout engine, the differential output buffer, and the raw-mode input parser. All of that machinery is a general-purpose terminal UI framework. It knows nothing about conversations, tools, or agents.

src/screens/REPL.tsx is where the application lives. It is a React component of roughly 3000 lines that assembles Claude Code's interactive session from the primitives Chapter 10 defined. Every token the model streams, every tool call the model makes, every permission request that pauses execution, every slash command the user types — all of it flows through or around this file.

That length can be intimidating. The way to make it manageable is to start with the top-level component tree and understand how the five major sub-components divide responsibility before examining any of them individually.

tsx
// src/screens/REPL.tsx — top-level component tree (conceptual)
export function REPL(props: REPLProps) {
  // ... many hooks ...

  return (
    <Box flexDirection="column" height={terminalHeight}>
      <TaskPanel tasks={backgroundTasks} />
      <MessageList
        messages={logMessages}
        scrollOffset={scrollOffset}
        onScroll={handleScroll}
      />
      <PermissionDialog
        request={pendingPermissionRequest}
        onDecision={handlePermissionDecision}
      />
      <PromptInput
        value={inputValue}
        onSubmit={handleSubmit}
        completions={typeaheadCompletions}
        isDisabled={isWaitingForPermission}
      />
      <StatusBar
        model={currentModel}
        tokenCount={tokenCount}
        agentCount={activeAgentCount}
      />
    </Box>
  )
}

The layout is vertical: task monitoring at the top, the message history in the middle (consuming whatever height remains), the permission dialog overlaid when active, the input at the bottom, and a one-line status bar at the very bottom. This is the same layout pattern every terminal REPL uses because terminal dimensions are finite and content must have a clear reading direction.

The five sub-components map neatly to the five things a user interacts with during a session. TaskPanel answers "what is running in the background?" MessageList answers "what has been said so far?" PermissionDialog answers "should this tool be allowed to run?" PromptInput answers "what does the user want to say next?" StatusBar answers "what is the system's current state?"

Before examining each of these, it is worth understanding the hook layer that wires them together. REPL.tsx imports a large set of custom hooks. Their names reveal the design intent: useLogMessages owns the message stream, useCommandQueue owns slash command processing, useTextInput owns the input box state, useTypeahead owns completion candidates, useCanUseTool owns the permission decision machinery, and useReplBridge owns remote synchronization for headless callers. These hooks are covered in depth in Chapter 13; this chapter treats them as black boxes that provide the state and callbacks that the sub-components consume.


11.2 The Message Display Pipeline

The central challenge of the message list is that messages arrive asynchronously, in a streaming fashion, and their content may change several times before they are complete. A streaming assistant response begins as an empty AssistantMessage and grows one token at a time. A ToolUseMessage appears as soon as the model starts emitting the tool call, and is later accompanied by a ToolResultMessage after the tool executes. The UI must handle all of these transitions without flicker, without layout thrash, and without ever repainting more of the terminal than is strictly necessary.

The pipeline has four distinct stages.

11.2.1 Stage One: Event Subscription via useLogMessages

The useLogMessages hook is the entry point. It subscribes to the StreamEvent emitter that the QueryEngine (Chapter 9) exposes and maintains a React state array of LogMessage objects, one per event. Every time the QueryEngine emits a StreamEvent — a token arriving, a tool call starting, a tool result completing, an error being thrown — useLogMessages receives it and updates the state.

typescript
// src/hooks/useLogMessages.ts — conceptual structure
export function useLogMessages(eventEmitter: EventEmitter): LogMessage[] {
  const [messages, setMessages] = useState<LogMessage[]>([])

  useEffect(() => {
    const handler = (event: StreamEvent) => {
      setMessages(prev => applyStreamEvent(prev, event))
    }
    eventEmitter.on('streamEvent', handler)
    return () => eventEmitter.off('streamEvent', handler)
  }, [eventEmitter])

  return messages
}

The key function is applyStreamEvent. It implements the state machine that determines what happens to the messages array when each event type arrives. A text_delta event finds the last AssistantMessage in the array and appends to its text. A tool_use_start event pushes a new ToolUseMessage with an empty input. A tool_use_input_delta event finds the matching ToolUseMessage by ID and appends to its input JSON. A tool_result event pushes a new ToolResultMessage keyed to the tool use ID. A message_start event pushes a new empty AssistantMessage. This incremental mutation approach means that each streaming token causes only one targeted array update rather than a full list rebuild.

11.2.2 Stage Two: Event Batching

Token streaming is fast. A Claude model streaming at full speed can emit dozens of text deltas per second. If every delta triggers a React state update and a re-render, the terminal will stutter — React's overhead per render, even with the differential output buffer from Chapter 10, adds up.

The useLogMessages hook therefore batches events before committing them to state. The batching rule is simple: while events of the same type are arriving in rapid succession — specifically while no other event type has arrived in between — they are merged into a single accumulated update, and the state update is deferred for one animation frame.

typescript
// Event batching: consecutive text_delta events are merged
// before triggering a React state update
const pendingDeltas = useRef<string[]>([])
const frameHandle = useRef<number | null>(null)

function flushDeltas() {
  if (pendingDeltas.current.length === 0) return
  const combined = pendingDeltas.current.join('')
  pendingDeltas.current = []
  frameHandle.current = null
  setMessages(prev => appendToLastAssistantMessage(prev, combined))
}

// For text_delta events:
pendingDeltas.current.push(event.delta)
if (frameHandle.current === null) {
  frameHandle.current = requestAnimationFrame(flushDeltas)
}

The result is that a burst of 30 text deltas in a single frame becomes one state update and one re-render, rather than 30 separate re-renders. The user cannot perceive the batching because it is sub-frame; they see smooth token streaming.

Non-text events (tool calls, results, system messages) are not batched — they are flushed immediately because they represent semantic boundaries that the user may want to see as soon as they arrive.

11.2.3 Stage Three: Message Normalization

The LogMessage type produced by useLogMessages is closely tied to the StreamEvent vocabulary. The MessageList component, however, works with a higher-level DisplayMessage type that includes rendering hints: whether to show the full content or a collapsed summary, what syntax highlighting language to apply to code blocks, whether the message is still streaming, and so on.

A normalization step in REPL.tsx converts LogMessage[] to DisplayMessage[] using a pure function. The normalization is where message-type-specific logic lives: a ToolUseMessage for BashTool gets its command string extracted and syntax-highlighted as shell; a ToolResultMessage that contains JSON gets reformatted with indentation; a ToolUseMessage whose tool name is computer gets marked for the compact "computer use" rendering variant.

The six DisplayMessage variants correspond directly to the six things that can appear in a conversation:

AssistantMessage carries the model's text response, which may contain markdown. The rendering component uses a simple streaming-aware markdown renderer: headings, bold, inline code, and fenced code blocks are handled; complex features like tables are rendered as plain text to avoid layout issues in a terminal.

ToolUseMessage shows the tool name and its arguments. The arguments rendering is tool-specific: BashTool shows the command prominently; WriteFileTool shows the target path and a byte count; FileReadTool shows the path and line range. This per-tool formatting logic lives in each tool's renderToolUseMessage method, which is called from the normalization step.

ToolResultMessage shows the output of tool execution. Long outputs are truncated to a configurable maximum line count with a "N lines omitted" indicator. Outputs that look like JSON are pretty-printed; outputs that look like diffs are syntax-highlighted; image outputs (from the computer tool or screenshot tools) are rendered using Ink's sixel/block-character image support if the terminal supports it.

HumanMessage echoes what the user typed, possibly with @-references expanded to show the referenced filename rather than the full content.

SystemMessage communicates events that are not part of the conversation but are meaningful to the user: the /compact command was executed and N tokens were removed from context, the model was switched, a session was resumed from a saved transcript, an error was caught and handled.

TombstoneMessage is the ghost of compacted messages. After /compact runs, the actual message objects are removed from the conversation, but a TombstoneMessage is inserted in their place so the user can see that history was removed at a specific point. The tombstone shows the compaction timestamp and the number of tokens reclaimed.

11.2.4 Stage Four: Virtual List Rendering

A long Claude Code session can accumulate hundreds of messages. Rendering all of them at once would compute thousands of Yoga layout nodes and write thousands of rows to the terminal on every state change — a performance problem that gets worse the longer the session runs.

The MessageList component solves this with virtual scrolling. It only renders the messages that are currently visible in the terminal viewport, plus a small overscan buffer above and below to prevent pop-in during scrolling.

tsx
// src/components/MessageList.tsx — virtual rendering logic (conceptual)
function MessageList({ messages, scrollOffset, terminalHeight }: Props) {
  // Measure each message's rendered height in terminal rows
  const heights = useMemo(() => messages.map(measureMessageHeight), [messages])

  // Compute which messages are visible given the current scroll position
  const { startIndex, endIndex, topPadding, bottomPadding } =
    computeVisibleRange(heights, scrollOffset, terminalHeight)

  return (
    <Box flexDirection="column">
      {/* Spacer that represents all messages above the viewport */}
      <Box height={topPadding} />

      {messages.slice(startIndex, endIndex + 1).map(msg => (
        <MessageItem key={msg.id} message={msg} />
      ))}

      {/* Spacer that represents all messages below the viewport */}
      <Box height={bottomPadding} />
    </Box>
  )
}

The height measurement in measureMessageHeight is approximated without actually rendering: it calculates the number of terminal rows a message will occupy based on the terminal width (from process.stdout.columns) and the message's content length, accounting for word-wrapping. This approximation is fast and good enough; it becomes exact when the message is actually rendered, at which point any discrepancy is corrected on the next scroll event.

The scroll offset is maintained in REPL.tsx state. By default it tracks the bottom of the list (the newest message is always visible). When the user scrolls up, the offset changes and the visible window moves. When a new message arrives while the user is scrolled up, the REPL does not automatically jump back to the bottom — it preserves the user's scroll position and shows an indicator ("N new messages below") to prompt them to return.

This behavior is intentional: it mirrors what every modern chat interface does, and it respects the user's intent when they deliberately scrolled up to review earlier output.


11.3 PromptInput: The User's Interface

src/components/PromptInput/ is a subdirectory, not a single file — it contains the main PromptInput.tsx component plus helper modules for history management, @-reference expansion, and character counting. Together they implement the multi-line text input area at the bottom of the REPL.

11.3.1 Multi-Line Editing and Submit Behavior

The terminal input field looks simple — it is a rectangular area at the bottom of the screen — but it must behave in a very specific way that is different from both a browser textarea and a traditional shell prompt.

The key behavioral split is between soft newline and hard submit. Pressing Enter alone submits the current input to the agent. Pressing Shift+Enter inserts a literal newline character into the input, allowing the user to compose multi-paragraph prompts. This is the standard behavior for chat interfaces, and it is what users expect.

typescript
// src/components/PromptInput/PromptInput.tsx — key handling
useInput((input, key) => {
  if (key.return && !key.shift) {
    // Hard submit: send the current value to the agent
    onSubmit(currentValue)
    clearInput()
    return
  }

  if (key.return && key.shift) {
    // Soft newline: insert \n into the input
    insertAtCursor('\n')
    return
  }

  // ... other key handling
})

The useInput call is the Ink framework's keyboard event subscription, described in Chapter 10. The key.shift flag is parsed by the termio layer from the terminal's key modifier sequences.

Multi-line input changes the layout calculation significantly. Each newline in the input increases the height of the input area by one row, which decreases the height available for the MessageList by one row, which changes the virtual scroll window. REPL.tsx manages this by reading the input area's height after each render (using a ref that tracks the rendered Yoga node's computed height) and subtracting it from the terminal height when computing the MessageList viewport.

11.3.2 History Navigation

Claude Code maintains a persistent command history across sessions, stored in a configuration file. The Up arrow key navigates to the previous command; the Down arrow key navigates forward. When the user has navigated into history, they can edit the historical entry before submitting, which creates a new history entry without overwriting the original — the standard behavior of a Unix shell's readline.

The history state is managed by the useTextInput hook (covered in Chapter 13) with one nuance: the "current" entry (the one the user is actively composing before pressing Up for the first time) is saved to a temporary slot when history navigation begins, and restored when the user presses Down past the most recent history entry. This prevents the common frustration of losing a partially-typed message when accidentally pressing Up.

typescript
// History navigation state in useTextInput (conceptual)
type HistoryState = {
  entries: string[]          // Persisted history entries
  currentIndex: number       // -1 means "at the live input"
  savedLive: string          // The live input saved before navigation began
}

function navigateUp(state: HistoryState): HistoryState {
  if (state.currentIndex === -1) {
    // Save the live input before we move into history
    return {
      ...state,
      currentIndex: state.entries.length - 1,
      savedLive: currentValue,
    }
  }
  if (state.currentIndex > 0) {
    return { ...state, currentIndex: state.currentIndex - 1 }
  }
  return state  // Already at the oldest entry
}

11.3.3 @-File References

Typing @ followed by a path prefix triggers file completion (described in Section 11.4), but once a file reference is confirmed it becomes part of the prompt in a special way. Rather than inserting the raw file contents inline (which could be enormous and would clutter the prompt), Claude Code inserts a typed reference — visually something like @src/tools/BashTool.ts — that is expanded to full content when the prompt is submitted.

This expansion happens in the buildPromptWithExpansions function called by handleSubmit. It finds all @path tokens in the input, reads the referenced files, and constructs the final prompt string with the file contents embedded in labeled blocks:

<file path="src/tools/BashTool.ts">
// ... full file content ...
</file>

The user sees a compact reference; the model receives the full content. This is important for token economy during composition — the user is not charged context window space for a file reference until they actually submit the prompt.

The @ syntax also supports line ranges: @src/main.ts:10-50 expands to only lines 10 through 50, using the same FileReadTool logic described in Chapter 6. This is useful when the user wants to focus the model's attention on a specific function or class without including the entire file.

11.3.4 Paste Handling

The bracketed paste mode described in Chapter 10 (Section 10.1.4) is handled at the PromptInput level. When the termio layer detects the bracketed paste start sequence \x1B[200~, it sets a flag in the input event stream indicating that subsequent input is pasted rather than typed. The PromptInput component uses this flag to suppress the Shift+Enter requirement: pasted newlines are always treated as soft newlines rather than submit triggers, regardless of whether Shift was "pressed" (it was not — the content was pasted).

Without this guard, a multi-line code snippet pasted into the prompt would submit after each line, creating a confusing series of partial prompts each being sent to the agent independently.

11.3.5 Character Counter and Token Warning

The bottom-right corner of the input area shows a character count. This count updates in real time as the user types. When the input approaches the practical context window limit — determined heuristically from the current model's context length and the estimated token count of the existing conversation — the counter changes color from the neutral default to a warning amber.

The token count estimation uses a fast approximation (roughly four characters per token) rather than calling the actual tokenizer, which would require a synchronous IPC call and would introduce latency on every keystroke. The estimation is conservative: it rounds up. Users occasionally see the warning on prompts that would actually fit; they never see the warning missing on prompts that would not.


11.4 Typeahead Completion

Typeahead completion activates when the user's current word matches one of two trigger conditions: a leading / for command completion, or a leading @ for file path completion. The two modes share the same overlay UI (the FuzzyPicker component) but differ in how they generate candidates.

11.4.1 Command Completion

When the input begins with /, useTypeahead calls getCommandCompletions(inputValue), which queries the command registry (Chapter 8) for all registered slash commands. The candidates include built-in commands (/compact, /clear, /model, /help, etc.) and any tool-derived commands registered by MCP servers.

The FuzzyPicker component receives the candidate list and the current query string (the part of the input after /) and performs fuzzy matching:

typescript
// Fuzzy matching: "bsh" matches "bash", "cmp" matches "compact"
function fuzzyScore(query: string, candidate: string): number {
  let queryIndex = 0
  let score = 0
  let consecutiveBonus = 0

  for (let i = 0; i < candidate.length && queryIndex < query.length; i++) {
    if (candidate[i].toLowerCase() === query[queryIndex].toLowerCase()) {
      score += 1 + consecutiveBonus
      consecutiveBonus += 2  // Reward consecutive character matches
      queryIndex++
    } else {
      consecutiveBonus = 0
    }
  }

  // All query characters must match
  return queryIndex === query.length ? score : -1
}

The match quality scoring — rewarding consecutive character matches over scattered ones — ensures that /cm matches /compact more strongly than it matches /clear-messages, even though both contain the letters c and m.

The completion overlay renders above the input area (not below, because the input is at the bottom of the screen and there is no space below it). It shows up to eight candidates, with the best match highlighted. Up and Down arrows move the selection; Tab or Enter accepts the current selection and replaces the current word in the input.

11.4.2 File Path Completion

When the input contains a word beginning with @, useTypeahead switches to file completion mode. The hook calls the filesystem API with the path prefix extracted from the @-prefixed word, retrieving matching files and directories from the current working directory.

The file completion candidates are filtered and ranked using the same FuzzyPicker component but with a different scoring bonus: paths that match the beginning of a path component score higher than paths where the match occurs in the middle of a component name. This means that @src/q ranks src/query.ts above src/ink/reconciler.ts even though both paths contain the letter q.

Directory entries in the completion list are displayed with a trailing / and are selectable — selecting a directory does not complete the reference but instead extends the prefix to the directory, allowing navigation down the filesystem tree incrementally. This matches the behavior of tab completion in a Unix shell.


11.5 The Permission Dialog System

When a tool needs user confirmation before executing, the entire REPL suspends its normal input handling and presents a permission dialog. This is not a modal overlay in the browser sense — terminal UIs do not have Z-order — but rather a targeted state change that replaces the normal bottom section of the REPL with a specialized UI.

11.5.1 From Tool to Dialog

The journey from a tool's permission check to a visible dialog involves several layers. Recall from Chapter 7 that checkPermissions returns a PermissionDecision. When the decision is needs_user_confirmation, the tool's invocation is paused and a permission request is placed in a shared queue.

REPL.tsx watches this queue via the useCanUseTool hook. When a new request arrives, the hook updates the pendingPermissionRequest state that REPL.tsx passes to the PermissionDialog component. Simultaneously, the isWaitingForPermission flag is set to true, which causes PromptInput to stop accepting keyboard input (it renders as visually dimmed and does not call useInput while disabled).

The pause-and-resume mechanism works because checkPermissions is an async function. The tool awaits its result. The permission system uses a deferred promise: it creates a Promise whose resolve function is stored in a map keyed by request ID. When the user makes a decision, useCanUseTool calls that stored resolve function with the decision. The awaited promise resolves, and the tool continues (or aborts).

11.5.2 Dialog Variants

The three decision outcomes correspond to three human-readable options shown in the dialog:

Allow once (interactive_temporary) permits the specific invocation but does not record any preference. The next time the same tool is called with the same class of arguments, the dialog will appear again.

Allow always (interactive_permanent) permits the invocation and records a permanent allow rule in settings.json under the toolPermissions key. The rule is keyed by tool name and, for tools like BashTool, by a pattern that matches the argument. Subsequent calls matching the rule skip the dialog entirely.

Deny (deny) cancels the tool invocation. The tool's call() method returns a ToolResult with a denied: true flag, which the agentic loop serializes back to the model as a tool result indicating the action was not permitted.

The dialog UI shows the tool name in a header, the critical arguments rendered in a way that makes the danger visible. For BashTool, the command string is shown verbatim with shell syntax highlighting — the user can see exactly what will be executed. For WriteFileTool, the target path is shown prominently along with the number of lines and bytes that will be written. For FileEditTool, a compact diff is shown. The rendering for each tool type is defined in the tool's renderPermissionRequest method, called from the PermissionDialog component.

tsx
// src/components/PermissionDialog.tsx — structure (conceptual)
function PermissionDialog({ request, onDecision }: Props) {
  const [selected, setSelected] = useState<0 | 1 | 2>(0)

  useInput((input, key) => {
    if (key.upArrow) setSelected(prev => Math.max(0, prev - 1) as 0 | 1 | 2)
    if (key.downArrow) setSelected(prev => Math.min(2, prev + 1) as 0 | 1 | 2)
    if (key.return) onDecision(OPTIONS[selected].decision)
    // Allow typing shortcut keys: 'y' for allow once, 'a' for always, 'n' for deny
    if (input === 'y') onDecision('interactive_temporary')
    if (input === 'a') onDecision('interactive_permanent')
    if (input === 'n') onDecision('deny')
  })

  return (
    <Box flexDirection="column" borderStyle="round" borderColor="yellow">
      <Text bold>{request.toolName}</Text>
      {/* Tool-specific argument rendering */}
      {request.renderedArgs}
      {/* Option buttons */}
      {OPTIONS.map((opt, i) => (
        <Box key={opt.label}>
          <Text color={selected === i ? 'cyan' : undefined}>
            {selected === i ? '>' : ' '} {opt.label}
          </Text>
        </Box>
      ))}
    </Box>
  )
}

The keyboard shortcuts deserve emphasis. While the Up/Down + Enter navigation is discoverable, experienced users learn y, a, and n as single-key shortcuts that let them respond to permission requests without looking at the selection state. This is a small but meaningful ergonomic choice — permission dialogs interrupt flow, and minimizing the keystrokes required to respond to them reduces friction.

11.5.3 Multi-Agent Permission Proxying

In multi-agent (swarm) mode, sub-agents run in separate contexts but share the parent REPL's UI. When a sub-agent needs a permission decision, the request is proxied up to the parent REPL's useCanUseTool hook via the useReplBridge mechanism (described in Chapter 13). The PermissionDialog in this case shows an additional header line identifying which sub-agent is requesting the permission:

Sub-agent: research_agent (task: "find API documentation")
Tool: BashTool
Command: curl https://api.example.com/v1/docs

This is important because the user may have granted BashTool broad permissions for the main agent while wanting to be more cautious about commands issued by an automated sub-agent that might be operating with less human oversight.


11.6 The Task Panel

Background tasks in Claude Code are execution contexts that run concurrently with the main conversation: sub-agents spawned by the spawn_agent tool, long-running shell processes started with a & suffix, or explicit background task requests via the /background command. The TaskPanel component provides continuous visibility into these tasks without requiring the user to navigate away from the main conversation.

11.6.1 Panel Layout and Behavior

The TaskPanel renders at the top of the REPL, above the message list. In its collapsed state (the default), it shows a single summary line:

[2 tasks running: build_test (1m 23s), research_agent (45s)]

The collapsed view is intentionally minimal. It communicates the existence and count of running tasks without consuming vertical space that the message list needs. The user presses a configurable keyboard shortcut (the default is Ctrl+T) to toggle the panel into its expanded state.

In the expanded state, each task gets its own row:

Tasks (2 running)
  build_test       [running]  1m 45s  npm run test:all
  research_agent   [running]    52s   Searching for API documentation
  compile_check    [done]     3m 02s  tsc --noEmit

The status column cycles through waiting, running, done, and failed as task state changes. The elapsed time column shows how long the task has been running (for active tasks) or how long it ran in total (for completed tasks). The description column shows a short summary extracted from the task's initial command or description.

11.6.2 Live Updates Without Layout Thrash

The challenge with the TaskPanel is that its content changes continuously — elapsed times update every second, status transitions happen at unpredictable intervals — but every change to its rendered height would shift the entire message list down or up, which would feel like the content is jumping.

The solution is that the TaskPanel reserves a fixed height when expanded. The height is determined when the panel opens based on the current number of tasks, and does not grow as new tasks are added — new tasks added after the panel opens are silently appended to a queue and shown when there is room. The elapsed time updates are handled with in-place character rewrites (exploiting the differential renderer from Chapter 10) rather than full re-layouts. Only the time digits change; the row structure stays constant.

When the panel is collapsed, its height is always exactly one row, so no message list reflow is needed on collapse/expand — instead, the message list's viewport height changes and the scroll offset is adjusted to preserve the visible content position.


11.7 Teammate Views in Multi-Agent Mode

When Claude Code operates as an orchestrator in a multi-agent system — coordinating a team of sub-agents each working on their own subtask — the REPL needs to present not just the orchestrator's conversation but also meaningful visibility into what the sub-agents are doing.

11.7.1 Process Model vs. In-Process Model

Sub-agents can be spawned in two modes. In the first mode (the default for long-running tasks), each sub-agent is a separate OS process, running its own independent Claude Code instance with its own terminal. Coordination between the orchestrator and sub-agents happens via the useReplBridge IPC mechanism. In this mode, the "teammate views" are implemented outside of the main REPL entirely — they appear as separate terminal panes in tmux, iTerm2 split panes, or Windows Terminal panes, and the main REPL shows only summary status in the TaskPanel.

In the second mode (used when sub-agents need to share state efficiently or when the host terminal does not support split panes), sub-agents run in the same process as the orchestrator. Each sub-agent gets an independent React subtree rendered into a separate virtual terminal buffer, and the top-level layout component stacks these buffers side by side (or in a configurable arrangement) before writing them to the physical terminal.

┌─────────────────────────────────┬────────────────────────────────┐
│  Main Agent                     │  research_agent                │
│  Working on: coordinate tasks   │  Working on: find API docs     │
│  ...                            │  ...                           │
│                                 │                                │
│  > |                            │  Fetching https://...          │
└─────────────────────────────────┴────────────────────────────────┘

The side-by-side rendering uses the same Box/flex layout system from Chapter 10: the root container is a <Box flexDirection="row"> containing one <Box> per agent, each with width={Math.floor(terminalWidth / agentCount)}. Each agent's <Box> contains its own independent <REPL> instance with its own hook state.

11.7.2 Leader Permission Bridge

Regardless of the process model, only the main (orchestrator) agent's REPL has the user's keyboard attention at any given time. When a sub-agent needs a permission decision, it cannot directly show a dialog — the user is not looking at its terminal buffer.

The leader permission bridge solves this by routing sub-agent permission requests to the orchestrator's permission queue. The useReplBridge hook in each sub-agent's REPL connects to the orchestrator's useCanUseTool hook via an IPC channel (for separate-process agents) or a shared React context (for in-process agents). When the sub-agent calls checkPermissions, the bridge intercepts the request and forwards it to the orchestrator REPL, which displays the dialog with the sub-agent attribution shown in Section 11.5.3.

From the user's perspective, all permission requests appear in one place regardless of how many agents are running. This is a deliberate design choice: it prevents the user from being overwhelmed by simultaneous dialogs from multiple agents and ensures that the human remains in control of the system's permission grants even in highly automated scenarios.


Long sessions accumulate many messages. Finding a specific earlier message — a tool result from an hour ago, a particular piece of information the model provided earlier — requires either scrolling manually through the virtual list or using the transcript search feature.

11.8.1 Activating Search Mode

Pressing Ctrl+R activates search mode. This mirrors the history search shortcut used in bash and zsh, which makes it discoverable to users already familiar with the terminal. When search mode is active, the PromptInput component is replaced by a search input that shows a (search): prefix:

(search): read file tool

Activating search mode is a state change in REPL.tsx: the isSearchActive flag flips to true, which causes the render to substitute the search input for the normal prompt input. The normal input's current value is preserved in state so that it can be restored when search mode ends.

11.8.2 Real-Time Fuzzy Filtering

As the user types in the search input, useTypeahead runs fuzzy matching against the normalized text content of all messages in the conversation history. The matching is performed on the displayText field of each DisplayMessage, which is the plain-text content stripped of ANSI codes and tool-specific formatting.

typescript
// Message search filtering
function searchMessages(
  messages: DisplayMessage[],
  query: string
): SearchResult[] {
  if (query.length < 2) return []  // Require at least 2 chars to avoid overwhelming results

  return messages
    .map((msg, index) => ({
      message: msg,
      index,
      score: fuzzyScore(query, msg.displayText),
    }))
    .filter(result => result.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, 20)  // Show at most 20 results
}

The results are not shown as a separate overlay but instead control the MessageList's scroll position. The best-matching message is scrolled into view, and its background is highlighted using a reversed color scheme (terminal equivalent of a selection highlight). Subsequent Up/Down keypresses in search mode cycle through the other matches.

11.8.3 Match Highlighting and Navigation

Within the visible message, the matching substring is highlighted. This uses the same technique as the syntax highlighter: the message text is split into runs, and non-matching runs are rendered in the normal color while matching runs are rendered with a different background color.

The implementation is simpler than it might appear because the fuzzy match already knows which character indices in the candidate string matched the query characters. These indices are used directly as the highlight positions:

typescript
// Given match indices from fuzzy matching, split text into highlighted runs
function buildHighlightedRuns(
  text: string,
  matchIndices: Set<number>
): Array<{ text: string; highlighted: boolean }> {
  const runs: Array<{ text: string; highlighted: boolean }> = []
  let current = ''
  let currentHighlighted = false

  for (let i = 0; i < text.length; i++) {
    const isHighlighted = matchIndices.has(i)
    if (isHighlighted !== currentHighlighted && current.length > 0) {
      runs.push({ text: current, highlighted: currentHighlighted })
      current = ''
      currentHighlighted = isHighlighted
    }
    current += text[i]
    currentHighlighted = isHighlighted
  }

  if (current.length > 0) {
    runs.push({ text: current, highlighted: currentHighlighted })
  }

  return runs
}

Pressing Escape or Enter exits search mode. Pressing Enter with a match active returns focus to the main input while keeping the message list scrolled to the matched message, making it easy to continue a conversation in context of the retrieved message.

11.8.4 Full History Retention

The search feature works well because Claude Code does not prune the in-memory message list during a session. The virtual scroll system described in Section 11.2.4 means that there is no performance reason to discard old messages from React state — they consume memory but not render time. The only limit on searchable history within a session is available RAM, which in practice allows tens of thousands of messages without issue.

Across sessions, history is bounded by the /compact mechanism and by the token window limit of the underlying model. But within a single session, transcript search reliably covers everything that has been said.


11.9 The REPL State Machine

It is worth stepping back and characterizing REPL.tsx's overall behavior as a state machine rather than just a collection of components and hooks. The component is always in exactly one of a small number of mutually exclusive states, and transitions between states are well-defined.

In the Idle state, the user can type freely, navigate history, trigger completions, and submit. In the Querying state, the agent is running: the model is generating tokens, tools may be executing, and the PromptInput shows a "stop" indicator that allows the user to interrupt with Escape. In WaitingForPermission, input is suspended and the PermissionDialog has focus. Searching is the transcript search mode. Expanding is the task panel in its expanded state.

Most transitions are clean: the REPL knows precisely when a query completes (the useLogMessages hook emits a terminal event), when a permission request arrives and resolves (via useCanUseTool), and when the user triggers mode changes.

The one complicated case is the Escape key in Querying state. Pressing Escape while a query is running sends an interrupt signal to the QueryEngine (via an abort controller), which triggers graceful cancellation: the current tool (if any) is given a brief window to clean up, the model stream is closed, and the REPL transitions back to Idle with a SystemMessage noting the interruption. This is not instantaneous — tool cleanup can take a second or two — so the REPL shows a "cancelling..." indicator during the transition.


Key Takeaways

src/screens/REPL.tsx is the application layer that assembles everything from the preceding chapters into a coherent interactive experience. It uses the Ink rendering framework from Chapter 10 as its output medium, the QueryEngine from Chapter 9 as its backend, the tool and permission systems from Chapters 6 and 7 as the machinery it mediates, and the command system from Chapter 8 as an additional input path for user control.

The message display pipeline — event subscription, batching, normalization, and virtual rendering — is a four-stage system designed around one insight: streaming token output is extremely fast, and every architectural decision in the pipeline exists to ensure that this speed translates into smooth rendering rather than dropped frames or stutter. Batching merges rapid events, normalization separates message semantics from rendering logic, and virtual scrolling ensures that session length does not degrade performance.

The PromptInput component is more complex than it appears. Multi-line editing, history navigation, @-file expansion, and the bracketed-paste guard are all non-trivial features that exist because the alternative — a simple single-line input — would be inadequate for the prompts that users of an AI coding tool actually write.

The permission dialog system is architecturally significant because it represents a synchronous human decision in the middle of an asynchronous computational process. The deferred-promise mechanism — creating a promise whose resolve function is stored until the user responds — is the right way to model this: the tool awaits the promise, the user provides input at their own pace, and the promise resolves exactly once with a well-typed decision.

The Task Panel and Teammate Views extend the single-agent REPL design to the multi-agent case without fundamentally changing the architecture. Background tasks get summary visibility in the panel; sub-agents get either separate terminal panes or in-process React subtrees; and the leader permission bridge ensures that human oversight remains centralized regardless of how many agents are running.

Transcript search completes the picture: the full session history is retained in memory and searchable in real time, with match highlighting and scroll navigation making it easy to retrieve and act on earlier conversation content.


The hooks that power this component — useLogMessages, useTextInput, useTypeahead, useCanUseTool, useCommandQueue, and useReplBridge — each have non-trivial implementations involving async state management, event sourcing, and coordination with external systems. Chapter 13 examines this hooks layer in detail, showing how each hook is implemented and how they collectively provide the state that REPL.tsx needs to function.

Built for learners who want to read Claude Code like a real system.