From 40523d62383eabcdeecb97e34935fe65b877014e Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 20:58:07 -0400 Subject: [PATCH 01/18] docs: update TUI2.md to reflect current implementation --- docs/TUI2.md | 666 +++++++++++++++++++++------------------------------ 1 file changed, 279 insertions(+), 387 deletions(-) diff --git a/docs/TUI2.md b/docs/TUI2.md index 851f730..f9e5e26 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -1,6 +1,6 @@ # TUI2: Terminal Interface Blueprint -*A clean-slate design for a code-first terminal interface using Ink and `useCursor`. IRC-inspired layout, not IRC semantics.* +*A design document for the madz terminal interface. Grounded in the existing implementation (Ink + `ink-scroll-view` + structured logger), inspired by the best patterns of bitchx IRC client.* --- @@ -14,8 +14,8 @@ The IRC layout is borrowed for its elegance: messages accumulate above, input si 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. +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. --- @@ -25,13 +25,12 @@ The IRC layout is borrowed for its elegance: messages accumulate above, input si ┌─────────────────────────────────────────────────────────────┐ │ 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) ... │ +│ [14:23] Mads: Hello, Jason. │ +│ [14:24] System: Build complete │ +│ [14:25] Mads: Here's the diff... │ +│ ... (scrollable conversation) ... │ +├─────────────────────────────────────────────────────────────┤ +│ [●] Ready | [⚡12] [💬42] [◣ 1.2k] │ ├─────────────────────────────────────────────────────────────┤ │ > _ │ └─────────────────────────────────────────────────────────────┘ @@ -40,39 +39,72 @@ The IRC layout is borrowed for its elegance: messages accumulate above, input si ### 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 +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 ``` -### State Model +### Key Dependencies -```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 -} +| 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 0ms to allow ink-scroll-view's useLayoutEffect to complete) +2. Streaming content grows → scrollToBottom() (content hash triggers re-evaluation) +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 | +| `remeasure()` | Re-measure viewport dimensions (call on terminal resize) | +| `getViewportHeight()` | Get visible row count | +| `getScrollOffset()` | Get current scroll position | + +### Keyboard Scrolling (when input is unfocused) + +| Key | Action | +|-----|--------| +| Up arrow | `scrollBy(-1)` | +| Down arrow | `scrollBy(1)` | +| PageUp | `scrollBy(-viewportHeight)` | +| PageDown | `scrollBy(viewportHeight)` | + --- -## 3. The Cursor Model +## 4. 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 cursor appears when the user is typing and fades when idle. This is managed by `InputPanel` with a configurable cursor character and color. ### The Breathing Cycle @@ -85,238 +117,177 @@ 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. | +| **Active** | Visible at input position | User presses any key. | +| **Idle** | Fading (color transition) | No input for 2 seconds while in Active state. | +| **Submit** | Visible | User presses Enter. | -### Implementation Strategy +### Implementation ```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 && } - - - ); -} +// InputPanel receives cursorChar and cursorColor as props +// The cursor is rendered as a Text element with inverse styling +{inputText} +{cursorVisible && {cursorChar}} ``` -### 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. +**Note:** The current implementation uses a color transition (white → dark gray) rather than opacity fading. This is more reliable across terminal emulators. --- -## 4. Virtual Scrolling +## 5. Message Display -Terminal virtualization is different from browser virtualization. We can't use DOM nodes — we render text rows. The strategy: +Messages are rendered as role-colored bubbles with markdown support, tool call display, and reasoning content. -### Windowed Rendering +### 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) + id?: string; // Stable identifier for memoization +} ``` -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) -``` +### Role-Based Styling -### Rendering Pipeline +| Role | Label Color | Bubble Border | Alignment | +|------|------------|---------------|-----------| +| **user** | Green | Green | Right | +| **system** | Yellow | Yellow | Left | +| **assistant** | Cyan | Cyan | Left | + +### Message Bubble Rendering ``` -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 +┌─────────────────────────────────────────┐ +│ [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... │ +└─────────────────────────────────────────┘ ``` -### Scroll Behavior +### Memoization -| 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 | +`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. --- -## 5. Component Breakdown - -### 5.1 Layout +## 6. Runtime Configuration (Bitchx-Inspired) -**Responsibility:** Full terminal management, resize handling, overall structure. +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. -```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 ( - -
- - - - - - - ); -} -``` +### Proposed: Toggle Commands -### 5.2 Header - -**Responsibility:** Connection status. Minimal, always visible. +| Toggle | Default | Description | +|--------|---------|-------------| +| `auto_scroll` | `on` | Auto-scroll to bottom on new messages | +| `timestamps` | `on` | Show timestamps on messages | +| `command_echo` | `on` | Echo user commands to output | +| `cursor_breathe` | `on` | Enable breathing cursor model | +| `debug_output` | `off` | Show debug-level messages | +Usage: ``` -┌─────────────────────────────────────────────────────────────┐ -│ ● connected │ -└─────────────────────────────────────────────────────────────┘ +/toggle timestamps → turns timestamps off +/toggle timestamps → turns timestamps on (toggle) +/toggle → shows all toggles and their states ``` -| Element | Position | Style | -|---------|----------|-------| -| Connection status | Left | Colored dot (green=connected, red=disconnected) | +### Proposed: Format Customization -### 5.3 OutputList +Bitchx's `/fset` allowed users to customize how every message type rendered. The TUI could adopt a similar pattern: -**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) => ( - - ))} - - ); -} +``` +/format system "[%T] %BSystem%n: %I%t%n" +/format error "[%T] %RError%n: %t" +/format agent "[%T] %CMads%n: %t" ``` -#### Output Row +Format specifiers: +- `%T` — timestamp (respects `timestamps` toggle) +- `%t` — text body +- `%B` — bold, `%n` — null color (reset) +- `%I` — italic, `%R` — red, `%C` — cyan, `%M` — magenta -Each entry is a terminal row. Entries can be different types: +### 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: ``` -$ 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... +/level debug → toggle debug messages on/off +/level → show active levels +/level all → show all levels and their states ``` -| 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 | +Available levels: +| Level | Description | +|-------|-------------| +| `user` | User messages | +| `assistant` | Agent responses | +| `system` | System notifications | +| `debug` | Debug/internal messages (hidden by default) | -### 5.4 InputBar +### Persistence -**Responsibility:** Text input, command parsing, cursor management. +All runtime configuration should be saved to `~/.madz/tui-config.json` on exit and loaded on startup: +- Toggles +- Formats +- Active filters -``` -> _ -``` +No config file editing required for common customizations. -| Element | Behavior | -|---------|----------| -| `>` prompt | Always visible, visual cue before input | -| Input text | Grows to fill available width | -| Cursor | Breathing model (see Section 3) | +--- -#### Command Handling +## 7. Command Parser -Commands are parsed from input when Enter is pressed: +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 | |---------|----------| -| `/clear` | Clear output history | -| `/help` | Show command reference | | `/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 | -All other input is treated as commands to execute — shell commands, code, system commands. No slash prefix needed. +### Skill Execution -### 5.5 StatusLine +Unrecognized `/command` patterns that match a registered skill name are executed as skills: +``` +/skillName [args] +``` -**Responsibility:** Contextual information, scroll position, mode indicators. +### Unknown Commands ``` -─── ↑ 127 lines above ─── [normal] +/unknownCommand +→ "Unknown command: /unknownCommand. Type /help for available commands." ``` -| Element | Content | -|---------|---------| -| Scroll position | Lines above fold (when scrolled up) | -| Mode indicator | `[normal]`, `[command]`, `[search]` | - --- -## 6. Interaction Model +## 8. Interaction Model ### Keyboard Shortcuts @@ -324,15 +295,14 @@ All other input is treated as commands to execute — shell commands, code, syst |-----|--------|--------| | 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 | +| 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 @@ -345,163 +315,90 @@ The up/down arrows scroll through command history when the user is at the bottom 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 +4. 2 seconds idle → cursor hidden (color transition to dark gray) 5. User presses key again → cycle repeats ``` --- -## 7. Rendering Pipeline - -### Update Cycle +## 9. Status Bar -``` -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 +The status bar displays connection status, system metrics, and contextual information. -| 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 +### Current Implementation ``` -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 +[●] Ready | [⚡12] [💬42] [◣ 1.2k] ``` -### 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) +| 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") | -### Layout Spacing +### Proposed Enhancement +Add toggle/filter indicators to the status bar: ``` -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 +[●] Ready | [⚡12] [💬42] [◣ 1.2k] [ts:1 scroll:1] ``` +This gives the user a quick glance at which runtime features are active. + --- -## 9. Edge Cases & Resilience +## 10. 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 +1. Detect resize via stdout.on("resize") +2. Call scrollRef.current.remeasure() to update viewport dimensions +3. ink-scroll-view handles re-layout automatically ``` -### Connection Loss +### Streaming Overflow ``` -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 +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 ``` -### Multi-line Output +### Connection Loss ``` -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 +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) ``` -### Output Retention Limit +### Model Stuck in Thinking Loop ``` -1. Retain last 1000 entries -2. When limit reached: trim oldest entries -3. If user scrolled up past trim point: show "output truncated" indicator +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 ``` ---- - -## 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. +### Output Retention -### 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. +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. --- @@ -510,79 +407,74 @@ A always-visible cursor creates visual tension — the eye is drawn to it even w ### 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) +2. **`useInput`** — For keyboard handling (single handler in App component) +3. **`useWindowSize`** — For terminal height (rows) +4. **`ScrollView` (ink-scroll-view)** — For scrollable conversation area +5. **`React.memo`** — For MessageBubble optimization -### Key Ink Patterns +### Key Patterns ```jsx -// Cursor visibility toggle -const { setVisibility } = useCursor({ isVisible: false }); -setVisibility(true); // Show -setVisibility(false); // Hide - -// Terminal dimensions -const { columns, rows } = useStdout(); +// 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]); -// Input handling -useInput((input, key) => { - if (key.enter) handleSubmit(); - if (key.up) handleScroll('up'); - // ... -}); +// Abort controller for streaming interruption +abortControllerRef.current = new AbortController(); +// ... +abortControllerRef.current.abort(); ``` -### State Management Recommendation +### State Management -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: +The current app uses React's built-in state (`useState`, `useRef`) rather than `useReducer`. This is sufficient for the current scale. The state model is: ```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 }; -} +// App-level state +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); + +// Refs for async operations +const scrollRef = useRef(null); +const abortControllerRef = useRef(null); +const isStreamingRef = useRef(false); +const dispatchPromiseRef = useRef(null); ``` --- ## 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: +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. **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. +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. --- -*Blueprint complete. No implementation references — this is a clean slate.* +*Design document. Reflects the current implementation and proposes compatible extensions.* From d146cf7fca087417cddb09ebdd4bb2cc77efb2b8 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:03:27 -0400 Subject: [PATCH 02/18] docs: comprehensively audit and rewrite TUI2.md to reflect actual implementation --- docs/TUI2.md | 755 +++++++++++++++++++++++++-------------------------- 1 file changed, 377 insertions(+), 378 deletions(-) diff --git a/docs/TUI2.md b/docs/TUI2.md index 851f730..0e8132d 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -1,6 +1,6 @@ # TUI2: Terminal Interface Blueprint -*A clean-slate design for a code-first terminal interface using Ink and `useCursor`. IRC-inspired layout, not IRC semantics.* +*A design document for the madz terminal interface. Grounded in the existing implementation (Ink + `ink-scroll-view` + structured logger), inspired by the best patterns of bitchx IRC client.* --- @@ -14,8 +14,8 @@ The IRC layout is borrowed for its elegance: messages accumulate above, input si 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. +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. --- @@ -25,13 +25,12 @@ The IRC layout is borrowed for its elegance: messages accumulate above, input si ┌─────────────────────────────────────────────────────────────┐ │ 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) ... │ +│ [14:23] Mads: Hello, Jason. │ +│ [14:24] System: Build complete │ +│ [14:25] Mads: Here's the diff... │ +│ ... (scrollable conversation) ... │ +├─────────────────────────────────────────────────────────────┤ +│ [●] Ready | [⚡12] [💬42] [◣ 1.2k] │ ├─────────────────────────────────────────────────────────────┤ │ > _ │ └─────────────────────────────────────────────────────────────┘ @@ -40,39 +39,72 @@ The IRC layout is borrowed for its elegance: messages accumulate above, input si ### 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 +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 ``` -### State Model +### Key Dependencies -```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 -} +| 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 0ms to allow ink-scroll-view's useLayoutEffect to complete) +2. Streaming content grows → scrollToBottom() (content hash triggers re-evaluation) +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 | +| `remeasure()` | Re-measure viewport dimensions (call on terminal resize) | +| `getViewportHeight()` | Get visible row count | +| `getScrollOffset()` | Get current scroll position | + +### Keyboard Scrolling (when input is unfocused) + +| Key | Action | +|-----|--------| +| Up arrow | `scrollBy(-1)` | +| Down arrow | `scrollBy(1)` | +| PageUp | `scrollBy(-viewportHeight)` | +| PageDown | `scrollBy(viewportHeight)` | + --- -## 3. The Cursor Model +## 4. 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 cursor appears when the user is typing and fades when idle. This is managed by `InputPanel` with a configurable cursor character and color. ### The Breathing Cycle @@ -85,238 +117,183 @@ 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. | +| **Active** | Visible at input position | User presses any key. | +| **Idle** | Fading (color transition) | No input for 2 seconds while in Active state. | +| **Submit** | Visible | User presses Enter. | -### Implementation Strategy +### Implementation ```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 && } - - - ); -} +// InputPanel receives cursorChar and cursorColor as props +// The cursor is rendered as a Text element with inverse styling +{inputText} +{cursorVisible && {cursorChar}} ``` -### 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. +**Note:** The current implementation uses a color transition (white → dark gray) rather than opacity fading. This is more reliable across terminal emulators. --- -## 4. Virtual Scrolling +## 5. Message Display -Terminal virtualization is different from browser virtualization. We can't use DOM nodes — we render text rows. The strategy: +Messages are rendered as role-colored bubbles with markdown support, tool call display, and reasoning content. -### Windowed Rendering +### 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) + id?: string; // Stable identifier for memoization +} ``` -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) -``` +### Role-Based Styling -### Rendering Pipeline +| Role | Label Color | Bubble Border | Alignment | +|------|------------|---------------|-----------| +| **user** | Green | Green | Right | +| **system** | Yellow | Yellow | Left | +| **assistant** | Cyan | Cyan | Left | + +### Message Bubble Rendering ``` -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 +┌─────────────────────────────────────────┐ +│ [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... │ +└─────────────────────────────────────────┘ ``` -### Scroll Behavior +### Memoization -| 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 | +`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 -## 5. Component Breakdown +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. -### 5.1 Layout +--- -**Responsibility:** Full terminal management, resize handling, overall structure. +## 6. Runtime Configuration (Bitchx-Inspired) -```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 ( - -
- - - - - - - ); -} -``` +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. -### 5.2 Header +### Proposed: Toggle Commands -**Responsibility:** Connection status. Minimal, always visible. +| Toggle | Default | Description | +|--------|---------|-------------| +| `auto_scroll` | `on` | Auto-scroll to bottom on new messages | +| `timestamps` | `on` | Show timestamps on messages | +| `command_echo` | `on` | Echo user commands to output | +| `cursor_breathe` | `on` | Enable breathing cursor model | +| `debug_output` | `off` | Show debug-level messages | +Usage: ``` -┌─────────────────────────────────────────────────────────────┐ -│ ● connected │ -└─────────────────────────────────────────────────────────────┘ +/toggle timestamps → turns timestamps off +/toggle timestamps → turns timestamps on (toggle) +/toggle → shows all toggles and their states ``` -| Element | Position | Style | -|---------|----------|-------| -| Connection status | Left | Colored dot (green=connected, red=disconnected) | - -### 5.3 OutputList +### Proposed: Format Customization -**Responsibility:** Virtualized output rendering, scroll management, auto-scroll logic. +Bitchx's `/fset` allowed users to customize how every message type rendered. The TUI could adopt a similar pattern: -```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) => ( - - ))} - - ); -} ``` +/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 -#### Output Row +### Proposed: Message Filtering -Each entry is a terminal row. Entries can be different types: +Bitchx had a sophisticated message-level system where you could filter what appeared in each window. The TUI could adopt a similar pattern: ``` -$ 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... +/level debug → toggle debug messages on/off +/level → show active levels +/level all → show all levels and their states ``` -| 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 | +Available levels: +| Level | Description | +|-------|-------------| +| `user` | User messages | +| `assistant` | Agent responses | +| `system` | System notifications | +| `debug` | Debug/internal messages (hidden by default) | -### 5.4 InputBar +### Persistence -**Responsibility:** Text input, command parsing, cursor management. +All runtime configuration should be saved to `~/.madz/tui-config.json` on exit and loaded on startup: +- Toggles +- Formats +- Active filters -``` -> _ -``` +No config file editing required for common customizations. -| Element | Behavior | -|---------|----------| -| `>` prompt | Always visible, visual cue before input | -| Input text | Grows to fill available width | -| Cursor | Breathing model (see Section 3) | +--- -#### Command Handling +## 7. Command Parser -Commands are parsed from input when Enter is pressed: +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 | |---------|----------| -| `/clear` | Clear output history | -| `/help` | Show command reference | | `/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 | -All other input is treated as commands to execute — shell commands, code, system commands. No slash prefix needed. +### Skill Execution -### 5.5 StatusLine +Unrecognized `/command` patterns that match a registered skill name are executed as skills: +``` +/skillName [args] +``` -**Responsibility:** Contextual information, scroll position, mode indicators. +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 ``` -─── ↑ 127 lines above ─── [normal] +/unknownCommand +→ "Unknown command: /unknownCommand. Type /help for available commands." ``` -| Element | Content | -|---------|---------| -| Scroll position | Lines above fold (when scrolled up) | -| Mode indicator | `[normal]`, `[command]`, `[search]` | - --- -## 6. Interaction Model +## 8. Interaction Model ### Keyboard Shortcuts @@ -324,15 +301,14 @@ All other input is treated as commands to execute — shell commands, code, syst |-----|--------|--------| | 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 | +| 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 @@ -345,244 +321,267 @@ The up/down arrows scroll through command history when the user is at the bottom 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 +4. 2 seconds idle → cursor hidden (color transition to dark gray) 5. User presses key again → cycle repeats ``` --- -## 7. Rendering Pipeline +## 9. Status Bar + +The status bar displays connection status, system metrics, and contextual information. -### Update Cycle +### Current Implementation ``` -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) +[●] Ready | [⚡12] [💬42] [◣ 1.2k] ``` -### Performance Considerations +| 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") | -| 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 | +### 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. --- -## 8. Visual Design +## 10. Edge Cases & Resilience -### Color Palette +### Terminal Resize ``` -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 +1. Detect resize via stdout.on("resize") +2. Call scrollRef.current.remeasure() to update viewport dimensions +3. ink-scroll-view handles re-layout automatically ``` -### Entry Styling +### Streaming Overflow -| 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 | +``` +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 +``` -### Typography +### Connection Loss -- **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) +``` +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) +``` -### Layout Spacing +### Model Stuck in Thinking Loop ``` -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 +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. + --- -## 9. Edge Cases & Resilience +## 11. Implementation Notes -### Terminal Resize +### Ink-Specific Patterns -``` -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 -``` +1. **`useStdout`** — For terminal dimensions and resize events +2. **`useInput`** — For keyboard handling (single handler in App component) +3. **`useWindowSize`** — For terminal height (rows) +4. **`ScrollView` (ink-scroll-view)** — For scrollable conversation area +5. **`React.memo`** — For MessageBubble optimization -### Connection Loss +### Key Patterns -``` -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 +```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(); ``` -### Multi-line Output +### State Management +The current app uses React's built-in state (`useState`, `useRef`) rather than `useReducer`. This is sufficient for the current scale. The state model is: + +```typescript +// App-level state +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); + +// Refs for async operations +const scrollRef = useRef(null); +const abortControllerRef = useRef(null); +const isStreamingRef = useRef(false); +const dispatchPromiseRef = useRef(null); ``` -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 +--- + +## 12. Streaming Architecture + +This section describes how the TUI wires into the AI agent's streaming pipeline. + +### Data Flow ``` -1. Retain last 1000 entries -2. When limit reached: trim oldest entries -3. If user scrolled up past trim point: show "output truncated" indicator +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 -## 10. The Cursor: A Deeper Look +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. -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. +### Abort / Interrupt -### States +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 ``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ READING │────▶│ ACTIVE │────▶│ IDLE │ -│ (Hidden) │◀────│ (Visible) │◀────│ (Fading) │ -└─────────────┘ └─────────────┘ └─────────────┘ - ▲ │ - └────────────── Escape ────────────────┘ +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/ ``` -### Transition Rules +### Context Token Calculation -| 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 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"). -### The "Why" +### GC Integration -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. +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. --- -## 11. Implementation Notes +## 14. Onboarding & Banner -### Ink-Specific Patterns +### Banner -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) +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). -### Key Ink Patterns +### Onboarding Panel -```jsx -// Cursor visibility toggle -const { setVisibility } = useCursor({ isVisible: false }); -setVisibility(true); // Show -setVisibility(false); // Hide +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 -// Terminal dimensions -const { columns, rows } = useStdout(); - -// Input handling -useInput((input, key) => { - if (key.enter) handleSubmit(); - if (key.up) handleScroll('up'); - // ... -}); -``` +--- -### State Management Recommendation +## 15. Panel Components (Available but Not Active) -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). +The TUI includes four panel components that are defined but not currently wired into the main render path: -Consider a custom hook for the cursor breathing model: +| Panel | File | Purpose | +|-------|------|---------| +| `ConversationPanel` | `conversationPanel.js` | Active — message display with ScrollView | +| `SkillsPanel` | `skillsPanel.js` | Lists registered skills with search | +| `MemoryPanel` | `memoryPanel.js` | Displays memory entries with file viewer | +| `SettingsPanel` | `settingsPanel.js` | Shows config sections | -```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 }; -} -``` +These panels use `useInput({ isActive })` for focused keyboard navigation and could be activated via Tab cycling (defined in `panels.js` and `hooks.js`). --- -## 12. Summary +## 16. 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: +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. **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. +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. --- -*Blueprint complete. No implementation references — this is a clean slate.* +*Design document. Reflects the current implementation and proposes compatible extensions.* From 5daa00f05b83bc2b6dceb093d0a386ca94deabd7 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:24:51 -0400 Subject: [PATCH 03/18] Remove unused panel components from TUI2 blueprint --- docs/TUI2.md | 182 ++++++++++++++++++++++++--------------------------- 1 file changed, 85 insertions(+), 97 deletions(-) diff --git a/docs/TUI2.md b/docs/TUI2.md index 0e8132d..347da49 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -1,6 +1,6 @@ # TUI2: Terminal Interface Blueprint -*A design document for the madz terminal interface. Grounded in the existing implementation (Ink + `ink-scroll-view` + structured logger), inspired by the best patterns of bitchx IRC client.* +*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.* --- @@ -23,8 +23,6 @@ The IRC layout is borrowed for its elegance: messages accumulate above, input si ``` ┌─────────────────────────────────────────────────────────────┐ -│ Header: [● connected] │ -├─────────────────────────────────────────────────────────────┤ │ [14:23] Mads: Hello, Jason. │ │ [14:24] System: Build complete │ │ [14:25] Mads: Here's the diff... │ @@ -48,13 +46,15 @@ App (src/tui/app.js) └── 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) | +||| 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) | --- @@ -82,23 +82,23 @@ The conversation area uses `ink-scroll-view`'s `ScrollView` component. This hand ### 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 | -| `remeasure()` | Re-measure viewport dimensions (call on terminal resize) | -| `getViewportHeight()` | Get visible row count | -| `getScrollOffset()` | Get current scroll position | +||| 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 | +||| `remeasure()` | Re-measure viewport dimensions (call on terminal resize) | +||| `getViewportHeight()` | Get visible row count | +||| `getScrollOffset()` | Get current scroll position | ### Keyboard Scrolling (when input is unfocused) -| Key | Action | -|-----|--------| -| Up arrow | `scrollBy(-1)` | -| Down arrow | `scrollBy(1)` | -| PageUp | `scrollBy(-viewportHeight)` | -| PageDown | `scrollBy(viewportHeight)` | +||| Key | Action | +|||-----|--------| +||| Up arrow | `scrollBy(-1)` | +||| Down arrow | `scrollBy(1)` | +||| PageUp | `scrollBy(-viewportHeight)` | +||| PageDown | `scrollBy(viewportHeight)` | --- @@ -114,12 +114,12 @@ 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. | -| **Idle** | Fading (color transition) | No input for 2 seconds while in Active state. | -| **Submit** | Visible | User presses Enter. | +||| State | Cursor | Trigger | +|||-------|--------|---------| +||| **Reading** | Hidden | Default state. User is consuming output. | +||| **Active** | Visible at input position | User presses any key. | +||| **Idle** | Fading (color transition) | No input for 2 seconds while in Active state. | +||| **Submit** | Visible | User presses Enter. | ### Implementation @@ -130,7 +130,7 @@ Hidden Visible Fading Hidden {cursorVisible && {cursorChar}} ``` -**Note:** The current implementation uses a color transition (white → dark gray) rather than opacity fading. This is more reliable across terminal emulators. +**Note:** The implementation uses a color transition (white → dark gray) rather than opacity fading. This is more reliable across terminal emulators. --- @@ -149,17 +149,18 @@ interface Message { 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 | +||| Role | Label Color | Bubble Border | Alignment | +|||------|------------|---------------|-----------| +||| **user** | Green | Green | Right | +||| **system** | Yellow | Yellow | Left | +||| **assistant** | Cyan | Cyan | Left | ### Message Bubble Rendering @@ -194,13 +195,13 @@ Bitchx's `/toggle` command was legendary because it made common customizations i ### Proposed: Toggle Commands -| Toggle | Default | Description | -|--------|---------|-------------| -| `auto_scroll` | `on` | Auto-scroll to bottom on new messages | -| `timestamps` | `on` | Show timestamps on messages | -| `command_echo` | `on` | Echo user commands to output | -| `cursor_breathe` | `on` | Enable breathing cursor model | -| `debug_output` | `off` | Show debug-level messages | +||| Toggle | Default | Description | +|||--------|---------|-------------| +||| `auto_scroll` | `on` | Auto-scroll to bottom on new messages | +||| `timestamps` | `on` | Show timestamps on messages | +||| `command_echo` | `on` | Echo user commands to output | +||| `cursor_breathe` | `on` | Enable breathing cursor model | +||| `debug_output` | `off` | Show debug-level messages | Usage: ``` @@ -236,12 +237,12 @@ Bitchx had a sophisticated message-level system where you could filter what appe ``` Available levels: -| Level | Description | -|-------|-------------| -| `user` | User messages | -| `assistant` | Agent responses | -| `system` | System notifications | -| `debug` | Debug/internal messages (hidden by default) | +||| Level | Description | +|||-------|-------------| +||| `user` | User messages | +||| `assistant` | Agent responses | +||| `system` | System notifications | +||| `debug` | Debug/internal messages (hidden by default) | ### Persistence @@ -260,20 +261,20 @@ Commands are parsed from input when Enter is pressed. The `CommandParser` class ### 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 | +||| 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 @@ -297,18 +298,18 @@ The skill body (from `SKILL.md`) is loaded and streamed to the agent as a prompt ### 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 | +||| 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 @@ -345,13 +346,13 @@ The status bar displays connection status, system metrics, and contextual inform [●] 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") | +||| 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 @@ -466,6 +467,8 @@ const scrollRef = useRef(null); const abortControllerRef = useRef(null); const isStreamingRef = useRef(false); const dispatchPromiseRef = useRef(null); +const autoContinueCountRef = useRef(0); +const isAutoContinuingRef = useRef(false); ``` --- @@ -499,7 +502,7 @@ The `streamingCallback` is set up in `handleChat()` / `handleCommand()` and pass ### 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. +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 @@ -556,22 +559,7 @@ When no user profile exists, the onboarding flow activates: --- -## 15. Panel Components (Available but Not Active) - -The TUI includes four panel components that are defined but not currently wired into the main render path: - -| Panel | File | Purpose | -|-------|------|---------| -| `ConversationPanel` | `conversationPanel.js` | Active — message display with ScrollView | -| `SkillsPanel` | `skillsPanel.js` | Lists registered skills with search | -| `MemoryPanel` | `memoryPanel.js` | Displays memory entries with file viewer | -| `SettingsPanel` | `settingsPanel.js` | Shows config sections | - -These panels use `useInput({ isActive })` for focused keyboard navigation and could be activated via Tab cycling (defined in `panels.js` and `hooks.js`). - ---- - -## 16. Summary +## 15. Summary This blueprint describes the madz terminal interface, grounded in the existing implementation. The design is defined by four principles: From 761909953a7c29dd2fd4f4a727e2c7f599218882 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:39:57 -0400 Subject: [PATCH 04/18] docs: add architectural debt section to TUI2.md --- docs/TUI2.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/docs/TUI2.md b/docs/TUI2.md index 347da49..9f5a17b 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -520,6 +520,95 @@ The todo tool queue emits status events (`todo_status`) that flow through the La --- +## 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. + +--- + ## 13. Session & Persistence ### Session Lifecycle From ad5be4c1bd57bffe12248c5396aa7cc266323af8 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:41:21 -0400 Subject: [PATCH 05/18] docs: fix malformed table syntax (||| -> |) --- docs/TUI2.md | 152 +++++++++++++++++++++++++-------------------------- 1 file changed, 76 insertions(+), 76 deletions(-) diff --git a/docs/TUI2.md b/docs/TUI2.md index 9f5a17b..f2f92b7 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -50,11 +50,11 @@ No panels, no tabs, no switching. The interface is a single scrollable output ar ### 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) | +| 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) | --- @@ -82,23 +82,23 @@ The conversation area uses `ink-scroll-view`'s `ScrollView` component. This hand ### 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 | -||| `remeasure()` | Re-measure viewport dimensions (call on terminal resize) | -||| `getViewportHeight()` | Get visible row count | -||| `getScrollOffset()` | Get current scroll position | +| 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 | +| `remeasure()` | Re-measure viewport dimensions (call on terminal resize) | +| `getViewportHeight()` | Get visible row count | +| `getScrollOffset()` | Get current scroll position | ### Keyboard Scrolling (when input is unfocused) -||| Key | Action | -|||-----|--------| -||| Up arrow | `scrollBy(-1)` | -||| Down arrow | `scrollBy(1)` | -||| PageUp | `scrollBy(-viewportHeight)` | -||| PageDown | `scrollBy(viewportHeight)` | +| Key | Action | +|-----|--------| +| Up arrow | `scrollBy(-1)` | +| Down arrow | `scrollBy(1)` | +| PageUp | `scrollBy(-viewportHeight)` | +| PageDown | `scrollBy(viewportHeight)` | --- @@ -114,12 +114,12 @@ 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. | -||| **Idle** | Fading (color transition) | No input for 2 seconds while in Active state. | -||| **Submit** | Visible | User presses Enter. | +| State | Cursor | Trigger | +|-------|--------|---------| +| **Reading** | Hidden | Default state. User is consuming output. | +| **Active** | Visible at input position | User presses any key. | +| **Idle** | Fading (color transition) | No input for 2 seconds while in Active state. | +| **Submit** | Visible | User presses Enter. | ### Implementation @@ -156,11 +156,11 @@ interface Message { ### Role-Based Styling -||| Role | Label Color | Bubble Border | Alignment | -|||------|------------|---------------|-----------| -||| **user** | Green | Green | Right | -||| **system** | Yellow | Yellow | Left | -||| **assistant** | Cyan | Cyan | Left | +| Role | Label Color | Bubble Border | Alignment | +|------|------------|---------------|-----------| +| **user** | Green | Green | Right | +| **system** | Yellow | Yellow | Left | +| **assistant** | Cyan | Cyan | Left | ### Message Bubble Rendering @@ -195,13 +195,13 @@ Bitchx's `/toggle` command was legendary because it made common customizations i ### Proposed: Toggle Commands -||| Toggle | Default | Description | -|||--------|---------|-------------| -||| `auto_scroll` | `on` | Auto-scroll to bottom on new messages | -||| `timestamps` | `on` | Show timestamps on messages | -||| `command_echo` | `on` | Echo user commands to output | -||| `cursor_breathe` | `on` | Enable breathing cursor model | -||| `debug_output` | `off` | Show debug-level messages | +| Toggle | Default | Description | +|--------|---------|-------------| +| `auto_scroll` | `on` | Auto-scroll to bottom on new messages | +| `timestamps` | `on` | Show timestamps on messages | +| `command_echo` | `on` | Echo user commands to output | +| `cursor_breathe` | `on` | Enable breathing cursor model | +| `debug_output` | `off` | Show debug-level messages | Usage: ``` @@ -237,12 +237,12 @@ Bitchx had a sophisticated message-level system where you could filter what appe ``` Available levels: -||| Level | Description | -|||-------|-------------| -||| `user` | User messages | -||| `assistant` | Agent responses | -||| `system` | System notifications | -||| `debug` | Debug/internal messages (hidden by default) | +| Level | Description | +|-------|-------------| +| `user` | User messages | +| `assistant` | Agent responses | +| `system` | System notifications | +| `debug` | Debug/internal messages (hidden by default) | ### Persistence @@ -261,20 +261,20 @@ Commands are parsed from input when Enter is pressed. The `CommandParser` class ### 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 | +| 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 @@ -298,18 +298,18 @@ The skill body (from `SKILL.md`) is loaded and streamed to the agent as a prompt ### 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 | +| 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 @@ -346,13 +346,13 @@ The status bar displays connection status, system metrics, and contextual inform [●] 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") | +| 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 From 3aafc9469cfcb7fce7e0e5900f84e1dcfdfc5dce Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:42:49 -0400 Subject: [PATCH 06/18] docs: convert toggle commands to camelCase per AGENTS.md --- docs/TUI2.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/TUI2.md b/docs/TUI2.md index f2f92b7..8386918 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -197,11 +197,11 @@ Bitchx's `/toggle` command was legendary because it made common customizations i | Toggle | Default | Description | |--------|---------|-------------| -| `auto_scroll` | `on` | Auto-scroll to bottom on new messages | +| `autoScroll` | `on` | Auto-scroll to bottom on new messages | | `timestamps` | `on` | Show timestamps on messages | -| `command_echo` | `on` | Echo user commands to output | -| `cursor_breathe` | `on` | Enable breathing cursor model | -| `debug_output` | `off` | Show debug-level messages | +| `commandEcho` | `on` | Echo user commands to output | +| `cursorBreathe` | `on` | Enable breathing cursor model | +| `debugOutput` | `off` | Show debug-level messages | Usage: ``` From ac61bf16dd303b86b736b6583a5ee878c3a55cdd Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:49:24 -0400 Subject: [PATCH 07/18] =?UTF-8?q?docs:=20fix=20audit=20findings=20?= =?UTF-8?q?=E2=80=94=20section=20numbering,=20line=20lengths,=20dedup,=20s?= =?UTF-8?q?ecurity,=20YAGNI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TUI2.md | 259 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 152 insertions(+), 107 deletions(-) diff --git a/docs/TUI2.md b/docs/TUI2.md index 8386918..9d502ea 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -1,21 +1,28 @@ # TUI2: 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.* +*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 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. +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. +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. +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. --- @@ -46,7 +53,8 @@ App (src/tui/app.js) └── InputPanel — Text input with cursor display ``` -No panels, no tabs, no switching. The interface is a single scrollable output area with one input line. +No panels, no tabs, no switching. The interface is a single scrollable output area with one input +line. ### Key Dependencies @@ -60,7 +68,8 @@ No panels, no tabs, no switching. The interface is a single scrollable output ar ## 3. Scrolling & Viewport -The conversation area uses `ink-scroll-view`'s `ScrollView` component. This handles all viewport management — no custom virtual scroll logic needed. +The conversation area uses `ink-scroll-view`'s `ScrollView` component. This handles all viewport +management — no custom virtual scroll logic needed. ### How It Works @@ -74,7 +83,8 @@ The conversation area uses `ink-scroll-view`'s `ScrollView` component. This hand ### Auto-Scroll Behavior ``` -1. New message arrives → scrollToBottom() (deferred 0ms to allow ink-scroll-view's useLayoutEffect to complete) +1. New message arrives → scrollToBottom() (deferred 0ms to allow ink-scroll-view's useLayoutEffect +to complete) 2. Streaming content grows → scrollToBottom() (content hash triggers re-evaluation) 3. User scrolls up → stays where they are (no forced scroll) 4. Terminal resize → remeasure() called via stdout.on("resize") @@ -104,7 +114,8 @@ The conversation area uses `ink-scroll-view`'s `ScrollView` component. This hand ## 4. The Cursor Model -The cursor appears when the user is typing and fades when idle. This is managed by `InputPanel` with a configurable cursor character and color. +The cursor appears when the user is typing and fades when idle. This is managed by `InputPanel` with +a configurable cursor character and color. ### The Breathing Cycle @@ -130,13 +141,15 @@ Hidden Visible Fading Hidden {cursorVisible && {cursorChar}} ``` -**Note:** The implementation uses a color transition (white → dark gray) rather than opacity fading. This is more reliable across terminal emulators. +**Note:** The implementation uses a color transition (white → dark gray) rather than opacity fading. +This is more reliable across terminal emulators. --- ## 5. Message Display -Messages are rendered as role-colored bubbles with markdown support, tool call display, and reasoning content. +Messages are rendered as role-colored bubbles with markdown support, tool call display, and +reasoning content. ### Message Structure @@ -181,17 +194,23 @@ interface Message { ### 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. +`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. +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. +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. ### Proposed: Toggle Commands @@ -212,7 +231,8 @@ Usage: ### Proposed: Format Customization -Bitchx's `/fset` allowed users to customize how every message type rendered. The TUI could adopt a similar pattern: +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" @@ -226,9 +246,12 @@ Format specifiers: - `%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: +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 @@ -244,20 +267,27 @@ Available levels: | `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 -All runtime configuration should be saved to `~/.madz/tui-config.json` on exit and loaded on startup: +All runtime configuration should be saved to `~/.madz/tui-config.json` on exit and loaded on +startup: - Toggles - Formats - Active filters No config file editing required for common customizations. +**Security:** File should be created with `0600` permissions. Do not store sensitive data (API keys, +tokens) in this file. + --- ## 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. +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 @@ -283,7 +313,8 @@ Unrecognized `/command` patterns that match a registered skill name are executed /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. +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 @@ -313,7 +344,8 @@ The skill body (from `SKILL.md`) is loaded and streamed to the agent as a prompt ### 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: +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 @@ -322,7 +354,8 @@ The up/down arrows scroll through command history when the user is at the bottom 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. +**Note:** History is in-memory (`chatHistory` array). The structured logger (`src/logger.js`) +handles persistent logging of all interactions. ### Input Lifecycle @@ -388,7 +421,8 @@ This gives the user a quick glance at which runtime features are active. ``` 1. Error in dispatchProvider → catch block handles it -2. System message displayed: "I couldn't connect right now - {error}. Try sending your message again?" +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) ``` @@ -405,7 +439,9 @@ This gives the user a quick glance at which runtime features are active. ### 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. +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. --- @@ -449,27 +485,8 @@ 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. The state model is: - -```typescript -// App-level state -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); - -// Refs for async operations -const scrollRef = useRef(null); -const abortControllerRef = useRef(null); -const isStreamingRef = useRef(false); -const dispatchPromiseRef = useRef(null); -const autoContinueCountRef = useRef(0); -const isAutoContinuingRef = useRef(false); -``` +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. --- @@ -498,11 +515,16 @@ User Input (Enter) ### 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. +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. +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 @@ -511,18 +533,84 @@ The `Escape` key triggers `handleInterrupt()`, which: 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 +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. +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. +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` @@ -539,11 +627,14 @@ 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. +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: +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 @@ -589,11 +680,16 @@ Not dogma — predictability. When you're looking for streaming logic, you know ### 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. +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. +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 @@ -605,59 +701,8 @@ The current `commandParser.js` is a dispatch table. Make it more extensible — ### 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. - ---- - -## 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. +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. --- From 1ebe3d3cbae332fdc1691ce5f342bd6ba1807b94 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:51:17 -0400 Subject: [PATCH 08/18] docs: wire Section 6 to config.yaml tui section --- docs/TUI2.md | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/TUI2.md b/docs/TUI2.md index 9d502ea..aedfd3c 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -210,17 +210,32 @@ renders. The streaming cursor character (`█`) is stripped before parsing to av 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. +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` | `on` | Auto-scroll to bottom on new messages | -| `timestamps` | `on` | Show timestamps on messages | -| `commandEcho` | `on` | Echo user commands to output | -| `cursorBreathe` | `on` | Enable breathing cursor model | -| `debugOutput` | `off` | Show debug-level messages | +| `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: ``` @@ -271,16 +286,8 @@ Available levels: ### Persistence -All runtime configuration should be saved to `~/.madz/tui-config.json` on exit and loaded on -startup: -- Toggles -- Formats -- Active filters - -No config file editing required for common customizations. - -**Security:** File should be created with `0600` permissions. Do not store sensitive data (API keys, -tokens) in this file. +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. --- From c289503dddf25499b90e38a504272efd326d5aa0 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:54:07 -0400 Subject: [PATCH 09/18] docs: replace custom cursor with ink useCursor hook --- docs/TUI2.md | 43 +++++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/docs/TUI2.md b/docs/TUI2.md index aedfd3c..95c5a4e 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -112,38 +112,33 @@ to complete) --- -## 4. The Cursor Model +## 4. The Cursor -The cursor appears when the user is typing and fades when idle. This is managed by `InputPanel` with -a configurable cursor character and color. +The cursor is managed by ink's `useCursor` hook, which handles visibility and blinking +automatically. The TUI passes the configured cursor character and color to the hook. -### The Breathing Cycle +### Configuration -``` -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. | -| **Idle** | Fading (color transition) | No input for 2 seconds while in Active state. | -| **Submit** | Visible | User presses Enter. | +```jsx +import { useCursor } from 'ink'; -### Implementation +const [cursorVisible, setCursorVisible] = useState(true); +const { Cursor } = useCursor(cursorVisible, { cursorChar: config.tui.cursorChar }); -```jsx -// InputPanel receives cursorChar and cursorColor as props -// The cursor is rendered as a Text element with inverse styling -{inputText} -{cursorVisible && {cursorChar}} +// Toggle visibility based on input focus +// Input focused → visible +// Input unfocused → hidden ``` -**Note:** The implementation uses a color transition (white → dark gray) rather than opacity fading. -This is more reliable across terminal emulators. +### Behavior + +- **Input focused** — cursor visible at input position +- **Input unfocused** — cursor hidden +- **Blinking** — handled by `useCursor` internally +- **Character/color** — sourced from `config.tui.cursorChar` and `config.tui.cursorColor` +No custom cursor state machine needed. `useCursor` handles the rendering; the TUI only manages +visibility toggling based on input focus state. --- ## 5. Message Display From 73b93cc3b17e7abb43b2d2d2ea2fed1e5dceb81d Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:58:55 -0400 Subject: [PATCH 10/18] =?UTF-8?q?docs:=20fix=20library=20API=20inaccuracie?= =?UTF-8?q?s=20=E2=80=94=20useCursor,=20ScrollView,=20useWindowSize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TUI2.md | 78 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 33 deletions(-) diff --git a/docs/TUI2.md b/docs/TUI2.md index 95c5a4e..ce5ee53 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -75,7 +75,7 @@ management — no custom virtual scroll logic needed. ```jsx // ConversationPanel wraps ScrollView around message bubbles - + {messages.map(msg => )} ``` @@ -83,62 +83,75 @@ management — no custom virtual scroll logic needed. ### Auto-Scroll Behavior ``` -1. New message arrives → scrollToBottom() (deferred 0ms to allow ink-scroll-view's useLayoutEffect -to complete) -2. Streaming content grows → scrollToBottom() (content hash triggers re-evaluation) +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 | +|| 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 | -| `remeasure()` | Re-measure viewport dimensions (call on terminal resize) | -| `getViewportHeight()` | Get visible row count | -| `getScrollOffset()` | Get current scroll position | +|| `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 | +|| Key | Action | |-----|--------| -| Up arrow | `scrollBy(-1)` | -| Down arrow | `scrollBy(1)` | -| PageUp | `scrollBy(-viewportHeight)` | -| PageDown | `scrollBy(viewportHeight)` | +|| 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 handles visibility and blinking -automatically. The TUI passes the configured cursor character and color to the hook. +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 [cursorVisible, setCursorVisible] = useState(true); -const { Cursor } = useCursor(cursorVisible, { cursorChar: config.tui.cursorChar }); +const { setCursorPosition } = useCursor(); -// Toggle visibility based on input focus -// Input focused → visible -// Input unfocused → hidden +// 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 visible at input position -- **Input unfocused** — cursor hidden -- **Blinking** — handled by `useCursor` internally -- **Character/color** — sourced from `config.tui.cursorChar` and `config.tui.cursorColor` +- **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. -No custom cursor state machine needed. `useCursor` handles the rendering; the TUI only manages -visibility toggling based on input focus state. --- ## 5. Message Display @@ -451,11 +464,10 @@ the TUI. ### Ink-Specific Patterns -1. **`useStdout`** — For terminal dimensions and resize events +1. **`useStdout`** — For terminal resize events (call `remeasure()` on the scroll ref) 2. **`useInput`** — For keyboard handling (single handler in App component) -3. **`useWindowSize`** — For terminal height (rows) -4. **`ScrollView` (ink-scroll-view)** — For scrollable conversation area -5. **`React.memo`** — For MessageBubble optimization +3. **`ScrollView` (ink-scroll-view)** — For scrollable conversation area +4. **`React.memo`** — For MessageBubble optimization ### Key Patterns From aa832248d384cb252c9c5bb57050c2c02bed9b1e Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 21:59:47 -0400 Subject: [PATCH 11/18] docs: add library references to TUI2.md --- docs/TUI2.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/TUI2.md b/docs/TUI2.md index ce5ee53..900f4e6 100644 --- a/docs/TUI2.md +++ b/docs/TUI2.md @@ -720,4 +720,13 @@ mental model. It's the implementation scaffolding around it that should be reorg --- +## 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) + +--- + *Design document. Reflects the current implementation and proposes compatible extensions.* From 0e0b5951bd59b1358534de599cdcc2c54bdcb0b7 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 22:01:30 -0400 Subject: [PATCH 12/18] =?UTF-8?q?docs:=20rename=20TUI2.md=20to=20TUI.md=20?= =?UTF-8?q?=E2=80=94=20new=20blueprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/{TUI2.md => TUI.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{TUI2.md => TUI.md} (100%) diff --git a/docs/TUI2.md b/docs/TUI.md similarity index 100% rename from docs/TUI2.md rename to docs/TUI.md From 3937699db742df07d08a149c9e29418f6a3f6243 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 22:02:19 -0400 Subject: [PATCH 13/18] docs: update header and tag line for TUI.md --- docs/TUI.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 900f4e6..3583409 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -1,4 +1,4 @@ -# TUI2: Terminal Interface Blueprint +# 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 @@ -729,4 +729,5 @@ mental model. It's the implementation scaffolding around it that should be reorg --- -*Design document. Reflects the current implementation and proposes compatible extensions.* +*Blueprint for the new madz terminal interface. Grounded in the existing implementation and the +ink + ink-scroll-view libraries.* From 5c82f5b7911710d219918d4eee43fef994b80a77 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 22:05:05 -0400 Subject: [PATCH 14/18] =?UTF-8?q?docs:=20add=20Section=2017=20Implementati?= =?UTF-8?q?on=20Spec=20=E2=80=94=20state,=20actions,=20commands,=20streami?= =?UTF-8?q?ng,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TUI.md | 266 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/docs/TUI.md b/docs/TUI.md index 3583409..96b2bfc 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -720,6 +720,272 @@ mental model. It's the implementation scaffolding around it that should be reorg --- +## 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 From 50765755f0a0a9bce2a4433a0068c8a184998372 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 22:07:21 -0400 Subject: [PATCH 15/18] docs: fix ASCII art alignment in Section 2 --- docs/TUI.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 96b2bfc..eaa8646 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -30,14 +30,14 @@ ship built-in. No config file editing needed for common changes. ``` ┌─────────────────────────────────────────────────────────────┐ -│ [14:23] Mads: Hello, Jason. │ -│ [14:24] System: Build complete │ -│ [14:25] Mads: Here's the diff... │ -│ ... (scrollable conversation) ... │ +│ [14:23] Mads: Hello, Jason. │ +│ [14:24] System: Build complete │ +│ [14:25] Mads: Here's the diff... │ +│ ... (scrollable conversation) ... │ ├─────────────────────────────────────────────────────────────┤ -│ [●] Ready | [⚡12] [💬42] [◣ 1.2k] │ +│ [●] Ready | [⚡12] [💬42] [◣ 1.2k] │ ├─────────────────────────────────────────────────────────────┤ -│ > _ │ +│ > _ │ └─────────────────────────────────────────────────────────────┘ ``` From 1c437005e67c5ffdcf26c320759ba9ace190b90d Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 22:08:19 -0400 Subject: [PATCH 16/18] docs: nudge right border on diff line in Section 2 ASCII art --- docs/TUI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TUI.md b/docs/TUI.md index eaa8646..97d83d0 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -32,7 +32,7 @@ ship built-in. No config file editing needed for common changes. ┌─────────────────────────────────────────────────────────────┐ │ [14:23] Mads: Hello, Jason. │ │ [14:24] System: Build complete │ -│ [14:25] Mads: Here's the diff... │ +│ [14:25] Mads: Here's the diff... │ │ ... (scrollable conversation) ... │ ├─────────────────────────────────────────────────────────────┤ │ [●] Ready | [⚡12] [💬42] [◣ 1.2k] │ From 9a1055a96507bf4fa84af8ce2c7538c47c01f6b4 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 22:09:16 -0400 Subject: [PATCH 17/18] docs: fix table edge pipes (|| -> |) --- docs/TUI.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 97d83d0..fa22ced 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -91,29 +91,29 @@ management — no custom virtual scroll logic needed. ### Scroll API (via ref) -|| Method | Purpose | +| 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 | +| `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 | +| Key | Action | |-----|--------| -|| Up arrow | `scrollBy(-1)` | -|| Down arrow | `scrollBy(1)` | -|| PageUp | `scrollBy(-viewportHeight)` | -|| PageDown | `scrollBy(viewportHeight)` | +| Up arrow | `scrollBy(-1)` | +| Down arrow | `scrollBy(1)` | +| PageUp | `scrollBy(-viewportHeight)` | +| PageDown | `scrollBy(viewportHeight)` | ### Controlled Mode From 579ebe7e3e9ad64dcd5af35145db382b997172b4 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Mon, 15 Jun 2026 22:21:39 -0400 Subject: [PATCH 18/18] docs: fix Message Bubble Rendering ASCII art alignment --- docs/TUI.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index fa22ced..f8d8d60 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -186,8 +186,8 @@ interface Message { ### Message Bubble Rendering ``` -┌─────────────────────────────────────────┐ -│ [14:23] Mads: Here's the code: │ +┌───────────────────────────────────────────┐ +│ [14:23] Mads: Here's the code: │ │ │ │ ```js │ │ const x = 42; │ @@ -197,7 +197,7 @@ interface Message { │ - Tool: patch (src/tui/app.js) │ │ │ │ (thinking) Analyzing the request... │ -└─────────────────────────────────────────┘ +└───────────────────────────────────────────┘ ``` ### Memoization