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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/ai/design/2026-05-28-feature-agent-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
6 changes: 4 additions & 2 deletions docs/ai/implementation/2026-05-28-feature-agent-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<Box>` 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/ai/planning/2026-05-28-feature-agent-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
15 changes: 8 additions & 7 deletions docs/ai/testing/2026-05-28-feature-agent-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
```
149 changes: 149 additions & 0 deletions packages/cli/src/__tests__/tui/console/ChatInput.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof ChatInput>[0]> = {}) {
return createElement(ChatInput, {
focused: true,
onSubmit: vi.fn(),
onCancel: vi.fn(),
innerWidth: 60,
onLineCountChange: vi.fn(),
...overrides,
});
}

function mount(element: ReturnType<typeof createElement>) {
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<typeof render>,
...keys: string[]
): Promise<void> {
await instance.waitUntilRenderFlush();
for (const key of keys) {
stdin.write(key);
await instance.waitUntilRenderFlush();
}
}
});
15 changes: 8 additions & 7 deletions packages/cli/src/tui/console/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
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';
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 + "> "). */
Expand All @@ -27,17 +25,20 @@ function computeLines(value: string, usableWidth: number): number {

const ChatInputInner: FC<ChatInputProps> = ({
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);
Expand All @@ -50,7 +51,7 @@ const ChatInputInner: FC<ChatInputProps> = ({

const handleSubmit = (text: string): void => {
const trimmed = text.trim();
onChange('');
setValue('');
if (trimmed.length === 0) {
onCancel();
return;
Expand All @@ -72,7 +73,7 @@ const ChatInputInner: FC<ChatInputProps> = ({
<Text color={TUI_COLORS.accent} bold>{'> '}</Text>
<TextInput
value={value}
onChange={onChange}
onChange={setValue}
onSubmit={handleSubmit}
placeholder="type a message · ⏎ send · esc cancel"
/>
Expand Down
4 changes: 0 additions & 4 deletions packages/cli/src/tui/console/ConsoleApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ const ConsoleAppShell: React.FC<{
const [selectedName, setSelectedName] = useState<string | null>(initialSelection);
const [focus, setFocus] = useState<ConsoleFocus>('list');
const [inputLines, setInputLines] = useState(1);
const [inputValue, setInputValue] = useState('');
const [transient, setTransient] = useState<TransientMessage | null>(null);
const [rightPaneMode, setRightPaneMode] = useState<RightPaneMode>({ type: 'preview' });
const [detailScrollOffset, setDetailScrollOffset] = useState(0);
Expand Down Expand Up @@ -205,7 +204,6 @@ const ConsoleAppShell: React.FC<{

if (focus === 'input') {
if (key.escape) {
setInputValue('');
setFocus('list');
}
return;
Expand Down Expand Up @@ -388,8 +386,6 @@ const ConsoleAppShell: React.FC<{
>
<ChatInput
focused={inputFocused}
value={inputValue}
onChange={setInputValue}
onSubmit={handleInputSubmit}
onCancel={handleInputCancel}
innerWidth={inputInnerWidth}
Expand Down
Loading