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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/refactor-tui/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-16
109 changes: 109 additions & 0 deletions openspec/changes/refactor-tui/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
## Context

The TUI (`src/tui/`) is the primary user-facing interface for madz. It uses Ink + `ink-scroll-view` + a structured logger. The current implementation works but has structural debt:

- `app.js` has 8 independent `useState` calls with no coordination
- Streaming callback is set up inline in `handleChat()` / `handleCommand()` and passed through multiple layers
- Flat 17-file layout in `src/tui/` — no grouping by concern
- Panel system (`panels.js`, `skillsPanel.js`, `memoryPanel.js`, `settingsPanel.js`) contradicts the blueprint's "no panels" philosophy
- Command parser is a switch-driven dispatch table, not extensible

The blueprint (`docs/TUI.md`) defines the target architecture. This design bridges the gap between current state and that target.

## Goals / Non-Goals

**Goals:**
- Consolidate all TUI state into a single `useReducer` with `TUIState` interface
- Extract streaming logic into `useStreaming()` hook with clean AbortController lifecycle
- Reorganize file structure into `state/`, `hooks/`, `components/`, `utils/` directories
- Remove the panel system entirely; move panel functionality to commands
- Refactor command parser to event-driven registry pattern
- Add runtime toggle system with `/toggle` commands and status bar indicators
- Maintain 100% test coverage on all new files; existing tests must pass

**Non-Goals:**
- Format customization system (YAGNI per blueprint)
- Message-level filtering (YAGNI per blueprint)
- Changing the component hierarchy (App → ConversationPanel + StatusBar + InputPanel stays the same)
- Modifying the structured logger, tiktoken calculation, or `ink-scroll-view` usage
- Changing the AI provider integration layer

## Decisions

### 1. `useReducer` over `useState` — Decision: Yes
**Rationale:** Eight independent state variables that update together (messages, statusMessage, contextSize, chatHistory) create race conditions and unnecessary re-renders. A single reducer with a `TUIState` interface gives us one render cycle per meaningful change and makes state transitions explicit through action types.

**Alternatives considered:**
- `useReducer` with context — overkill for a single component tree
- Zustand/Jotai — adds external dependency for what can be solved with built-in React

### 2. Streaming Hook — Decision: `useStreaming()` returning `streamingState` object
**Rationale:** The streaming callback currently lives inline, passed through `handleChat()` → `dispatchProvider` → `callProvider` → `callReactAgentStreaming`. Extracting it into a hook that manages the AbortController lifecycle, translates stream events into state transitions, and exposes a clean `streamingState` object separates *how we stream* from *what we stream*.

**Alternatives considered:**
- Context provider — adds unnecessary abstraction layer
- Separate service class — over-engineering for a single-consumer hook

### 3. File Structure — Decision: Group by concern
**Rationale:** A flat 17-file layout doesn't scale. Grouping by concern (`state/`, `hooks/`, `components/`, `utils/`) means when you're looking for streaming logic, you know exactly where to look. This is about predictability, not dogma.

**Target structure:**
```
src/tui/
├── app.js # Root component, providers, reducer
├── state/
│ ├── reducer.js # useReducer implementation, all action handlers
│ ├── types.js # TUIState, TUIAction, Message interfaces
│ └── selectors.js # Derived state (contextSize, statusMessage, etc.)
├── hooks/
│ ├── useStreaming.js # AbortController, event transformation, auto-continue
│ ├── useScroll.js # ScrollView ref, resize handling, keyboard scroll
│ ├── useInput.js # Keyboard routing (scroll vs history vs input)
│ └── useCommand.js # Command parsing + dispatch
├── components/
│ ├── ConversationPanel.js
│ ├── InputPanel.js
│ ├── StatusBar.js
│ ├── MessageBubble.js
│ └── Banner.js
├── panels/
│ └── OnboardingPanel.js # Only panel that stays
├── utils/
│ ├── commandParser.js # Command registry, dispatch table
│ ├── contextTokens.js # tiktoken token calculation
│ ├── markdownText.js # marked + marked-terminal rendering
│ └── format.js # Format specifiers, toggle logic
└── index.js
```

### 4. Panel Removal — Decision: Remove all panels except OnboardingPanel
**Rationale:** The blueprint's core philosophy is "no panels, no tabs, no switching." The skills, memory, and settings panels contradict this. Their functionality moves to commands (`/skills`, `/memory`, `/config`) that produce output in the conversation stream. OnboardingPanel stays because it's a first-run flow, not a navigation surface.

