@cueloop/client 0.1.0-alpha.78 → 0.1.0-alpha.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. package/src/components/agent-launcher.tsx +0 -149
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Grid layout for the two block kinds the read-only thread renders as tables:
3
+ * a GFM table (bold header over a rule, columns aligned per the delimiter row,
4
+ * no vertical borders) and leading YAML
5
+ * frontmatter (a full-bordered key/value grid, bold nowrap keys, wrapped
6
+ * values). Both are pure: they turn a block's source text and a width into
7
+ * positioned, styled segments the renderer prints verbatim, so a char-frame
8
+ * test can assert the exact grid.
9
+ */
10
+
11
+ import { wrapLines } from "./mark-runs";
12
+
13
+ /** One painted piece of a grid line: its text plus whether it reads bold or dim (a border). */
14
+ export interface GridSegment {
15
+ text: string;
16
+ bold: boolean;
17
+ dim: boolean;
18
+ }
19
+
20
+ /** A grid line is left-to-right segments; concatenating their text gives the row. */
21
+ export type GridLine = GridSegment[];
22
+
23
+ export type ColumnAlign = "left" | "right" | "center";
24
+
25
+ export interface MarkdownTable {
26
+ aligns: ColumnAlign[];
27
+ header: string[];
28
+ rows: string[][];
29
+ }
30
+
31
+ /**
32
+ * Split a table row into trimmed cells on unescaped pipes, unescaping `\|` into a
33
+ * literal pipe, and dropping the empty cells an optional leading or trailing
34
+ * border pipe produces (an intentional blank cell keeps its surrounding spaces).
35
+ */
36
+ function splitTableRow(line: string): string[] {
37
+ const trimmed = line.trim();
38
+ const parts: string[] = [];
39
+ let cell = "";
40
+
41
+ for (let index = 0; index < trimmed.length; index++) {
42
+ const char = trimmed[index]!;
43
+
44
+ if (char === "\\" && trimmed[index + 1] === "|") {
45
+ cell += "|";
46
+ index++;
47
+ } else if (char === "|") {
48
+ parts.push(cell);
49
+ cell = "";
50
+ } else {
51
+ cell += char;
52
+ }
53
+ }
54
+ parts.push(cell);
55
+ if (parts.length > 1 && parts[0] === "") parts.shift();
56
+ if (parts.length > 1 && parts[parts.length - 1] === "") parts.pop();
57
+
58
+ return parts.map((part) => part.trim());
59
+ }
60
+
61
+ /** Parse a GFM table block's verbatim source into header cells, column alignments, and body rows. */
62
+ export function parseMarkdownTable(text: string): MarkdownTable {
63
+ const lines = text.split("\n").filter((line) => line.trim() !== "");
64
+ const header = splitTableRow(lines[0] ?? "");
65
+ const aligns: ColumnAlign[] = splitTableRow(lines[1] ?? "").map((cell) => {
66
+ const left = cell.startsWith(":");
67
+ const right = cell.endsWith(":");
68
+
69
+ return left && right ? "center" : right ? "right" : "left";
70
+ });
71
+ const rows = lines.slice(2).map(splitTableRow);
72
+
73
+ return { aligns, header, rows };
74
+ }
75
+
76
+ /** Pad `text` to `width` cells under an alignment. */
77
+ function pad(text: string, width: number, align: ColumnAlign): string {
78
+ const slack = Math.max(0, width - text.length);
79
+
80
+ if (align === "right") return " ".repeat(slack) + text;
81
+ if (align === "center") {
82
+ const left = Math.floor(slack / 2);
83
+
84
+ return " ".repeat(left) + text + " ".repeat(slack - left);
85
+ }
86
+
87
+ return text + " ".repeat(slack);
88
+ }
89
+
90
+ /** Two spaces separate table columns; the read-only render draws no vertical rule. */
91
+ const COLUMN_GAP = " ";
92
+
93
+ /**
94
+ * Lay out a GFM table: a bold header row, a dim rule spanning the table width,
95
+ * then the body rows, every column padded to its widest cell under its alignment.
96
+ */
97
+ export function layoutMarkdownTable(table: MarkdownTable): GridLine[] {
98
+ const columnCount = Math.max(table.header.length, ...table.rows.map((row) => row.length), 1);
99
+ const alignOf = (column: number): ColumnAlign => table.aligns[column] ?? "left";
100
+ const cellAt = (cells: string[], column: number): string => cells[column] ?? "";
101
+ const widths = Array.from({ length: columnCount }, (_unused, column) =>
102
+ Math.max(
103
+ cellAt(table.header, column).length,
104
+ ...table.rows.map((row) => cellAt(row, column).length),
105
+ 1,
106
+ ),
107
+ );
108
+ const rowText = (cells: string[]): string =>
109
+ widths
110
+ .map((width, column) => pad(cellAt(cells, column), width, alignOf(column)))
111
+ .join(COLUMN_GAP);
112
+ const totalWidth =
113
+ widths.reduce((sum, width) => sum + width, 0) + COLUMN_GAP.length * (columnCount - 1);
114
+ const lines: GridLine[] = [
115
+ [{ text: rowText(table.header), bold: true, dim: false }],
116
+ [{ text: "─".repeat(totalWidth), bold: false, dim: true }],
117
+ ];
118
+
119
+ for (const row of table.rows) lines.push([{ text: rowText(row), bold: false, dim: false }]);
120
+
121
+ return lines;
122
+ }
123
+
124
+ export interface FrontmatterRow {
125
+ key: string;
126
+ value: string;
127
+ }
128
+
129
+ /** Parse frontmatter YAML into top-level key/value rows; indented lines fold into the row above. */
130
+ export function parseFrontmatterRows(text: string): FrontmatterRow[] {
131
+ const rows: FrontmatterRow[] = [];
132
+
133
+ for (const line of text.split("\n")) {
134
+ if (line.trim() === "") continue;
135
+ const match = /^([^:\s][^:]*):\s?(.*)$/.exec(line);
136
+
137
+ if (match) {
138
+ rows.push({ key: match[1]!.trim(), value: match[2]! });
139
+ } else if (rows.length > 0) {
140
+ const previous = rows[rows.length - 1]!;
141
+
142
+ previous.value = previous.value === "" ? line.trim() : `${previous.value}\n${line.trim()}`;
143
+ }
144
+ }
145
+
146
+ return rows;
147
+ }
148
+
149
+ /** The fixed cells a frontmatter grid row spends on borders and padding: `│ key │ value │`. */
150
+ const FRONTMATTER_CHROME = 7;
151
+
152
+ /** Wrap a cell value into lines no wider than `width`, honouring its own line breaks. */
153
+ function wrapCell(value: string, width: number): string[] {
154
+ if (value === "") return [""];
155
+
156
+ return wrapLines(value, width).map((line) => value.slice(line.start, line.end));
157
+ }
158
+
159
+ /**
160
+ * Lay out leading frontmatter as a full-bordered key/value grid: bold nowrap
161
+ * keys on the left, values wrapped to the remaining width on the right, a rule
162
+ * between every row.
163
+ */
164
+ export function layoutFrontmatterGrid(rows: FrontmatterRow[], maxWidth: number): GridLine[] {
165
+ if (rows.length === 0) return [];
166
+ const keyWidth = Math.max(3, ...rows.map((row) => row.key.length));
167
+ // size the value column to its content, capped at the width left after the key column and borders,
168
+ // so a short frontmatter table stays compact and a long value wraps instead of overflowing
169
+ const available = Math.max(3, maxWidth - keyWidth - FRONTMATTER_CHROME);
170
+ const naturalWidth = Math.max(
171
+ 3,
172
+ ...rows.map((row) => Math.max(0, ...row.value.split("\n").map((line) => line.length))),
173
+ );
174
+ const valueWidth = Math.min(naturalWidth, available);
175
+ const rule = (left: string, mid: string, right: string): GridLine => [
176
+ {
177
+ text: left + "─".repeat(keyWidth + 2) + mid + "─".repeat(valueWidth + 2) + right,
178
+ bold: false,
179
+ dim: true,
180
+ },
181
+ ];
182
+ const lines: GridLine[] = [rule("┌", "┬", "┐")];
183
+
184
+ rows.forEach((row, rowIndex) => {
185
+ if (rowIndex > 0) lines.push(rule("├", "┼", "┤"));
186
+ const valueLines = wrapCell(row.value, valueWidth);
187
+
188
+ valueLines.forEach((valueLine, valueIndex) => {
189
+ const keyCell = valueIndex === 0 ? row.key.padEnd(keyWidth) : " ".repeat(keyWidth);
190
+
191
+ lines.push([
192
+ { text: "│ ", bold: false, dim: true },
193
+ { text: keyCell, bold: valueIndex === 0, dim: false },
194
+ { text: " │ ", bold: false, dim: true },
195
+ { text: valueLine.padEnd(valueWidth), bold: false, dim: false },
196
+ { text: " │", bold: false, dim: true },
197
+ ]);
198
+ });
199
+ });
200
+ lines.push(rule("└", "┴", "┘"));
201
+
202
+ return lines;
203
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { markdownHighlightRanges, type MarkdownHighlightGroup } from "./markdown-highlight";
3
+
4
+ /** The substring a range covers, paired with its group, for readable assertions. */
5
+ function painted(source: string): Array<{ text: string; group: MarkdownHighlightGroup }> {
6
+ return markdownHighlightRanges(source).map((range) => ({
7
+ text: source.slice(range.start, range.end),
8
+ group: range.group,
9
+ }));
10
+ }
11
+
12
+ describe("markdownHighlightRanges", () => {
13
+ test("a heading dims its marker and bolds the text without coloring it", () => {
14
+ expect(painted("## Rollout Plan")).toEqual([
15
+ { text: "## ", group: "marker" },
16
+ { text: "Rollout Plan", group: "heading" },
17
+ ]);
18
+ });
19
+
20
+ test("a plain link colors its brackets and href but leaves the label plain", () => {
21
+ const runs = painted("see [docs](https://x.dev)");
22
+
23
+ expect(runs).toContainEqual({ text: "[", group: "link" });
24
+ expect(runs).toContainEqual({ text: "](https://x.dev)", group: "link" });
25
+ expect(runs.some((run) => run.text.includes("docs"))).toBe(false);
26
+ });
27
+
28
+ test("an @-scope link label colors along with the brackets and href", () => {
29
+ expect(painted("[@pierre/diffs](https://npm/@pierre/diffs)")).toEqual([
30
+ { text: "[@pierre/diffs](https://npm/@pierre/diffs)", group: "link" },
31
+ ]);
32
+ });
33
+
34
+ test("inline code grays the whole backtick span", () => {
35
+ expect(painted("call `run()` now")).toContainEqual({ text: "`run()`", group: "code" });
36
+ });
37
+
38
+ test("a fenced code block grays the whole block", () => {
39
+ const source = "```ts\nconst a = 1;\n```";
40
+
41
+ expect(painted(source)).toEqual([{ text: source, group: "code" }]);
42
+ });
43
+
44
+ test("inline code inside a list item resolves against the whole source offset", () => {
45
+ const source = "- run `cueloop dev`";
46
+ const code = markdownHighlightRanges(source).find((range) => range.group === "code");
47
+
48
+ expect(code && source.slice(code.start, code.end)).toBe("`cueloop dev`");
49
+ });
50
+
51
+ test("bold, emphasis, list markers, quotes, and rules stay plain", () => {
52
+ expect(markdownHighlightRanges("ship **now** or _later_")).toEqual([]);
53
+ expect(markdownHighlightRanges("- a task")).toEqual([]);
54
+ expect(markdownHighlightRanges("> quoted line")).toEqual([]);
55
+ expect(markdownHighlightRanges("---")).toEqual([]);
56
+ });
57
+
58
+ test("plain prose yields no ranges", () => {
59
+ expect(markdownHighlightRanges("just words here")).toEqual([]);
60
+ });
61
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Scan raw markdown source into highlight ranges for the inline thread editor.
3
+ * The scheme is deliberately restrained, matching a plain markdown editor: a
4
+ * heading dims its `#` marker and bolds the title text (no color); links render
5
+ * in one link color; inline and fenced code gray out. Everything else - bold,
6
+ * emphasis, list and quote markers, rules - stays plain source. Block structure
7
+ * reuses the schema parser so a heading or code fence is defined in one place.
8
+ */
9
+
10
+ import { parseBlocks, LEADING_BLOCK_MARKER, type Block } from "@cueloop/schema";
11
+
12
+ /** Every markdown token class the editor paints; "marker" is the dimmed heading punctuation. */
13
+ export const MARKDOWN_HIGHLIGHT_GROUPS = ["heading", "marker", "link", "code"] as const;
14
+
15
+ /** A markdown token class the editor paints. */
16
+ export type MarkdownHighlightGroup = (typeof MARKDOWN_HIGHLIGHT_GROUPS)[number];
17
+
18
+ /** A char range in the raw markdown source to paint with one group's style; end is exclusive. */
19
+ export interface MarkdownHighlightRange {
20
+ start: number;
21
+ end: number;
22
+ group: MarkdownHighlightGroup;
23
+ }
24
+
25
+ /** The absolute source offset where each 0-based line begins. */
26
+ function lineStartOffsets(source: string): number[] {
27
+ const offsets = [0];
28
+ for (let index = 0; index < source.length; index++) {
29
+ if (source[index] === "\n") offsets.push(index + 1);
30
+ }
31
+
32
+ return offsets;
33
+ }
34
+
35
+ // gray a balanced backtick run and color a [text](href) link; bold and emphasis stay plain source
36
+ const INLINE_TOKEN = /(`+)(?:.+?)\1|\[[^\]]+\]\([^)]+\)/g;
37
+
38
+ /** Gray inline code and color links found in one text span; other inline markup stays plain. */
39
+ function scanInlineTokens(text: string, base: number, into: MarkdownHighlightRange[]): void {
40
+ INLINE_TOKEN.lastIndex = 0;
41
+ let match: RegExpExecArray | null;
42
+ while ((match = INLINE_TOKEN.exec(text)) !== null) {
43
+ const token = match[0];
44
+ const start = base + match.index;
45
+ const end = start + token.length;
46
+
47
+ if (token.startsWith("`")) {
48
+ into.push({ start, end, group: "code" });
49
+ continue;
50
+ }
51
+ // a link colors its brackets and href; the [] label stays plain unless it is an @-scope, matching how a
52
+ // markdown source view paints links
53
+ const closeBracket = token.indexOf("]");
54
+
55
+ if (token.slice(1, closeBracket).startsWith("@")) {
56
+ into.push({ start, end, group: "link" });
57
+ } else {
58
+ into.push({ start, end: start + 1, group: "link" });
59
+ into.push({ start: start + closeBracket, end, group: "link" });
60
+ }
61
+ }
62
+ }
63
+
64
+ function isHeadingKind(kind: Block["kind"]): boolean {
65
+ return kind === "h1" || kind === "h2" || kind === "h3";
66
+ }
67
+
68
+ /** Dim a heading's `#` marker and bold the rest of the line (no color). */
69
+ function scanHeadingBlock(
70
+ firstLineStart: number,
71
+ source: string,
72
+ lineStarts: number[],
73
+ block: Block,
74
+ into: MarkdownHighlightRange[],
75
+ ): void {
76
+ const lineText = source.slice(firstLineStart, lineStarts[block.lineStart + 1] ?? source.length);
77
+ const marker = lineText.match(LEADING_BLOCK_MARKER)?.[0] ?? "";
78
+
79
+ into.push({ start: firstLineStart, end: firstLineStart + marker.length, group: "marker" });
80
+ into.push({
81
+ start: firstLineStart + marker.length,
82
+ end: firstLineStart + lineText.trimEnd().length,
83
+ group: "heading",
84
+ });
85
+ }
86
+
87
+ /** Paint one block: heading marker and title, grayed code block, or inline code and links in prose. */
88
+ function scanBlock(
89
+ block: Block,
90
+ source: string,
91
+ lineStarts: number[],
92
+ into: MarkdownHighlightRange[],
93
+ ): void {
94
+ const firstLineStart = lineStarts[block.lineStart] ?? 0;
95
+
96
+ if (isHeadingKind(block.kind)) {
97
+ scanHeadingBlock(firstLineStart, source, lineStarts, block, into);
98
+ } else if (block.kind === "code") {
99
+ into.push({
100
+ start: firstLineStart,
101
+ end: lineStarts[block.lineEnd + 1] ?? source.length,
102
+ group: "code",
103
+ });
104
+ } else if (block.kind !== "hr") {
105
+ for (let line = block.lineStart; line <= block.lineEnd; line++) {
106
+ const lineStart = lineStarts[line] ?? source.length;
107
+ const lineText = source.slice(lineStart, lineStarts[line + 1] ?? source.length);
108
+
109
+ scanInlineTokens(lineText.replace(/\r?\n$/, ""), lineStart, into);
110
+ }
111
+ }
112
+ }
113
+
114
+ /** Scan raw markdown source into highlight ranges over the source (markers included), for the inline editor. */
115
+ export function markdownHighlightRanges(source: string): MarkdownHighlightRange[] {
116
+ const lineStarts = lineStartOffsets(source);
117
+ const ranges: MarkdownHighlightRange[] = [];
118
+
119
+ for (const block of parseBlocks(source)) scanBlock(block, source, lineStarts, ranges);
120
+
121
+ return ranges;
122
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { cssBoxToCell, imageCellToCss } from "./prototype-browser";
2
+ import { closePrototypeBrowser, cssBoxToCell, imageCellToCss } from "./prototype-browser";
3
3
 
4
4
  const IMAGE = { x: 1, y: 1, width: 80, height: 40 };
5
5
  const VIEWPORT = { width: 1280, height: 720 };
@@ -54,3 +54,11 @@ describe("cssBoxToCell", () => {
54
54
  expect(cell.rows).toBe(1);
55
55
  });
56
56
  });
