From 369442d4b2b8786bd5de27b91da8828b593623a6 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 00:17:21 +0800 Subject: [PATCH] fix(runtime): exit on Ctrl+C under kitty for raw-mode-only apps (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the exitOnCtrlC guard into the always-on stdin controller (emitInput), encoding-agnostic via parseKeypress, so Ctrl+C exits under both the legacy \x03 byte and the kitty CSI-u form regardless of which composable holds raw mode (useInput / useFocus / usePaste, or none). Single source of truth — dropped from useInput. Excludes Ctrl+Shift+C; fast-paths \x03 and only parses escape-prefixed sequences. Adds TDD PTY coverage and updates the divergence doc. Also: stop tracking docs/superpowers/plans/2026-05-27-ink-test-parity.md — docs/ must stay out of git (AGENTS.md); it was committed before .gitignore covered it. --- .agents/docs/ink-divergences.md | 15 +- .../plans/2026-05-27-ink-test-parity.md | 588 ------------------ .../pty/fixtures/use-input-kitty.tsx | 11 +- .../pty/fixtures/use-paste-ctrl-c.tsx | 24 + .../integration/pty/input-kitty.test.ts | 30 + packages/runtime/src/composables/useInput.ts | 12 +- packages/runtime/src/render.ts | 31 +- 7 files changed, 105 insertions(+), 606 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-27-ink-test-parity.md create mode 100644 packages/runtime-tests/integration/pty/fixtures/use-paste-ctrl-c.tsx diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index ca16f12..a831c12 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -75,12 +75,15 @@ deliberate. Divergences fall into a few kinds: ### Ctrl+C exits under the kitty protocol too -- **Ink:** wires `exitOnCtrlC` only to the legacy `\x03` path, so a kitty-protocol Ctrl+C - (`\x1b[99;5u`) does **not** exit. -- **vue-tui:** `useInput` exits on `input === 'c' && key.ctrl`, gated on `exitOnCtrlC` - (default `true`), so Ctrl+C exits under **both** legacy and kitty protocols. -- **Why:** `exitOnCtrlC` reliably exiting under all protocols is the intended behavior; opt - out with `exitOnCtrlC: false`. Maintainer decision (2026-05-30): KEEP. +- **Ink:** exits only on the legacy `\x03` byte (in `App`), so a kitty-protocol Ctrl+C + (`\x1b[99;5u`) parses fine but never exits — its guard is byte-specific, not Ctrl+C-specific. +- **vue-tui:** one encoding-agnostic exit in the always-on stdin controller (`emitInput`), via + `parseKeypress` — matches Ctrl+C in both the legacy and kitty forms (but not Ctrl+Shift+C), so + it fires no matter which composable holds raw mode (`useInput` / `useFocus` / `usePaste`, or none). +- **Why:** `exitOnCtrlC` is a contract that shouldn't depend on the wire encoding; keeping the lone + exit at the single always-on layer avoids a two-place seam. Opt out with `exitOnCtrlC: false`. + Maintainer decision (2026-05-30): KEEP. Tests: `usePaste-only app exits on {legacy,kitty} Ctrl+C` + in `input-kitty.test.ts`. ## Framework-semantic divergences (Vue ≠ React) diff --git a/docs/superpowers/plans/2026-05-27-ink-test-parity.md b/docs/superpowers/plans/2026-05-27-ink-test-parity.md deleted file mode 100644 index 30f0bf2..0000000 --- a/docs/superpowers/plans/2026-05-27-ink-test-parity.md +++ /dev/null @@ -1,588 +0,0 @@ -# Ink Test Parity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add ~233 tests to reach parity with Ink v7.0.4's test suite for all already-implemented features. - -**Architecture:** Expand existing test files in-place. Unit tests colocated with source (`packages/runtime/src/**/*.test.ts`), integration tests in `packages/runtime-tests/integration/**/*.test.tsx`. Each task migrates one module's missing tests from Ink (at `/tmp/ink/test/`) to vue-tui, adapting from React/AVA to Vue/vitest. - -**Tech Stack:** vitest (via vite-plus/test), Vue 3 (defineComponent, h(), shallowRef), @vue-tui/testing render helper - ---- - -## Common Adaptation Patterns - -All tasks follow these rules when porting from Ink: - -**Imports:** - -```ts -// Ink (AVA) -import test from "ava"; -import { renderToString } from "../src/index.js"; -// vue-tui (vitest) -import { test, expect, describe } from "vite-plus/test"; -import { sanitizeAnsi } from "./sanitize-ansi.ts"; -``` - -**Assertions:** - -```ts -// Ink: t.is(result, expected) -// vue-tui: expect(result).toBe(expected) -// Ink: t.deepEqual(a, b) -// vue-tui: expect(a).toEqual(b) -// Ink: t.true(x) / t.false(x) -// vue-tui: expect(x).toBe(true) / expect(x).toBe(false) -``` - -**Components (integration tests):** - -```tsx -// Ink: const output = renderToString(hello); -// vue-tui: -const App = defineComponent(() => () => hello); -const { lastFrame } = await render(App); -expect(lastFrame()).toBe("hello"); -``` - -**Loop-generated tests:** Use `test.each` or `for` loop with `test()` inside. - -**Run commands:** - -- Unit tests: `cd packages/runtime && vp test run -- src/path/to/file.test.ts` -- Integration tests: `cd packages/runtime-tests && vp test run -- integration/path/to/file.test.tsx` - -**Commit convention:** `test: add parity tests from Ink` - ---- - -## Part A: Unit Tests (+125) - -**Branch:** `test/ink-parity-unit` - -### Task 1: sanitize-ansi (+24 tests) - -**Files:** - -- Modify: `packages/runtime/src/paint/sanitize-ansi.test.ts` -- Ink ref: `/tmp/ink/test/sanitize-ansi.ts` - -- [ ] **Step 1: Read Ink's sanitize-ansi.ts and existing vue-tui test** - -Read `/tmp/ink/test/sanitize-ansi.ts` (29 tests) and `packages/runtime/src/paint/sanitize-ansi.test.ts` (5 tests). Identify the 24 tests present in Ink but not in vue-tui. - -- [ ] **Step 2: Add the 24 missing tests** - -Append to the existing `describe("sanitize-ansi", ...)` block. Each Ink test like: - -```ts -test("strips C1 CSI", (t) => { - t.is(sanitizeAnsi("\x9b2Ahello"), "hello"); -}); -``` - -becomes: - -```ts -test("strips C1 CSI", () => { - expect(sanitizeAnsi("\x9b2Ahello")).toBe("hello"); -}); -``` - -Port all 24 missing tests following this pattern. Categories: - -- C1 variant stripping (CSI, OSC with C1/ESC ST/BEL terminators) -- DCS passthrough stripping (tmux, incomplete, BEL in payload) -- SOS control strings (all terminator variants, escaped ESC, SGR preserved) -- Private-parameter m-sequences, ESC ST, malformed ESC, incomplete CSI -- Standalone ST bytes, standalone C1 controls - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime && vp test run -- src/paint/sanitize-ansi.test.ts` -Expected: 29 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime/src/paint/sanitize-ansi.test.ts -git commit -m "test: add sanitize-ansi parity tests from Ink (+24)" -``` - -### Task 2: ansi-tokenizer (+19 tests) - -**Files:** - -- Modify: `packages/runtime/src/paint/ansi-tokenizer.test.ts` -- Ink ref: `/tmp/ink/test/ansi-tokenizer.ts` - -- [ ] **Step 1: Read Ink's ansi-tokenizer.ts and existing test** - -Read `/tmp/ink/test/ansi-tokenizer.ts` (26 tests) and `packages/runtime/src/paint/ansi-tokenizer.test.ts` (7 tests). Identify the 19 missing tests. - -- [ ] **Step 2: Add the 19 missing tests** - -Ink uses `tokenize()` which returns an array of token objects. vue-tui's `tokenizeAnsi()` may have a slightly different API — check the export in `packages/runtime/src/paint/ansi-tokenizer.ts` and adapt accordingly. - -Categories: C1 CSI with intermediates, C1 SGR CSI, incomplete C1 CSI/OSC, OSC with ST terminator, tmux DCS as single token, DCS with BEL in payload, ESC/C1 SOS variants, incomplete CSI/ESC-intermediate as invalid, standalone C1 controls. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime && vp test run -- src/paint/ansi-tokenizer.test.ts` -Expected: 26 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime/src/paint/ansi-tokenizer.test.ts -git commit -m "test: add ansi-tokenizer parity tests from Ink (+19)" -``` - -### Task 3: input-parser (+32 tests) - -**Files:** - -- Modify: `packages/runtime/src/io/input-parser.test.ts` -- Ink ref: `/tmp/ink/test/input-parser.ts` - -- [ ] **Step 1: Read Ink's input-parser.ts and existing test** - -Read `/tmp/ink/test/input-parser.ts` (44 tests, including 14 loop-generated from `deleteAndBackspaceCases`). Existing vue-tui file has 12 tests. - -- [ ] **Step 2: Add the 32 missing tests** - -Ink creates a parser with `createInputParser()` and calls `parser.push(chunk)`. vue-tui uses the same API. For the backspace/delete loop tests, use `test.each`: - -```ts -const deleteAndBackspaceCases = [ - { title: "0x7F before text", chunks: ["\x7Fabc"], events: ["\x7F", "abc"] }, - // ... port all 14 cases from Ink -]; - -test.each(deleteAndBackspaceCases)("$title", ({ chunks, events }) => { - const parser = createInputParser(); - const result = chunks.flatMap((c) => parser.push(c)); - expect(result).toEqual(events); -}); -``` - -Categories: multi-key chunks, CSI parameters, SS3 sequences, meta+CSI/SS3 double escape, escaped printable/supplementary codepoints, legacy ESC[[, incomplete holding states, flush pending, empty chunk handling, 14 backspace/delete cases, 10 bracketed paste tests. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime && vp test run -- src/io/input-parser.test.ts` -Expected: 44 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime/src/io/input-parser.test.ts -git commit -m "test: add input-parser parity tests from Ink (+32)" -``` - -### Task 4: cursor-helpers (+7 tests) - -**Files:** - -- Modify: `packages/runtime/src/io/cursor-helpers.test.ts` -- Ink ref: `/tmp/ink/test/cursor-helpers.tsx` - -- [ ] **Step 1: Read Ink's cursor-helpers.tsx and existing test** - -Read `/tmp/ink/test/cursor-helpers.tsx` (17 tests) and existing file (10 tests). Identify 7 missing. - -- [ ] **Step 2: Add the 7 missing tests** - -Ink tests `buildCursorSuffix`, `buildReturnToBottom`, `buildCursorOnlySequence`, `buildReturnToBottomPrefix`. Check vue-tui's exported function names in `packages/runtime/src/io/cursor-helpers.ts` and adapt. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime && vp test run -- src/io/cursor-helpers.test.ts` -Expected: 17 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime/src/io/cursor-helpers.test.ts -git commit -m "test: add cursor-helpers parity tests from Ink (+7)" -``` - -### Task 5: frame-writer / log-update (+34 tests) - -**Files:** - -- Modify: `packages/runtime/src/io/frame-writer.test.ts` -- Ink ref: `/tmp/ink/test/log-update.tsx` - -- [ ] **Step 1: Read Ink's log-update.tsx and existing test** - -Read `/tmp/ink/test/log-update.tsx` (35 test declarations, 6 run in 2 rendering modes via `for` loop). Existing vue-tui file has 1 test. - -**Critical:** Ink tests `logUpdate.create()` which returns a `render/clear/done` API. vue-tui's equivalent is `createFrameWriter()` which returns `write/clear/done/sync/setCursorPosition/willRender`. Map Ink's `render()` to vue-tui's `write()`. The stream setup pattern is: - -```ts -const stream = new PassThrough() as unknown as NodeJS.WriteStream; -Object.assign(stream, { columns: 80, rows: 24, isTTY: true }); -const writes: string[] = []; -stream.on("data", (chunk) => writes.push(chunk.toString())); - -const writer = createFrameWriter(stream, { debug: false, incremental: false }); -``` - -- [ ] **Step 2: Add the 34 missing tests** - -For rendering mode variants (standard vs incremental), use `describe.each`: - -```ts -const modes = [ - { name: "standard", incremental: false }, - { name: "incremental", incremental: true }, -]; - -describe.each(modes)("$name mode", ({ incremental }) => { - test("clear() returns cursor to bottom before erasing", () => { - // ... - }); -}); -``` - -Categories: standard render/update/skip-identical, incremental render/update/skip-identical/surgical/cursor-rewind/clear-extra/grow/shrink, clear()/done() reset, multiple clear(), sync()+update, 6 cursor positioning tests × 2 modes, 6 no-trailing-newline tests, render-to-empty. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime && vp test run -- src/io/frame-writer.test.ts` -Expected: 35 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime/src/io/frame-writer.test.ts -git commit -m "test: add frame-writer parity tests from Ink log-update (+34)" -``` - -### Task 6: text-width (+9 tests) - -**Files:** - -- Modify: `packages/runtime/src/host/text-measure.test.ts` -- Ink ref: `/tmp/ink/test/text-width.tsx` - -- [ ] **Step 1: Read Ink's text-width.tsx and existing test** - -Read `/tmp/ink/test/text-width.tsx` (13 tests) and existing file (4 tests). Identify 9 missing. - -**Note:** Ink's text-width tests render components with `renderToString` and check visual widths. vue-tui's `text-measure.test.ts` tests the `measureText` function directly. For tests that require rendering (e.g., "wide chars in fixed-width Box"), use `renderToString` from `@vue-tui/runtime` or the render helper. - -- [ ] **Step 2: Add the 9 missing tests** - -Categories: wide chars in fixed-width Box, CJK fixed-width, mixed ASCII+wide, ANSI styled width, empty Text sibling, truncate CJK (3 variants + box-width), overlay CJK 2nd/1st cell, CJK overlay on CJK, clipped empty write. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime && vp test run -- src/host/text-measure.test.ts` -Expected: 13 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime/src/host/text-measure.test.ts -git commit -m "test: add text-width parity tests from Ink (+9)" -``` - -### Task 7: Part A verification + PR - -- [ ] **Step 1: Run all unit tests** - -```bash -cd packages/runtime && vp test run -``` - -Expected: all pass - -- [ ] **Step 2: Run full test suite** - -```bash -cd packages/runtime-tests && vp test run && pnpm pty-test -``` - -Expected: 482+ in-process + 82 PTY tests pass - -- [ ] **Step 3: Create PR** - -```bash -git push -u origin test/ink-parity-unit -gh pr create --title "test: add Ink unit test parity (+125)" --body "..." -``` - ---- - -## Part B: Component & Composable Tests (+100) - -**Branch:** `test/ink-parity-components` - -### Task 8: text component (+15 tests) - -**Files:** - -- Modify: `packages/runtime-tests/integration/components/text.test.tsx` -- Ink ref: `/tmp/ink/test/text.tsx` - -- [ ] **Step 1: Read Ink's text.tsx and existing test** - -Read `/tmp/ink/test/text.tsx` (54 raw, 46 after excluding 8 concurrent). Existing file has 31. Identify 15 missing — all are ANSI sanitization integration tests (Text strips non-SGR sequences). - -- [ ] **Step 2: Add the 15 missing tests** - -These test that `` content passes through `sanitizeAnsi`. Pattern: - -```tsx -test("strips cursor movement in Text content", async () => { - const App = defineComponent(() => () => {"\x1b[2Ahello"}); - const { lastFrame } = await render(App); - expect(lastFrame()).toBe("hello"); -}); -``` - -Categories: strip cursor movement/position/erase, preserve SGR/OSC hyperlinks/C1 OSC/SGR colon params, strip non-SGR CSI/C1 non-SGR/ESC controls, strip tmux DCS/C1 DCS/PM/APC/SOS, strip incomplete sequences/standalone ST/C1. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime-tests && vp test run -- integration/components/text.test.tsx` -Expected: 46 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime-tests/integration/components/text.test.tsx -git commit -m "test: add text ANSI sanitization parity tests from Ink (+15)" -``` - -### Task 9: use-animation (+34 tests) - -**Files:** - -- Modify: `packages/runtime-tests/integration/composables/use-animation.test.tsx` -- Ink ref: `/tmp/ink/test/use-animation.tsx` - -- [ ] **Step 1: Read Ink's use-animation.tsx and existing test** - -Read `/tmp/ink/test/use-animation.tsx` (45 raw, 43 after excluding 2 concurrent/Suspense). Existing file has 9. Identify 34 missing. - -- [ ] **Step 2: Add the 34 missing tests** - -Ink's animation uses `useAnimation()` returning `{frame, time, delta, reset, isActive}`. vue-tui's `useAnimation()` returns the same shape. Many tests use `vi.useFakeTimers()` — check if vue-tui's existing tests use fake timers and follow the same pattern. - -Categories: shared timer (same/different intervals), timer leak prevention, frame catch-up, isActive/interval reset behavior, different interval rates, edge intervals (NaN/Infinity/-Infinity/oversized/zero/negative), maxFps interaction, delta with throttled ticks, pausing, interval change, wall clock protection, debug+non-interactive, newly mounted/activated, same-interval rerender stability, time/delta progression, reset(), unmount before first tick, resume cycles. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime-tests && vp test run -- integration/composables/use-animation.test.tsx` -Expected: 43 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime-tests/integration/composables/use-animation.test.tsx -git commit -m "test: add use-animation parity tests from Ink (+34)" -``` - -### Task 10: use-box-metrics / measure (+15 tests) - -**Files:** - -- Modify: `packages/runtime-tests/integration/composables/use-box-metrics.test.tsx` -- Ink ref: `/tmp/ink/test/use-box-metrics.tsx` + `measure-element.tsx` + `measure-text.tsx` - -- [ ] **Step 1: Read Ink's test files and existing test** - -Read all three Ink ref files (32 total). Existing vue-tui has 9 use-box-metrics tests. After A6 covers text-measure, ~15 remain. - -- [ ] **Step 2: Add the 15 missing tests** - -Ink's `useMeasureElement()` returns `{ref, ...metrics}`. vue-tui's `useBoxMetrics()` may have a different API — check `packages/runtime/src/composables/useBoxMetrics.ts` first. - -Missing useBoxMetrics (11): resize updates, tracked ref on resize, sibling content changes, tracked ref attaches after initial render, no extra re-renders, resize listener cleanup, no crash after unmount+resize, zeros when ref unattached, hasMeasured lifecycle, resets on unmount. - -Missing measure-element (4): after state update, multiple updates, useLayoutEffect equivalent, throttled. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime-tests && vp test run -- integration/composables/use-box-metrics.test.tsx` -Expected: ~24 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime-tests/integration/composables/use-box-metrics.test.tsx -git commit -m "test: add use-box-metrics/measure parity tests from Ink (+15)" -``` - -### Task 11: screen-reader (+11 tests) - -**Files:** - -- Modify: `packages/runtime-tests/integration/accessibility/screen-reader.test.tsx` -- Ink ref: `/tmp/ink/test/screen-reader.tsx` - -- [ ] **Step 1: Read Ink's screen-reader.tsx and existing test** - -Read `/tmp/ink/test/screen-reader.tsx` (25 tests). Existing file has 14. Identify 11 missing. - -- [ ] **Step 2: Add the 11 missing tests** - -Screen reader tests render components with the screen reader enabled and check text output. vue-tui may need the render helper with `isScreenReaderEnabled: true` option — check how existing tests set this up. - -Missing: aria-label on Text/Box, omit ANSI styling, multiple Text components, nested Box+Text, null component, aria-state variants (busy, disabled, expanded, multiline, multiselectable, readonly, required, selected), multi-line text, roles on multi-line, listbox with multiselectable. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime-tests && vp test run -- integration/accessibility/screen-reader.test.tsx` -Expected: 25 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime-tests/integration/accessibility/screen-reader.test.tsx -git commit -m "test: add screen-reader parity tests from Ink (+11)" -``` - -### Task 12: render-to-string (+18 tests) - -**Files:** - -- Modify: `packages/runtime-tests/integration/render-to-string.test.tsx` -- Ink ref: `/tmp/ink/test/render-to-string.tsx` - -- [ ] **Step 1: Read Ink's render-to-string.tsx and existing test** - -Read `/tmp/ink/test/render-to-string.tsx` (32 tests). Existing file has 14. Identify 18 missing. - -- [ ] **Step 2: Add the 18 missing tests** - -Ink's `renderToString()` takes JSX and returns a string. vue-tui's `renderToString()` takes a component. Pattern: - -```ts -// Ink: const output = renderToString(hello); -// vue-tui: -const App = defineComponent(() => () => hello); -const output = renderToString(App); -``` - -Missing: text with variable, nested text, empty fragment, box padding, flex row/column, margin, gap, fixed width+height, Spacer, Newline, Border, colored/bold text, wrap/truncate, default/custom columns, Transform, Static (3), effect behavior (3), error handling (3), independent calls, deeply nested tree. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime-tests && vp test run -- integration/render-to-string.test.tsx` -Expected: 32 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime-tests/integration/render-to-string.test.tsx -git commit -m "test: add render-to-string parity tests from Ink (+18)" -``` - -### Task 13: cursor composable (+7 tests) - -**Files:** - -- Modify: `packages/runtime-tests/integration/composables/use-cursor.test.tsx` -- Ink ref: `/tmp/ink/test/cursor.tsx` - -- [ ] **Step 1: Read Ink's cursor.tsx and existing test** - -Read `/tmp/ink/test/cursor.tsx` (13 raw, 12 after excluding 1 concurrent). Existing file has 5. Identify 7 missing. - -- [ ] **Step 2: Add the 7 missing tests** - -Ink's cursor tests use `useCursor()` and check stdout output for cursor positioning sequences. vue-tui's `useCursor()` should work similarly — check the composable API. - -Missing: cursor follows text input, space input cursor move, cursor cleared on unmount, no screen scroll on subsequent renders, useStdout/useStderr write with cursor, debug mode write interactions. - -- [ ] **Step 3: Run tests** - -Run: `cd packages/runtime-tests && vp test run -- integration/composables/use-cursor.test.tsx` -Expected: 12 tests pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime-tests/integration/composables/use-cursor.test.tsx -git commit -m "test: add cursor composable parity tests from Ink (+7)" -``` - -### Task 14: Part B verification + PR - -- [ ] **Step 1: Run full test suite** - -```bash -cd packages/runtime-tests && vp test run && pnpm pty-test -``` - -Expected: all pass (482+ in-process + 82 PTY) - -- [ ] **Step 2: Create PR** - -```bash -git push -u origin test/ink-parity-components -gh pr create --title "test: add Ink component/composable test parity (+100)" --body "..." -``` - ---- - -## Part D: Render Lifecycle (+8) - -**Branch:** `test/ink-parity-lifecycle` - -### Task 15: render lifecycle (+8 tests) - -**Files:** - -- Modify: `packages/runtime-tests/integration/lifecycle/on-render.test.tsx` and/or `lifecycle/exit.test.tsx` -- Ink ref: `/tmp/ink/test/render.tsx` - -- [ ] **Step 1: Read Ink's render.tsx and existing lifecycle tests** - -Read `/tmp/ink/test/render.tsx` (61 raw, ~56 in-scope). Read all files in `packages/runtime-tests/integration/lifecycle/` (33 total) + `pty/render.test.ts` (15). Identify ~8 missing scenarios. - -- [ ] **Step 2: Add the 8 missing tests** - -Distribute across appropriate existing lifecycle test files: - -- `on-render.test.tsx`: onRender metrics callback variants -- `exit.test.tsx` or new: unmount forces pending throttled render, no throttled render after unmount, exit value ordering edge cases, unmount with ended stdout -- Possibly `scheduler-throttle.test.tsx`: throttle-related render tests -- CI/non-TTY rendering variants may go in PTY or lifecycle tests - -- [ ] **Step 3: Run full suite** - -```bash -cd packages/runtime-tests && vp test run && pnpm pty-test -``` - -Expected: all pass - -- [ ] **Step 4: Commit** - -```bash -git add packages/runtime-tests/integration/lifecycle/ -git commit -m "test: add render lifecycle parity tests from Ink (+8)" -``` - -### Task 16: Part D verification + PR - -- [ ] **Step 1: Run `vp run ready`** - -```bash -vp run ready -``` - -Expected: lint, type-check, all tests pass - -- [ ] **Step 2: Create PR** - -```bash -git push -u origin test/ink-parity-lifecycle -gh pr create --title "test: add Ink render lifecycle test parity (+8)" --body "..." -``` diff --git a/packages/runtime-tests/integration/pty/fixtures/use-input-kitty.tsx b/packages/runtime-tests/integration/pty/fixtures/use-input-kitty.tsx index 8aba171..9ee9b4e 100644 --- a/packages/runtime-tests/integration/pty/fixtures/use-input-kitty.tsx +++ b/packages/runtime-tests/integration/pty/fixtures/use-input-kitty.tsx @@ -39,6 +39,15 @@ const KittyInput = defineComponent({ return; } + // Ctrl+Shift+C must NOT be treated as Ctrl+C exit (it's "copy" in many + // terminals). Even with exitOnCtrlC on, the kitty protocol disambiguates + // it (\x1b[67;6u -> input "C", ctrl+shift), so it must reach the handler. + if (props.test === "ctrlShiftC" && input === "C" && key.ctrl && key.shift) { + process.stdout.write("__CTRL_SHIFT_C__"); + exit(); + return; + } + if (props.test === "press" && key.eventType === "press") { exit(); return; @@ -125,7 +134,7 @@ const KittyInput = defineComponent({ const testName = process.argv[2]; -if (testName === "kittyCtrlCExit") { +if (testName === "kittyCtrlCExit" || testName === "ctrlShiftC") { const app = createApp(KittyInput, { test: testName }); app.mount({ exitOnCtrlC: true }); await app.waitUntilExit(); diff --git a/packages/runtime-tests/integration/pty/fixtures/use-paste-ctrl-c.tsx b/packages/runtime-tests/integration/pty/fixtures/use-paste-ctrl-c.tsx new file mode 100644 index 0000000..a7c060b --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/use-paste-ctrl-c.tsx @@ -0,0 +1,24 @@ +import process from "node:process"; +import { createApp, usePaste } from "@vue-tui/runtime"; +import { defineComponent, onMounted } from "vue"; + +// Regression fixture for the kitty-protocol Ctrl+C exit gap: an app that +// enables raw mode via usePaste() — which turns off the tty's Ctrl+C -> SIGINT +// translation — but never mounts useInput, so it carries no Ctrl+C exit guard +// of its own. With exitOnCtrlC the app must still exit on Ctrl+C under BOTH the +// legacy (\x03) and kitty (CSI-u) encodings, via the always-on stdin +// controller. See .agents/docs/ink-divergences.md. +const PasteOnly = defineComponent(() => { + usePaste(() => {}); + + onMounted(() => { + process.stdout.write("__READY__"); + }); + + return () => null; +}); + +const app = createApp(PasteOnly); +app.mount({ exitOnCtrlC: true }); +await app.waitUntilExit(); +console.log("exited"); diff --git a/packages/runtime-tests/integration/pty/input-kitty.test.ts b/packages/runtime-tests/integration/pty/input-kitty.test.ts index b559765..43031e3 100644 --- a/packages/runtime-tests/integration/pty/input-kitty.test.ts +++ b/packages/runtime-tests/integration/pty/input-kitty.test.ts @@ -151,6 +151,36 @@ it("useInput - kitty Ctrl+C exits app when exitOnCtrlC is true", async () => { expect(ps.output).toContain("exited"); }); +// --- Ctrl+C exit without useInput (raw mode held by usePaste) --- +// exitOnCtrlC must exit even when no useInput is mounted: the exit lives in the +// always-on stdin controller, encoding-agnostically. The legacy case is the +// control (it already exits via the \x03 byte); the kitty case is the gap Ink +// has — it only checks \x03, so a CSI-u Ctrl+C never exits there. + +it("usePaste-only app exits on legacy Ctrl+C (control)", async () => { + const ps = term("use-paste-ctrl-c"); + ps.write("\x03"); + await ps.waitForExit(); + expect(ps.output).toContain("exited"); +}); + +it("usePaste-only app exits on kitty Ctrl+C", async () => { + const ps = term("use-paste-ctrl-c"); + ps.write(kittyKey(99, 5)); + await ps.waitForExit(); + expect(ps.output).toContain("exited"); +}); + +// Ctrl+Shift+C is a distinct combo (commonly "copy"), not Ctrl+C. The kitty +// protocol disambiguates it, so even with exitOnCtrlC on it must reach the +// handler rather than exit. The handler writes a marker to prove delivery. +it("useInput - kitty Ctrl+Shift+C is delivered, not treated as Ctrl+C exit", async () => { + const ps = term("use-input-kitty", ["ctrlShiftC"]); + ps.write(kittyKey(67, 6)); + await ps.waitForExit(); + expect(ps.output).toContain("__CTRL_SHIFT_C__"); +}); + // --- Query response suppression --- it("useInput - query response is silently ignored, next real key works", async () => { diff --git a/packages/runtime/src/composables/useInput.ts b/packages/runtime/src/composables/useInput.ts index a652dc6..be3afad 100644 --- a/packages/runtime/src/composables/useInput.ts +++ b/packages/runtime/src/composables/useInput.ts @@ -97,13 +97,11 @@ export function useInput( key.shift = true; } - // exitOnCtrlC: intercept kitty Ctrl+C (\x1b[3;5u). Legacy \x03 is caught - // by emitInput in createStdinController and never reaches useInput. - if (input === "c" && key.ctrl && stdin?.internal_exitOnCtrlC) { - app!.exit(); - return; - } - + // Ctrl+C exit (both the legacy \x03 byte and the kitty CSI-u form) is + // handled once, upstream in emitInput (createStdinController), so when + // exitOnCtrlC is on Ctrl+C never reaches here — and useInput forwards every + // key it does receive. Keeping the exit in one always-on place is what makes + // it fire for useFocus/usePaste-only apps too; don't re-add a copy here. handler(input, key); } diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index e34fbe3..7c9a794 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -18,6 +18,7 @@ import patchConsoleFn from "patch-console"; import ansiEscapes from "ansi-escapes"; import wrapAnsi from "wrap-ansi"; import { createInputParser, type InputEvent } from "./io/input-parser.ts"; +import { parseKeypress } from "./io/parse-keypress.ts"; import { createKittyKeyboardController, type KittyKeyboardOptions } from "./io/kitty-keyboard.ts"; import { createRoot, emitLayoutListeners, type TuiRoot, type TuiNode } from "./host/nodes.ts"; import { attachYoga, detachYoga } from "./host/yoga.ts"; @@ -1220,10 +1221,32 @@ function createStdinController( } function emitInput(input: string) { - // exitOnCtrlC: intercept \x03 BEFORE dispatching to useInput - if (input === "\x03" && opts.exitOnCtrlC) { - appCtx.exit(); - return; + // exitOnCtrlC: intercept Ctrl+C here — at the always-on stdin controller, + // BEFORE dispatching to any listener — so the app exits no matter which + // composable holds raw mode (useInput / useFocus / usePaste, or none), and + // there's a single source of truth (useInput no longer carries its own + // copy). Legacy Ctrl+C is the bare \x03 byte; the kitty keyboard protocol + // encodes it as a CSI-u sequence (\x1b[99;5u). Fast-path the \x03 byte and + // parse only escape-prefixed sequences, so ordinary keystrokes aren't parsed + // here just to be parsed again in useInput. (Ink only checks \x03 and so + // never exits under kitty; see .agents/docs/ink-divergences.md.) + if (opts.exitOnCtrlC) { + if (input === "\x03") { + appCtx.exit(); + return; + } + // Only an escape sequence can be a kitty-encoded Ctrl+C. `!key.shift` + // keeps Ctrl+Shift+C (kitty \x1b[67;6u, a distinct "copy" combo) from + // being read as Ctrl+C — the kitty parser lowercases `name` to "c", so + // shift is the only signal. (Legacy can't disambiguate the two: both send + // \x03 above, so legacy Ctrl+Shift+C still exits, as it always has.) + if (input.charCodeAt(0) === 0x1b) { + const key = parseKeypress(input); + if (key.name === "c" && key.ctrl && !key.shift && key.eventType !== "release") { + appCtx.exit(); + return; + } + } } // Esc resets focus when focus is enabled if (input === "\x1b" && focusContext.enabled) {