tickmarkr 1.83.0 → 1.85.0

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.
Files changed (37) hide show
  1. package/dist/adapters/claude-code.d.ts +1 -0
  2. package/dist/adapters/claude-code.js +57 -1
  3. package/dist/adapters/fake.js +9 -0
  4. package/dist/adapters/types.d.ts +3 -0
  5. package/dist/adapters/types.js +21 -0
  6. package/dist/cli/commands/status.js +160 -30
  7. package/dist/compile/collateral.d.ts +86 -2
  8. package/dist/compile/collateral.js +294 -3
  9. package/dist/config/config.d.ts +62 -0
  10. package/dist/config/config.js +157 -2
  11. package/dist/drivers/herdr.d.ts +20 -3
  12. package/dist/drivers/herdr.js +288 -105
  13. package/dist/gates/baseline.d.ts +1 -0
  14. package/dist/gates/baseline.js +91 -13
  15. package/dist/gates/review.d.ts +7 -0
  16. package/dist/gates/review.js +99 -6
  17. package/dist/gates/run-gates.d.ts +9 -0
  18. package/dist/gates/run-gates.js +285 -41
  19. package/dist/run/daemon.d.ts +48 -2
  20. package/dist/run/daemon.js +1417 -315
  21. package/dist/run/journal.d.ts +56 -3
  22. package/dist/run/journal.js +275 -1
  23. package/dist/run/stall.d.ts +35 -1
  24. package/dist/run/stall.js +118 -8
  25. package/dist/tui/cockpit/components.d.ts +30 -1
  26. package/dist/tui/cockpit/components.js +19 -3
  27. package/dist/tui/cockpit/derive.d.ts +29 -2
  28. package/dist/tui/cockpit/derive.js +219 -23
  29. package/dist/tui/cockpit/layout.d.ts +75 -6
  30. package/dist/tui/cockpit/layout.js +97 -19
  31. package/dist/tui/cockpit/live.d.ts +14 -1
  32. package/dist/tui/cockpit/live.js +223 -29
  33. package/dist/tui/cockpit/pointer.d.ts +261 -0
  34. package/dist/tui/cockpit/pointer.js +610 -0
  35. package/dist/tui/cockpit/run-cockpit.d.ts +36 -5
  36. package/dist/tui/cockpit/run-cockpit.js +270 -51
  37. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { PANEL_CHROME_ROWS, READABLE_PANEL_COLUMNS } from "./components.js";
1
2
  import { cellWidth } from "./width.js";
2
3
  export const COCKPIT_ROW_FLOOR = 14;
3
4
  export const COCKPIT_COLUMN_FLOOR = 40;
@@ -57,6 +58,30 @@ export const FRAME_WIDTH_FLOOR = COCKPIT_COLUMN_FLOOR;
57
58
  export const FRAME_HEIGHT_FLOOR = COCKPIT_ROW_FLOOR;
58
59
  export const SIDEBAR_COLUMN_FLOOR = 64;
59
60
  export const RAIL_COLUMNS = 15;
61
+ /**
62
+ * The narrowest rail that still reads: the selection marker, one space and the
63
+ * longest view name, whole — the name measured in display cells by the width
64
+ * authority, never String length, so a name that ever gains a wide glyph moves
65
+ * the floor with it. A drag never takes the rail below it — below the floor
66
+ * the element stands at the floor, surrendered whole per the contract, never
67
+ * collapsed.
68
+ */
69
+ export const RAIL_READABLE_FLOOR_COLUMNS = Math.max(...FRAME_VIEWS.map((view) => cellWidth(view))) + 2;
70
+ /**
71
+ * The rail width the plan draws for a session resize override, clamped at both
72
+ * panels' readable floors: the rail never narrows past its own floor, and
73
+ * never widens past what leaves the body its readable floor
74
+ * (`READABLE_PANEL_COLUMNS`). The clamp is the plan's — the override is a
75
+ * request, the floors are the contract, and an element is surrendered whole or
76
+ * not at all, never collapsed by a drag. No override is the contract's
77
+ * constant rail.
78
+ */
79
+ export function planRailColumns(columns, override) {
80
+ if (override === undefined || !Number.isFinite(override))
81
+ return RAIL_COLUMNS;
82
+ const ceiling = Math.max(RAIL_READABLE_FLOOR_COLUMNS, Math.floor(columns) - READABLE_PANEL_COLUMNS);
83
+ return Math.min(ceiling, Math.max(RAIL_READABLE_FLOOR_COLUMNS, Math.floor(override)));
84
+ }
60
85
  export const STANDARD_TAIL_HEIGHT = 16;
