diff --git a/openspec/changes/refactor-tui/.openspec.yaml b/openspec/changes/refactor-tui/.openspec.yaml new file mode 100644 index 0000000..a903f7f --- /dev/null +++ b/openspec/changes/refactor-tui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-16 diff --git a/openspec/changes/refactor-tui/design.md b/openspec/changes/refactor-tui/design.md new file mode 100644 index 0000000..17afb0a --- /dev/null +++ b/openspec/changes/refactor-tui/design.md @@ -0,0 +1,109 @@ +## Context + +The TUI (`src/tui/`) is the primary user-facing interface for madz. It uses Ink + `ink-scroll-view` + a structured logger. The current implementation works but has structural debt: + +- `app.js` has 8 independent `useState` calls with no coordination +- Streaming callback is set up inline in `handleChat()` / `handleCommand()` and passed through multiple layers +- Flat 17-file layout in `src/tui/` — no grouping by concern +- Panel system (`panels.js`, `skillsPanel.js`, `memoryPanel.js`, `settingsPanel.js`) contradicts the blueprint's "no panels" philosophy +- Command parser is a switch-driven dispatch table, not extensible + +The blueprint (`docs/TUI.md`) defines the target architecture. This design bridges the gap between current state and that target. + +## Goals / Non-Goals + +**Goals:** +- Consolidate all TUI state into a single `useReducer` with `TUIState` interface +- Extract streaming logic into `useStreaming()` hook with clean AbortController lifecycle +- Reorganize file structure into `state/`, `hooks/`, `components/`, `utils/` directories +- Remove the panel system entirely; move panel functionality to commands +- Refactor command parser to event-driven registry pattern +- Add runtime toggle system with `/toggle` commands and status bar indicators +- Maintain 100% test coverage on all new files; existing tests must pass + +**Non-Goals:** +- Format customization system (YAGNI per blueprint) +- Message-level filtering (YAGNI per blueprint) +- Changing the component hierarchy (App → ConversationPanel + StatusBar + InputPanel stays the same) +- Modifying the structured logger, tiktoken calculation, or `ink-scroll-view` usage +- Changing the AI provider integration layer + +## Decisions + +### 1. `useReducer` over `useState` — Decision: Yes +**Rationale:** Eight independent state variables that update together (messages, statusMessage, contextSize, chatHistory) create race conditions and unnecessary re-renders. A single reducer with a `TUIState` interface gives us one render cycle per meaningful change and makes state transitions explicit through action types. + +**Alternatives considered:** +- `useReducer` with context — overkill for a single component tree +- Zustand/Jotai — adds external dependency for what can be solved with built-in React + +### 2. Streaming Hook — Decision: `useStreaming()` returning `streamingState` object +**Rationale:** The streaming callback currently lives inline, passed through `handleChat()` → `dispatchProvider` → `callProvider` → `callReactAgentStreaming`. Extracting it into a hook that manages the AbortController lifecycle, translates stream events into state transitions, and exposes a clean `streamingState` object separates *how we stream* from *what we stream*. + +**Alternatives considered:** +- Context provider — adds unnecessary abstraction layer +- Separate service class — over-engineering for a single-consumer hook + +### 3. File Structure — Decision: Group by concern +**Rationale:** A flat 17-file layout doesn't scale. Grouping by concern (`state/`, `hooks/`, `components/`, `utils/`) means when you're looking for streaming logic, you know exactly where to look. This is about predictability, not dogma. + +**Target structure:** +``` +src/tui/ +├── app.js # Root component, providers, reducer +├── state/ +│ ├── reducer.js # useReducer implementation, all action handlers +│ ├── types.js # TUIState, TUIAction, Message interfaces +│ └── selectors.js # Derived state (contextSize, statusMessage, etc.) +├── hooks/ +│ ├── useStreaming.js # AbortController, event transformation, auto-continue +│ ├── useScroll.js # ScrollView ref, resize handling, keyboard scroll +│ ├── useInput.js # Keyboard routing (scroll vs history vs input) +│ └── useCommand.js # Command parsing + dispatch +├── components/ +│ ├── ConversationPanel.js +│ ├── InputPanel.js +│ ├── StatusBar.js +│ ├── MessageBubble.js +│ └── Banner.js +├── panels/ +│ └── OnboardingPanel.js # Only panel that stays +├── utils/ +│ ├── commandParser.js # Command registry, dispatch table +│ ├── contextTokens.js # tiktoken token calculation +│ ├── markdownText.js # marked + marked-terminal rendering +│ └── format.js # Format specifiers, toggle logic +└── index.js +``` + +### 4. Panel Removal — Decision: Remove all panels except OnboardingPanel +**Rationale:** The blueprint's core philosophy is "no panels, no tabs, no switching." The skills, memory, and settings panels contradict this. Their functionality moves to commands (`/skills`, `/memory`, `/config`) that produce output in the conversation stream. OnboardingPanel stays because it's a first-run flow, not a navigation surface. + +### 5. Command Registry — Decision: Event-driven with validate/execute/help +**Rationale:** The current switch-driven dispatch table requires editing the parser to add commands. An event-driven registry where commands are registered as objects with `validate`, `execute`, and `help` properties makes adding new commands a registration, not a switch case edit. + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|-----------| +| Reducer complexity — single reducer handling all TUI state could become unwieldy | Split into named reducers (messagesReducer, inputReducer, etc.) composed with `combineReducers`-style pattern | +| Streaming hook abstraction leaks implementation details | Keep the hook's public API minimal: `streamingState` object + `startStreaming()` / `abort()` methods | +| Panel removal breaks existing tests | All panel-related tests move to command tests; OnboardingPanel tests stay | +| File relocation causes merge conflicts during transition | Do all moves in a single commit; no incremental refactoring | +| Toggle system adds UI complexity | Start with 5 toggles only; format specifiers and message filtering deferred (YAGNI) | + +## Migration Plan + +1. **Phase 1 — State consolidation:** Create `state/types.js`, `state/reducer.js`, `state/selectors.js`. Migrate `app.js` from `useState` to `useReducer`. +2. **Phase 2 — Streaming extraction:** Create `hooks/useStreaming.js`. Extract streaming callback from `handleChat()`/`handleCommand()`. +3. **Phase 3 — File reorganization:** Move files to new directory structure. Update all imports. +4. **Phase 4 — Panel removal:** Delete `panels.js`, `skillsPanel.js`, `memoryPanel.js`, `settingsPanel.js`. Move their functionality to commands. +5. **Phase 5 — Command registry:** Rewrite `commandParser.js` as event-driven registry. +6. **Phase 6 — Runtime toggles:** Add toggle commands and status bar indicators. +7. **Phase 7 — Tests:** Write new tests for reducer, streaming hook, command registry. Verify all existing tests pass. + +## Open Questions + +1. Should the reducer be a single `reducer.js` or split into named sub-reducers composed together? +2. What's the exact API surface of `useStreaming()` — should it return a hook or a custom hook factory? +3. Should toggle overrides persist to `config.yaml` on exit, or remain in-memory only? (Blueprint says in-memory only.) diff --git a/openspec/changes/refactor-tui/proposal.md b/openspec/changes/refactor-tui/proposal.md new file mode 100644 index 0000000..8403404 --- /dev/null +++ b/openspec/changes/refactor-tui/proposal.md @@ -0,0 +1,32 @@ +## Why + +The TUI works but its implementation scaffolding doesn't scale. Eight independent `useState` calls with no coordination, streaming logic scattered inline, a flat 17-file layout, and a panel system that contradicts the blueprint's "no panels" philosophy. As the TUI grows, these structural decisions compound — making it harder to find code, harder to reason about state transitions, and harder to add new features without touching multiple unrelated files. + +## What Changes + +- **Consolidate state** into a single `useReducer` with a `TUIState` interface, replacing eight independent `useState` calls +- **Extract streaming logic** into a `useStreaming()` hook that manages AbortController lifecycle, translates stream events into state transitions, and handles the auto-continue circuit breaker +- **Reorganize file structure** from flat layout to grouped-by-concern (`state/`, `hooks/`, `components/`, `utils/`) +- **Remove the panel system** (`panels.js`, `skillsPanel.js`, `memoryPanel.js`, `settingsPanel.js`) — all panel functionality moves to commands that produce output in the conversation stream +- **Refactor command parser** from switch-driven dispatch table to event-driven command registry with `validate`, `execute`, and `help` properties +- **Add runtime toggle system** (`/toggle` commands) for `autoScroll`, `timestamps`, `commandEcho`, `cursorBreathe`, `debugOutput` +- **Add toggle indicators** to the status bar + +## Capabilities + +### New Capabilities +- `tui-state-management`: Consolidated `useReducer` state management with `TUIState` interface and typed actions +- `tui-streaming-hook`: Extracted streaming logic into `useStreaming()` hook with AbortController lifecycle and auto-continue circuit breaker +- `tui-file-structure`: Reorganized TUI file structure grouped by concern (`state/`, `hooks/`, `components/`, `utils/`) +- `tui-panel-removal`: Removed panel system; all panel functionality moved to commands producing output in conversation stream +- `tui-command-registry`: Event-driven command registry replacing switch-driven dispatch table +- `tui-runtime-toggles`: Runtime toggle system with `/toggle` commands and status bar indicators + +### Modified Capabilities +- `cron-scheduler`: No requirement changes — TUI refactoring is orthogonal to scheduling + +## Impact + +- **Affected code**: `src/tui/app.js` (major refactor), `src/tui/panels.js`, `src/tui/skillsPanel.js`, `src/tui/memoryPanel.js`, `src/tui/settingsPanel.js` (removal), `src/tui/commandParser.js` (rewrite), all TUI component files (relocation) +- **Tests**: All existing TUI tests must pass; new tests for reducer, streaming hook, command registry +- **Breaking**: Panel system removal changes internal component tree; no external API changes diff --git a/openspec/changes/refactor-tui/specs/tui-command-registry/spec.md b/openspec/changes/refactor-tui/specs/tui-command-registry/spec.md new file mode 100644 index 0000000..4754cc3 --- /dev/null +++ b/openspec/changes/refactor-tui/specs/tui-command-registry/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Command parser uses event-driven registry +The command parser SHALL be refactored from a switch-driven dispatch table to an event-driven command registry where commands are registered as objects with `validate`, `execute`, and `help` properties. + +#### Scenario: Command registration schema +- **WHEN** a new command is registered +- **THEN** it is an object with: `name: string`, `description: string`, `usage: string`, `validate: (args) => boolean | string`, `execute: (args, state, dispatch, helpers) => Promise | void` + +#### Scenario: Command validation +- **WHEN** a command is invoked with arguments +- **THEN** the registry calls `validate(args)` before executing +- **WHEN** validation returns a string (error message) +- **THEN** the error message is displayed to the user and execution is skipped + +#### Scenario: Command execution +- **WHEN** a command passes validation +- **THEN** the registry calls `execute(args, state, dispatch, helpers)` with access to TUI state, dispatch, and command helpers + +#### Scenario: Unknown command handling +- **WHEN** a command is not found in the registry +- **THEN** the TUI displays "Unknown command: /. Type /help for available commands." + +#### Scenario: Help command +- **WHEN** the user types `/help` +- **THEN** the TUI displays all registered commands grouped by category (Chat, Command, etc.) + +### Requirement: Command helpers provide necessary context +Commands SHALL receive a `CommandHelpers` object providing access to `dispatchProvider`, `sessionState`, `config`, and `scrollRef`. + +#### Scenario: Command can dispatch provider +- **WHEN** a command needs to send a message to the agent +- **THEN** it uses `helpers.dispatchProvider` to trigger the streaming pipeline + +#### Scenario: Command can access session state +- **WHEN** a command needs to read or modify session data +- **THEN** it uses `helpers.sessionState` to interact with the session manager + +#### Scenario: Command can access config +- **WHEN** a command needs to read or modify configuration +- **THEN** it uses `helpers.config` to access the current configuration + +#### Scenario: Command can control scroll +- **WHEN** a command needs to scroll the viewport +- **THEN** it uses `helpers.scrollRef` to call `scrollToBottom()` or `scrollBy()` diff --git a/openspec/changes/refactor-tui/specs/tui-file-structure/spec.md b/openspec/changes/refactor-tui/specs/tui-file-structure/spec.md new file mode 100644 index 0000000..4e5c0d2 --- /dev/null +++ b/openspec/changes/refactor-tui/specs/tui-file-structure/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: TUI files are organized by concern +The TUI file structure SHALL be reorganized from a flat layout into grouped directories: `state/`, `hooks/`, `components/`, `utils/`, with `panels/` retained only for `OnboardingPanel.js`. + +#### Scenario: State files are grouped +- **WHEN** the TUI directory is inspected +- **THEN** `state/reducer.js`, `state/types.js`, and `state/selectors.js` exist + +#### Scenario: Hook files are grouped +- **WHEN** the TUI directory is inspected +- **THEN** `hooks/useStreaming.js`, `hooks/useScroll.js`, `hooks/useInput.js`, and `hooks/useCommand.js` exist + +#### Scenario: Component files are grouped +- **WHEN** the TUI directory is inspected +- **THEN** `components/ConversationPanel.js`, `components/InputPanel.js`, `components/StatusBar.js`, `components/MessageBubble.js`, and `components/Banner.js` exist + +#### Scenario: Utility files are grouped +- **WHEN** the TUI directory is inspected +- **THEN** `utils/commandParser.js`, `utils/contextTokens.js`, `utils/markdownText.js`, and `utils/format.js` exist + +#### Scenario: Root files remain at top level +- **WHEN** the TUI directory is inspected +- **THEN** `app.js` (root component with reducer), `index.js` (entry point) remain at the top level + +### Requirement: All imports are updated to reflect new structure +All internal imports within the TUI SHALL reference the new file paths after reorganization. + +#### Scenario: Component imports use new paths +- **WHEN** `app.js` imports components +- **THEN** it uses `./components/ConversationPanel`, `./components/StatusBar`, etc. + +#### Scenario: Hook imports use new paths +- **WHEN** `app.js` imports hooks +- **THEN** it uses `./hooks/useStreaming`, `./hooks/useScroll`, etc. + +#### Scenario: Utility imports use new paths +- **WHEN** components import utilities +- **THEN** they use `../utils/contextTokens`, `../utils/markdownText`, etc. diff --git a/openspec/changes/refactor-tui/specs/tui-panel-removal/spec.md b/openspec/changes/refactor-tui/specs/tui-panel-removal/spec.md new file mode 100644 index 0000000..e6f4e71 --- /dev/null +++ b/openspec/changes/refactor-tui/specs/tui-panel-removal/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Panel system is removed +The panel system (`panels.js`, `skillsPanel.js`, `memoryPanel.js`, `settingsPanel.js`) SHALL be removed entirely, as it contradicts the blueprint's philosophy of "no panels, no tabs, no switching." + +#### Scenario: Panel files are deleted +- **WHEN** the refactoring is complete +- **THEN** `src/tui/panels.js`, `src/tui/skillsPanel.js`, `src/tui/memoryPanel.js`, and `src/tui/settingsPanel.js` no longer exist + +#### Scenario: Panel imports are removed +- **WHEN** `app.js` is inspected +- **THEN** it contains no imports or references to the removed panel files + +#### Scenario: OnboardingPanel is retained +- **WHEN** the TUI directory is inspected +- **THEN** `panels/OnboardingPanel.js` still exists (it is a first-run flow, not a navigation surface) + +### Requirement: Panel functionality moves to commands +All functionality previously provided by panels SHALL be accessible via commands that produce output in the conversation stream. + +#### Scenario: Skills inspection via command +- **WHEN** the user types `/skills` +- **THEN** the TUI displays a list of registered skills in the conversation stream + +#### Scenario: Memory inspection via command +- **WHEN** the user types `/memory` +- **THEN** the TUI displays memory context information in the conversation stream + +#### Scenario: Config inspection via command +- **WHEN** the user types `/config` +- **THEN** the TUI displays current configuration in the conversation stream diff --git a/openspec/changes/refactor-tui/specs/tui-runtime-toggles/spec.md b/openspec/changes/refactor-tui/specs/tui-runtime-toggles/spec.md new file mode 100644 index 0000000..20c4d6c --- /dev/null +++ b/openspec/changes/refactor-tui/specs/tui-runtime-toggles/spec.md @@ -0,0 +1,72 @@ +## ADDED Requirements + +### Requirement: Runtime toggle system via /toggle commands +The TUI SHALL provide runtime toggle commands that allow users to override `config.yaml` defaults without editing configuration files. + +#### Scenario: Toggle a setting on/off +- **WHEN** the user types `/toggle timestamps` +- **THEN** the `timestamps` toggle is turned off +- **WHEN** the user types `/toggle timestamps` again +- **THEN** the `timestamps` toggle is turned back on + +#### Scenario: Show all toggle states +- **WHEN** the user types `/toggle` (no arguments) +- **THEN** the TUI displays all toggles and their current states + +#### Scenario: Toggle overrides config defaults +- **WHEN** a toggle is changed via `/toggle` +- **THEN** the override takes effect immediately in the UI +- **WHEN** the application restarts +- **THEN** the toggle reverts to the `config.yaml` default (overrides are in-memory only) + +### Requirement: Five toggles are supported +The toggle system SHALL support exactly five toggles with the specified defaults: + +| Toggle | Default | Description | +|--------|---------|-------------| +| `autoScroll` | `true` | Auto-scroll to bottom on new messages | +| `timestamps` | `true` | Show timestamps on messages | +| `commandEcho` | `true` | Echo user commands to output | +| `cursorBreathe` | `true` | Enable breathing cursor model | +| `debugOutput` | `false` | Show debug-level messages | + +#### Scenario: autoScroll toggle +- **WHEN** `autoScroll` is `true` +- **THEN** new messages trigger `scrollToBottom()` +- **WHEN** `autoScroll` is `false` +- **THEN** the user's scroll position is preserved on new messages + +#### Scenario: timestamps toggle +- **WHEN** `timestamps` is `true` +- **THEN** messages display with `[HH:MM]` timestamps +- **WHEN** `timestamps` is `false` +- **THEN** messages display without timestamps + +#### Scenario: commandEcho toggle +- **WHEN** `commandEcho` is `true` +- **THEN** user commands are echoed to the output stream +- **WHEN** `commandEcho` is `false` +- **THEN** user commands are not echoed + +#### Scenario: cursorBreathe toggle +- **WHEN** `cursorBreathe` is `true` +- **THEN** the cursor fades to dark gray after 2 seconds of idle +- **WHEN** `cursorBreathe` is `false` +- **THEN** the cursor remains fully visible + +#### Scenario: debugOutput toggle +- **WHEN** `debugOutput` is `true` +- **THEN** debug-level messages appear in the output stream +- **WHEN** `debugOutput` is `false` +- **THEN** debug-level messages are hidden (default) + +### Requirement: Toggle indicators appear in status bar +The status bar SHALL display toggle indicators showing which runtime features are active. + +#### Scenario: Status bar shows toggle indicators +- **WHEN** the status bar renders +- **THEN** it displays toggle indicators in the format `[ts:1 scroll:1]` where `1` means enabled and `0` means disabled + +#### Scenario: Indicators update on toggle change +- **WHEN** a toggle is changed via `/toggle` +- **THEN** the status bar indicators update immediately diff --git a/openspec/changes/refactor-tui/specs/tui-state-management/spec.md b/openspec/changes/refactor-tui/specs/tui-state-management/spec.md new file mode 100644 index 0000000..c1da4ca --- /dev/null +++ b/openspec/changes/refactor-tui/specs/tui-state-management/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: TUI state is managed by a single useReducer +The TUI SHALL consolidate all state into a single `useReducer` with a `TUIState` interface, replacing the current eight independent `useState` calls. The reducer SHALL handle all state transitions through typed actions. + +#### Scenario: State initialization +- **WHEN** the TUI mounts +- **THEN** `useReducer` initializes with `initialState` containing all fields: `messages`, `chatHistory`, `historyIndex`, `inputText`, `inputFocused`, `statusMessage`, `contextSize`, `isCompacting`, `isStreaming`, `isAutoContinuing`, `autoContinueCount`, `scrollOffset`, `viewportHeight`, and `toggles` + +#### Scenario: Message addition via reducer +- **WHEN** a message arrives via streaming callback +- **THEN** the reducer processes an `ADD_MESSAGE` or `UPDATE_MESSAGE` action and updates the messages array in a single state transition + +#### Scenario: Input state updates +- **WHEN** the user types in the input field +- **THEN** the reducer processes a `SET_INPUT_TEXT` action and updates `inputText` without affecting other state fields + +#### Scenario: Concurrent state updates +- **WHEN** a message arrives and context size changes simultaneously +- **THEN** both updates are batched into a single reducer call, producing one render cycle + +### Requirement: TUIState interface defines all state shape +The `TUIState` interface SHALL define all state fields with correct types, and `initialState` SHALL provide default values matching the blueprint. + +#### Scenario: TUIState includes all current state fields +- **WHEN** the types file is inspected +- **THEN** `TUIState` includes: `messages: Message[]`, `chatHistory: string[]`, `historyIndex: number`, `inputText: string`, `inputFocused: boolean`, `statusMessage: string`, `contextSize: number`, `isCompacting: boolean`, `isStreaming: boolean`, `isAutoContinuing: boolean`, `autoContinueCount: number`, `scrollOffset: number`, `viewportHeight: number`, `toggles: ToggleConfig` + +#### Scenario: Toggle config has all five toggles +- **WHEN** the `Toggles` type is inspected +- **THEN** it includes: `autoScroll: boolean`, `timestamps: boolean`, `commandEcho: boolean`, `cursorBreathe: boolean`, `debugOutput: boolean` + +### Requirement: Typed action types cover all state transitions +All state transitions SHALL go through typed actions defined in `TUIAction`, with no direct state mutations. + +#### Scenario: Message actions +- **WHEN** messages need to be added, updated, or cleared +- **THEN** the reducer handles `ADD_MESSAGE`, `UPDATE_MESSAGE`, and `CLEAR_MESSAGES` actions + +#### Scenario: Input actions +- **WHEN** the user types, submits, or changes focus +- **THEN** the reducer handles `SET_INPUT_TEXT`, `SUBMIT_INPUT`, and `SET_INPUT_FOCUSED` actions + +#### Scenario: Status actions +- **WHEN** status changes (compacting, streaming, context size) +- **THEN** the reducer handles `SET_STATUS`, `SET_CONTEXT_SIZE`, `SET_COMPACTING`, `SET_STREAMING`, `SET_AUTO_CONTINUING`, `INCREMENT_AUTO_CONTINUE`, and `RESET_AUTO_CONTINUE` actions + +#### Scenario: Scroll actions +- **WHEN** scroll position or viewport changes +- **THEN** the reducer handles `SET_SCROLL_OFFSET` and `SET_VIEWPORT_HEIGHT` actions + +#### Scenario: Config actions +- **WHEN** runtime toggles change +- **THEN** the reducer handles `TOGGLE_CONFIG` and `SET_CONFIG` actions + +### Requirement: Selectors derive computed state +Derived state values SHALL be computed via selector functions, not stored redundantly in state. + +#### Scenario: Status message derivation +- **WHEN** `isCompacting` is true +- **THEN** the status selector returns "Compacting context..." + +#### Scenario: Context size formatting +- **WHEN** context size is 1200 tokens +- **THEN** the context size selector returns "1.2k" (human-readable format) diff --git a/openspec/changes/refactor-tui/specs/tui-streaming-hook/spec.md b/openspec/changes/refactor-tui/specs/tui-streaming-hook/spec.md new file mode 100644 index 0000000..3dc1338 --- /dev/null +++ b/openspec/changes/refactor-tui/specs/tui-streaming-hook/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Streaming logic is extracted into useStreaming hook +The streaming callback logic SHALL be extracted into a `useStreaming()` hook that manages the AbortController lifecycle, translates stream events into state transitions, and handles the auto-continue circuit breaker. + +#### Scenario: AbortController lifecycle management +- **WHEN** a streaming session starts +- **THEN** `useStreaming` creates a new `AbortController` and exposes its signal to the dispatch layer + +#### Scenario: Stream event transformation +- **WHEN** a `text` event arrives from the stream +- **THEN** the hook updates the last message's `content` and sets `streaming: true` + +#### Scenario: Reasoning event handling +- **WHEN** a `reasoning` event arrives from the stream +- **THEN** the hook updates the last message's `reasoningContent` + +#### Scenario: Tool call event handling +- **WHEN** a `tool_start` event arrives +- **THEN** the hook sets `activeToolCall` on the last message +- **WHEN** a `tool_end` event arrives +- **THEN** the hook clears `activeToolCall` and appends to `toolCallDisplay` +- **WHEN** a `tool_error` event arrives +- **THEN** the hook clears `activeToolCall` and appends the error to `toolCallDisplay` + +#### Scenario: Compaction event handling +- **WHEN** a `compaction_start` event arrives +- **THEN** the hook sets `isCompacting: true` and `statusMessage: "Compacting context..."` +- **WHEN** a `compaction_end` event arrives +- **THEN** the hook sets `isCompacting: false` and `statusMessage: "Ready"` + +#### Scenario: Todo status event handling +- **WHEN** a `todo_status` event arrives +- **THEN** the hook updates the last message's `toolCallDisplay` with the status + +### Requirement: Auto-continue circuit breaker is managed by the hook +The auto-continue circuit breaker SHALL be managed within `useStreaming`, tracking consecutive empty responses and triggering the circuit breaker after the configured limit. + +#### Scenario: Auto-continue triggers on empty response +- **WHEN** the agent returns zero text output +- **THEN** the hook sends a "Please continue." signal and increments `autoContinueCount` + +#### Scenario: Circuit breaker triggers after limit +- **WHEN** `autoContinueCount` reaches `config.agent.autoContinueLimit` (default 1000) +- **THEN** the hook triggers a circuit breaker error and resets the counter + +#### Scenario: Counter resets on text output +- **WHEN** any text output arrives during auto-continue +- **THEN** the `autoContinueCount` resets to 0 and `isAutoContinuing` is set to false + +### Requirement: Hook exposes clean streaming state to UI +The `useStreaming` hook SHALL expose a `streamingState` object to the UI that contains all streaming-related display state, separating streaming concerns from the UI. + +#### Scenario: streamingState contains display fields +- **WHEN** the hook is called +- **THEN** it returns a `streamingState` object containing: `isStreaming`, `isAutoContinuing`, `autoContinueCount`, and `statusMessage` + +#### Scenario: Hook provides abort method +- **WHEN** the UI calls the hook's `abort()` method +- **THEN** the AbortController is aborted and streaming is interrupted diff --git a/openspec/changes/refactor-tui/tasks.md b/openspec/changes/refactor-tui/tasks.md new file mode 100644 index 0000000..faee7df --- /dev/null +++ b/openspec/changes/refactor-tui/tasks.md @@ -0,0 +1,103 @@ +## 1. Setup — Types and State Interface + +- [ ] 1.1 Create `src/tui/state/types.js` with `TUIState` interface, `TUIAction` type union, `Message` interface, and `ToggleConfig` type +- [ ] 1.2 Define `initialState` object in `types.js` with all fields and correct defaults +- [ ] 1.3 Create `src/tui/state/selectors.js` with `getStatusMessage`, `formatContextSize`, and derived state selectors + +## 2. Reducer Implementation + +- [ ] 2.1 Create `src/tui/state/reducer.js` with `tuiReducer` handling all action types +- [ ] 2.2 Implement message actions: `ADD_MESSAGE`, `UPDATE_MESSAGE`, `CLEAR_MESSAGES` +- [ ] 2.3 Implement input actions: `SET_INPUT_TEXT`, `SUBMIT_INPUT`, `SET_INPUT_FOCUSED` +- [ ] 2.4 Implement history actions: `ADD_HISTORY`, `SET_HISTORY_INDEX` +- [ ] 2.5 Implement status actions: `SET_STATUS`, `SET_CONTEXT_SIZE`, `SET_COMPACTING` +- [ ] 2.6 Implement streaming actions: `SET_STREAMING`, `SET_AUTO_CONTINUING`, `INCREMENT_AUTO_CONTINUE`, `RESET_AUTO_CONTINUE` +- [ ] 2.7 Implement scroll actions: `SET_SCROLL_OFFSET`, `SET_VIEWPORT_HEIGHT` +- [ ] 2.8 Implement config actions: `TOGGLE_CONFIG`, `SET_CONFIG` +- [ ] 2.9 Write unit tests for reducer in `tests/unit/tui/reducer.test.js` + +## 3. Streaming Hook + +- [ ] 3.1 Create `src/tui/hooks/useStreaming.js` with `useStreaming` hook +- [ ] 3.2 Implement AbortController lifecycle: create on start, abort on interrupt +- [ ] 3.3 Implement stream event transformation: `text`, `reasoning`, `tool_start`, `tool_end`, `tool_error`, `compaction_start`, `compaction_end`, `todo_status` +- [ ] 3.4 Implement auto-continue circuit breaker with configurable limit +- [ ] 3.5 Expose `streamingState` object and `abort()` method from hook +- [ ] 3.6 Write unit tests for streaming hook in `tests/unit/tui/useStreaming.test.js` + +## 4. Scroll and Input Hooks + +- [ ] 4.1 Create `src/tui/hooks/useScroll.js` with ScrollView ref, resize handling, keyboard scroll +- [ ] 4.2 Create `src/tui/hooks/useInput.js` with keyboard routing (scroll vs history vs input) +- [ ] 4.3 Wire `useInput` to handle arrow keys, PageUp/PageDown, Tab, Escape, Enter + +## 5. Command Registry + +- [ ] 5.1 Create `src/tui/utils/commandParser.js` with event-driven command registry +- [ ] 5.2 Define `Command` interface with `name`, `description`, `usage`, `validate`, `execute` +- [ ] 5.3 Define `CommandHelpers` interface with `dispatchProvider`, `sessionState`, `config`, `scrollRef` +- [ ] 5.4 Register all existing commands: `/quit`, `/clear`, `/new`, `/help`, `/config`, `/provider`, `/schedule`, `/gc` +- [ ] 5.5 Implement unknown command handling with helpful error message +- [ ] 5.6 Implement `/help` command with grouped command display +- [ ] 5.7 Write unit tests for command parser in `tests/unit/tui/commandParser.test.js` + +## 6. Runtime Toggles + +- [ ] 6.1 Create `src/tui/utils/format.js` with toggle logic and format specifier utilities +- [ ] 6.2 Implement `/toggle` command: toggle on/off with no args, show all states with no params +- [ ] 6.3 Implement toggle state in reducer (already done in step 2) +- [ ] 6.4 Wire toggle state to streaming hook (autoScroll affects scroll behavior) +- [ ] 6.5 Wire toggle state to message rendering (timestamps, commandEcho, debugOutput) +- [ ] 6.6 Wire toggle state to cursor behavior (cursorBreathe) + +## 7. Status Bar Toggle Indicators + +- [ ] 7.1 Update `StatusBar` component to display toggle indicators `[ts:1 scroll:1]` +- [ ] 7.2 Indicators update reactively when toggles change +- [ ] 7.3 `1` = enabled, `0` = disabled + +## 8. File Structure Reorganization + +- [ ] 8.1 Create directory structure: `state/`, `hooks/`, `components/`, `utils/`, `panels/` +- [ ] 8.2 Move `MessageBubble.js` → `components/MessageBubble.js` +- [ ] 8.3 Move `ConversationPanel.js` → `components/ConversationPanel.js` +- [ ] 8.4 Move `InputPanel.js` → `components/InputPanel.js` +- [ ] 8.5 Move `StatusBar.js` → `components/StatusBar.js` +- [ ] 8.6 Move `Banner.js` → `components/Banner.js` +- [ ] 8.7 Move `OnboardingPanel.js` → `panels/OnboardingPanel.js` +- [ ] 8.8 Move `contextTokens.js` → `utils/contextTokens.js` +- [ ] 8.9 Move `markdownText.js` → `utils/markdownText.js` +- [ ] 8.10 Update all import paths throughout the TUI to reflect new structure + +## 9. Panel Removal + +- [ ] 9.1 Delete `src/tui/panels.js` +- [ ] 9.2 Delete `src/tui/skillsPanel.js` +- [ ] 9.3 Delete `src/tui/memoryPanel.js` +- [ ] 9.4 Delete `src/tui/settingsPanel.js` +- [ ] 9.5 Remove all panel imports from `app.js` +- [ ] 9.6 Add `/skills` command that displays registered skills in conversation stream +- [ ] 9.7 Add `/memory` command that displays memory context in conversation stream +- [ ] 9.8 Add `/config` command that displays current config in conversation stream + +## 10. App Integration + +- [ ] 10.1 Replace `useState` calls in `app.js` with `useReducer` +- [ ] 10.2 Integrate `useStreaming` hook into `app.js` +- [ ] 10.3 Integrate `useScroll` hook into `app.js` +- [ ] 10.4 Integrate `useInput` hook into `app.js` +- [ ] 10.5 Integrate `useCommand` hook into `app.js` +- [ ] 10.6 Wire command registry to input submission +- [ ] 10.7 Wire toggle state to all dependent components +- [ ] 10.8 Update component imports to use new file structure paths + +## 11. Testing + +- [ ] 11.1 Write `tests/unit/tui/reducer.test.js` — all action types, edge cases +- [ ] 11.2 Write `tests/unit/tui/commandParser.test.js` — command validation, execution, unknown commands +- [ ] 11.3 Write `tests/unit/tui/useStreaming.test.js` — event transformation, auto-continue, abort +- [ ] 11.4 Write `tests/unit/tui/contextTokens.test.js` — tiktoken calculation, fallback +- [ ] 11.5 Write `tests/unit/tui/markdownText.test.js` — markdown rendering, cursor stripping, cache +- [ ] 11.6 Write `tests/integration/tui/full-flow.test.js` — user input → streaming → display → commands +- [ ] 11.7 Verify all existing TUI tests still pass +- [ ] 11.8 Achieve 100% coverage on all new files diff --git a/src/tui/hooks/useInput.js b/src/tui/hooks/useInput.js new file mode 100644 index 0000000..8de326a --- /dev/null +++ b/src/tui/hooks/useInput.js @@ -0,0 +1,141 @@ +/** + * Input hook for TUI. + * Routes keyboard input between scroll, history navigation, and input fields. + */ +import { useCallback } from "react"; + +/** + * Hook that manages keyboard input routing for the TUI. + * Determines whether to handle input as scroll, history navigation, + * or text input based on focus state and current context. + * + * @param {Object} options + * @param {boolean} options.inputFocused - Whether the input field has focus + * @param {string} options.inputText - Current input text + * @param {Function} options.setInputText - Setter for input text + * @param {string[]} options.chatHistory - Chat history array + * @param {number} options.historyIndex - Current history index (-1 = none) + * @param {Function} options.setHistoryIndex - Setter for history index + * @param {Function} options.handleSubmit - Submit handler for input + * @param {Function} options.handleInterrupt - Interrupt handler for streaming + * @param {Function} options.handleQuit - Quit handler + * @param {boolean} options.isStreaming - Whether currently streaming + * @param {Function} options.setInputFocused - Setter for input focus + * @param {Function} options.scrollUp - Scroll up handler + * @param {Function} options.scrollDown - Scroll down handler + * @param {Function} options.pageUp - Page up handler + * @param {Function} options.pageDown - Page down handler + * @returns {Function} Input handler suitable for use with ink's useInput + */ +export function useInput(options) { + const { + inputFocused, + inputText, + setInputText, + chatHistory, + historyIndex, + setHistoryIndex, + handleSubmit, + handleInterrupt, + handleQuit, + isStreaming, + setInputFocused, + scrollUp, + scrollDown, + pageUp, + pageDown, + } = options; + + /** + * Handle a single keystroke. + * @param {string} input - The input character + * @param {Object} key - Key details from ink + * @param {boolean} [key.return] - Whether Enter was pressed + * @param {boolean} [key.shift] - Whether Shift was held + * @param {boolean} [key.escape] - Whether Escape was pressed + * @param {boolean} [key.upArrow] - Whether Up arrow was pressed + * @param {boolean} [key.downArrow] - Whether Down arrow was pressed + * @param {boolean} [key.backspace] - Whether Backspace was pressed + * @param {boolean} [key.tab] - Whether Tab was pressed + * @param {boolean} [key.pageUp] - Whether PageUp was pressed + * @param {boolean} [key.pageDown] - Whether PageDown was pressed + */ + const handleKeystroke = useCallback( + (input, key) => { + // Tab toggles focus + if (input === "\t" || key.tab) { + setInputFocused((prev) => !prev); + return; + } + + if (inputFocused) { + // ── Input field is focused ────────────────────────────── + + if (key.escape) { + if (isStreaming) { + handleInterrupt(); + } else { + handleQuit(); + } + } else if (key.return && !key.shift) { + handleSubmit(inputText); + } else if (key.upArrow && chatHistory.length > 0) { + const newIndex = + historyIndex === -1 + ? chatHistory.length - 1 + : Math.max(0, historyIndex - 1); + setHistoryIndex(newIndex); + setInputText(chatHistory[newIndex]); + } else if (key.downArrow) { + if (historyIndex === -1) return; + const nextIndex = historyIndex + 1; + if (nextIndex >= chatHistory.length) { + setHistoryIndex(-1); + setInputText(""); + } else { + setHistoryIndex(nextIndex); + setInputText(chatHistory[nextIndex]); + } + } else if (key.backspace && inputText.length > 0) { + setInputText((prev) => prev.slice(0, -1)); + } else if (input && input !== "\r") { + setInputText((prev) => prev + input); + } + } else { + // ── Input field is NOT focused (scroll mode) ──────────── + + if (key.escape) { + if (isStreaming) { + handleInterrupt(); + } else { + handleQuit(); + } + } else { + if (key.upArrow) scrollUp(); + if (key.downArrow) scrollDown(); + if (key.pageUp) pageUp(); + if (key.pageDown) pageDown(); + } + } + }, + [ + inputFocused, + inputText, + setInputText, + chatHistory, + historyIndex, + setHistoryIndex, + handleSubmit, + handleInterrupt, + handleQuit, + isStreaming, + setInputFocused, + scrollUp, + scrollDown, + pageUp, + pageDown, + ], + ); + + return handleKeystroke; +} diff --git a/src/tui/hooks/useScroll.js b/src/tui/hooks/useScroll.js new file mode 100644 index 0000000..0a8dba3 --- /dev/null +++ b/src/tui/hooks/useScroll.js @@ -0,0 +1,152 @@ +/** + * Scroll hook for TUI. + * Manages ScrollView ref, terminal resize handling, and keyboard scroll. + */ +import { useRef, useCallback, useEffect } from "react"; +import { useStdout } from "ink"; + +/** + * Hook that manages scroll state and behavior for the conversation panel. + * @param {Object} [options] + * @param {boolean} [options.autoScroll=true] - Whether to auto-scroll on new messages + * @returns {Object} Scroll management API + */ +export function useScroll(options = {}) { + const { autoScroll = true } = options; + const scrollRef = useRef(null); + const previousMessageCount = useRef(0); + const previousContentHashRef = useRef(0); + const { stdout } = useStdout(); + + /** + * Handle terminal resize by remeasuring content heights. + */ + useEffect(() => { + const resizeHandler = () => { + if (scrollRef.current && stdout.isTTY && !process.env.CI) { + scrollRef.current.remeasure(); + } + }; + stdout.on("resize", resizeHandler); + return () => { + stdout.off("resize", resizeHandler); + }; + }, [stdout]); + + /** + * Scroll to bottom if auto-scroll is enabled and content changed. + * @param {number} messageCount - Current message count + * @param {boolean} isStreaming - Whether currently streaming + * @param {string} streamingContent - Current streaming content length + */ + const maybeScrollToBottom = useCallback( + (messageCount, isStreaming, streamingContentLen) => { + if (!autoScroll || !scrollRef.current) return; + + const contentHash = messageCount + (isStreaming ? streamingContentLen : 0); + const shouldScroll = + messageCount > previousMessageCount.current || + (isStreaming && contentHash !== previousContentHashRef.current); + + if (shouldScroll) { + if (scrollRef.current.remeasure) { + scrollRef.current.remeasure(); + } + const scrollHandle = () => { + if (scrollRef.current && scrollRef.current.scrollToBottom) { + scrollRef.current.scrollToBottom(); + previousMessageCount.current = messageCount; + } + }; + const timer = setTimeout(scrollHandle, 0); + return () => clearTimeout(timer); + } + + previousContentHashRef.current = contentHash; + }, + [autoScroll], + ); + + /** + * Scroll up by the specified amount. + * @param {number} [amount=1] - Lines to scroll up + */ + const scrollUp = useCallback((amount = 1) => { + if (scrollRef.current && scrollRef.current.scrollBy) { + scrollRef.current.scrollBy(-amount); + } + }, []); + + /** + * Scroll down by the specified amount. + * @param {number} [amount=1] - Lines to scroll down + */ + const scrollDown = useCallback((amount = 1) => { + if (scrollRef.current && scrollRef.current.scrollBy) { + scrollRef.current.scrollBy(amount); + } + }, []); + + /** + * Page up by one viewport. + */ + const pageUp = useCallback(() => { + if (scrollRef.current && scrollRef.current.getViewportHeight) { + const height = scrollRef.current.getViewportHeight() || 1; + scrollRef.current.scrollBy(-height); + } + }, []); + + /** + * Page down by one viewport. + */ + const pageDown = useCallback(() => { + if (scrollRef.current && scrollRef.current.getViewportHeight) { + const height = scrollRef.current.getViewportHeight() || 1; + scrollRef.current.scrollBy(height); + } + }, []); + + /** + * Get the current scroll offset. + * @returns {number} + */ + const getScrollOffset = useCallback(() => { + if (scrollRef.current && scrollRef.current.getScrollOffset) { + return scrollRef.current.getScrollOffset(); + } + return 0; + }, []); + + /** + * Get the viewport height. + * @returns {number} + */ + const getViewportHeight = useCallback(() => { + if (scrollRef.current && scrollRef.current.getViewportHeight) { + return scrollRef.current.getViewportHeight(); + } + return 0; + }, []); + + /** + * Re-measure content heights. + */ + const remeasure = useCallback(() => { + if (scrollRef.current && scrollRef.current.remeasure) { + scrollRef.current.remeasure(); + } + }, []); + + return { + scrollRef, + maybeScrollToBottom, + scrollUp, + scrollDown, + pageUp, + pageDown, + getScrollOffset, + getViewportHeight, + remeasure, + }; +} diff --git a/src/tui/hooks/useStreaming.js b/src/tui/hooks/useStreaming.js new file mode 100644 index 0000000..b3ce6c5 --- /dev/null +++ b/src/tui/hooks/useStreaming.js @@ -0,0 +1,329 @@ +/** + * Streaming hook for TUI. + * Manages AbortController lifecycle, translates stream events into + * state transitions, and handles the auto-continue circuit breaker. + * + * This replaces the inline streaming callback logic that was previously + * duplicated in handleChat() and handleCommand(). + */ +import { useRef, useCallback, useMemo } from "react"; + +/** + * Streaming state object exposed by the useStreaming hook. + * @typedef {Object} StreamingState + * @property {boolean} isStreaming - Whether a stream is currently active + * @property {boolean} isAutoContinuing - Whether auto-continue is active + * @property {number} autoContinueCount - Consecutive empty response count + * @property {string} committedContent - Accumulated text content + * @property {string} committedReasoning - Accumulated reasoning content + * @property {string} lastToolCallDisplay - Last tool call display string + * @property {string} todoStatusLines - Todo status lines + */ + +/** + * Create a streaming hook instance. + * @returns {Object} Hook instance with state and methods + */ +export function useStreaming() { + // Refs for mutable streaming state (avoids React re-render cycles during streaming) + const abortControllerRef = useRef(null); + const isStreamingRef = useRef(false); + const isAutoContinuingRef = useRef(false); + const autoContinueCountRef = useRef(0); + const dispatchPromiseRef = useRef(null); + const committedContentRef = useRef(""); + const committedReasoningRef = useRef(""); + const lastToolCallDisplayRef = useRef(""); + const todoStatusLinesRef = useRef(""); + + /** + * Create a streaming state snapshot. + * @returns {StreamingState} + */ + const getStreamingState = useCallback(() => { + return { + isStreaming: isStreamingRef.current, + isAutoContinuing: isAutoContinuingRef.current, + autoContinueCount: autoContinueCountRef.current, + committedContent: committedContentRef.current, + committedReasoning: committedReasoningRef.current, + lastToolCallDisplay: lastToolCallDisplayRef.current, + todoStatusLines: todoStatusLinesRef.current, + }; + }, []); + + /** + * Reset all streaming refs to initial state. + */ + const resetStreaming = useCallback(() => { + abortControllerRef.current = null; + isStreamingRef.current = false; + isAutoContinuingRef.current = false; + autoContinueCountRef.current = 0; + dispatchPromiseRef.current = null; + committedContentRef.current = ""; + committedReasoningRef.current = ""; + lastToolCallDisplayRef.current = ""; + todoStatusLinesRef.current = ""; + }, []); + + /** + * Abort the current stream. + * @returns {void} + */ + const abort = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + isStreamingRef.current = false; + }, []); + + /** + * Check if the current stream should be aborted. + * @returns {boolean} + */ + const shouldAbort = useCallback(() => { + return !!(abortControllerRef.current?.signal?.aborted); + }, []); + + /** + * Create a streaming event transformer callback. + * This callback is passed to dispatchProvider and updates + * committed content, tool call display, and todo status. + * @param {Function} setMessageCallback - React setState callback for message updates + * @returns {Function} Event transformer callback + */ + const createEventTransformer = useCallback( + (setMessageCallback) => { + return (event) => { + if (shouldAbort()) return; + + try { + if (event.type === "text") { + committedContentRef.current = + (committedContentRef.current || "") + event.text; + setMessageCallback((prev) => { + const cloned = [...prev]; + const last = cloned[cloned.length - 1]; + if (last.role === "assistant" && last.streaming) { + last.content = committedContentRef.current + "\u2588"; + } + return cloned; + }); + } else if (event.type === "reasoning") { + committedReasoningRef.current = + (committedReasoningRef.current || "") + event.text; + setMessageCallback((prev) => { + const cloned = [...prev]; + const last = cloned[cloned.length - 1]; + if (last.role === "assistant" && last.streaming) { + last.reasoningContent = + (committedReasoningRef.current || "") + "\u2588"; + } + return cloned; + }); + } else if (event.type === "tool_start") { + setMessageCallback((prev) => { + const cloned = [...prev]; + const last = cloned[cloned.length - 1]; + if (last.role === "assistant" && last.streaming) { + last.activeToolCall = { name: event.toolName }; + last.toolCallDisplay = lastToolCallDisplayRef.current; + } + return cloned; + }); + } else if (event.type === "tool_end") { + const resultLine = event.data + ? ` Result: ${JSON.stringify(event.data).slice(0, 200)}` + : ""; + const displayLine = event.toolName + ? `- Tool: ${event.toolName}${resultLine}` + : `- Tool: ${event.toolCallId || "unknown"}${resultLine}`; + lastToolCallDisplayRef.current = + (lastToolCallDisplayRef.current + ? lastToolCallDisplayRef.current + "\n" + : "") + displayLine; + setMessageCallback((prev) => { + const cloned = [...prev]; + const last = cloned[cloned.length - 1]; + if (last.role === "assistant" && last.streaming) { + last.activeToolCall = null; + last.toolCallDisplay = lastToolCallDisplayRef.current; + } + return cloned; + }); + } else if (event.type === "tool_error") { + const errorLine = event.toolName + ? `- Tool: ${event.toolName} (error: ${event.error})` + : `- Tool call failed (${event.toolCallId || "unknown"})`; + lastToolCallDisplayRef.current = + (lastToolCallDisplayRef.current + ? lastToolCallDisplayRef.current + "\n" + : "") + errorLine; + setMessageCallback((prev) => { + const cloned = [...prev]; + const last = cloned[cloned.length - 1]; + if (last.role === "assistant" && last.streaming) { + last.activeToolCall = null; + last.toolCallDisplay = lastToolCallDisplayRef.current; + } + return cloned; + }); + } else if (event.type === "compaction_start") { + setMessageCallback((prev) => { + const cloned = [...prev]; + const last = cloned[cloned.length - 1]; + if (last.role === "assistant" && last.streaming) { + last.activeToolCall = null; + last.toolCallDisplay = lastToolCallDisplayRef.current + ? lastToolCallDisplayRef.current + "\nCompacting context..." + : "Compacting context..."; + } + return cloned; + }); + } else if (event.type === "compaction_end") { + setMessageCallback((prev) => { + const cloned = [...prev]; + const last = cloned[cloned.length - 1]; + if (last.role === "assistant" && last.streaming) { + last.activeToolCall = null; + last.toolCallDisplay = lastToolCallDisplayRef.current; + } + return cloned; + }); + } else if (event.type === "todo_status") { + const statusLine = event.message + ? `- ${event.message}` + : `- Todo: ${event.action} ${event.key || ""}`; + todoStatusLinesRef.current = + (todoStatusLinesRef.current + ? todoStatusLinesRef.current + "\n" + : "") + statusLine; + setMessageCallback((prev) => { + const cloned = [...prev]; + const last = cloned[cloned.length - 1]; + if (last.role === "assistant" && last.streaming) { + last.toolCallDisplay = lastToolCallDisplayRef.current + ? lastToolCallDisplayRef.current + "\n" + todoStatusLinesRef.current + : todoStatusLinesRef.current; + } + return cloned; + }); + } + } catch (_cbErr) { + // Silently ignore streaming callback errors + } + }; + }, + [shouldAbort], + ); + + /** + * Start a new stream session. + * Creates AbortController, resets accumulated content, sets streaming flag. + * @param {Function} setMessageCallback - React setState callback + * @returns {Object} Stream session with transformer and abort method + */ + const startStream = useCallback( + (setMessageCallback) => { + resetStreaming(); + abortControllerRef.current = new AbortController(); + isStreamingRef.current = true; + + return { + transformer: createEventTransformer(setMessageCallback), + abort: () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + isStreamingRef.current = false; + }, + get state() { + return getStreamingState(); + }, + }; + }, + [resetStreaming, createEventTransformer, getStreamingState], + ); + + /** + * Execute auto-continue with circuit breaker. + * @param {Function} dispatchProvider - Provider dispatch function + * @param {string} continuationText - Text to send for continuation + * @param {Function} setMessageCallback - React setState callback + * @param {number} limit - Circuit breaker limit (default 1000) + * @returns {Promise} + */ + const autoContinue = useCallback( + async (dispatchProvider, continuationText, setMessageCallback, limit = 1000) => { + if (autoContinueCountRef.current >= limit) { + return { exceeded: true }; + } + + isAutoContinuingRef.current = true; + try { + const continuePromise = dispatchProvider( + continuationText, + null, // provider name — caller should pass this + createEventTransformer(setMessageCallback), + abortControllerRef.current?.signal, + ); + dispatchPromiseRef.current = continuePromise; + await continuePromise; + return { exceeded: false }; + } catch (err) { + return { error: err.message }; + } finally { + isAutoContinuingRef.current = false; + autoContinueCountRef.current++; + } + }, + [createEventTransformer], + ); + + /** + * Store the dispatch promise for interrupt handling. + * @param {Promise} promise - The dispatch provider promise + */ + const setDispatchPromise = useCallback((promise) => { + dispatchPromiseRef.current = promise; + }, []); + + /** + * Get the current dispatch promise (for interrupt handling). + * @returns {Promise|null} + */ + const getDispatchPromise = useCallback(() => { + return dispatchPromiseRef.current; + }, []); + + /** + * Clear the dispatch promise reference. + */ + const clearDispatchPromise = useCallback(() => { + dispatchPromiseRef.current = null; + }, []); + + // Public API — memoized to avoid unnecessary re-renders + const api = useMemo( + () => ({ + startStream, + abort, + autoContinue, + getStreamingState, + shouldAbort, + setDispatchPromise, + getDispatchPromise, + clearDispatchPromise, + resetStreaming, + get state() { + return getStreamingState(); + }, + }), + [startStream, abort, autoContinue, getStreamingState, shouldAbort, setDispatchPromise, getDispatchPromise, clearDispatchPromise, resetStreaming], + ); + + return api; +} diff --git a/src/tui/state/reducer.js b/src/tui/state/reducer.js new file mode 100644 index 0000000..7b869b3 --- /dev/null +++ b/src/tui/state/reducer.js @@ -0,0 +1,190 @@ +/** + * TUI reducer handling all state transitions. + * Consolidates eight independent useState calls into a single reducer. + */ +import { initialState, DEFAULT_TOGGLES } from "./types.js"; + +/** + * TUI reducer — handles all action types for the TUI state machine. + * @param {Object} state - Current TUIState + * @param {Object} action - TUIAction + * @returns {Object} New TUIState + */ +export function tuiReducer(state, action) { + switch (action.type) { + // ── Message actions ────────────────────────────────────────── + + case "ADD_MESSAGE": { + const newMessage = { + ...action.message, + id: action.message.id ?? Date.now().toString(36) + Math.random().toString(36).slice(2, 7), + }; + return { ...state, messages: [...state.messages, newMessage] }; + } + + case "UPDATE_MESSAGE": { + const { index, updates } = action; + if (index < 0 || index >= state.messages.length) return state; + const updatedMessages = [...state.messages]; + updatedMessages[index] = { ...updatedMessages[index], ...updates }; + return { ...state, messages: updatedMessages }; + } + + case "CLEAR_MESSAGES": + return { ...state, messages: [] }; + + // ── Input actions ──────────────────────────────────────────── + + case "SET_INPUT_TEXT": + return { ...state, inputText: action.text }; + + case "SUBMIT_INPUT": + return { ...state, inputText: "", inputFocused: true }; + + case "SET_INPUT_FOCUSED": + return { ...state, inputFocused: action.focused }; + + // ── History actions ────────────────────────────────────────── + + case "ADD_HISTORY": { + const filtered = state.chatHistory.filter((line) => line.trim()); + return { + ...state, + chatHistory: [...filtered, action.text], + historyIndex: -1, + }; + } + + case "SET_HISTORY_INDEX": + return { ...state, historyIndex: action.index }; + + // ── Status actions ─────────────────────────────────────────── + + case "SET_STATUS": + return { ...state, statusMessage: action.message }; + + case "SET_CONTEXT_SIZE": + return { ...state, contextSize: action.size }; + + case "SET_COMPACTING": + return { ...state, isCompacting: action.compacting }; + + // ── Streaming actions ──────────────────────────────────────── + + case "SET_STREAMING": + return { + ...state, + isStreaming: action.streaming, + isAutoContinuing: false, + }; + + case "SET_AUTO_CONTINUING": + return { ...state, isAutoContinuing: action.autoContinuing }; + + case "INCREMENT_AUTO_CONTINUE": + return { ...state, autoContinueCount: state.autoContinueCount + 1 }; + + case "RESET_AUTO_CONTINUE": + return { ...state, autoContinueCount: 0 }; + + // ── Scroll actions ─────────────────────────────────────────── + + case "SET_SCROLL_OFFSET": + return { ...state, scrollOffset: action.offset }; + + case "SET_VIEWPORT_HEIGHT": + return { ...state, viewportHeight: action.height }; + + // ── Config/toggle actions ──────────────────────────────────── + + case "TOGGLE_CONFIG": { + const key = action.key; + const current = state.toggles[key]; + if (current === undefined) return state; + return { + ...state, + toggles: { + ...state.toggles, + [key]: !current, + }, + }; + } + + case "SET_CONFIG": + return { + ...state, + toggles: { + ...state.toggles, + ...action.config, + }, + }; + + // ── Banner/Onboarding actions ──────────────────────────────── + + case "SET_SHOW_BANNER": + return { ...state, showBanner: action.show }; + + case "SET_SHOW_ONBOARDING": + return { ...state, showOnboarding: action.show }; + + case "SET_ONBOARDING_RESPONSE": + return { ...state, onboardingResponse: action.response }; + + // ── Unknown action ─────────────────────────────────────────── + default: + return state; + } +} + +/** + * Helper to create an ADD_MESSAGE action with a stable id. + * @param {Object} message - Message data + * @returns {Object} ADD_MESSAGE action + */ +export function addMessageAction(message) { + return { + type: "ADD_MESSAGE", + message: { + ...message, + id: message.id ?? Date.now().toString(36) + Math.random().toString(36).slice(2, 7), + }, + }; +} + +/** + * Helper to create an UPDATE_MESSAGE action. + * @param {number} index - Message index + * @param {Object} updates - Fields to update + * @returns {Object} UPDATE_MESSAGE action + */ +export function updateMessageAction(index, updates) { + return { type: "UPDATE_MESSAGE", index, updates }; +} + +/** + * Get all action type constants (exported for tests). + */ +export const ActionTypes = { + ADD_MESSAGE: "ADD_MESSAGE", + UPDATE_MESSAGE: "UPDATE_MESSAGE", + CLEAR_MESSAGES: "CLEAR_MESSAGES", + SET_INPUT_TEXT: "SET_INPUT_TEXT", + SUBMIT_INPUT: "SUBMIT_INPUT", + SET_INPUT_FOCUSED: "SET_INPUT_FOCUSED", + ADD_HISTORY: "ADD_HISTORY", + SET_HISTORY_INDEX: "SET_HISTORY_INDEX", + SET_STATUS: "SET_STATUS", + SET_CONTEXT_SIZE: "SET_CONTEXT_SIZE", + SET_COMPACTING: "SET_COMPACTING", + SET_STREAMING: "SET_STREAMING", + SET_AUTO_CONTINUING: "SET_AUTO_CONTINUING", + INCREMENT_AUTO_CONTINUE: "INCREMENT_AUTO_CONTINUE", + RESET_AUTO_CONTINUE: "RESET_AUTO_CONTINUE", + SET_SCROLL_OFFSET: "SET_SCROLL_OFFSET", + SET_VIEWPORT_HEIGHT: "SET_VIEWPORT_HEIGHT", + TOGGLE_CONFIG: "TOGGLE_CONFIG", + SET_CONFIG: "SET_CONFIG", + SET_SHOW_BANNER: "SET_SHOW_BANNER", + SET_SHOW_ONBOARDING: "SET_SHOW_ONBOARDING", + SET_ONBOARDING_RESPONSE: "SET_ONBOARDING_RESPONSE", +}; diff --git a/src/tui/state/selectors.js b/src/tui/state/selectors.js new file mode 100644 index 0000000..d59c9ba --- /dev/null +++ b/src/tui/state/selectors.js @@ -0,0 +1,79 @@ +/** + * Derived state selectors for TUI state. + * Extracted from app.js to provide computed values without duplicating logic. + */ + +/** + * Get a human-readable status message based on current state. + * Combines statusMessage with compacting/streaming indicators. + * @param {Object} state - TUIState + * @returns {string} Formatted status message + */ +export function getStatusMessage(state) { + const { statusMessage, isCompacting, isStreaming } = state; + + if (isCompacting) { + return "Compacting context..."; + } + if (isStreaming) { + return "Streaming..."; + } + return statusMessage || "Ready"; +} + +/** + * Format context size using human-readable units. + * @param {number} size - Token count + * @returns {string} Formatted size string (e.g., "12.2k", "1.4M") + */ +export function formatContextSize(size) { + if (size === 0) return "0"; + if (size < 1024) return String(size); + const units = ["k", "M"]; + const exp = Math.floor(Math.log(size) / Math.log(1024)); + const value = size / Math.pow(1024, exp); + const locale = Intl.DateTimeFormat().resolvedOptions().locale; + const formatted = + value % 1 === 0 + ? new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(Math.round(value)) + : new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value); + return formatted + units[exp - 1]; +} + +/** + * Get the last message in the conversation. + * @param {Object} state - TUIState + * @returns {Object|null} Last message or null + */ +export function getLastMessage(state) { + const { messages } = state; + if (!messages || messages.length === 0) return null; + return messages[messages.length - 1]; +} + +/** + * Check if there are any messages in the conversation. + * @param {Object} state - TUIState + * @returns {boolean} + */ +export function hasMessages(state) { + return state.messages && state.messages.length > 0; +} + +/** + * Get the number of user messages. + * @param {Object} state - TUIState + * @returns {number} + */ +export function getUserMessageCount(state) { + return state.messages.filter((m) => m.role === "user").length; +} + +/** + * Get the number of assistant messages. + * @param {Object} state - TUIState + * @returns {number} + */ +export function getAssistantMessageCount(state) { + return state.messages.filter((m) => m.role === "assistant").length; +} diff --git a/src/tui/state/types.js b/src/tui/state/types.js new file mode 100644 index 0000000..01c3c8a --- /dev/null +++ b/src/tui/state/types.js @@ -0,0 +1,112 @@ +/** + * TUI State types, interfaces, and initial state. + * Consolidated state management replacing eight independent useState calls. + */ + +/** + * @typedef {Object} Message + * @property {string} role - "user" | "assistant" | "system" + * @property {string} content - The message content + * @property {string} [reasoningContent] - Thinking/thought content for assistant messages + * @property {Object} [activeToolCall] - {name: string} for assistant when a tool is running + * @property {string} [toolCallDisplay] - Tool call result strings for assistant messages + * @property {string} [time] - Timestamp + * @property {boolean} [streaming] - Whether currently streaming + * @property {string} [id] - Stable message identifier + */ + +/** + * @typedef {Object} ToggleConfig + * @property {boolean} autoScroll - Auto-scroll to bottom on new messages + * @property {boolean} timestamps - Show timestamps on messages + * @property {boolean} commandEcho - Echo user commands in chat display + * @property {boolean} cursorBreathe - Animate cursor when input is focused + * @property {boolean} debugOutput - Show debug information in messages + */ + +/** + * @typedef {Object} TUIState + * @property {Message[]} messages - Conversation messages + * @property {string} statusMessage - Current status indicator text + * @property {number} contextSize - Current conversation token count + * @property {string[]} chatHistory - User input history for arrow key navigation + * @property {number} historyIndex - Current position in chat history (-1 = none) + * @property {string} inputText - Current text in the input field + * @property {boolean} inputFocused - Whether the input field has focus + * @property {boolean} isCompacting - Whether context compaction is in progress + * @property {boolean} isStreaming - Whether a stream is currently active + * @property {boolean} isAutoContinuing - Whether auto-continue is active + * @property {number} autoContinueCount - Consecutive empty response count + * @property {number} scrollOffset - Current scroll position + * @property {number} viewportHeight - Current viewport height + * @property {ToggleConfig} toggles - Runtime toggle state + * @property {boolean} showBanner - Whether the startup banner is showing + * @property {boolean} showOnboarding - Whether onboarding is active + * @property {number} onboardingResponse - Onboarding response counter + */ + +/** + * @typedef { + * | { type: "ADD_MESSAGE"; message: Message } + * | { type: "UPDATE_MESSAGE"; index: number; updates: Partial } + * | { type: "CLEAR_MESSAGES" } + * | { type: "SET_INPUT_TEXT"; text: string } + * | { type: "SUBMIT_INPUT" } + * | { type: "SET_INPUT_FOCUSED"; focused: boolean } + * | { type: "ADD_HISTORY"; text: string } + * | { type: "SET_HISTORY_INDEX"; index: number } + * | { type: "SET_STATUS"; message: string } + * | { type: "SET_CONTEXT_SIZE"; size: number } + * | { type: "SET_COMPACTING"; compacting: boolean } + * | { type: "SET_STREAMING"; streaming: boolean } + * | { type: "SET_AUTO_CONTINUING"; autoContinuing: boolean } + * | { type: "INCREMENT_AUTO_CONTINUE" } + * | { type: "RESET_AUTO_CONTINUE" } + * | { type: "SET_SCROLL_OFFSET"; offset: number } + * | { type: "SET_VIEWPORT_HEIGHT"; height: number } + * | { type: "TOGGLE_CONFIG"; key: keyof ToggleConfig } + * | { type: "SET_CONFIG"; config: Partial } + * | { type: "SET_SHOW_BANNER"; show: boolean } + * | { type: "SET_SHOW_ONBOARDING"; show: boolean } + * | { type: "SET_ONBOARDING_RESPONSE"; response: number } + * } TUIAction + */ + +/** + * Default toggle configuration. + */ +const DEFAULT_TOGGLES = { + autoScroll: true, + timestamps: true, + commandEcho: true, + cursorBreathe: true, + debugOutput: false, +}; + +/** + * Initial TUI state with all fields and correct defaults. + */ +export const initialState = { + messages: [], + statusMessage: "Ready", + contextSize: 0, + chatHistory: [], + historyIndex: -1, + inputText: "", + inputFocused: true, + isCompacting: false, + isStreaming: false, + isAutoContinuing: false, + autoContinueCount: 0, + scrollOffset: 0, + viewportHeight: 0, + toggles: { ...DEFAULT_TOGGLES }, + showBanner: true, + showOnboarding: false, + onboardingResponse: 0, +}; + +/** + * Default toggle configuration (exported for tests). + */ +export { DEFAULT_TOGGLES }; diff --git a/src/tui/utils/commandParser.js b/src/tui/utils/commandParser.js new file mode 100644 index 0000000..0b12081 --- /dev/null +++ b/src/tui/utils/commandParser.js @@ -0,0 +1,356 @@ +/** + * Event-driven command registry for TUI. + * Replaces the switch-driven dispatch table with a registration-based + * system where commands are objects with validate, execute, and help properties. + */ + +/** + * @typedef {Object} Command + * @property {string} name - Command name (e.g., "quit", "config") + * @property {string} description - Human-readable description + * @property {string} usage - Usage string (e.g., "/config set ") + * @property {Function} validate - (args, ctx) => { valid: boolean, error?: string } + * @property {Function} execute - (args, ctx) => { action, ...result } + * @property {string} [group] - Command group for /help display + */ + +/** + * @typedef {Object} CommandHelpers + * @property {Function} dispatchProvider - Provider dispatch function + * @property {Object} sessionState - Current session state + * @property {Object} config - Application config + * @property {Object} scrollRef - ScrollView ref + * @property {Function} [setConfigValue] - Config setter + * @property {Array} [scheduleList] - Current schedule list + * @property {Function} [schedulePause] - Schedule pause function + * @property {Function} [scheduleResume] - Schedule resume function + * @property {Array} [skillList] - Available skills + * @property {Function} [executeSkill] - Skill execution function + * @property {Function} [gcTrigger] - GC trigger function + * @property {Function} [gcStatus] - GC status function + */ + +/** + * Event-driven command registry. + */ +export class CommandRegistry { + #commands = new Map(); + #groups = new Map(); + + constructor() { + // Register all built-in commands + this.#registerBuiltins(); + } + + /** + * Register a command with the registry. + * @param {Command} command - Command definition + */ + register(command) { + this.#commands.set(command.name, command); + + // Track groups + const group = command.group || "General"; + if (!this.#groups.has(group)) { + this.#groups.set(group, []); + } + this.#groups.get(group).push(command.name); + } + + /** + * Check if a command exists. + * @param {string} name + * @returns {boolean} + */ + has(name) { + return this.#commands.has(name); + } + + /** + * Parse a raw input string and return a command result. + * @param {string} input - Raw input (e.g., "/config set telemetry.enabled true") + * @param {Object} context - Execution context with module references + * @returns {Object|null} Parsed command result + */ + parse(input, context) { + if (!input || typeof input !== "string") return null; + const trimmed = input.trim(); + if (!trimmed.startsWith("/")) return null; + + const parts = trimmed.slice(1).trim().split(/\s+/); + const commandName = parts[0]; + const args = parts.slice(1); + + // Check registered commands + const command = this.#commands.get(commandName); + if (command) { + // Validate first + const validation = command.validate ? command.validate(args, context) : { valid: true }; + if (!validation.valid) { + return { + action: "unknown", + message: `Invalid ${commandName}: ${validation.error || "invalid arguments"}`, + }; + } + // Execute + return command.execute(args, context); + } + + // Unknown command + return { + action: "unknown", + message: `Unknown command: /${commandName}. Type /help for available commands.`, + }; + } + + /** + * Check if an input is a command (starts with "/"). + * @param {string} input + * @returns {boolean} + */ + isCommand(input) { + return input && typeof input === "string" && input.trim().startsWith("/"); + } + + /** + * Get a list of all registered commands. + * @returns {string[]} + */ + listCommands() { + return Array.from(this.#commands.keys()); + } + + /** + * Get grouped command list for /help display. + * @returns {Array<{group: string, commands: string[]}>} + */ + getGroupedCommands() { + const result = []; + for (const [group, commands] of this.#groups) { + result.push({ group, commands }); + } + return result; + } + + /** + * Get help text for a specific command. + * @param {string} name + * @returns {string|null} + */ + getHelp(name) { + const command = this.#commands.get(name); + if (!command) return null; + return `${command.usage}\n${command.description}`; + } + + /** + * Register all built-in commands. + */ + #registerBuiltins() { + // Quit + this.register({ + name: "quit", + description: "Exit the application", + usage: "/quit", + group: "Session", + validate: () => ({ valid: true }), + execute: () => ({ action: "quit", value: true, message: "Quitting." }), + }); + + // New session + this.register({ + name: "new", + description: "Start a new conversation session", + usage: "/new", + group: "Session", + validate: () => ({ valid: true }), + execute: () => ({ action: "new", message: "New session started." }), + }); + + // Clear + this.register({ + name: "clear", + description: "Clear the conversation", + usage: "/clear", + group: "Session", + validate: () => ({ valid: true }), + execute: () => ({ action: "clear", message: "Conversation cleared." }), + }); + + // Provider + this.register({ + name: "provider", + description: "List or switch the AI provider", + usage: "/provider [set ]", + group: "Provider", + validate: (args) => { + if (args[0] === "set" && !args[1]) { + return { valid: false, error: "Missing provider name" }; + } + return { valid: true }; + }, + execute: (args, ctx) => { + if (args[0] === "set" && args[1]) { + ctx.sessionState?.setProvider(args[1]); + return { action: "provider", subAction: "set", value: args[1] }; + } + return { + action: "provider", + message: `Current provider: ${ctx.sessionState?.getProvider() || "unknown"}`, + }; + }, + }); + + // Config + this.register({ + name: "config", + description: "View or update configuration", + usage: "/config [set ]", + group: "Config", + validate: (args) => { + if (args[0] === "set") { + if (!args[1]) return { valid: false, error: "Missing config path" }; + if (args.length < 3) return { valid: false, error: "Missing config value" }; + } + return { valid: true }; + }, + execute: (args, ctx) => { + if (args[0] === "set" && args[1]) { + const dotPath = args[1].split(/[-:]/).join("."); + const valueStr = args[2] || undefined; + if (ctx.setConfigValue) { + ctx.setConfigValue(dotPath, valueStr); + return { + action: "config", + subAction: "set", + path: dotPath, + message: `Config: ${dotPath} set.`, + }; + } + } + if (args[0]) { + const dotPath = args[0].split(/[-:]/).join("."); + const valueStr = args[1] || undefined; + if (ctx.setConfigValue) { + ctx.setConfigValue(dotPath, valueStr); + return { + action: "config", + subAction: "set", + path: dotPath, + message: `Config: ${dotPath} set.`, + }; + } + } + return { action: "config", message: "Usage: /config set " }; + }, + }); + + // Schedule + this.register({ + name: "schedule", + description: "Manage scheduled tasks", + usage: "/schedule [list|pause|resume|run-now]", + group: "Schedule", + validate: (args) => { + if (args[0] === "pause" && !args[1]) { + return { valid: false, error: "Missing schedule name" }; + } + if (args[0] === "resume" && !args[1]) { + return { valid: false, error: "Missing schedule name" }; + } + if (args[0] === "run-now" && !args[1]) { + return { valid: false, error: "Missing schedule name" }; + } + return { valid: true }; + }, + execute: (args, ctx) => { + if (!args[0]) { + return { action: "schedule", list: ctx.scheduleList || [] }; + } + const sub = args[0]; + if (sub === "list") { + return { action: "schedule", subAction: "list", list: ctx.scheduleList || [] }; + } + if (sub === "pause" && args[1]) { + ctx.schedulePause?.(args[1]); + return { action: "schedule", subAction: "pause", name: args[1] }; + } + if (sub === "resume" && args[1]) { + ctx.scheduleResume?.(args[1]); + return { action: "schedule", subAction: "resume", name: args[1] }; + } + if (sub === "run-now" && args[1]) { + return { action: "schedule", subAction: "run-now", name: args[1] }; + } + return { action: "schedule", message: `Unknown subcommand: ${sub}` }; + }, + }); + + // GC + this.register({ + name: "gc", + description: "Trigger or check V8 garbage collection", + usage: "/gc [status]", + group: "System", + validate: () => ({ valid: true }), + execute: (args, ctx) => { + if (args[0] === "status") { + const gcInfo = ctx.gcStatus ? ctx.gcStatus() : null; + if (gcInfo) { + return { + action: "gc", + subAction: "status", + available: gcInfo.available, + calls: gcInfo.calls || [], + hourCalls: gcInfo.hourCalls || 0, + message: gcInfo.available + ? `V8 GC is available (${gcInfo.hourCalls} calls this hour)` + : "V8 GC is not available (start with --expose-gc)", + }; + } + return { action: "gc", subAction: "status", message: "GC status unavailable" }; + } + const result = ctx.gcTrigger ? ctx.gcTrigger() : { triggered: false, reason: "gc not wired" }; + const msg = result.triggered + ? `GC triggered (${result.hourCalls} calls this hour)` + : `GC ${result.reason || "skipped"}`; + return { action: "gc", subAction: "run", ...result, message: msg }; + }, + }); + + // Help + this.register({ + name: "help", + description: "Show available commands", + usage: "/help", + group: "General", + validate: () => ({ valid: true }), + execute: (_args, ctx) => { + const grouped = this.getGroupedCommands(); + let message = "Available commands:\n"; + for (const { group, commands } of grouped) { + message += `\n${group}:\n`; + for (const cmd of commands) { + const command = this.#commands.get(cmd); + message += ` /${cmd} - ${command.usage}\n`; + } + } + if (ctx.skillList && ctx.skillList.length > 0) { + message += `\nSkills:\n`; + for (const skill of ctx.skillList) { + message += ` /${skill} - execute skill\n`; + } + } + return { action: "help", message }; + }, + }); + } +} + +/** + * Create a new command registry instance. + * @returns {CommandRegistry} + */ +export function createCommandRegistry() { + return new CommandRegistry(); +} diff --git a/src/tui/utils/format.js b/src/tui/utils/format.js new file mode 100644 index 0000000..0241fcc --- /dev/null +++ b/src/tui/utils/format.js @@ -0,0 +1,148 @@ +/** + * Format specifiers and toggle logic for TUI. + * Provides runtime toggle commands and format utilities. + */ + +/** + * Toggleable state keys and their display names. + */ +export const TOGGLE_KEYS = { + autoScroll: "scroll", + timestamps: "ts", + commandEcho: "echo", + cursorBreathe: "cursor", + debugOutput: "debug", +}; + +/** + * Get the display name for a toggle key. + * @param {string} key - Toggle key + * @returns {string} Display name + */ +export function getToggleDisplayName(key) { + return TOGGLE_KEYS[key] || key; +} + +/** + * Get all toggle keys. + * @returns {string[]} + */ +export function getToggleKeys() { + return Object.keys(TOGGLE_KEYS); +} + +/** + * Get toggle state string for status bar display. + * @param {Object} toggles - Toggle config object + * @returns {string} Status bar string (e.g., "[ts:1 scroll:1]") + */ +export function getToggleStatusString(toggles) { + const parts = []; + for (const [key, display] of Object.entries(TOGGLE_KEYS)) { + const value = toggles[key] ? 1 : 0; + parts.push(`${display}:${value}`); + } + return `[${parts.join(" ")}]`; +} + +/** + * Parse a toggle key from a short name or full name. + * @param {string} name - Toggle name (e.g., "ts", "timestamps", "scroll") + * @returns {string|null} Mapped toggle key or null + */ +export function parseToggleKey(name) { + // Check full names first + if (TOGGLE_KEYS[name]) return name; + + // Check short names (reverse lookup) + for (const [key, short] of Object.entries(TOGGLE_KEYS)) { + if (short === name) return key; + } + + return null; +} + +/** + * Format a number using Intl.NumberFormat with the user's locale. + * @param {number} num - The number to format + * @returns {string} Formatted number string + */ +export function formatNumber(num) { + try { + const locale = Intl.DateTimeFormat().resolvedOptions().locale; + const formatter = new Intl.NumberFormat(locale, { + maximumFractionDigits: 0, + }); + const result = formatter.format(num); + if (result === "NaN" || result === "-NaN") { + return String(num); + } + return result; + } catch { + return String(num); + } +} + +/** + * Convert a raw number to a human-readable abbreviated form. + * @param {number} num - Number to convert + * @returns {string} Human-readable string representation + */ +export function formatSize(bytes) { + if (bytes === 0) return "0"; + if (bytes < 1024) return String(bytes); + const units = ["k", "M"]; + const exp = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, exp); + const locale = Intl.DateTimeFormat().resolvedOptions().locale; + const formatted = + value % 1 === 0 + ? new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(Math.round(value)) + : new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value); + return formatted + units[exp - 1]; +} + +/** + * Check if timestamps should be shown for a message. + * @param {Object} toggles - Toggle config + * @returns {boolean} + */ +export function shouldShowTimestamps(toggles) { + return toggles?.timestamps !== false; // default true +} + +/** + * Check if command echo should be shown. + * @param {Object} toggles - Toggle config + * @returns {boolean} + */ +export function shouldEchoCommands(toggles) { + return toggles?.commandEcho !== false; // default true +} + +/** + * Check if debug output should be shown. + * @param {Object} toggles - Toggle config + * @returns {boolean} + */ +export function shouldShowDebug(toggles) { + return toggles?.debugOutput === true; // default false +} + +/** + * Check if cursor should breathe (animate). + * @param {Object} toggles - Toggle config + * @returns {boolean} + */ +export function shouldBreatheCursor(toggles) { + return toggles?.cursorBreathe !== false; // default true +} + +/** + * Check if auto-scroll should be enabled. + * @param {Object} toggles - Toggle config + * @returns {boolean} + */ +export function shouldAutoScroll(toggles) { + return toggles?.autoScroll !== false; // default true +}