feat(runtime)!: replace useExit() with Ink-aligned useAppContext() (#69)
Ink's `useApp()` returns `{ exit, waitUntilRenderFlush }`. vue-tui previously
exposed only `exit()` via `useExit()` and kept `waitUntilRenderFlush` on the
`TuiApp` handle alone. Align the public surface with Ink: add `useAppContext()`
returning the same pair, and remove `useExit()`.
- thread `waitUntilRenderFlush` into the injected `AppContext` via a hoisted
impl shared by the `TuiApp` handle and the composable, so both resolve
identically
- add `useAppContext()`; delete `useExit()`; migrate all call sites, PTY
fixtures, examples, READMEs and the public-API surface test
- port Ink's two "useApp waitUntilRenderFlush" tests; Ink's third relies on
React concurrent mode (N/A in Vue)
- rewrite the ink-divergences entry: this is now a *naming* divergence
(`useAppContext` vs `useApp`, mirroring `createApp` vs `render`), not a
surface one — and fix the prior wrong claim that Ink's `useApp` returns
stdin/stdout/stderr
The name is qualified (`useAppContext`, not `useApp`) so it doesn't read as the
Vue application instance (`createApp`/`app.mount`) — the same Vue-native naming
choice vue-tui already makes with `createApp()` vs Ink's `render()`.
BREAKING CHANGE: `useExit()` is removed. Replace `const exit = useExit()` with
`const { exit } = useAppContext()`.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,14 +31,15 @@ deliberate. Divergences fall into a few kinds:
|
||||
- **Why:** mirrors Vue's own `createApp` mental model — a Vue developer expects an app
|
||||
object they mount, not a one-shot render call.
|
||||
|
||||
### App composable — `useExit()` instead of `useApp()`
|
||||
### App composable — `useAppContext()` instead of `useApp()`
|
||||
|
||||
- **Ink:** `useApp()` returns the full AppContext (`exit`, `waitUntilRenderFlush`,
|
||||
stdin/stdout/stderr, …).
|
||||
- **vue-tui:** `useExit()` returns only the `exit` function; the rest is reached through
|
||||
dedicated composables (`useStdin`, `useStdout`, `useStderr`, …).
|
||||
- **Why:** intentionally minimal, single-purpose composables. `waitUntilRenderFlush` is
|
||||
deliberately **not** exposed.
|
||||
- **Ink:** `useApp()` returns `{ exit, waitUntilRenderFlush }` — stdin/stdout/stderr are
|
||||
separate hooks (`useStdin`/`useStdout`/`useStderr`), not part of it.
|
||||
- **vue-tui:** `useAppContext()` returns the same `{ exit, waitUntilRenderFlush }`, with
|
||||
streams on those same peer composables.
|
||||
- **Why:** only the name differs — `useApp` reads as "the Vue application instance"
|
||||
(`createApp`, `app.mount`), so vue-tui qualifies it as `useAppContext`. The shape is
|
||||
identical to Ink; a naming divergence, not a surface one.
|
||||
|
||||
### Named type / prop re-exports
|
||||
|
||||
|
||||
@@ -83,11 +83,11 @@ useInput((input) => {
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Description |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [`@vue-tui/runtime`](https://www.npmjs.com/package/@vue-tui/runtime) | The core framework — Vue 3 renderer for the terminal with components (`Box`, `Text`, `Static`, etc.), composables (`useInput`, `useFocus`, `useExit`, etc.), and yoga-based flexbox layout |
|
||||
| [`@vue-tui/cli`](https://www.npmjs.com/package/@vue-tui/cli) | Development tool — `vue-tui dev` starts your app with Vite-powered HMR |
|
||||
| [`@vue-tui/testing`](https://www.npmjs.com/package/@vue-tui/testing) | Test harness — render in an isolated fake terminal, simulate input, assert output frame by frame |
|
||||
| Package | Description |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [`@vue-tui/runtime`](https://www.npmjs.com/package/@vue-tui/runtime) | The core framework — Vue 3 renderer for the terminal with components (`Box`, `Text`, `Static`, etc.), composables (`useInput`, `useFocus`, `useAppContext`, etc.), and yoga-based flexbox layout |
|
||||
| [`@vue-tui/cli`](https://www.npmjs.com/package/@vue-tui/cli) | Development tool — `vue-tui dev` starts your app with Vite-powered HMR |
|
||||
| [`@vue-tui/testing`](https://www.npmjs.com/package/@vue-tui/testing) | Test harness — render in an isolated fake terminal, simulate input, assert output frame by frame |
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -116,7 +116,7 @@ useInput((input) => {
|
||||
| `useInput(handler, opts?)` | Handle keyboard input — receives `(input, key)` with modifier and arrow key detection |
|
||||
| `useFocus(opts?)` | Component-level focus — returns `{ isFocused, focus }` |
|
||||
| `useFocusManager()` | App-level focus control — `focusNext()`, `focusPrevious()`, `focus(id)` |
|
||||
| `useExit()` | Programmatic app exit — returns `exit(error?)` |
|
||||
| `useAppContext()` | App context — `{ exit(error?), waitUntilRenderFlush() }` |
|
||||
| `useTerminalSize()` | Reactive terminal dimensions — `{ columns, rows }` |
|
||||
| `useStdin()` | Access stdin stream and raw mode control |
|
||||
| `useStdout()` | Write directly to stdout |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from "vue";
|
||||
import { Box, Text, Static, useInput, useExit } from "@vue-tui/runtime";
|
||||
import { Box, Text, Static, useInput, useAppContext } from "@vue-tui/runtime";
|
||||
import { runAgentLoop, type Message, type ToolCall } from "./agent";
|
||||
import MessageList from "./components/MessageList.vue";
|
||||
|
||||
@@ -14,7 +14,7 @@ const pendingCommand = shallowRef("");
|
||||
const messages: Message[] = [];
|
||||
|
||||
let approvalResolve: ((approved: boolean) => void) | null = null;
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
const autoApprove = process.argv.includes("--yolo");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onScopeDispose, reactive } from "vue";
|
||||
import chalk from "chalk";
|
||||
import { Box, Text, useExit, useInput } from "@vue-tui/runtime";
|
||||
import { Box, Text, useAppContext, useInput } from "@vue-tui/runtime";
|
||||
|
||||
// --- world dimensions ----------------------------------------------------
|
||||
|
||||
@@ -130,7 +130,7 @@ function renderFrame(w: World): string[] {
|
||||
|
||||
// --- component -----------------------------------------------------------
|
||||
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
const world = reactive<World>(makeWorld(0));
|
||||
|
||||
function flap(): void {
|
||||
|
||||
@@ -30,6 +30,7 @@ const DIRECTION_LTR = 0;
|
||||
function createTestAppContext(): AppContext {
|
||||
return {
|
||||
exit: () => {},
|
||||
waitUntilRenderFlush: async () => {},
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
stdin: process.stdin,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineComponent, nextTick, shallowRef } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { Box, Text, useExit } from "@vue-tui/runtime";
|
||||
import { Box, Text, useAppContext } from "@vue-tui/runtime";
|
||||
|
||||
test("setup() throw rejects render()", async () => {
|
||||
const Boom = defineComponent(() => {
|
||||
@@ -32,20 +32,20 @@ test("render-time throw does not prevent unmount", async () => {
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
|
||||
test("useExit() called with error rejects waitUntilExit", async () => {
|
||||
test("useAppContext() called with error rejects waitUntilExit", async () => {
|
||||
// Mirrors Ink's "exit on exit() with error" fixture test, adapted for
|
||||
// render-based testing. Verifies exit(err) rejects the promise cleanly.
|
||||
// Also covered by exit.test.tsx "exit(error) rejects waitUntilExit with the error".
|
||||
let exitFn!: (err?: Error) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>running</Text>;
|
||||
});
|
||||
|
||||
const { waitUntilExit } = await render(App);
|
||||
|
||||
const err = new Error("errored via useExit");
|
||||
const err = new Error("errored via useAppContext");
|
||||
exitFn(err);
|
||||
|
||||
await expect(waitUntilExit()).rejects.toBe(err);
|
||||
|
||||
@@ -2,15 +2,15 @@ import { Writable } from "node:stream";
|
||||
import { defineComponent, onMounted, onScopeDispose } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { createApp, Text, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
|
||||
import { makeFakeWritable, makeFakeStdin, isWriteBarrierChunk } from "./test-streams.ts";
|
||||
|
||||
test("useExit() triggers teardown and waitUntilExit resolves", async () => {
|
||||
test("useAppContext() triggers teardown and waitUntilExit resolves", async () => {
|
||||
let exitFn!: () => void;
|
||||
let disposed = false;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
exitFn = exit;
|
||||
onScopeDispose(() => {
|
||||
disposed = true;
|
||||
@@ -30,7 +30,7 @@ test("exit(error) rejects waitUntilExit with the error", async () => {
|
||||
let exitFn!: (err: Error) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
exitFn = exit;
|
||||
return () => <Text>x</Text>;
|
||||
});
|
||||
@@ -56,7 +56,7 @@ test("unmount() after exit() is idempotent", async () => {
|
||||
let exitFn!: () => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
exitFn = exit;
|
||||
return () => <Text>x</Text>;
|
||||
});
|
||||
@@ -74,7 +74,7 @@ test("exit() called multiple times is idempotent", async () => {
|
||||
let exitFn!: () => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>x</Text>;
|
||||
});
|
||||
|
||||
@@ -89,7 +89,7 @@ test("waitUntilExit() resolves with result value passed to exit()", async () =>
|
||||
let exitFn!: (errorOrResult?: unknown) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>hello from vue-tui</Text>;
|
||||
});
|
||||
|
||||
@@ -105,7 +105,7 @@ test("waitUntilExit() resolves with object result value", async () => {
|
||||
let exitFn!: (errorOrResult?: unknown) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>hello</Text>;
|
||||
});
|
||||
|
||||
@@ -119,7 +119,7 @@ test("waitUntilExit() resolves with undefined when exit() called with no args",
|
||||
let exitFn!: (errorOrResult?: unknown) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>hello</Text>;
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ test("onScopeDispose fires when exit(error) is called", async () => {
|
||||
let disposed = false;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
onScopeDispose(() => {
|
||||
disposed = true;
|
||||
});
|
||||
@@ -156,7 +156,7 @@ test("exit(error) followed by exit(value) still rejects", async () => {
|
||||
let exitFn!: (errorOrResult?: unknown) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>hello</Text>;
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ test("exit(value) resolves with the FIRST value when called rapidly twice", asyn
|
||||
let exitFn!: (errorOrResult?: unknown) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>hello</Text>;
|
||||
});
|
||||
|
||||
@@ -194,7 +194,7 @@ test("exit(err1) then exit(err2) rejects with the FIRST error", async () => {
|
||||
let exitFn!: (errorOrResult?: unknown) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>hello</Text>;
|
||||
});
|
||||
|
||||
@@ -215,7 +215,7 @@ test("exit(value) then exit(error) resolves with the FIRST value", async () => {
|
||||
let exitFn!: (errorOrResult?: unknown) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>hello</Text>;
|
||||
});
|
||||
|
||||
@@ -229,8 +229,9 @@ test("exit(value) then exit(error) resolves with the FIRST value", async () => {
|
||||
|
||||
test("exit('late') after app.unmount() is a no-op (unmount value wins)", async () => {
|
||||
// isUnmounting parity (Ink parity G33): app.unmount() runs teardown()+
|
||||
// resolveExit() without setting exitInitiated. A retained useExit() called
|
||||
// AFTER unmount has started teardown must be a complete no-op — it must not
|
||||
// resolveExit() without setting exitInitiated. A retained exit() (from
|
||||
// useAppContext()) called AFTER unmount has started teardown must be a
|
||||
// complete no-op — it must not
|
||||
// overwrite the resolved exit value. waitUntilExit resolves the original
|
||||
// unmount value (undefined), NOT 'late'. Without the teardownStarted guard in
|
||||
// exit(), the late exit captures 'late' into pendingExitResult and 'late'
|
||||
@@ -238,7 +239,7 @@ test("exit('late') after app.unmount() is a no-op (unmount value wins)", async (
|
||||
let exitFn!: (errorOrResult?: unknown) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>hello</Text>;
|
||||
});
|
||||
|
||||
@@ -251,8 +252,8 @@ test("exit('late') after app.unmount() is a no-op (unmount value wins)", async (
|
||||
});
|
||||
|
||||
test("retained exit() re-entered DURING unmount teardown writes is a no-op", async () => {
|
||||
// isUnmounting parity (Ink parity G33), faithful reentrancy: a useExit()
|
||||
// captured during setup is invoked re-entrantly from inside the stdout write
|
||||
// isUnmounting parity (Ink parity G33), faithful reentrancy: an exit() (from
|
||||
// useAppContext()) captured during setup is invoked re-entrantly from inside the stdout write
|
||||
// that unmount()'s final commit performs. teardownStarted is already true at
|
||||
// that point, so exit("reentrant") is a complete no-op and the original
|
||||
// unmount value (undefined) wins. Without the teardownStarted guard the
|
||||
@@ -278,7 +279,7 @@ test("retained exit() re-entered DURING unmount teardown writes is a no-op", asy
|
||||
stdout.isTTY = true;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
onMounted(() => {
|
||||
exitFn = exit;
|
||||
});
|
||||
@@ -306,7 +307,7 @@ test("single exit('x') resolves with 'x' (control)", async () => {
|
||||
let exitFn!: (errorOrResult?: unknown) => void;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
exitFn = useExit();
|
||||
exitFn = useAppContext().exit;
|
||||
return () => <Text>hello</Text>;
|
||||
});
|
||||
|
||||
@@ -340,7 +341,7 @@ test("waitUntilExit resolves FIRST exit value when duplicate exits happen during
|
||||
stdout.columns = 100;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
onMounted(() => {
|
||||
exit("first");
|
||||
setTimeout(() => exit("second"), 0);
|
||||
@@ -388,7 +389,7 @@ test("waitUntilExit resolves FIRST exit value when exit is re-entered during unm
|
||||
stdout.isTTY = true;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
onMounted(() => {
|
||||
exitFn = exit;
|
||||
shouldReenterExit = true;
|
||||
@@ -437,7 +438,7 @@ test("exit with cross-realm Error resolves after stdout write callback", async (
|
||||
const foreignError = vm.runInNewContext("new Error('boom')") as Error;
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
onMounted(() => {
|
||||
setTimeout(() => exit(foreignError), 0);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineComponent, nextTick, onMounted, shallowRef } from "vue";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { render } from "@vue-tui/testing";
|
||||
import { createApp, Text, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
makeFakeWritable,
|
||||
@@ -229,7 +229,7 @@ test("waitUntilRenderFlush waits for unmount write callback", async () => {
|
||||
test("waitUntilRenderFlush resolves after exit with error", async () => {
|
||||
let exitFn!: (err: Error) => void;
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
onMounted(() => {
|
||||
exitFn = exit as (err: Error) => void;
|
||||
});
|
||||
@@ -248,12 +248,91 @@ test("waitUntilRenderFlush resolves after exit with error", async () => {
|
||||
await app.waitUntilRenderFlush();
|
||||
});
|
||||
|
||||
// useApp-level waitUntilRenderFlush tests:
|
||||
// These test that waitUntilRenderFlush works when called from inside a component.
|
||||
// In vue-tui, waitUntilRenderFlush is on the app instance, not a composable,
|
||||
// so these are tested via the app.waitUntilRenderFlush() API above.
|
||||
// The 2 "useApp waitUntilRenderFlush" tests from Ink are covered by the
|
||||
// existing tests since vue-tui exposes the same API on the app object.
|
||||
// useAppContext-level waitUntilRenderFlush tests (Ink parity, ported from Ink
|
||||
// render.tsx "useApp waitUntilRenderFlush …"): waitUntilRenderFlush is reachable
|
||||
// from INSIDE a component via useAppContext() — Ink's useApp() returns the same
|
||||
// { exit, waitUntilRenderFlush } pair. Ink's third "queued in same effect tick"
|
||||
// test relies on React `concurrent: true` (concurrent mode is N/A in Vue — see
|
||||
// .agents/docs/ink-divergences.md), so only the first two are ported.
|
||||
|
||||
test("useAppContext waitUntilRenderFlush resolves after the first frame write callback", async () => {
|
||||
let didInitialWriteCallbackFire = false;
|
||||
let didFlushResolve = false;
|
||||
|
||||
const stdout = createDelayedWriteCallbackStdout({
|
||||
shouldDelay: (chunk) => !isWriteBarrierChunk(chunk),
|
||||
onDelayElapsed: () => {
|
||||
didInitialWriteCallbackFire = true;
|
||||
},
|
||||
});
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const { exit, waitUntilRenderFlush } = useAppContext();
|
||||
onMounted(() => {
|
||||
void (async () => {
|
||||
await waitUntilRenderFlush();
|
||||
didFlushResolve = true;
|
||||
exit();
|
||||
})();
|
||||
});
|
||||
return () => <Text>Hello</Text>;
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
const stderr = makeFakeWritable();
|
||||
const { stream: stdin } = makeFakeStdin();
|
||||
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
|
||||
|
||||
await app.waitUntilExit();
|
||||
expect(didInitialWriteCallbackFire).toBe(true);
|
||||
expect(didFlushResolve).toBe(true);
|
||||
});
|
||||
|
||||
test("useAppContext waitUntilRenderFlush waits for state update frame flush", async () => {
|
||||
let didWorldWriteCallbackFire = false;
|
||||
let didFlushResolve = false;
|
||||
|
||||
const stdout = createDelayedWriteCallbackStdout({
|
||||
shouldDelay: (chunk) =>
|
||||
!isWriteBarrierChunk(chunk) &&
|
||||
stripAnsi(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)).includes(
|
||||
"World",
|
||||
),
|
||||
onDelayElapsed: () => {
|
||||
didWorldWriteCallbackFire = true;
|
||||
},
|
||||
});
|
||||
|
||||
const text = shallowRef("Hello");
|
||||
const App = defineComponent(() => {
|
||||
const { exit, waitUntilRenderFlush } = useAppContext();
|
||||
onMounted(() => {
|
||||
void (async () => {
|
||||
// Settle the initial "Hello" frame first (not delayed by the harness,
|
||||
// which only delays the "World" chunk) so the subsequent "World" write
|
||||
// is unambiguously a second, state-update frame — mirroring the stable
|
||||
// app-level "waits for rerender write callback" test above.
|
||||
await waitUntilRenderFlush();
|
||||
text.value = "World";
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await waitUntilRenderFlush();
|
||||
didFlushResolve = true;
|
||||
exit();
|
||||
})();
|
||||
});
|
||||
return () => <Text>{text.value}</Text>;
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
const stderr = makeFakeWritable();
|
||||
const { stream: stdin } = makeFakeStdin();
|
||||
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
|
||||
|
||||
await app.waitUntilExit();
|
||||
expect(didWorldWriteCallbackFire).toBe(true);
|
||||
expect(didFlushResolve).toBe(true);
|
||||
});
|
||||
|
||||
// --- clear() API test ---
|
||||
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { createApp, Text, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createApp, Text, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted, onScopeDispose, shallowRef } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const counter = shallowRef(0);
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createApp, Text, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createApp, Text, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createApp, Text, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted, onScopeDispose, shallowRef } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const counter = shallowRef(0);
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(exit, 500);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createApp, Text, useExit, useStdin } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useAppContext, useStdin } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
const { setRawMode } = useStdin();
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createApp, Text, useExit, useStdin } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useAppContext, useStdin } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
const { setRawMode } = useStdin();
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createApp, Static, Text, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Static, Text, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
exit(new Error("errored"));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import process from "node:process";
|
||||
import { Box, Text, createApp, useExit } from "@vue-tui/runtime";
|
||||
import { Box, Text, createApp, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, h, onMounted, onScopeDispose } from "vue";
|
||||
|
||||
const Fullscreen = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import process from "node:process";
|
||||
import { Box, Text, createApp, useExit } from "@vue-tui/runtime";
|
||||
import { Box, Text, createApp, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, h, onMounted, onScopeDispose } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import process from "node:process";
|
||||
import { Box, Static, Text, createApp, useExit } from "@vue-tui/runtime";
|
||||
import { Box, Static, Text, createApp, useAppContext } from "@vue-tui/runtime";
|
||||
import { Fragment, defineComponent, h, onMounted, onScopeDispose, shallowRef, watch } from "vue";
|
||||
|
||||
type RerenderFixtureOptions = {
|
||||
@@ -18,7 +18,7 @@ const Issue450RerenderFixtureComponent = defineComponent(
|
||||
heightForFrame: (rows: number, frameCount: number) => number;
|
||||
rows: number;
|
||||
}) => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
const frameCount = shallowRef(0);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
@@ -102,7 +102,7 @@ type InitialFixtureOptions = {
|
||||
|
||||
const Issue450InitialFixtureComponent = defineComponent(
|
||||
(props: { renderedMarker: string; lineCount: number; linePrefix: string }) => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { Text, createApp, useAnimation, useExit } from "@vue-tui/runtime";
|
||||
import { Text, createApp, useAnimation, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, h, watch } from "vue";
|
||||
|
||||
const Spinner = defineComponent(() => {
|
||||
const { frame } = useAnimation({ interval: 8 });
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
watch(frame, (value) => {
|
||||
if (value >= 3) {
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { Text, createApp, useAnimation, useExit } from "@vue-tui/runtime";
|
||||
import { Text, createApp, useAnimation, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, h, watch } from "vue";
|
||||
|
||||
const Spinner = defineComponent(() => {
|
||||
const { frame } = useAnimation({ interval: 8 });
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
watch(frame, (value) => {
|
||||
if (value >= 3) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import process from "node:process";
|
||||
import { createApp, useInput, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, useInput, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const UserInput = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
useInput((input, key) => {
|
||||
if (input === "c" && key.ctrl) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import process from "node:process";
|
||||
import { createApp, Box, Text, useInput, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Box, Text, useInput, useAppContext } from "@vue-tui/runtime";
|
||||
import { computed, defineComponent, h, onMounted, shallowRef, watch } from "vue";
|
||||
|
||||
/**
|
||||
@@ -11,7 +11,7 @@ import { computed, defineComponent, h, onMounted, shallowRef, watch } from "vue"
|
||||
* processed before the deferred state catches up.
|
||||
*/
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
const query = shallowRef("abcde");
|
||||
const deferredQuery = shallowRef("abcde");
|
||||
let done = false;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import process from "node:process";
|
||||
import { createApp, useInput, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, useInput, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const KittyInput = defineComponent({
|
||||
@@ -7,7 +7,7 @@ const KittyInput = defineComponent({
|
||||
test: { type: String, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
process.stdout.write("__READY__");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import process from "node:process";
|
||||
import { createApp, Text, useInput, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useInput, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, h, onMounted } from "vue";
|
||||
|
||||
// Detect MaxListenersExceededWarning
|
||||
@@ -15,7 +15,7 @@ const InputHandler = defineComponent(() => {
|
||||
});
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(exit, 100);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import process from "node:process";
|
||||
import { createApp, Text, useInput, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useInput, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, h, onMounted, shallowRef } from "vue";
|
||||
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
const input = shallowRef("");
|
||||
|
||||
const handleInput = (char: string) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import process from "node:process";
|
||||
import { createApp, useInput, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, useInput, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const UserInput = defineComponent({
|
||||
@@ -7,7 +7,7 @@ const UserInput = defineComponent({
|
||||
test: { type: String, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
let rapidDownArrowCount = 0;
|
||||
let rapidTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import process from "node:process";
|
||||
import { createApp, useExit, useInput, usePaste } from "@vue-tui/runtime";
|
||||
import { createApp, useAppContext, useInput, usePaste } from "@vue-tui/runtime";
|
||||
import { defineComponent, onMounted } from "vue";
|
||||
|
||||
const PasteDemo = defineComponent({
|
||||
@@ -7,7 +7,7 @@ const PasteDemo = defineComponent({
|
||||
test: { type: String, default: undefined },
|
||||
},
|
||||
setup(props) {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
usePaste((text) => {
|
||||
if (props.test === "basic" && text === "hello world") {
|
||||
@@ -41,7 +41,7 @@ const PasteDemo = defineComponent({
|
||||
});
|
||||
|
||||
const MultipleHooksDemo = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
let receivedCount = 0;
|
||||
|
||||
const onPaste = (text: string) => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createApp, Text, useStdout, useExit } from "@vue-tui/runtime";
|
||||
import { createApp, Text, useStdout, useAppContext } from "@vue-tui/runtime";
|
||||
import { defineComponent, h, onMounted } from "vue";
|
||||
|
||||
const WriteToStdout = defineComponent(() => {
|
||||
const { write } = useStdout();
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
|
||||
onMounted(() => {
|
||||
write("Hello from vue-tui to stdout\n");
|
||||
|
||||
@@ -13,7 +13,7 @@ test("public API exposes documented members", () => {
|
||||
"Static",
|
||||
"Transform",
|
||||
// Composables
|
||||
"useExit",
|
||||
"useAppContext",
|
||||
"useInput",
|
||||
"useFocus",
|
||||
"useFocusManager",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Static,
|
||||
Transform,
|
||||
useInput,
|
||||
useExit,
|
||||
useAppContext,
|
||||
useFocus,
|
||||
useFocusManager,
|
||||
useStdin,
|
||||
@@ -65,9 +65,9 @@ describe("renderToString", () => {
|
||||
expect(output).toContain("with input");
|
||||
});
|
||||
|
||||
test("useExit does not throw in renderToString", () => {
|
||||
test("useAppContext does not throw in renderToString", () => {
|
||||
const App = defineComponent(() => {
|
||||
const exit = useExit();
|
||||
const { exit } = useAppContext();
|
||||
// exit is a function but calling it is a no-op
|
||||
void exit;
|
||||
return () => <Text>with exit</Text>;
|
||||
|
||||
@@ -73,7 +73,7 @@ useInput((input) => {
|
||||
| `useInput(handler, opts?)` | Keyboard input — `(input, key)` with modifier and arrow key detection |
|
||||
| `useFocus(opts?)` | Component-level focus — returns `{ isFocused, focus }` |
|
||||
| `useFocusManager()` | App-level focus — `focusNext()`, `focusPrevious()`, `focus(id)` |
|
||||
| `useExit()` | Programmatic exit — returns `exit(error?)` |
|
||||
| `useAppContext()` | App context — `{ exit(error?), waitUntilRenderFlush() }` |
|
||||
| `useTerminalSize()` | Reactive terminal dimensions — `{ columns, rows }` |
|
||||
| `useAnimation(opts?)` | Frame-based animation loop — returns `{ frame, time, delta, reset }` |
|
||||
| `useBoxMetrics(ref)` | Reactive layout metrics — `{ width, height, left, top, hasMeasured }` |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { inject } from "vue";
|
||||
import { AppContextKey } from "../context.ts";
|
||||
|
||||
/**
|
||||
* Returns the app-level context for a component inside the render tree:
|
||||
*
|
||||
* - `exit(error?)` — end the app. Pass an `Error` to reject `app.waitUntilExit()`
|
||||
* (and any awaiter); pass any other value to resolve with it as the result;
|
||||
* call with no args to resolve with `undefined`.
|
||||
* - `waitUntilRenderFlush()` — resolve once the next frame has been committed and
|
||||
* flushed to the output stream.
|
||||
*
|
||||
* Mirrors Ink's `useApp()` (which returns `{ exit, waitUntilRenderFlush }`). It
|
||||
* is named `useAppContext` rather than `useApp` to avoid colliding with Vue's
|
||||
* own "App" mental model (`createApp`, the Vue application instance) — the same
|
||||
* Vue-native-naming choice vue-tui makes with `createApp()` vs Ink's `render()`.
|
||||
* Streams remain on their dedicated peer composables (`useStdin`, `useStdout`,
|
||||
* `useStderr`), exactly as in Ink.
|
||||
*/
|
||||
export function useAppContext(): {
|
||||
exit: (errorOrResult?: unknown) => void;
|
||||
waitUntilRenderFlush: () => Promise<void>;
|
||||
} {
|
||||
const ctx = inject(AppContextKey);
|
||||
if (!ctx) throw new Error("useAppContext() must be called inside a vue-tui render tree");
|
||||
return { exit: ctx.exit, waitUntilRenderFlush: ctx.waitUntilRenderFlush };
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { inject } from "vue";
|
||||
import { AppContextKey } from "../context.ts";
|
||||
|
||||
/**
|
||||
* Returns the exit function so a component can end the app from inside the
|
||||
* tree. Pass an Error to reject `app.waitUntilExit()` (and any awaiter); pass
|
||||
* any other value to resolve with it as the result; call with no args to
|
||||
* resolve with undefined.
|
||||
*/
|
||||
export function useExit(): (errorOrResult?: unknown) => void {
|
||||
const ctx = inject(AppContextKey);
|
||||
if (!ctx) throw new Error("useExit() must be called inside a vue-tui render tree");
|
||||
return ctx.exit;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface CursorPosition {
|
||||
|
||||
export interface AppContext {
|
||||
exit: (errorOrResult?: unknown) => void;
|
||||
waitUntilRenderFlush: () => Promise<void>;
|
||||
stdout: NodeJS.WriteStream;
|
||||
stderr: NodeJS.WriteStream;
|
||||
stdin: NodeJS.ReadStream;
|
||||
|
||||
@@ -14,7 +14,7 @@ export { Spacer } from "./components/Spacer.ts";
|
||||
export { Static, type StaticProps } from "./components/Static.ts";
|
||||
export { Transform, type TransformProps } from "./components/Transform.ts";
|
||||
|
||||
export { useExit } from "./composables/useExit.ts";
|
||||
export { useAppContext } from "./composables/useAppContext.ts";
|
||||
export { useInput, type Key, type UseInputOptions } from "./composables/useInput.ts";
|
||||
export { usePaste, type UsePasteOptions } from "./composables/usePaste.ts";
|
||||
export { useFocus, type UseFocusOptions } from "./composables/useFocus.ts";
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface RenderToStringOptions {
|
||||
* starting a persistent terminal application.
|
||||
*
|
||||
* Terminal-specific composables (`useInput`, `useStdin`, `useStdout`,
|
||||
* `useStderr`, `useExit`, `useFocus`, `useFocusManager`) return default
|
||||
* `useStderr`, `useAppContext`, `useFocus`, `useFocusManager`) return default
|
||||
* no-op values since there is no terminal session. They will not throw, but
|
||||
* they will not function as in a live terminal.
|
||||
*
|
||||
@@ -168,6 +168,9 @@ export function renderToString(component: Component, options?: RenderToStringOpt
|
||||
function createNoOpAppContext(isScreenReaderEnabled = false): AppContext {
|
||||
return {
|
||||
exit: () => {},
|
||||
// No terminal session: nothing to flush, resolve immediately (mirrors Ink's
|
||||
// default AppContext `async waitUntilRenderFlush() {}`).
|
||||
waitUntilRenderFlush: async () => {},
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
stdin: process.stdin,
|
||||
|
||||
@@ -545,7 +545,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
//
|
||||
// teardownStarted mirrors Ink's `isUnmounting` half of that guard:
|
||||
// app.unmount() runs teardown()+resolveExit() WITHOUT setting
|
||||
// exitInitiated, so a retained useExit() called re-entrantly DURING
|
||||
// exitInitiated, so a retained exit() (from useAppContext()) called re-entrantly DURING
|
||||
// unmount teardown (or any exit() after unmount) would otherwise pass
|
||||
// the exitInitiated check, overwrite pendingExitResult/pendingExitError
|
||||
// and queue a microtask — letting that late value win over the unmount.
|
||||
@@ -570,6 +570,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
resolveExit();
|
||||
});
|
||||
},
|
||||
waitUntilRenderFlush,
|
||||
stdout,
|
||||
stderr,
|
||||
stdin,
|
||||
@@ -959,7 +960,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
// Auto-cleanup on process exit (process.exit, event-loop drain, uncaught
|
||||
// exception — anything that fires Node's 'exit' event). teardown() is
|
||||
// sync and idempotent, safe to call from this hook. If the user already
|
||||
// called unmount() / useExit(), this is a no-op.
|
||||
// called unmount() / exit() (via useAppContext()), this is a no-op.
|
||||
const exitListener = () => teardown();
|
||||
process.on("exit", exitListener);
|
||||
mountedExitListener = exitListener;
|
||||
@@ -1030,7 +1031,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
return exitPromise;
|
||||
};
|
||||
|
||||
app.waitUntilRenderFlush = async function waitUntilRenderFlush(): Promise<void> {
|
||||
// Hoisted so the injected appContext (built inside mount()) can expose the
|
||||
// SAME implementation via useAppContext().waitUntilRenderFlush — both the
|
||||
// TuiApp handle and the in-tree composable resolve identically.
|
||||
async function waitUntilRenderFlush(): Promise<void> {
|
||||
// Flush any pending OR scheduled render. Gating on hasPending() alone
|
||||
// misses the window after schedule() queues a commit but before the
|
||||
// post-flush callback sets hasPendingFlag, letting this resolve early.
|
||||
@@ -1051,7 +1055,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
|
||||
setImmediate(resolve);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
app.waitUntilRenderFlush = waitUntilRenderFlush;
|
||||
|
||||
app.clear = function clear(): void {
|
||||
mountedClear?.();
|
||||
|
||||
Reference in New Issue
Block a user