### 5. Command Registry — Decision: Event-driven with validate/execute/help
**Rationale:** The current switch-driven dispatch table requires editing the parser to add commands. An event-driven registry where commands are registered as objects with `validate`, `execute`, and `help` properties makes adding new commands a registration, not a switch case edit.

## Risks / Trade-offs

| Risk | Mitigation |
|------|-----------|
| Reducer complexity — single reducer handling all TUI state could become unwieldy | Split into named reducers (messagesReducer, inputReducer, etc.) composed with `combineReducers`-style pattern |
| Streaming hook abstraction leaks implementation details | Keep the hook's public API minimal: `streamingState` object + `startStreaming()` / `abort()` methods |
| Panel removal breaks existing tests | All panel-related tests move to command tests; OnboardingPanel tests stay |
| File relocation causes merge conflicts during transition | Do all moves in a single commit; no incremental refactoring |
| Toggle system adds UI complexity | Start with 5 toggles only; format specifiers and message filtering deferred (YAGNI) |

## Migration Plan

1. **Phase 1 — State consolidation:** Create `state/types.js`, `state/reducer.js`, `state/selectors.js`. Migrate `app.js` from `useState` to `useReducer`.
2. **Phase 2 — Streaming extraction:** Create `hooks/useStreaming.js`. Extract streaming callback from `handleChat()`/`handleCommand()`.
3. **Phase 3 — File reorganization:** Move files to new directory structure. Update all imports.
4. **Phase 4 — Panel removal:** Delete `panels.js`, `skillsPanel.js`, `memoryPanel.js`, `settingsPanel.js`. Move their functionality to commands.
5. **Phase 5 — Command registry:** Rewrite `commandParser.js` as event-driven registry.
6. **Phase 6 — Runtime toggles:** Add toggle commands and status bar indicators.
7. **Phase 7 — Tests:** Write new tests for reducer, streaming hook, command registry. Verify all existing tests pass.

## Open Questions

1. Should the reducer be a single `reducer.js` or split into named sub-reducers composed together?
2. What's the exact API surface of `useStreaming()` — should it return a hook or a custom hook factory?
3. Should toggle overrides persist to `config.yaml` on exit, or remain in-memory only? (Blueprint says in-memory only.)
32 changes: 32 additions & 0 deletions openspec/changes/refactor-tui/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## Why

The TUI works but its implementation scaffolding doesn't scale. Eight independent `useState` calls with no coordination, streaming logic scattered inline, a flat 17-file layout, and a panel system that contradicts the blueprint's "no panels" philosophy. As the TUI grows, these structural decisions compound — making it harder to find code, harder to reason about state transitions, and harder to add new features without touching multiple unrelated files.

## What Changes

- **Consolidate state** into a single `useReducer` with a `TUIState` interface, replacing eight independent `useState` calls
- **Extract streaming logic** into a `useStreaming()` hook that manages AbortController lifecycle, translates stream events into state transitions, and handles the auto-continue circuit breaker
- **Reorganize file structure** from flat layout to grouped-by-concern (`state/`, `hooks/`, `components/`, `utils/`)
- **Remove the panel system** (`panels.js`, `skillsPanel.js`, `memoryPanel.js`, `settingsPanel.js`) — all panel functionality moves to commands that produce output in the conversation stream
- **Refactor command parser** from switch-driven dispatch table to event-driven command registry with `validate`, `execute`, and `help` properties
- **Add runtime toggle system** (`/toggle` commands) for `autoScroll`, `timestamps`, `commandEcho`, `cursorBreathe`, `debugOutput`
- **Add toggle indicators** to the status bar

## Capabilities

### New Capabilities
- `tui-state-management`: Consolidated `useReducer` state management with `TUIState` interface and typed actions
- `tui-streaming-hook`: Extracted streaming logic into `useStreaming()` hook with AbortController lifecycle and auto-continue circuit breaker
- `tui-file-structure`: Reorganized TUI file structure grouped by concern (`state/`, `hooks/`, `components/`, `utils/`)
- `tui-panel-removal`: Removed panel system; all panel functionality moved to commands producing output in conversation stream
- `tui-command-registry`: Event-driven command registry replacing switch-driven dispatch table
- `tui-runtime-toggles`: Runtime toggle system with `/toggle` commands and status bar indicators

### Modified Capabilities
- `cron-scheduler`: No requirement changes — TUI refactoring is orthogonal to scheduling

## Impact

