Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/superpowers/plans/2026-09-07-agent-terminal-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Agent Terminal Follow Implementation Plan

> **For agentic workers:** Execute inline with executing-plans. The initial plan is preserved in first commit `7471ee41`; this revision records the implemented contracts and review corrections rather than retaining incorrect copy-paste recipes.

**Goal:** Support Jump to Latest and per-agent/Tail All auto-follow on raw agent terminal surfaces, without affecting plain shell panes.

**Architecture:** Keep the existing runtime counter and follow flags as the source of truth. `AgentTerminalLeaf` owns the xterm lifetime; `useAgentTerminalFollow` translates intent into public viewport operations without reattaching the PTY. Tail All is masked by composed subtree visibility.

**Tech Stack:** React 18, xterm 6, Vitest renderer and real Electron system tests. Use Node 24 as specified by `.nvmrc`.

**Tracking:** Issue #837, branch `feat/agent-terminal-follow`, worktree `.worktrees/agent-terminal-follow`.

## Scope and Contracts

- Agent terminal surfaces only: OpenCode Terminal, Claude/Codex terminal views, and raw Hybrid fallback. No new provider and no shell-terminal follow.
- Commands remain agent-scoped but lose their rendered-feed-only gate. Never switch surfaces or acquire a rendered lease to jump/follow.
- A new jump counter scrolls to bottom; a mount/session change baselines the old counter rather than replaying it.
- Effective follow is `(runtime.tailMode || tailAllMode) && ownerVisible`. Hidden retained panes suspend forced scrolling and re-pin on reveal.
- Live writes and attach replay pin only after parsing completes. Each deferred callback checks current follow state and terminal lifetime.
- Re-pin after `onScroll` is deferred and coalesced: xterm's browser viewport suppresses reentrant scroll operations.
- Save the first visible normal-buffer line with `registerMarker(viewportY - baseY - cursorY)`. Markers track trimming; `baseY` is NOT a trim counter and stops growing when scrollback fills.
- Disengagement restores the retained marker via `scrollToLine`; an evicted marker falls back to the oldest surviving line. Do not apply a normal-buffer anchor to the alternate screen.
- Dispose markers and scroll listeners on session change/unmount. A new session cannot inherit the prior session's saved reading position.
- Alternate-screen TUIs often own their history internally. These commands control the xterm viewport, not provider-specific keybindings or internal transcript navigation.

## Implementation and Review

- [x] Create dedicated branch and worktree; commit plan first.
- [x] Add jump/follow hook and `AgentTerminalLeaf` wiring, without broadening the mount effect dependencies.
- [x] Remove rendered-only policies from `toggle-tail` and `jump-latest-message`; keep shell exclusion.
- [x] Add TAIL indicator and align command/control descriptions with both agent surfaces.
- [x] Add renderer integration tests through the real session-data dispatcher, including deferred write/replay callbacks, session swaps and hidden-pane behavior.
- [x] Obtain initial independent Claude and Codex reviews via Agent Code MCP orchestration.
- [x] Fix unconditional write callback, pre-parse replay pin, synchronous reentrant scrolling, session-state leakage and tautological command guard test.
- [x] Replace incorrect numeric/baseY restoration with public xterm markers.
- [x] Add a colocated Electron system test using the installed xterm and production hook. Confirm red before the marker fix: after 100 trimmed lines, restoration returned `line-1191` instead of `line-1091`. Confirm green afterward.

## Files and Responsibilities

- `src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts`: session-local intent, marker lifetime and deferred viewport re-pin.
- `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx`: effective follow, write/replay completion guards and header status.
- `src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.follow.renderer.test.tsx`: component/dispatcher/lifecycle regression tests; fake Terminal is not authoritative about trimming.
- `src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts`: real Chromium/xterm re-pin, content restoration after trimming, eviction fallback, jump and alternate-buffer coverage; isolated temporary userData and no provider processes.
- `src/renderer/src/features/workspace/commands/paneCommands.follow.renderer.test.ts`: actual kind-guard invocation using a focused-tab fixture, plus rendered-policy regression guard.
- `src/renderer/src/features/workspace/commands/paneCommands.ts`, `workspace/control/preferences.ts`, `app-state/uiShell/types.ts`: truthful user/operator help and ownership comments.
- Existing submit and dimension-ownership test mocks: add the real public scroll API consumed by the component, without weakening production behavior.

## Verification and Delivery

Run from this worktree with Node 24 on PATH. Do not pipe test commands through truncation filters that hide their exit status.

```bash
npm run typecheck
npm run test:renderer -- AgentTerminalLeaf paneCommands.follow
npm run test:system -- agentTerminalFollow
npm run test:contract
npm run check:keybindings
npm test -- --maxWorkers=4 --testTimeout=15000
git diff --check
```