61
86
  export const FULL_TAIL_HEIGHT = 24;
62
87
  export const TAIL_STANDARD_ROWS = 1;
@@ -93,6 +118,23 @@ export const FRAME_SURRENDER_FIT = {
93
118
  caption: ({ captionCells, columns }) => captionCells >= 1 && captionCells <= columns - HEADER_FIXED_CELLS,
94
119
  tail: ({ tailRows }) => tailRows > 0,
95
120
  };
121
+ /**
122
+ * Border plus horizontal padding the drawn panel charges around its body —
123
+ * half of it on each side. The plan owns this because the plan owns the item
124
+ * rows: the paint draws its list into the region planned below, and nothing
125
+ * downstream subtracts chrome of its own to find it again.
126
+ */
127
+ export const RUN_PANEL_HORIZONTAL_CHROME = 4;
128
+ /**
129
+ * Row bands drawn INSIDE another band rather than beside it, mapped to the band
130
+ * that hosts them. A nested band refines its host's rows, so the band walk skips
131
+ * it when tiling the height and asserts containment instead. It is no less a
132
+ * member of `rowSpans` for that: the oracle pins its span against the planned
133
+ * region, and demands the paint register a node at exactly that rectangle —
134
+ * which is precisely what a parallel field beside the plan could never be held
135
+ * to.
136
+ */
137
+ export const FRAME_NESTED_ROW_BANDS = { items: "body" };
96
138
  /* ------------------------------------------------------------------ */
97
139
  /* The sidebar band's composition: the menu at the rail's top, the */
98
140
  /* vitals anchored to the rail's bottom, and at least one blank row */
@@ -113,17 +155,28 @@ export const SIDEBAR_VITALS_COMPACT_ELEMENT_ROWS = 1;
113
155
  * one gap row is the vitals budget, spent on the full two-row block when it
114
156
  * fits whole, else on the one-row elements, surrendered from the top of the
115
157
  * block with the meter surrendered last. An element draws whole or not at all.
158
+ * `railRow` is the frame row the rail band starts at, so the view rows the plan
159
+ * reports are frame rows a reported cell is compared against directly. It is
160
+ * required for that reason: a default here would answer rail-relative rows in a
161
+ * field documented as frame rows — the same defaulted-measurement defect the key
162
+ * delivery carried, and this one would land in the rows a rail hit resolves
163
+ * through.
116
164
  */