57
+
58
+ describe("closePrototypeBrowser", () => {
59
+ test("is a safe no-op when no prototype browser was ever launched", async () => {
60
+ // the shutdown path calls this on every quit; with no warm Chromium it must not throw or load puppeteer
61
+ await expect(closePrototypeBrowser()).resolves.toBeUndefined();
62
+ await expect(closePrototypeBrowser()).resolves.toBeUndefined();
63
+ });
64
+ });
@@ -135,6 +135,28 @@ async function warmBrowser(executablePath: string | undefined): Promise<Puppetee
135
135
  return browser;
136
136
  }
137
137
 
138
+ /**
139
+ * Close the warm Chromium so it never orphans when cueloop exits. Called from
140
+ * the shutdown path (the normal quit is not a signal, so puppeteer's own signal
141
+ * handlers do not fire). Idempotent.
142
+ */
143
+ export async function closePrototypeBrowser(): Promise<void> {
144
+ const pending = sharedBrowser;
145
+
146
+ sharedBrowser = null;
147
+ if (!pending) return;
148
+ const browser = await pending.catch(() => null);
149
+
150
+ await browser?.close().catch(() => undefined);
151
+ }
152
+
153
+ /** Test-only: the warm browser's OS process id, or null when none is running. */
154
+ export async function prototypeBrowserPidForTest(): Promise<number | null> {
155
+ const browser = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
156
+
157
+ return browser?.process()?.pid ?? null;
158
+ }
159
+
138
160
  export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
