@cueloop/client 0.1.0-alpha.63 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.63",
3
+ "version": "0.1.0-alpha.64",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -26,10 +26,10 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.63",
30
- "@cueloop/extension-api": "0.1.0-alpha.63",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.63",
32
- "@cueloop/schema": "0.1.0-alpha.63",
29
+ "@cueloop/daemon": "0.1.0-alpha.64",
30
+ "@cueloop/extension-api": "0.1.0-alpha.64",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.64",
32
+ "@cueloop/schema": "0.1.0-alpha.64",
33
33
  "@opentui/core": "^0.5.8",
34
34
  "@opentui/keymap": "0.5.8",
35
35
  "@opentui/react": "^0.5.8",
@@ -37,7 +37,8 @@
37
37
  "@pierre/diffs": "^1.3.6",
38
38
  "diff": "^9",
39
39
  "puppeteer-core": "^25.8.0",
40
- "react": "^19.2.0"
40
+ "react": "^19.2.0",
41
+ "valibot": "^1.4.2"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/react": "^19.2.0",
@@ -8,20 +8,20 @@ import React from "react";
8
8
  import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import type { ShareTransport } from "./session-controller";
13
+ import { isolateUserConfig, press, waitForText, waitForTextGone } from "./test-support";
11
14
 
12
- // the controller reaches the gateway through "./share"; stub it so `S` yields a toast
13
15
  const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
14
-
15
- mock.module("./share", () => ({
16
- publishShare,
17
- pullShare: mock(async () => null),
18
- pushShare: mock(async () => {}),
19
- shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
16
+ const shareTransport: ShareTransport = {
17
+ publish: publishShare,
18
+ pull: mock(async () => {
19
+ throw new Error("Unexpected share pull");
20
+ }),
21
+ push: mock(async () => {}),
22
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
20
23
  collaboratorAnnotations: () => [],
21
- }));
22
-
23
- const { App } = await import("./App");
24
- const { isolateUserConfig, press, waitForText, waitForTextGone } = await import("./test-support");
24
+ };
25
25
 
26
26
  const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
27
27
 