117
- export function planSidebar(railRows) {
165
+ export function planSidebar(railRows, railRow) {
118
166
  const menuRows = railRows >= SIDEBAR_MENU_LABEL_ROWS
119
167
  ? 1 + FRAME_VIEWS.length
120
168
  : FRAME_VIEWS.length;
169
+ // The views occupy the menu block's last rows: whatever the block exceeds
170
+ // them by is the label row the plan funded above them.
171
+ const firstViewRow = railRow + (menuRows - FRAME_VIEWS.length);
172
+ const viewRows = Object.fromEntries(FRAME_VIEWS.map((view, index) => [view, firstViewRow + index]));
121
173
  const fullRows = SIDEBAR_VITALS_ORDER.length * SIDEBAR_VITALS_FULL_ELEMENT_ROWS;
122
174
  // The gap is funded before the vitals are: the budget is what remains after it.
123
175
  const budget = railRows - menuRows - 1;
124
176
  if (budget >= fullRows) {
125
177
  return {
126
178
  menuRows,
179
+ viewRows,
127
180
  gapRows: railRows - menuRows - fullRows,
128
181
  vitalsMode: "full",
129
182
  vitals: SIDEBAR_VITALS_ORDER,
@@ -132,11 +185,29 @@ export function planSidebar(railRows) {
132
185
  const kept = Math.max(0, Math.min(SIDEBAR_VITALS_ORDER.length, Math.floor(budget)));
133
186
  return {
134
187
  menuRows,
188
+ viewRows,
135
189
  gapRows: kept > 0 ? railRows - menuRows - kept : 0,
136
190
  vitalsMode: "compact",
137
191
  vitals: SIDEBAR_VITALS_ORDER.slice(SIDEBAR_VITALS_ORDER.length - kept),
138
192
  };
139
193
  }
194
+ /**
195
+ * The item rows of a body band: the band less the chrome the drawn panel puts
196
+ * around its list — the border row and the title row above the first item, the
197
+ * border row below it, and a bordered padded column on each side. THE ONE
198
+ * PLACE that subtraction happens, and it happens in the plan: paint and hit
199
+ * resolution alike consume this region rather than deriving it again.
200
+ */
201
+ function planItemRows(body) {
202
+ const heading = PANEL_CHROME_ROWS - 1; // border row, then the title row
203
+ return {
204
+ id: "items",
205
+ row: body.row + heading,
206
+ rows: Math.max(1, body.rows - PANEL_CHROME_ROWS),
207
+ column: body.column + RUN_PANEL_HORIZONTAL_CHROME / 2,
208
+ columns: Math.max(1, body.columns - RUN_PANEL_HORIZONTAL_CHROME),
209
+ };
210
+ }
140
211
  function tailRowsFor(rows) {
141
212
  if (rows >= FULL_TAIL_HEIGHT)
142
213
  return TAIL_FULL_ROWS;
@@ -151,7 +222,9 @@ function tailRowsFor(rows) {
151
222
  * height exactly — header, rule, the view strip (40–63 columns), the body
152
223
  * band, a second rule, the journal tail surrendered whole by height tier,
153
224
  * status, keybar — and, at ≥64 columns, column bands that tile the width
154
- * exactly: the constant 15-column rail beside the body. The body is the sole
225
+ * exactly: the rail beside the body, at the constant 15 columns or at the
226
+ * session's dragged override clamped to both panels' readable floors
227
+ * (`planRailColumns`). The body is the sole
155
228
  * flexible element on both axes. The view and state decide what the flexible
156
229
  * bands draw (the Journal view is the full-height tail; SETUP turns the tail
157
230
  * into PENDING WRITES; an open detail owns the body) without moving any
@@ -165,7 +238,12 @@ function tailRowsFor(rows) {
165
238
  * of the block, the meter last, rather than closing the gap. rowSpans and
166
239
  * columnSpans are band maps: they deliberately exclude the nested caption,
167
240
  * and column bands are reported for the body band only, so each map's spans
168
- * sum to the full height and width respectively.
241
+ * sum to the full height and width respectively. The body band's item rows are
242
+ * a band of the plan in their own right — a region in `regions` and a span in
243
+ * `rowSpans`, nested inside the body band rather than tiling beside it — so the
244
+ * region the paint draws its list into and the region a pointer resolves a row
245
+ * through are the one thing the oracle pins, and neither subtracts the drawn
246
+ * panel's chrome for itself.
169
247
  */
170
248
  export function planFrame(size, view = "tasks", state = {}) {
171
249
  const columns = Math.floor(size.columns);
@@ -178,6 +256,9 @@ export function planFrame(size, view = "tasks", state = {}) {
178
256
  }
179
257
  const tab = state.tab ?? "watch";
180
258
  const band = columns >= SIDEBAR_COLUMN_FLOOR ? "sidebar" : "strip";
259
+ // The one panel the session can resize: the rail, at the operator's dragged
260
+ // override clamped to the readable floors, or the contract's constant rail.
261
+ const railColumns = planRailColumns(columns, state.railColumns);
181
262
  const stripRows = band === "strip" ? 1 : 0;
182
263
  const tailRows = tailRowsFor(rows);
183
264
  // Fixed chrome is five rows: header, rule, a second rule below the body
@@ -221,25 +302,21 @@ export function planFrame(size, view = "tasks", state = {}) {
221
302
  row: bodyRow,
222
303
  rows: bodyRows,
223
304
  column: 0,
224
- columns: RAIL_COLUMNS,
225
- });
226
- regions.push({
227
- id: "body",
228
- row: bodyRow,
229
- rows: bodyRows,
230
- column: RAIL_COLUMNS,
231
- columns: columns - RAIL_COLUMNS,
305
+ columns: railColumns,
232
306
  });
233
307
  }
234
- else {
235
- regions.push({
308
+ const bodyRegion = band === "sidebar"
309
+ ? {
236
310
  id: "body",
237
311
  row: bodyRow,
238
312
  rows: bodyRows,
239
- column: 0,
240
- columns,
241
- });
242
- }
313
+ column: railColumns,
314
+ columns: columns - railColumns,
315
+ }
316
+ : { id: "body", row: bodyRow, rows: bodyRows, column: 0, columns };
317
+ regions.push(bodyRegion);
318
+ const itemsRegion = planItemRows(bodyRegion);
319
+ regions.push(itemsRegion);
243
320
  if (captionFits) {
244
321
  regions.push({
245
322
  id: "caption",
@@ -253,13 +330,14 @@ export function planFrame(size, view = "tasks", state = {}) {
253
330
  if (stripRows > 0)
254
331
  rowSpans.strip = stripRows;
255
332
  rowSpans.body = bodyRows;
333
+ rowSpans.items = itemsRegion.rows;
256
334
  rowSpans.rule2 = 1;
257
335
  if (tailRows > 0)
258
336
  rowSpans.tail = tailRows;
259
337
  rowSpans.status = 1;
260
338
  rowSpans.keybar = 1;
261
339
  const columnSpans = band === "sidebar"
262
- ? { rail: RAIL_COLUMNS, body: columns - RAIL_COLUMNS }
340
+ ? { rail: railColumns, body: columns - railColumns }
263
341
  : { body: columns };
264
342
  return {
265
343
  kind: "frame",
@@ -275,7 +353,7 @@ export function planFrame(size, view = "tasks", state = {}) {
275
353
  rowSpans,
276
354
  columnSpans,
277
355
  flexible: { row: "body", column: "body" },
278
- sidebar: band === "sidebar" ? planSidebar(bodyRows) : null,
356
+ sidebar: band === "sidebar" ? planSidebar(bodyRows, bodyRow) : null,
279
357
  surrendered,
280
358
  };
281
359
  }
@@ -1,5 +1,6 @@
1
1
  import type { CockpitGraph, RunCockpitData, RunCockpitSource } from "./derive.js";
2
2
  import { type RunInteractionState, type RunKeyEvent } from "./keys.js";
3
+ import { type PointerReport, type PointerSurface } from "./pointer.js";
3
4
  /**
4
5
  * THE engagement selection rule — stated once, here, so it can change in one
5
6
  * place without touching the renderer or the command: an explicit engagement
@@ -46,6 +47,17 @@ export declare function reconcileLiveRunInteraction(interaction: RunInteractionS
46
47
  * one place rows enter the dispatcher, so no key can reach a journal row.
47
48
  */
48
49
  export declare function liveRunViewRowIds(interaction: RunInteractionState, data: RunCockpitData): readonly string[];
50
+ /**
51
+ * The suite's own planning of the surface a frame at this data, state and
52
+ * measured size commits — the same composition the renderer publishes through
53
+ * its commit seam, so a hit is tested against the frame production actually
54
+ * painted. The live input path never calls this: it resolves through the
55
+ * committed frame, and what nothing has painted yet has no geometry.
56
+ */
57
+ export declare function liveRunPointerSurface(data: RunCockpitData, interaction: RunInteractionState, size: {
58
+ readonly columns: number;
59
+ readonly rows: number;
60
+ }): PointerSurface | undefined;
49
61
  export type LiveCockpitSurface = {
50
62
  readonly data: RunCockpitData;
51
63
  readonly interaction: RunInteractionState;
@@ -62,7 +74,8 @@ export type LiveCockpitSurface = {
62
74
  export type LiveCockpitDelivery = {
63
75
  readonly snapshot: () => LiveCockpitSurface;
64
76
  readonly refresh: () => boolean;
65
- readonly key: (event: RunKeyEvent, columns?: number) => boolean;
77
+ readonly key: (event: RunKeyEvent) => boolean;
78
+ readonly pointer: (report: PointerReport) => boolean;
66
79
  readonly batch: <Result>(deliver: () => Result) => Result;
67
80
  };
68
81
  export declare function runLiveCockpit({ input, output, cwd, runId, binaryVersion, refreshMs, now, debug, onDelivery, }: {
@@ -1,12 +1,13 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { render, useApp, useInput } from "ink";
4
- import { createElement, useEffect, useRef, useSyncExternalStore } from "react";
4
+ import { createElement, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
5
5
  import { graphPath, loadGraph, stateDirName } from "../../graph/graph.js";
6
6
  import { Journal, parseRunId } from "../../run/journal.js";
7
7
  import { deriveRunCockpitData } from "./derive.js";
8
- import { deriveRunViewRows, runKeyColumns, RunCockpitFrame } from "./run-cockpit.js";
8
+ import { deriveRunViewRows, drawnRunViewRowIds, planRunCockpitFrame, runKeyColumns, RunCockpitFrame, } from "./run-cockpit.js";
9
9
  import { dispatchRunSurfaceKey, openingRunSurfaceState, reconcileRunInteraction, RUN_INPUT_BINDINGS, selectableRunViewRowIds, } from "./keys.js";
10
+ import { applyPointerReport, borrowPointerTracking, createPointerReportReader, } from "./pointer.js";
10
11
  // ponytail: fixed 2s re-derive cadence, matching `status --watch`; promote to a
11
12
  // config knob only if an operator asks.
12
13
  const REFRESH_MS = 2_000;
@@ -110,7 +111,44 @@ export function liveRunViewRowIds(interaction, data) {
110
111
  deriveRunViewRows(data, interaction.activeView, interaction.filterQuery)
111
112
  .map((row) => row.id));
112
113
  }
113
- function createLiveCockpitDelivery({ cwd, runId, binaryVersion, now, }) {
114
+ /**
115
+ * The pointer surface a committed frame carries: the plan the paint drew and
116
+ * the rows drawn into its body, both read off the renderer's own output. This
117
+ * is the only composition the live input path resolves through — it holds no
118
+ * planner and no geometry of its own, so a hit can never land on bands or
119
+ * rows the operator has not seen.
120
+ */
121
+ function committedRunPointerSurface(planned, data) {
122
+ if (planned.plan.kind !== "frame" || planned.content === undefined) {
123
+ return undefined;
124
+ }
125
+ return {
126
+ interaction: planned.interaction,
127
+ // The plan is the whole geometry the pointer receives: the item rows it
128
+ // resolves a hit through are the plan's own `items` band, the same region
129
+ // the paint draws its list into, so there is no second reading of where the
130
+ // list starts.
131
+ plan: planned.plan,
132
+ drawnRowIds: drawnRunViewRowIds(data, planned.interaction, planned.content.items.rows),
133
+ rowIds: liveRunViewRowIds(planned.interaction, data),
134
+ };
135
+ }
136
+ /**
137
+ * The suite's own planning of the surface a frame at this data, state and
138
+ * measured size commits — the same composition the renderer publishes through
139
+ * its commit seam, so a hit is tested against the frame production actually
140
+ * painted. The live input path never calls this: it resolves through the
141
+ * committed frame, and what nothing has painted yet has no geometry.
142
+ */
143
+ export function liveRunPointerSurface(data, interaction, size) {
144
+ return committedRunPointerSurface(planRunCockpitFrame({
145
+ data,
146
+ columns: size.columns,
147
+ rows: size.rows,
148
+ interaction,
149
+ }), data);
150
+ }
151
+ function createLiveCockpitDelivery({ cwd, runId, binaryVersion, now, size, }) {
114
152
  // Both sources are read every refresh: a recompile between refreshes changes
115
153
  // which tasks exist, and the surface must draw the graph the repository has
116
154
  // now rather than the one it had when the cockpit opened.
@@ -124,6 +162,15 @@ function createLiveCockpitDelivery({ cwd, runId, binaryVersion, now, }) {
124
162
  const listeners = new Set();
125
163
  let batchDepth = 0;
126
164
  let pendingDraw = false;
165
+ /**
166
+ * The frame on the screen, published by the renderer each time it commits
167
+ * one. Pointer reports resolve through this and only this: the input path
168
+ * plans nothing and caches no geometry, so a refresh, resize or key that
169
+ * nothing has painted yet cannot move a hit — the target set is the bands
170
+ * and rows the operator is actually looking at, until the paint replaces
171
+ * them.
172
+ */
173
+ let committedPointerSurface;
127
174
  const publish = () => {
128
175
  if (batchDepth > 0) {
129
176
  pendingDraw = true;
@@ -154,6 +201,9 @@ function createLiveCockpitDelivery({ cwd, runId, binaryVersion, now, }) {
154
201
  finally {
155
202
  batchDepth -= 1;
156
203
  if (batchDepth === 0 && pendingDraw) {
204
+ // The batch ends and the surface is drawn again; the renderer's
205
+ // commit of that draw publishes the frame the next report resolves
206
+ // through.
157
207
  pendingDraw = false;
158
208
  publish();
159
209
  }
@@ -178,10 +228,12 @@ function createLiveCockpitDelivery({ cwd, runId, binaryVersion, now, }) {
178
228
  // can be in. The prompt is a scope inside that registry rather than an
179
229
  // owner in front of it, so a global key — Tab above all — reaches its
180
230
  // handler with a prompt open exactly as it does without one.
181
- key: (event, columns = Number.MAX_SAFE_INTEGER) => transition((current) => {
231
+ key: (event) => transition((current) => {
182
232
  const next = dispatchRunSurfaceKey(event, { interaction: current.interaction, stashed: current.stashed }, RUN_INPUT_BINDINGS, liveRunViewRowIds(current.interaction, current.data),
183
- // The dispatcher decides on rail visibility, and the plan owns that.
184
- runKeyColumns(columns));
233
+ // The dispatcher decides on rail visibility from the width the surface
234
+ // is measured at. A default here routed every key as if the rail were
235
+ // drawn, at widths where the plan draws no rail at all.
236
+ runKeyColumns(size().columns));
185
237
  if (next === undefined)
186
238
  return current;
187
239
  const interaction = reconcileLiveRunInteraction(next.interaction, current.data);
@@ -189,9 +241,40 @@ function createLiveCockpitDelivery({ cwd, runId, binaryVersion, now, }) {
189
241
  ? current
190
242
  : { ...current, interaction, stashed: next.stashed };
191
243
  }),
244
+ // A pointer report resolves through the committed frame — the plan and
245
+ // the drawn rows the paint has on the screen, and nothing the input path
246
+ // derives for itself. Adjacent reports — a double click above all —
247
+ // arrive in one batch, which defers the redraw: they all resolve through
248
+ // the ONE committed frame still on the screen, and only the state each
249
+ // transition is dispatched FROM moves. The first press's own new scroll
250
+ // window cannot slide the list under the second press, because that
251
+ // window does not exist until the paint draws it.
252
+ pointer: (report) => transition((current) => {
253
+ const drawn = committedPointerSurface;
254
+ if (drawn === undefined)
255
+ return current;
256
+ const next = applyPointerReport(report, {
257
+ ...drawn,
258
+ interaction: current.interaction,
259
+ });
260
+ if (next === undefined)
261
+ return current;
262
+ const interaction = reconcileLiveRunInteraction(next, current.data);
263
+ return interaction === current.interaction
264
+ ? current
265
+ : { ...current, interaction };
266
+ }),
267
+ commitPointerSurface: (surface) => {
268
+ committedPointerSurface = surface;
269
+ },
192
270
  };
193
271
  return delivery;
194
272
  }
273
+ /** The measured size a snapshot states, in the whole cells a plan is made of. */
274
+ function parseMeasuredSize(snapshot) {
275
+ const [columns, rows] = snapshot.split(":").map((part) => Math.max(0, Math.floor(Number(part))));
276
+ return { columns, rows };
277
+ }
195
278
  function measureOutput(output) {
196
279
  const read = () => `${output.columns ?? 80}:${output.rows ?? 24}`;
197
280
  let size = read();
@@ -245,11 +328,83 @@ function measureOutput(output) {
245
328
  close: () => output.off("resize", resized),
246
329
  };
247
330
  }
331
+ /**
332
+ * The stdin the surface's keyboard reads: the real stream with every pointer
333
+ * report taken out of it. Reports and keys arrive interleaved on one stream, and
334
+ * a reader beside Ink's would not be enough — Ink would still see the report
335
+ * bytes and read a whole one as an unnameable escape sequence, or half of one as
336
+ * the single character its chunk happens to end on, dispatching either into
337
+ * whatever scope is live. So the split sits in the pull Ink already does
338
+ * (`read()` under its `readable` listener): reports are handed to the pointer
339
+ * boundary, and only what was left is ever read as a keystroke. A chunk that was
340
+ * nothing but reports reads as no input at all rather than as empty input.
341
+ */
342
+ function pointerFilteredInput(input, deliver) {
343
+ const reader = createPointerReportReader();
344
+ const tokens = [];
345
+ let pendingEscape;
346
+ const clearPendingEscape = () => {
347
+ if (pendingEscape === undefined)
348
+ return;
349
+ clearImmediate(pendingEscape);
350
+ pendingEscape = undefined;
351
+ };
352
+ const wake = () => {
353
+ input.emit("readable");
354
+ };
355
+ const schedulePendingEscape = () => {
356
+ clearPendingEscape();
357
+ // The same next-turn grace Ink gives an ambiguous ESC: a continuation that
358
+ // arrives first completes the report; otherwise ESC becomes keyboard input.
359
+ if (reader.pending() !== "\x1b")
360
+ return;
361
+ pendingEscape = setImmediate(() => {
362
+ pendingEscape = undefined;
363
+ tokens.push(...reader.flush().tokens);
364
+ wake();
365
+ });
366
+ };
367
+ const read = (...args) => {
368
+ while (true) {
369
+ const token = tokens.shift();
370
+ if (token?.type === "pointer") {
371
+ const reports = [token.report];
372
+ while (tokens[0]?.type === "pointer") {
373
+ const adjacent = tokens.shift();
374
+ if (adjacent?.type === "pointer")
375
+ reports.push(adjacent.report);
376
+ }
377
+ deliver(reports);
378
+ continue;
379
+ }
380
+ if (token?.type === "keys")
381
+ return token.bytes;
382
+ const chunk = input.read(...args);
383
+ if (chunk === null || chunk === undefined)
384
+ return null;
385
+ clearPendingEscape();
386
+ const readout = reader(String(chunk));
387
+ tokens.push(...readout.tokens);
388
+ schedulePendingEscape();
389
+ }
390
+ };
391
+ const stdin = new Proxy(input, {
392
+ get(target, property) {
393
+ if (property === "read")
394
+ return read;
395
+ const value = Reflect.get(target, property);
396
+ return typeof value === "function" && !Object.hasOwn(target, property)
397
+ ? value.bind(target)
398
+ : value;
399
+ },
400
+ });
401
+ return { stdin, close: clearPendingEscape };
402
+ }
248
403
  function LiveApp({ delivery, size, refreshMs, }) {
249
404
  const { exit } = useApp();
250
405
  const surface = useSyncExternalStore(delivery.subscribe, delivery.snapshot, delivery.snapshot);
251
406
  const measuredSize = useSyncExternalStore(size.subscribe, size.snapshot, size.snapshot);
252
- const [columns, rows] = measuredSize.split(":").map((part) => Math.max(0, Math.floor(Number(part))));
407
+ const { columns, rows } = parseMeasuredSize(measuredSize);
253
408
  const drawnSize = useRef(measuredSize);
254
409
  useEffect(() => {
255
410
  if (drawnSize.current === measuredSize)
@@ -270,42 +425,81 @@ function LiveApp({ delivery, size, refreshMs, }) {
270
425
  exit();
271
426
  return;
272
427
  }
273
- delivery.key({ input, key }, columns);
428
+ delivery.key({ input, key });
274
429
  });
430
+ // Every frame the renderer commits is published to the delivery, so a
431
+ // pointer report resolves against the plan and the drawn rows of the frame
432
+ // on the screen — the input path never plans one of its own.
433
+ const commitFrame = useCallback((planned, frameData) => {
434
+ delivery.commitPointerSurface(committedRunPointerSurface(planned, frameData));
435
+ }, [delivery]);
275
436
  return createElement(RunCockpitFrame, {
276
437
  data: surface.data,
277
438
  columns,
278
439
  rows,
279
440
  interaction: surface.interaction,
441
+ onCommittedFrame: commitFrame,
280
442
  });
281
443
  }
282
444
  export async function runLiveCockpit({ input, output, cwd, runId, binaryVersion, refreshMs = REFRESH_MS, now = Date.now, debug = false, onDelivery, }) {
283
- const delivery = createLiveCockpitDelivery({
284
- cwd,
285
- runId,
286
- binaryVersion,
287
- now,
288
- });
289
- onDelivery?.(delivery);
290
445
  // Subscribe before Ink mounts so no stale-size repaint can overtake the
291
- // surface's newly planned frame.
446
+ // surface's newly planned frame — and before the delivery exists, which
447
+ // routes every input against this measurement.
292
448
  const size = measureOutput(output);
293
- const app = render(createElement(LiveApp, {
294
- delivery,
295
- size,
296
- refreshMs,
297
- }), {
298
- stdin: input,
299
- stdout: size.stdout,
300
- debug,
301
- exitOnCtrlC: false,
302
- patchConsole: false,
303
- });
449
+ let app;
450
+ let filteredInput;
451
+ let releasePointerTracking;
304
452
  try {
453
+ const delivery = createLiveCockpitDelivery({
454
+ cwd,
455
+ runId,
456
+ binaryVersion,
457
+ now,
458
+ size: () => parseMeasuredSize(size.snapshot()),
459
+ });
460
+ onDelivery?.(delivery);
461
+ // Pointer reports are their own input class: they are taken off the stream
462
+ // before the keyboard reads it and delivered to the pointer boundary, never
463
+ // handed to a key handler. A batch keeps adjacent reports to one redraw.
464
+ filteredInput = pointerFilteredInput(input, (reports) => {
465
+ delivery.batch(() => {
466
+ for (const report of reports)
467
+ delivery.pointer(report);
468
+ });
469
+ });
470
+ // One loan owns the complete live lifecycle. Its signal repayments stand
471
+ // before the ask, then remain installed through mount, paint, input and
472
+ // unmount. Non-tty output borrows nothing and writes nothing.
473
+ releasePointerTracking = borrowPointerTracking(output);
474
+ app = render(createElement(LiveApp, {
475
+ delivery,
476
+ size,
477
+ refreshMs,
478
+ }), {
479
+ stdin: filteredInput.stdin,
480
+ stdout: size.stdout,
481
+ debug,
482
+ exitOnCtrlC: false,
483
+ patchConsole: false,
484
+ });
305
485
  await app.waitUntilExit();
306
486
  }
307
487
  finally {
308
- size.close();
309
- app.unmount();
488
+ try {
489
+ filteredInput?.close();
490
+ }
491
+ finally {
492
+ try {
493
+ app?.unmount();
494
+ }
495
+ finally {
496
+ try {
497
+ releasePointerTracking?.();
498
+ }
499
+ finally {
500
+ size.close();
501
+ }
502
+ }
503
+ }
310
504
  }
311
505
  }