Skip to content
Open
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
10 changes: 5 additions & 5 deletions coverage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
ℹ timeoutHandler.js | 100.00 | 100.00 | 100.00 |
ℹ urlFilter.js | 100.00 | 93.75 | 100.00 |
ℹ scheduler | | | |
ℹ autoSchedule.js | 92.66 | 73.33 | 100.00 | 50-52 66-68 106-107
ℹ autoSchedule.js | 90.99 | 73.33 | 100.00 | 50-52 66-68 106-109
ℹ cron.js | 81.11 | 65.93 | 87.50 | 23-25 45-46 99-100 113-118 133-135 149-150 162 178-180 192-235 251-253 255-257 268-270 272-274 351 378-379 402-409 481-483
ℹ index.js | 100.00 | 100.00 | 100.00 |
ℹ scheduler.js | 88.55 | 89.66 | 81.82 | 87-99 129-130
Expand Down Expand Up @@ -58,7 +58,7 @@
ℹ index.js | 100.00 | 100.00 | 100.00 |
ℹ memory.js | 97.58 | 83.78 | 93.75 | 52 95-96 191-195
ℹ moa.js | 100.00 | 96.77 | 80.00 |
ℹ sampling.js | 92.51 | 90.32 | 62.50 | 24 194 197 202-215
ℹ sampling.js | 92.51 | 87.50 | 62.50 | 24 194 197 202-215
ℹ sessionSearch.js | 97.21 | 75.44 | 89.47 | 64-65 111-112 121 174-175
ℹ skills.js | 79.74 | 86.89 | 44.44 | 23-43 68-100 156-157 184-185 212-220 231-238 258-265 281-283 298-299 396-402
ℹ terminal.js | 93.69 | 82.00 | 78.95 | 38-41 77 105-106 193-194 200-202 208-209 216-217 224-225 227
Expand All @@ -71,13 +71,13 @@
ℹ tui | | | |
ℹ banner.js | 90.00 | 100.00 | 85.71 | 45-52
ℹ commandParser.js | 98.90 | 82.46 | 100.00 | 114-115
ℹ conversationPanel.js | 79.86 | 69.44 | 76.92 | 86-97 102-109 114-125 172-183 249-251 265-275
ℹ inputPanel.js | 92.06 | 80.00 | 75.00 | 59-63
ℹ conversationPanel.js | 83.28 | 71.43 | 73.33 | 86-97 102-109 114-125 172-183 250-252 283-286
ℹ inputPanel.js | 95.92 | 83.33 | 100.00 | 44-45
ℹ markdownText.js | 100.00 | 100.00 | 100.00 |
ℹ messages.js | 100.00 | 94.44 | 100.00 |
ℹ panels.js | 100.00 | 100.00 | 100.00 |
ℹ statusBar.js | 96.55 | 85.71 | 100.00 | 14-15
ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ all files | 92.09 | 84.93 | 81.25 |
ℹ all files | 92.21 | 84.97 | 81.40 |
ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ end of coverage report
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-13
49 changes: 49 additions & 0 deletions openspec/changes/replace-input-panel-with-usecursor/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## Context

The TUI (`src/tui/`) uses Ink for terminal rendering. The `InputPanel` component (`src/tui/inputPanel.js`) currently renders a cosmetic Unicode block character (`\u2588`) as a visual cursor, alongside the typed input text. This is wrapped in a `Blink` component (misnamed — no actual blinking occurs). The cursor is styled with a near-invisible color (`#202020`) when the input is unfocused, which is a visual hack rather than proper cursor hiding.

The `InputPanel` is a display-only component — all input handling (typing, Enter-to-send, history navigation, backspace) is managed by `App`'s single `useInput` hook. `InputPanel` receives `inputText`, `cursorChar`, and `cursorColor` props.

## Goals / Non-Goals

**Goals:**
- Replace the cosmetic cursor character with real terminal cursor positioning using Ink's `useCursor` hook.
- Properly hide/show the cursor based on input focus state.
- Handle wide characters (CJK, emoji) correctly using `string-width`.
- Clean up the `Blink` component entirely.