Earlier Node 25 full-suite runs hit `localStorage.setItem` failures also seen in the other checkout; they are not evidence of a clean Node 24 baseline. A Node 24 renderer run passed 548 tests with one unrelated cold-transform timeout. Record fresh results on the final integrated revision, not inferred counts or test-name-only comparisons.

- [ ] Commit the review corrections and integrate current `main` without overwriting concurrent work.
- [ ] Re-run targeted and full checks under the project Node version; report any genuine baseline failures separately.
- [ ] Get both existing reviewers to recheck the final revision, including the real-xterm evidence.
- [ ] Open a fully described PR linked with `Fixes #837`; keep review findings and verification synchronized there.
- [ ] Wait for CI, resolve valid findings, then merge using the user's explicit review-then-merge authorization. Never bypass failing checks or merge an unreviewed revision.
15 changes: 7 additions & 8 deletions src/renderer/src/app-state/uiShell/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export type UiShellState = {
* leaving click interception active after restart would make the app appear
* broken, and the captured inputs may contain developer-sensitive payloads. */
renderingDebugMode: boolean
/** Workspace-wide feed auto-follow. While true, every *visible* agent pane
/** Workspace-wide agent auto-follow. While true, every *visible* agent pane
* tails, and a pane that becomes visible later (a new lane selection, a tab
* switch, a fresh split) is already tailing without the user re-running the
* command. This is a stance on the workspace, not a batch operation.
Expand All @@ -213,13 +213,12 @@ export type UiShellState = {
* WHY there is no "which panes are visible" query behind this: there is no
* canonical visible-session selector in this codebase (`resolveTabSessions`
* answers membership, not visibility, and says so in its own header). The OR
* is resolved inside `TileLeaf`, which with one exception mounts only for
* on-screen panes — so grid tab-scoping, dispatch lanes, duplicate tiled
* lanes, and terminal exclusion all fall out of what React already mounts.
* The exception is Global Editor fullscreen, which keeps the workspace
* mounted under `display: 'none'`; the full reasoning for why that is
* tolerable lives at the OR site in TileLeaf.tsx. Reader Mode is a takeover
* and mounts no TileLeaf at all, so Tail All is inert there. See
* is resolved inside `TileLeaf` and `AgentTerminalLeaf`, each masked by
* subtree visibility. Grid tabs and Dispatch lanes therefore follow without
* a second visibility enumeration. Plain shell TerminalLeaf never reads it.
* Global Editor fullscreen and Reader/Spotlight/Settings retain hidden
* workspace subtrees; their composed mask suspends follow until re-reveal.
* The full rationale lives at the OR site in TileLeaf.tsx. See
* docs/superpowers/plans/2026-07-20-tail-all.md before replacing this with an
* enumeration; the enumeration has at least four documented ways to be wrong.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'

import type { CommandContext } from '@renderer/features/command-palette/types'
import { paneCommands } from '@renderer/features/workspace/commands/paneCommands'

// Guards the command-availability half of terminal follow: both commands
// previously carried `renderedViewPolicy: 'requires-rendered-feed'`, which
// `commandAllowedByRenderedViewPolicy` resolves to false on ANY terminal
// surface — and unconditionally for OpenCode Terminal sessions
// (providerRuntime === 'terminal'). Someone re-adding the policy "for
// consistency" would silently uninstall the commands from raw terminal views
// again while the leaf-side behavior stays green.

// The state shape `commandTargetSessionIdForState` needs to resolve a target
// (mirror of contextWithAgent() in sessionCommands.renderer.test.ts — active
// tab with a focused leaf). Without it the selector returns null and `when`
// answers false for every kind, which would make this guard vacuous.
function contextWithKind(kind: string): CommandContext {
return {
workspace: {
state: {
activeTabId: 'tab',
dispatchMode: null,
sessions: {
agent: { cwd: '/projects/app', kind, providerSessionId: 'provider-abc' },
},
tabs: [{ id: 'tab', focusedSessionId: 'agent', root: { type: 'leaf', sessionId: 'agent' } }],
},
},
ui: {},
flags: {},
} as unknown as CommandContext
}

describe('follow command availability', () => {
const tail = paneCommands.find(command => command.id === 'toggle-tail')
const jump = paneCommands.find(command => command.id === 'jump-latest-message')

it('exposes both follow commands without a rendered-view policy', () => {
expect(tail).toBeDefined()
expect(jump).toBeDefined()
expect(tail!.renderedViewPolicy).toBeUndefined()
expect(jump!.renderedViewPolicy).toBeUndefined()
})

it('keeps both commands hidden for plain shell terminals and visible for agent kinds', () => {
for (const command of [tail!, jump!]) {
expect(command.when?.(contextWithKind('terminal'))).toBe(false)
expect(command.when?.(contextWithKind('claude'))).toBe(true)
// OpenCode covers both process runtimes: the structured HTTP session
// and OpenCode Terminal (same kind, providerRuntime 'terminal').
expect(command.when?.(contextWithKind('opencode'))).toBe(true)
}
})
})
36 changes: 21 additions & 15 deletions src/renderer/src/features/workspace/commands/paneCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,12 @@ export const paneCommands: CommandDef[] = [
surface: 'session',
title: 'Auto-follow Focused Agent',
keywords: ['tail'],
description: '**What it does:** Toggles feed **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection.',
renderedViewPolicy: { kind: 'requires-rendered-feed' },
description: '**What it does:** Toggles **auto-follow** for the focused target.\n\n**Use when:** You want output to stay pinned to the bottom.\n\n**Notes:** Applies to the visible command target, including **Dispatch** selection. Works in both the rendered feed and raw agent terminal views — in a terminal view the TUI output stays pinned to the bottom.',
// NO `renderedViewPolicy` — deliberately: this command owns follow
// behavior on BOTH agent surfaces now (Feed's tailMode on the rendered
// surface, useAgentTerminalFollow on the raw terminal). The old
// 'requires-rendered-feed' gate hid it on terminal surfaces, where
// following is exactly as meaningful.
getState: ({ workspace, flags }) => {
const sessionId = commandTargetSessionId(workspace)
const tailMode = sessionId
Expand Down Expand Up @@ -514,11 +518,11 @@ export const paneCommands: CommandDef[] = [
when: ({ workspace }) => {
const sessionId = commandTargetSessionId(workspace)
if (!sessionId) return false
// WHY tail is agent-only even though terminals are Dispatch rows:
// tailMode controls the rendered transcript/feed scroll container.
// Terminal panes delegate scrollback to xterm.js, so toggling this
// runtime flag on a terminal would present a command that appears to
// work while changing nothing visible.
// WHY tail is agent-only even though plain shells are Dispatch rows:
// agent sessions consume tailMode on both of their surfaces since the
// terminal-follow work (useAgentTerminalFollow). Plain shell terminals
// (kind === 'terminal') delegate entirely to xterm scrollback and have
// no tail state.
return workspace.state.sessions[sessionId]?.kind !== 'terminal'
},
run: ({ workspace }) => {
Expand All @@ -538,13 +542,13 @@ export const paneCommands: CommandDef[] = [
surface: 'app',
title: 'Auto-follow All Visible Agents',
description:
'**What it does:** Toggles feed **auto-follow for every visible agent** at once.\n\n**Use when:** You are watching several agents work and want them all pinned to the bottom.\n\n**Notes:** Scopes to what is on screen — in **single dispatch** that is the one agent, in **tiled** every lane, in the **grid** the current tab\'s panes only. Panes you open afterward tail too, until you toggle it off. Terminals are never affected.\n\n**Caution:** A tailing feed cannot be scrolled up this takes scrollback away from every visible pane at once, and turning it off does not restore where you were reading.',
'**What it does:** Toggles **auto-follow for every visible agent** at once.\n\n**Use when:** You are watching several agents work and want them all pinned to the bottom.\n\n**Notes:** Scopes to what is on screen — in **single dispatch** that is the one agent, in **tiled** every lane, in the **grid** the current tab\'s panes only. Panes you open afterward tail too, until you toggle it off. Plain shell terminals are never affected; raw agent terminal views follow too.\n\n**Caution:** A tailing pane cannot be scrolled up. Turning this off leaves individually enabled followers on; other panes restore their earlier reading position where that content is still retained. Raw terminal follow controls xterm scrollback, not a TUI\'s internal history.',
keywords: ['tail', 'all', 'follow', 'auto-scroll', 'bulk', 'every', 'watch', 'tail all', 'tail'],
// WHY no `renderedViewPolicy` even though per-session Tail has one: that
// gate resolves ONE target session and checks whether it renders a feed.
// Tail All has no single target — it is a stance that applies to whatever
// is mounted, now and later. Gating it on the currently focused pane would
// hide a workspace-level command because of one pane's view mode.
// WHY no `renderedViewPolicy` — Tail All is a stance over whatever is
// mounted, on either agent surface (rendered feed or raw terminal view,
// both of which follow now). Gating it on the currently focused pane's
// view mode would hide a workspace-level command for pane-local reasons.
// (Per-session Tail used to carry such a policy; it no longer does.)
//
// WHY no `when` guard: it is meaningful in every layout mode, and with zero
// agent panes visible it is a harmless no-op rather than a command that
Expand All @@ -557,8 +561,10 @@ export const paneCommands: CommandDef[] = [
category: 'navigate',
surface: 'session',
title: 'Jump to Latest Message',
description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only.',
renderedViewPolicy: { kind: 'requires-rendered-feed' },
description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only — in a raw terminal view this scrolls the TUI viewport to the bottom.',
// NO `renderedViewPolicy` — the xterm viewport answers jump requests too
// (useAgentTerminalFollow); gating on a rendered feed would hide this on
// the surface where returning to the bottom is most often needed.
when: ({ workspace }) => {
const sessionId = commandTargetSessionId(workspace)
if (!sessionId) return false
Expand Down
Loading