diff --git a/docs/TUI.md b/docs/TUI.md new file mode 100644 index 0000000..f8d8d60 --- /dev/null +++ b/docs/TUI.md @@ -0,0 +1,999 @@ +# TUI: Terminal Interface Blueprint + +*A design document for the new madz terminal interface. Grounded in the existing implementation (Ink ++ `ink-scroll-view` + structured logger), inspired by the best patterns of bitchx IRC client. The +core functionality stays the same — the new TUI renders it better.* + +--- + +## 1. Philosophy + +The interface is a terminal. Text flows in from the system, text flows out from the user. No panels, +no tabs, no switching. One scrollable output area, one input line. + +The IRC layout is borrowed for its elegance: messages accumulate above, input sits at the bottom, +scrolling is natural. But the content is code, output, system responses — not conversation. + +### Core Tenets + +1. **Input is primary.** The user lives at the input line. The output area is secondary — read when +needed, scroll when needed. +2. **Output is a log.** System output, code output, agent responses — all flow into the same stream. +3. **Silence is the default.** The interface should feel like a quiet terminal — alive with output, +not noise. +4. **Batteries included, not scripts required.** Runtime customizations (toggles, formats, filters) +ship built-in. No config file editing needed for common changes. + +--- + +## 2. Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [14:23] Mads: Hello, Jason. │ +│ [14:24] System: Build complete │ +│ [14:25] Mads: Here's the diff... │ +│ ... (scrollable conversation) ... │ +├─────────────────────────────────────────────────────────────┤ +│ [●] Ready | [⚡12] [💬42] [◣ 1.2k] │ +├─────────────────────────────────────────────────────────────┤ +│ > _ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Component Hierarchy + +``` +App (src/tui/app.js) +├── Banner / OnboardingPanel — First-run experience +├── ConversationPanel — ScrollView-based message display +│ └── ScrollView (ink-scroll-view) +│ └── MessageBubble[] — Role-colored, markdown-rendered +├── StatusBar — Status indicator, skill/message/context counts +└── InputPanel — Text input with cursor display +``` + +No panels, no tabs, no switching. The interface is a single scrollable output area with one input +line. + +### Key Dependencies + +| Dependency | Role | +|------------|------| +| `ink` | TUI framework (Box, Text, useInput, useStdout, useWindowSize) | +| `ink-scroll-view` | Scrollable viewport (ScrollView, scrollToBottom, scrollBy, remeasure) | +| `pino` | Structured logger (dual-file: madz.log + madz_error.log) | + +--- + +## 3. Scrolling & Viewport + +The conversation area uses `ink-scroll-view`'s `ScrollView` component. This handles all viewport +management — no custom virtual scroll logic needed. + +### How It Works + +```jsx +// ConversationPanel wraps ScrollView around message bubbles + + {messages.map(msg => )} + +``` + +### Auto-Scroll Behavior + +``` +1. New message arrives → scrollToBottom() (deferred via setTimeout 0ms to allow React to commit) +2. Streaming content grows → scrollToBottom() (React re-render triggers scroll) +3. User scrolls up → stays where they are (no forced scroll) +4. Terminal resize → remeasure() called via stdout.on("resize") +``` + +### Scroll API (via ref) + +| Method | Purpose | +|--------|---------| +| `scrollToBottom()` | Scroll to the end of content | +| `scrollBy(delta)` | Scroll by N rows (positive = down, negative = up) | +| `scrollTo(offset)` | Scroll to absolute position | +| `scrollToTop()` | Scroll to offset 0 | +| `remeasure()` | Re-measure viewport dimensions (call on terminal resize) | +| `remeasureItem(index)` | Force re-measure of a specific child (useful for dynamic content) | +| `getViewportHeight()` | Get visible row count | +| `getScrollOffset()` | Get current scroll position | +| `getContentHeight()` | Get total content height | +| `getBottomOffset()` | Get scroll offset when at bottom | +| `getItemHeight(index)` | Get measured height of a specific item | +| `getItemPosition(index)` | Get position and height of a specific item | + +### Keyboard Scrolling (when input is unfocused) + +| Key | Action | +|-----|--------| +| Up arrow | `scrollBy(-1)` | +| Down arrow | `scrollBy(1)` | +| PageUp | `scrollBy(-viewportHeight)` | +| PageDown | `scrollBy(viewportHeight)` | + +### Controlled Mode + +For advanced use cases (e.g., synchronizing multiple views), `ink-scroll-view` provides +`ControlledScrollView` which accepts a `scrollOffset` prop instead of managing state internally. + +--- + +## 4. The Cursor + +The cursor is managed by ink's `useCursor` hook, which provides `setCursorPosition` for +controlling cursor visibility and position. Passing `undefined` hides the cursor; passing a +`{x, y}` object shows it at the specified position. + +### Configuration + +```jsx +import { useCursor } from 'ink'; + +const { setCursorPosition } = useCursor(); + +// Hide cursor when input is unfocused +setCursorPosition(undefined); + +// Show cursor at input position when focused +setCursorPosition({ x: stringWidth(prompt + text), y: 1 }); +``` + +### Behavior + +- **Input focused** — cursor shown at input position via `setCursorPosition({x, y})` +- **Input unfocused** — cursor hidden via `setCursorPosition(undefined)` +- **Blinking** — handled by the terminal emulator, not by Ink +- **Character** — sourced from `config.tui.cursorChar` (set via terminal escape sequences) + +The TUI manages cursor visibility toggling based on input focus state. `useCursor` provides the +positioning primitive; the TUI decides when to show or hide. + +--- + +## 5. Message Display + +Messages are rendered as role-colored bubbles with markdown support, tool call display, and +reasoning content. + +### Message Structure + +```typescript +interface Message { + role: "user" | "assistant" | "system"; + content: string; + time: string; // HH:MM format + streaming?: boolean; // True while content is being streamed + reasoningContent?: string; // Agent thinking (shown inline, truncated at 200 chars) + activeToolCall?: { name: string }; // Currently running tool + toolCallDisplay?: string; // Completed tool calls (multi-line) + toolCalls?: { name: string }[]; // Tool call history + id?: string; // Stable identifier for memoization +} +``` + +### Role-Based Styling + +| Role | Label Color | Bubble Border | Alignment | +|------|------------|---------------|-----------| +| **user** | Green | Green | Right | +| **system** | Yellow | Yellow | Left | +| **assistant** | Cyan | Cyan | Left | + +### Message Bubble Rendering + +``` +┌───────────────────────────────────────────┐ +│ [14:23] Mads: Here's the code: │ +│ │ +│ ```js │ +│ const x = 42; │ +│ ``` │ +│ │ +│ - Tool: readFile (src/logger.js) │ +│ - Tool: patch (src/tui/app.js) │ +│ │ +│ (thinking) Analyzing the request... │ +└───────────────────────────────────────────┘ +``` + +### Memoization + +`MessageBubble` uses `React.memo` with a custom `areEqual` function that compares display-relevant +fields (role, content, time, reasoningContent, streaming, toolCallDisplay, activeToolCall, id). This +prevents unnecessary re-renders of unchanged messages during streaming. + +### Markdown Rendering + +Assistant messages are rendered through `marked` + `marked-terminal`, which converts markdown to +ANSI terminal text. A module-level parse cache (`Map`) avoids reparsing identical content across +renders. The streaming cursor character (`█`) is stripped before parsing to avoid parser errors. + +--- + +## 6. Runtime Configuration (Bitchx-Inspired) + +Bitchx's `/toggle` command was legendary because it made common customizations instant — no config +file editing required. The TUI should ship with sensible defaults and built-in features for runtime +control. All TUI configuration lives in the `tui` section of `config.yaml`. + +### TUI Configuration (config.yaml) + +```yaml +tui: + name: madz + cursorChar: "█" + autoScroll: true + timestamps: true + commandEcho: true + cursorBreathe: true + debugOutput: false +``` + +### Proposed: Toggle Commands + +Toggle commands allow runtime overrides of the `config.yaml` 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 | + +Usage: +``` +/toggle timestamps → turns timestamps off +/toggle timestamps → turns timestamps on (toggle) +/toggle → shows all toggles and their states +``` + +### Proposed: Format Customization + +Bitchx's `/fset` allowed users to customize how every message type rendered. The TUI could adopt a +similar pattern: + +``` +/format system "[%T] %BSystem%n: %I%t%n" +/format error "[%T] %RError%n: %t" +/format agent "[%T] %CMads%n: %t" +``` + +Format specifiers: +- `%T` — timestamp (respects `timestamps` toggle) +- `%t` — text body +- `%B` — bold, `%n` — null color (reset) +- `%I` — italic, `%R` — red, `%C` — cyan, `%M` — magenta + +> **YAGNI:** These format specifiers are speculative. Implement only if there is a clear, demonstrated need. Do not build a full format customization system without evidence that users will use it. + +### Proposed: Message Filtering + +Bitchx had a sophisticated message-level system where you could filter what appeared in each window. +The TUI could adopt a similar pattern: + +``` +/level debug → toggle debug messages on/off +/level → show active levels +/level all → show all levels and their states +``` + +Available levels: +| Level | Description | +|-------|-------------| +| `user` | User messages | +| `assistant` | Agent responses | +| `system` | System notifications | +| `debug` | Debug/internal messages (hidden by default) | + +> **YAGNI:** Message-level filtering adds significant complexity (parsing, persistence, UI indicators). Implement only if there is a clear, demonstrated need. + +### Persistence + +Runtime toggle overrides are stored in memory only. The `config.yaml` `tui` section is the source of +truth — changes to it take effect on restart. No separate `tui-config.json` file is needed. + +--- + +## 7. Command Parser + +Commands are parsed from input when Enter is pressed. The `CommandParser` class handles a dispatch +table of registered commands, with fallback to skill execution. + +### Registered Commands + +| Command | Behavior | +|---------|----------| +| `/quit` | Disconnect and exit | +| `/clear` | Clear conversation | +| `/new` | Start a new session | +| `/help` | Show available commands | +| `/config set ` | Set a config value | +| `/provider set ` | Switch AI provider | +| `/schedule list` | List scheduled tasks | +| `/schedule pause ` | Pause a scheduled task | +| `/schedule resume ` | Resume a scheduled task | +| `/schedule run-now ` | Run a scheduled task immediately | +| `/gc` | Trigger V8 garbage collection | +| `/gc status` | Show GC status | + +### Skill Execution + +Unrecognized `/command` patterns that match a registered skill name are executed as skills: +``` +/skillName [args] +``` + +The skill body (from `SKILL.md`) is loaded and streamed to the agent as a prompt, allowing the agent +to interpret and execute the skill instructions. + +### Unknown Commands + +``` +/unknownCommand +→ "Unknown command: /unknownCommand. Type /help for available commands." +``` + +--- + +## 8. Interaction Model + +### Keyboard Shortcuts + +| Key | Action | Cursor | +|-----|--------|--------| +| Any character | Append to input | Visible | +| Enter | Submit command | Visible → Hidden (after submit) | +| Escape | Interrupt streaming / quit | Hidden | +| Tab | Toggle input focus | N/A | +| Up arrow (focused) | Scroll through command history | Visible | +| Down arrow (focused) | Scroll forward through history | Visible | +| Up arrow (unfocused) | Scroll output up | Hidden | +| Down arrow (unfocused) | Scroll output down | Hidden | +| PageUp (unfocused) | Scroll output up 1 page | Hidden | +| PageDown (unfocused) | Scroll output down 1 page | Hidden | + +### Command History + +The up/down arrows scroll through command history when the user is at the bottom of the output (not +scrolling the output itself). This is a terminal convention: + +``` +1. User presses Enter → command executes, output appears, auto-scroll active +2. User presses Up → scrolls through previous commands (not output) +3. User presses Down → scrolls forward through command history +4. User presses Up while scrolled up in output → scrolls output +``` + +**Note:** History is in-memory (`chatHistory` array). The structured logger (`src/logger.js`) +handles persistent logging of all interactions. + +### Input Lifecycle + +``` +1. User presses key → cursor appears, input focused +2. User types → cursor follows text end +3. User presses Enter → command executed, output appended, input cleared, cursor fades +4. 2 seconds idle → cursor hidden (color transition to dark gray) +5. User presses key again → cycle repeats +``` + +--- + +## 9. Status Bar + +The status bar displays connection status, system metrics, and contextual information. + +### Current Implementation + +``` +[●] Ready | [⚡12] [💬42] [◣ 1.2k] +``` + +| Element | Content | +|---------|---------| +| Status indicator | `●` green (ready), `▶` yellow (streaming), `✖` red (error) | +| Status message | Current state ("Ready", "Streaming...", "Compacting context...") | +| Skill count | Number of registered skills | +| Message count | Total messages in conversation | +| Context size | Current conversation token count (human-readable: "1.2k", "15k") | + +### Proposed Enhancement + +Add toggle/filter indicators to the status bar: +``` +[●] Ready | [⚡12] [💬42] [◣ 1.2k] [ts:1 scroll:1] +``` + +This gives the user a quick glance at which runtime features are active. + +--- + +## 10. Edge Cases & Resilience + +### Terminal Resize + +``` +1. Detect resize via stdout.on("resize") +2. Call scrollRef.current.remeasure() to update viewport dimensions +3. ink-scroll-view handles re-layout automatically +``` + +### Streaming Overflow + +``` +1. New message arrives during streaming +2. Content hash (messageCount + streamingContentLength) triggers re-evaluation +3. scrollToBottom() is deferred 0ms to allow ink-scroll-view's useLayoutEffect to complete +4. Previous content hash is tracked to avoid redundant scrolls +``` + +### Connection Loss + +``` +1. Error in dispatchProvider → catch block handles it +2. System message displayed: "I couldn't connect right now - {error}. Try sending your message +again?" +3. Streaming message is cleared from UI +4. Session is saved (onSaveSession callback) +``` + +### Model Stuck in Thinking Loop + +``` +1. Auto-continue circuit breaker tracks consecutive empty responses +2. After config.agent.autoContinueLimit (default 1000) empty responses: + - Show error message + - Reset counter + - User must rephrase or start new session +``` + +### Output Retention + +The conversation is managed by `sessionState` (not the TUI). The TUI renders whatever messages are +in its state array. Memory management (compaction, trimming) is handled by the session layer, not +the TUI. + +--- + +## 11. Implementation Notes + +### Ink-Specific Patterns + +1. **`useStdout`** — For terminal resize events (call `remeasure()` on the scroll ref) +2. **`useInput`** — For keyboard handling (single handler in App component) +3. **`ScrollView` (ink-scroll-view)** — For scrollable conversation area +4. **`React.memo`** — For MessageBubble optimization + +### Key Patterns + +```jsx +// Scroll to bottom with deferred timing +const scrollHandle = () => { + if (scrollRef.current) { + scrollRef.current.scrollToBottom(); + } +}; +const timer = setTimeout(scrollHandle, 0); + +// Terminal resize handling +useEffect(() => { + const resizeHandler = () => { + if (scrollRef.current && stdout.isTTY && !process.env.CI) { + scrollRef.current.remeasure(); + } + }; + stdout.on("resize", resizeHandler); + return () => stdout.off("resize", resizeHandler); +}, [stdout, scrollRef]); + +// Abort controller for streaming interruption +abortControllerRef.current = new AbortController(); +// ... +abortControllerRef.current.abort(); +``` + +### State Management + +The current app uses React's built-in state (`useState`, `useRef`) rather than `useReducer`. This is +sufficient for the current scale. See Section 16 for proposed consolidation. + +--- + +## 12. Streaming Architecture + +This section describes how the TUI wires into the AI agent's streaming pipeline. + +### Data Flow + +``` +User Input (Enter) + → App.handleSubmit() + → App.handleChat() or App.handleCommand() + → dispatchProvider(message, provider, streamingCallback, signal) + → callProvider() + → callReactAgent(agent, message, sessionConfig, callPrompt, streamingCallback, options) + → LangGraph ReactAgent stream + → streamingCallback(event) for each event type: + - "text" → append to committedContent, update message.content + █ cursor + - "reasoning" → append to committedReasoning, update message.reasoningContent + - "tool_start" → set message.activeToolCall + - "tool_end" → append to message.toolCallDisplay + - "tool_error" → append error to message.toolCallDisplay + - "compaction_start" / "compaction_end" → toggle isCompacting +``` + +### Streaming Callback + +The `streamingCallback` is set up in `handleChat()` / `handleCommand()` and passed to +`dispatchProvider`. Each event type triggers a `setMessages()` call that clones the messages array +and mutates the last (streaming) message in place. + +### Auto-Continue Circuit Breaker + +If the agent returns zero text output, the TUI automatically sends a "Please continue." signal. This +repeats up to `config.agent.autoContinueLimit` (default 1000) times before triggering a circuit +breaker error. The counter resets as soon as any text output arrives. An `isAutoContinuingRef` flag +tracks whether the TUI is in auto-continue mode. + +### Abort / Interrupt + +The `Escape` key triggers `handleInterrupt()`, which: +1. Calls `abortControllerRef.current.abort()` +2. Sets `isStreamingRef.current = false` +3. Clears the streaming cursor from the last message +4. Awaits the `dispatchPromise` (which throws `AbortError` and is caught by the try/catch) +5. If interrupted during chat: pops the user message from sessionState, clears the partial assistant +message, deletes the checkpointer thread +6. If interrupted during skill: pops the user message, deletes the checkpointer thread + +### Todo Queue Integration + +The todo tool queue emits status events (`todo_status`) that flow through the LangGraph stream. The +TUI wires into this via `setTodoStreamingCallback()`, which updates `message.toolCallDisplay` with +todo status lines alongside tool call results. + +--- + +## 13. Session & Persistence + +### Session Lifecycle + +``` +1. index.js creates session via createSession() +2. SessionStateManager wraps the session state +3. App receives sessionState as a prop +4. On user message: sessionState.addExchange({ role: "user", content }) +5. On agent response: sessionState.addExchange({ role: "assistant", content }) +6. onSaveSession callback persists to memory/sessions/ +``` + +### Context Token Calculation + +The TUI calculates conversation tokens using `tiktoken` (with a character-count fallback). Both the +conversation and the system prompt are counted. The result is displayed in the status bar as a +human-readable size (e.g., "1.2k"). + +### GC Integration + +When GC is enabled (`config.memory.gc.enabled !== false`), a `gcManager` is initialized in +`index.js`. The TUI receives `gcManager.onActivity` as a prop, which is called after each message +exchange to trigger idle GC. The `/gc` command allows manual triggering and status inspection. + +--- + +## 14. Onboarding & Banner + +### Banner + +A BBS-style ASCII art banner displays on first launch. It shows command help grouped by category +(Chat, Command). Dismisses on any key press (Escape exits the app). + +### Onboarding Panel + +When no user profile exists, the onboarding flow activates: +1. `OnboardingPanel` renders with prompts from the onboarding instance +2. User responses are processed via `onboarding.processResponse()` +3. Progress is shown as `(current/total)` +4. On completion, the banner displays and normal conversation begins + +--- + +## 15. Summary + +This blueprint describes the madz terminal interface, grounded in the existing implementation. The +design is defined by four principles: + +1. **Input is primary.** The user lives at the input line. Output is secondary. +2. **Silence is the default.** The interface should feel like a quiet terminal — alive with output, +not noise. +3. **Batteries included.** Runtime customizations (toggles, formats, filters) should ship built-in — +no config file editing required. +4. **The terminal is the window.** `ink-scroll-view` handles scrolling, Ink handles rendering, the +structured logger handles persistence. + +The result is an interface that feels like a quiet terminal — present, alive, and ready for work. +Not a dashboard. Not a tool. A workspace. + +--- + +## 16. Architectural Debt & Proposed Improvements + +The current implementation works, but several structural decisions compound as the TUI grows. This +section documents known debt and proposed improvements for future refactoring. + +### 16.1 State Management — `useReducer` over `useState` + +The current `app.js` has eight independent `useState` calls with no coordination: + +```typescript +const [messages, setMessages] = useState([]); +const [statusMessage, setStatusMessage] = useState("Ready"); +const [chatHistory, setChatHistory] = useState([]); +const [historyIndex, setHistoryIndex] = useState(-1); +const [inputText, setInputText] = useState(""); +const [inputFocused, setInputFocused] = useState(true); +const [contextSize, setContextSize] = useState(0); +const [isCompacting, setIsCompacting] = useState(false); +``` + +When a message arrives, you're updating `messages`, `statusMessage`, `contextSize`, and +`chatHistory` — all separate calls, all separate renders. Consolidate into a single `useReducer` +with a `TUIState` interface. One state tree, one render cycle per meaningful change. + +### 16.2 Streaming Logic — Extract to Its Own Hook + +The streaming callback is set up inline in `handleChat()` / `handleCommand()` and passed through +multiple layers. Extract into a `useStreaming()` hook that: + +- Manages the `AbortController` lifecycle +- Translates stream events into state transitions +- Handles the auto-continue circuit breaker +- Exposes a clean `streamingState` object to the UI + +Separates *how we stream* from *what we stream*. + +### 16.3 File Structure — Group by Concern, Not by Component + +The current flat 17-file layout works for now but doesn't scale. Proposed structure: + +``` +src/tui/ +├── app.js # Root component, providers +├── state/ +│ ├── reducer.js # TUIState + all action types +│ ├── types.js # Action type constants +│ └── 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 +│ └── (future panels, if any) +├── utils/ +│ ├── commandParser.js +│ ├── contextTokens.js +│ ├── markdownText.js +│ └── format.js # Format specifiers, toggle logic +└── index.js +``` + +Not dogma — predictability. When you're looking for streaming logic, you know exactly where to look. + +### 16.4 Remove the Panel System Entirely + +The `panels.js`, `skillsPanel.js`, `memoryPanel.js`, `settingsPanel.js` files contradict the +blueprint's philosophy ("No panels, no tabs, no switching"). If Jason needs to inspect skills or +memory, those are commands (`/skills`, `/memory`) that produce output in the conversation stream — +not separate UI surfaces. The TUI should be one thing: a terminal. + +### 16.5 Command Parser — Event-Driven, Not Switch-Driven + +The current `commandParser.js` is a dispatch table. Make it more extensible — a command registry +where commands are registered as objects with `validate`, `execute`, and `help` properties. Adding a +new command is a registration, not a switch case edit. + +### 16.6 What to Keep + +- `ink-scroll-view` for scrolling — works well +- `React.memo` on MessageBubble — correct optimization +- The structured logger — dual-file pino is solid +- The `tiktoken` context token calculation — accurate +- The overall component hierarchy (App → ConversationPanel + StatusBar + InputPanel) — that part is right + +### 16.7 What to Keep the Same + +The *philosophy* — input is primary, output is a log, silence is the default. That's the right +mental model. It's the implementation scaffolding around it that should be reorganized. + +--- + +## 17. Implementation Spec + +This section provides the concrete types, interfaces, and patterns needed to implement the TUI. + +### 17.1 State Shape + +```typescript +interface TUIState { + // Messages + messages: Message[]; + chatHistory: string[]; + historyIndex: number; + + // Input + inputText: string; + inputFocused: boolean; + + // Status + statusMessage: string; + contextSize: number; + isCompacting: boolean; + + // Streaming + isStreaming: boolean; + isAutoContinuing: boolean; + autoContinueCount: number; + + // Scroll + scrollOffset: number; + viewportHeight: number; + + // Config overrides (runtime toggles) + toggles: { + autoScroll: boolean; + timestamps: boolean; + commandEcho: boolean; + cursorBreathe: boolean; + debugOutput: boolean; + }; +} + +const initialState: TUIState = { + messages: [], + chatHistory: [], + historyIndex: -1, + inputText: '', + inputFocused: true, + statusMessage: 'Ready', + contextSize: 0, + isCompacting: false, + isStreaming: false, + isAutoContinuing: false, + autoContinueCount: 0, + scrollOffset: 0, + viewportHeight: 0, + toggles: { + autoScroll: true, + timestamps: true, + commandEcho: true, + cursorBreathe: true, + debugOutput: false, + }, +}; +``` + +### 17.2 Action Types + +```typescript +type TUIAction = + // Messages + | { type: 'ADD_MESSAGE'; message: Message } + | { type: 'UPDATE_MESSAGE'; id: string; updates: Partial } + | { type: 'CLEAR_MESSAGES' } + | { type: 'ADD_HISTORY'; text: string } + | { type: 'SET_HISTORY_INDEX'; index: number } + + // Input + | { type: 'SET_INPUT_TEXT'; text: string } + | { type: 'SUBMIT_INPUT' } + | { type: 'SET_INPUT_FOCUSED'; focused: boolean } + + // Status + | { type: 'SET_STATUS'; message: string } + | { type: 'SET_CONTEXT_SIZE'; size: number } + | { type: 'SET_COMPACTING'; compacting: boolean } + + // Streaming + | { type: 'SET_STREAMING'; streaming: boolean } + | { type: 'SET_AUTO_CONTINUING'; autoContinuing: boolean } + | { type: 'INCREMENT_AUTO_CONTINUE' } + | { type: 'RESET_AUTO_CONTINUE' } + + // Scroll + | { type: 'SET_SCROLL_OFFSET'; offset: number } + | { type: 'SET_VIEWPORT_HEIGHT'; height: number } + + // Config + | { type: 'TOGGLE_CONFIG'; key: keyof TUIState['toggles'] } + | { type: 'SET_CONFIG'; updates: Partial }; +``` + +### 17.3 Command Registry Schema + +```typescript +interface Command { + name: string; + description: string; + usage: string; + validate: (args: string[]) => boolean | string; // returns error message on failure + execute: ( + args: string[], + state: TUIState, + dispatch: React.Dispatch, + helpers: CommandHelpers + ) => Promise | void; +} + +interface CommandHelpers { + dispatchProvider: (message: string, provider: string, streamingCallback: StreamingCallback, signal: AbortSignal) => Promise; + sessionState: SessionStateManager; + config: Config; + scrollRef: React.RefObject; +} + +// Registration +const commands: Record = { + quit: { + name: 'quit', + description: 'Disconnect and exit', + usage: '/quit', + validate: () => true, + execute: async () => { /* ... */ }, + }, + // ... more commands +}; +``` + +### 17.4 Streaming Callback Transformation + +```typescript +type StreamEventType = + | 'text' + | 'reasoning' + | 'tool_start' + | 'tool_end' + | 'tool_error' + | 'compaction_start' + | 'compaction_end' + | 'todo_status'; + +interface StreamingCallback { + (event: StreamEvent): void; +} + +interface StreamEvent { + type: StreamEventType; + content?: string; // for 'text', 'reasoning' + toolName?: string; // for 'tool_start', 'tool_end', 'tool_error' + toolArgs?: string; // for 'tool_end' + error?: string; // for 'tool_error' + status?: string; // for 'todo_status' +} + +// Transformation logic (inside useStreaming hook) +function handleStreamEvent(event: StreamEvent, state: TUIState): Partial { + switch (event.type) { + case 'text': + return { + isStreaming: true, + messages: updateLastMessage(state.messages, { + content: event.content, + streaming: true, + }), + }; + case 'reasoning': + return { + messages: updateLastMessage(state.messages, { + reasoningContent: event.content, + }), + }; + case 'tool_start': + return { + messages: updateLastMessage(state.messages, { + activeToolCall: { name: event.toolName! }, + }), + }; + case 'tool_end': + return { + messages: updateLastMessage(state.messages, { + activeToolCall: undefined, + toolCallDisplay: event.content, + }), + }; + case 'tool_error': + return { + messages: updateLastMessage(state.messages, { + activeToolCall: undefined, + toolCallDisplay: event.error, + }), + }; + case 'compaction_start': + return { isCompacting: true, statusMessage: 'Compacting context...' }; + case 'compaction_end': + return { isCompacting: false, statusMessage: 'Ready' }; + case 'todo_status': + return { + messages: updateLastMessage(state.messages, { + toolCallDisplay: event.status, + }), + }; + } +} +``` + +### 17.5 File 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 # ScrollView + MessageBubble[] +│ ├── InputPanel.js # Text input with cursor +│ ├── StatusBar.js # Status indicator, metrics +│ ├── MessageBubble.js # Role-colored, markdown-rendered +│ └── Banner.js # ASCII art banner +├── panels/ +│ └── OnboardingPanel.js # First-run onboarding flow +├── 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 # Entry point, render() +``` + +### 17.6 Test Strategy + +**Unit tests** (`tests/unit/tui/`): +- `reducer.test.js` — All action types, edge cases (empty state, concurrent updates) +- `commandParser.test.js` — Command validation, execution, unknown commands +- `contextTokens.test.js` — tiktoken calculation, character-count fallback +- `markdownText.test.js` — Markdown rendering, streaming cursor stripping, cache behavior +- `useStreaming.test.js` — Event transformation, auto-continue circuit breaker, abort handling + +**Integration tests** (`tests/integration/tui/`): +- `full-flow.test.js` — User input → streaming → message display → command execution + +**Mocking**: +- `dispatchProvider` — Mock with resolved promise, configurable streaming events +- `tiktoken` — Mock with fixed token counts +- `marked-terminal` — Mock with plain text output +- `ink-scroll-view` — Mock ScrollViewRef with no-op methods + +**Coverage**: 100% on all new files. Existing files maintain current coverage. + +--- + +## References + +### Libraries + +- **[Ink](https://github.com/vadimdemedes/ink)** — React for CLIs. [README](https://raw.githubusercontent.com/vadimdemedes/ink/refs/heads/master/readme.md) +- **[ink-scroll-view](https://github.com/ByteLandTechnology/ink-scroll-view)** — ScrollView component for Ink. [README](https://raw.githubusercontent.com/ByteLandTechnology/ink-scroll-view/refs/heads/main/README.md) + +--- + +*Blueprint for the new madz terminal interface. Grounded in the existing implementation and the +ink + ink-scroll-view libraries.* diff --git a/docs/TUI2.md b/docs/TUI2.md deleted file mode 100644 index 851f730..0000000 --- a/docs/TUI2.md +++ /dev/null @@ -1,588 +0,0 @@ -# TUI2: Terminal Interface Blueprint - -*A clean-slate design for a code-first terminal interface using Ink and `useCursor`. IRC-inspired layout, not IRC semantics.* - ---- - -## 1. Philosophy - -The interface is a terminal. Text flows in from the system, text flows out from the user. No panels, no tabs, no switching. One scrollable output area, one input line. - -The IRC layout is borrowed for its elegance: messages accumulate above, input sits at the bottom, scrolling is natural. But the content is code, output, system responses — not conversation. - -### Core Tenets - -1. **Input is primary.** The user lives at the input line. The output area is secondary — read when needed, scroll when needed. -2. **Output is a log.** System output, code output, agent responses — all flow into the same stream. -3. **The cursor is the only control.** It appears when you're about to act, disappears when you're reading. -4. **Silence is the default.** The interface should feel like a quiet terminal — alive with output, not noise. - ---- - -## 2. Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Header: [● connected] │ -├─────────────────────────────────────────────────────────────┤ -│ $ npm run build │ -│ > madz@1.0.0 build │ -│ > tsc --project tsconfig.json │ -│ > Compiled successfully in 1.2s │ -│ [14:23] System: Build complete │ -│ [14:24] System: Running tests... │ -│ ... (scrollable output history) ... │ -├─────────────────────────────────────────────────────────────┤ -│ > _ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Component Hierarchy - -``` -App -├── Layout — Full terminal dimensions, manages resize -├── Header — Connection status only -├── OutputList — Virtualized output rendering -├── InputBar — Text input with cursor management -└── StatusLine — Bottom bar: scroll position, mode indicators -``` - -### State Model - -```typescript -interface AppState { - // Output management - entries: OutputEntry[]; // Mixed: system output, code output, responses - scrollOffset: number; // Lines above the fold - autoScroll: boolean; // True when at bottom - - // Input & cursor - input: string; - cursorVisible: boolean; - cursorRow: number; // Row within terminal (for useCursor positioning) - - // Connection - connected: boolean; - lastActivity: number; // Timestamp of last user input -} -``` - ---- - -## 3. The Cursor Model - -This is the heart of the design. `useCursor` from Ink gives us precise control over cursor visibility and position. We use it to create a **breathing cursor** — the interface's only interactive element. - -### The Breathing Cycle - -``` -Reading → Typing → Idle → Reading - ↓ ↓ ↓ ↓ -Hidden Visible Fading Hidden -``` - -| State | Cursor | Trigger | -|-------|--------|---------| -| **Reading** | Hidden | Default state. User is consuming output. | -| **Active** | Visible at input position | User presses any key. Cursor appears at end of input. | -| **Idle** | Fading (opacity transition) | No input for 2 seconds while in Active state. | -| **Submit** | Visible | User presses Enter. Cursor stays visible during send. | -| **Navigate** | Hidden | User presses Up/Down arrows for scroll. | - -### Implementation Strategy - -```jsx -function InputBar() { - const { setVisibility } = useCursor({ isVisible: false }); - const [input, setInput] = useState(''); - const lastActivity = useRef(Date.now()); - - // Show cursor when user starts typing - const handleInput = (key) => { - setVisibility(true); - lastActivity.current = Date.now(); - setInput(prev => prev + key); - }; - - // Hide cursor after 2s of idle - useEffect(() => { - const interval = setInterval(() => { - if (Date.now() - lastActivity.current > 2000) { - setVisibility(false); - } - }, 500); - return () => clearInterval(interval); - }, []); - - // Hide cursor on navigation keys - const handleKey = (key) => { - if (key === 'up' || key === 'down') { - setVisibility(false); - handleScroll(key); - return; - } - handleInput(key); - }; - - return ( - - - {input} - {cursorVisible && } - - - ); -} -``` - -### Why This Matters - -A visible cursor during reading creates visual noise. The cleanest interface is one where the cursor is only present when you're about to contribute. The breathing model captures this naturally — the cursor is a living thing that appears when needed and rests when not. - ---- - -## 4. Virtual Scrolling - -Terminal virtualization is different from browser virtualization. We can't use DOM nodes — we render text rows. The strategy: - -### Windowed Rendering - -``` -Terminal height: 40 lines -Header: 1 line -Input: 1 line -Status: 1 line -Output area: 37 lines - -Visible window: 37 entries -Buffer above: 10 entries (for smooth scroll back) -``` - -### Rendering Pipeline - -``` -1. Calculate visible range: [scrollOffset, scrollOffset + visibleRows] -2. Render only entries in that range -3. On new output: - - If autoScroll is true: append to end, scrollOffset = total - visibleRows - - If autoScroll is false: append to end, user sees nothing new until they scroll down -4. On scroll up: autoScroll = false, user can review output -5. On scroll to bottom: autoScroll = true, cursor hides -``` - -### Scroll Behavior - -| Action | Effect | -|--------|--------| -| New output arrives + at bottom | Auto-scroll, cursor hidden | -| New output arrives + scrolled up | No scroll, cursor hidden | -| User scrolls up | Auto-scroll off, cursor hidden | -| User scrolls to bottom | Auto-scroll on, cursor hidden | -| User presses Down arrow | Scroll down 1 line, cursor hidden | -| User presses Up arrow | Scroll up 1 line, cursor hidden | -| User presses PageDown | Scroll down 1 page, cursor hidden | -| User presses PageUp | Scroll up 1 page, cursor hidden | -| User starts typing | Cursor visible, scroll to bottom | - ---- - -## 5. Component Breakdown - -### 5.1 Layout - -**Responsibility:** Full terminal management, resize handling, overall structure. - -```jsx -function Layout() { - const { columns, rows } = useStdout(); - const [headerHeight] = useState(1); - const [inputHeight] = useState(1); - const [statusHeight] = useState(1); - const outputAreaHeight = rows - headerHeight - inputHeight - statusHeight; - - return ( - -
- - - - - - - ); -} -``` - -### 5.2 Header - -**Responsibility:** Connection status. Minimal, always visible. - -``` -┌─────────────────────────────────────────────────────────────┐ -│ ● connected │ -└─────────────────────────────────────────────────────────────┘ -``` - -| Element | Position | Style | -|---------|----------|-------| -| Connection status | Left | Colored dot (green=connected, red=disconnected) | - -### 5.3 OutputList - -**Responsibility:** Virtualized output rendering, scroll management, auto-scroll logic. - -```jsx -function OutputList({ height }) { - const { entries, scrollOffset, autoScroll, setScrollOffset, setAutoScroll } = useApp(); - const visibleEntries = entries.slice(scrollOffset, scrollOffset + height); - - // Auto-scroll on new output when at bottom - useEffect(() => { - if (autoScroll) { - setScrollOffset(Math.max(0, entries.length - height)); - } - }, [entries.length]); - - return ( - - {visibleEntries.map((entry, i) => ( - - ))} - - ); -} -``` - -#### Output Row - -Each entry is a terminal row. Entries can be different types: - -``` -$ npm run build ← User command (echoed) -> madz@1.0.0 build ← Command output -> tsc --project tsconfig.json -> Compiled successfully in 1.2s -[14:23] System: Build complete ← System message -[14:24] System: Running tests... -``` - -| Entry Type | Format | Style | -|------------|--------|-------| -| **User command** | `$ command` | Bold, no timestamp | -| **Command output** | `> line` | Default, no timestamp | -| **System message** | `[HH:MM] System: text` | Dim timestamp, italic body | -| **Agent response** | `[HH:MM] Mads: text` | Dim timestamp, color-coded nickname | -| **Error** | `[HH:MM] Error: text` | Dim timestamp, red text | - -### 5.4 InputBar - -**Responsibility:** Text input, command parsing, cursor management. - -``` -> _ -``` - -| Element | Behavior | -|---------|----------| -| `>` prompt | Always visible, visual cue before input | -| Input text | Grows to fill available width | -| Cursor | Breathing model (see Section 3) | - -#### Command Handling - -Commands are parsed from input when Enter is pressed: - -| Command | Behavior | -|---------|----------| -| `/clear` | Clear output history | -| `/help` | Show command reference | -| `/quit` | Disconnect and exit | - -All other input is treated as commands to execute — shell commands, code, system commands. No slash prefix needed. - -### 5.5 StatusLine - -**Responsibility:** Contextual information, scroll position, mode indicators. - -``` -─── ↑ 127 lines above ─── [normal] -``` - -| Element | Content | -|---------|---------| -| Scroll position | Lines above fold (when scrolled up) | -| Mode indicator | `[normal]`, `[command]`, `[search]` | - ---- - -## 6. Interaction Model - -### Keyboard Shortcuts - -| Key | Action | Cursor | -|-----|--------|--------| -| Any character | Append to input | Visible | -| Enter | Submit command | Visible → Hidden (after submit) | -| Escape | Clear input, hide cursor | Hidden | -| Up arrow | Scroll up | Hidden | -| Down arrow | Scroll down | Hidden | -| PageDown | Scroll down 1 page | Hidden | -| PageUp | Scroll up 1 page | Hidden | -| Home | Scroll to top | Hidden | -| End | Scroll to bottom, enable auto-scroll | Hidden | -| Ctrl+C | Cancel input, hide cursor | Hidden | -| Tab | Command autocomplete | Visible | - -### Command History - -The up/down arrows scroll through command history when the user is at the bottom of the output (not scrolling the output itself). This is a terminal convention: - -``` -1. User presses Enter → command executes, output appears, auto-scroll active -2. User presses Up → scrolls through previous commands (not output) -3. User presses Down → scrolls forward through command history -4. User presses Up while scrolled up in output → scrolls output -``` - -### Input Lifecycle - -``` -1. User presses key → cursor appears, input focused -2. User types → cursor follows text end -3. User presses Enter → command executed, output appended, input cleared, cursor fades -4. 2 seconds idle → cursor hidden -5. User presses key again → cycle repeats -``` - ---- - -## 7. Rendering Pipeline - -### Update Cycle - -``` -1. State change detected (new output, input change, etc.) -2. Re-render triggered by Ink -3. Layout recalculates dimensions -4. OutputList virtualizes visible window -5. InputBar renders with cursor state -6. Terminal updates only changed cells (Ink's diffing) -``` - -### Performance Considerations - -| Concern | Strategy | -|---------|----------| -| Large output history | Virtual scroll — only render visible rows | -| Frequent re-renders | Ink's diffing engine — only update changed cells | -| Terminal resize | Recalculate visible window, adjust scrollOffset | -| High-frequency output | Batch renders — coalesce updates within 50ms | -| Memory with long sessions | Trim old entries beyond retention limit | - ---- - -## 8. Visual Design - -### Color Palette - -``` -Background: Terminal default (respects user's theme) -Text: Terminal default -Timestamp: Dim (#555555 or 240) -User command: Bold -Command output: Default -System: Italic, dim -Error: Red -Input: Inverse (white on dark) when cursor visible -Header: Bold, subtle background -Divider: ─── characters, dim -``` - -### Entry Styling - -| Entry Type | Color | Emphasis | -|------------|-------|----------| -| User command | Terminal default | Bold | -| Command output | Terminal default | Normal | -| System message | Terminal default | Italic | -| Agent response | Color-coded | Bold nickname | -| Error | Red | Normal | - -### Typography - -- **Font:** Terminal default monospace (no overrides) -- **Timestamp:** Fixed-width, always 6 chars `[HH:MM]` -- **Command output prefix:** `> ` (2 chars, aligned) -- **Body:** Wraps to terminal width -- **User commands:** Prefixed with `$ ` (2 chars) - -### Layout Spacing - -``` -Terminal width: N columns -Header: 1 row, full width -Output area: N-3 rows, full width -Input: 1 row, full width -Status: 1 row, full width - -Output padding: 2 chars left (indent) -Command prefix: Variable, padded to align output -Body start: After prefix + space -``` - ---- - -## 9. Edge Cases & Resilience - -### Terminal Resize - -``` -1. Detect resize via useStdout -2. Recalculate visible window -3. If previously at bottom: stay at bottom -4. If scrolled up: preserve scrollOffset -5. Re-render with new dimensions -``` - -### Connection Loss - -``` -1. Show red dot in header -2. Display system message: "* Disconnected" -3. Attempt reconnection (exponential backoff) -4. On reconnect: system message "* Reconnected" -5. Cursor behavior unchanged -``` - -### Multi-line Output - -``` -1. Command output longer than terminal width: wrap to multiple rows -2. Track wrapped row count for accurate scroll calculation -3. Virtual scroll accounts for wrapped entries -4. User commands (echoed) are single-line -``` - -### Output Retention Limit - -``` -1. Retain last 1000 entries -2. When limit reached: trim oldest entries -3. If user scrolled up past trim point: show "output truncated" indicator -``` - ---- - -## 10. The Cursor: A Deeper Look - -The cursor is not just a UI element — it's the interface's personality. It is a quiet promise: *you can type now.* In this design, we make that promise explicit through the breathing model. - -### States - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ READING │────▶│ ACTIVE │────▶│ IDLE │ -│ (Hidden) │◀────│ (Visible) │◀────│ (Fading) │ -└─────────────┘ └─────────────┘ └─────────────┘ - ▲ │ - └────────────── Escape ────────────────┘ -``` - -### Transition Rules - -| From | To | Trigger | -|------|-----|---------| -| Reading | Active | Any key press | -| Active | Idle | 2s no input | -| Idle | Active | Any key press | -| Active | Reading | Enter (submit) | -| Any | Reading | Escape | - -### The "Why" - -A always-visible cursor creates visual tension — the eye is drawn to it even when it's not needed. A hidden cursor during reading creates a clean canvas. The breathing model gives the interface a sense of presence without demanding attention. It's the difference between a terminal with a blinking cursor and one that waits patiently for input. - ---- - -## 11. Implementation Notes - -### Ink-Specific Patterns - -1. **`useStdout`** — For terminal dimensions and resize events -2. **`useInput`** — For keyboard handling (replaces custom key listeners) -3. **`useCursor`** — For cursor visibility control (the star of the show) -4. **`Box` with `overflow="hidden"`** — For the output list viewport -5. **`Text` with `wrap="never"`** — For output rows (handle wrapping manually) - -### Key Ink Patterns - -```jsx -// Cursor visibility toggle -const { setVisibility } = useCursor({ isVisible: false }); -setVisibility(true); // Show -setVisibility(false); // Hide - -// Terminal dimensions -const { columns, rows } = useStdout(); - -// Input handling -useInput((input, key) => { - if (key.enter) handleSubmit(); - if (key.up) handleScroll('up'); - // ... -}); -``` - -### State Management Recommendation - -For this scale, React's built-in state is sufficient. Use `useReducer` for complex state transitions (scroll management, cursor lifecycle) and `useState` for simple values (input text, connection state). - -Consider a custom hook for the cursor breathing model: - -```typescript -function useBreathingCursor() { - const { setVisibility } = useCursor({ isVisible: false }); - const lastActivity = useRef(Date.now()); - const [visible, setVisible] = useState(false); - - const activate = useCallback(() => { - setVisibility(true); - setVisible(true); - lastActivity.current = Date.now(); - }, [setVisibility]); - - const deactivate = useCallback(() => { - setVisibility(false); - setVisible(false); - }, [setVisibility]); - - // Auto-deactivate after idle - useEffect(() => { - const interval = setInterval(() => { - if (Date.now() - lastActivity.current > 2000) { - deactivate(); - } - }, 500); - return () => clearInterval(interval); - }, [deactivate]); - - return { activate, deactivate, visible }; -} -``` - ---- - -## 12. Summary - -This blueprint describes a code-first TUI built on Ink with `useCursor` at its core. The IRC layout is borrowed — scrollable output above, input at the bottom — but the content is commands, output, and system responses. The design is defined by three principles: - -1. **Input is primary.** The user lives at the input line. Output is secondary. -2. **The cursor breathes.** It appears when you're about to act, disappears when you're reading. -3. **The terminal is the window.** Virtual scrolling, clean rendering, respect for the user's environment. - -The result is an interface that feels like a quiet terminal — present, alive, and ready for work. Not a dashboard. Not a tool. A workspace. - ---- - -*Blueprint complete. No implementation references — this is a clean slate.*