- **Affected code**: `src/tui/app.js` (major refactor), `src/tui/panels.js`, `src/tui/skillsPanel.js`, `src/tui/memoryPanel.js`, `src/tui/settingsPanel.js` (removal), `src/tui/commandParser.js` (rewrite), all TUI component files (relocation)
- **Tests**: All existing TUI tests must pass; new tests for reducer, streaming hook, command registry
- **Breaking**: Panel system removal changes internal component tree; no external API changes
45 changes: 45 additions & 0 deletions openspec/changes/refactor-tui/specs/tui-command-registry/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
## ADDED Requirements

### Requirement: Command parser uses event-driven registry
The command parser SHALL be refactored from a switch-driven dispatch table to an event-driven command registry where commands are registered as objects with `validate`, `execute`, and `help` properties.

#### Scenario: Command registration schema
- **WHEN** a new command is registered
- **THEN** it is an object with: `name: string`, `description: string`, `usage: string`, `validate: (args) => boolean | string`, `execute: (args, state, dispatch, helpers) => Promise<void> | void`

#### Scenario: Command validation
- **WHEN** a command is invoked with arguments
- **THEN** the registry calls `validate(args)` before executing
- **WHEN** validation returns a string (error message)
- **THEN** the error message is displayed to the user and execution is skipped

#### Scenario: Command execution
- **WHEN** a command passes validation
- **THEN** the registry calls `execute(args, state, dispatch, helpers)` with access to TUI state, dispatch, and command helpers

#### Scenario: Unknown command handling
- **WHEN** a command is not found in the registry
- **THEN** the TUI displays "Unknown command: /<command>. Type /help for available commands."

#### Scenario: Help command
- **WHEN** the user types `/help`
- **THEN** the TUI displays all registered commands grouped by category (Chat, Command, etc.)

### Requirement: Command helpers provide necessary context
Commands SHALL receive a `CommandHelpers` object providing access to `dispatchProvider`, `sessionState`, `config`, and `scrollRef`.

#### Scenario: Command can dispatch provider
- **WHEN** a command needs to send a message to the agent
- **THEN** it uses `helpers.dispatchProvider` to trigger the streaming pipeline

#### Scenario: Command can access session state
- **WHEN** a command needs to read or modify session data
- **THEN** it uses `helpers.sessionState` to interact with the session manager

#### Scenario: Command can access config
- **WHEN** a command needs to read or modify configuration
- **THEN** it uses `helpers.config` to access the current configuration

#### Scenario: Command can control scroll
- **WHEN** a command needs to scroll the viewport
- **THEN** it uses `helpers.scrollRef` to call `scrollToBottom()` or `scrollBy()`
39 changes: 39 additions & 0 deletions openspec/changes/refactor-tui/specs/tui-file-structure/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
## ADDED Requirements

### Requirement: TUI files are organized by concern
The TUI file structure SHALL be reorganized from a flat layout into grouped directories: `state/`, `hooks/`, `components/`, `utils/`, with `panels/` retained only for `OnboardingPanel.js`.

#### Scenario: State files are grouped
- **WHEN** the TUI directory is inspected
- **THEN** `state/reducer.js`, `state/types.js`, and `state/selectors.js` exist

#### Scenario: Hook files are grouped
- **WHEN** the TUI directory is inspected
- **THEN** `hooks/useStreaming.js`, `hooks/useScroll.js`, `hooks/useInput.js`, and `hooks/useCommand.js` exist

#### Scenario: Component files are grouped
- **WHEN** the TUI directory is inspected
- **THEN** `components/ConversationPanel.js`, `components/InputPanel.js`, `components/StatusBar.js`, `components/MessageBubble.js`, and `components/Banner.js` exist

#### Scenario: Utility files are grouped
- **WHEN** the TUI directory is inspected
- **THEN** `utils/commandParser.js`, `utils/contextTokens.js`, `utils/markdownText.js`, and `utils/format.js` exist

#### Scenario: Root files remain at top level
- **WHEN** the TUI directory is inspected
- **THEN** `app.js` (root component with reducer), `index.js` (entry point) remain at the top level

### Requirement: All imports are updated to reflect new structure
All internal imports within the TUI SHALL reference the new file paths after reorganization.

#### Scenario: Component imports use new paths
- **WHEN** `app.js` imports components
- **THEN** it uses `./components/ConversationPanel`, `./components/StatusBar`, etc.

#### Scenario: Hook imports use new paths
- **WHEN** `app.js` imports hooks
- **THEN** it uses `./hooks/useStreaming`, `./hooks/useScroll`, etc.

#### Scenario: Utility imports use new paths
- **WHEN** components import utilities
- **THEN** they use `../utils/contextTokens`, `../utils/markdownText`, etc.
31 changes: 31 additions & 0 deletions openspec/changes/refactor-tui/specs/tui-panel-removal/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## ADDED Requirements