**Non-Goals:**
- Adding cursor blinking animation (not requested, adds complexity).
- Changing the input handling logic (remains in `App`'s `useInput`).
- Supporting custom cursor shapes (terminal cursor shape is controlled by the terminal emulator).
- IME (Input Method Editor) implementation beyond what `useCursor` provides natively.

## Decisions

### Decision 1: Use `useCursor` over manual ANSI escape codes
**Rationale:** Ink's `useCursor` hook is the framework-recommended approach. It handles cursor positioning relative to Ink's rendered output, accounting for layout changes automatically. Manual ANSI codes would be fragile and break on re-renders.

### Decision 2: Use `string-width` for x-position calculation
**Rationale:** Ink's own cursor-IME example uses `string-width` for the same reason. Terminal columns don't map 1:1 to character count when wide characters are present. This is essential for correct cursor placement.

### Decision 3: Remove `cursorChar` prop, keep `cursorColor` as focus signal
**Rationale:** The real terminal cursor replaces the character prop entirely. The `cursorColor` prop value (`#202020`) is repurposed as a focus indicator — when set to this value, the cursor is hidden; when `undefined`, it's shown. This minimizes API surface changes.

### Decision 4: Add `> ` prompt prefix
**Rationale:** The App component JSDoc describes an "IRC-style layout." The prompt prefix aligns with this description and provides visual clarity. It's a minor enhancement that improves UX.

### Decision 5: Remove `Blink` component entirely
**Rationale:** It serves no purpose beyond the cosmetic cursor. The name was already misleading. No other component references it.

## Risks / Trade-offs

| Risk | Mitigation |
|------|-----------|
| `string-width` adds a production dependency | It's a small, well-maintained package (~2KB). Required for correctness with wide characters. |
| Cursor positioning may be off by one due to Box padding (`paddingX: 1`) | The `useCursor` hook positions relative to Ink output, which already accounts for container padding. Test with actual rendering. |
| Cursor hiding may flash briefly on unfocus | Ink handles cursor state transitions smoothly. The `setCursorPosition(undefined)` call is synchronous within the render cycle. |
| Terminal cursor appearance varies by emulator | This is expected — cursor shape/color is a terminal setting, not something we control. The positioning is correct regardless. |

## Open Questions

- None at this time. The approach is straightforward and follows Ink's documented patterns.
26 changes: 26 additions & 0 deletions openspec/changes/replace-input-panel-with-usecursor/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## Why

The current `InputPanel` component renders a cosmetic Unicode block character (`\u2588`) as a visual cursor. This approach is purely decorative — it doesn't provide real terminal cursor behavior, breaks with IME (Input Method Editor) support, and requires visual hacks (near-invisible color) to hide the cursor when the input is unfocused. Ink's `useCursor` hook provides proper terminal cursor positioning, which is essential for correct input behavior and accessibility.

## What Changes

- Replace the `Blink` component (which renders a static cursor character) with Ink's `useCursor` hook in `src/tui/inputPanel.js`.
- Use `setCursorPosition({x, y})` to position the real terminal cursor after the typed input text.
- Use `setCursorPosition(undefined)` to hide the cursor when the input panel is not focused.
- Add `string-width` dependency for accurate wide-character (CJK, emoji) cursor x-positioning.
- Remove the `cursorChar` prop from the `InputPanel` API — no longer needed with a real cursor.
- Add a `> ` prompt prefix to the input display to match the IRC-style layout.

## Capabilities

### New Capabilities
- `tui-cursor-positioning`: Proper terminal cursor positioning via Ink's `useCursor` hook in the input panel.

### Modified Capabilities
- *(none — this is a new capability, not a modification of existing spec-level behavior)*

## Impact

- **Affected code**: `src/tui/inputPanel.js` (refactored), `src/tui/app.js` (consumer removes `cursorChar` prop), `package.json` (new dependency).
- **Dependencies**: Adds `string-width` to production dependencies.
- **Breaking changes**: The `cursorChar` prop is removed from `InputPanel`. Any external consumers would need to drop this prop (internal only, so no external breaking change).
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## ADDED Requirements

### Requirement: Input panel uses real terminal cursor
The InputPanel component SHALL use Ink's `useCursor` hook to position a real terminal cursor after the typed input text, replacing the previous cosmetic Unicode block character approach.

#### Scenario: Cursor positioned after input text
- **WHEN** the user types text in the input panel
- **THEN** the terminal cursor appears immediately after the last character of the input text

#### Scenario: Cursor handles wide characters correctly
- **WHEN** the input text contains wide characters (CJK characters, emoji)
- **THEN** the cursor x-position is calculated using `string-width` to account for multi-byte character column width

#### Scenario: Cursor hidden when input is not focused
- **WHEN** the user tabs away from the input panel to the conversation area
- **THEN** the terminal cursor is hidden (not visible in the input area)

#### Scenario: Cursor visible when input is focused
- **WHEN** the input panel has focus (default state, or user tabs to it)
- **THEN** the terminal cursor is visible and positioned after the input text

### Requirement: Input panel displays prompt prefix
The InputPanel component SHALL display a `> ` prompt prefix before the typed input text, matching the IRC-style layout described in the App component documentation.

#### Scenario: Prompt prefix is displayed
- **WHEN** the input panel renders
- **THEN** the text `> ` appears before the input text

#### Scenario: Prompt prefix is always visible
- **WHEN** the input text is empty
- **THEN** the `> ` prompt prefix is still displayed

### Requirement: Blink component removed
The `Blink` component SHALL be removed from `inputPanel.js` as it is no longer needed with real cursor positioning.

#### Scenario: No Blink component in inputPanel.js
- **WHEN** `inputPanel.js` is inspected
- **THEN** no `Blink` function or component is exported or defined

### Requirement: cursorChar prop removed
The `cursorChar` prop SHALL be removed from the `InputPanel` component API.

#### Scenario: InputPanel accepts no cursorChar prop
- **WHEN** `InputPanel` is called
- **THEN** the `cursorChar` prop is ignored (not used internally)

### Requirement: string-width dependency added
The `string-width` package SHALL be added to the project's production dependencies for accurate wide-character column width calculation.

#### Scenario: string-width is a production dependency
- **WHEN** `package.json` is inspected
- **THEN** `string-width` appears in the `dependencies` section
33 changes: 33 additions & 0 deletions openspec/changes/replace-input-panel-with-usecursor/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## 1. Add string-width dependency

- [ ] 1.1 Add `string-width` to package.json dependencies

## 2. Refactor inputPanel.js

- [ ] 2.1 Remove the `Blink` component entirely
- [ ] 2.2 Import `useCursor` from `ink` and `string-width`
- [ ] 2.3 Implement `useCursor` hook with `setCursorPosition` in `InputPanel`
- [ ] 2.4 Use `string-width(prompt + inputText)` for x-position calculation
- [ ] 2.5 Hide cursor (`setCursorPosition(undefined)`) when unfocused (cursorColor === "#202020")
- [ ] 2.6 Add `> ` prompt prefix to the rendered text
- [ ] 2.7 Remove `cursorChar` prop from `InputPanel` API

## 3. Update app.js consumer

- [ ] 3.1 Remove `cursorChar` prop from `InputPanel` call site
- [ ] 3.2 Keep `cursorColor` prop passing (undefined when focused, "#202020" when unfocused)

## 4. Write unit tests

- [ ] 4.1 Create `tests/unit/inputPanel.test.js`
- [ ] 4.2 Test: InputPanel renders prompt prefix and input text
- [ ] 4.3 Test: InputPanel does not export Blink component
- [ ] 4.4 Test: InputPanel accepts and ignores cursorChar prop (backward compat)
- [ ] 4.5 Test: useCursor is called with correct x-position for plain ASCII text
- [ ] 4.6 Test: useCursor is called with correct x-position for text with wide characters

## 5. Verify

- [ ] 5.1 Run `npm run lint` — passes
- [ ] 5.2 Run `npm run test` — all tests pass
- [ ] 5.3 Run `npm run coverage` — 100% coverage maintained
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@opentelemetry/sdk-node": "^0.218.0",
"cron-parser": "^5.5.0",
"ink": "^7.0.5",
"string-width": "^7.0.0",
"ink-scroll-view": "^0.3.7",
"js-yaml": "^4.2.0",
"marked": "^9.1.6",
Expand Down
1 change: 0 additions & 1 deletion src/tui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,6 @@ export default function App({
React.createElement(InputPanel, {
key: inputFocused ? "input-focused" : "input-unfocused",
inputText: inputText,
cursorChar: config?.tui?.cursorChar ?? "\u2588",
cursorColor: inputFocused ? undefined : "#202020",
}),
)
Expand Down
64 changes: 37 additions & 27 deletions src/tui/inputPanel.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,49 @@
import React from "react";
import { Box, Text } from "ink";
import React, { useEffect } from "react";
import { Text, useCursor } from "ink";
import stringWidth from "string-width";

/**
* Input cursor component. Renders a static cursor to avoid periodic re-renders.
* @param {Object} props
* @param {string} props.text - Input text to render
* @param {string} props.char - Cursor character
* @param {string} [props.cursorColor] - Cursor text color (defaults to white)
* @returns {React.ReactElement}
* Determine if the cursor should be visible based on the cursorColor prop.
* @param {string|undefined} cursorColor - Focus state signal
* @returns {boolean} True if cursor should be visible
*/
export function Blink({ text = "", char = "\u2588", cursorColor }) {
return React.createElement(
Box,
{ flexDirection: "row" },
React.createElement(Text, { key: "text", flexGrow: 1, color: "white" }, text || ""),
React.createElement(
Text,
{ key: "cursor", bold: true, color: cursorColor || "cyan" },
char || "\u2588",
),
);
export function isCursorVisible(cursorColor) {
return cursorColor !== "#202020";
}

/**
* Display-only input panel with IRC-style prompt and blinking cursor.
* Calculate the cursor x-position for the given prompt and input text.
* Uses string-width to correctly handle wide characters (CJK, emoji).
* @param {string} prompt - The prompt prefix (e.g., "> ")
* @param {string} inputText - The current input text
* @returns {number} The cursor x-position (column)
*/
export function calculateCursorX(prompt, inputText) {
return stringWidth(prompt + inputText);
}

/**
* Input panel component using Ink's useCursor hook for proper terminal cursor positioning.
* All input handling (typing, Enter-to-send, history nav, backspace)
* is handled by App's single useInput hook.
* @param {Object} props
* @param {string} props.inputText - Current text being typed
* @param {string} props.cursorChar - Character to use as cursor indicator
* @param {string} [props.cursorColor] - Color for the cursor
* @param {string} [props.cursorColor] - Focus state signal: undefined = focused (show cursor), "#202020" = unfocused (hide cursor)
* @param {string} [props._cursorChar] - Deprecated: ignored, kept for backward compatibility
* @returns {React.ReactElement}
*/
export function InputPanel({ inputText = "", cursorChar = "\u2588", cursorColor }) {
if (cursorColor) {
return React.createElement(Blink, { text: inputText, char: cursorChar, cursorColor });
}
return React.createElement(Blink, { text: inputText, char: cursorChar });
export function InputPanel({ inputText = "", cursorColor, _cursorChar }) {
const { setCursorPosition } = useCursor();
const prompt = "> ";

useEffect(() => {
if (isCursorVisible(cursorColor)) {
const x = calculateCursorX(prompt, inputText);
setCursorPosition({ x, y: 1 });
} else {
setCursorPosition(undefined);
}
}, [inputText, cursorColor, setCursorPosition]);

return React.createElement(Text, { color: "white" }, prompt + inputText);
}
86 changes: 86 additions & 0 deletions tests/unit/inputPanel.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from "react";
import { describe, it } from "node:test";
import assert from "node:assert";
import { renderToString } from "ink";
import stringWidth from "string-width";
import { InputPanel, calculateCursorX, isCursorVisible } from "../../src/tui/inputPanel.js";

describe("InputPanel - component rendering", () => {
it("renders prompt prefix and input text", () => {
const result = String(renderToString(React.createElement(InputPanel, { inputText: "hello" })));
assert.ok(result.includes("> hello"), "should render prompt prefix and input text");
});

it("renders prompt prefix when input is empty", () => {
const result = renderToString(React.createElement(InputPanel, { inputText: "" }));
// Verify it renders a Text element (the prompt + empty text)
assert.ok(result !== null && result !== undefined);
});

it("renders text in white color", () => {
const result = renderToString(React.createElement(InputPanel, { inputText: "test" }));
assert.ok(result !== null && result !== undefined);
});
});

describe("InputPanel - cursorChar prop ignored", () => {
it("does not crash when cursorChar is passed", () => {
assert.doesNotThrow(() => {
renderToString(
React.createElement(InputPanel, {
inputText: "hello",
cursorChar: "█",
}),
);
}, "should not throw when cursorChar is passed");
});
});

describe("InputPanel - cursor positioning logic", () => {
it("positions cursor after prompt + text for ASCII input", () => {
const prompt = "> ";
const text = "hello";
const expectedX = calculateCursorX(prompt, text);
assert.strictEqual(expectedX, 7); // "> " (2) + "hello" (5)
});

it("positions cursor correctly for text with wide characters", () => {
const prompt = "> ";
const text = "hello 🌍";
const expectedX = calculateCursorX(prompt, text);
assert.strictEqual(expectedX, 10); // "> " (2) + "hello " (6) + "🌍" (2)
});

it("positions cursor correctly for CJK characters", () => {
const prompt = "> ";
const text = "你好";
const expectedX = calculateCursorX(prompt, text);
assert.strictEqual(expectedX, 6); // "> " (2) + "你好" (4)
});

it("uses string-width for accurate column calculation", () => {
const prompt = "> ";
const text = "a";
const expectedX = stringWidth(prompt + text);
assert.strictEqual(calculateCursorX(prompt, text), expectedX);
});
});

describe("InputPanel - cursor visibility logic", () => {
it("returns true when cursorColor is undefined (focused)", () => {
assert.strictEqual(isCursorVisible(undefined), true);
});

it("returns true when cursorColor is empty string", () => {
assert.strictEqual(isCursorVisible(""), true);
});

it("returns true when cursorColor is any other value", () => {
assert.strictEqual(isCursorVisible("cyan"), true);
assert.strictEqual(isCursorVisible("white"), true);
});

it("returns false when cursorColor is #202020 (unfocused)", () => {
assert.strictEqual(isCursorVisible("#202020"), false);
});
});
Loading
Loading