omniharness-cli 0.1.85 → 0.1.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { ownVersion, runUpdate } from './update.js';
7
7
  import { readActiveCombo } from './config/settings.js';
8
8
  import { OmniRouteClient } from './config/omniRoute.js';
9
9
  import { doctor, helpText, models } from './doctor.js';
10
+ import { debounceResizeEvents } from './ui/resizeDebounce.js';
10
11
  // A crash anywhere below would otherwise surface as a raw Node stack trace, or
11
12
  // as an unhandled rejection that terminates the process without saying why.
12
13
  // A CLI should fail with a sentence.
@@ -81,8 +82,12 @@ else {
81
82
  // The cost is that quitting no longer restores the pre-launch screen. For
82
83
  // a tool whose output you are meant to read back, that is the right trade.
83
84
  //
85
+ // Coalesce resize delivery before Ink's own internal listener ever sees
86
+ // it — see resizeDebounce.ts. Only meaningful for a real terminal; a
87
+ // non-TTY stdout never emits 'resize' and patching it would be inert.
88
+ const stdout = process.stdout.isTTY ? debounceResizeEvents(process.stdout, 80) : process.stdout;
84
89
  // The app owns Ctrl+C so idle quits but an in-flight run is cancelled first.
85
- const { waitUntilExit } = render(_jsx(TerminalInterface, { engine: engine }), { exitOnCtrlC: false });
90
+ const { waitUntilExit } = render(_jsx(TerminalInterface, { engine: engine }), { stdout, exitOnCtrlC: false });
86
91
  await waitUntilExit();
87
92
  })();
88
93
  }
@@ -0,0 +1,18 @@
1
+ export function debounce(fn, delayMs) {
2
+ let timer;
3
+ const debounced = ((...args) => {
4
+ if (timer)
5
+ clearTimeout(timer);
6
+ timer = setTimeout(() => {
7
+ timer = undefined;
8
+ fn(...args);
9
+ }, delayMs);
10
+ });
11
+ debounced.cancel = () => {
12
+ if (timer)
13
+ clearTimeout(timer);
14
+ timer = undefined;
15
+ };
16
+ return debounced;
17
+ }
18
+ //# sourceMappingURL=debounce.js.map
package/dist/ui/keys.js CHANGED
@@ -102,10 +102,22 @@ export function parseRawKey(chunk) {
102
102
  return null;
103
103
  }
104
104
  /**
105
- * True when `value` is a CSI-u encoded key or protocol response (kitty key
106
- * events, push/pop echoes, query answers), never plain text to insert.
105
+ * True when `value` is a CSI-u encoded key, a protocol response (kitty key
106
+ * events, push/pop echoes, query answers), or a DECRQM mode-report reply
107
+ * (`CSI ? Ps ; Pm $ y`, e.g. the answer to the synchronized-output query) —
108
+ * never plain text to insert.
109
+ *
110
+ * `value` here is Ink's own parsed form, which strips the leading ESC before
111
+ * handing the rest to `useInput`. A terminal's reply to a query this project
112
+ * sends (synchronized output, in particular) arrives on the same stdin the
113
+ * editor reads from, and if nothing recognises it in this stripped form it is
114
+ * treated as pasted text and typed into whatever the user was editing — the
115
+ * escape sequence itself, landing in the input box. Some terminals are slow
116
+ * to answer that specific query, so this has to keep matching however late
117
+ * the reply arrives, not just during a short probe window at startup.
107
118
  */
