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.
- package/dist/adapters/claude-code.d.ts +1 -0
- package/dist/adapters/claude-code.js +57 -1
- package/dist/adapters/fake.js +9 -0
- package/dist/adapters/types.d.ts +3 -0
- package/dist/adapters/types.js +21 -0
- package/dist/cli/commands/status.js +160 -30
- package/dist/compile/collateral.d.ts +86 -2
- package/dist/compile/collateral.js +294 -3
- package/dist/config/config.d.ts +62 -0
- package/dist/config/config.js +157 -2
- package/dist/drivers/herdr.d.ts +20 -3
- package/dist/drivers/herdr.js +288 -105
- package/dist/gates/baseline.d.ts +1 -0
- package/dist/gates/baseline.js +91 -13
- package/dist/gates/review.d.ts +7 -0
- package/dist/gates/review.js +99 -6
- package/dist/gates/run-gates.d.ts +9 -0
- package/dist/gates/run-gates.js +285 -41
- package/dist/run/daemon.d.ts +48 -2
- package/dist/run/daemon.js +1417 -315
- package/dist/run/journal.d.ts +56 -3
- package/dist/run/journal.js +275 -1
- package/dist/run/stall.d.ts +35 -1
- package/dist/run/stall.js +118 -8
- package/dist/tui/cockpit/components.d.ts +30 -1
- package/dist/tui/cockpit/components.js +19 -3
- package/dist/tui/cockpit/derive.d.ts +29 -2
- package/dist/tui/cockpit/derive.js +219 -23
- package/dist/tui/cockpit/layout.d.ts +75 -6
- package/dist/tui/cockpit/layout.js +97 -19
- package/dist/tui/cockpit/live.d.ts +14 -1
- package/dist/tui/cockpit/live.js +223 -29
- package/dist/tui/cockpit/pointer.d.ts +261 -0
- package/dist/tui/cockpit/pointer.js +610 -0
- package/dist/tui/cockpit/run-cockpit.d.ts +36 -5
- package/dist/tui/cockpit/run-cockpit.js +270 -51
- 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
|
|
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:
|
|
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
|
-
|
|
235
|
-
|
|
308
|
+
const bodyRegion = band === "sidebar"
|
|
309
|
+
? {
|
|
236
310
|
id: "body",
|
|
237
311
|
row: bodyRow,
|
|
238
312
|
rows: bodyRows,
|
|
239
|
-
column:
|
|
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:
|
|
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
|
|
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, }: {
|
package/dist/tui/cockpit/live.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
184
|
-
|
|
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
|
|
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 }
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
}
|