From 2fa0460774ea709f1593928c3cf62b3d81ffdc7a Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 14:38:33 +0200 Subject: [PATCH] perf(cli): isolate console message input state --- .../design/2026-05-28-feature-agent-watch.md | 7 +- .../2026-05-28-feature-agent-watch.md | 6 +- .../2026-05-28-feature-agent-watch.md | 2 +- .../testing/2026-05-28-feature-agent-watch.md | 15 +- .../__tests__/tui/console/ChatInput.test.ts | 149 ++++++++++++++++++ packages/cli/src/tui/console/ChatInput.tsx | 15 +- packages/cli/src/tui/console/ConsoleApp.tsx | 4 - 7 files changed, 174 insertions(+), 24 deletions(-) create mode 100644 packages/cli/src/__tests__/tui/console/ChatInput.test.ts diff --git a/docs/ai/design/2026-05-28-feature-agent-watch.md b/docs/ai/design/2026-05-28-feature-agent-watch.md index 84b2b3ff..d3b26f55 100644 --- a/docs/ai/design/2026-05-28-feature-agent-watch.md +++ b/docs/ai/design/2026-05-28-feature-agent-watch.md @@ -34,7 +34,8 @@ graph TD ``` **Key architectural decisions:** -- All keyboard handling (`useInput`) centralised in `ConsoleAppShell` (non-memo) — Ink 7 + React 19 silently drops `useInput` inside `React.memo` components +- Global keyboard handling (`useInput`) centralised in `ConsoleAppShell` (non-memo) — Ink 7 + React 19 silently drops `useInput` inside `React.memo` components +- Message draft state colocated in `ChatInput`; the shell is notified only when wrapping changes the input height - Actions dispatch via `spawn()` re-invoking the CLI with `stdio: pipe` so the TUI never yields the terminal - Context value stabilised with `useMemo` so quiet polls don't re-render all consumers @@ -65,13 +66,13 @@ graph TD | Component | File | Responsibility | |-----------|------|----------------| | `ConsoleApp` | `ConsoleApp.tsx` | Context provider wrapper | -| `ConsoleAppShell` | `ConsoleApp.tsx` | All state, keyboard handling, layout math | +| `ConsoleAppShell` | `ConsoleApp.tsx` | Shell state, global keyboard handling, layout math | | `HeaderBar` | `HeaderBar.tsx` | Agent count + app label | | `AgentListPane` | `AgentListPane.tsx` | 2-line agent rows with status/name/type/summary | | `PreviewSection` | `PreviewSection.tsx` | Runs `useAgentConversation`, wraps `PreviewPane` | | `PreviewPane` | `PreviewPane.tsx` | Renders last N messages with role/timestamp | | `StatusFooter` | `StatusFooter.tsx` | Status counts + updated time + keybinding hints | -| `ChatInput` | `ChatInput.tsx` | Controlled text input for sending messages | +| `ChatInput` | `ChatInput.tsx` | Locally controlled message draft and submit/cancel behavior | | `FormatStatus` | `render/formatStatus.tsx` | Status glyph + label | | `ConsoleProvider` | `state/ConsoleContext.tsx` | Provides agent list via context | | `useAgentList` | `hooks/useAgentList.ts` | Polls `manager.listAgents()` every 3s | diff --git a/docs/ai/implementation/2026-05-28-feature-agent-watch.md b/docs/ai/implementation/2026-05-28-feature-agent-watch.md index b77fa99c..babfcf21 100644 --- a/docs/ai/implementation/2026-05-28-feature-agent-watch.md +++ b/docs/ai/implementation/2026-05-28-feature-agent-watch.md @@ -17,7 +17,7 @@ packages/cli/src/ ├── PreviewPane.tsx # Last-N message renderer ├── PreviewSection.tsx # Runs useAgentConversation, wraps PreviewPane ├── StatusFooter.tsx # Status counts + keybinding hints - ├── ChatInput.tsx # Controlled text input + ├── ChatInput.tsx # Locally controlled message draft ├── HeaderBar.tsx # App label + agent count ├── actions/ │ ├── runAction.ts # Subprocess dispatcher @@ -37,7 +37,9 @@ packages/cli/src/ ## Key Implementation Notes ### Ink 7 + React 19 keyboard handling -`useInput` silently fails inside `React.memo` components. All keyboard handling lives in `ConsoleAppShell` (non-memo). Refs (`selectedNameRef`, `agentsRef`) capture current values for use inside `useInput` closures without stale closure bugs. +`useInput` silently fails inside `React.memo` components. Global keyboard handling lives in `ConsoleAppShell` (non-memo). Refs (`selectedNameRef`, `agentsRef`) capture current values for use inside `useInput` closures without stale closure bugs. + +`ChatInput` owns only the controlled `ink-text-input` value. Character edits therefore rerender the input subtree without rerendering `ConsoleAppShell`; focus changes, Escape routing, submission, polling pause, and layout remain shell-owned. Line-count callbacks cross the boundary only when wrapping changes the required height. ### Layout stability Every `` has explicit `width` + `flexShrink={0}`. Without this, Yoga recalculates and shifts layout on every selection change. `computeLayout()` is a pure function — easy to verify and test independently. diff --git a/docs/ai/planning/2026-05-28-feature-agent-watch.md b/docs/ai/planning/2026-05-28-feature-agent-watch.md index edc3692c..3d99c2aa 100644 --- a/docs/ai/planning/2026-05-28-feature-agent-watch.md +++ b/docs/ai/planning/2026-05-28-feature-agent-watch.md @@ -25,7 +25,7 @@ description: Break down work into actionable tasks and estimate timeline - [x] Task 3.3: `PreviewSection` — reads context + runs `useAgentConversation`, paused during input focus ### Phase 4: Chat Input & Actions -- [x] Task 4.1: `ChatInput` — fully controlled (value/onChange lifted to `ConsoleAppShell`); dynamic line-count reporting for layout +- [x] Task 4.1: `ChatInput` — locally owns its draft so typing stays within the input subtree; dynamic line-count reporting updates shell layout only when wrapping changes - [x] Task 4.2: `runAction` — spawns CLI subprocess (`agent open` / `agent send`) with `stdio: pipe`; resolves via `process.execPath + execArgv + argv[1]` - [x] Task 4.3: Transient feedback messages — 4s auto-clear; shown in `StatusFooter` diff --git a/docs/ai/testing/2026-05-28-feature-agent-watch.md b/docs/ai/testing/2026-05-28-feature-agent-watch.md index 4f113815..dac22b93 100644 --- a/docs/ai/testing/2026-05-28-feature-agent-watch.md +++ b/docs/ai/testing/2026-05-28-feature-agent-watch.md @@ -8,7 +8,7 @@ description: Test coverage plan, test file locations, and results ## Scope -React components and hooks cannot be tested without `@testing-library/react` or `ink-testing-library` (not available in this project). Coverage targets all pure TypeScript logic: layout calculation, equality checks, LRU cache, time formatting, and subprocess dispatch. +React components are tested selectively through Ink's public renderer without adding a separate testing-library dependency. Coverage otherwise targets pure TypeScript logic: layout calculation, equality checks, LRU cache, time formatting, and subprocess dispatch. ## Test Files @@ -19,8 +19,9 @@ React components and hooks cannot be tested without `@testing-library/react` or | `src/__tests__/tui/console/hooks/conversationCache.test.ts` | `cacheSet`, `conversationCache`, `messagesEqual` | 11 | | `src/__tests__/tui/console/hooks/agentsEqual.test.ts` | `agentsEqual` in `useAgentList.ts` | 11 | | `src/__tests__/tui/console/actions/runAction.test.ts` | `runAction.ts` | 7 | +| `src/__tests__/tui/console/ChatInput.test.ts` | Local draft rendering, parent render isolation, submit/cancel clearing, focus clearing, wrapping, cursor editing | 6 | -**Total new tests: 47** | **All passing** +**Total new tests: 53** | **All passing** ## What Each Suite Validates @@ -67,8 +68,8 @@ React components and hooks cannot be tested without `@testing-library/react` or ## Coverage Notes -**Not covered by automated tests** (require ink-testing-library or manual QA): -- React component rendering: `AgentListPane`, `PreviewPane`, `StatusFooter`, `ChatInput`, `HeaderBar` +**Not covered by automated tests** (require broader Ink integration coverage or manual QA): +- React component rendering: `AgentListPane`, `PreviewPane`, `StatusFooter`, `HeaderBar` - Hook behaviour: `useAgentList`, `useAgentConversation`, `useTerminalSize` - Keyboard navigation: j/k, o, i, q in `ConsoleAppShell` - Narrow/wide layout transition on terminal resize @@ -83,7 +84,7 @@ React components and hooks cannot be tested without `@testing-library/react` or ## Results ``` -Test Files 41 passed (41) - Tests 621 passed (621) ← includes 47 new agent-console tests - Duration 2.65s +Test Files 80 passed (80) + Tests 965 passed (965) ← includes 53 agent-console tests from this feature + Duration 13.76s ``` diff --git a/packages/cli/src/__tests__/tui/console/ChatInput.test.ts b/packages/cli/src/__tests__/tui/console/ChatInput.test.ts new file mode 100644 index 00000000..bdff44e8 --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/ChatInput.test.ts @@ -0,0 +1,149 @@ +import { PassThrough } from 'node:stream'; +import { createElement } from 'react'; +import { render } from 'ink'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ChatInput } from '../../../tui/console/ChatInput.js'; + +class TestInputStream extends PassThrough { + isTTY = true; + setRawMode = vi.fn(); + ref = vi.fn(() => this); + unref = vi.fn(() => this); +} + +class TestOutputStream extends PassThrough { + isTTY = true; + columns = 80; + rows = 24; +} + +describe('ChatInput', () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); + }); + + it('updates while typing without rendering its parent again', async () => { + let parentRenderCount = 0; + const Parent = () => { + parentRenderCount += 1; + return createElement(ChatInput, { + focused: true, + onSubmit: vi.fn(), + onCancel: vi.fn(), + innerWidth: 60, + onLineCountChange: vi.fn(), + }); + }; + + const { stdin, output, instance } = mount(createElement(Parent)); + await type(stdin, instance, 'a', 'b', 'c'); + + expect(output.join('')).toContain('abc'); + expect(parentRenderCount).toBe(1); + }); + + it('trims submitted messages and clears after submit', async () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const { stdin, instance } = mount(createChatInput({ onSubmit, onCancel })); + + await type(stdin, instance, ' hello ', '\r'); + + expect(onSubmit).toHaveBeenCalledWith('hello'); + expect(onCancel).not.toHaveBeenCalled(); + + await type(stdin, instance, '\r'); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('cancels an empty submission', async () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const { stdin, instance } = mount(createChatInput({ onSubmit, onCancel })); + + await type(stdin, instance, ' ', '\r'); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('clears the draft when focus is lost', async () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const props = { onSubmit, onCancel }; + const { stdin, instance } = mount(createChatInput(props)); + await type(stdin, instance, 'draft'); + + instance.rerender(createChatInput({ ...props, focused: false })); + await instance.waitUntilRenderFlush(); + instance.rerender(createChatInput(props)); + await instance.waitUntilRenderFlush(); + await type(stdin, instance, '\r'); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('reports line-count changes as the local value wraps', async () => { + const onLineCountChange = vi.fn(); + const { stdin, instance } = mount(createChatInput({ + innerWidth: 6, + onLineCountChange, + })); + + await type(stdin, instance, 'abcdefgh'); + + expect(onLineCountChange).toHaveBeenLastCalledWith(3); + }); + + it('preserves cursor editing keyboard behavior', async () => { + const onSubmit = vi.fn(); + const { stdin, instance } = mount(createChatInput({ onSubmit })); + + await type(stdin, instance, 'ac', '\u001B[D', 'b', '\r'); + + expect(onSubmit).toHaveBeenCalledWith('abc'); + }); + + function createChatInput(overrides: Partial[0]> = {}) { + return createElement(ChatInput, { + focused: true, + onSubmit: vi.fn(), + onCancel: vi.fn(), + innerWidth: 60, + onLineCountChange: vi.fn(), + ...overrides, + }); + } + + function mount(element: ReturnType) { + const stdin = new TestInputStream(); + const stdout = new TestOutputStream(); + const output: string[] = []; + stdout.on('data', chunk => output.push(chunk.toString())); + const instance = render(element, { + stdin: stdin as NodeJS.ReadStream, + stdout: stdout as NodeJS.WriteStream, + debug: true, + exitOnCtrlC: false, + patchConsole: false, + interactive: true, + }); + cleanups.push(instance.cleanup); + return { stdin, output, instance }; + } + + async function type( + stdin: TestInputStream, + instance: ReturnType, + ...keys: string[] + ): Promise { + await instance.waitUntilRenderFlush(); + for (const key of keys) { + stdin.write(key); + await instance.waitUntilRenderFlush(); + } + } +}); diff --git a/packages/cli/src/tui/console/ChatInput.tsx b/packages/cli/src/tui/console/ChatInput.tsx index c900195b..8ac9faa1 100644 --- a/packages/cli/src/tui/console/ChatInput.tsx +++ b/packages/cli/src/tui/console/ChatInput.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import type { FC } from 'react'; import { Box, Text } from 'ink'; import TextInput from 'ink-text-input'; @@ -6,8 +6,6 @@ import { TUI_COLORS } from '../design-system/index.js'; interface ChatInputProps { focused: boolean; - value: string; - onChange: (value: string) => void; onSubmit: (text: string) => void; onCancel: () => void; /** Inner width available for text content (after borders + padding + "> "). */ @@ -27,17 +25,20 @@ function computeLines(value: string, usableWidth: number): number { const ChatInputInner: FC = ({ focused, - value, - onChange, onSubmit, onCancel, innerWidth, onLineCountChange, }) => { + const [value, setValue] = useState(''); const lastLinesRef = useRef(MIN_LINES); const onLineCountChangeRef = useRef(onLineCountChange); onLineCountChangeRef.current = onLineCountChange; + useEffect(() => { + if (!focused) setValue(''); + }, [focused]); + useEffect(() => { const promptWidth = 2; // "> " const usable = Math.max(1, innerWidth - promptWidth); @@ -50,7 +51,7 @@ const ChatInputInner: FC = ({ const handleSubmit = (text: string): void => { const trimmed = text.trim(); - onChange(''); + setValue(''); if (trimmed.length === 0) { onCancel(); return; @@ -72,7 +73,7 @@ const ChatInputInner: FC = ({ {'> '} diff --git a/packages/cli/src/tui/console/ConsoleApp.tsx b/packages/cli/src/tui/console/ConsoleApp.tsx index 46c7d1db..661bc7bd 100644 --- a/packages/cli/src/tui/console/ConsoleApp.tsx +++ b/packages/cli/src/tui/console/ConsoleApp.tsx @@ -79,7 +79,6 @@ const ConsoleAppShell: React.FC<{ const [selectedName, setSelectedName] = useState(initialSelection); const [focus, setFocus] = useState('list'); const [inputLines, setInputLines] = useState(1); - const [inputValue, setInputValue] = useState(''); const [transient, setTransient] = useState(null); const [rightPaneMode, setRightPaneMode] = useState({ type: 'preview' }); const [detailScrollOffset, setDetailScrollOffset] = useState(0); @@ -205,7 +204,6 @@ const ConsoleAppShell: React.FC<{ if (focus === 'input') { if (key.escape) { - setInputValue(''); setFocus('list'); } return; @@ -388,8 +386,6 @@ const ConsoleAppShell: React.FC<{ >