108
119
  export function isEncodedKey(value) {
109
- return /^\[\d+(?:;\d+)*u$|^\[[><?]\d+(?:;\d+)*u$/.test(value);
120
+ return /^\[\d+(?:;\d+)*u$|^\[[><?]\d+(?:;\d+)*u$/.test(value)
121
+ || /^\[\?\d+;\d\$y$/.test(value);
110
122
  }
111
123
  //# sourceMappingURL=keys.js.map
@@ -0,0 +1,52 @@
1
+ import { debounce } from './debounce.js';
2
+ /**
3
+ * Coalesce `resize` event delivery on a TTY stream so every listener — Ink's
4
+ * own internal one included — sees one event per burst instead of one per raw
5
+ * OS-level tick.
6
+ *
7
+ * Ink attaches its own `resize` listener directly to whatever stream is
8
+ * handed to `render()`, and on every single tick it recalculates its Yoga
9
+ * layout and writes a fresh frame — independent of anything application state
10
+ * does. That is not reachable by debouncing a `useState` call in a component:
11
+ * the redraw happens inside Ink regardless.
12
+ *
13
+ * A maximise or restore on Windows Terminal does not deliver one resize
14
+ * event. It animates through the transition and fires a burst of
15
+ * intermediate sizes a few milliseconds apart. Reacting to each one means
16
+ * Ink redraws against a size that is already stale by the time the escape
17
+ * sequences reach the terminal, and the frames land on top of one another
18
+ * instead of replacing one another — duplicated prompt boxes, fragments of an
19
+ * earlier frame still on screen once the window settles.
20
+ *
21
+ * The only place to fix that is before Ink's own listener ever sees the
22
+ * event — so this patches `.on` / `.off` for `resize` specifically, on the
23
+ * same stream object Ink and application code both subscribe to. `write`,
24
+ * `columns`, `rows`, `isTTY` and every other event pass through untouched.
25
+ */
26
+ export function debounceResizeEvents(stream, delayMs) {
27
+ const realOn = stream.on.bind(stream);
28
+ const realOff = stream.off.bind(stream);
29
+ // Keyed on the caller's own listener, so .off(listener) still finds and
30
+ // cancels the right debounced wrapper — the caller never sees the wrapper.
31
+ const wrapped = new WeakMap();
32
+ stream.on = ((event, listener) => {
33
+ if (event !== 'resize')
34
+ return realOn(event, listener);
35
+ const debounced = debounce(listener, delayMs);
36
+ wrapped.set(listener, debounced);
37
+ return realOn(event, debounced);
38
+ });
39
+ stream.off = ((event, listener) => {
40
+ if (event !== 'resize')
41
+ return realOff(event, listener);
42
+ const debounced = wrapped.get(listener);
43
+ if (debounced) {
44
+ debounced.cancel();
45
+ wrapped.delete(listener);
46
+ return realOff(event, debounced);
47
+ }
48
+ return realOff(event, listener);
49
+ });
50
+ return stream;
51
+ }
52
+ //# sourceMappingURL=resizeDebounce.js.map
@@ -320,6 +320,13 @@ export function TerminalInterface({ engine }) {
320
320
  return () => { alive = false; };
321
321
  }, []);
322
322
  useEffect(() => {
323
+ // The real fix for a resize storm (maximise/restore firing a burst of
324
+ // intermediate sizes) lives one layer down, in resizeDebounce.ts: it
325
+ // coalesces delivery of the raw 'resize' event on the stream itself,
326
+ // before Ink's own internal listener — which redraws unconditionally on
327
+ // every tick, independent of any component state — ever sees it. This
328
+ // handler can stay a plain listener because of that; by the time it
329
+ // fires, the event has already been coalesced upstream.
323
330
  const onResize = () => {
324
331
  setWidth(widthOf(stdout));
325
332
  setRows(rowsOf(stdout));
@@ -327,22 +334,41 @@ export function TerminalInterface({ engine }) {
327
334
  stdout.on('resize', onResize);
328
335
  stdout.write(KITTY_PUSH);
329
336
  let kittyTimer;
330
- // One probe pass: kitty keyboard protocol + synchronized output (DECSET 2026).
331
- const onProbe = (chunk) => {
332
- const text = chunk.toString();
333
- if (isSyncOutputReply(text) && syncRestoreRef.current === null) {
334
- syncRestoreRef.current = wrapSynchronizedOutput(stdout);
335
- }
336
- if (!isKittyQueryResponse(text))
337
+ // Two independent probes, not one shared listener. They used to be a
338
+ // single handler torn down together after 300ms, on the assumption both
339
+ // replies would have arrived by then. Some terminals answer the
340
+ // synchronized-output query well after that — observed landing right
341
+ // around a resize, though the terminal's own reasons for the delay are
342
+ // opaque from here — and a listener already removed cannot catch a late
343
+ // reply. Two things followed from that: synchronized output silently
344
+ // never turned on for the rest of the session, and the reply itself, with
345
+ // nothing left to claim it, reached Ink's own useInput and got typed into
346
+ // the input box as literal text (the isEncodedKey guard now recognises
347
+ // and drops that shape regardless, but the actual fix is to keep
348
+ // listening for it rather than lean on that alone).
349
+ const onSyncProbe = (chunk) => {
350
+ if (syncRestoreRef.current !== null)
351
+ return;
352
+ if (!isSyncOutputReply(chunk.toString()))
353
+ return;
354
+ syncRestoreRef.current = wrapSynchronizedOutput(stdout);
355
+ stdin?.off('data', onSyncProbe);
356
+ };
357
+ // The kitty check is a UI decision — whether Ctrl+letter and a distinct
358
+ // Shift+Tab are available — that has to resolve one way or the other, so
359
+ // it keeps a bounded timeout; "no answer in 300ms" is itself the answer.
360
+ const onKittyProbe = (chunk) => {
361
+ if (!isKittyQueryResponse(chunk.toString()))
337
362
  return;
338
363
  if (kittyTimer)
339
364
  clearTimeout(kittyTimer);
340
- stdin?.off('data', onProbe);
365
+ stdin?.off('data', onKittyProbe);
341
366
  setKitty(true);
342
367
  };
343
368
  if (stdin) {
344
- stdin.on('data', onProbe);
345
- kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data', onProbe); }, 300);
369
+ stdin.on('data', onSyncProbe);
370
+ stdin.on('data', onKittyProbe);
371
+ kittyTimer = setTimeout(() => { setKitty(false); stdin.off('data', onKittyProbe); }, 300);
346
372
  stdout.write(SYNC_QUERY);
347
373
  stdout.write(KITTY_QUERY);
348
374
  }
@@ -425,8 +451,9 @@ export function TerminalInterface({ engine }) {
425
451
  return () => {
426
452
  if (kittyTimer)
427
453
  clearTimeout(kittyTimer);
428
- stdin?.off('data', onProbe);
429
- stdout.off('resize', onResize);
454
+ stdin?.off('data', onSyncProbe);
455
+ stdin?.off('data', onKittyProbe);
456
+ stdout.off('resize', onResize); // resizeDebounce cancels the coalesced timer on .off itself
430
457
  const pendingApproval = approvalResolve.current;
431
458
  approvalResolve.current = null;
432
459
  pendingApproval?.({ approved: false });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omniharness-cli",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "description": "OmniHarness — local-first agent orchestration harness for OmniRoute.",
5
5
  "license": "MIT",
6
6
  "type": "module",