@cueloop/client 0.1.0-alpha.67 → 0.1.0-alpha.69
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 +7 -6
- package/src/App.appearance.test.tsx +8 -6
- package/src/App.diff.test.tsx +3 -3
- package/src/App.export.test.tsx +9 -3
- package/src/App.plan-review.test.tsx +10 -8
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +17 -28
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +3 -4
- package/src/App.test.tsx +14 -18
- package/src/App.tsx +117 -42
- package/src/App.walk.test.tsx +2 -2
- package/src/App.workbench.test.tsx +85 -14
- package/src/app-screens.tsx +26 -10
- package/src/app-view-model.ts +17 -3
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.tsx +6 -6
- package/src/components/AppShell.tsx +62 -15
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +69 -0
- package/src/components/ChangesColumn.tsx +3 -2
- package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
- package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
- package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
- package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
- package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tabs.test.tsx +17 -4
- package/src/components/EditorGrid.tsx +49 -19
- package/src/components/FileContentsView.test.tsx +3 -3
- package/src/components/FileContentsView.tsx +37 -17
- package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
- package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
- package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
- package/src/components/ProjectTreeView.tsx +3 -2
- package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
- package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.tsx +3 -2
- package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
- package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
- package/src/components/ThreadsSidebar.tsx +5 -4
- package/src/components/TreePane.tsx +3 -2
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
- package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
- package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
- package/src/config.test.ts +19 -6
- package/src/config.ts +26 -6
- package/src/ghostty-terminal.test.ts +2 -1
- package/src/ghostty-terminal.ts +27 -4
- package/src/intent-dispatch.test.ts +2 -0
- package/src/intent-dispatch.ts +1 -1
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +5 -1
- package/src/keymap.ts +1 -1
- package/src/prototype-browser.ts +8 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/session-controller.live-diff.test.ts +198 -0
- package/src/session-controller.ts +141 -26
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +32 -18
- package/src/test-support.test.ts +12 -0
- package/src/test-support.ts +69 -10
- package/src/theme-presets.ts +20 -0
- package/src/theme.ts +18 -0
- package/src/thread-chords.ts +1 -1
- package/src/use-annotation-surface.tsx +12 -2
- package/src/use-settings-dialog.tsx +4 -4
- package/src/view-diff.test.ts +44 -1
- package/src/view-diff.ts +81 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
- package/src/components/AnnotationCard.stories.tsx +0 -94
- package/src/components/AnnotationCard.tsx +0 -250
- package/src/components/MarkerPopover.stories.tsx +0 -58
- package/src/components/MarkerPopover.tsx +0 -109
- package/src/components/WelcomeSurface.stories.tsx +0 -12
- package/src/components/WelcomeSurface.tsx +0 -52
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure color logic for the diff review sheet, split out so it can be tested
|
|
3
3
|
* without rendering: a row's colored spans (syntax under the intra-line change
|
|
4
|
-
* color).
|
|
4
|
+
* color). DiffContentView.tsx paints marks and the caret over these per character.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Theme } from "../theme";
|
package/src/config.test.ts
CHANGED
|
@@ -126,19 +126,32 @@ describe("loadConfig", () => {
|
|
|
126
126
|
}
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
-
test("[ui] diff_view defaults to
|
|
129
|
+
test("[ui] diff_view defaults to split and parses stacked", () => {
|
|
130
130
|
// Arrange
|
|
131
131
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
|
|
132
132
|
const path = join(dir, "config.toml");
|
|
133
133
|
|
|
134
|
-
writeFileSync(path, `[ui]\ndiff_view = "
|
|
134
|
+
writeFileSync(path, `[ui]\ndiff_view = "stacked"\n`);
|
|
135
135
|
|
|
136
136
|
try {
|
|
137
137
|
// Assert
|
|
138
|
-
expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.diffView).toBe(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.diffView).toBe("split");
|
|
139
|
+
expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("stacked");
|
|
140
|
+
} finally {
|
|
141
|
+
rmSync(dir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('[ui] diff_view loads the pre-rename "unified" value as "stacked"', () => {
|
|
146
|
+
// Arrange - an upgrade must not flip a user who had persisted the old spelling
|
|
147
|
+
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
|
|
148
|
+
const path = join(dir, "config.toml");
|
|
149
|
+
|
|
150
|
+
writeFileSync(path, `[ui]\ndiff_view = "unified"\n`);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
// Assert
|
|
154
|
+
expect(loadConfig({ userConfigPath: path }).ui.diffView).toBe("stacked");
|
|
142
155
|
} finally {
|
|
143
156
|
rmSync(dir, { recursive: true, force: true });
|
|
144
157
|
}
|
package/src/config.ts
CHANGED
|
@@ -26,8 +26,9 @@ export interface IntegrationsConfig {
|
|
|
26
26
|
/** Post-submit behavior: "off" prompts, 0 closes instantly, N counts down. */
|
|
27
27
|
export type AutoClose = "off" | number;
|
|
28
28
|
|
|
29
|
-
/** How the Changes diff renders
|
|
30
|
-
|
|
29
|
+
/** How the Changes diff renders when wide: old|new side by side, or one stacked column. A narrow
|
|
30
|
+
* (unzoomed) pane has no room for two columns, so it is always stacked regardless of this choice. */
|
|
31
|
+
export type DiffViewMode = "split" | "stacked";
|
|
31
32
|
|
|
32
33
|
/** One marker-popover quick action: a preset comment body, plus optional extra lines. */
|
|
33
34
|
export interface QuickAction {
|
|
@@ -99,7 +100,7 @@ export interface CueloopConfig {
|
|
|
99
100
|
editor?: string;
|
|
100
101
|
/** The selected theme preset name; its tokens are the base for `theme`, before any `[theme]` overrides. */
|
|
101
102
|
theme: ThemeName;
|
|
102
|
-
/** How the Changes diff renders when
|
|
103
|
+
/** How the Changes diff renders when wide: old|new side by side or one stacked column. */
|
|
103
104
|
diffView: DiffViewMode;
|
|
104
105
|
/** Session ids the user has pinned to the top of the sidebar; client-local view state. */
|
|
105
106
|
pins: string[];
|
|
@@ -109,6 +110,13 @@ export interface CueloopConfig {
|
|
|
109
110
|
/** Marker-popover quick actions ([[actions]] tables); the 5 defaults when unset. */
|
|
110
111
|
actions: QuickAction[];
|
|
111
112
|
integrations: IntegrationsConfig;
|
|
113
|
+
/** Opt-in experimental features ([experimental] table); all default off. */
|
|
114
|
+
experimental: ExperimentalConfig;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ExperimentalConfig {
|
|
118
|
+
/** Render a prototype as a pixel mockup (kitty graphics) instead of the default markdown design doc. */
|
|
119
|
+
prototypePixels: boolean;
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
/** Every action in the grammar, with its default binding(s). */
|
|
@@ -143,6 +151,7 @@ const ConfigDocumentSchema = v.object({
|
|
|
143
151
|
keys: v.optional(v.unknown()),
|
|
144
152
|
theme: v.optional(v.unknown()),
|
|
145
153
|
ui: v.optional(v.unknown()),
|
|
154
|
+
experimental: v.optional(v.unknown()),
|
|
146
155
|
});
|
|
147
156
|
|
|
148
157
|
const QuickActionSchema = v.object({
|
|
@@ -165,7 +174,7 @@ const UiSchema = v.object({
|
|
|
165
174
|
),
|
|
166
175
|
editor: v.fallback(v.optional(v.string()), undefined),
|
|
167
176
|
theme: v.fallback(v.optional(v.string()), undefined),
|
|
168
|
-
diff_view: v.fallback(v.optional(v.picklist(["
|
|
177
|
+
diff_view: v.fallback(v.optional(v.picklist(["split", "stacked", "unified"])), undefined),
|
|
169
178
|
pins: v.fallback(v.optional(v.array(v.string())), undefined),
|
|
170
179
|
});
|
|
171
180
|
const ObsidianSchema = v.object({
|
|
@@ -176,6 +185,9 @@ const ObsidianSchema = v.object({
|
|
|
176
185
|
exportOn: v.fallback(v.optional(v.picklist(["approve", "resolve", "manual"])), undefined),
|
|
177
186
|
});
|
|
178
187
|
const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
|
|
188
|
+
const ExperimentalSchema = v.object({
|
|
189
|
+
prototype_pixels: v.fallback(v.optional(v.boolean()), undefined),
|
|
190
|
+
});
|
|
179
191
|
const ThemeOverridesSchema = v.record(v.string(), v.unknown());
|
|
180
192
|
|
|
181
193
|
function isThemeToken(token: string): token is keyof Theme {
|
|
@@ -225,12 +237,14 @@ function layer(
|
|
|
225
237
|
authors: { ...base.authors },
|
|
226
238
|
actions: [...base.actions],
|
|
227
239
|
integrations: { obsidian: { ...base.integrations.obsidian } },
|
|
240
|
+
experimental: { ...base.experimental },
|
|
228
241
|
};
|
|
229
242
|
const actions = parseActions(raw.actions);
|
|
230
243
|
const authors = v.safeParse(AuthorsSchema, raw.authors);
|
|
231
244
|
const keys = v.safeParse(KeysSchema, raw.keys);
|
|
232
245
|
const ui = v.safeParse(UiSchema, raw.ui);
|
|
233
246
|
const integrations = v.safeParse(IntegrationsSchema, raw.integrations);
|
|
247
|
+
const experimental = v.safeParse(ExperimentalSchema, raw.experimental);
|
|
234
248
|
|
|
235
249
|
if (actions) out.actions = actions;
|
|
236
250
|
if (authors.success) {
|
|
@@ -251,12 +265,17 @@ function layer(
|
|
|
251
265
|
if (ui.success) {
|
|
252
266
|
if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
|
|
253
267
|
if (ui.output.editor?.trim()) out.ui.editor = ui.output.editor.trim();
|
|
254
|
-
|
|
268
|
+
// "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
|
|
269
|
+
if (ui.output.diff_view !== undefined)
|
|
270
|
+
out.ui.diffView = ui.output.diff_view === "unified" ? "stacked" : ui.output.diff_view;
|
|
255
271
|
if (ui.output.pins !== undefined) out.ui.pins = ui.output.pins;
|
|
256
272
|
}
|
|
257
273
|
if (integrations.success && integrations.output.obsidian) {
|
|
258
274
|
mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
|
|
259
275
|
}
|
|
276
|
+
if (experimental.success && experimental.output.prototype_pixels !== undefined) {
|
|
277
|
+
out.experimental.prototypePixels = experimental.output.prototype_pixels;
|
|
278
|
+
}
|
|
260
279
|
|
|
261
280
|
return out;
|
|
262
281
|
}
|
|
@@ -271,12 +290,13 @@ export function loadConfig(
|
|
|
271
290
|
ui: {
|
|
272
291
|
autoClose: "off",
|
|
273
292
|
theme: DEFAULT_THEME_NAME,
|
|
274
|
-
diffView: "
|
|
293
|
+
diffView: "split",
|
|
275
294
|
pins: [],
|
|
276
295
|
},
|
|
277
296
|
authors: {},
|
|
278
297
|
actions: [...DEFAULT_QUICK_ACTIONS],
|
|
279
298
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
299
|
+
experimental: { prototypePixels: false },
|
|
280
300
|
};
|
|
281
301
|
// Theme name and per-token overrides are separate concerns, composed once
|
|
282
302
|
// after all layers: the last file to set [ui] theme wins, and every [theme]
|
|
@@ -21,10 +21,11 @@ describe("ghostty-terminal FFI", () => {
|
|
|
21
21
|
term.write(new TextEncoder().encode("hi\x1b[1;32mGO\x1b[0m"));
|
|
22
22
|
const cells = [0, 1, 2, 3].map((x) => term.readCell(x, 0));
|
|
23
23
|
|
|
24
|
-
// Assert - glyphs decode, and the green run is bold on ANSI palette index 2
|
|
24
|
+
// Assert - glyphs decode, the row reads as text, and the green run is bold on ANSI palette index 2
|
|
25
25
|
expect(
|
|
26
26
|
cells.map((c) => (c?.codepoint ? String.fromCodePoint(c.codepoint) : " ")).join(""),
|
|
27
27
|
).toBe("hiGO");
|
|
28
|
+
expect(term.rowText(0, 80)).toBe("hiGO");
|
|
28
29
|
expect(cells[2]!.bold).toBe(true);
|
|
29
30
|
expect(cells[2]!.fg).toEqual({ kind: "palette", index: 2 });
|
|
30
31
|
term.free();
|
package/src/ghostty-terminal.ts
CHANGED
|
@@ -60,25 +60,31 @@ function nativeLibraryPath(): string | null {
|
|
|
60
60
|
return existsSync(path) ? path : null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** One shared load of the VT library; null when no dylib ships or it failed to load. */
|
|
64
|
+
let ghosttyFactory: GhosttyTerminalFactory | null | undefined;
|
|
65
|
+
|
|
63
66
|
/**
|
|
64
67
|
* Open the Ghostty VT library for this platform, or return null when the prebuilt
|
|
65
68
|
* dylib is missing (the caller degrades to a herdr split instead of embedding).
|
|
69
|
+
* The library is opened once per process; later calls return the same factory.
|
|
66
70
|
*/
|
|
67
71
|
export function loadGhosttyTerminals(): GhosttyTerminalFactory | null {
|
|
72
|
+
if (ghosttyFactory !== undefined) return ghosttyFactory;
|
|
68
73
|
const path = nativeLibraryPath();
|
|
69
74
|
|
|
70
|
-
if (!path) return null;
|
|
75
|
+
if (!path) return (ghosttyFactory = null);
|
|
71
76
|
try {
|
|
72
77
|
const lib = dlopen(path, CVT_SYMBOLS);
|
|
73
78
|
|
|
74
|
-
|
|
79
|
+
ghosttyFactory = new GhosttyTerminalFactory(lib.symbols);
|
|
75
80
|
} catch (error) {
|
|
76
81
|
// The file exists but failed to load (bad arch, missing symbol) - a real
|
|
77
82
|
// fault, not the expected no-prebuilt case; surface it, then degrade.
|
|
78
83
|
console.error(`cueloop: failed to load ${path}:`, error);
|
|
79
|
-
|
|
80
|
-
return null;
|
|
84
|
+
ghosttyFactory = null;
|
|
81
85
|
}
|
|
86
|
+
|
|
87
|
+
return ghosttyFactory;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/** Creates `GhosttyTerminal`s that share one loaded copy of the VT library. */
|
|
@@ -146,6 +152,23 @@ export class GhosttyTerminal {
|
|
|
146
152
|
};
|
|
147
153
|
}
|
|
148
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Row `y` as text: blank cells read as spaces, the trailing half of a wide
|
|
157
|
+
* glyph is skipped, and trailing spaces are trimmed.
|
|
158
|
+
*/
|
|
159
|
+
rowText(y: number, cols: number): string {
|
|
160
|
+
let line = "";
|
|
161
|
+
|
|
162
|
+
for (let x = 0; x < cols; x++) {
|
|
163
|
+
const cell = this.readCell(x, y);
|
|
164
|
+
|
|
165
|
+
if (cell === null || cell.width === 2) continue;
|
|
166
|
+
line += cell.codepoint === 0 ? " " : String.fromCodePoint(cell.codepoint);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return line.trimEnd();
|
|
170
|
+
}
|
|
171
|
+
|
|
149
172
|
/** The cursor's current viewport position and visibility. */
|
|
150
173
|
readCursor(): GhosttyCursor {
|
|
151
174
|
if (this.freed) return { x: 0, y: 0, visible: false };
|
|
@@ -97,6 +97,8 @@ function baseController(): ReviewController {
|
|
|
97
97
|
restoreCuration: mock(),
|
|
98
98
|
edit: mock(),
|
|
99
99
|
annotate: mock(() => undefined),
|
|
100
|
+
addComment: mock(() => undefined),
|
|
101
|
+
commentOnWorkbench: mock(() => Promise.resolve()),
|
|
100
102
|
reply: mock(() => undefined),
|
|
101
103
|
annotatePrototype: mock(() => undefined),
|
|
102
104
|
updateAnnotation: mock(),
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -113,7 +113,7 @@ export interface IntentDispatchDeps {
|
|
|
113
113
|
selectCardFromDocument: (annotationId: string) => void;
|
|
114
114
|
runEditorHandOff: () => void;
|
|
115
115
|
openCardEdit: (annotationId: string) => void;
|
|
116
|
-
/** Flip
|
|
116
|
+
/** Flip split/stacked diff and persist it (App-owned); split lays out only when wide/zoomed. */
|
|
117
117
|
toggleDiffView: () => void;
|
|
118
118
|
}
|
|
119
119
|
|
package/src/key-bindings.test.ts
CHANGED
|
@@ -115,7 +115,7 @@ describe("getActiveKeys-generated status hints", () => {
|
|
|
115
115
|
|
|
116
116
|
test("submit hint", () => {
|
|
117
117
|
expect(bindings({ overlay: "submit" }).statusHint("submit")).toBe(
|
|
118
|
-
"
|
|
118
|
+
"decision ←/→ · enter submit · esc cancel",
|
|
119
119
|
);
|
|
120
120
|
});
|
|
121
121
|
|
package/src/key-bindings.ts
CHANGED
|
@@ -92,7 +92,11 @@ const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
|
|
|
92
92
|
{ commands: ["cancel_overlay"], label: "cancel" },
|
|
93
93
|
],
|
|
94
94
|
submit: [
|
|
95
|
-
{
|
|
95
|
+
{
|
|
96
|
+
commands: ["cycle_verdict_left", "cycle_verdict_right"],
|
|
97
|
+
label: "decision",
|
|
98
|
+
labelFirst: true,
|
|
99
|
+
},
|
|
96
100
|
{ commands: ["submit_verdict"], label: "submit" },
|
|
97
101
|
{ commands: ["cancel_submit"], label: "cancel" },
|
|
98
102
|
],
|
package/src/keymap.ts
CHANGED
|
@@ -372,7 +372,7 @@ function navigationIntent(action: string | undefined): Intent[] | null {
|
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
/** Ungated view toggles in the diff: right folds a file to its band, left unfolds it, s flips
|
|
375
|
-
*
|
|
375
|
+
* split/stacked (split lays out only when the Changes pane is wide/zoomed). */
|
|
376
376
|
function viewToggleIntent(action: string | undefined): Intent[] | null {
|
|
377
377
|
if (action === "collapse_file") return [{ type: "foldFile" }];
|
|
378
378
|
if (action === "expand_file") return [{ type: "unfoldFile" }];
|
package/src/prototype-browser.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface LaunchOptions {
|
|
|
42
42
|
deviceScaleFactor?: number;
|
|
43
43
|
/** Absolute path to a Chrome/Chromium binary; falls back to the channel. */
|
|
44
44
|
executablePath?: string;
|
|
45
|
+
/** The terminal's color scheme, emulated as prefers-color-scheme so a mockup
|
|
46
|
+
* with theme-aware CSS renders to match the surface it emerges into. */
|
|
47
|
+
colorScheme?: "dark" | "light";
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
/** Cell click -> CSS pixel inside the letterboxed image, or null when outside it. */
|
|
@@ -145,6 +148,11 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
|
|
|
145
148
|
height: options.viewport.height,
|
|
146
149
|
deviceScaleFactor: options.deviceScaleFactor ?? 2,
|
|
147
150
|
});
|
|
151
|
+
// match the terminal's scheme so a theme-aware mockup renders its dark or
|
|
152
|
+
// light variant to sit correctly on the surface it emerges into
|
|
153
|
+
await page.emulateMediaFeatures([
|
|
154
|
+
{ name: "prefers-color-scheme", value: options.colorScheme ?? "light" },
|
|
155
|
+
]);
|
|
148
156
|
// `load` waits for images and styles but skips networkidle0's fixed 500ms
|
|
149
157
|
// idle window, which a static local file would otherwise always pay
|
|
150
158
|
await page.goto(pathToFileURL(options.filePath).href, { waitUntil: "load" });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy boundary for the opt-in kitty pixel prototype. The pixel path pulls in the
|
|
3
|
+
* kitty image writer, the headless-browser renderer, and (on first paint) puppeteer;
|
|
4
|
+
* none of that belongs in the runtime unless the `[experimental] prototype_pixels`
|
|
5
|
+
* flag is on. Importing only the TYPE here is erased at build time, so the real
|
|
6
|
+
* module - and its dependencies - loads only when this component actually mounts,
|
|
7
|
+
* which App does exclusively for a pixel prototype. This lives outside components/
|
|
8
|
+
* on purpose: it is an app boundary, not a visual component the story catalog covers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, { useEffect, useState } from "react";
|
|
12
|
+
import type { PrototypeContentViewProps } from "./components/PrototypeContentView";
|
|
13
|
+
|
|
14
|
+
type PixelPrototypeView =
|
|
15
|
+
(typeof import("./components/PrototypeContentView"))["PrototypeContentView"];
|
|
16
|
+
|
|
17
|
+
export function PrototypePixels(props: PrototypeContentViewProps): React.ReactNode {
|
|
18
|
+
const [View, setView] = useState<PixelPrototypeView | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let alive = true;
|
|
22
|
+
|
|
23
|
+
void import("./components/PrototypeContentView").then((module) => {
|
|
24
|
+
if (alive) setView(() => module.PrototypeContentView);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
alive = false;
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
if (!View) return null;
|
|
33
|
+
|
|
34
|
+
return <View {...props} />;
|
|
35
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** The ready signal fires once, after a frame, and writes the ready file when the env names one. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createElement, useState } from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { READY_FILE_ENV, useReadySignal } from "./ready-signal";
|
|
10
|
+
import { settle } from "./test-support";
|
|
11
|
+
|
|
12
|
+
const homes: string[] = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
|
|
16
|
+
delete process.env[READY_FILE_ENV];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Hands its readiness setter to the test, so a frame can be rendered before and after the flip. */
|
|
20
|
+
function ReadyProbe(props: {
|
|
21
|
+
initiallyReady: boolean;
|
|
22
|
+
onReady: () => void;
|
|
23
|
+
exposeSetter?: (setReady: (ready: boolean) => void) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const [ready, setReady] = useState(props.initiallyReady);
|
|
26
|
+
|
|
27
|
+
props.exposeSetter?.(setReady);
|
|
28
|
+
useReadySignal(ready, props.onReady);
|
|
29
|
+
|
|
30
|
+
return createElement("text", null, ready ? "ready" : "booting");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("useReadySignal", () => {
|
|
34
|
+
test("fires once on the first frame after ready turns true", async () => {
|
|
35
|
+
// Arrange - not ready yet
|
|
36
|
+
let fired = 0;
|
|
37
|
+
let setReady: ((ready: boolean) => void) | null = null;
|
|
38
|
+
const onReady = () => {
|
|
39
|
+
fired += 1;
|
|
40
|
+
};
|
|
41
|
+
const setup = await testRender(
|
|
42
|
+
createElement(ReadyProbe, {
|
|
43
|
+
initiallyReady: false,
|
|
44
|
+
onReady,
|
|
45
|
+
exposeSetter: (setter) => {
|
|
46
|
+
setReady = setter;
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
{ width: 20, height: 3 },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
await settle(setup);
|
|
53
|
+
expect(fired).toBe(0);
|
|
54
|
+
|
|
55
|
+
// Act - ready, then let the commit, the effect, and more frames than one signal needs run
|
|
56
|
+
setReady!(true);
|
|
57
|
+
await settle(setup);
|
|
58
|
+
await settle(setup);
|
|
59
|
+
|
|
60
|
+
// Assert - exactly one signal, and the screen shows the ready tree
|
|
61
|
+
expect(fired).toBe(1);
|
|
62
|
+
expect(setup.captureCharFrame()).toContain("ready");
|
|
63
|
+
setup.renderer.destroy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("writes the ready file through the env channel", async () => {
|
|
67
|
+
// Arrange
|
|
68
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-ready-"));
|
|
69
|
+
const readyFile = join(home, "ready");
|
|
70
|
+
|
|
71
|
+
homes.push(home);
|
|
72
|
+
process.env[READY_FILE_ENV] = readyFile;
|
|
73
|
+
const setup = await testRender(
|
|
74
|
+
createElement(ReadyProbe, { initiallyReady: true, onReady: () => {} }),
|
|
75
|
+
{ width: 20, height: 3 },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
await settle(setup);
|
|
80
|
+
|
|
81
|
+
// Assert
|
|
82
|
+
expect(existsSync(readyFile)).toBe(true);
|
|
83
|
+
setup.renderer.destroy();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app ready signal: fired once, after the first frame that paints a
|
|
3
|
+
* usable screen (a session, the no-thread shell, or an error) has been
|
|
4
|
+
* flushed. React runs child effects before parent effects, so by the time the
|
|
5
|
+
* App-level effect arms the frame listener every keyboard handler of that
|
|
6
|
+
* first screen is already subscribed - a key sent after the signal cannot be
|
|
7
|
+
* dropped. Surfaces that mount later (a composer, a lazily loaded tab) still
|
|
8
|
+
* need their own screen predicate. Tests wait on this instead of guessing
|
|
9
|
+
* from output silence. Two channels: an
|
|
10
|
+
* `onReady` callback for in-process suites, and the file named by
|
|
11
|
+
* CUELOOP_READY_FILE for subprocess and PTY suites. Users never see either.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { writeFileSync } from "node:fs";
|
|
15
|
+
import { useEffect, useRef } from "react";
|
|
16
|
+
import { useRenderer } from "@opentui/react";
|
|
17
|
+
|
|
18
|
+
/** The env var naming the file the app writes once it is ready; unset in normal use. */
|
|
19
|
+
export const READY_FILE_ENV = "CUELOOP_READY_FILE";
|
|
20
|
+
|
|
21
|
+
/** Write the ready file when CUELOOP_READY_FILE names one; a no-op otherwise, so the signal costs nothing outside tests. */
|
|
22
|
+
function announceReady(): void {
|
|
23
|
+
const path = process.env[READY_FILE_ENV];
|
|
24
|
+
|
|
25
|
+
if (!path) return;
|
|
26
|
+
writeFileSync(path, "ready\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fire the ready signal once, on the first rendered frame after `ready`
|
|
31
|
+
* turns true. Call it after every keyboard hook of the component so their
|
|
32
|
+
* subscriptions are in place when the signal goes out.
|
|
33
|
+
*/
|
|
34
|
+
export function useReadySignal(ready: boolean, onReady?: () => void): void {
|
|
35
|
+
const renderer = useRenderer();
|
|
36
|
+
const fired = useRef(false);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!ready || fired.current) return;
|
|
40
|
+
const emit = (): void => {
|
|
41
|
+
fired.current = true;
|
|
42
|
+
announceReady();
|
|
43
|
+
onReady?.();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
renderer?.once("frame", emit);
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
renderer?.off("frame", emit);
|
|
50
|
+
};
|
|
51
|
+
}, [ready, renderer, onReady]);
|
|
52
|
+
}
|