139
161
  const { pathToFileURL } = await import("node:url");
140
162
  const browser = await warmBrowser(options.executablePath);
package/src/run.ts CHANGED
@@ -4,6 +4,7 @@ import React from "react";
4
4
  import { createCliRenderer } from "@opentui/core";
5
5
  import { createRoot } from "@opentui/react";
6
6
  import { App } from "./App";
7
+ import { closePrototypeBrowser } from "./prototype-browser";
7
8
  import type { Appearance } from "./theme-presets";
8
9
  import { loadConfig } from "./config";
9
10
  import { perfMark } from "./perf/perf-timings";
@@ -41,20 +42,21 @@ export async function runClient(options: RunClientOptions): Promise<number> {
41
42
  });
42
43
  return new Promise<number>((resolve) => {
43
44
  let exited = false;
44
- const shutdown = (code: number): void => {
45
+ const shutdown = async (code: number): Promise<void> => {
45
46
  if (exited) return;
46
47
  exited = true;
47
48
  renderer.destroy();
49
+ // close the warm prototype Chromium before exit so it never orphans; the
50
+ // await also gives the renderer time to flush its terminal-restore bytes
51
+ await closePrototypeBrowser();
48
52
  resolve(code);
49
- // one microtask between destroy and exit lets the renderer flush
50
- // its terminal-restore sequences before the process dies
51
- queueMicrotask(() => process.exit(code));
53
+ process.exit(code);
52
54
  };
53
55
 
54
56
  // a closed pane/terminal hangs up (or the parent kills us); exit instead of
55
57
  // lingering as an orphan whose pty read loop keeps burning CPU
56
- process.once("SIGHUP", () => shutdown(0));
57
- process.once("SIGTERM", () => shutdown(0));
58
+ process.once("SIGHUP", () => void shutdown(0));
59
+ process.once("SIGTERM", () => void shutdown(0));
58
60
 
59
61
  const root = createRoot(renderer);
60
62
  const renderApp = (appearance: Appearance): void => {
package/src/share.ts CHANGED
@@ -41,6 +41,11 @@ export function shareIdFromLine(line: string): string | undefined {
41
41
  return line.match(/^ssh (\S+)@/)?.[1];
42
42
  }
43
43
 
44
+ /** The paste line for a share id: `ssh p_…@host`. Used to copy an existing link. */
45
+ export function formatShareLine(shareId: string, target: ShareTarget = {}): string {
46
+ return `ssh ${shareId}@${target.host ?? DEFAULT_SHARE_HOST}`;
47
+ }
48
+
44
49
  /** Upload the session to the gateway and copy the resulting ssh line. */
45
50
  export async function publishShare(
46
51
  session: Thread,
@@ -108,7 +113,8 @@ export function mergeFromShare(remote: Thread): SharedMerge {
108
113
  export async function pushShare(
109
114
  shareId: string,
110
115
  annotations: Array<Omit<Annotation, "createdAt">>,
111
- access?: ShareAccess,
116
+ // ShareAccess sets a private allowlist; "public" clears it (makes the link public); undefined leaves it
117
+ access?: ShareAccess | "public",
112
118
  target: ShareTarget = {},
113
119
  ): Promise<void> {
114
120
  const payload = access ? { shareId, annotations, access } : { shareId, annotations };
@@ -121,6 +127,14 @@ export async function pushShare(
121
127
  if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
122
128
  }
123
129
 
130
+ /** Revoke a share: the gateway deletes the blob so its link stops resolving. Owner-gated; idempotent. */
131
+ export async function revokeShare(shareId: string, target: ShareTarget = {}): Promise<void> {
132
+ const { stderr, code } = await runShareSsh("cueloop-revoke", Buffer.from(shareId), target);
133
+
134
+ if (code !== 0)
135
+ throw new Error(`gateway revoke failed: ${stderr.trim() || `ssh exited ${code}`}`);
136
+ }
137
+
124
138
  export interface ShareWatchHandlers {
125
139
  /** The whole session record, each time the share changes. */
126
140
  onSession: (session: Thread) => void;
@@ -157,6 +157,20 @@ export async function pressKey(
157
157
  await settle(setup);
158
158
  }
159
159
 
160
+ /**
161
+ * Run a thread nav-mode command: esc drops the surface into nav mode, then a bare
162
+ * key acts (a letter, or a named key like "enter"/"backspace"). The caret and any
163
+ * held selection carry into nav, so the command lands where the caret is.
164
+ */
165
+ export async function navCommand(setup: TestRendererSetup, key: string): Promise<void> {
166
+ await press(setup, "escape");
167
+ await new Promise((resolve) => setTimeout(resolve, 40));
168
+ await settle(setup);
169
+ // a single letter is a discrete keypress (nav command); named keys route through press
170
+ if (key.length === 1) await pressKey(setup, key);
171
+ else await press(setup, key);
172
+ }
173
+
160
174
  /** A 0-based cell position within a captured char frame. */
161
175
  export interface FrameLocation {
162
176
  row: number;