### Requirement: Panel system is removed
The panel system (`panels.js`, `skillsPanel.js`, `memoryPanel.js`, `settingsPanel.js`) SHALL be removed entirely, as it contradicts the blueprint's philosophy of "no panels, no tabs, no switching."

#### Scenario: Panel files are deleted
- **WHEN** the refactoring is complete
- **THEN** `src/tui/panels.js`, `src/tui/skillsPanel.js`, `src/tui/memoryPanel.js`, and `src/tui/settingsPanel.js` no longer exist

#### Scenario: Panel imports are removed
- **WHEN** `app.js` is inspected
- **THEN** it contains no imports or references to the removed panel files

#### Scenario: OnboardingPanel is retained
- **WHEN** the TUI directory is inspected
- **THEN** `panels/OnboardingPanel.js` still exists (it is a first-run flow, not a navigation surface)

### Requirement: Panel functionality moves to commands
All functionality previously provided by panels SHALL be accessible via commands that produce output in the conversation stream.

#### Scenario: Skills inspection via command
- **WHEN** the user types `/skills`
- **THEN** the TUI displays a list of registered skills in the conversation stream

#### Scenario: Memory inspection via command
- **WHEN** the user types `/memory`
- **THEN** the TUI displays memory context information in the conversation stream

#### Scenario: Config inspection via command
- **WHEN** the user types `/config`
- **THEN** the TUI displays current configuration in the conversation stream
72 changes: 72 additions & 0 deletions openspec/changes/refactor-tui/specs/tui-runtime-toggles/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
## ADDED Requirements

### Requirement: Runtime toggle system via /toggle commands
The TUI SHALL provide runtime toggle commands that allow users to override `config.yaml` defaults without editing configuration files.

#### Scenario: Toggle a setting on/off
- **WHEN** the user types `/toggle timestamps`
- **THEN** the `timestamps` toggle is turned off
- **WHEN** the user types `/toggle timestamps` again
- **THEN** the `timestamps` toggle is turned back on

#### Scenario: Show all toggle states
- **WHEN** the user types `/toggle` (no arguments)
- **THEN** the TUI displays all toggles and their current states

#### Scenario: Toggle overrides config defaults
- **WHEN** a toggle is changed via `/toggle`
- **THEN** the override takes effect immediately in the UI
- **WHEN** the application restarts
- **THEN** the toggle reverts to the `config.yaml` default (overrides are in-memory only)

### Requirement: Five toggles are supported
The toggle system SHALL support exactly five toggles with the specified defaults:

| Toggle | Default | Description |
|--------|---------|-------------|
| `autoScroll` | `true` | Auto-scroll to bottom on new messages |
| `timestamps` | `true` | Show timestamps on messages |
| `commandEcho` | `true` | Echo user commands to output |
| `cursorBreathe` | `true` | Enable breathing cursor model |
| `debugOutput` | `false` | Show debug-level messages |

#### Scenario: autoScroll toggle
- **WHEN** `autoScroll` is `true`
- **THEN** new messages trigger `scrollToBottom()`
- **WHEN** `autoScroll` is `false`
- **THEN** the user's scroll position is preserved on new messages

#### Scenario: timestamps toggle
- **WHEN** `timestamps` is `true`
- **THEN** messages display with `[HH:MM]` timestamps
- **WHEN** `timestamps` is `false`
- **THEN** messages display without timestamps

#### Scenario: commandEcho toggle
- **WHEN** `commandEcho` is `true`
- **THEN** user commands are echoed to the output stream
- **WHEN** `commandEcho` is `false`
- **THEN** user commands are not echoed

#### Scenario: cursorBreathe toggle
- **WHEN** `cursorBreathe` is `true`
- **THEN** the cursor fades to dark gray after 2 seconds of idle
- **WHEN** `cursorBreathe` is `false`
- **THEN** the cursor remains fully visible

#### Scenario: debugOutput toggle
- **WHEN** `debugOutput` is `true`
- **THEN** debug-level messages appear in the output stream
- **WHEN** `debugOutput` is `false`
- **THEN** debug-level messages are hidden (default)

### Requirement: Toggle indicators appear in status bar
The status bar SHALL display toggle indicators showing which runtime features are active.

#### Scenario: Status bar shows toggle indicators
- **WHEN** the status bar renders
- **THEN** it displays toggle indicators in the format `[ts:1 scroll:1]` where `1` means enabled and `0` means disabled

#### Scenario: Indicators update on toggle change
- **WHEN** a toggle is changed via `/toggle`
- **THEN** the status bar indicators update immediately
Loading
Loading