diff --git a/research/docs/2026-02-21-workflow-sdk-inline-mode-research.md b/research/docs/2026-02-21-workflow-sdk-inline-mode-research.md new file mode 100644 index 000000000..e3d906af3 --- /dev/null +++ b/research/docs/2026-02-21-workflow-sdk-inline-mode-research.md @@ -0,0 +1,487 @@ +--- +date: 2026-02-21 05:21:04 UTC +researcher: Copilot +git_commit: 06b2a8f6c411b6ffb6a4928a33d6ab1cbeb3191d +branch: lavaman131/hotfix/ralph +repository: ralph +topic: "Workflow SDK Inline Mode, Clear Node Removal, Visual Mode Indicators, and Ralph Task List Persistence" +tags: [research, codebase, workflow-sdk, ralph, tui, opentui, keyboard-handling, theme, task-list, clear-nodes] +status: complete +last_updated: 2026-02-21 +last_updated_by: Copilot +--- + +# Research: Workflow SDK Inline Mode & Visual Mode Indicators + +## Research Question + +Document the current workflow SDK architecture (including the `/ralph` workflow command) and the TUI chat system to understand: +1. How the workflow SDK executes and whether its output appears in the main chat context +2. How the chat box outline/border styling works in OpenTUI +3. How keyboard input (specifically Ctrl+C) is handled +4. How tasks.json and task list widgets work with session IDs +5. How the reviewer agent updates task state +6. How clear nodes function in the workflow SDK + +This research informs a feature that makes workflows run inline in the chat context with visual mode indicators (teal border), double Ctrl+C workflow exit, persistent task list display for `/ralph`, and removal of clear nodes. + +## Summary + +The workflow system already runs **inline within the main chat context** — workflow commands like `/ralph` use `streamAndWait()` which sends prompts through the existing chat streaming pipeline. Clear nodes (`clearContextNode()`) emit signals that trigger session reset and message clearing via `context.clearContext()`. The chat box border uses `themeColors.inputFocus` (currently `#585b70` dark / `#acb0be` light), which is a static theme color. Ctrl+C handling already supports double-press exit, but has no concept of "workflow mode" — it either interrupts streaming, cancels workflows, or exits the TUI. The task list panel is already conditionally rendered based on `ralphSessionDir` state and watches `tasks.json` for updates. + +--- + +## Detailed Findings + +### 1. Workflow SDK Architecture + +#### Graph-Based Execution Engine (`src/graph/`) + +The workflow SDK is a full graph execution engine with these components: + +- **Type System** (`src/graph/types.ts`): Defines `BaseState`, `NodeDefinition`, `ExecutionContext`, `NodeResult`, `CompiledGraph`, and `GraphConfig` interfaces. Seven node types: `agent`, `tool`, `decision`, `wait`, `ask_user`, `subgraph`, `parallel`. + +- **Builder API** (`src/graph/builder.ts:136-696`): `GraphBuilder` class with fluent API — `start()`, `then()`, `if()`, `else()`, `endif()`, `loop()`, `wait()`, `catch()`, `compile()`. Entry point: `graph()` factory at line 694. + +- **Execution Engine** (`src/graph/compiled.ts:213-695`): `GraphExecutor` class executes compiled graphs via streaming BFS-style traversal. Factory functions: `createExecutor()`, `executeGraph()`, `streamGraph()` at lines 721-757. + +- **Node Factories** (`src/graph/nodes.ts`): Pre-built node types: + - `agentNode()` (line 170) — AI agent execution + - `clearContextNode()` (line 494) — Context window clearing + - `decisionNode()` (line 577) — Conditional routing + - `waitNode()` (line 668) — Human-in-the-loop pause + - `askUserNode()` (line 816) — Structured user questions + - `parallelNode()` (line 988) — Concurrent branch execution + - `subgraphNode()` (line 1126) — Nested workflow execution + - `contextMonitorNode()` (line 1374) — Context usage monitoring + +#### How Workflows Execute in Chat Context + +Workflows are NOT separate processes — they execute **within the main chat event loop**: + +1. User types `/ralph ` (`src/ui/chat.tsx:5344`) +2. `parseSlashCommand()` extracts command (`src/ui/commands/index.ts:148`) +3. `executeCommand()` looks up command in `globalRegistry` (`chat.tsx:3420`) +4. `CommandContext` created with session, helpers, and state (`chat.tsx:3451-3781`) +5. Command's `execute()` function invoked with context (`chat.tsx:3807`) +6. Command uses `context.streamAndWait()` to send prompts through normal chat pipeline +7. `streamAndWait()` returns a `Promise` that resolves on stream completion + +**Key Insight**: `streamAndWait()` wraps the regular message streaming — it creates placeholder assistant messages, processes chunks through the parts system, and renders in the chat transcript. The only difference is that `hideContent: true` option suppresses rendering while still accumulating content. + +#### Workflow Output Integration (`src/ui/chat.tsx:3714-3724`) + +``` +streamAndWait(prompt, options?) + └─> sendSilentMessage(prompt) // Triggers streaming without user msg + └─> handleChunk(chunk) // Accumulates in lastStreamingContentRef + └─> if !hideContent: handleTextDelta() // Updates parts for display + └─> handleComplete() // Resolves promise with {content, wasInterrupted} +``` + +- When `hideContent: false` (default in Step 2 loop), output renders normally in chat +- When `hideContent: true` (Step 1 task decomposition), output is accumulated but not rendered +- Empty placeholder messages are removed on completion when content was hidden + +### 2. Clear Nodes — Current Implementation + +#### `clearContextNode()` (`src/graph/nodes.ts:494-524`) + +Creates a node that emits a `context_window_warning` signal with `data.action = "summarize"` and `data.usage = 100` (forcing summarization). It does NOT directly call session methods — it relies on the workflow handler to respond to the signal. + +**Usage Pattern**: +```typescript +const clearNode = clearContextNode({ + id: "clear-after-research", + message: "Clearing context for spec creation" +}); +graph().start(researchNode).then(clearNode).then(specNode).compile(); +``` + +**In Loops**: +```typescript +builder.loop([clearContextNode, processNode], { + until: (s) => s.done +}); +// Chains: clearContextNode → processNode → loop_check +// On continue: returns to clearContextNode (first body node) +``` + +#### `context.clearContext()` (`src/ui/chat.tsx:3726-3744`) + +The actual clearing is performed by `CommandContext.clearContext()`: +1. Calls `onResetSession()` to destroy SDK session (line 3728) +2. Moves messages to history buffer via `appendToHistoryBuffer()` (line 3731) +3. Clears messages array: `setMessagesWindowed([])` (line 3732) +4. Resets UI state: `trimmedMessageCount`, `compactionSummary`, `showCompactionHistory`, `parallelAgents` (lines 3734-3737) +5. **Preserves**: `todoItems`, `ralphSessionDir`, `ralphSessionId` from refs (lines 3738-3743) + +#### Where Clear Context Is Called in Ralph (`src/ui/commands/workflow-commands.ts:684`) + +In the Ralph workflow, `context.clearContext()` is called before the review phase (Step 3, line 684) to give the reviewer agent a clean context window. Task state is preserved across this clear. + +#### Other Context Management + +- `contextMonitorNode()` (line 1374): Monitors token usage and triggers compaction based on agent type (OpenCode: summarize, Claude: recreate session, Copilot: warn only) +- `compactContext()` (line 1549): Direct session compaction function + +### 3. Chat Box Border/Outline Styling + +#### Current Border Rendering (`src/ui/chat.tsx:5685-5694`) + +```tsx + 0 ? SPACING.ELEMENT : SPACING.NONE} + flexDirection="row" + alignItems="flex-start" + flexShrink={0} +> +``` + +- **`borderColor`**: Uses `themeColors.inputFocus` — a static theme color +- **Dark theme value**: `#585b70` (Catppuccin Mocha Surface 2, `theme.tsx:230`) +- **Light theme value**: `#acb0be` (Catppuccin Latte Surface 2, `theme.tsx:262`) +- **`inputFocused`** state exists (line 1799) but is hardcoded to `true` + +#### Theme System (`src/ui/theme.tsx`) + +**ThemeColors Interface** (lines 20-61) — key border-related properties: +- `inputFocus: string` — Input border when focused (line 44) +- `inputStreaming: string` — Input border when streaming (line 46) +- `border: string` — General container borders (line 28) +- `dim: string` — Faded elements (line 54) + +**Theme Context** (lines 78-87): +```typescript +interface ThemeContextValue { + theme: Theme; + toggleTheme: () => void; + setTheme: (theme: Theme) => void; + isDark: boolean; +} +``` + +**Usage**: `const themeColors = useThemeColors()` returns `ThemeColors` object. Colors are reactive — changing theme via `setTheme()` or `toggleTheme()` triggers re-renders across all consuming components. + +#### OpenTUI Border API + +OpenTUI `` component supports: +- `border`: `boolean | BorderSides[]` — enables border +- `borderStyle`: `"single" | "double" | "rounded" | "heavy"` — border line style +- `borderColor`: `string | RGBA` — hex colors, named CSS colors, RGBA objects +- `focusedBorderColor`: `ColorInput` — color when `focused={true}` (default: `#00AAFF`) +- `focused`: `boolean` — toggles between `borderColor` and `focusedBorderColor` + +**Dynamic Color Changes**: OpenTUI supports state-driven border color changes via React state. When `borderColor` prop changes, `BoxRenderable` calls `this.requestRender()` for visual update. + +### 4. Keyboard Input / Ctrl+C Handling + +#### Ctrl+C Decision Tree (`src/ui/chat.tsx:4212-4402`) + +The handler processes Ctrl+C through a priority chain: + +1. **Text selection?** → Copy to clipboard, return (lines 4214-4220) +2. **Streaming?** → Interrupt stream: abort controller, map agents to "interrupted", cancel workflow (lines 4222-4306) +3. **Sub-agents running?** → Interrupt sub-agents, finalize tasks (lines 4308-4349) +4. **Workflow active?** → Cancel workflow via state update (lines 4351-4366) +5. **Textarea has content?** → Clear textarea (lines 4368-4374) +6. **Empty/idle** → Double-press exit logic (lines 4376-4401) + +#### Double-Press Exit Logic (lines 4376-4401) + +``` +First press: + interruptCount = 1 + setCtrlCPressed(true) // Shows "Press Ctrl-C again to exit" + setTimeout(1000ms) // Reset counter after 1 second + +Second press (within 1s): + interruptCount >= 2 + onExit() // Calls cleanup() → process exit +``` + +#### Warning Display (`src/ui/chat.tsx:5766-5773`) + +```tsx +{ctrlCPressed && ( + + + Press Ctrl-C again to exit + + +)} +``` + +#### Signal Handler Setup (`src/ui/index.ts:1513-1540`) + +- `exitOnCtrlC: false` in renderer options — disables default exit-on-Ctrl+C +- `useKittyKeyboard: { disambiguate: true }` — Ctrl+C arrives as keyboard event, not SIGINT +- SIGINT handler calls `handleInterrupt("signal")` as fallback for non-Kitty terminals +- AbortController wraps SDK stream for immediate cancellation + +#### Dual Source Handling (`src/ui/index.ts:1448-1510`) + +`handleInterrupt(sourceType: "ui" | "signal")` handles both keyboard and signal Ctrl+C: +- If streaming: aborts controller, resets state, tracks telemetry +- If idle: increments counter, shows warning, exits on double-press + +**Key State Variables** (`chat.tsx`): +- `interruptCount` (line 1806) — consecutive press counter +- `interruptTimeoutRef` (line 1807) — 1s reset timeout +- `ctrlCPressed` (line 1810) — warning visibility +- `isStreamingRef` (line 1974) — synchronous streaming check + +### 5. Ralph Workflow Specifics + +#### Session & Task Lifecycle (`src/ui/commands/workflow-commands.ts:547-800`) + +**Initialization** (lines 581-591): +1. `sessionId = crypto.randomUUID()` +2. `initWorkflowSession("ralph", sessionId)` creates directory at `~/.atomic/workflows/sessions/{sessionId}/` +3. Stored in `activeSessions` map +4. Updates workflow state: `workflowActive: true`, `workflowType: "ralph"` + +**Step 1 — Task Decomposition** (lines 593-617): +1. `streamAndWait(buildSpecToTasksPrompt(prompt), { hideContent: true })` +2. Parses tasks from JSON via `parseTasks(step1.content)` +3. `saveTasksToActiveSession(tasks, sessionId)` writes `tasks.json` to session dir +4. Seeds in-memory state via `context.setTodoItems()` +5. Sets `ralphSessionDir`, `ralphSessionId`, `ralphTaskIds` + +**Step 2 — Implementation Loop** (lines 636-668): +1. Loops until all tasks completed or `MAX_RALPH_ITERATIONS = 100` +2. First iteration: `buildBootstrappedTaskContext()` injects task list +3. Subsequent iterations: `buildContinuePrompt()` +4. `streamAndWait(prompt)` without `hideContent` — output visible in chat +5. `readTasksFromDisk()` after each iteration to update UI state +6. Exits if `allCompleted` or `!hasActionableTasks()` + +**Step 3 — Review & Fix** (lines 670-794): +1. Only runs if all tasks completed +2. `context.clearContext()` before review (line 684) +3. `context.spawnSubagent()` spawns `reviewer` agent (lines 694-697) +4. `parseReviewResult()` extracts findings +5. Saves `review-{iteration}.json` to session directory +6. `buildFixSpecFromReview()` generates fix specification +7. If fixes needed, re-invokes Steps 1-2 with fix spec + +#### tasks.json File Format + +Tasks are written via `saveTasksToActiveSession()` using `atomicWrite()` (temp file + rename): +```json +[ + { "id": "#1", "content": "Task description", "status": "pending", "blockedBy": [] }, + { "id": "#2", "content": "Another task", "status": "in_progress", "blockedBy": ["#1"] } +] +``` + +- File path: `~/.atomic/workflows/sessions/{sessionId}/tasks.json` +- `atomicWrite()` (lines 133-156) uses temp file + rename for safe updates +- Task status values: `"pending"`, `"in_progress"`, `"completed"`, `"error"` + +#### Task List Panel Visibility (`src/ui/chat.tsx:5674-5679`) + +```tsx +{ralphSessionDir && showTodoPanel && ( + +)} +``` + +- `ralphSessionDir` (line 1926) — set by `context.setRalphSessionDir()` +- `showTodoPanel` (line 1922) — toggled by Ctrl+T, default `true` +- Task list watches `tasks.json` via `watchTasksJson()` (file watcher on session directory) + +#### File Watching System (`src/ui/commands/workflow-commands.ts:806-858`) + +- Watches **directory** (not file) to catch creation events (line 846) +- Reads `tasks.json` on change, parses JSON, normalizes via `normalizeTodoItems()` +- Debounces reads to handle rapid file updates +- Ignores errors for missing/mid-write files + +### 6. Reviewer Agent Integration + +#### Reviewer Agent Definitions + +Three parallel definitions for each coding agent: +- `.claude/agents/reviewer.md` +- `.github/agents/reviewer.md` +- `.opencode/agents/reviewer.md` + +#### Reviewer in Ralph Workflow (`workflow-commands.ts:694-697`) + +```typescript +context.spawnSubagent({ + type: "reviewer", + instruction: buildReviewPrompt(...) +}) +``` + +The reviewer is spawned as a sub-agent via `context.spawnSubagent()` which: +1. Formats instruction into `sendSilentMessage()` call +2. Sets `hideStreamContentRef = true` to suppress UI rendering +3. Accumulates output in `lastStreamingContentRef` +4. Returns result via Promise resolution + +**Current Behavior**: The reviewer does NOT currently update `tasks.json` directly. It returns a structured review result that is parsed by `parseReviewResult()`. If fixes are needed, the workflow creates a new fix specification and re-runs Steps 1-2, which generates a NEW set of tasks. + +### 7. Subagent Bridge Architecture (`src/graph/subagent-bridge.ts`) + +The bridge uses a multi-layered event-driven architecture: + +1. **SubagentGraphBridge**: Created in chat component with a session factory (`createSubagentSession`) +2. **Session Factory**: Creates isolated sessions per subagent (no shared context) +3. **SDK Event System**: Three event types route through handlers in `ui/index.ts`: + - `subagent.start` (line 963): Creates `ParallelAgent` in UI state + - `tool.complete` (line 702): Parses Task tool result, finalizes agent status + - `subagent.complete` (line 1080): Updates agent status to completed/error + +**Correlation ID Chain**: `SDK IDs → internal toolId → agentId` tracked in `sdkToolIdMap`, `toolCallToAgentMap`, `sdkCorrelationToRunMap`. + +### 8. Workflow State Management (`src/ui/chat.tsx`) + +**Key State Variables for Workflow Mode**: +- `workflowState.workflowActive` (boolean) — whether a workflow is currently running +- `workflowState.workflowType` (string) — type of active workflow (e.g., "ralph") +- `ralphSessionDir` (line 1926) — session directory path +- `ralphSessionId` (line 1928) — session UUID +- `todoItems` (line 1920) — current task items for display +- `showTodoPanel` (line 1922) — task panel visibility toggle + +**Default Workflow State** (`defaultWorkflowChatState`): +- `workflowActive: false` +- `workflowType: undefined` +- `ralphConfig: undefined` + +--- + +## Code References + +### Workflow SDK Core +- `src/graph/types.ts` — All workflow type definitions +- `src/graph/builder.ts:136-696` — GraphBuilder fluent API +- `src/graph/compiled.ts:213-695` — GraphExecutor engine +- `src/graph/nodes.ts:494-524` — `clearContextNode()` implementation +- `src/graph/nodes.ts:1374-1512` — `contextMonitorNode()` implementation +- `src/graph/index.ts:14-304` — Public API exports + +### Ralph Workflow +- `src/ui/commands/workflow-commands.ts:547-800` — Ralph command implementation +- `src/ui/commands/workflow-commands.ts:415-423` — Ralph metadata definition +- `src/ui/commands/workflow-commands.ts:806-858` — `watchTasksJson()` file watcher +- `src/ui/commands/workflow-commands.ts:133-156` — `atomicWrite()` for tasks.json +- `src/graph/nodes/ralph.ts` — Ralph graph node implementation +- `src/workflows/session.ts` — Workflow session management + +### Chat Box & TUI +- `src/ui/chat.tsx:5685-5694` — Chat box border rendering +- `src/ui/chat.tsx:4212-4402` — Ctrl+C handler +- `src/ui/chat.tsx:4376-4401` — Double-press exit logic +- `src/ui/chat.tsx:5766-5773` — Ctrl+C warning display +- `src/ui/chat.tsx:3726-3744` — `clearContext()` implementation +- `src/ui/chat.tsx:3714-3724` — `streamAndWait()` implementation + +### Theme & Styling +- `src/ui/theme.tsx:20-61` — ThemeColors interface +- `src/ui/theme.tsx:215-240` — Dark theme (inputFocus: `#585b70`) +- `src/ui/theme.tsx:247-272` — Light theme (inputFocus: `#acb0be`) +- `src/ui/theme.tsx:358-390` — ThemeProvider component + +### Task List UI +- `src/ui/components/task-list-panel.tsx:156` — TaskListPanel wrapper +- `src/ui/components/task-list-panel.tsx:71` — TaskListBox presentational component +- `src/ui/components/task-list-indicator.tsx:93` — TaskListIndicator items +- `src/ui/utils/ralph-task-state.ts` — Ralph task state management +- `src/ui/utils/task-status.ts` — Task status normalization utilities + +### Keyboard & Signal Handling +- `src/ui/index.ts:1448-1510` — `handleInterrupt()` unified handler +- `src/ui/index.ts:1513-1540` — Signal handler setup +- `src/ui/index.ts:1566-1573` — Renderer options (exitOnCtrlC, Kitty keyboard) +- `src/ui/index.ts:1612` — `handleInterruptFromUI()` bridge + +### Subagent System +- `src/graph/subagent-bridge.ts` — Subagent-to-workflow bridge +- `src/graph/subagent-registry.ts` — Subagent type registry +- `src/ui/parts/handlers.ts` — Part event handlers +- `src/ui/parts/store.ts` — Binary search-based part storage + +--- + +## Architecture Documentation + +### Current Patterns + +1. **Workflows run inline in chat**: Workflow commands use `streamAndWait()` which pipes through the normal chat streaming pipeline. Output already appears in the main chat context. + +2. **Static border color**: The chat box border is always `themeColors.inputFocus` — there is no dynamic mode-based color switching. The `inputStreaming` color exists in the theme but is not used for the chat input border. + +3. **No "workflow mode" concept in UI**: The system tracks `workflowState.workflowActive` but does not visually differentiate workflow mode from normal mode (no border color change, no mode indicator). + +4. **Ctrl+C priority chain**: Streaming interrupt → sub-agent interrupt → workflow cancel → clear input → double-press exit. When a workflow is active, Ctrl+C cancels it (step 4 in the chain) — it does NOT require double-press to exit the workflow. + +5. **Clear context in review phase**: The Ralph workflow only clears context before the review phase (Step 3). This is the only use of `clearContext()` in the Ralph workflow. + +6. **Task state survives context clears**: `todoItems`, `ralphSessionDir`, and `ralphSessionId` are stored in refs and restored after `clearContext()` operations. + +7. **Reviewer does not update tasks.json**: The reviewer returns structured findings; the workflow generates new tasks from the fix spec if needed. + +8. **File-based task watching**: `TaskListPanel` watches the session directory for `tasks.json` changes and re-renders on updates. + +--- + +## Historical Context (from research/) + +### Workflow SDK Research +- `research/docs/2026-02-11-workflow-sdk-implementation.md` — Comprehensive workflow SDK implementation research +- `research/docs/2026-02-05-pluggable-workflows-sdk-design.md` — Original pluggable workflows design +- `research/docs/2026-02-03-workflow-composition-patterns.md` — Workflow composition pattern research +- `research/docs/2026-02-03-custom-workflow-file-format.md` — Custom workflow file format research +- `research/docs/2026-01-31-workflow-config-semantics.md` — Workflow configuration semantics + +### Ralph-Specific Research +- `research/docs/2026-02-15-ralph-dag-orchestration-implementation.md` — DAG-based orchestration implementation +- `research/docs/2026-02-15-ralph-dag-orchestration-blockedby.md` — BlockedBy feature for task dependencies +- `research/docs/2026-02-15-ralph-loop-manual-worker-dispatch.md` — Manual worker dispatch +- `research/docs/2026-02-13-ralph-task-list-ui.md` — Task list UI design research +- `research/docs/qa-ralph-task-list-ui.md` — QA analysis of task list UI +- `research/docs/2026-02-09-163-ralph-loop-enhancements.md` — Loop enhancements (Issue #163) + +### TUI Architecture Research +- `research/docs/2026-02-16-atomic-chat-architecture-current.md` — Current chat architecture +- `research/docs/2026-02-16-chat-system-design-ui-research.md` — Chat system design research +- `research/docs/2026-02-16-opentui-rendering-architecture.md` — OpenTUI rendering architecture + +### Related Specs +- `specs/workflow-sdk-implementation.md` — Workflow SDK implementation spec +- `specs/ralph-task-list-ui.md` — Ralph task list UI spec +- `specs/ralph-dag-orchestration.md` — Ralph DAG orchestration spec +- `specs/pluggable-workflows-sdk.md` — Pluggable workflows SDK spec + +--- + +## Related Research + +- `research/docs/2026-02-19-sdk-v2-first-unified-layer-research.md` — SDK v2 unified layer +- `research/docs/2026-02-15-ralph-orchestrator-ui-cleanup.md` — Ralph orchestrator UI cleanup +- `research/docs/2026-02-12-sub-agent-sdk-integration-analysis.md` — Sub-agent SDK integration + +--- + +## Open Questions + +1. **Workflow mode border color**: The `inputStreaming` theme color (`#6c7086`/`#9ca0b0`) exists but is unused for the input border. Should "workflow mode" reuse this property or introduce a new `inputWorkflow` theme color for teal blue? + +2. **Double Ctrl+C behavior change**: Currently, Ctrl+C when a workflow is active immediately cancels the workflow (single press, step 4 in the priority chain). The proposed change requires double Ctrl+C to exit workflow mode. This means the priority chain needs restructuring — the workflow cancel step needs to become a double-press step, and the existing double-press exit needs to be layered on top. + +3. **Reviewer updating tasks.json**: Currently the reviewer does not update tasks.json. The proposed feature requires the reviewer to write back task updates (new tasks, blockers). This would need changes to the reviewer agent prompt and the review result handling in `workflow-commands.ts`. + +4. **Context preservation on workflow exit**: When exiting workflow mode via double Ctrl+C, should the workflow messages remain in the chat transcript? The current `clearContext()` moves messages to history buffer — but the proposal says "the context will still have the ralph run." + +5. **Clear node removal scope**: Does "remove clear nodes" mean removing `clearContextNode()` from the SDK entirely, or just not using it in the Ralph workflow? The Ralph workflow currently only calls `clearContext()` before the review phase, not via `clearContextNode()` graph nodes. + +6. **Task list widget lifecycle**: The task list panel currently shows/hides based on `ralphSessionDir` being set. When exiting workflow mode, should `ralphSessionDir` be cleared (hiding the panel) or should it persist until the user explicitly dismisses it? diff --git a/specs/workflow-sdk-inline-mode.md b/specs/workflow-sdk-inline-mode.md new file mode 100644 index 000000000..13356eab2 --- /dev/null +++ b/specs/workflow-sdk-inline-mode.md @@ -0,0 +1,370 @@ +# Workflow SDK Inline Mode, Visual Mode Indicators & Clear Node Removal — Technical Design Document + +| Document Metadata | Details | +| ---------------------- | ----------- | +| Author(s) | lavaman131 | +| Status | Draft (WIP) | +| Team / Owner | Atomic CLI | +| Created / Last Updated | 2026-02-21 | + +## 1. Executive Summary + +This RFC proposes four coordinated changes to the Atomic CLI's workflow system: **(1)** a teal border visual indicator when a workflow (like `/ralph`) is actively running, **(2)** Ctrl+C only interrupts the current stream while keeping the workflow orchestration active — the user can then type a new prompt that gets passed to the model within the workflow context, **(3)** removal of `clearContextNode()` usage from the Ralph workflow so context persists through the entire run, and **(4)** auto-hiding the task list panel only when the workflow ends naturally or is fully cancelled. These changes improve the user experience by providing clear visual feedback about the system's operational mode, enabling mid-workflow user intervention without losing orchestration state, and preserving conversation context throughout. + +**Research Reference:** [`research/docs/2026-02-21-workflow-sdk-inline-mode-research.md`](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md) + +## 2. Context and Motivation + +### 2.1 Current State + +The workflow system already runs **inline within the main chat context** — workflow commands like `/ralph` use `streamAndWait()` which pipes prompts through the normal chat streaming pipeline ([research ref: §1 "Workflow SDK Architecture"](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md)). However, there is no visual distinction between "normal mode" and "workflow mode": + +- **Border color**: The chat box border always uses `themeColors.inputFocus` (`#585b70` dark / `#acb0be` light) regardless of workflow state ([research ref: §3 "Chat Box Border/Outline Styling"](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md)). +- **Ctrl+C behavior**: When `workflowState.workflowActive` is true, a single Ctrl+C immediately cancels the entire workflow (priority chain step 4 in `chat.tsx:4351-4366`) ([research ref: §4 "Keyboard Input / Ctrl+C Handling"](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md)). +- **Context clearing**: The Ralph workflow calls `context.clearContext()` before the review phase (line 684) and again before fix decomposition (line 734), destroying conversation history ([research ref: §2 "Clear Nodes"](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md)). +- **Task list lifecycle**: The `TaskListPanel` shows/hides based on `ralphSessionDir` being set, but the panel's lifecycle after workflow completion is tied to whether `ralphSessionDir` persists ([research ref: §5 "Ralph Workflow Specifics"](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md)). + +**Related Architecture:** + +- [`research/docs/2026-02-16-atomic-chat-architecture-current.md`](../research/docs/2026-02-16-atomic-chat-architecture-current.md) — Chat system architecture +- [`research/docs/2026-02-13-ralph-task-list-ui.md`](../research/docs/2026-02-13-ralph-task-list-ui.md) — Task list panel design +- [`specs/ralph-task-list-ui.md`](./ralph-task-list-ui.md) — Task list panel spec + +### 2.2 The Problem + +- **User Impact:** Users have no visual cue that a workflow is running. Ctrl+C during a workflow kills the entire orchestration with no way to redirect the model mid-workflow. After the review phase clears context, users lose the conversation history from the implementation phase. +- **Technical Debt:** The `inputStreaming` theme color exists in `ThemeColors` but is never used for the chat input border. The `clearContextNode()` mechanism adds complexity without clear user benefit since the reviewer sub-agent receives its own isolated context anyway (via `spawnSubagent()`). + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +- [ ] **G1 — Visual Mode Indicator:** The chat input box border changes to a teal color (`themeColors.accent`: `#94e2d5` dark / `#179299` light) when a workflow is active (`workflowState.workflowActive === true`). +- [ ] **G2 — Ctrl+C Interrupts Stream, Not Workflow:** When a workflow is active and streaming, Ctrl+C interrupts the current stream **only**. The workflow orchestration remains active (`workflowActive: true`, teal border stays, task list persists). After the stream is interrupted, the user can type a new prompt which gets passed to the model within the workflow context, replacing the workflow's autonomous train of thought with the user's direction. +- [ ] **G3 — Remove Clear Context from Ralph:** Remove the `context.clearContext()` calls from the Ralph workflow's review phase so conversation context persists throughout the entire run. +- [ ] **G4 — Task List Auto-Hide:** The task list panel auto-hides only when the workflow ends naturally (all tasks complete) or is fully terminated. It remains visible while the workflow is active, including after a Ctrl+C stream interruption. + +### 3.2 Non-Goals (Out of Scope) + +- [ ] We will NOT remove the `clearContextNode()` factory function from the graph SDK entirely — it remains available for custom workflows. +- [ ] We will NOT add a workflow progress bar or step indicator (separate feature). +- [ ] We will NOT change the border color during streaming (the existing `inputStreaming` color remains unused for now). +- [ ] We will NOT change how `spawnSubagent()` works for the reviewer agent. +- [ ] We will NOT implement workflow pause/resume functionality. + +## 4. Proposed Solution (High-Level Design) + +### 4.1 System Architecture Diagram + +```mermaid +%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#f8f9fa','primaryTextColor':'#2c3e50','primaryBorderColor':'#4a5568','lineColor':'#4a90e2','secondaryColor':'#ffffff','tertiaryColor':'#e9ecef','background':'#f5f7fa','mainBkg':'#f8f9fa','nodeBorder':'#4a5568','clusterBkg':'#ffffff','clusterBorder':'#cbd5e0','edgeLabelBackground':'#ffffff'}}}%% + +flowchart TB + classDef stateNode fill:#5a67d8,stroke:#4c51bf,stroke-width:2.5px,color:#ffffff,font-weight:600 + classDef uiNode fill:#4a90e2,stroke:#357abd,stroke-width:2.5px,color:#ffffff,font-weight:600 + classDef actionNode fill:#48bb78,stroke:#38a169,stroke-width:2.5px,color:#ffffff,font-weight:600 + classDef removedNode fill:#f56565,stroke:#c53030,stroke-width:2.5px,color:#ffffff,font-weight:600,stroke-dasharray:6 3 + + subgraph WorkflowMode["◆ Workflow Mode Changes"] + direction TB + + WS["workflowState
workflowActive: boolean
workflowType: string"]:::stateNode + + subgraph VisualChanges["Visual Indicator"] + BC["Border Color
Normal: inputFocus
Workflow: accent (teal)"]:::uiNode + WM["Warning Message
'Press Ctrl+C again
to exit workflow'"]:::uiNode + end + + subgraph CtrlCChanges["Ctrl+C Behavior"] + S["Streaming + Workflow
Ctrl+C: interrupt stream
Workflow stays active
User prompt replaces
autonomous execution"]:::actionNode + NW["Not Streaming + Workflow
User can type prompt
directly into workflow"]:::actionNode + end + + subgraph RemovedFeatures["Removed from Ralph"] + CC["clearContext() calls
Lines 684 & 734
in workflow-commands.ts"]:::removedNode + end + + TLP["Task List Panel
Visible while workflow active
Auto-hides on completion"]:::uiNode + end + + WS -->|"drives"| BC + WS -->|"gates"| NW + WS -->|"gates"| S + WS -->|"preserves"| TLP + + style WorkflowMode fill:#ffffff,stroke:#cbd5e0,stroke-width:2px,color:#2d3748,stroke-dasharray:8 4 + style VisualChanges fill:#f0f4ff,stroke:#4a90e2,stroke-width:1px,color:#2d3748 + style CtrlCChanges fill:#f0fff4,stroke:#48bb78,stroke-width:1px,color:#2d3748 + style RemovedFeatures fill:#fff5f5,stroke:#f56565,stroke-width:1px,color:#2d3748 +``` + +### 4.2 Architectural Pattern + +We are adopting a **state-driven UI theming** pattern where `workflowState.workflowActive` drives both visual presentation (border color) and behavioral changes (Ctrl+C handler logic). This follows the existing React state → props → render pattern already used throughout the chat component. + +### 4.3 Key Components + +| Component | Responsibility | File | Change Type | +| --------------- | ------------------------------------------------------------------------ | ---------------------------------------------- | ----------- | +| Chat Box Border | Renders teal border during workflow mode | `src/ui/chat.tsx:5685-5694` | Modified | +| Ctrl+C Handler | Stream-only interruption; workflow stays active for user prompt | `src/ui/chat.tsx:4212-4402` | Modified | +| Ralph Workflow | Remove `clearContext()` calls; accept user prompt after stream interrupt | `src/ui/commands/workflow-commands.ts:684,734` | Modified | +| Task List Panel | Auto-hide on workflow completion, persist during active workflow | `src/ui/chat.tsx:5674-5679` | Modified | + +## 5. Detailed Design + +### 5.1 Visual Mode Indicator — Teal Border + +**File:** `src/ui/chat.tsx` (~line 5688) + +**Current:** + +```tsx +borderColor={themeColors.inputFocus} +``` + +**Proposed:** + +```tsx +borderColor={workflowState.workflowActive ? themeColors.accent : themeColors.inputFocus} +``` + +The `accent` color is already teal in both themes: + +- Dark: `#94e2d5` (Catppuccin Mocha Teal) +- Light: `#179299` (Catppuccin Latte Teal) + +This is a single-line change. OpenTUI's `` component supports dynamic `borderColor` changes and triggers `requestRender()` on prop updates ([research ref: §3 "OpenTUI Border API"](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md)). + +**No new theme colors needed.** The `accent` color is already defined and visually appropriate (teal), matching the `assistantMessage` and `codeTitle` colors. + +### 5.2 Ctrl+C — Stream Interruption Within Active Workflow + +**File:** `src/ui/chat.tsx` (~lines 4212-4402) + +The core change: Ctrl+C during a workflow **only interrupts the current stream** — it does NOT deactivate the workflow itself. The workflow orchestration remains active, the teal border stays, and the task list stays visible. The user can then type a new prompt which gets passed to the model within the workflow context. + +#### 5.2.1 When Streaming Inside a Workflow + +**Current behavior (Step 2, lines 4222-4306):** Interrupting a stream also cancels the workflow (`workflowActive: false`). + +**Proposed:** When `workflowState.workflowActive && isStreamingRef.current`, Ctrl+C interrupts the stream but **keeps `workflowActive: true`**. Remove the workflow cancellation block from within the streaming interrupt handler (lines 4289-4294): + +```typescript +// REMOVE this block from the streaming interrupt handler: +// if (workflowState.workflowActive) { +// updateWorkflowState({ +// workflowActive: false, +// workflowType: null, +// initialPrompt: null, +// }); +// } +``` + +After the stream is interrupted, the workflow's `streamAndWait()` returns `{ wasInterrupted: true }`. Instead of continuing its autonomous train of thought, the workflow should **yield control to the user** — waiting for the user's next prompt and passing it to the model. + +#### 5.2.2 When Not Streaming Inside a Workflow (Idle Between Steps) + +**Current behavior (Step 4, lines 4351-4366):** Single Ctrl+C cancels the workflow immediately. + +**Proposed:** Remove the workflow cancellation from step 4. When the workflow is active but between steps (not streaming), the user can simply type a prompt into the input box. The workflow orchestration picks up the user's prompt and passes it to the model as the next message, rather than the autonomous prompt the workflow would have generated. + +The step 4 block should be removed entirely: + +```typescript +// REMOVE this block: +// if (workflowState.workflowActive) { +// updateWorkflowState({ +// workflowActive: false, +// workflowType: null, +// initialPrompt: null, +// }); +// ... +// } +``` + +#### 5.2.3 User Prompt Passthrough in Workflow Context + +When a workflow is active and the user submits a prompt (after interrupting with Ctrl+C or during an idle phase between workflow steps): + +1. The user's prompt is sent through `streamAndWait()` within the workflow context +2. The model receives the full conversation history (including prior workflow output) plus the user's new prompt +3. The model responds to the user's direction instead of the workflow's autonomous prompt +4. The workflow continues orchestrating from that point with the model's response + +This requires the workflow loop in `workflow-commands.ts` to detect `wasInterrupted` on `streamAndWait()` and wait for user input before continuing: + +```typescript +// In the implementation loop (Step 2): +const result = await context.streamAndWait(prompt); +if (result.wasInterrupted) { + // Wait for user's next prompt instead of auto-continuing + const userPrompt = await context.waitForUserInput(); + // Pass user's prompt to model within workflow context + const userResult = await context.streamAndWait(userPrompt); + // Continue orchestration with the response +} +``` + +> **Note:** The `context.waitForUserInput()` mechanism may need to be implemented if it doesn't exist. This would resolve the `streamAndWait()` promise with the user's typed input once they submit. + +#### 5.2.4 Revised Priority Chain + +| Step | Condition | Action | +| ---- | ------------------------------------ | ---------------------------------------------- | +| 1 | Text selected | Copy to clipboard | +| 2 | Streaming (with or without workflow) | Interrupt stream only; workflow remains active | +| 3 | Sub-agents running | Interrupt sub-agents | +| 4 | Textarea has content | Clear textarea | +| 5 | Empty/idle | Double-press to exit TUI | + +**Note:** Step 4 (workflow cancel) from the current chain is **removed entirely**. Workflow deactivation only happens via natural completion or the workflow itself deciding to exit. + +### 5.3 Remove Clear Context from Ralph Workflow + +**File:** `src/ui/commands/workflow-commands.ts` + +Remove the two `clearContext()` calls: + +1. **Line ~684** — Before the reviewer sub-agent spawn: + + ```typescript + // REMOVE: await context.clearContext(); + ``` + +2. **Line ~734** — Before fix spec decomposition: + ```typescript + // REMOVE: await context.clearContext(); + ``` + +**Justification:** The reviewer sub-agent is spawned via `context.spawnSubagent()`, which creates an **isolated session** with its own context ([research ref: §7 "Subagent Bridge Architecture"](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md)). Clearing the main chat context is unnecessary for the reviewer to function, and it destroys valuable conversation history that users may want to reference after the workflow completes. + +**Risk Mitigation:** For long-running workflows, the context may grow large. The existing `contextMonitorNode()` mechanism ([research ref: §2 "Other Context Management"](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md)) already handles token usage monitoring and can trigger compaction as needed. This is a more graceful approach than hard-clearing. + +### 5.4 Task List Panel Lifecycle + +**Current behavior:** The `TaskListPanel` renders when `ralphSessionDir` is truthy ([research ref: §5 "Task List Panel Visibility"](../research/docs/2026-02-21-workflow-sdk-inline-mode-research.md)): + +```tsx +{ + ralphSessionDir && showTodoPanel && ( + + ); +} +``` + +**Proposed:** The task list panel remains visible for the entire duration that `workflowActive === true`. This includes after Ctrl+C stream interruptions — the user can see the task progress while typing their follow-up prompt. + +When `workflowActive` transitions to `false` (natural completion), clear `ralphSessionDir` to auto-hide the panel: + +```typescript +useEffect(() => { + if (!workflowState.workflowActive && ralphSessionDir) { + setRalphSessionDir(null); + setRalphSessionId(null); + } +}, [workflowState.workflowActive]); +``` + +The `tasks.json` file on disk is preserved for future reference in the session directory (`~/.atomic/workflows/sessions/{sessionId}/`). + +### 5.5 Workflow Completion State Reset + +**Current Issue:** The Ralph workflow returns `{ success: true }` but does **not** explicitly set `workflowActive: false` at completion. It relies on Ctrl+C cancellation to reset the state. + +**Proposed Fix:** At the end of the Ralph command's `execute()` function (after the implementation/review loop completes), explicitly reset workflow state: + +```typescript +// At end of Ralph workflow execute(): +return { + success: true, + message: "Workflow completed successfully.", + stateUpdate: { + workflowActive: false, + workflowType: null, + initialPrompt: null, + }, +}; +``` + +This ensures the border reverts to the normal color and Ctrl+C behavior returns to default after a workflow finishes naturally. The `ralphSessionDir` and `ralphSessionId` are preserved (they're in separate refs), so the task list panel remains visible. + +## 6. Alternatives Considered + +| Option | Pros | Cons | Reason for Rejection | +| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| **A: New `inputWorkflow` theme color** | Fully independent color, no shared semantics | Adds theme complexity, requires changes to `ThemeColors` interface in all consuming components | Rejected: `accent` is already teal and semantically appropriate for "active/highlighted" state. | +| **B: Reuse `inputStreaming` for workflow** | Uses existing unused color | `inputStreaming` semantically means "streaming", not "workflow mode"; creates confusion | Rejected: Mixing semantic meanings makes future changes harder. | +| **C: Ctrl+C deactivates workflow entirely** | Clean exit, simple mental model | Loses orchestration state; user cannot redirect workflow mid-execution | Rejected: Keeping workflow active after stream interrupt enables powerful mid-workflow user intervention. | +| **D: Replace `clearContext()` with `compactContext()`** | Preserves some context while reducing token usage | Compaction is lossy and may remove important details; unnecessary since SDK handles it | Rejected: SDK-level compaction is automatic and sufficient. | +| **E (Selected): Accent color + stream-only Ctrl+C + user prompt passthrough + auto-hide** | Minimal changes, enables mid-workflow user direction, clean UX | Requires `waitForUserInput()` mechanism | **Selected.** | + +## 7. Cross-Cutting Concerns + +### 7.1 Observability Strategy + +- **State Tracking:** `workflowState.workflowActive` and `workflowState.workflowType` are already tracked in React state and accessible via command context. No additional telemetry needed. +- **Ctrl+C Events:** The existing telemetry for interrupt events (via `handleInterrupt()` in `src/ui/index.ts:1448`) will naturally capture the new double-press pattern. + +### 7.2 Backward Compatibility + +- **Theme System:** No changes to `ThemeColors` interface. All existing themes continue to work. +- **Custom Workflows:** `clearContextNode()` remains in the SDK. Only Ralph's usage is removed. +- **Keyboard Shortcuts:** Ctrl+T toggle for task list panel is unchanged. + +### 7.3 Edge Cases + +| Edge Case | Handling | +| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Ctrl+C during streaming inside workflow | Interrupts stream only. Workflow stays active (teal border, task list remain). User can type a new prompt to redirect the model. | +| User types prompt after Ctrl+C in workflow | Prompt is passed to the model within the workflow context. Workflow continues orchestrating with the model's response to the user's direction. | +| Workflow completes naturally | `stateUpdate` sets `workflowActive: false`. `useEffect` clears `ralphSessionDir`. Task list auto-hides. Border reverts. | +| Workflow errors mid-execution | Workflow sets `workflowActive: false` on error exit via `stateUpdate`, border reverts and task list hides automatically. | +| Context grows large without clearing | Handled automatically at the SDK level. No manual compaction needed. | +| User types while workflow is streaming | Normal message queuing behavior — prompt is queued and processed after the current stream completes. | +| Multiple Ctrl+C presses during workflow | Each press interrupts the current stream if streaming; if idle, falls through to textarea clear / double-press TUI exit. | + +## 8. Migration, Rollout, and Testing + +### 8.1 Deployment Strategy + +- [ ] **Phase 1:** Implement visual mode indicator (teal border) — lowest risk, purely visual. +- [ ] **Phase 2:** Modify Ctrl+C handler to only interrupt stream (not workflow) and implement user prompt passthrough via `waitForUserInput()`. +- [ ] **Phase 3:** Remove `clearContext()` calls from Ralph workflow. Add task list auto-hide on workflow completion. +- [ ] **Phase 4:** Validation and testing across all three SDK agents (OpenCode, Claude, Copilot). + +### 8.2 Test Plan + +- **Unit Tests:** + - [ ] Border color returns `accent` when `workflowActive === true`, `inputFocus` otherwise. + - [ ] Ctrl+C handler: press during streaming workflow interrupts stream but does NOT set `workflowActive = false`. + - [ ] Ctrl+C handler: workflow cancellation block (step 4) is removed from priority chain. + - [ ] `useEffect` clears `ralphSessionDir` when `workflowActive` transitions to `false` on natural completion. + - [ ] Workflow completion returns `stateUpdate` with `workflowActive: false`. + - [ ] `waitForUserInput()` resolves with user-typed prompt. + +- **Integration Tests:** + - [ ] Ralph workflow runs end-to-end without `clearContext()` calls. + - [ ] After Ctrl+C stream interruption, user prompt is passed to model within workflow context. + - [ ] Workflow continues orchestrating after user prompt passthrough. + - [ ] Task list panel remains visible after Ctrl+C stream interruption. + - [ ] Task list panel auto-hides after workflow completes naturally. + - [ ] Reviewer sub-agent functions correctly without prior context clearing. + +- **E2E Tests:** + - [ ] Visual verification: teal border appears when `/ralph` starts, stays after Ctrl+C, reverts on natural completion. + - [ ] Ctrl+C during `/ralph` streaming stops stream; user types prompt; model responds within workflow. + - [ ] Task list visible throughout workflow lifecycle, hides on completion. + - [ ] `tasks.json` persists on disk after workflow ends. + +## 9. Resolved Questions + +- [x] **Q1 — Stream interruption recovery:** Ctrl+C interrupts the current stream but the **workflow stays active**. The user can then type a new prompt which gets passed to the model within the workflow context, replacing the workflow's autonomous train of thought. The workflow orchestration continues from there. The teal border and task list remain visible throughout. + +- [x] **Q2 — Workflow border color for non-Ralph workflows:** **Teal for all workflow types.** The `themeColors.accent` color is universal for any active workflow, regardless of type. + +- [x] **Q3 — Context compaction threshold:** **No changes needed.** Context compaction is handled automatically at the SDK level. No `contextMonitorNode()` adjustments required. + +- [x] **Q4 — Task panel dismissal mechanism:** The task list panel **stays visible while the workflow is active** (including after Ctrl+C stream interruptions). It **auto-hides only when the workflow ends naturally** (all tasks complete) or on error exit. The teal border reverting to normal is the visual signal that the workflow and task list are deactivating. + +- [x] **Q5 — Workflow exit message:** **No system message.** The workflow silently deactivates on natural completion. The border color reverting to normal and the task list hiding are sufficient visual feedback. diff --git a/src/ui/chat.tsx b/src/ui/chat.tsx index 0724b0d42..d9b316365 100644 --- a/src/ui/chat.tsx +++ b/src/ui/chat.tsx @@ -808,6 +808,10 @@ export interface MessageBubbleProps { tasksExpanded?: boolean; /** Whether task updates should be rendered inline for this message */ inlineTasksEnabled?: boolean; + /** Ralph session directory for persistent task list panel */ + ralphSessionDir?: string | null; + /** Whether the todo/task panel is visible (Ctrl+T toggle) */ + showTodoPanel?: boolean; /** Elapsed streaming time in milliseconds */ elapsedMs?: number; /** Whether the conversation is collapsed (shows compact single-line summaries) */ @@ -1598,7 +1602,7 @@ function getRenderableAssistantParts( return parts; } -export function MessageBubble({ message, isLast, syntaxStyle, hideAskUserQuestion: _hideAskUserQuestion = false, hideLoading = false, todoItems, tasksExpanded = false, inlineTasksEnabled = true, elapsedMs, collapsed = false, streamingMeta }: MessageBubbleProps): React.ReactNode { +export function MessageBubble({ message, isLast, syntaxStyle, hideAskUserQuestion: _hideAskUserQuestion = false, hideLoading = false, todoItems, tasksExpanded = false, inlineTasksEnabled = true, ralphSessionDir, showTodoPanel = true, elapsedMs, collapsed = false, streamingMeta }: MessageBubbleProps): React.ReactNode { const themeColors = useThemeColors(); // Collapsed mode: show compact single-line summary for each message @@ -1660,6 +1664,14 @@ export function MessageBubble({ message, isLast, syntaxStyle, hideAskUserQuestio {message.content} + + {/* Ralph persistent task list - also shown after user messages */} + {isLast && ralphSessionDir && showTodoPanel && ( + + )}
); } @@ -1690,6 +1702,14 @@ export function MessageBubble({ message, isLast, syntaxStyle, hideAskUserQuestio > + {/* Ralph persistent task list - pinned above streaming text in last message */} + {isLast && ralphSessionDir && showTodoPanel && ( + + )} + {/* Loading spinner — shown during streaming OR while background agents are still running */} {(message.streaming || hasActiveBackgroundAgents) && !hideLoading && ( 0 ? SPACING.ELEMENT : SPACING.NONE}> @@ -1917,6 +1937,8 @@ export function ChatApp({ const lastStreamingContentRef = useRef(""); // Resolver for streamAndWait: when set, handleComplete resolves the Promise instead of processing the queue const streamCompletionResolverRef = useRef<((result: import("./commands/registry.ts").StreamResult) => void) | null>(null); + // Resolver for waitForUserInput: when set, handleSubmit resolves the Promise with the user's prompt + const waitForUserInputResolverRef = useRef<{ resolve: (prompt: string) => void; reject: (reason: Error) => void } | null>(null); // When true, streaming chunks are accumulated but NOT rendered in the assistant message (for hidden workflow steps) const hideStreamContentRef = useRef(false); const [showTodoPanel, setShowTodoPanel] = useState(true); @@ -2674,6 +2696,14 @@ export function ChatApp({ } }, [workflowState.workflowActive]); + // Auto-hide task list panel when workflow ends naturally + useEffect(() => { + if (!workflowState.workflowActive && ralphSessionDir) { + setRalphSessionDir(null); + setRalphSessionId(null); + } + }, [workflowState.workflowActive, ralphSessionDir]); + /** * Handle human_input_required signal. * Shows UserQuestionDialog for HITL interactions. @@ -3674,19 +3704,38 @@ export function ChatApp({ }, spawnSubagent: async (options) => { // Inject into main session — SDK's native sub-agent dispatch handles it. - // Wait for the streaming response so the caller gets the actual result - // (previously returned empty output immediately). + // Wait for the streaming response so the caller gets the actual result. + // + // IMPORTANT: For ralph review-fix loops, the sub-agent output must be + // clean JSON without additional commentary. We hide the stream content + // to avoid polluting the chat UI with intermediate steps. const agentName = options.name ?? options.model ?? "general-purpose"; const task = options.message; - const instruction = `Use the ${agentName} sub-agent to handle this task: ${task}`; + + // Format instruction to ensure clean sub-agent invocation. + // Explicitly request the agent tool and ask for the complete output + // to be passed through without additional commentary. + const instruction = `Invoke the "${agentName}" sub-agent with the following task. Return ONLY the sub-agent's complete output with no additional commentary or explanation. + +Task for ${agentName}: +${task} + +Important: Do not add any text before or after the sub-agent's output. Pass through the complete response exactly as produced.`; + const result = await new Promise((resolve) => { const previousResolver = streamCompletionResolverRef.current; if (previousResolver) { previousResolver({ content: lastStreamingContentRef.current, wasInterrupted: true }); } streamCompletionResolverRef.current = resolve; + // Hide stream content to keep chat UI clean (content is still accumulated) + hideStreamContentRef.current = true; context.sendSilentMessage(instruction); }); + + // Reset hideStreamContent for next stream + hideStreamContentRef.current = false; + return { success: !result.wasInterrupted, output: result.content, @@ -3704,6 +3753,11 @@ export function ChatApp({ context.sendSilentMessage(prompt); }); }, + waitForUserInput: () => { + return new Promise((resolve, reject) => { + waitForUserInputResolverRef.current = { resolve, reject }; + }); + }, clearContext: async () => { if (onResetSession) { await onResetSession(); @@ -3802,6 +3856,16 @@ export function ChatApp({ clearHistoryBuffer(); setTrimmedMessageCount(0); loadedSkillsRef.current.clear(); + // Reset ralph state on /clear (Copilot only) + if (agentType === "copilot") { + setRalphSessionDir(null); + setRalphSessionId(null); + ralphSessionDirRef.current = null; + ralphSessionIdRef.current = null; + ralphTaskIdsRef.current = new Set(); + todoItemsRef.current = []; + setTodoItems([]); + } // /clear postcondition contract: messages=[], trimmedMessageCount=0, // transcriptMode=false, historyBuffer=[], compactionSummary=null console.debug("[lifecycle] /clear postconditions: messages=[], trimmedMessageCount=0, transcriptMode=false, historyBuffer=[], compactionSummary=null"); @@ -4252,27 +4316,65 @@ export function ChatApp({ // Sub-agent cancellation handled by SDK session interrupt - // Clear any pending ask-user question so dialog dismisses on ESC + // Clear any pending ask-user question so dialog dismisses setActiveQuestion(null); askUserQuestionRequestIdRef.current = null; activeHitlToolCallIdRef.current = null; - // Cancel active workflow too (if running) - if (workflowState.workflowActive) { - updateWorkflowState({ - workflowActive: false, - workflowType: null, - initialPrompt: null, - }); + // Resolve streamAndWait promise with interrupted flag so workflow can react + const streamResolver = streamCompletionResolverRef.current; + if (streamResolver) { + streamCompletionResolverRef.current = null; + if (hideStreamContentRef.current && interruptedId) { + setMessagesWindowed((prev: ChatMessage[]) => prev.filter((msg: ChatMessage) => msg.id !== interruptedId)); + } + hideStreamContentRef.current = false; + + if (workflowState.workflowActive && interruptCount >= 1) { + // Double Ctrl+C during streaming — cancel workflow + streamResolver({ content: lastStreamingContentRef.current, wasInterrupted: true, wasCancelled: true }); + } else { + streamResolver({ content: lastStreamingContentRef.current, wasInterrupted: true }); + } } - setInterruptCount(0); - if (interruptTimeoutRef.current) { - clearTimeout(interruptTimeoutRef.current); - interruptTimeoutRef.current = null; + if (workflowState.workflowActive) { + const newCount = interruptCount + 1; + if (newCount >= 2) { + // Double Ctrl+C — terminate workflow + updateWorkflowState({ workflowActive: false, workflowType: null, initialPrompt: null }); + if (waitForUserInputResolverRef.current) { + waitForUserInputResolverRef.current.reject(new Error("Workflow cancelled")); + waitForUserInputResolverRef.current = null; + } + setInterruptCount(0); + if (interruptTimeoutRef.current) { + clearTimeout(interruptTimeoutRef.current); + interruptTimeoutRef.current = null; + } + setCtrlCPressed(false); + } else { + // Single Ctrl+C — cancel stream, workflow will waitForUserInput + setInterruptCount(newCount); + setCtrlCPressed(true); + if (interruptTimeoutRef.current) { + clearTimeout(interruptTimeoutRef.current); + } + interruptTimeoutRef.current = setTimeout(() => { + setInterruptCount(0); + setCtrlCPressed(false); + interruptTimeoutRef.current = null; + }, 1000); + } + } else { + setInterruptCount(0); + if (interruptTimeoutRef.current) { + clearTimeout(interruptTimeoutRef.current); + interruptTimeoutRef.current = null; + } + setCtrlCPressed(false); + continueQueuedConversation(); } - setCtrlCPressed(false); - continueQueuedConversation(); return; } @@ -4319,23 +4421,6 @@ export function ChatApp({ } } - // Cancel active workflow regardless of streaming state - // (workflow may be active but between API calls, e.g. after error) - if (workflowState.workflowActive) { - updateWorkflowState({ - workflowActive: false, - workflowType: null, - initialPrompt: null, - }); - setInterruptCount(0); - if (interruptTimeoutRef.current) { - clearTimeout(interruptTimeoutRef.current); - interruptTimeoutRef.current = null; - } - setCtrlCPressed(false); - return; - } - // Not streaming: if textarea has content, clear it first if (textarea?.plainText) { textarea.gotoBufferHome(); @@ -4344,17 +4429,27 @@ export function ChatApp({ return; } - // Textarea empty: use double-press to exit + // Textarea empty: use double-press to cancel workflow or exit const newCount = interruptCount + 1; if (newCount >= 2) { - // Double press - exit setInterruptCount(0); if (interruptTimeoutRef.current) { clearTimeout(interruptTimeoutRef.current); interruptTimeoutRef.current = null; } setCtrlCPressed(false); - onExit?.(); + + if (workflowState.workflowActive) { + // Double Ctrl+C — terminate workflow + updateWorkflowState({ workflowActive: false, workflowType: null, initialPrompt: null }); + if (waitForUserInputResolverRef.current) { + waitForUserInputResolverRef.current.reject(new Error("Workflow cancelled")); + waitForUserInputResolverRef.current = null; + } + } else { + // Double press - exit + onExit?.(); + } return; } @@ -4505,16 +4600,20 @@ export function ChatApp({ askUserQuestionRequestIdRef.current = null; activeHitlToolCallIdRef.current = null; - // Cancel active workflow too (if running) - if (workflowState.workflowActive) { - updateWorkflowState({ - workflowActive: false, - workflowType: null, - initialPrompt: null, - }); + // Resolve streamAndWait promise with interrupted flag so workflow can react + const streamResolver = streamCompletionResolverRef.current; + if (streamResolver) { + streamCompletionResolverRef.current = null; + if (hideStreamContentRef.current && interruptedId) { + setMessagesWindowed((prev: ChatMessage[]) => prev.filter((msg: ChatMessage) => msg.id !== interruptedId)); + } + hideStreamContentRef.current = false; + streamResolver({ content: lastStreamingContentRef.current, wasInterrupted: true }); } - continueQueuedConversation(); + if (!workflowState.workflowActive) { + continueQueuedConversation(); + } return; } @@ -4561,16 +4660,6 @@ export function ChatApp({ } } - // Cancel active workflow regardless of streaming state - if (workflowState.workflowActive) { - updateWorkflowState({ - workflowActive: false, - workflowType: null, - initialPrompt: null, - }); - return; - } - // ESC when idle does nothing - use /exit or Ctrl+C twice to exit return; } @@ -5301,6 +5390,17 @@ export function ChatApp({ // Check if this is a slash command const parsed = parseSlashCommand(trimmedValue); if (parsed.isCommand) { + // Dismiss ralph panel when user sends a non-ralph slash command (Copilot only) + if (agentType === "copilot" && ralphSessionDirRef.current && parsed.name !== "ralph") { + setRalphSessionDir(null); + setRalphSessionId(null); + ralphSessionDirRef.current = null; + ralphSessionIdRef.current = null; + ralphTaskIdsRef.current = new Set(); + todoItemsRef.current = []; + setTodoItems([]); + } + // Add the slash command to conversation history like any regular user message addMessage("user", trimmedValue); // Execute the slash command (allowed even during streaming) @@ -5308,8 +5408,18 @@ export function ChatApp({ return; } - // Dismiss ralph panel when user sends a non-ralph message - if (ralphSessionDirRef.current && !trimmedValue.startsWith("/ralph")) { + // If a workflow is waiting for user input (after Ctrl+C stream interrupt), + // resolve the pending promise with the user's prompt instead of sending normally. + if (waitForUserInputResolverRef.current) { + const { resolve } = waitForUserInputResolverRef.current; + waitForUserInputResolverRef.current = null; + addMessage("user", trimmedValue); + resolve(trimmedValue); + return; + } + + // Dismiss ralph panel when user sends a non-ralph message (Copilot only) + if (agentType === "copilot" && ralphSessionDirRef.current && !trimmedValue.startsWith("/ralph")) { setRalphSessionDir(null); setRalphSessionId(null); ralphSessionDirRef.current = null; @@ -5499,6 +5609,8 @@ export function ChatApp({ collapsed={!showLive} tasksExpanded={tasksExpanded} inlineTasksEnabled={!ralphSessionDir} + ralphSessionDir={ralphSessionDir} + showTodoPanel={showTodoPanel} /> ); })} @@ -5530,6 +5642,8 @@ export function ChatApp({ collapsed={false} tasksExpanded={tasksExpanded} inlineTasksEnabled={!ralphSessionDir} + ralphSessionDir={ralphSessionDir} + showTodoPanel={showTodoPanel} /> ); })} @@ -5630,14 +5744,6 @@ export function ChatApp({ )} - {/* Ralph persistent task list - rendered in chat flow, Ctrl+T toggleable */} - {ralphSessionDir && showTodoPanel && ( - - )} - {/* Input Area - flows with content inside scrollbox */} {/* Hidden when question dialog or model selector is active */} {!activeQuestion && !showModelSelector && ( @@ -5645,7 +5751,7 @@ export function ChatApp({ 0 ? SPACING.ELEMENT : SPACING.NONE} @@ -5693,8 +5799,8 @@ export function ChatApp({ )}
- {/* Streaming hints - shows "esc to interrupt" and "ctrl+q enqueue" during streaming */} - {isStreaming ? ( + {/* Streaming/workflow hints */} + {isStreaming && !workflowState.workflowActive ? ( esc to interrupt @@ -5705,6 +5811,26 @@ export function ChatApp({ ) : null} + {/* Workflow mode label with hints - shown when workflow is active */} + {workflowState.workflowActive && ( + + + workflow + + {MISC.separator} + + esc to interrupt + + {MISC.separator} + + ctrl+q enqueue + + {MISC.separator} + + ctrl+c twice to exit workflow + + + )} )} diff --git a/src/ui/commands/builtin-commands.test.ts b/src/ui/commands/builtin-commands.test.ts index 631655b18..eee6e9e23 100644 --- a/src/ui/commands/builtin-commands.test.ts +++ b/src/ui/commands/builtin-commands.test.ts @@ -32,6 +32,7 @@ function createMockContext(overrides?: Partial): CommandContext sendSilentMessage: () => {}, spawnSubagent: async () => ({ success: true, output: "" }), streamAndWait: async () => ({ content: "", wasInterrupted: false }), + waitForUserInput: async () => "", clearContext: async () => {}, setTodoItems: () => {}, setRalphSessionDir: () => {}, diff --git a/src/ui/commands/registry.ts b/src/ui/commands/registry.ts index dcf292336..6b9f6666a 100644 --- a/src/ui/commands/registry.ts +++ b/src/ui/commands/registry.ts @@ -24,6 +24,8 @@ export interface StreamResult { content: string; /** Whether the stream was interrupted (e.g., Ctrl+C / ESC) */ wasInterrupted: boolean; + /** Whether the workflow was cancelled (double Ctrl+C) */ + wasCancelled?: boolean; } /** @@ -124,6 +126,12 @@ export interface CommandContext { * updates are written to tasks.json (prevents sub-agent overwrites). */ setRalphTaskIds: (ids: Set) => void; + /** + * Wait for the user to type and submit a prompt. + * Used by workflows after a stream interruption (Ctrl+C) to yield control + * to the user and receive their next direction. + */ + waitForUserInput: () => Promise; /** * Update workflow state from a command handler. */ diff --git a/src/ui/commands/workflow-commands.test.ts b/src/ui/commands/workflow-commands.test.ts index eb093f17e..767afe5b5 100644 --- a/src/ui/commands/workflow-commands.test.ts +++ b/src/ui/commands/workflow-commands.test.ts @@ -19,6 +19,7 @@ function createMockContext(overrides?: Partial): CommandContext sendSilentMessage: () => {}, spawnSubagent: async () => ({ success: true, output: "" }), streamAndWait: async () => ({ content: "", wasInterrupted: false }), + waitForUserInput: async () => "", clearContext: async () => {}, setTodoItems: () => {}, setRalphSessionDir: () => {}, @@ -280,9 +281,6 @@ describe("review step in /ralph", () => { expect(spawnCalls[0]?.name).toBe("reviewer"); expect(spawnCalls[0]?.message).toContain("Code Review Request"); - // Verify context was cleared before review - expect(clearCallCount).toBeGreaterThanOrEqual(1); - // Cleanup await rm(tempDir, { recursive: true, force: true }); if (sessionDir) { @@ -462,9 +460,9 @@ describe("review step in /ralph", () => { ]) ); } - // After first iteration, return interrupted to stop loop + // After first iteration, return cancelled to stop loop if (streamCallCount > 2) { - return { content: "", wasInterrupted: true }; + return { content: "", wasInterrupted: true, wasCancelled: true }; } return { content: "", wasInterrupted: false }; }, @@ -713,3 +711,420 @@ describe("review step in /ralph", () => { } }); }); + +describe("workflow inline mode", () => { + test("workflow completion returns stateUpdate with workflowActive: false", async () => { + // Mock streamAndWait to return a valid task JSON response + const taskJson = JSON.stringify([ + { id: "#1", content: "Test task", status: "completed", activeForm: "Testing" } + ]); + const commands = getWorkflowCommands(); + const ralphCommand = commands.find((cmd) => cmd.name === "ralph"); + expect(ralphCommand).toBeDefined(); + + const context = createMockContext({ + streamAndWait: async () => ({ content: taskJson, wasInterrupted: false }), + state: { isStreaming: false, messageCount: 0, workflowActive: false }, + }); + + const result = await ralphCommand!.execute("Build a feature", context); + expect(result.success).toBe(true); + expect(result.stateUpdate).toBeDefined(); + expect(result.stateUpdate?.workflowActive).toBe(false); + expect(result.stateUpdate?.workflowType).toBeNull(); + expect(result.stateUpdate?.initialPrompt).toBeNull(); + }); + + test("waitForUserInput is present in CommandContext interface", () => { + const context = createMockContext(); + expect(typeof context.waitForUserInput).toBe("function"); + }); + + test("mock waitForUserInput resolves with a string", async () => { + const context = createMockContext({ + waitForUserInput: async () => "user typed this", + }); + const result = await context.waitForUserInput(); + expect(result).toBe("user typed this"); + }); + + test("clearContext is not called during workflow execution", async () => { + let clearContextCalled = false; + const taskJson = JSON.stringify([ + { id: "#1", content: "Test task", status: "completed", activeForm: "Testing" } + ]); + const commands = getWorkflowCommands(); + const ralphCommand = commands.find((cmd) => cmd.name === "ralph"); + + const context = createMockContext({ + streamAndWait: async () => ({ content: taskJson, wasInterrupted: false }), + clearContext: async () => { clearContextCalled = true; }, + state: { isStreaming: false, messageCount: 0, workflowActive: false }, + }); + + await ralphCommand!.execute("Build a feature", context); + expect(clearContextCalled).toBe(false); + }); + + test("interrupted step1 waits for user input and continues", async () => { + const commands = getWorkflowCommands(); + const ralphCommand = commands.find((cmd) => cmd.name === "ralph"); + const prompts: string[] = []; + let streamCallCount = 0; + let waitForUserInputCalled = false; + + const context = createMockContext({ + waitForUserInput: async () => { + waitForUserInputCalled = true; + return "retry planning with smaller scope"; + }, + streamAndWait: async (prompt: string) => { + prompts.push(prompt); + streamCallCount++; + + if (streamCallCount === 1) { + return { content: "", wasInterrupted: true }; + } + + if (streamCallCount === 2) { + return { + content: JSON.stringify([ + { + id: "#1", + content: "Test task", + status: "completed", + activeForm: "Testing", + }, + ]), + wasInterrupted: false, + }; + } + + return { content: "done", wasInterrupted: false }; + }, + state: { isStreaming: false, messageCount: 0, workflowActive: false }, + }); + + const result = await ralphCommand!.execute("Build something", context); + + expect(waitForUserInputCalled).toBe(true); + expect(streamCallCount).toBeGreaterThanOrEqual(2); + expect(prompts[1]).toContain("retry planning with smaller scope"); + expect(result.success).toBe(true); + expect(result.stateUpdate).toBeDefined(); + expect(result.stateUpdate?.workflowActive).toBe(false); + }); +}); + +describe("workflow inline mode integration", () => { + test("#16 - Ralph end-to-end without clearContext calls", async () => { + let clearContextCalled = false; + let streamCallCount = 0; + let sessionDir: string | null = null; + const spawnCalls: Array<{ name?: string; message: string }> = []; + + const context = createMockContext({ + streamAndWait: async (prompt: string, options?: { hideContent?: boolean }) => { + streamCallCount++; + + if (streamCallCount === 1) { + // Step 1: Initial task decomposition + return { + content: JSON.stringify([ + { id: "#1", content: "Task 1", status: "pending", activeForm: "Working" }, + ]), + wasInterrupted: false, + }; + } + + if (streamCallCount === 2) { + // Step 2: Implementation - write completed tasks + if (sessionDir) { + await fsWriteFile( + join(sessionDir, "tasks.json"), + JSON.stringify([ + { id: "#1", content: "Task 1", status: "completed", activeForm: "Working" }, + ]) + ); + } + return { content: "Task completed", wasInterrupted: false }; + } + + if (streamCallCount === 3 && options?.hideContent) { + // Step 3: Fix task decomposition (after review finds issues) + return { + content: JSON.stringify([ + { id: "#fix-1", content: "Fix this", status: "pending", activeForm: "Fixing" }, + ]), + wasInterrupted: false, + }; + } + + if (streamCallCount === 4) { + // Step 4: Fix implementation + if (sessionDir) { + await fsWriteFile( + join(sessionDir, "tasks.json"), + JSON.stringify([ + { id: "#fix-1", content: "Fix this", status: "completed", activeForm: "Fixing" }, + ]) + ); + } + return { content: "Fix completed", wasInterrupted: false }; + } + + return { content: "", wasInterrupted: false }; + }, + spawnSubagent: async (options) => { + spawnCalls.push({ name: options.name, message: options.message }); + // Return reviewer output with findings + return { + success: true, + output: JSON.stringify({ + findings: [ + { + title: "[P1] Fix this", + body: "Details", + priority: 1, + }, + ], + overall_correctness: "patch is incorrect", + overall_explanation: "Fix needed", + overall_confidence_score: 0.9, + }), + }; + }, + clearContext: async () => { + clearContextCalled = true; + }, + setRalphSessionDir: (dir: string | null) => { + sessionDir = dir; + if (dir) { + const { mkdirSync } = require("fs"); + mkdirSync(dir, { recursive: true }); + } + }, + setRalphSessionId: () => {}, + setRalphTaskIds: () => {}, + updateWorkflowState: () => {}, + }); + + const ralphCommand = getWorkflowCommands().find((cmd) => cmd.name === "ralph"); + expect(ralphCommand).toBeDefined(); + + const result = await ralphCommand!.execute("Build feature", context); + + // Assert: clearContext was NEVER called + expect(clearContextCalled).toBe(false); + + // Assert: result.success is true + expect(result.success).toBe(true); + + // Assert: result.stateUpdate.workflowActive is false + expect(result.stateUpdate?.workflowActive).toBe(false); + + // Cleanup + if (sessionDir) { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + test("#17 - user prompt passthrough after Ctrl+C in workflow", async () => { + let streamCallCount = 0; + let sessionDir: string | null = null; + const streamPrompts: string[] = []; + let waitForUserInputCalled = false; + + const context = createMockContext({ + waitForUserInput: async () => { + waitForUserInputCalled = true; + return "please fix the button color"; + }, + streamAndWait: async (prompt: string) => { + streamCallCount++; + streamPrompts.push(prompt); + + if (streamCallCount === 1) { + // Step 1: Initial decomposition with 2 tasks + return { + content: JSON.stringify([ + { id: "#1", content: "Task 1", status: "pending", activeForm: "Working" }, + { id: "#2", content: "Task 2", status: "pending", activeForm: "Working" }, + ]), + wasInterrupted: false, + }; + } + + if (streamCallCount === 2) { + // Step 2: First task implementation - simulates Ctrl+C + if (sessionDir) { + await fsWriteFile( + join(sessionDir, "tasks.json"), + JSON.stringify([ + { id: "#1", content: "Task 1", status: "completed", activeForm: "Working" }, + { id: "#2", content: "Task 2", status: "pending", activeForm: "Working" }, + ]) + ); + } + return { content: "Task 1 done", wasInterrupted: true }; + } + + if (streamCallCount === 3) { + // Step 3: Continue after user input - should contain user's prompt + if (sessionDir) { + await fsWriteFile( + join(sessionDir, "tasks.json"), + JSON.stringify([ + { id: "#1", content: "Task 1", status: "completed", activeForm: "Working" }, + { id: "#2", content: "Task 2", status: "completed", activeForm: "Working" }, + ]) + ); + } + return { content: "Task 2 done", wasInterrupted: false }; + } + + return { content: "", wasInterrupted: false }; + }, + spawnSubagent: async () => { + // Return clean review (no actionable findings) + return { + success: true, + output: JSON.stringify({ + findings: [], + overall_correctness: "correct", + overall_explanation: "All changes look good", + overall_confidence_score: 0.95, + }), + }; + }, + setRalphSessionDir: (dir: string | null) => { + sessionDir = dir; + if (dir) { + const { mkdirSync } = require("fs"); + mkdirSync(dir, { recursive: true }); + } + }, + setRalphSessionId: () => {}, + setRalphTaskIds: () => {}, + updateWorkflowState: () => {}, + }); + + const ralphCommand = getWorkflowCommands().find((cmd) => cmd.name === "ralph"); + expect(ralphCommand).toBeDefined(); + + const result = await ralphCommand!.execute("Build feature", context); + + // Verify waitForUserInput was called + expect(waitForUserInputCalled).toBe(true); + + // Verify the third streamAndWait call's prompt contains the user's input + expect(streamPrompts.length).toBeGreaterThanOrEqual(3); + expect(streamPrompts[2]).toContain("please fix the button color"); + + // Verify result.success is true + expect(result.success).toBe(true); + + // Cleanup + if (sessionDir) { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + test("#18 - task list persists after Ctrl+C, hides on completion", async () => { + let streamCallCount = 0; + let sessionDir: string | null = null; + const setRalphSessionDirCalls: Array = []; + const updateWorkflowStateCalls: Array> = []; + + const context = createMockContext({ + waitForUserInput: async () => "continue", + streamAndWait: async (prompt: string) => { + streamCallCount++; + + if (streamCallCount === 1) { + // Step 1: Initial decomposition + return { + content: JSON.stringify([ + { id: "#1", content: "Task 1", status: "pending", activeForm: "Working" }, + ]), + wasInterrupted: false, + }; + } + + if (streamCallCount === 2) { + // Step 2: Ctrl+C during implementation + if (sessionDir) { + await fsWriteFile( + join(sessionDir, "tasks.json"), + JSON.stringify([ + { id: "#1", content: "Task 1", status: "in_progress", activeForm: "Working" }, + ]) + ); + } + return { content: "Working...", wasInterrupted: true }; + } + + if (streamCallCount === 3) { + // Step 3: Continue after user input + if (sessionDir) { + await fsWriteFile( + join(sessionDir, "tasks.json"), + JSON.stringify([ + { id: "#1", content: "Task 1", status: "completed", activeForm: "Working" }, + ]) + ); + } + return { content: "Task completed", wasInterrupted: false }; + } + + return { content: "", wasInterrupted: false }; + }, + spawnSubagent: async () => { + // Return clean review + return { + success: true, + output: JSON.stringify({ + findings: [], + overall_correctness: "correct", + overall_explanation: "All good", + overall_confidence_score: 0.95, + }), + }; + }, + setRalphSessionDir: (dir: string | null) => { + setRalphSessionDirCalls.push(dir); + sessionDir = dir; + if (dir) { + const { mkdirSync } = require("fs"); + mkdirSync(dir, { recursive: true }); + } + }, + updateWorkflowState: (state: Partial<{ workflowActive: boolean }>) => { + updateWorkflowStateCalls.push(state); + }, + setRalphSessionId: () => {}, + setRalphTaskIds: () => {}, + }); + + const ralphCommand = getWorkflowCommands().find((cmd) => cmd.name === "ralph"); + expect(ralphCommand).toBeDefined(); + + const result = await ralphCommand!.execute("Build feature", context); + + // Verify setRalphSessionDir was called with a non-null path at start + expect(setRalphSessionDirCalls.length).toBeGreaterThan(0); + expect(setRalphSessionDirCalls[0]).not.toBeNull(); + + // Verify setRalphSessionDir(null) was NOT called during workflow + const nullCalls = setRalphSessionDirCalls.filter((dir) => dir === null); + expect(nullCalls.length).toBe(0); + + // Verify result.stateUpdate.workflowActive is false (signals task list should hide) + expect(result.stateUpdate?.workflowActive).toBe(false); + + // Cleanup + if (sessionDir) { + await rm(sessionDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/ui/commands/workflow-commands.ts b/src/ui/commands/workflow-commands.ts index 5411487ed..5b1eee5d5 100644 --- a/src/ui/commands/workflow-commands.ts +++ b/src/ui/commands/workflow-commands.ts @@ -544,6 +544,39 @@ function hasActionableTasks(tasks: NormalizedTodoItem[]): boolean { }); } +type StreamAndWaitResult = Awaited>; + +async function streamWithInterruptRecovery( + context: CommandContext, + initialPrompt: string, + options?: { hideContent?: boolean }, + onInterrupted?: ( + userPrompt: string, + ) => { prompt: string; options?: { hideContent?: boolean } }, +): Promise { + let prompt = initialPrompt; + let streamOptions = options; + + while (true) { + const result = await context.streamAndWait(prompt, streamOptions); + + if (result.wasCancelled || !result.wasInterrupted) { + return result; + } + + const userPrompt = await context.waitForUserInput(); + + if (onInterrupted) { + const next = onInterrupted(userPrompt); + prompt = next.prompt; + streamOptions = next.options; + } else { + prompt = userPrompt; + streamOptions = undefined; + } + } +} + function createRalphCommand(metadata: WorkflowMetadata): CommandDefinition { return { name: metadata.name, @@ -590,31 +623,45 @@ function createRalphCommand(metadata: WorkflowMetadata): CommandDefinition { ralphConfig: { sessionId, userPrompt: parsed.prompt }, }); - // Step 1: Task decomposition (blocks until streaming completes) - // hideContent suppresses raw JSON rendering in the chat — content is still - // accumulated in StreamResult for parseTasks() and task-state persistence takes over. - const step1 = await context.streamAndWait( - buildSpecToTasksPrompt(parsed.prompt), - { hideContent: true }, - ); - if (step1.wasInterrupted) return { success: true }; - - // Parse tasks from step 1 output and save to disk (file watcher handles UI) - const tasks = parseTasks(step1.content); - if (tasks.length > 0) { - await saveTasksToActiveSession(tasks, sessionId); - // Seed in-memory TodoWrite state so later payloads that omit IDs - // can be reconciled against the planning-phase task list. - context.setTodoItems( - tasks.map((task) => ({ - ...task, - status: - task.status === "error" - ? "pending" - : task.status, - })) as TodoItem[], + try { + // Step 1: Task decomposition (blocks until streaming completes) + // hideContent suppresses raw JSON rendering in the chat — content is still + // accumulated in StreamResult for parseTasks() and task-state persistence takes over. + const step1 = await streamWithInterruptRecovery( + context, + buildSpecToTasksPrompt(parsed.prompt), + { hideContent: true }, + (userPrompt) => ({ + prompt: buildSpecToTasksPrompt(userPrompt), + options: { hideContent: true }, + }), ); - } + if (step1.wasCancelled) + return { + success: true, + stateUpdate: { + workflowActive: false, + workflowType: null, + initialPrompt: null, + }, + }; + + // Parse tasks from step 1 output and save to disk (file watcher handles UI) + const tasks = parseTasks(step1.content); + if (tasks.length > 0) { + await saveTasksToActiveSession(tasks, sessionId); + // Seed in-memory TodoWrite state so later payloads that omit IDs + // can be reconciled against the planning-phase task list. + context.setTodoItems( + tasks.map((task) => ({ + ...task, + status: + task.status === "error" + ? "pending" + : task.status, + })) as TodoItem[], + ); + } // Track Ralph session metadata AFTER tasks.json exists on disk context.setRalphSessionDir(sessionDir); @@ -647,8 +694,11 @@ function createRalphCommand(metadata: WorkflowMetadata): CommandDefinition { ) : buildContinuePrompt(currentTasks, sessionId); - const result = await context.streamAndWait(prompt); - if (result.wasInterrupted) break; + const result = await streamWithInterruptRecovery( + context, + prompt, + ); + if (result.wasCancelled) break; // Read latest task state from disk after agent response const diskTasks = await readTasksFromDisk(sessionDir); @@ -680,9 +730,6 @@ function createRalphCommand(metadata: WorkflowMetadata): CommandDefinition { reviewIteration < MAX_REVIEW_ITERATIONS; reviewIteration++ ) { - // Clear context before review to provide clean slate - await context.clearContext(); - // Get current task state for review const reviewTasks = await readTasksFromDisk(sessionDir); const reviewPrompt = buildReviewPrompt( @@ -731,12 +778,16 @@ function createRalphCommand(metadata: WorkflowMetadata): CommandDefinition { await writeFile(fixSpecPath, fixSpec); // Re-invoke ralph: decompose fix-spec into tasks (Step 1 again) - await context.clearContext(); - const fixStep1 = await context.streamAndWait( + const fixStep1 = await streamWithInterruptRecovery( + context, buildSpecToTasksPrompt(fixSpec), { hideContent: true }, + (userPrompt) => ({ + prompt: buildSpecToTasksPrompt(userPrompt), + options: { hideContent: true }, + }), ); - if (fixStep1.wasInterrupted) break; + if (fixStep1.wasCancelled) break; const fixTasks = parseTasks(fixStep1.content); if (fixTasks.length === 0) break; @@ -770,8 +821,11 @@ function createRalphCommand(metadata: WorkflowMetadata): CommandDefinition { sessionId, ); - const result = await context.streamAndWait(prompt); - if (result.wasInterrupted) break; + const result = await streamWithInterruptRecovery( + context, + prompt, + ); + if (result.wasCancelled) break; // Read latest task state from disk after agent response const diskTasks = @@ -794,7 +848,36 @@ function createRalphCommand(metadata: WorkflowMetadata): CommandDefinition { } } - return { success: true }; + return { + success: true, + stateUpdate: { + workflowActive: false, + workflowType: null, + initialPrompt: null, + }, + }; + } catch (error) { + // Silent exit for workflow cancellation (double Ctrl+C) + if (error instanceof Error && error.message === "Workflow cancelled") { + return { + success: true, + stateUpdate: { + workflowActive: false, + workflowType: null, + initialPrompt: null, + }, + }; + } + return { + success: false, + message: `Workflow failed: ${error instanceof Error ? error.message : String(error)}`, + stateUpdate: { + workflowActive: false, + workflowType: null, + initialPrompt: null, + }, + }; + } }, }; } diff --git a/src/ui/commands/workflow-inline-mode-e2e.test.ts b/src/ui/commands/workflow-inline-mode-e2e.test.ts new file mode 100644 index 000000000..e665a2711 --- /dev/null +++ b/src/ui/commands/workflow-inline-mode-e2e.test.ts @@ -0,0 +1,432 @@ +/** + * E2E tests for workflow inline mode + * + * These tests verify the complete lifecycle of the /ralph workflow in inline mode, + * including teal border state, Ctrl+C user intervention, and task list persistence. + */ + +import { describe, test, expect } from "bun:test"; +import { mkdtemp, writeFile as fsWriteFile, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { mkdirSync, existsSync, readFileSync } from "fs"; +import type { CommandContext, CommandContextState } from "./registry.ts"; +import { getWorkflowCommands } from "./workflow-commands.ts"; + +function createMockContext(overrides?: Partial): CommandContext { + return { + session: null, + state: { + isStreaming: false, + messageCount: 0, + workflowActive: false, + }, + addMessage: () => {}, + setStreaming: () => {}, + sendMessage: () => {}, + sendSilentMessage: () => {}, + spawnSubagent: async () => ({ success: true, output: "" }), + streamAndWait: async () => ({ content: "", wasInterrupted: false }), + waitForUserInput: async () => "", + clearContext: async () => {}, + setTodoItems: () => {}, + setRalphSessionDir: () => {}, + setRalphSessionId: () => {}, + setRalphTaskIds: () => {}, + updateWorkflowState: () => {}, + ...overrides, + }; +} + +describe("Workflow inline mode E2E", () => { + test("teal border state tracks workflow lifecycle", async () => { + // Track updateWorkflowState calls + const workflowStateUpdates: Array> = []; + let sessionDir: string | null = null; + + const context = createMockContext({ + updateWorkflowState: (update) => { + workflowStateUpdates.push(update); + }, + setRalphSessionDir: (dir) => { + sessionDir = dir; + if (dir && !existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + }, + streamAndWait: async (prompt: string) => { + // Call 1: Return task JSON + if (prompt.includes("task list")) { + return { + content: JSON.stringify([ + { + id: "#1", + content: "Task 1", + status: "pending", + activeForm: "Working on task 1", + }, + ]), + wasInterrupted: false, + }; + } + + // Call 2: Write tasks.json completed, return content + if (sessionDir) { + const tasksPath = join(sessionDir, "tasks.json"); + await fsWriteFile( + tasksPath, + JSON.stringify([ + { + id: "#1", + content: "Task 1", + status: "completed", + activeForm: "Working on task 1", + }, + ]), + ); + } + return { content: "Task completed", wasInterrupted: false }; + }, + spawnSubagent: async () => ({ + success: true, + output: JSON.stringify({ + findings: [], + overall_correctness: "correct", + overall_explanation: "LGTM", + overall_confidence_score: 1.0, + }), + }), + }); + + // Get the ralph command + const commands = getWorkflowCommands(); + const ralphCommand = commands.find((cmd) => cmd.name === "ralph"); + expect(ralphCommand).toBeDefined(); + + // Run workflow + const result = await ralphCommand!.execute("Build feature", context); + + // Assert: updateWorkflowState was called with workflowActive: true at some point + const hasWorkflowActive = workflowStateUpdates.some( + (update) => update.workflowActive === true, + ); + expect(hasWorkflowActive).toBe(true); + + // Assert: updateWorkflowState was called with workflowType containing a string + const hasWorkflowType = workflowStateUpdates.some( + (update) => typeof update.workflowType === "string" && update.workflowType.length > 0, + ); + expect(hasWorkflowType).toBe(true); + + // Assert: result.stateUpdate.workflowActive is false + expect(result.stateUpdate?.workflowActive).toBe(false); + + // Assert: result.stateUpdate.workflowType is null + expect(result.stateUpdate?.workflowType).toBe(null); + + // Assert: result.stateUpdate.initialPrompt is null + expect(result.stateUpdate?.initialPrompt).toBe(null); + + // Clean up temp dir + if (sessionDir && existsSync(sessionDir)) { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + test("Ctrl+C interruption triggers user prompt and continues workflow", async () => { + // Track calls + const streamAndWaitCalls: string[] = []; + let waitForUserInputCallCount = 0; + const workflowStateUpdates: Array> = []; + let sessionDir: string | null = null; + + const context = createMockContext({ + updateWorkflowState: (update) => { + workflowStateUpdates.push(update); + }, + setRalphSessionDir: (dir) => { + sessionDir = dir; + if (dir && !existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + }, + streamAndWait: async (prompt: string) => { + streamAndWaitCalls.push(prompt); + + // Call 1: return 2 tasks JSON (step1 decomposition) + if (streamAndWaitCalls.length === 1) { + return { + content: JSON.stringify([ + { + id: "#1", + content: "Task 1", + status: "pending", + activeForm: "Working on task 1", + }, + { + id: "#2", + content: "Task 2", + status: "pending", + activeForm: "Working on task 2", + }, + ]), + wasInterrupted: false, + }; + } + + // Call 2: return wasInterrupted: true (Ctrl+C during task 1) + if (streamAndWaitCalls.length === 2) { + return { content: "", wasInterrupted: true }; + } + + // Call 3: should receive prompt containing user's follow-up text, write task 1 completed to tasks.json + if (streamAndWaitCalls.length === 3) { + if (sessionDir) { + const tasksPath = join(sessionDir, "tasks.json"); + await fsWriteFile( + tasksPath, + JSON.stringify([ + { + id: "#1", + content: "Task 1", + status: "completed", + activeForm: "Working on task 1", + }, + { + id: "#2", + content: "Task 2", + status: "pending", + activeForm: "Working on task 2", + }, + ]), + ); + } + return { content: "Task 1 completed with alignment fix", wasInterrupted: false }; + } + + // Call 4: write task 2 completed to tasks.json, return content + if (streamAndWaitCalls.length === 4) { + if (sessionDir) { + const tasksPath = join(sessionDir, "tasks.json"); + await fsWriteFile( + tasksPath, + JSON.stringify([ + { + id: "#1", + content: "Task 1", + status: "completed", + activeForm: "Working on task 1", + }, + { + id: "#2", + content: "Task 2", + status: "completed", + activeForm: "Working on task 2", + }, + ]), + ); + } + return { content: "Task 2 completed", wasInterrupted: false }; + } + + return { content: "", wasInterrupted: false }; + }, + waitForUserInput: async () => { + waitForUserInputCallCount++; + return "fix the alignment issue"; + }, + spawnSubagent: async () => ({ + success: true, + output: JSON.stringify({ + findings: [], + overall_correctness: "correct", + overall_explanation: "LGTM", + overall_confidence_score: 1.0, + }), + }), + }); + + // Get the ralph command + const commands = getWorkflowCommands(); + const ralphCommand = commands.find((cmd) => cmd.name === "ralph"); + expect(ralphCommand).toBeDefined(); + + // Run workflow + const result = await ralphCommand!.execute("Build feature", context); + + // Assert: waitForUserInput was called exactly once + expect(waitForUserInputCallCount).toBe(1); + + // Assert: streamAndWait call 3 prompt includes user's follow-up text + expect(streamAndWaitCalls[2]).toContain("fix the alignment issue"); + + // Assert: result.success is true + expect(result.success).toBe(true); + + // Assert: result.stateUpdate.workflowActive is false + expect(result.stateUpdate?.workflowActive).toBe(false); + + // Assert: updateWorkflowState was called with workflowActive: true at start + const hasWorkflowActive = workflowStateUpdates.some( + (update) => update.workflowActive === true, + ); + expect(hasWorkflowActive).toBe(true); + + // Clean up temp dir + if (sessionDir && existsSync(sessionDir)) { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + test("task list persists through interruption and tasks.json is maintained", async () => { + // Track calls + let sessionDir: string | null = null; + let sessionId: string | null = null; + let setRalphSessionDirCallCount = 0; + let setRalphSessionIdCallCount = 0; + let setRalphTaskIdsCallCount = 0; + + const context = createMockContext({ + setRalphSessionDir: (dir) => { + setRalphSessionDirCallCount++; + sessionDir = dir; + if (dir && !existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + }, + setRalphSessionId: (id) => { + setRalphSessionIdCallCount++; + sessionId = id; + }, + setRalphTaskIds: () => { + setRalphTaskIdsCallCount++; + }, + streamAndWait: async (prompt: string, options) => { + // Call 1: return task JSON with 2 tasks (step1) + if (prompt.includes("task list")) { + return { + content: JSON.stringify([ + { + id: "#1", + content: "Task 1", + status: "pending", + activeForm: "Working on task 1", + }, + { + id: "#2", + content: "Task 2", + status: "pending", + activeForm: "Working on task 2", + }, + ]), + wasInterrupted: false, + }; + } + + // Call 2: return wasInterrupted: true (Ctrl+C) + if (!sessionDir) { + return { content: "", wasInterrupted: true }; + } + + // Call 3: write task 1 completed to tasks.json, return content + const tasksPath = join(sessionDir, "tasks.json"); + const currentTasks = existsSync(tasksPath) + ? JSON.parse(readFileSync(tasksPath, "utf-8")) + : []; + + if (currentTasks.length === 2 && currentTasks[0].status === "pending") { + await fsWriteFile( + tasksPath, + JSON.stringify([ + { + id: "#1", + content: "Task 1", + status: "completed", + activeForm: "Working on task 1", + }, + { + id: "#2", + content: "Task 2", + status: "pending", + activeForm: "Working on task 2", + }, + ]), + ); + return { content: "Task 1 completed", wasInterrupted: false }; + } + + // Call 4: write task 2 completed to tasks.json, return content + await fsWriteFile( + tasksPath, + JSON.stringify([ + { + id: "#1", + content: "Task 1", + status: "completed", + activeForm: "Working on task 1", + }, + { + id: "#2", + content: "Task 2", + status: "completed", + activeForm: "Working on task 2", + }, + ]), + ); + return { content: "Task 2 completed", wasInterrupted: false }; + }, + waitForUserInput: async () => "keep going", + spawnSubagent: async () => ({ + success: true, + output: JSON.stringify({ + findings: [], + overall_correctness: "correct", + overall_explanation: "LGTM", + overall_confidence_score: 1.0, + }), + }), + }); + + // Get the ralph command + const commands = getWorkflowCommands(); + const ralphCommand = commands.find((cmd) => cmd.name === "ralph"); + expect(ralphCommand).toBeDefined(); + + // Run workflow + const result = await ralphCommand!.execute("Build feature", context); + + // Assert: setRalphSessionDir was called with a non-null string (dir was set) + expect(setRalphSessionDirCallCount).toBeGreaterThan(0); + expect(sessionDir).not.toBeNull(); + expect(typeof sessionDir).toBe("string"); + + // Assert: setRalphSessionId was called with a non-null string + expect(setRalphSessionIdCallCount).toBeGreaterThan(0); + expect(sessionId).not.toBeNull(); + expect(typeof sessionId).toBe("string"); + + // Assert: setRalphTaskIds was called (tasks were tracked) + expect(setRalphTaskIdsCallCount).toBeGreaterThan(0); + + // Assert: tasks.json exists in the session dir and contains task data + if (sessionDir) { + const tasksPath = join(sessionDir, "tasks.json"); + expect(existsSync(tasksPath)).toBe(true); + + const tasks = JSON.parse(readFileSync(tasksPath, "utf-8")); + expect(Array.isArray(tasks)).toBe(true); + expect(tasks.length).toBe(2); + + // Assert: tasks.json has all tasks with status "completed" + expect(tasks.every((task: any) => task.status === "completed")).toBe(true); + } + + // Assert: result.stateUpdate.workflowActive is false + expect(result.stateUpdate?.workflowActive).toBe(false); + + // Clean up temp dir + if (sessionDir && existsSync(sessionDir)) { + await rm(sessionDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/ui/components/parts/reasoning-part-display.tsx b/src/ui/components/parts/reasoning-part-display.tsx index 338cbd846..f07dfe2bc 100644 --- a/src/ui/components/parts/reasoning-part-display.tsx +++ b/src/ui/components/parts/reasoning-part-display.tsx @@ -12,6 +12,7 @@ import type { SyntaxStyle } from "@opentui/core"; import type { ReasoningPart } from "../../parts/types.ts"; import { createDimmedSyntaxStyle, createMarkdownSyntaxStyle, useTheme, useThemeColors } from "../../theme.tsx"; import { SPACING } from "../../constants/spacing.ts"; +import { normalizeMarkdownNewlines } from "../../utils/format.ts"; export interface ReasoningPartDisplayProps { part: ReasoningPart; @@ -49,7 +50,7 @@ export function ReasoningPartDisplay({ part, syntaxStyle }: ReasoningPartDisplay drawUnstyledText={false} streaming={part.isStreaming} syntaxStyle={dimmedStyle} - content={part.content} + content={normalizeMarkdownNewlines(part.content)} conceal={true} fg={colors.muted} /> diff --git a/src/ui/components/parts/text-part-display.tsx b/src/ui/components/parts/text-part-display.tsx index 5fad3b286..94c667b01 100644 --- a/src/ui/components/parts/text-part-display.tsx +++ b/src/ui/components/parts/text-part-display.tsx @@ -13,6 +13,7 @@ import type { TextPart } from "../../parts/types.ts"; import { useThrottledValue } from "../../hooks/use-throttled-value.ts"; import { createMarkdownSyntaxStyle, useTheme, useThemeColors } from "../../theme.tsx"; import { STATUS } from "../../constants/icons.ts"; +import { normalizeMarkdownNewlines } from "../../utils/format.ts"; export interface TextPartDisplayProps { part: TextPart; @@ -37,18 +38,22 @@ export function TextPartDisplay({ part, syntaxStyle }: TextPartDisplayProps) { return null; } + // Collapse single newlines to spaces (standard markdown soft-break behaviour). + // OpenTUI's TextRenderable renders literal \n as hard line breaks unlike HTML. + const normalizedContent = normalizeMarkdownNewlines(trimmedContent); + return ( {syntaxStyle ? ( ) : ( { + fences.push(m); + return `\x00F${fences.length - 1}\x00`; + }); + + // 2. Collapse single newlines to spaces; keep \n\n+ intact + text = text.replace(/(? fences[parseInt(i)] ?? ""); + return text; +} + // ============================================================================ // TEXT TRUNCATION // ============================================================================