@giovannijecha/jecode 0.7.4 → 0.8.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.
@@ -1,43 +1,63 @@
1
- // A compact execution trace: state and identity on one rail, evidence below.
2
- import { hasColor, row } from "../../ui/render.js";
1
+ // A compact execution trace: state and identity on one semantic rail,
2
+ // bounded evidence below, and motion that never enters durable state.
3
+ import { fitSegs, hasColor, plainLen, row } from "../../ui/render.js";
3
4
  import { graphemeCeiling, graphemeFloor } from "../../text-boundary.js";
5
+ import { toolDuration } from "../../duration.js";
6
+ import { breathe, easeInOut, easeOut, interval, mix, TOOL_BIRTH_MS, TOOL_LEADER_MAX_MS, TOOL_ROW_ARRIVAL_MS, TOOL_SETTLE_MS, } from "../motion.js";
7
+ import { transcriptLead, transcriptMark } from "../transcript-grammar.js";
4
8
  const OUTPUT_ROWS = 8;
5
9
  const LIVE_OUTPUT_ROWS = 6;
6
10
  const DIFF_ROWS = 15;
11
+ const TOOL_NAME_COLS = 12;
12
+ const TOOL_COLUMNS_AT = 64;
13
+ const SPINNER_MS = 80;
14
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
15
  export function renderTool(block, width, pal, context = {}) {
16
+ const now = context.now ?? Date.now();
8
17
  const shown = visibleDetails(block);
9
- const right = liveLabel(block, context);
18
+ const ink = statusInk(block, pal, context, now);
19
+ const nameInk = birthInk(pal.ink.bright, pal.ink.dim, context, now);
20
+ const left = [
21
+ ...transcriptLead(width, { text: stateGlyph(block, context, now), fg: ink, bold: true }),
22
+ { text: toolName(block, width), fg: nameInk, bold: true },
23
+ ...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.technical }]),
24
+ ];
25
+ const right = resultSegments(block, pal, context, now);
26
+ const leader = movingLeader(width, left, right, pal, context, now);
10
27
  return [
11
- ...(context.continues === true ? [] : [""]),
12
- row(width, [
13
- { text: " " },
14
- { text: `${stateGlyph(block)} `, fg: statusInk(block.tone, pal), bold: true },
15
- { text: block.name, fg: pal.ink.bright, bold: true },
16
- ...(block.target === "" ? [] : [{ text: ` ${block.target}`, fg: pal.technical }]),
17
- ], right === "" ? [] : [{ text: right, fg: statusInk(block.tone, pal) }]),
18
- ...shown.map((detail) => renderDetail(detail, block.tone, width, pal)),
28
+ ...(context.followsReasoning === true
29
+ ? [row(width, transcriptMark(width, { text: "│", fg: pal.rule }))]
30
+ : context.continues === true ? [] : [""]),
31
+ row(width, leader === undefined ? left : [...left, leader], right),
32
+ ...shown.map(({ detail, sourceIndex }) => renderDetail(detail, block.tone, width, pal, context.reducedMotion === true ? undefined : context.motion?.rowsAt[sourceIndex ?? -1], now)),
19
33
  ];
20
34
  }
