Files
3c95e7cffb fix(cli): make the ink useBoxMetrics loop guard independent of machine speed (#11835)
* fix(cli): make the ink useBoxMetrics loop guard independent of machine speed

The measurement budget added in #11565 refilled on elapsed wall-clock time
(32 measurements per 16ms), but React's nested-update cap is a fixed count of
50. The guard therefore only won that race while ~32 commits fit inside one
16ms window (~0.5ms each, including a Yoga layout pass). Under CPU contention
or on a slower platform the window refilled mid-cascade, the count reset to 0,
the budget never tripped, and React threw "Maximum update depth exceeded"
(#185) out of the commit phase - which is what the regression suite caught on
loaded CI runners, and why lastFrame() came back empty: onRender never ran.

Spend budget only on measurements that actually change the metrics, since
those are the only ones that schedule another render of the component, and
refill it on events in React's own update chain rather than on elapsed
milliseconds: a render the hook did not cause, a measurement that settled, and
a terminal resize. The cap is 16 self-driven renders - the same burst the old
budget allowed in practice, since updateMetrics runs twice per render - which
sits well under React's 50 and no longer depends on how fast the machine is.
No timer handle is introduced, so fake-timer tests still drain and React's act
queue is unaffected.

Adds a deterministic reproduction that pins machine speed by advancing
performance.now() 16ms per read: it fails with the reported #185 stack before
this change and passes after. Also adds regression coverage that the guard
still stops an oscillation in far fewer commits than React tolerates, and that
a box driven by external re-renders keeps reporting fresh metrics well past a
single budget.

Fixes #11817

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-issue-patrol/jmu0yjkfs3z

* fix(cli): settle the ink box-metrics budget per commit, not per render

The guard settled its budget in the render body. React invokes a render body
twice under StrictMode, which `DEBUG=1 npm run dev` renders the CLI inside:
the first invocation spends budget and the second refills it, so the count
never drains, the guard never trips, and React's #185 fires - the same failure
#11817 reports, on the one code path that renders inside StrictMode.

Settle the budget from the root layout listener instead. It runs once per
commit and never during render, so a doubled render body cannot double count
it, and the measurement that schedules a render marks the commit that follows
as the hook's own: that commit spends budget, any other commit refills it.
The cap of 16 and the resize refill are unchanged.

Adds the StrictMode case to the regression suite - it fails with #185 against
both the previous patch and unpatched ink - and rewords the wall-clock case to
say what its performance.now() spy actually establishes.

* style(cli): keep the guard test React import on one line

Adding StrictMode to the existing one-line import is what Prettier produces;
the expanded form failed the lint job's Prettier check.

* test(cli): pin the resize refill, the budget bounds and the shared oscillator

The resize refill is the only path that reaches an instance whose budget
already tripped, and dropping it left every case green. The render bounds
also admitted any constant up to React own cap and pinned no floor at all.

Folding the counting fixture back into the oscillator removes the second
copy of the oscillation rule, and the metrics dispatch is now skipped when
the caller already knows the value did not change.

* fix(cli): arm the ink box-metrics guard on a hasMeasured transition

Round-2 review findings on the previous revision.

The budget is now armed when `hasMeasured` flips as well as when a measurement
changes: `setHasMeasured` schedules a commit of its own with no changed
measurement behind it, so a consumer gating the measured element on
`hasMeasured` took the refill branch on every commit and ran to React's cap.
Only the transition arms it - that call is unconditional, and arming on the
call would charge the external commits that keep a streaming box measuring.

Test changes, all pinned by mutation:

- The render counter is commit-scoped (reported from an effect, not the render
  body) and the budget bound now runs in both StrictMode modes. With a
  render-body counter the StrictMode arm reports 34 against `<= 24`.
- The StrictMode case pins the clock, so it is red against the pre-PR guard
  instead of green; it now fails with `Maximum update depth exceeded` and the
  commit-phase stack.
- Dropped the try/catch around that mount. It does not contain a #185 that
  escapes the commit phase - every case after it fails either way - so it only
  cost the stack trace.
- Corrected the resize case's title and comment: any commit in the ink root
  refills a tripped instance, not only a resize. A resize is special in that
  ink recomputes it with no React commit at all.
- Folded the clock pinning into one helper, restored in a `finally`.

Verified at this head: guard suite 10/10; the 8-file `useBoxMetrics` consumer
suite 173 passed; loaded A/B (6 CPU burners on 4 cores, 8 consecutive rounds,
`--retry=0`) 8/8 green here against 8/8 red on the pre-PR guard. The patch
round-trips: pristine `[email protected]` + `npx patch-package` reproduces
`build/hooks/use-box-metrics.js` at blob `a6d1a4d`.

Co-authored-by: Qwen-Coder <[email protected]>

* test(cli): pin the multi-pass budget headroom and correct the guard comments

The lower bound on the budget was only pinned as "more than 2 commits", and no
cap tighter than the oscillator needs can violate that: the oscillator settles
after cap + 2 commits, so a cap of 2 leaves the suite green while a consumer
legitimately needing a second measurement pass freezes mid-convergence. Add a
consumer that halves its own width on every measurement and assert the settled
width reaches the frame. It settles at 3px on the current budget and freezes at
16px with the budget lowered to 2.

The guard comments claimed a fixed per-commit trip depth, and that onLayout runs
for any commit in the ink root. The charge happens per layout-listener
invocation, and the listener is only registered while the measured ref resolves
to a root, so a consumer that detaches its measured element is charged for
roughly every other commit and trips at up to twice the count. Say that instead,
and stop promising headroom the constant does not deliver for that shape.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu1r4dew54

* test(cli): pin the commit-path refill and the gated cascade's depth

Three review findings on the loop-guard suite, plus the PR description's stale
numbers.

The commit-path refill of an already-tripped instance had no witness: every
other recovery in the file goes through stdout.emit('resize'), and the
external-rerender case never trips (its count oscillates between 0 and 1), so
the comment on the resize case asserted a guarantee nothing checked. The new
case trips the oscillator, then bumps a sibling that owns its own state and
shifts the oscillator's left, and asserts the oscillator runs a second cascade
off that commit. Both refill paths are now pinned separately: deleting the
onResize reset reddens only the resize case, while a partial refill or one
gated on the instance not being tripped reddens only the new case (measured
deltas 1 and 0, against 16 at this head).

HasMeasuredGatedBox reports its commits through the same onRender counter its
siblings use, and the case now asserts the cascade's depth (<= 34) alongside
the frame. The frame alone is parity-blind - this fixture detaches its measured
element every other commit, so whichever of its two states it freezes in, the
last non-blank frame is the fallback render - and the suite stayed green with
the budget at 23 or 26, which takes this cascade to 46 or 52 commits against
React's own cap of 50.

ConvergingBox drops the `from`/`floor` props no caller ever sets and inlines
the 32 and 3 that its comment's arithmetic is about, so the multi-pass headroom
property cannot be quietly disabled by a mount that parameterises it.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu1zp0la5i

* docs(cli): narrow the guard comments to the bound it delivers

The vendored patch's two comments promised that "a cascade trips after the
same number of charged invocations on any machine", which review measured to
be false for two self-sustaining shapes: a cascade re-entering through a
parent's state, and one where two instances in a single component refill each
other's budget. Neither reaches the count - zero clamps - and only React's
50-update cap stops them, because the budget is per hook instance while the
cap is per root.

Both shapes are now named in the comment, with #11858 as the tracked rewrite.
The detached-ref caveat and the wall-clock comparison are unchanged, and the
hook is byte-identical apart from these comment lines: 12/12 tests pass.

Refs #11817

* test(cli): pin the loop guard's exhaustion precondition and cascade floor

Two recovery cases preconditioned on `afterTrip > 1` where the refill lines
they pin depend on the budget having been exhausted, and the gated cascade
case asserted a depth ceiling with no floor, so an early-settling hook passed
both. Assert exhaustion (`>= 16`, the vendored budget) at both preconditions
and floor the gated case's depth at the same constant.

Mutation-verified on the real suite: a one-line `hasMeasured` latch drops the
gated cascade from 32 commits to 2 and now reddens it (`2 >= 16`); a hook that
dedupes seen layouts drops both recovery cases to `afterTrip=3` and now reddens
them, where the resize case previously stayed green with the `onResize` reset
deleted. Deleting either refill line alone still reddens only its own case.

* fix(cli): scope the box metrics guard to the ink root

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
2026-09-16 11:43:37 +00:00

1299 lines
60 KiB
Diff

diff --git a/node_modules/ink/build/components/Text.d.ts b/node_modules/ink/build/components/Text.d.ts
index 1d7bf6b..29401a6 100644
--- a/node_modules/ink/build/components/Text.d.ts
+++ b/node_modules/ink/build/components/Text.d.ts
@@ -47,9 +47,13 @@ export type Props = {
This property tells Ink to wrap or truncate text if its width is larger than the container. If `wrap` is passed (the default), Ink will wrap text and split it into multiple lines. If `hard` is passed, Ink will fill each line to the full column width, breaking words as necessary. If `truncate-*` is passed, Ink will truncate text instead, resulting in one line of text with the rest cut off.
*/
readonly wrap?: Styles['textWrap'];
+ readonly selectable?: boolean;
+ readonly selectionFlow?: string;
+ readonly selectionBreakAfter?: 'soft' | 'hard';
+ readonly selectionJoiner?: string;
readonly children?: ReactNode;
};
/**
This component can display text and change its style to make it bold, underlined, italic, or strikethrough.
*/
-export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null;
+export default function Text({ color, backgroundColor, dimColor, bold, italic, underline, strikethrough, inverse, wrap, selectable, selectionFlow, selectionBreakAfter, selectionJoiner, children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, }: Props): React.JSX.Element | null;
diff --git a/node_modules/ink/build/components/Text.js b/node_modules/ink/build/components/Text.js
index 2e4b9ff..77ed0f6 100644
--- a/node_modules/ink/build/components/Text.js
+++ b/node_modules/ink/build/components/Text.js
@@ -6,7 +6,7 @@ import { backgroundContext } from './BackgroundContext.js';
/**
This component can display text and change its style to make it bold, underlined, italic, or strikethrough.
*/
-export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) {
+export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', selectable = true, selectionFlow, selectionBreakAfter, selectionJoiner = '', children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }) {
const { isScreenReaderEnabled } = useContext(accessibilityContext);
const inheritedBackgroundColor = useContext(backgroundContext);
const childrenOrAriaLabel = isScreenReaderEnabled && ariaLabel ? ariaLabel : children;
@@ -45,6 +45,6 @@ export default function Text({ color, backgroundColor, dimColor = false, bold =
if (isScreenReaderEnabled && ariaHidden) {
return null;
}
- return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform }, childrenOrAriaLabel));
+ return (React.createElement("ink-text", { style: { flexGrow: 0, flexShrink: 1, flexDirection: 'row', textWrap: wrap }, internal_transform: transform, selectable: selectable, selectionFlow: selectionFlow, selectionBreakAfter: selectionBreakAfter, selectionJoiner: selectionJoiner }, childrenOrAriaLabel));
}
//# sourceMappingURL=Text.js.map
\ No newline at end of file
diff --git a/node_modules/ink/build/cursor-helpers.d.ts b/node_modules/ink/build/cursor-helpers.d.ts
index c6e9466..6dd4c0f 100644
--- a/node_modules/ink/build/cursor-helpers.d.ts
+++ b/node_modules/ink/build/cursor-helpers.d.ts
@@ -11,9 +11,11 @@ Compare two cursor positions. Returns true if they differ.
export declare const cursorPositionChanged: (a: CursorPosition | undefined, b: CursorPosition | undefined) => boolean;
/**
Build escape sequence to move cursor from bottom of output to the target position and show it.
-Assumes cursor is at (col 0, line visibleLineCount) — i.e. just after the last output line.
+When hasTrailingNewline is true the terminal cursor sits at (col 0, line visibleLineCount)
+— i.e. just after the last output line. When false (fullscreen mode) it sits at the END
+of the last visible line (line visibleLineCount - 1), so one fewer cursorUp is needed.
*/
-export declare const buildCursorSuffix: (visibleLineCount: number, cursorPosition: CursorPosition | undefined) => string;
+export declare const buildCursorSuffix: (visibleLineCount: number, cursorPosition: CursorPosition | undefined, hasTrailingNewline?: boolean) => string;
/**
Build escape sequence to move cursor from previousCursorPosition back to the bottom of output.
This must be done before eraseLines or any operation that assumes cursor is at the bottom.
@@ -25,6 +27,7 @@ export type CursorOnlyInput = {
previousCursorPosition: CursorPosition | undefined;
visibleLineCount: number;
cursorPosition: CursorPosition | undefined;
+ hasTrailingNewline?: boolean;
};
/**
Build the escape sequence for cursor-only updates (output unchanged, cursor moved).
diff --git a/node_modules/ink/build/cursor-helpers.js b/node_modules/ink/build/cursor-helpers.js
index be49354..14215f0 100644
--- a/node_modules/ink/build/cursor-helpers.js
+++ b/node_modules/ink/build/cursor-helpers.js
@@ -8,13 +8,15 @@ Compare two cursor positions. Returns true if they differ.
export const cursorPositionChanged = (a, b) => a?.x !== b?.x || a?.y !== b?.y;
/**
Build escape sequence to move cursor from bottom of output to the target position and show it.
-Assumes cursor is at (col 0, line visibleLineCount) — i.e. just after the last output line.
+When hasTrailingNewline is true the terminal cursor sits at (col 0, line visibleLineCount)
+— i.e. just after the last output line. When false (fullscreen mode) it sits at the END
+of the last visible line (line visibleLineCount - 1), so one fewer cursorUp is needed.
*/
-export const buildCursorSuffix = (visibleLineCount, cursorPosition) => {
+export const buildCursorSuffix = (visibleLineCount, cursorPosition, hasTrailingNewline = true) => {
if (!cursorPosition) {
return '';
}
- const moveUp = visibleLineCount - cursorPosition.y;
+ const moveUp = (hasTrailingNewline ? visibleLineCount : visibleLineCount - 1) - cursorPosition.y;
return ((moveUp > 0 ? ansiEscapes.cursorUp(moveUp) : '') +
ansiEscapes.cursorTo(cursorPosition.x) +
showCursorEscape);
@@ -27,8 +29,10 @@ export const buildReturnToBottom = (previousLineCount, previousCursorPosition) =
if (!previousCursorPosition) {
return '';
}
- // PreviousLineCount includes trailing newline, so visible lines = previousLineCount - 1
- // cursor is at previousCursorPosition.y, need to go to line (previousLineCount - 1)
+ // previousLineCount is lines.length from str.split('\n'), which already
+ // includes the trailing empty element when the output ends with '\n'.
+ // The bottom line is therefore always previousLineCount - 1 regardless
+ // of whether a trailing newline was present.
const down = previousLineCount - 1 - previousCursorPosition.y;
return ((down > 0 ? ansiEscapes.cursorDown(down) : '') + ansiEscapes.cursorTo(0));
};
@@ -39,7 +43,7 @@ Hides cursor if it was previously shown, returns to bottom, then repositions.
export const buildCursorOnlySequence = (input) => {
const hidePrefix = input.cursorWasShown ? hideCursorEscape : '';
const returnToBottom = buildReturnToBottom(input.previousLineCount, input.previousCursorPosition);
- const cursorSuffix = buildCursorSuffix(input.visibleLineCount, input.cursorPosition);
+ const cursorSuffix = buildCursorSuffix(input.visibleLineCount, input.cursorPosition, input.hasTrailingNewline);
return hidePrefix + returnToBottom + cursorSuffix;
};
/**
diff --git a/node_modules/ink/build/frame-controller.d.ts b/node_modules/ink/build/frame-controller.d.ts
new file mode 100644
index 0000000..c294036
--- /dev/null
+++ b/node_modules/ink/build/frame-controller.d.ts
@@ -0,0 +1,36 @@
+/// <reference types="node" />
+export type ScreenSelection = {
+ sx: number;
+ sy: number;
+ ex: number;
+ ey: number;
+};
+export type FrameCell = {
+ type: 'char';
+ value: string;
+ fullWidth: boolean;
+ styles: unknown[];
+ selectable: boolean;
+ flowId: number | null;
+};
+export type FrameBoundary = {
+ kind: 'soft' | 'hard';
+ joiner: string;
+ selectable: boolean;
+ flowId: number;
+};
+export type ReadonlyFrame = {
+ width: number;
+ height: number;
+ cells: ReadonlyArray<ReadonlyArray<FrameCell>>;
+ boundaries: ReadonlyArray<ReadonlyArray<FrameBoundary | null>>;
+};
+export type FrameController = {
+ getFrame(): ReadonlyFrame | null;
+ getSelection(): ScreenSelection | null;
+ setSelection(selection: ScreenSelection | null): void;
+ subscribe(listener: (frame: ReadonlyFrame) => void): () => void;
+ publishFrame(frame: ReadonlyFrame): void;
+};
+export declare const createFrameController: (requestRender: () => void) => FrameController;
+export declare const getFrameController: (stdout: NodeJS.WriteStream) => FrameController | undefined;
diff --git a/node_modules/ink/build/frame-controller.js b/node_modules/ink/build/frame-controller.js
new file mode 100644
index 0000000..745a4ba
--- /dev/null
+++ b/node_modules/ink/build/frame-controller.js
@@ -0,0 +1,45 @@
+import instances from './instances.js';
+const sameSelection = (a, b) => {
+ if (a === b) {
+ return true;
+ }
+ if (!a || !b) {
+ return false;
+ }
+ return a.sx === b.sx && a.sy === b.sy && a.ex === b.ex && a.ey === b.ey;
+};
+// Creates the bidirectional bridge between the application and the renderer:
+// the app reads the latest composited frame (getFrame) and pushes a selection
+// (setSelection) that the renderer highlights before serialization. setSelection
+// deduplicates and schedules exactly one repaint through Ink's own throttle via
+// the requestRender callback, so it never mutates already-committed frame state.
+export const createFrameController = (requestRender) => {
+ let currentSelection = null;
+ let lastFrame = null;
+ const listeners = new Set();
+ return {
+ getFrame: () => lastFrame,
+ getSelection: () => currentSelection,
+ setSelection(selection) {
+ if (sameSelection(currentSelection, selection)) {
+ return;
+ }
+ currentSelection = selection;
+ requestRender();
+ },
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ publishFrame(frame) {
+ lastFrame = frame;
+ for (const listener of listeners) {
+ listener(frame);
+ }
+ },
+ };
+};
+export const getFrameController = (stdout) => instances.get(stdout)?.frameController;
+//# sourceMappingURL=frame-controller.js.map
diff --git a/node_modules/ink/build/hooks/use-box-metrics.js b/node_modules/ink/build/hooks/use-box-metrics.js
index c4386e4..753a9b4 100644
--- a/node_modules/ink/build/hooks/use-box-metrics.js
+++ b/node_modules/ink/build/hooks/use-box-metrics.js
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useMemo } from 'react';
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { addLayoutListener } from '../dom.js';
import useStdout from './use-stdout.js';
const emptyMetrics = {
@@ -7,6 +7,50 @@ const emptyMetrics = {
left: 0,
top: 0,
};
+// React throws error #185 once 50 nested updates stack up on a root, so the loop
+// guard below has to stop a self-sustaining cascade before that. The budget is
+// shared by every useBoxMetrics instance on the root and charged once per commit
+// in which any instance scheduled an update. A microtask refills it only after
+// the current synchronous nested-update chain has unwound, so parent-routed
+// commits cannot re-arm it while later input and timer events remain live.
+const maxSelfDrivenMeasurements = 16;
+const measurementGuards = new WeakMap();
+const getMeasurementGuard = (rootNode) => {
+ const existing = measurementGuards.get(rootNode);
+ if (existing) {
+ return existing;
+ }
+ const guard = {
+ count: 0,
+ listeners: new Set(),
+ };
+ measurementGuards.set(rootNode, guard);
+ addLayoutListener(rootNode, () => {
+ if (guard.count >= maxSelfDrivenMeasurements) {
+ return;
+ }
+ let changed = false;
+ for (const listener of guard.listeners) {
+ changed = listener() || changed;
+ }
+ if (changed) {
+ if (guard.count === 0) {
+ queueMicrotask(() => {
+ guard.count = 0;
+ });
+ }
+ guard.count += 1;
+ }
+ });
+ return guard;
+};
+const addMeasurementListener = (rootNode, listener) => {
+ const guard = getMeasurementGuard(rootNode);
+ guard.listeners.add(listener);
+ return () => {
+ guard.listeners.delete(listener);
+ };
+};
// eslint-disable-next-line @typescript-eslint/no-restricted-types
const findRootNode = (node) => {
if (!node) {
@@ -50,21 +94,48 @@ const useBoxMetrics = (
ref) => {
const [metrics, setMetrics] = useState(emptyMetrics);
const [hasMeasured, setHasMeasured] = useState(false);
+ // Per-instance measurement state. The budget itself lives on the ink root,
+ // because React applies its nested-update limit at that scope too.
+ const stateRef = useRef({
+ last: null,
+ hasMeasured: false,
+ rootNode: undefined,
+ });
const { stdout } = useStdout();
const updateMetrics = useCallback(() => {
+ const state = stateRef.current;
+ const rootNode = findRootNode(ref.current) ?? state.rootNode;
+ if (rootNode) {
+ state.rootNode = rootNode;
+ }
+ const guard = rootNode ? getMeasurementGuard(rootNode) : undefined;
+ if (guard?.count >= maxSelfDrivenMeasurements) {
+ return false;
+ }
const layout = ref.current?.yogaNode?.getComputedLayout() ?? emptyMetrics;
- setMetrics(previousMetrics => {
- const hasChanged = previousMetrics.width !== layout.width ||
- previousMetrics.height !== layout.height ||
- previousMetrics.left !== layout.left ||
- previousMetrics.top !== layout.top;
- return hasChanged ? layout : previousMetrics;
- });
- setHasMeasured(Boolean(ref.current));
+ const last = state.last;
+ const hasChanged = last === null ||
+ last.width !== layout.width ||
+ last.height !== layout.height ||
+ last.left !== layout.left ||
+ last.top !== layout.top;
+ if (hasChanged) {
+ state.last = layout;
+ setMetrics(layout);
+ }
+ const nextHasMeasured = Boolean(ref.current);
+ const hasMeasuredChanged = nextHasMeasured !== state.hasMeasured;
+ if (hasMeasuredChanged) {
+ state.hasMeasured = nextHasMeasured;
+ }
+ setHasMeasured(nextHasMeasured);
+ return hasChanged || hasMeasuredChanged;
}, [ref]);
// Runs after every render of this component.
// This keeps metrics fresh when local state/props in this subtree change.
- useEffect(updateMetrics);
+ useEffect(() => {
+ updateMetrics();
+ });
// Subscribe to root layout commits so memoized components still receive
// sibling-driven position/size updates, even when they skip re-rendering.
useEffect(() => {
@@ -72,16 +143,27 @@ ref) => {
if (!rootNode) {
return;
}
- return addLayoutListener(rootNode, updateMetrics);
+ stateRef.current.rootNode = rootNode;
+ return addMeasurementListener(rootNode, updateMetrics);
});
// Terminal resize events do not go through React's render cycle. Ink
// recalculates Yoga layout in its own resize handler — registered in the
// Ink constructor, before this hook mounts — so by the time the resize
// callback runs, Yoga has already computed the post-resize metrics.
useEffect(() => {
- stdout.on('resize', updateMetrics);
+ const onResize = () => {
+ // A resize is a new interaction, so it refills the budget even when
+ // an earlier oscillation already tripped it.
+ const rootNode = findRootNode(ref.current) ?? stateRef.current.rootNode;
+ if (rootNode) {
+ const guard = getMeasurementGuard(rootNode);
+ guard.count = 0;
+ }
+ updateMetrics();
+ };
+ stdout.on('resize', onResize);
return () => {
- stdout.off('resize', updateMetrics);
+ stdout.off('resize', onResize);
};
}, [stdout, updateMetrics]);
return useMemo(() => ({
diff --git a/node_modules/ink/build/index.d.ts b/node_modules/ink/build/index.d.ts
index f3e7349..cce56fe 100644
--- a/node_modules/ink/build/index.d.ts
+++ b/node_modules/ink/build/index.d.ts
@@ -39,3 +39,5 @@ export { default as measureElement } from './measure-element.js';
export type { DOMElement } from './dom.js';
export { kittyFlags, kittyModifiers } from './kitty-keyboard.js';
export type { KittyKeyboardOptions, KittyFlagName } from './kitty-keyboard.js';
+export { getFrameController } from './frame-controller.js';
+export type { FrameController, ReadonlyFrame, ScreenSelection, FrameCell, FrameBoundary, } from './frame-controller.js';
diff --git a/node_modules/ink/build/index.js b/node_modules/ink/build/index.js
index cc849f0..25d6b33 100644
--- a/node_modules/ink/build/index.js
+++ b/node_modules/ink/build/index.js
@@ -21,4 +21,5 @@ export { default as useWindowSize } from './hooks/use-window-size.js';
export { default as useBoxMetrics } from './hooks/use-box-metrics.js';
export { default as measureElement } from './measure-element.js';
export { kittyFlags, kittyModifiers } from './kitty-keyboard.js';
+export { getFrameController } from './frame-controller.js';
//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/node_modules/ink/build/ink.js b/node_modules/ink/build/ink.js
index ef659f3..1beca9b 100644
--- a/node_modules/ink/build/ink.js
+++ b/node_modules/ink/build/ink.js
@@ -17,6 +17,7 @@ import { hideCursorEscape, showCursorEscape } from './cursor-helpers.js';
import logUpdate from './log-update.js';
import { bsu, esu, shouldSynchronize } from './write-synchronized.js';
import instances from './instances.js';
+import { createFrameController } from './frame-controller.js';
import App from './components/App.js';
import { accessibilityContext as AccessibilityContext } from './components/AccessibilityContext.js';
import { resolveFlags, } from './kitty-keyboard.js';
@@ -160,6 +161,7 @@ export default class Ink {
kittyProtocolEnabled = false;
cancelKittyDetection;
nextRenderCommit;
+ frameController;
constructor(options) {
autoBind(this);
this.options = options;
@@ -196,6 +198,12 @@ export default class Ink {
this.throttledOnRender = throttled;
}
this.rootNode.onImmediateRender = this.onRender;
+ // Bridge for application-level text selection: setSelection schedules a
+ // throttled repaint via the same path as a normal render, and each frame
+ // publishes its composited cells (see onRender).
+ this.frameController = createFrameController(() => {
+ this.rootNode.onRender?.();
+ });
this.rootNode.onStaticChange = this.handleStaticChange;
this.log = logUpdate.create(options.stdout, {
incremental: options.incrementalRendering,
@@ -317,7 +325,16 @@ export default class Ink {
this.nextRenderCommit = undefined;
}
const startTime = performance.now();
- const { output, outputHeight, staticOutput } = render(this.rootNode, this.isScreenReaderEnabled);
+ const selection = this.frameController?.getSelection() ?? null;
+ const { output, outputHeight, staticOutput, cells, boundaries } = render(this.rootNode, this.isScreenReaderEnabled, selection);
+ if (cells && this.frameController) {
+ this.frameController.publishFrame({
+ width: cells.reduce((width, row) => Math.max(width, row.length), 0),
+ height: cells.length,
+ cells,
+ boundaries: boundaries ?? [],
+ });
+ }
this.options.onRender?.({ renderTime: performance.now() - startTime });
// If <Static> output isn't empty, it means new children have been added to it
const hasStaticOutput = staticOutput && staticOutput !== '\n';
@@ -642,6 +659,7 @@ export default class Ink {
this.alternateScreen = this.resolveAlternateScreenOption(enabled, this.interactive);
if (this.alternateScreen) {
this.writeBestEffort(this.options.stdout, ansiEscapes.enterAlternativeScreen);
+ this.writeBestEffort(this.options.stdout, ansiEscapes.clearTerminal);
this.writeBestEffort(this.options.stdout, hideCursorEscape);
}
}
@@ -688,6 +706,7 @@ export default class Ink {
return this.nextRenderCommit.promise;
}
renderInteractiveFrame(output, outputHeight, staticOutput) {
+ this.log.setCursorPosition(this.cursorPosition);
const hasStaticOutput = staticOutput !== '';
const isTty = this.options.stdout.isTTY;
// Detect fullscreen: output fills or exceeds terminal height.
@@ -707,7 +726,7 @@ export default class Ink {
if (sync) {
this.options.stdout.write(bsu);
}
- this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + output);
+ this.options.stdout.write(ansiEscapes.clearTerminal + this.fullStaticOutput + outputToRender);
this.lastOutput = output;
this.lastOutputToRender = outputToRender;
this.lastOutputHeight = outputHeight;
diff --git a/node_modules/ink/build/log-update.js b/node_modules/ink/build/log-update.js
index cdb5fc4..cd10dc2 100644
--- a/node_modules/ink/build/log-update.js
+++ b/node_modules/ink/build/log-update.js
@@ -4,6 +4,10 @@ import { cursorPositionChanged, buildCursorSuffix, buildCursorOnlySequence, buil
// Count visible lines in a string, ignoring the trailing empty element
// that `split('\n')` produces when the string ends with '\n'.
const visibleLineCount = (lines, str) => str.endsWith('\n') ? lines.length - 1 : lines.length;
+// Lazy cursor getters resolve against live Yoga layout. Each renderer prepares
+// one numeric snapshot per flush and invalidates unconsumed snapshots on state
+// resets so they cannot cross flush boundaries.
+const snapshotCursorPosition = (position) => position ? { x: position.x, y: position.y } : undefined;
const createStandard = (stream, { showCursor = false } = {}) => {
let previousLineCount = 0;
let previousOutput = '';
@@ -12,7 +16,26 @@ const createStandard = (stream, { showCursor = false } = {}) => {
let cursorDirty = false;
let previousCursorPosition;
let cursorWasShown = false;
- const getActiveCursor = () => (cursorDirty ? cursorPosition : undefined);
+ let previousHasTrailingNewline = true;
+ let preparedActiveCursor;
+ let activeCursorPrepared = false;
+ const prepareActiveCursor = () => {
+ if (!activeCursorPrepared) {
+ preparedActiveCursor = snapshotCursorPosition(cursorDirty ? cursorPosition : undefined);
+ activeCursorPrepared = true;
+ }
+ return preparedActiveCursor;
+ };
+ const consumeActiveCursor = () => {
+ const activeCursor = prepareActiveCursor();
+ preparedActiveCursor = undefined;
+ activeCursorPrepared = false;
+ return activeCursor;
+ };
+ const invalidateActiveCursor = () => {
+ preparedActiveCursor = undefined;
+ activeCursorPrepared = false;
+ };
const hasChanges = (str, activeCursor) => {
const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
return str !== previousOutput || cursorChanged;
@@ -24,7 +47,7 @@ const createStandard = (stream, { showCursor = false } = {}) => {
}
// Only use cursor if setCursorPosition was called since last render.
// This ensures stale positions don't persist after component unmount.
- const activeCursor = getActiveCursor();
+ const activeCursor = consumeActiveCursor();
cursorDirty = false;
const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
if (!hasChanges(str, activeCursor)) {
@@ -32,7 +55,8 @@ const createStandard = (stream, { showCursor = false } = {}) => {
}
const lines = str.split('\n');
const visibleCount = visibleLineCount(lines, str);
- const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
+ const hasTrailingNewline = str.endsWith('\n');
+ const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, hasTrailingNewline);
if (str === previousOutput && cursorChanged) {
stream.write(buildCursorOnlySequence({
cursorWasShown,
@@ -40,6 +64,7 @@ const createStandard = (stream, { showCursor = false } = {}) => {
previousCursorPosition,
visibleLineCount: visibleCount,
cursorPosition: activeCursor,
+ hasTrailingNewline: previousHasTrailingNewline,
}));
}
else {
@@ -50,6 +75,7 @@ const createStandard = (stream, { showCursor = false } = {}) => {
str +
cursorSuffix);
previousLineCount = lines.length;
+ previousHasTrailingNewline = hasTrailingNewline;
}
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
cursorWasShown = activeCursor !== undefined;
@@ -62,12 +88,16 @@ const createStandard = (stream, { showCursor = false } = {}) => {
previousLineCount = 0;
previousCursorPosition = undefined;
cursorWasShown = false;
+ previousHasTrailingNewline = true;
+ invalidateActiveCursor();
};
render.done = () => {
previousOutput = '';
previousLineCount = 0;
previousCursorPosition = undefined;
cursorWasShown = false;
+ previousHasTrailingNewline = true;
+ invalidateActiveCursor();
if (!showCursor) {
cliCursor.show(stream);
hasHiddenCursor = false;
@@ -78,18 +108,21 @@ const createStandard = (stream, { showCursor = false } = {}) => {
previousLineCount = 0;
previousCursorPosition = undefined;
cursorWasShown = false;
+ previousHasTrailingNewline = true;
+ invalidateActiveCursor();
};
render.sync = (str) => {
- const activeCursor = cursorDirty ? cursorPosition : undefined;
+ const activeCursor = consumeActiveCursor();
cursorDirty = false;
const lines = str.split('\n');
previousOutput = str;
previousLineCount = lines.length;
+ previousHasTrailingNewline = str.endsWith('\n');
if (!activeCursor && cursorWasShown) {
stream.write(hideCursorEscape);
}
if (activeCursor) {
- stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor));
+ stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor, previousHasTrailingNewline));
}
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
cursorWasShown = activeCursor !== undefined;
@@ -97,9 +130,10 @@ const createStandard = (stream, { showCursor = false } = {}) => {
render.setCursorPosition = (position) => {
cursorPosition = position;
cursorDirty = true;
+ invalidateActiveCursor();
};
render.isCursorDirty = () => cursorDirty;
- render.willRender = (str) => hasChanges(str, getActiveCursor());
+ render.willRender = (str) => hasChanges(str, prepareActiveCursor());
return render;
};
const createIncremental = (stream, { showCursor = false } = {}) => {
@@ -110,7 +144,26 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
let cursorDirty = false;
let previousCursorPosition;
let cursorWasShown = false;
- const getActiveCursor = () => (cursorDirty ? cursorPosition : undefined);
+ let previousHasTrailingNewline = true;
+ let preparedActiveCursor;
+ let activeCursorPrepared = false;
+ const prepareActiveCursor = () => {
+ if (!activeCursorPrepared) {
+ preparedActiveCursor = snapshotCursorPosition(cursorDirty ? cursorPosition : undefined);
+ activeCursorPrepared = true;
+ }
+ return preparedActiveCursor;
+ };
+ const consumeActiveCursor = () => {
+ const activeCursor = prepareActiveCursor();
+ preparedActiveCursor = undefined;
+ activeCursorPrepared = false;
+ return activeCursor;
+ };
+ const invalidateActiveCursor = () => {
+ preparedActiveCursor = undefined;
+ activeCursorPrepared = false;
+ };
const hasChanges = (str, activeCursor) => {
const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
return str !== previousOutput || cursorChanged;
@@ -122,7 +175,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
}
// Only use cursor if setCursorPosition was called since last render.
// This ensures stale positions don't persist after component unmount.
- const activeCursor = getActiveCursor();
+ const activeCursor = consumeActiveCursor();
cursorDirty = false;
const cursorChanged = cursorPositionChanged(activeCursor, previousCursorPosition);
if (!hasChanges(str, activeCursor)) {
@@ -138,6 +191,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
previousCursorPosition,
visibleLineCount: visibleCount,
cursorPosition: activeCursor,
+ hasTrailingNewline: previousHasTrailingNewline,
}));
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
cursorWasShown = activeCursor !== undefined;
@@ -145,7 +199,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
}
const returnPrefix = buildReturnToBottomPrefix(cursorWasShown, previousLines.length, previousCursorPosition);
if (str === '\n' || previousOutput.length === 0) {
- const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
+ const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, str.endsWith('\n'));
stream.write(returnPrefix +
ansiEscapes.eraseLines(previousLines.length) +
str +
@@ -154,6 +208,7 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
previousOutput = str;
previousLines = nextLines;
+ previousHasTrailingNewline = str.endsWith('\n');
return true;
}
const hasTrailingNewline = str.endsWith('\n');
@@ -187,13 +242,14 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
// has no trailing newline (fullscreen mode).
(isLastLine && !hasTrailingNewline ? '' : '\n'));
}
- const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor);
+ const cursorSuffix = buildCursorSuffix(visibleCount, activeCursor, hasTrailingNewline);
buffer.push(cursorSuffix);
stream.write(buffer.join(''));
cursorWasShown = activeCursor !== undefined;
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
previousOutput = str;
previousLines = nextLines;
+ previousHasTrailingNewline = hasTrailingNewline;
return true;
};
render.clear = () => {
@@ -203,12 +259,16 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
previousLines = [];
previousCursorPosition = undefined;
cursorWasShown = false;
+ previousHasTrailingNewline = true;
+ invalidateActiveCursor();
};
render.done = () => {
previousOutput = '';
previousLines = [];
previousCursorPosition = undefined;
cursorWasShown = false;
+ previousHasTrailingNewline = true;
+ invalidateActiveCursor();
if (!showCursor) {
cliCursor.show(stream);
hasHiddenCursor = false;
@@ -219,18 +279,21 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
previousLines = [];
previousCursorPosition = undefined;
cursorWasShown = false;
+ previousHasTrailingNewline = true;
+ invalidateActiveCursor();
};
render.sync = (str) => {
- const activeCursor = cursorDirty ? cursorPosition : undefined;
+ const activeCursor = consumeActiveCursor();
cursorDirty = false;
const lines = str.split('\n');
previousOutput = str;
previousLines = lines;
+ previousHasTrailingNewline = str.endsWith('\n');
if (!activeCursor && cursorWasShown) {
stream.write(hideCursorEscape);
}
if (activeCursor) {
- stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor));
+ stream.write(buildCursorSuffix(visibleLineCount(lines, str), activeCursor, previousHasTrailingNewline));
}
previousCursorPosition = activeCursor ? { ...activeCursor } : undefined;
cursorWasShown = activeCursor !== undefined;
@@ -238,9 +301,10 @@ const createIncremental = (stream, { showCursor = false } = {}) => {
render.setCursorPosition = (position) => {
cursorPosition = position;
cursorDirty = true;
+ invalidateActiveCursor();
};
render.isCursorDirty = () => cursorDirty;
- render.willRender = (str) => hasChanges(str, getActiveCursor());
+ render.willRender = (str) => hasChanges(str, prepareActiveCursor());
return render;
};
const create = (stream, { showCursor = false, incremental = false } = {}) => {
diff --git a/node_modules/ink/build/output.d.ts b/node_modules/ink/build/output.d.ts
index 0f4523f..a3ef634 100644
--- a/node_modules/ink/build/output.d.ts
+++ b/node_modules/ink/build/output.d.ts
@@ -1,4 +1,6 @@
import { type OutputTransformer } from './render-node-to-output.js';
+import { type ScreenSelection, type FrameCell, type FrameBoundary } from './frame-controller.js';
+import { type TextBoundary } from './wrap-text.js';
/**
"Virtual" output class
@@ -24,12 +26,21 @@ export default class Output {
constructor(options: Options);
write(x: number, y: number, text: string, options: {
transformers: OutputTransformer[];
+ selectable?: boolean;
+ semantic?: {
+ flowId: number;
+ selectable: boolean;
+ selectableRows: boolean[];
+ boundaries: Array<TextBoundary | null>;
+ };
}): void;
clip(clip: Clip): void;
unclip(): void;
- get(): {
+ get(selection?: ScreenSelection | null): {
output: string;
height: number;
+ cells: FrameCell[][];
+ boundaries: Array<Array<FrameBoundary | null>>;
};
}
export {};
diff --git a/node_modules/ink/build/output.js b/node_modules/ink/build/output.js
index c763b77..bfd3d08 100644
--- a/node_modules/ink/build/output.js
+++ b/node_modules/ink/build/output.js
@@ -1,6 +1,26 @@
import sliceAnsi from 'slice-ansi';
import stringWidth from 'string-width';
import { styledCharsFromTokens, styledCharsToString, tokenize, } from '@alcalzone/ansi-tokenize';
+// Background applied to selected cells. Appended after a cell's existing styles
+// so the foreground is preserved and this background wins at the terminal.
+const SELECTION_BG = { code: '\x1b[48;5;240m', endCode: '\x1b[49m' };
+// Linear reading-order selection: whole rows between the first and last, partial
+// on the first/last row. Coordinates are screen cells in the composited frame.
+const isCellSelected = (x, y, sel) => {
+ if (y < sel.sy || y > sel.ey) {
+ return false;
+ }
+ if (sel.sy === sel.ey) {
+ return x >= sel.sx && x <= sel.ex;
+ }
+ if (y === sel.sy) {
+ return x >= sel.sx;
+ }
+ if (y === sel.ey) {
+ return x <= sel.ex;
+ }
+ return true;
+};
class OutputCaches {
widths = new Map();
blockWidths = new Map();
@@ -45,7 +65,7 @@ export default class Output {
this.height = height;
}
write(x, y, text, options) {
- const { transformers } = options;
+ const { transformers, selectable, semantic } = options;
if (!text) {
return;
}
@@ -55,6 +75,8 @@ export default class Output {
y,
text,
transformers,
+ selectable,
+ semantic,
});
}
clip(clip) {
@@ -68,7 +90,7 @@ export default class Output {
type: 'unclip',
});
}
- get() {
+ get(selection) {
// Initialize output array with a specific set of rows, so that margin/padding at the bottom is preserved
const output = [];
for (let y = 0; y < this.height; y++) {
@@ -79,10 +101,13 @@ export default class Output {
value: ' ',
fullWidth: false,
styles: [],
+ selectable: false,
+ flowId: null,
});
}
output.push(row);
}
+ const boundaries = output.map(() => Array.from({ length: this.width }, () => null));
const clips = [];
for (const operation of this.operations) {
if (operation.type === 'clip') {
@@ -92,9 +117,10 @@ export default class Output {
clips.pop();
}
if (operation.type === 'write') {
- const { text, transformers } = operation;
+ const { text, transformers, selectable, semantic } = operation;
let { x, y } = operation;
let lines = text.split('\n');
+ let firstLineIndex = 0;
const clip = clips.at(-1);
if (clip) {
const clipHorizontally = typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number';
@@ -129,6 +155,7 @@ export default class Output {
const height = lines.length;
const to = y + height > clip.y2 ? clip.y2 - y : height;
lines = lines.slice(from, to);
+ firstLineIndex = from;
if (y < clip.y1) {
y = clip.y1;
}
@@ -144,8 +171,34 @@ export default class Output {
for (const transformer of transformers) {
line = transformer(line, index);
}
+ const boundaryRow = boundaries[y + offsetY];
+ if (boundaryRow) {
+ const boundaryWidth = semantic
+ ? Math.max(1, this.caches.getStringWidth(line))
+ : this.caches.getStringWidth(line);
+ const boundaryOrigin = x;
+ const startX = Math.max(0, boundaryOrigin, clip?.x1 ?? 0);
+ const endX = Math.min(this.width, boundaryOrigin + boundaryWidth, clip?.x2 ?? this.width);
+ const sourceBoundary = semantic?.boundaries[firstLineIndex + index] ?? null;
+ const boundary = sourceBoundary && semantic
+ ? {
+ ...sourceBoundary,
+ flowId: semantic.flowId,
+ selectable: semantic.selectable,
+ }
+ : null;
+ for (let boundaryX = startX; boundaryX < endX; boundaryX++) {
+ if (boundaryX < boundaryRow.length) {
+ boundaryRow[boundaryX] = boundary;
+ }
+ }
+ }
const characters = this.caches.getStyledChars(line);
let offsetX = x;
+ const rowSelectable = semantic
+ ? semantic.selectable &&
+ (semantic.selectableRows[firstLineIndex + index] ?? true)
+ : selectable ?? true;
// Nothing to write (e.g. line was clipped away).
if (characters.length === 0) {
offsetY++;
@@ -156,6 +209,8 @@ export default class Output {
value: ' ',
fullWidth: false,
styles: [],
+ selectable: false,
+ flowId: null,
};
// Wide characters (e.g. CJK) occupy two cells: a leading
// cell with the character and a trailing placeholder with
@@ -169,7 +224,11 @@ export default class Output {
currentLine[offsetX - 1] = spaceCell;
}
for (const character of characters) {
- currentLine[offsetX] = character;
+ currentLine[offsetX] = {
+ ...character,
+ selectable: rowSelectable,
+ flowId: semantic?.flowId ?? null,
+ };
// Determine printed width using string-width to align with measurement
const characterWidth = Math.max(1, this.caches.getStringWidth(character.value));
// For multi-column characters, clear following cells to avoid stray spaces/artifacts
@@ -180,6 +239,8 @@ export default class Output {
value: '',
fullWidth: false,
styles: character.styles,
+ selectable: rowSelectable,
+ flowId: semantic?.flowId ?? null,
};
}
}
@@ -192,6 +253,30 @@ export default class Output {
}
}
}
+ // Apply the selection highlight before serialization. Selected slots are
+ // replaced with new cell objects (never mutated in place) because cells
+ // reference StyledChar objects cached and shared across identical lines,
+ // so mutating one would leak the highlight onto other on-screen text.
+ if (selection) {
+ for (let y = 0; y < output.length; y++) {
+ const row = output[y];
+ if (!row) {
+ continue;
+ }
+ for (let x = 0; x < row.length; x++) {
+ if (!isCellSelected(x, y, selection)) {
+ continue;
+ }
+ const cell = row[x];
+ if (cell) {
+ row[x] = {
+ ...cell,
+ styles: [...(cell.styles ?? []), SELECTION_BG],
+ };
+ }
+ }
+ }
+ }
const generatedOutput = output
.map(line => {
// See https://github.com/vadimdemedes/ink/pull/564#issuecomment-1637022742
@@ -202,6 +287,8 @@ export default class Output {
return {
output: generatedOutput,
height: output.length,
+ cells: output,
+ boundaries,
};
}
}
diff --git a/node_modules/ink/build/reconciler.js b/node_modules/ink/build/reconciler.js
index 5b4a9d7..25cc017 100644
--- a/node_modules/ink/build/reconciler.js
+++ b/node_modules/ink/build/reconciler.js
@@ -53,6 +53,25 @@ const cleanupYogaNode = (node) => {
node?.unsetMeasureFunc();
node?.freeRecursive();
};
+// Clear staticNode when the node it points at is removed as part of a larger
+// subtree. The previous identity check (staticNode === removeNode) only caught
+// direct removal of <Static>; removing an ancestor left a dangling reference
+// whose freed WASM memory crashed the next render (QwenLM/qwen-code#6820).
+// Must be called BEFORE removeChildNode breaks the parent chain.
+const clearStaticNodeIfContained = (removeNode) => {
+ const staticNode = currentRootNode?.staticNode;
+ if (!staticNode) {
+ return;
+ }
+ let current = staticNode;
+ while (current) {
+ if (current === removeNode) {
+ currentRootNode.staticNode = undefined;
+ return;
+ }
+ current = current.parentNode;
+ }
+};
let currentUpdatePriority = NoEventPriority;
let currentRootNode;
async function loadPackageJson() {
@@ -212,13 +231,10 @@ export default createReconciler({
appendChildToContainer: appendChildNode,
insertInContainerBefore: insertBeforeNode,
removeChildFromContainer(node, removeNode) {
+ clearStaticNodeIfContained(removeNode);
removeChildNode(node, removeNode);
cleanupYogaNode(removeNode.yogaNode);
- // Only clear staticNode if it still points at the removed node. On key-driven remounts, `createInstance` already registered the new node before this removal fires.
- if (removeNode.internal_static &&
- currentRootNode?.staticNode === removeNode) {
- currentRootNode.staticNode = undefined;
- }
+ removeNode.yogaNode = undefined;
},
commitUpdate(node, _type, oldProps, newProps) {
if (currentRootNode && node.internal_static) {
@@ -254,13 +270,10 @@ export default createReconciler({
setTextNodeValue(node, newText);
},
removeChild(node, removeNode) {
+ clearStaticNodeIfContained(removeNode);
removeChildNode(node, removeNode);
cleanupYogaNode(removeNode.yogaNode);
- // Same guard as removeChildFromContainer: only clear if this is still the active static node.
- if (removeNode.internal_static &&
- currentRootNode?.staticNode === removeNode) {
- currentRootNode.staticNode = undefined;
- }
+ removeNode.yogaNode = undefined;
},
setCurrentUpdatePriority(newPriority) {
currentUpdatePriority = newPriority;
diff --git a/node_modules/ink/build/render-background.js b/node_modules/ink/build/render-background.js
index db5c807..3ac805a 100644
--- a/node_modules/ink/build/render-background.js
+++ b/node_modules/ink/build/render-background.js
@@ -18,7 +18,10 @@ const renderBackground = (x, y, node, output) => {
// Create background fill for each row
const backgroundLine = colorize(' '.repeat(contentWidth), node.style.backgroundColor, 'background');
for (let row = 0; row < contentHeight; row++) {
- output.write(x + leftBorderWidth, y + topBorderHeight + row, backgroundLine, { transformers: [] });
+ output.write(x + leftBorderWidth, y + topBorderHeight + row, backgroundLine, {
+ transformers: [],
+ selectable: false,
+ });
}
};
export default renderBackground;
diff --git a/node_modules/ink/build/render-node-to-output.d.ts b/node_modules/ink/build/render-node-to-output.d.ts
index 127c620..6d5a005 100644
--- a/node_modules/ink/build/render-node-to-output.d.ts
+++ b/node_modules/ink/build/render-node-to-output.d.ts
@@ -10,5 +10,9 @@ declare const renderNodeToOutput: (node: DOMElement, output: Output, options: {
offsetY?: number;
transformers?: OutputTransformer[];
skipStaticElements: boolean;
+ flowIds: Map<unknown, number>;
+ nextFlowId: {
+ value: number;
+ };
}) => void;
export default renderNodeToOutput;
diff --git a/node_modules/ink/build/render-node-to-output.js b/node_modules/ink/build/render-node-to-output.js
index f00a278..e6bc4ca 100644
--- a/node_modules/ink/build/render-node-to-output.js
+++ b/node_modules/ink/build/render-node-to-output.js
@@ -1,7 +1,6 @@
-import widestLine from 'widest-line';
-import indentString from 'indent-string';
import Yoga from 'yoga-layout';
-import wrapText from './wrap-text.js';
+import widestLine from 'widest-line';
+import { wrapTextWithMetadata } from './wrap-text.js';
import getMaxWidth from './get-max-width.js';
import squashTextNodes from './squash-text-nodes.js';
import renderBorder from './render-border.js';
@@ -12,14 +11,15 @@ import renderBackground from './render-background.js';
// and use it as offset for the rest of the nodes
// Only first node is taken into account, because other text nodes can't have margin or padding,
// so their coordinates will be relative to the first node anyway
-const applyPaddingToText = (node, text) => {
+const getTextOffset = (node) => {
const yogaNode = node.childNodes[0]?.yogaNode;
if (yogaNode) {
- const offsetX = yogaNode.getComputedLeft();
- const offsetY = yogaNode.getComputedTop();
- text = '\n'.repeat(offsetY) + indentString(text, offsetX);
+ return {
+ x: yogaNode.getComputedLeft(),
+ y: yogaNode.getComputedTop(),
+ };
}
- return text;
+ return { x: 0, y: 0 };
};
export const renderNodeToScreenReaderOutput = (node, options = {}) => {
if (options.skipStaticElements && node.internal_static) {
@@ -69,7 +69,7 @@ export const renderNodeToScreenReaderOutput = (node, options = {}) => {
};
// After nodes are laid out, render each to output object, which later gets rendered to terminal
const renderNodeToOutput = (node, output, options) => {
- const { offsetX = 0, offsetY = 0, transformers = [], skipStaticElements, } = options;
+ const { offsetX = 0, offsetY = 0, transformers = [], skipStaticElements, flowIds, nextFlowId, } = options;
if (skipStaticElements && node.internal_static) {
return;
}
@@ -88,16 +88,54 @@ const renderNodeToOutput = (node, output, options) => {
newTransformers = [node.internal_transform, ...transformers];
}
if (node.nodeName === 'ink-text') {
- let text = squashTextNodes(node);
- if (text.length > 0) {
- const currentWidth = widestLine(text);
+ const sourceText = squashTextNodes(node);
+ if (sourceText.length > 0) {
const maxWidth = getMaxWidth(yogaNode);
- if (currentWidth > maxWidth) {
- const textWrap = node.style.textWrap ?? 'wrap';
- text = wrapText(text, maxWidth, textWrap);
+ const textWrap = node.style.textWrap ?? 'wrap';
+ const wrapped = wrapTextWithMetadata(sourceText, maxWidth, textWrap, widestLine(sourceText) > maxWidth);
+ const flowKey = node.attributes.selectionFlow ?? node;
+ let flowId = flowIds.get(flowKey);
+ if (flowId === undefined) {
+ flowId = nextFlowId.value++;
+ flowIds.set(flowKey, flowId);
+ }
+ const boundaries = [...wrapped.boundaries];
+ const breakAfter = node.attributes.selectionBreakAfter;
+ boundaries.push(breakAfter === 'soft' || breakAfter === 'hard'
+ ? {
+ kind: breakAfter,
+ joiner: typeof node.attributes.selectionJoiner === 'string'
+ ? node.attributes.selectionJoiner
+ : breakAfter === 'hard'
+ ? '\n'
+ : '',
+ }
+ : null);
+ const textOffset = getTextOffset(node);
+ if (textOffset.x > 0) {
+ const padding = wrapped.text
+ .split('\n')
+ .map(line => line.length > 0 ? ' '.repeat(textOffset.x) : '')
+ .join('\n');
+ output.write(x, y + textOffset.y, padding, {
+ transformers: newTransformers,
+ semantic: {
+ flowId,
+ selectable: false,
+ selectableRows: wrapped.selectableRows.map(() => false),
+ boundaries: wrapped.boundaries.map(() => null),
+ },
+ });
}
- text = applyPaddingToText(node, text);
- output.write(x, y, text, { transformers: newTransformers });
+ output.write(x + textOffset.x, y + textOffset.y, wrapped.text, {
+ transformers: newTransformers,
+ semantic: {
+ flowId,
+ selectable: node.attributes.selectable !== false,
+ selectableRows: wrapped.selectableRows,
+ boundaries,
+ },
+ });
}
return;
}
@@ -135,6 +173,8 @@ const renderNodeToOutput = (node, output, options) => {
offsetY: y,
transformers: newTransformers,
skipStaticElements,
+ flowIds,
+ nextFlowId,
});
}
if (clipped) {
diff --git a/node_modules/ink/build/renderer.d.ts b/node_modules/ink/build/renderer.d.ts
index 8c35aea..26e3196 100644
--- a/node_modules/ink/build/renderer.d.ts
+++ b/node_modules/ink/build/renderer.d.ts
@@ -1,8 +1,11 @@
import { type DOMElement } from './dom.js';
+import { type ScreenSelection, type FrameCell, type FrameBoundary } from './frame-controller.js';
type Result = {
output: string;
outputHeight: number;
staticOutput: string;
+ cells?: FrameCell[][];
+ boundaries?: Array<Array<FrameBoundary | null>>;
};
-declare const renderer: (node: DOMElement, isScreenReaderEnabled: boolean) => Result;
+declare const renderer: (node: DOMElement, isScreenReaderEnabled: boolean, selection?: ScreenSelection | null) => Result;
export default renderer;
diff --git a/node_modules/ink/build/renderer.js b/node_modules/ink/build/renderer.js
index bf23246..7fa831f 100644
--- a/node_modules/ink/build/renderer.js
+++ b/node_modules/ink/build/renderer.js
@@ -1,6 +1,6 @@
import renderNodeToOutput, { renderNodeToScreenReaderOutput, } from './render-node-to-output.js';
import Output from './output.js';
-const renderer = (node, isScreenReaderEnabled) => {
+const renderer = (node, isScreenReaderEnabled, selection) => {
if (node.yogaNode) {
if (isScreenReaderEnabled) {
const output = renderNodeToScreenReaderOutput(node, {
@@ -23,8 +23,12 @@ const renderer = (node, isScreenReaderEnabled) => {
width: node.yogaNode.getComputedWidth(),
height: node.yogaNode.getComputedHeight(),
});
+ const flowIds = new Map();
+ const nextFlowId = { value: 1 };
renderNodeToOutput(node, output, {
skipStaticElements: true,
+ flowIds,
+ nextFlowId,
});
let staticOutput;
if (node.staticNode?.yogaNode) {
@@ -34,15 +38,19 @@ const renderer = (node, isScreenReaderEnabled) => {
});
renderNodeToOutput(node.staticNode, staticOutput, {
skipStaticElements: false,
+ flowIds,
+ nextFlowId,
});
}
- const { output: generatedOutput, height: outputHeight } = output.get();
+ const { output: generatedOutput, height: outputHeight, cells, boundaries, } = output.get(selection);
return {
output: generatedOutput,
outputHeight,
// Newline at the end is needed, because static output doesn't have one, so
// interactive output will override last line of static output
staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '',
+ cells,
+ boundaries,
};
}
return {
diff --git a/node_modules/ink/build/wrap-text.d.ts b/node_modules/ink/build/wrap-text.d.ts
index 935cfb8..3dddced 100644
--- a/node_modules/ink/build/wrap-text.d.ts
+++ b/node_modules/ink/build/wrap-text.d.ts
@@ -1,3 +1,12 @@
import { type Styles } from './styles.js';
declare const wrapText: (text: string, maxWidth: number, wrapType: Styles["textWrap"]) => string;
+export type TextBoundary = {
+ kind: 'soft' | 'hard';
+ joiner: string;
+};
+export declare const wrapTextWithMetadata: (text: string, maxWidth: number, wrapType: Styles["textWrap"], shouldWrap?: boolean) => {
+ text: string;
+ boundaries: TextBoundary[];
+ selectableRows: boolean[];
+};
export default wrapText;
diff --git a/node_modules/ink/build/wrap-text.js b/node_modules/ink/build/wrap-text.js
index 51f696c..1f66ca0 100644
--- a/node_modules/ink/build/wrap-text.js
+++ b/node_modules/ink/build/wrap-text.js
@@ -1,5 +1,6 @@
import wrapAnsi from 'wrap-ansi';
import cliTruncate from 'cli-truncate';
+import stripAnsi from 'strip-ansi';
const cache = {};
const wrapText = (text, maxWidth, wrapType) => {
const cacheKey = text + String(maxWidth) + String(wrapType);
@@ -34,5 +35,51 @@ const wrapText = (text, maxWidth, wrapType) => {
cache[cacheKey] = wrappedText;
return wrappedText;
};
+export const wrapTextWithMetadata = (text, maxWidth, wrapType, shouldWrap = true) => {
+ const sourceLines = text.replaceAll('\r\n', '\n').split('\n');
+ const lines = [];
+ const boundaries = [];
+ const selectableRows = [];
+ for (const [sourceLineIndex, sourceLine] of sourceLines.entries()) {
+ const wrappedLines = (shouldWrap
+ ? wrapText(sourceLine, maxWidth, wrapType)
+ : sourceLine).split('\n');
+ const visibleSource = stripAnsi(sourceLine);
+ let sourceOffset = 0;
+ for (const [wrappedLineIndex, wrappedLine] of wrappedLines.entries()) {
+ const visibleLine = stripAnsi(wrappedLine);
+ const separatorRow = sourceOffset > 0 &&
+ visibleLine.length > 0 &&
+ /^\s+$/u.test(visibleLine) &&
+ /\S/u.test(visibleSource.slice(sourceOffset));
+ const sourceSeparator = separatorRow
+ ? (/^\s+/u.exec(visibleSource.slice(sourceOffset))?.[0] ?? '').slice(0, visibleLine.length)
+ : '';
+ const lineOffset = separatorRow
+ ? sourceOffset
+ : visibleSource.indexOf(visibleLine, sourceOffset);
+ const resolvedOffset = lineOffset === -1 ? sourceOffset : lineOffset;
+ if (lines.length > 0) {
+ boundaries.push({
+ kind: wrappedLineIndex === 0 ? 'hard' : 'soft',
+ joiner: wrappedLineIndex === 0
+ ? '\n'
+ : separatorRow
+ ? sourceSeparator
+ : visibleSource.slice(sourceOffset, resolvedOffset),
+ });
+ }
+ lines.push(wrappedLine);
+ selectableRows.push(!separatorRow);
+ sourceOffset = separatorRow
+ ? sourceOffset + sourceSeparator.length
+ : resolvedOffset + visibleLine.length;
+ }
+ if (sourceLineIndex < sourceLines.length - 1 && wrappedLines.length === 0) {
+ lines.push('');
+ }
+ }
+ return { text: lines.join('\n'), boundaries, selectableRows };
+};
export default wrapText;
//# sourceMappingURL=wrap-text.js.map
\ No newline at end of file