@cueloop/client 0.1.0-alpha.62 → 0.1.0-alpha.64

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.
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { Keymap, type KeymapEvent, type KeymapHost } from "@opentui/keymap";
12
12
  import { registerDefaultKeys } from "@opentui/keymap/addons";
13
+ import * as v from "valibot";
13
14
  import type { KeyState } from "./keymap";
14
15
 
15
16
  export interface ResolvableKey {
@@ -111,7 +112,11 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
111
112
  };
112
113
 
113
114
  /** Status-line glyphs for named keys. */
114
- const KEY_GLYPHS: Record<string, string> = {
115
+ interface KeyGlyphs {
116
+ [key: string]: string;
117
+ }
118
+
119
+ const KEY_GLYPHS: KeyGlyphs = {
115
120
  return: "enter",
116
121
  enter: "enter",
117
122
  escape: "esc",
@@ -186,7 +191,7 @@ class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
186
191
  * keymap's canonical form is lowercase + the shift modifier. Non-letter
187
192
  * single characters ("$", "0") already encode their shift in the character.
188
193
  */
189
- function normalizedEvent(key: ResolvableKey): { name: string; shift: boolean } {
194
+ function normalizedEvent(key: ResolvableKey) {
190
195
  if (key.name.length === 1) {
191
196
  if (/[a-zA-Z]/.test(key.name)) {
192
197
  return {
@@ -220,6 +225,10 @@ const SPAN_COMMANDS: [key: string, command: string][] = [
220
225
  ["escape", "span_cancel"],
221
226
  ];
222
227
 
228
+ function isConditionMatcher(value: unknown): value is () => boolean {
229
+ return typeof value === "function";
230
+ }
231
+
223
232
  export class KeyBindings {
224
233
  private readonly host = new HeadlessKeymapHost();
225
234
  private readonly keymap = new Keymap<object, KeymapEvent>(this.host);
@@ -234,7 +243,7 @@ export class KeyBindings {
234
243
  // and dispatch only see the layer that owns the current mode
235
244
  this.keymap.registerLayerFields({
236
245
  when: (value, fieldContext) => {
237
- if (typeof value === "function") fieldContext.activeWhen(value as () => boolean);
246
+ if (isConditionMatcher(value)) fieldContext.activeWhen(value);
238
247
  },
239
248
  });
240
249
  this.registerModeLayers();
@@ -373,10 +382,10 @@ export class KeyBindings {
373
382
  /** First active key display for a command, mapped through the glyph table. */
374
383
  private keyDisplayFor(command: string): string | null {
375
384
  for (const activeKey of this.keymap.getActiveKeys({ includeBindings: true })) {
376
- const bindingCommand =
377
- typeof activeKey.command === "string"
378
- ? activeKey.command
379
- : activeKey.bindings?.[0]?.command;
385
+ const parsedCommand = v.safeParse(v.string(), activeKey.command);
386
+ const bindingCommand = parsedCommand.success
387
+ ? parsedCommand.output
388
+ : activeKey.bindings?.[0]?.command;
380
389
 
381
390
  if (bindingCommand !== command) continue;
382
391
 
@@ -21,11 +21,7 @@ function state(patch: Partial<KeyState> = {}): KeyState {
21
21
  };
22
22
  }
23
23
 
24
- function key(
25
- name: string,
26
- shift = false,
27
- meta = false,
28
- ): { name: string; shift: boolean; meta: boolean } {
24
+ function key(name: string, shift = false, meta = false) {
29
25
  return { name, shift, meta };
30
26
  }
31
27
 
@@ -537,11 +533,13 @@ describe("review panel controls", () => {
537
533
 
538
534
  describe("duplicated-branch collapse", () => {
539
535
  test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
540
- for (const patch of [
536
+ const patches: Partial<KeyState>[] = [
541
537
  {},
542
538
  { annotationCount: 0, hasFocusedAnnotation: false },
543
539
  { resolved: true },
544
- ] as Partial<KeyState>[]) {
540
+ ];
541
+
542
+ for (const patch of patches) {
545
543
  for (const name of ["n", "p", "backspace", "return"]) {
546
544
  const plan = reduceKey(state(patch), key(name));
547
545
  const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
@@ -54,13 +54,13 @@ describe("kitty image placement", () => {
54
54
  describe("resolveTransmitMedium", () => {
55
55
  test("defaults to base64; file transfer is opt-in and never over ssh", () => {
56
56
  // Assert
57
- expect(resolveTransmitMedium({} as NodeJS.ProcessEnv)).toBe("base64");
58
- expect(resolveTransmitMedium({ CUELOOP_KITTY_FILE: "1" } as NodeJS.ProcessEnv)).toBe("file");
57
+ expect(resolveTransmitMedium({})).toBe("base64");
58
+ expect(resolveTransmitMedium({ CUELOOP_KITTY_FILE: "1" })).toBe("file");
59
59
  expect(
60
60
  resolveTransmitMedium({
61
61
  CUELOOP_KITTY_FILE: "1",
62
62
  SSH_CONNECTION: "1.2.3.4 5 6 7",
63
- } as NodeJS.ProcessEnv),
63
+ }),
64
64
  ).toBe("base64");
65
65
  });
66
66
  });
@@ -73,7 +73,7 @@ export function cssBoxToCell(
73
73
  box: ElementBox,
74
74
  image: { x: number; y: number; width: number; height: number },
75
75
  viewport: PrototypeViewport,
76
- ): { column: number; row: number; columns: number; rows: number } {
76
+ ) {
77
77
  const scaleColumn = image.width / viewport.width;
78
78
  const scaleRow = image.height / viewport.height;
79
79
 
@@ -174,13 +174,13 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
174
174
  omitBackground: true,
175
175
  });
176
176
 
177
- return new Uint8Array(buffer as Buffer);
177
+ return new Uint8Array(buffer);
178
178
  },
179
179
  async elementAt(cssX, cssY) {
180
- return (await page.evaluate(elementAtScript, cssX, cssY)) as PrototypeElement | null;
180
+ return page.evaluate(elementAtScript, cssX, cssY);
181
181
  },
182
182
  async scrollBy(deltaY) {
183
- return (await page.evaluate(scrollByScript, deltaY)) as boolean;
183
+ return page.evaluate(scrollByScript, deltaY);
184
184
  },
185
185
  async close() {
186
186
  await page.close().catch(() => undefined);
@@ -189,7 +189,7 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
189
189
  }
190
190
 
191
191
  /** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
192
- function elementAtScript(x: number, y: number): unknown {
192
+ function elementAtScript(x: number, y: number) {
193
193
  const SEMANTIC = new Set([
194
194
  "SECTION",
195
195
  "ARTICLE",
@@ -232,7 +232,8 @@ function elementAtScript(x: number, y: number): unknown {
232
232
  const parent: Element | null = current.parentElement;
233
233
 
234
234
  if (parent) {
235
- const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
235
+ const currentTagName = current.tagName;
236
+ const twins = [...parent.children].filter((child) => child.tagName === currentTagName);
236
237
 
237
238
  if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
238
239
  }
@@ -242,15 +243,24 @@ function elementAtScript(x: number, y: number): unknown {
242
243
 
243
244
  return parts.join(" > ");
244
245
  };
246
+ // Prefer the nearest focusable target before falling back to its component container.
247
+ const focusableTarget = (start: Element | null): Element | null => {
248
+ for (
249
+ let element: Element | null = start;
250
+ element && element !== document.body;
251
+ element = element.parentElement
252
+ ) {
253
+ if (element instanceof HTMLElement && element.tabIndex >= 0) {
254
+ return element;
255
+ }
256
+ }
257
+
258
+ return null;
259
+ };
245
260
  const hit = document.elementFromPoint(x, y);
246
261
 
247
262
  if (!(hit instanceof Element)) return null;
248
- // A click on an interactive control anchors to that control - a button in a
249
- // grid is the target, not the grid. Everything else climbs to its component.
250
- const control = hit.closest(
251
- "button, a, [role='button'], input, select, textarea, label, summary",
252
- );
253
- const node = control ?? componentRoot(hit);
263
+ const node = focusableTarget(hit) ?? componentRoot(hit);
254
264
  const selector = selectorFor(node);
255
265
 
256
266
  if (!selector) return null;
package/src/serve.ts CHANGED
@@ -36,7 +36,7 @@ export interface ServeOptions {
36
36
  home?: string;
37
37
  /** Silence the @opentui/ssh startup banner (tests). Default true. */
38
38
  banner?: boolean;
39
- onError?: (err: unknown) => void;
39
+ onError?: (cause: unknown) => void;
40
40
  }
41
41
 
42
42
  export interface ServeHandle {
@@ -37,26 +37,41 @@ function diffSession(files?: DiffFileContents[]): ReviewSession {
37
37
  };
38
38
  }
39
39
 
40
+ interface WorkingCopySink {
41
+ workingCopy?: string;
42
+ }
43
+
44
+ const unimplemented = (member: string) => () =>
45
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
46
+
40
47
  /** A fake client that records the working copy the controller writes. */
41
- function fakeClient(session: ReviewSession, sink: { workingCopy?: string }): SessionClient {
48
+ function fakeClient(session: ReviewSession, sink: WorkingCopySink): SessionClient {
42
49
  return {
43
50
  onEvent: () => () => {},
44
51
  subscribe: async () => {},
45
52
  sessionGet: async () => session,
46
53
  sessionList: async () => [session],
54
+ sessionAnnotate: unimplemented("sessionAnnotate"),
55
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
47
56
  sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
48
57
  sink.workingCopy = content;
49
58
 
50
59
  return { ...session, workingCopy: content };
51
60
  }),
61
+ sessionSetViewed: unimplemented("sessionSetViewed"),
62
+ sessionSetShareId: unimplemented("sessionSetShareId"),
63
+ sessionMergeShared: unimplemented("sessionMergeShared"),
64
+ sessionDelete: unimplemented("sessionDelete"),
65
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
66
+ sessionResolve: unimplemented("sessionResolve"),
52
67
  close: () => {},
53
- } as unknown as SessionClient;
68
+ };
54
69
  }
55
70
 
56
71
  const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
57
72
 
58
73
  async function connected(session: ReviewSession) {
59
- const sink: { workingCopy?: string } = {};
74
+ const sink: WorkingCopySink = {};
60
75
  const client = fakeClient(session, sink);
61
76
  const controller = createReviewController({
62
77
  sessionId: session.id,
@@ -1,10 +1,9 @@
1
- import { describe, expect, mock, test } from "bun:test";
1
+ import { describe, expect, mock, test, type Mock } from "bun:test";
2
2
  import { ManualClock } from "@opentui/core/testing";
3
3
  import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
4
4
  import type { SessionClient } from "@cueloop/daemon/client";
5
+ import { createReviewController, SHARE_POLL_MS, type ShareTransport } from "./session-controller";
5
6
 
6
- // Stub the ssh transport so no subprocess spawns; the controller under test
7
- // imports these from "./share".
8
7
  const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
9
8
  let remote: ReviewSession;
10
9
  const pullShare = mock(async () => remote);
@@ -12,16 +11,13 @@ const pushShare = mock(
12
11
  async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
13
12
  );
14
13
 
15
- mock.module("./share", () => ({
16
- publishShare,
17
- pullShare,
18
- pushShare,
19
- shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
20
- collaboratorAnnotations: (session: ReviewSession) =>
21
- session.annotations.filter((annotation) => annotation.author),
22
- }));
23
-
24
- const { createReviewController, SHARE_POLL_MS } = await import("./session-controller");
14
+ const shareTransport: ShareTransport = {
15
+ publish: publishShare,
16
+ pull: pullShare,
17
+ push: pushShare,
18
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
19
+ collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
20
+ };
25
21
 
26
22
  function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
27
23
  return {
@@ -49,13 +45,23 @@ function annotation(id: string, author: string): Annotation {
49
45
  };
50
46
  }
51
47
 
52
- function fakeClient(session: ReviewSession): SessionClient {
48
+ interface FakeSessionClient extends SessionClient {
49
+ sessionAnnotate: Mock<SessionClient["sessionAnnotate"]>;
50
+ }
51
+
52
+ const unimplemented = (member: string) => () =>
53
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
54
+
55
+ function fakeClient(session: ReviewSession): FakeSessionClient {
53
56
  return {
54
57
  onEvent: () => () => {},
55
58
  subscribe: async () => {},
56
59
  sessionGet: async () => session,
57
60
  sessionList: async () => [session],
58
- sessionAnnotate: mock(async () => session),
61
+ sessionAnnotate: mock<SessionClient["sessionAnnotate"]>(async () => session),
62
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
63
+ sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
64
+ sessionSetViewed: unimplemented("sessionSetViewed"),
59
65
  sessionSetShareId: mock(
60
66
  async (_id: string, shareId: string) => ((session.shareId = shareId), session),
61
67
  ),
@@ -67,8 +73,11 @@ function fakeClient(session: ReviewSession): SessionClient {
67
73
 
68
74
  return session;
69
75
  }),
76
+ sessionDelete: unimplemented("sessionDelete"),
77
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
78
+ sessionResolve: unimplemented("sessionResolve"),
70
79
  close: () => {},
71
- } as unknown as SessionClient;
80
+ };
72
81
  }
73
82
 
74
83
  const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -76,12 +85,13 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
76
85
  async function connectedController(
77
86
  session: ReviewSession,
78
87
  clock?: ManualClock,
79
- ): Promise<{ controller: ReturnType<typeof createReviewController>; client: SessionClient }> {
88
+ ): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeSessionClient }> {
80
89
  const client = fakeClient(session);
81
90
  const controller = createReviewController({
82
91
  sessionId: session.id,
83
92
  openClient: async () => client,
84
93
  clock,
94
+ shareTransport,
85
95
  });
86
96
 
87
97
  controller.connect();
@@ -205,7 +215,7 @@ describe("mirror on annotate", () => {
205
215
  sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
206
216
  );
207
217
 
208
- (client.sessionAnnotate as ReturnType<typeof mock>).mockImplementationOnce(async () => {
218
+ client.sessionAnnotate.mockImplementationOnce(async () => {
209
219
  throw new Error("session is resolved");
210
220
  });
211
221
 
@@ -131,6 +131,22 @@ export interface ControllerSnapshot {
131
131
  walk: { index: number } | null;
132
132
  }
133
133
 
134
+ export interface ShareTransport {
135
+ publish: typeof publishShare;
136
+ pull: typeof pullShare;
137
+ push: typeof pushShare;
138
+ parseShareId: typeof shareIdFromLine;
139
+ collaboratorAnnotations: typeof collaboratorAnnotations;
140
+ }
141
+
142
+ const DEFAULT_SHARE_TRANSPORT: ShareTransport = {
143
+ publish: publishShare,
144
+ pull: pullShare,
145
+ push: pushShare,
146
+ parseShareId: shareIdFromLine,
147
+ collaboratorAnnotations,
148
+ };
149
+
134
150
  export interface ReviewControllerOptions {
135
151
  home?: string;
136
152
  sessionId?: string;
@@ -145,6 +161,7 @@ export interface ReviewControllerOptions {
145
161
  * renders a decrypted share instead.
146
162
  */
147
163
  openClient?: () => Promise<SessionClient>;
164
+ shareTransport?: ShareTransport;
148
165
  }
149
166
 
150
167
  export interface ReviewController {
@@ -235,6 +252,13 @@ export function createReviewController(options: ReviewControllerOptions): Review
235
252
  return new Controller(options);
236
253
  }
237
254
 
255
+ interface DerivedSessionProjection {
256
+ display: DisplayBlock[];
257
+ rows: DiffRow[];
258
+ files: WalkFile[];
259
+ models: Map<string, FileDiffMetadata>;
260
+ }
261
+
238
262
  class Controller implements ReviewController {
239
263
  readonly readOnly: boolean;
240
264
  private client: SessionClient | null = null;
@@ -254,18 +278,13 @@ class Controller implements ReviewController {
254
278
  private editor: string | undefined;
255
279
  private exporters: BundledExporter[] = [];
256
280
  private readonly clock: Clock;
281
+ private readonly shareTransport: ShareTransport;
257
282
  private countdown: TimerHandle | undefined;
258
283
  private sharePoll: TimerHandle | undefined;
259
284
  private shareRun: object | null = null;
260
285
  /** Projections keyed by session identity so renders reuse one computation. */
261
286
  private derivedFor: ReviewSession | null = null;
262
- private derived: {
263
- display: DisplayBlock[];
264
- rows: DiffRow[];
265
- files: WalkFile[];
266
- /** Per-path parsed diff models, for hunk curation; empty when files absent. */
267
- models: Map<string, FileDiffMetadata>;
268
- } = {
287
+ private derived: DerivedSessionProjection = {
269
288
  display: [],
270
289
  rows: [],
271
290
  files: [],
@@ -277,6 +296,7 @@ class Controller implements ReviewController {
277
296
  constructor(private readonly options: ReviewControllerOptions) {
278
297
  this.readOnly = options.readOnly ?? false;
279
298
  this.clock = options.clock ?? new SystemClock();
299
+ this.shareTransport = options.shareTransport ?? DEFAULT_SHARE_TRANSPORT;
280
300
  }
281
301
 
282
302
  subscribe = (listener: () => void): (() => void) => {
@@ -402,16 +422,16 @@ class Controller implements ReviewController {
402
422
  private async refreshSession(id: string): Promise<void> {
403
423
  try {
404
424
  if (this.client) this.update({ session: await this.client.sessionGet(id) });
405
- } catch (error) {
406
- if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
425
+ } catch (cause) {
426
+ if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
407
427
  }
408
428
  }
409
429
 
410
430
  private async refreshInbox(): Promise<void> {
411
431
  try {
412
432
  if (this.client) this.update({ inbox: await this.client.sessionList({ status: "pending" }) });
413
- } catch (error) {
414
- if (!this.closed) this.setStatus(error instanceof Error ? error.message : String(error));
433
+ } catch (cause) {
434
+ if (!this.closed) this.setStatus(cause instanceof Error ? cause.message : String(cause));
415
435
  }
416
436
  }
417
437
 
@@ -419,8 +439,8 @@ class Controller implements ReviewController {
419
439
  private apply(mutation: Promise<ReviewSession>): void {
420
440
  mutation
421
441
  .then((session) => this.update({ session }))
422
- .catch((error: unknown) =>
423
- this.setStatus(String(error instanceof Error ? error.message : error)),
442
+ .catch((cause: unknown) =>
443
+ this.setStatus(String(cause instanceof Error ? cause.message : cause)),
424
444
  );
425
445
  }
426
446
 
@@ -438,8 +458,8 @@ class Controller implements ReviewController {
438
458
  this.client
439
459
  ?.sessionDelete(id)
440
460
  .then(() => this.setStatus("plan deleted"))
441
- .catch((error: unknown) =>
442
- this.setStatus(`delete failed: ${error instanceof Error ? error.message : String(error)}`),
461
+ .catch((cause: unknown) =>
462
+ this.setStatus(`delete failed: ${cause instanceof Error ? cause.message : String(cause)}`),
443
463
  );
444
464
  }
445
465
 
@@ -775,7 +795,7 @@ class Controller implements ReviewController {
775
795
  const shareId = this.snapshot.session?.shareId;
776
796
 
777
797
  if (!shareId) return;
778
- void persisted.then(() => pushShare(shareId, [annotation])).catch(() => {});
798
+ void persisted.then(() => this.shareTransport.push(shareId, [annotation])).catch(() => {});
779
799
  }
780
800
 
781
801
  removeAnnotation(id: string): void {
@@ -868,12 +888,12 @@ class Controller implements ReviewController {
868
888
  // a remembers the choice. A configured delay overrides; 0 closes now.
869
889
  const delay = this.autoClose;
870
890
 
871
- if (delay === 0) this.finishReview();
872
- else if (typeof delay === "number") this.startCounting(delay);
873
- else this.startCounting(DEFAULT_AUTO_CLOSE);
891
+ if (delay === "off") this.startCounting(DEFAULT_AUTO_CLOSE);
892
+ else if (delay === 0) this.finishReview();
893
+ else this.startCounting(delay);
874
894
  })
875
- .catch((error: unknown) =>
876
- this.setStatus(String(error instanceof Error ? error.message : error)),
895
+ .catch((cause: unknown) =>
896
+ this.setStatus(String(cause instanceof Error ? cause.message : cause)),
877
897
  );
878
898
  }
879
899
 
@@ -882,17 +902,18 @@ class Controller implements ReviewController {
882
902
 
883
903
  if (!session) return;
884
904
  this.setStatus("sharing…");
885
- publishShare(session)
905
+ this.shareTransport
906
+ .publish(session)
886
907
  .then(async ({ line, copied }) => {
887
908
  // Stamp the id back so a later pull knows which share to collect from.
888
- const shareId = shareIdFromLine(line);
909
+ const shareId = this.shareTransport.parseShareId(line);
889
910
 
890
911
  if (shareId && this.client) await this.client.sessionSetShareId(session.id, shareId);
891
912
  this.setStatus("");
892
913
  this.showToast(line, copied ? "share link copied" : "share link");
893
914
  })
894
- .catch((error: unknown) =>
895
- this.setStatus(`share failed: ${error instanceof Error ? error.message : String(error)}`),
915
+ .catch((cause: unknown) =>
916
+ this.setStatus(`share failed: ${cause instanceof Error ? cause.message : String(cause)}`),
896
917
  );
897
918
  }
898
919
 
@@ -908,10 +929,11 @@ class Controller implements ReviewController {
908
929
  if (!session?.shareId || !this.client) return Promise.resolve();
909
930
  const client = this.client;
910
931
 
911
- return pullShare(session.shareId)
932
+ return this.shareTransport
933
+ .pull(session.shareId)
912
934
  .then((remote) =>
913
935
  client.sessionMergeShared(session.id, {
914
- annotations: collaboratorAnnotations(remote),
936
+ annotations: this.shareTransport.collaboratorAnnotations(remote),
915
937
  participants: remote.participants,
916
938
  }),
917
939
  )
package/src/share.ts CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  packSessionBlob,
14
14
  } from "@cueloop/daemon/share-blob";
15
15
  import type { Annotation, ReviewSession } from "@cueloop/schema";
16
+ import { SessionRecordSchema } from "@cueloop/daemon/validate";
17
+ import * as v from "valibot";
16
18
  import { copyToClipboard } from "./clipboard";
17
19
 
18
20
  export interface ShareTarget {
@@ -63,7 +65,7 @@ export async function pullShare(shareId: string, target: ShareTarget = {}): Prom
63
65
 
64
66
  if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
65
67
 
66
- return JSON.parse(stdout) as ReviewSession;
68
+ return v.parse(SessionRecordSchema, JSON.parse(stdout));
67
69
  }
68
70
 
69
71
  /** A share's collaborator notes: the ones a viewer authored (author stamped). */
@@ -31,9 +31,13 @@ export function isolateUserConfig(home: string, fileName = "no-config.toml"): ()
31
31
  };
32
32
  }
33
33
 
34
+ declare global {
35
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
36
+ }
37
+
34
38
  /** The harness drives the real event loop by design; keep React's act warning off. */
35
39
  export function allowEventLoopUpdates(): void {
36
- (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false;
40
+ globalThis.IS_REACT_ACT_ENVIRONMENT = false;
37
41
  }
38
42
 
39
43
  /**
@@ -148,7 +148,14 @@ export const THEME_PRESETS: Record<ThemeName, Theme> = {
148
148
  };
149
149
 
150
150
  /** Cycle order for the Settings theme row; the registry's insertion order, branded default first. */
151
- export const THEME_NAMES: ThemeName[] = Object.keys(THEME_PRESETS) as ThemeName[];
151
+ export const THEME_NAMES: ThemeName[] = [
152
+ "cueloop",
153
+ "rose-pine-moon",
154
+ "catppuccin-mocha",
155
+ "tokyo-night",
156
+ "gruvbox-dark",
157
+ "nord",
158
+ ];
152
159
 
153
160
  /** Human labels for the Settings cycle row, keyed by the persisted name. */
154
161
  export const THEME_LABELS: Record<ThemeName, string> = {
@@ -169,7 +176,7 @@ export const THEME_LABELS: Record<ThemeName, string> = {
169
176
  export function themeForName(name: string, appearance: Appearance = "dark"): Theme {
170
177
  if (name === "cueloop") return brandedTheme(appearance);
171
178
 
172
- return THEME_PRESETS[name as ThemeName] ?? brandedTheme(appearance);
179
+ return isThemeName(name) ? THEME_PRESETS[name] : brandedTheme(appearance);
173
180
  }
174
181
 
175
182
  /** A preset with the user's `[theme]` per-token overrides layered on top - the live-switch equivalent of config load. */
package/src/view-diff.ts CHANGED
@@ -73,10 +73,7 @@ function fileLabel(file: FileDiffMetadata): string {
73
73
  }
74
74
 
75
75
  /** Quote-primary anchor for a diff row: neighbors as context selectors. */
76
- export function diffRowAnchor(
77
- rows: DiffRow[],
78
- rowIndex: number,
79
- ): { quote: string; prefix: string; suffix: string } {
76
+ export function diffRowAnchor(rows: DiffRow[], rowIndex: number) {
80
77
  const row = rows[rowIndex]!;
81
78
  const prev = rows[rowIndex - 1];
82
79
  const next = rows[rowIndex + 1];
package/src/view-plan.ts CHANGED
@@ -285,12 +285,14 @@ export function inlineStyleRuns(text: string, base: number): StyleRun[] {
285
285
 
286
286
  for (const run of inlineRuns(text)) {
287
287
  if (run.role === "marker" || run.start === null) continue;
288
- runs.push({
288
+ const styleRun: StyleRun = {
289
289
  text: run.text,
290
290
  role: run.role === "text" ? "plain" : run.role,
291
291
  start: base + run.start,
292
- ...(run.href !== undefined ? { href: run.href } : {}),
293
- });
292
+ };
293
+
294
+ if (run.href !== undefined) styleRun.href = run.href;
295
+ runs.push(styleRun);
294
296
  }
295
297
 
296
298
  return runs;
@@ -437,10 +439,7 @@ export function workRangeForRendered(
437
439
  }
438
440
 
439
441
  /** Line delta between two revision contents, for the sheet-header summary. */
440
- export function revisionDelta(
441
- previousContent: string,
442
- nextContent: string,
443
- ): { added: number; removed: number } {
442
+ export function revisionDelta(previousContent: string, nextContent: string) {
444
443
  const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
445
444
  let added = 0;
446
445
  let removed = 0;