21
35
  function visibleDetails(block) {
22
36
  const all = block.body ?? [];
23
- if (block.expanded === true || all.length === 0)
24
- return all;
37
+ if (block.expanded === true || all.length === 0) {
38
+ return all.map((detail, sourceIndex) => ({ detail, sourceIndex }));
39
+ }
25
40
  if (all.every((detail) => detail.kind === "out")) {
26
41
  const limit = block.tone === "pending" ? LIVE_OUTPUT_ROWS : OUTPUT_ROWS;
27
42
  if (all.length <= limit)
28
- return all;
43
+ return all.map((detail, sourceIndex) => ({ detail, sourceIndex }));
29
44
  const hidden = all.length - limit;
30
45
  const note = block.tone === "pending"
31
46
  ? `… ${all.length} lines so far`
32
47
  : `… ${hidden} earlier lines · ctrl+o expand`;
33
- return [{ kind: "gap", text: note }, ...all.slice(-limit)];
48
+ return [
49
+ { detail: { kind: "gap", text: note } },
50
+ ...all.slice(-limit).map((detail, index) => ({
51
+ detail,
52
+ sourceIndex: all.length - limit + index,
53
+ })),
54
+ ];
34
55
  }
35
- // The compact transcript is an audit of what changed, not a code excerpt.
36
- // One shared budget applies to writes and edits. Keep both ends so a large
37
- // replacement cannot show only deletions while hiding all new content.
38
- // Context, omitted changes, and gap rows remain in semantic state for the
39
- // explicit full view.
40
- const changed = all.filter((detail) => detail.kind === "add" || detail.kind === "del");
56
+ // A compact transcript audits what changed rather than repeating unchanged
57
+ // source. One budget covers writes and edits, with both ends retained.
58
+ const changed = all
59
+ .map((detail, sourceIndex) => ({ detail, sourceIndex }))
60
+ .filter(({ detail }) => detail.kind === "add" || detail.kind === "del");
41
61
  if (changed.length <= DIFF_ROWS)
42
62
  return changed;
43
63
  const leading = Math.ceil(DIFF_ROWS / 2);
@@ -46,32 +66,50 @@ function visibleDetails(block) {
46
66
  return [
47
67
  ...changed.slice(0, leading),
48
68
  {
49
- kind: "gap",
50
- text: `… ${hidden} more changed ${hidden === 1 ? "line" : "lines"} · ctrl+o expand`,
69
+ detail: {
70
+ kind: "gap",
71
+ text: `… ${hidden} more changed ${hidden === 1 ? "line" : "lines"} · ctrl+o expand`,
72
+ },
51
73
  },
52
74
  ...changed.slice(-trailing),
53
75
  ];
54
76
  }
55
- function renderDetail(detail, tone, width, pal) {
56
- const lead = { text: " " };
57
- const rail = { text: "│ ", fg: pal.rule };
77
+ function renderDetail(detail, tone, width, pal, arrivedAt, now) {
78
+ const rail = transcriptLead(width, { text: "│", fg: pal.rule });
58
79
  if (detail.kind === "out") {
59
- const fg = tone === "fail" || failureLine(detail.text) ? pal.ink.removed : pal.ink.muted;
60
- return row(width, [lead, rail, { text: detail.text === "" ? " " : detail.text, fg }]);
80
+ const base = tone === "fail" || failureLine(detail.text) ? pal.ink.removed : pal.ink.muted;
81
+ return row(width, [
82
+ ...rail,
83
+ { text: detail.text === "" ? " " : detail.text, fg: arrivalInk(base, pal, arrivedAt, now) },
84
+ ]);
61
85
  }
62
86
  if (detail.kind === "gap") {
63
- return row(width, [lead, rail, { text: detail.text, fg: pal.ink.dim, italic: true }]);
87
+ return row(width, [
88
+ ...rail,
89
+ { text: detail.text, fg: arrivalInk(pal.ink.dim, pal, arrivedAt, now), italic: true },
90
+ ]);
64
91
  }
65
92
  const number = detail.kind === "add" ? detail.newLine : detail.oldLine;
66
- const prefix = `${detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " "}${String(number ?? "").padStart(3)} `;
67
- const fg = detail.kind === "add"
93
+ const sign = detail.kind === "add" ? "+" : detail.kind === "del" ? "-" : " ";
94
+ const base = detail.kind === "add"
68
95
  ? pal.ink.added
69
96
  : detail.kind === "del"
70
97
  ? pal.ink.removed
71
98
  : pal.ink.dim;
72
- return row(width, [lead, rail, { text: prefix, fg }, ...emphasized(detail.text, detail.emphasis, fg)]);
99
+ const fg = arrivalInk(base, pal, arrivedAt, now);
100
+ const tint = detail.kind === "add"
101
+ ? pal.surface.added
102
+ : detail.kind === "del"
103
+ ? pal.surface.removed
104
+ : undefined;
105
+ return row(width, [
106
+ ...rail,
107
+ { text: sign, fg, bold: detail.kind !== "keep" },
108
+ { text: `${String(number ?? "").padStart(4)} `, fg: pal.ink.dim },
109
+ ...emphasized(detail.text, detail.emphasis, fg, tint),
110
+ ]);
73
111
  }
74
- function emphasized(text, emphasis, fg) {
112
+ function emphasized(text, emphasis, fg, bg) {
75
113
  if (emphasis === undefined || emphasis.length <= 0)
76
114
  return [{ text, fg }];
77
115
  const requestedStart = Math.max(0, Math.min(text.length, emphasis.start));
@@ -82,33 +120,92 @@ function emphasized(text, emphasis, fg) {
82
120
  return [{ text, fg }];
83
121
  return [
84
122
  ...(start === 0 ? [] : [{ text: text.slice(0, start), fg }]),
85
- { text: text.slice(start, end), fg, inverse: true },
123
+ { text: text.slice(start, end), fg, bg, bold: true },
86
124
  ...(end === text.length ? [] : [{ text: text.slice(end), fg }]),
87
125
  ];
88
126
  }
89
- function stateGlyph(block) {
90
- if (block.tone === "pending")
91
- return "◌";
127
+ function toolName(block, width) {
128
+ if (width < TOOL_COLUMNS_AT || block.target === "")
129
+ return block.name;
130
+ return block.name.padEnd(TOOL_NAME_COLS);
131
+ }
132
+ function stateGlyph(block, context, now) {
133
+ if (block.tone === "pending") {
134
+ if (block.startedAt === undefined || block.right !== "running")
135
+ return "○";
136
+ if (context.reducedMotion === true)
137
+ return "○";
138
+ return SPINNER[Math.floor(now / SPINNER_MS) % SPINNER.length] ?? "○";
139
+ }
92
140
  if (block.tone === "fail")
93
- return hasColor() ? "●" : "×";
141
+ return "×";
94
142
  if (block.tone === "deny")
95
143
  return "○";
96
144
  return hasColor() ? "●" : "✓";
97
145
  }
98
- function liveLabel(block, context) {
99
- if (block.tone !== "pending" || block.startedAt === undefined || block.right !== "running") {
100
- return block.right;
146
+ function resultSegments(block, pal, context, now) {
147
+ const status = block.right;
148
+ const duration = liveDuration(block, now);
149
+ if (status === "" && duration === "")
150
+ return [];
151
+ const ink = statusInk(block, pal, context, now);
152
+ return [
153
+ ...(status === "" ? [] : [{ text: status, fg: ink }]),
154
+ ...(duration === "" ? [] : [{ text: `${status === "" ? "" : " · "}${duration}`, fg: pal.ink.dim }]),
155
+ ];
156
+ }
157
+ function liveDuration(block, now) {
158
+ if (block.tone === "pending" && block.startedAt !== undefined && block.right === "running") {
159
+ return toolDuration(Math.max(0, now - block.startedAt), true);
101
160
  }
102
- const elapsed = Math.max(0, (context.now ?? Date.now()) - block.startedAt);
103
- const seconds = elapsed / 1_000;
104
- return `running · ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
161
+ return block.durationMs === undefined ? "" : toolDuration(block.durationMs);
105
162
  }
106
- function statusInk(tone, pal) {
107
- if (tone === "fail")
163
+ function statusInk(block, pal, context, now) {
164
+ if (block.tone === "fail")
108
165
  return pal.ink.removed;
109
- if (tone === "pending" || tone === "deny")
166
+ if (block.tone === "deny")
110
167
  return pal.ink.attention;
111
- return pal.ink.added;
168
+ if (block.tone === "pending") {
169
+ if (block.startedAt === undefined || context.reducedMotion === true)
170
+ return pal.ink.attention;
171
+ return mix(pal.ink.attention, pal.accent, breathe(now));
172
+ }
173
+ if (context.reducedMotion === true || context.motion?.settledAt === undefined)
174
+ return pal.ink.muted;
175
+ const cooled = easeOut(interval(now, context.motion.settledAt, TOOL_SETTLE_MS));
176
+ return mix(pal.ink.added, pal.ink.muted, cooled);
177
+ }
178
+ function birthInk(final, initial, context, now) {
179
+ if (context.reducedMotion === true || context.motion === undefined)
180
+ return final;
181
+ return mix(initial, final, easeOut(interval(now, context.motion.bornAt, TOOL_BIRTH_MS)));
182
+ }
183
+ function arrivalInk(base, pal, arrivedAt, now) {
184
+ if (arrivedAt === undefined)
185
+ return base;
186
+ return mix(pal.ink.bright, base, easeOut(interval(now, arrivedAt, TOOL_ROW_ARRIVAL_MS)));
187
+ }
188
+ function movingLeader(width, left, right, pal, context, now) {
189
+ if (context.reducedMotion === true || context.motion === undefined || right.length === 0)
190
+ return undefined;
191
+ const progress = interval(now, context.motion.bornAt, TOOL_LEADER_MAX_MS);
192
+ if (progress >= 1)
193
+ return undefined;
194
+ const fittedRight = fitSegs(right, width);
195
+ const gap = width - plainLen(left) - plainLen(fittedRight) - 1;
196
+ if (gap < 9)
197
+ return undefined;
198
+ const cells = Array.from({ length: gap }, () => " ");
199
+ const travelCells = gap - 2;
200
+ const trail = Math.min(7, travelCells);
201
+ const travel = travelCells + trail;
202
+ const head = Math.floor(easeInOut(progress) * travel) - trail;
203
+ for (let offset = 0; offset < trail; offset++) {
204
+ const at = head + offset;
205
+ if (at >= 0 && at < travelCells)
206
+ cells[at + 1] = "·";
207
+ }
208
+ return { text: cells.join(""), fg: pal.rule };
112
209
  }
113
210
  function failureLine(line) {
114
211
  return line.startsWith("✖") || line.startsWith("×") || line.includes("AssertionError") || /\bfailed\b/i.test(line);
package/dist/tui/help.js CHANGED
@@ -7,7 +7,7 @@ const CONTROLS = [
7
7
  { key: "left / right", description: "move cursor or change a value" },
8
8
  { key: "ctrl+left / right", description: "move cursor by word" },
9
9
  { key: "ctrl+backspace/del", description: "delete a word" },
10
- { key: "enter / tab", description: "select or send · complete" },
10
+ { key: "enter / tab", description: "select, send/steer · complete" },
11
11
  { key: "alt+enter", description: "insert a new line" },
12
12
  { key: "esc", description: "close UI or interrupt work" },
13
13
  { key: "ctrl+c", description: "interrupt work or exit" },
@@ -0,0 +1,32 @@
1
+ // Small deterministic motion primitives for terminal cells.
2
+ export const TOOL_BIRTH_MS = 300;
3
+ export const TOOL_SETTLE_MS = 700;
4
+ export const TOOL_ROW_ARRIVAL_MS = 420;
5
+ export const TOOL_LEADER_MAX_MS = 1_500;
6
+ export function interval(now, start, duration) {
7
+ if (start === undefined)
8
+ return 1;
9
+ if (duration <= 0)
10
+ return 1;
11
+ return Math.max(0, Math.min(1, (now - start) / duration));
12
+ }
13
+ export function easeOut(value) {
14
+ const rest = 1 - Math.max(0, Math.min(1, value));
15
+ return 1 - rest * rest * rest;
16
+ }
17
+ export function easeInOut(value) {
18
+ const at = Math.max(0, Math.min(1, value));
19
+ return at < 0.5 ? 4 * at * at * at : 1 - Math.pow(-2 * at + 2, 3) / 2;
20
+ }
21
+ export function mix(from, to, amount) {
22
+ const at = Math.max(0, Math.min(1, amount));
23
+ return [
24
+ Math.round(from[0] + (to[0] - from[0]) * at),
25
+ Math.round(from[1] + (to[1] - from[1]) * at),
26
+ Math.round(from[2] + (to[2] - from[2]) * at),
27
+ ];
28
+ }
29
+ /** A restrained pulse that never reaches either endpoint. */
30
+ export function breathe(now, period = 1_400) {
31
+ return 0.2 + ((Math.sin((now / period) * Math.PI * 2) + 1) / 2) * 0.6;
32
+ }
@@ -1,7 +1,7 @@
1
1
  // Small projections of the live session used by the shell and footer.
2
2
  import { credentialSource } from "../credentials.js";
3
3
  import { providerFailure } from "../provider-errors.js";
4
- export function controllerOptions(session, contextPolicy, tools = session.tools) {
4
+ export function controllerOptions(session, contextPolicy, tools = session.tools, steering) {
5
5
  return {
6
6
  provider: session.provider,
7
7
  tools,
@@ -10,8 +10,9 @@ export function controllerOptions(session, contextPolicy, tools = session.tools)
10
10
  maxTokens: session.config.maxTokens,
11
11
  contextPolicy,
12
12
  effort: session.config.effort,
13
- maxSteps: session.config.maxSteps,
13
+ maxModelRequests: session.config.maxModelRequests,
14
14
  toolContext: { root: session.config.root },
15
+ ...(steering === undefined ? {} : { steering }),
15
16
  };
16
17
  }
17
18
  export function footerInfo(session, workspace = session.config.root) {
@@ -0,0 +1,25 @@
1
+ // One semantic margin shared by every transcript block.
2
+ const FULL_GUTTER = 4;
3
+ const COMPACT_GUTTER = 2;
4
+ const COMPACT_BELOW = 64;
5
+ export function transcriptGutter(width) {
6
+ return width < COMPACT_BELOW ? COMPACT_GUTTER : FULL_GUTTER;
7
+ }
8
+ export function transcriptWidth(width) {
9
+ return Math.max(8, width - transcriptGutter(width));
10
+ }
11
+ /** Put one semantic mark in the transcript margin, or reserve the same cells. */
12
+ export function transcriptLead(width, mark) {
13
+ if (mark === undefined)
14
+ return [{ text: " ".repeat(transcriptGutter(width)) }];
15
+ return [
16
+ ...transcriptMark(width, mark),
17
+ { text: " ".repeat(width < COMPACT_BELOW ? 1 : 2) },
18
+ ];
19
+ }
20
+ /** Draw only the gutter mark, without trailing content-column whitespace. */
21
+ export function transcriptMark(width, mark) {
22
+ if (width < COMPACT_BELOW)
23
+ return [{ ...mark }];
24
+ return [{ text: " " }, { ...mark }];
25
+ }
@@ -1,5 +1,6 @@
1
1
  // Incremental transcript layout for long-running sessions.
2
2
  import { render } from "./blocks.js";
3
+ import { TOOL_BIRTH_MS, TOOL_LEADER_MAX_MS, TOOL_ROW_ARRIVAL_MS, TOOL_SETTLE_MS, } from "./motion.js";
3
4
  const MAX_LAYOUTS = 2;
4
5
  const REFLOW_BLOCKS_PER_FRAME = 128;
5
6
  /**
@@ -10,11 +11,20 @@ const REFLOW_BLOCKS_PER_FRAME = 128;
10
11
  * are refreshed over later frames. This keeps resize responsive without a
11
12
  * worker, hidden loop, or unbounded multi-width cache.
12
13
  */
13
- export function transcriptRenderer(draw = render) {
14
+ export function transcriptRenderer(draw = render, clock = Date.now) {
14
15
  let layouts = [];
16
+ const motions = new WeakMap();
17
+ const activeMotion = new Set();
15
18
  return {
16
19
  viewport(blocks, width, height, scroll, palette, state = {}) {
17
20
  const layout = layoutFor(blocks, width, palette);
21
+ const now = state.now ?? clock();
22
+ if (state.reducedMotion === true) {
23
+ discardSuppressedMotion(layout);
24
+ }
25
+ else if (scroll === 0) {
26
+ invalidateMovingEntries(layout, now);
27
+ }
18
28
  // Reflowing rows below a scroll-locked viewport would move the content
19
29
  // being read as estimates become exact. Visible rows still resolve on
20
30
  // demand; background work resumes when the user follows the tail again.
@@ -25,6 +35,7 @@ export function transcriptRenderer(draw = render) {
25
35
  rows: visibleRows(layout, window, Math.max(0, height)),
26
36
  maxScroll: window.maxScroll,
27
37
  pending: scroll === 0 && layout.pending > 0,
38
+ animating: scroll === 0 && state.reducedMotion !== true && hasMovingEntry(layout, now),
28
39
  };
29
40
  },
30
41
  invalidate(block) {
@@ -32,16 +43,12 @@ export function transcriptRenderer(draw = render) {
32
43
  layouts = [];
33
44
  return;
34
45
  }
46
+ observeMotion(block, clock());
35
47
  for (const layout of layouts) {
36
48
  const index = layout.positions.get(block);
37
49
  if (index === undefined)
38
50
  continue;
39
- const entry = layout.entries[index];
40
- if (entry?.rows !== undefined) {
41
- entry.rows = undefined;
42
- layout.pending++;
43
- }
44
- layout.next = Math.max(layout.next, index);
51
+ dirty(layout, index);
45
52
  }
46
53
  },
47
54
  };
@@ -131,6 +138,7 @@ export function transcriptRenderer(draw = render) {
131
138
  entry.rows = draw(entry.block, layout.width, layout.palette, {
132
139
  ...state,
133
140
  previous: layout.entries[index - 1]?.block,
141
+ motion: motions.get(entry.block),
134
142
  });
135
143
  entry.rowCount = entry.rows.length;
136
144
  layout.pending--;
@@ -172,6 +180,123 @@ export function transcriptRenderer(draw = render) {
172
180
  rows.unshift("");
173
181
  return rows;
174
182
  }
183
+ function dirty(layout, index) {
184
+ const entry = layout.entries[index];
185
+ if (entry?.rows !== undefined) {
186
+ entry.rows = undefined;
187
+ layout.pending++;
188
+ }
189
+ layout.next = Math.max(layout.next, index);
190
+ }
191
+ function observeMotion(block, now) {
192
+ if (block.kind !== "tool")
193
+ return;
194
+ let motion = motions.get(block);
195
+ // A settled historical block may be invalidated by Ctrl+O. It should not
196
+ // replay its original arrival animation after resume.
197
+ if (motion === undefined) {
198
+ if (block.tone !== "pending")
199
+ return;
200
+ motion = {
201
+ bornAt: now,
202
+ rowsAt: (block.body ?? []).map(() => now),
203
+ rowKeys: (block.body ?? []).map(detailKey),
204
+ tone: block.tone,
205
+ };
206
+ motions.set(block, motion);
207
+ activeMotion.add(block);
208
+ return;
209
+ }
210
+ if (motion.tone === "pending" && block.tone !== "pending")
211
+ motion.settledAt = now;
212
+ motion.tone = block.tone;
213
+ reconcileRows(motion, block.body ?? [], now);
214
+ activeMotion.add(block);
215
+ }
216
+ function invalidateMovingEntries(layout, now) {
217
+ for (const block of activeMotion) {
218
+ const index = layout.positions.get(block);
219
+ if (index === undefined) {
220
+ activeMotion.delete(block);
221
+ continue;
222
+ }
223
+ const motion = motions.get(block);
224
+ if (motion === undefined || !moving(block, motion, now)) {
225
+ // Paint the resting state once before leaving the motion registry;
226
+ // otherwise the cache could retain the final in-between frame.
227
+ dirty(layout, index);
228
+ activeMotion.delete(block);
229
+ continue;
230
+ }
231
+ dirty(layout, index);
232
+ }
233
+ }
234
+ function hasMovingEntry(layout, now) {
235
+ for (const block of activeMotion) {
236
+ if (layout.positions.get(block) === undefined)
237
+ continue;
238
+ const motion = motions.get(block);
239
+ if (motion !== undefined && moving(block, motion, now))
240
+ return true;
241
+ }
242
+ return false;
243
+ }
244
+ function discardSuppressedMotion(layout) {
245
+ for (const block of activeMotion) {
246
+ if (layout.positions.get(block) !== undefined && block.kind === "tool" && block.tone === "pending") {
247
+ continue;
248
+ }
249
+ activeMotion.delete(block);
250
+ motions.delete(block);
251
+ }
252
+ }
253
+ }
254
+ function reconcileRows(motion, details, now) {
255
+ const available = new Map();
256
+ for (let index = 0; index < motion.rowKeys.length; index++) {
257
+ const key = motion.rowKeys[index];
258
+ if (key === undefined)
259
+ continue;
260
+ const bucket = available.get(key) ?? { values: [], next: 0 };
261
+ bucket.values.push(motion.rowsAt[index] ?? motion.bornAt);
262
+ available.set(key, bucket);
263
+ }
264
+ const rowKeys = details.map(detailKey);
265
+ const rowsAt = rowKeys.map((key) => {
266
+ const bucket = available.get(key);
267
+ if (bucket === undefined || bucket.next >= bucket.values.length)
268
+ return now;
269
+ const value = bucket.values[bucket.next] ?? now;
270
+ bucket.next++;
271
+ return value;
272
+ });
273
+ motion.rowKeys = rowKeys;
274
+ motion.rowsAt = rowsAt;
275
+ }
276
+ function detailKey(detail) {
277
+ if (detail.kind === "out" || detail.kind === "gap") {
278
+ return JSON.stringify([detail.kind, detail.text]);
279
+ }
280
+ return JSON.stringify([
281
+ detail.kind,
282
+ detail.oldLine ?? "",
283
+ detail.newLine ?? "",
284
+ detail.emphasis?.start ?? "",
285
+ detail.emphasis?.length ?? "",
286
+ detail.text,
287
+ ]);
288
+ }
289
+ function moving(block, motion, now) {
290
+ if (block.kind === "tool" && block.tone === "pending" &&
291
+ block.startedAt !== undefined && block.right === "running")
292
+ return true;
293
+ let until = motion.bornAt + Math.max(TOOL_BIRTH_MS, TOOL_LEADER_MAX_MS);
294
+ if (motion.settledAt !== undefined)
295
+ until = Math.max(until, motion.settledAt + TOOL_SETTLE_MS);
296
+ for (const arrivedAt of motion.rowsAt) {
297
+ until = Math.max(until, arrivedAt + TOOL_ROW_ARRIVAL_MS);
298
+ }
299
+ return now < until;
175
300
  }
176
301
  function compatible(layout, blocks) {
177
302
  return layout.source === blocks &&