@@ -53,10 +53,13 @@ afterEach(() => {
53
53
  describe("share toast", () => {
54
54
  test("escape cancels an open composer even while the toast is up", async () => {
55
55
  // Arrange
56
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
57
- width: 120,
58
- height: 32,
59
- });
56
+ const setup = await testRender(
57
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
58
+ {
59
+ width: 120,
60
+ height: 32,
61
+ },
62
+ );
60
63
 
61
64
  await waitForText(setup, "cueloop");
62
65
 
package/src/App.tsx CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  type ThemeName,
38
38
  } from "./theme-presets";
39
39
  import type { Theme } from "./theme";
40
- import { createReviewController } from "./session-controller";
40
+ import { createReviewController, type ShareTransport } from "./session-controller";
41
41
  import type { SessionClient } from "@cueloop/daemon/client";
42
42
  import { launchHarnessInSplit } from "@cueloop/daemon/herdr-split";
43
43
  import { activeSpanState, createIntentDispatch, type Mode } from "./intent-dispatch";
@@ -105,6 +105,7 @@ export interface AppProps {
105
105
  clock?: Clock;
106
106
  /** Session source; the sharing gateway injects a blob-backed client. */
107
107
  openClient?: () => Promise<SessionClient>;
108
+ shareTransport?: ShareTransport;
108
109
  /**
109
110
  * Who is at the keyboard. `owner` is the local planner (default). `observer`
110
111
  * is a passive `cueloop serve` watcher (read-only). `collaborator` is a share
@@ -131,6 +132,7 @@ export function App({
131
132
  onExit,
132
133
  clock,
133
134
  openClient,
135
+ shareTransport,
134
136
  role = "owner",
135
137
  selfAuthor,
136
138
  appearance = "dark",
@@ -138,7 +140,15 @@ export function App({
138
140
  const { observer, isOwner } = computeRoleCapabilities(readOnly, role);
139
141
  const controller = useMemo(
140
142
  () =>
141
- createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
143
+ createReviewController({
144
+ home,
145
+ sessionId,
146
+ readOnly: observer,
147
+ onExit,
148
+ clock,
149
+ openClient,
150
+ shareTransport,
151
+ }),
142
152
  // eslint-disable-next-line react-hooks/exhaustive-deps
143
153
  [home, sessionId],
144
154
  );
@@ -305,7 +315,8 @@ export function App({
305
315
  if (!session) return positions;
306
316
  if (isDiff) {
307
317
  for (const annotation of session.annotations) {
308
- const blockIndex = (annotation.anchor as { blockIndex?: number }).blockIndex;
318
+ const blockIndex =
319
+ "blockIndex" in annotation.anchor ? annotation.anchor.blockIndex : undefined;
309
320
 
310
321
  if (blockIndex !== undefined) positions.set(annotation.id, blockIndex);
311
322
  }
@@ -18,17 +18,13 @@ import { REVIEW_COMPACT_WIDTH, resolveReviewWidth, type ReviewPanelMode } from "
18
18
  export function computeRoleCapabilities(
19
19
  readOnly: boolean,
20
20
  role: "owner" | "observer" | "collaborator",
21
- ): { observer: boolean; isOwner: boolean } {
21
+ ) {
22
22
  const observer = readOnly || role === "observer";
23
23
 
24
24
  return { observer, isOwner: !observer && role === "owner" };
25
25
  }
26
26
 
27
- export function deriveReviewFlags(session: ReviewSession | null): {
28
- isDiff: boolean;
29
- isPrototype: boolean;
30
- resolved: boolean;
31
- } {
27
+ export function deriveReviewFlags(session: ReviewSession | null) {
32
28
  return {
33
29
  isDiff: session?.artifact.type === "diff",
34
30
  isPrototype: session?.artifact.type === "prototype",
@@ -121,13 +117,7 @@ export function buildRenderFlags(params: {
121
117
  menuOpen: boolean;
122
118
  menuDialog: "keybinds" | "settings" | null;
123
119
  resolvedIds: Set<string>;
124
- }): {
125
- showOwnerActions: boolean;
126
- prototypeCanComment: boolean;
127
- chromeHidden: boolean;
128
- prototypePath: string;
129
- railResolvedIds: Set<string> | null;
130
- } {
120
+ }) {
131
121
  const { session, isOwner, isDiff, isPrototype, resolved, menuOpen, menuDialog, resolvedIds } =
132
122
  params;
133
123
 
@@ -4,7 +4,7 @@ import { expect, test } from "bun:test";
4
4
  import React, { useState } from "react";
5
5
  import { testRender } from "@opentui/react/test-utils";
6
6
  import { useKeyboard } from "@opentui/react";
7
- import type { Renderable, ScrollBoxRenderable } from "@opentui/core";
7
+ import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
8
8
  import type { Annotation } from "@cueloop/schema";
9
9
  import { DiffSheet } from "./DiffSheet";
10
10
  import { diffRows, type DiffRow } from "../view-diff";
@@ -82,7 +82,10 @@ test("scrolling down past wrapped annotation cards keeps the cursor pinned and t
82
82
  });
83
83
 
84
84
  await setup.waitForVisualIdle();
85
- const scrollbox = findById(setup.renderer.root, "diff-scroll") as ScrollBoxRenderable;
85
+ const found = findById(setup.renderer.root, "diff-scroll");
86
+
87
+ if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
88
+ const scrollbox = found;
86
89
 
87
90
  // the cursor row is the one painted with the cursor background
88
91
  const cursorScreenRow = (): number => {
@@ -60,12 +60,7 @@ export interface DiffSheetProps {
60
60
  }
61
61
 
62
62
  /** The sign, base color, and background a row draws with. */
63
- function rowStyle(
64
- row: DiffRow,
65
- isCursorRow: boolean,
66
- isAnnotatedRow: boolean,
67
- tokens: Theme,
68
- ): { sign: string; baseColor: string; background: string | undefined } {
63
+ function rowStyle(row: DiffRow, isCursorRow: boolean, isAnnotatedRow: boolean, tokens: Theme) {
69
64
  const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
70
65
  const baseColor =
71
66
  row.kind === "add"
@@ -94,10 +94,7 @@ interface BlockRef {
94
94
  }
95
95
 
96
96
  /** Screen position of a rendered-text offset inside a wrapped text. */
97
- function positionOfRenderedOffset(
98
- renderable: TextRenderable,
99
- renderedOffset: number,
100
- ): { x: number; y: number } {
97
+ function positionOfRenderedOffset(renderable: TextRenderable, renderedOffset: number) {
101
98
  const info = renderable.lineInfo;
102
99
  let lineIndex = 0;
103
100
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  import React, { useEffect, useRef, useState } from "react";
12
12
  import type { BoxRenderable } from "@opentui/core";
13
+ import * as v from "valibot";
13
14
  import { useKeyboard, useRenderer } from "@opentui/react";
14
15
  import type { Theme } from "../theme";
15
16
  import { quickActionBody, type QuickAction } from "../config";
@@ -95,10 +96,7 @@ type CaptureRenderer = {
95
96
  * raster and fewer bytes). Without a resolution report, fall back to a fixed
96
97
  * width at 2x and derive the height from the cell aspect.
97
98
  */
98
- function captureConfig(
99
- region: CellRegion,
100
- renderer: CaptureRenderer,
101
- ): { viewport: PrototypeViewport; deviceScaleFactor: number } {
99
+ function captureConfig(region: CellRegion, renderer: CaptureRenderer) {
102
100
  const resolution = renderer.resolution;
103
101
 
104
102
  if (resolution && renderer.terminalWidth >= 1 && renderer.terminalHeight >= 1) {
@@ -174,11 +172,9 @@ function PrototypeSheetImpl({
174
172
  // harness), but the launch trigger still runs.
175
173
  useEffect(() => {
176
174
  if (!renderer) return;
177
- // writeOut is the renderer's raw output channel, ordered against its frame
178
- // bytes; it is private, so reach it through one narrow cast
179
- const rawWrite = (renderer as unknown as { writeOut?: (data: string) => void }).writeOut;
180
- const write =
181
- typeof rawWrite === "function" ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
175
+ const rendererOutput = v.safeParse(v.object({ writeOut: v.function() }), renderer);
176
+ const rawWrite = rendererOutput.success ? rendererOutput.output.writeOut : null;
177
+ const write = rawWrite ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
182
178
  const medium = resolveTransmitMedium();
183
179
  const paint = (): void => {
184
180
  if (!write) return;
@@ -187,7 +187,9 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
187
187
  >
188
188
  <Tabs
189
189
  selectedKey={railTab}
190
- onSelectionChange={(key) => onTabChange(key as RailTab)}
190
+ onSelectionChange={(key) => {
191
+ if (key === "review" || key === "agent") onTabChange(key);
192
+ }}
191
193
  theme={theme}
192
194
  >
193
195
  <TabList>
@@ -44,11 +44,13 @@ function blockBaseStyle(block: DisplayBlock, tokens: Theme): BlockBaseStyle {
44
44
  return { baseFg, baseAttributes };
45
45
  }
46
46
 
47
- function inlineRoleStyle(
48
- run: StyleRun,
49
- base: BlockBaseStyle,
50
- tokens: Theme,
51
- ): { fg?: string; bg?: string; attributes?: number } {
47
+ interface PlanSheetRunStyle {
48
+ fg?: string;
49
+ bg?: string;
50
+ attributes?: number;
51
+ }
52
+
53
+ function inlineRoleStyle(run: StyleRun, base: BlockBaseStyle, tokens: Theme): PlanSheetRunStyle {
52
54
  const { baseFg, baseAttributes } = base;
53
55
  const withBase = (roleAttributes: number): number => baseAttributes | roleAttributes;
54
56
 
@@ -78,11 +80,7 @@ function inlineRoleStyle(
78
80
  }
79
81
  }
80
82
 
81
- export function runStyle(
82
- run: StyleRun,
83
- block: DisplayBlock,
84
- tokens: Theme,
85
- ): { fg?: string; bg?: string; attributes?: number } {
83
+ export function runStyle(run: StyleRun, block: DisplayBlock, tokens: Theme): PlanSheetRunStyle {
86
84
  // a cut block reads as removed: every run struck through and grayed, never red
87
85
  if (block.type === "del") return { fg: tokens.textDim, attributes: CUT_ATTRIBUTES };
88
86
 
@@ -65,7 +65,7 @@ export function TabList({ children }: TabListProps): React.ReactNode {
65
65
  const items: { id: string; label: string }[] = [];
66
66
 
67
67
  React.Children.forEach(children, (child) => {
68
- if (React.isValidElement<TabProps>(child) && typeof child.props.id === "string") {
68
+ if (React.isValidElement<TabProps>(child)) {
69
69
  items.push({ id: child.props.id, label: child.props.children });
70
70
  }
71
71
  });
@@ -63,9 +63,11 @@ export function fixturePlanSession(overrides: Partial<ReviewSession> = {}): Revi
63
63
  },
64
64
  revisions: [{ revision: 1, content: FIXTURE_PLAN, submittedAt: "2026-01-01T00:00:00Z" }],
65
65
  annotations: FIXTURE_ANNOTATIONS,
66
+ verdict: null,
66
67
  status: "pending",
68
+ createdAt: "2026-01-01T00:00:00Z",
67
69
  ...overrides,
68
- } as ReviewSession;
70
+ };
69
71
  }
70
72
 
71
73
  export function fixtureDiffSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
@@ -80,9 +82,11 @@ export function fixtureDiffSession(overrides: Partial<ReviewSession> = {}): Revi
80
82
  },
81
83
  revisions: [{ revision: 1, content: FIXTURE_PATCH, submittedAt: "2026-01-01T00:00:00Z" }],
82
84
  annotations: [],
85
+ verdict: null,
83
86
  status: "pending",
87
+ createdAt: "2026-01-01T00:00:00Z",
84
88
  ...overrides,
85
- } as ReviewSession;
89
+ };
86
90
  }
87
91
 
88
92
  export function fixtureDisplay() {
@@ -32,7 +32,19 @@ export interface LoadedStory {
32
32
 
33
33
  export function isStory(value: unknown): value is Story {
34
34
  return (
35
- typeof value === "object" && value !== null && typeof (value as Story).render === "function"
35
+ typeof value === "object" &&
36
+ value !== null &&
37
+ "render" in value &&
38
+ typeof value.render === "function"
39
+ );
40
+ }
41
+
42
+ function isStoryMeta(value: unknown): value is StoryMeta {
43
+ return (
44
+ typeof value === "object" &&
45
+ value !== null &&
46
+ "title" in value &&
47
+ typeof value.title === "string"
36
48
  );
37
49
  }
38
50
 
@@ -43,9 +55,9 @@ export async function loadStories(): Promise<LoadedStory[]> {
43
55
  const loaded: LoadedStory[] = [];
44
56
 
45
57
  for (const file of files) {
46
- const moduleExports = (await import(`${import.meta.dir}/${file}`)) as Record<string, unknown>;
47
- const meta = moduleExports["meta"] as StoryMeta | undefined;
48
- const moduleTitle = meta?.title ?? file.replace(/\.stories\.tsx$/, "");
58
+ const moduleExports = await import(`${import.meta.dir}/${file}`);
59
+ const meta = moduleExports["meta"];
60
+ const moduleTitle = isStoryMeta(meta) ? meta.title : file.replace(/\.stories\.tsx$/, "");
49
61
 
50
62
  for (const [exportName, exported] of Object.entries(moduleExports)) {
51
63
  if (exportName === "meta" || !isStory(exported)) continue;
@@ -19,7 +19,7 @@ export function filetypeFor(language?: string): string | undefined {
19
19
  const info = (language ?? "").toLowerCase();
20
20
 
21
21
  if (!info) return undefined;
22
- const aliases: Record<string, string> = {
22
+ const aliases: SyntaxAliases = {
23
23
  ts: "typescript",
24
24
  js: "javascript",
25
25
  py: "python",
@@ -47,13 +47,21 @@ export function filetypeForPath(path: string): string | undefined {
47
47
  return extensionToFiletype.get(basename.slice(dotIndex + 1).toLowerCase());
48
48
  }
49
49
 
50
+ interface SyntaxAliases {
51
+ [alias: string]: string;
52
+ }
53
+
50
54
  interface SyntaxGroupStyle {
51
55
  fg: string;
52
56
  italic?: boolean;
53
57
  }
54
58
 
59
+ interface SyntaxGroupStyles {
60
+ [group: string]: SyntaxGroupStyle;
61
+ }
62
+
55
63
  /** The one group -> style map, keyed by tree-sitter capture name. */
56
- function syntaxGroupStyles(theme: Theme): Record<string, SyntaxGroupStyle> {
64
+ function syntaxGroupStyles(theme: Theme): SyntaxGroupStyles {
57
65
  return {
58
66
  default: { fg: theme.textMuted },
59
67
  comment: { fg: theme.textDim, italic: true },
@@ -34,7 +34,7 @@ describe("TerminalPaneRenderable", () => {
34
34
 
35
35
  test("exposes availability + the renderable class", () => {
36
36
  // Assert - the module loads and reports platform support honestly
37
- expect(typeof embeddedTerminalAvailable()).toBe("boolean");
37
+ expect([true, false]).toContain(embeddedTerminalAvailable());
38
38
  expect(TerminalPaneRenderable).toBeDefined();
39
39
  });
40
40
  });
@@ -47,6 +47,12 @@ export interface TerminalPaneOptions extends RenderableOptions {
47
47
  onExit?: (exitCode: number) => void;
48
48
  }
49
49
 
50
+ function terminalEnvironment(env: NodeJS.ProcessEnv) {
51
+ return Object.fromEntries(
52
+ Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
53
+ );
54
+ }
55
+
50
56
  const encoder = new TextEncoder();
51
57
  const DEFAULT_FG = RGBA.fromInts(208, 208, 208, 255);
52
58
  const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0);
@@ -87,7 +93,7 @@ export class TerminalPaneRenderable extends Renderable {
87
93
  cols,
88
94
  rows,
89
95
  cwd: this.opts.cwd ?? process.cwd(),
90
- env: (this.opts.env ?? process.env) as Record<string, string>,
96
+ env: this.opts.env ?? terminalEnvironment(process.env),
91
97
  });
92
98
  this.pty.onData((data) => {
93
99
  // the shim streams a UTF-8-decoded string (split multibyte is handled); a