@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.101
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/annotations/annotation-palette.ts +62 -0
- package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
- package/src/annotations/components/AnnotationCards.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +369 -0
- package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
- package/src/annotations/discussions.ts +77 -0
- package/src/annotations/thread-selection.test.ts +210 -0
- package/src/annotations/thread-selection.ts +242 -0
- package/src/annotations/use-annotation-surface.tsx +1243 -0
- package/src/app/App.appearance.test.tsx +53 -0
- package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
- package/src/app/App.deleted-scroll.test.tsx +187 -0
- package/src/app/App.diff.test.tsx +313 -0
- package/src/app/App.export.test.tsx +105 -0
- package/src/app/App.focus.test.ts +89 -0
- package/src/app/App.identity.test.tsx +84 -0
- package/src/app/App.plan-render.test.tsx +209 -0
- package/src/app/App.plan-review.test.tsx +428 -0
- package/src/app/App.prototype-markdown.test.tsx +128 -0
- package/src/app/App.prototype.test.tsx +176 -0
- package/src/app/App.readonly.test.tsx +210 -0
- package/src/app/App.regression.test.tsx +99 -0
- package/src/app/App.rendering.test.tsx +134 -0
- package/src/app/App.share-dialog.test.tsx +177 -0
- package/src/app/App.submit-confirm.test.tsx +267 -0
- package/src/app/App.test.tsx +496 -0
- package/src/app/App.thread-agent.test.ts +20 -0
- package/src/app/App.toast.test.tsx +124 -0
- package/src/app/App.tsx +1667 -0
- package/src/app/App.visual.test.tsx +59 -0
- package/src/app/App.walk.test.tsx +299 -0
- package/src/app/App.workbench.test.tsx +1149 -0
- package/src/app/app-key-state.ts +61 -0
- package/src/app/app-screens.tsx +606 -0
- package/src/app/app-view-model.ts +168 -0
- package/src/app/components/AppHeader.stories.tsx +50 -0
- package/src/app/components/AppHeader.tsx +81 -0
- package/src/app/components/AppShell.brand.test.tsx +58 -0
- package/src/app/components/AppShell.extensions.test.tsx +191 -0
- package/src/app/components/AppShell.stories.tsx +89 -0
- package/src/app/components/AppShell.tsx +358 -0
- package/src/app/components/AppShell.zoom.test.tsx +149 -0
- package/src/app/components/Breadcrumb.stories.tsx +19 -0
- package/src/app/components/Breadcrumb.tsx +45 -0
- package/src/app/components/MenuBar.stories.tsx +28 -0
- package/src/app/components/MenuBar.tsx +44 -0
- package/src/app/components/PaneGrid.stories.tsx +84 -0
- package/src/app/components/PaneGrid.tsx +128 -0
- package/src/app/components/PanelColumn.stories.tsx +25 -0
- package/src/app/components/PanelColumn.tsx +93 -0
- package/src/app/components/ShellHeader.stories.tsx +50 -0
- package/src/app/components/ShellHeader.tsx +137 -0
- package/src/app/components/WorkspacePanels.stories.tsx +36 -0
- package/src/app/components/WorkspacePanels.tsx +120 -0
- package/src/app/components/pages/Workspace.stories.tsx +181 -0
- package/src/app/intent-dispatch.test.ts +728 -0
- package/src/app/intent-dispatch.ts +687 -0
- package/src/app/launch-layout.test.ts +78 -0
- package/src/app/launch-layout.ts +57 -0
- package/src/app/ready-signal.test.ts +85 -0
- package/src/app/ready-signal.ts +52 -0
- package/src/app/run.ts +109 -0
- package/src/{serve.test.ts → app/serve.test.ts} +24 -12
- package/src/app/serve.ts +111 -0
- package/src/app/version.ts +5 -0
- package/src/appearance/components/theme-context.stories.tsx +46 -0
- package/src/appearance/components/theme-context.tsx +37 -0
- package/src/appearance/theme-presets.test.ts +51 -0
- package/src/appearance/theme-presets.ts +219 -0
- package/src/appearance/theme.ts +112 -0
- package/src/appearance/visual/contrast.test.ts +35 -0
- package/src/appearance/visual/contrast.ts +33 -0
- package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
- package/src/diff/components/AnnotatableFileView.tsx +141 -0
- package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
- package/src/diff/components/DiffContentView.split.test.tsx +140 -0
- package/src/diff/components/DiffContentView.stories.tsx +80 -0
- package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/diff/components/DiffContentView.tsx +1055 -0
- package/src/diff/components/WalkWizard.stories.tsx +100 -0
- package/src/diff/components/WalkWizard.tsx +146 -0
- package/src/diff/components/diff-content-view-layout.test.ts +198 -0
- package/src/diff/components/diff-content-view-layout.ts +84 -0
- package/src/diff/diff-fold.test.ts +78 -0
- package/src/diff/diff-fold.ts +126 -0
- package/src/diff/diff-hunk-curate.test.ts +315 -0
- package/src/diff/diff-hunk-curate.ts +152 -0
- package/src/diff/diff-intraline.test.ts +239 -0
- package/src/diff/diff-intraline.ts +238 -0
- package/src/diff/diff-syntax.test.ts +125 -0
- package/src/diff/diff-syntax.ts +186 -0
- package/src/diff/split-diff.test.ts +110 -0
- package/src/diff/split-diff.ts +144 -0
- package/src/diff/view-diff.test.ts +325 -0
- package/src/diff/view-diff.ts +522 -0
- package/src/diff/walk.test.ts +121 -0
- package/src/diff/walk.ts +73 -0
- package/src/index.ts +31 -2
- package/src/integrations/attribution.test.ts +31 -0
- package/src/integrations/attribution.ts +42 -0
- package/src/integrations/client-extension-registry.test.tsx +61 -0
- package/src/integrations/client-extension-registry.ts +159 -0
- package/src/integrations/clipboard.test.ts +23 -0
- package/src/integrations/clipboard.ts +33 -0
- package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
- package/src/integrations/components/ExtensionZone.tsx +145 -0
- package/src/integrations/components/ShareDialog.agent.test.tsx +50 -0
- package/src/integrations/components/ShareDialog.stories.tsx +121 -0
- package/src/integrations/components/ShareDialog.tsx +574 -0
- package/src/integrations/components/share-dialog-store.ts +59 -0
- package/src/integrations/editor.test.ts +149 -0
- package/src/integrations/editor.ts +160 -0
- package/src/integrations/github-identity.test.ts +58 -0
- package/src/integrations/github-identity.ts +41 -0
- package/src/integrations/herdr.test.ts +43 -0
- package/src/integrations/herdr.ts +38 -0
- package/src/integrations/integrations.test.ts +41 -0
- package/src/integrations/integrations.ts +32 -0
- package/src/integrations/pasted-image.test.ts +67 -0
- package/src/integrations/pasted-image.ts +60 -0
- package/src/integrations/share.ts +238 -0
- package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
- package/src/keyboard/components/CompletionOverlay.tsx +75 -0
- package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
- package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
- package/src/keyboard/components/HandleChipsInput.tsx +143 -0
- package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
- package/src/keyboard/components/KeybindsDialog.tsx +103 -0
- package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
- package/src/keyboard/components/NavModeHint.tsx +31 -0
- package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
- package/src/keyboard/components/SlashComposer.tsx +87 -0
- package/src/keyboard/key-bindings.test.ts +156 -0
- package/src/keyboard/key-bindings.ts +459 -0
- package/src/keyboard/keymap.test.ts +611 -0
- package/src/keyboard/keymap.ts +395 -0
- package/src/keyboard/skills.test.ts +74 -0
- package/src/keyboard/skills.ts +75 -0
- package/src/keyboard/slash-palette.test.ts +103 -0
- package/src/keyboard/slash-palette.ts +129 -0
- package/src/keyboard/thread-chords.test.ts +79 -0
- package/src/keyboard/thread-chords.ts +163 -0
- package/src/keyboard/use-shared-keyboard.ts +55 -0
- package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
- package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
- package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
- package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
- package/src/markdown/components/ThreadView.stories.tsx +26 -0
- package/src/markdown/components/ThreadView.tsx +560 -0
- package/src/markdown/mark-runs.ts +121 -0
- package/src/markdown/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown/markdown-grid.test.ts +101 -0
- package/src/markdown/markdown-grid.ts +203 -0
- package/src/markdown/markdown-highlight.test.ts +61 -0
- package/src/markdown/markdown-highlight.ts +124 -0
- package/src/markdown/thread-code-syntax.ts +45 -0
- package/src/markdown/use-thread-body-editing.tsx +54 -0
- package/src/markdown/view-plan.regression.test.ts +60 -0
- package/src/markdown/view-plan.test.ts +545 -0
- package/src/markdown/view-plan.ts +1009 -0
- package/src/performance/otlp-trace.test.ts +119 -0
- package/src/performance/otlp-trace.ts +134 -0
- package/src/performance/perf-report.ts +18 -0
- package/src/performance/perf-timings.test.ts +18 -0
- package/src/performance/perf-timings.ts +39 -0
- package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
- package/src/prototype/components/PrototypeContentView.tsx +442 -0
- package/src/prototype/prototype-browser.test.ts +81 -0
- package/src/prototype/prototype-browser.ts +321 -0
- package/src/prototype/prototype-pixels.tsx +35 -0
- package/src/settings/components/SettingsDialog.stories.tsx +117 -0
- package/src/settings/components/SettingsDialog.tsx +183 -0
- package/src/settings/components/SettingsRows.stories.tsx +18 -0
- package/src/settings/components/SettingsRows.tsx +112 -0
- package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
- package/src/settings/components/quick-actions-editor.test.tsx +120 -0
- package/src/settings/components/quick-actions-editor.tsx +222 -0
- package/src/settings/config-identity.test.ts +78 -0
- package/src/settings/config.test.ts +676 -0
- package/src/settings/config.ts +628 -0
- package/src/settings/use-settings-dialog.tsx +351 -0
- package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
- package/src/stories/stories-app.tsx +196 -0
- package/src/stories/stories.test.tsx +74 -0
- package/src/stories/story-fixtures.ts +102 -0
- package/src/stories/story-tree.test.ts +108 -0
- package/src/stories/story.ts +154 -0
- package/src/terminal/ansi-screen.test.ts +28 -0
- package/src/terminal/ansi-screen.ts +72 -0
- package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
- package/src/terminal/components/AnsiScreen.tsx +28 -0
- package/src/terminal/components/terminal-pane.test.tsx +40 -0
- package/src/terminal/components/terminal-pane.ts +196 -0
- package/src/terminal/ghostty-terminal.test.ts +85 -0
- package/src/terminal/ghostty-terminal.ts +197 -0
- package/src/terminal/kitty-image.test.ts +66 -0
- package/src/terminal/kitty-image.ts +137 -0
- package/src/terminal/pty.test.ts +81 -0
- package/src/terminal/pty.ts +246 -0
- package/src/testing/test-support.test.ts +264 -0
- package/src/testing/test-support.ts +367 -0
- package/src/thread/agent-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +336 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +112 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +935 -0
- package/src/thread/components/AgentThreadPane.tsx +377 -0
- package/src/thread/components/ThreadFooter.stories.tsx +30 -0
- package/src/thread/components/ThreadFooter.test.tsx +49 -0
- package/src/thread/components/ThreadFooter.tsx +77 -0
- package/src/thread/components/ThreadTree.stories.tsx +46 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +267 -0
- package/src/thread/components/thread-tree.test.ts +137 -0
- package/src/thread/components/thread-tree.ts +112 -0
- package/src/thread/thread-controller.curate.test.ts +502 -0
- package/src/thread/thread-controller.derivation.test.ts +62 -0
- package/src/thread/thread-controller.live-diff.test.ts +357 -0
- package/src/thread/thread-controller.optimistic.test.ts +69 -0
- package/src/thread/thread-controller.share.test.ts +573 -0
- package/src/thread/thread-controller.switch-cache.test.ts +174 -0
- package/src/thread/thread-controller.tree.test.ts +263 -0
- package/src/thread/thread-controller.ts +2026 -0
- package/src/thread/tree-view.test.ts +128 -0
- package/src/thread/tree-view.ts +146 -0
- package/src/thread/use-thread-agent.test.tsx +208 -0
- package/src/thread/use-thread-agent.ts +117 -0
- package/src/ui/components/CodeBlock.stories.tsx +42 -0
- package/src/ui/components/CodeBlock.tsx +80 -0
- package/src/ui/components/ConfirmCard.stories.tsx +37 -0
- package/src/ui/components/ConfirmCard.tsx +151 -0
- package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
- package/src/ui/components/ConfirmDialog.tsx +72 -0
- package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
- package/src/ui/components/OverlayScrollbar.tsx +129 -0
- package/src/ui/components/PromptDialog.stories.tsx +36 -0
- package/src/ui/components/PromptDialog.test.tsx +65 -0
- package/src/ui/components/PromptDialog.tsx +99 -0
- package/src/ui/components/RootOverlay.stories.tsx +45 -0
- package/src/ui/components/RootOverlay.tsx +49 -0
- package/src/ui/components/ScrollArea.stories.tsx +20 -0
- package/src/ui/components/ScrollArea.tsx +61 -0
- package/src/ui/components/Toast.stories.tsx +19 -0
- package/src/ui/components/Toast.tsx +54 -0
- package/src/ui/components/Tooltip.stories.tsx +26 -0
- package/src/ui/components/Tooltip.tsx +72 -0
- package/src/ui/components/menu-control.stories.tsx +35 -0
- package/src/ui/components/menu-control.tsx +43 -0
- package/src/ui/components/primitives/Button.stories.tsx +32 -0
- package/src/ui/components/primitives/Button.tsx +57 -0
- package/src/ui/components/primitives/Card.stories.tsx +25 -0
- package/src/ui/components/primitives/Card.tsx +62 -0
- package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
- package/src/ui/components/primitives/Dialog.test.tsx +38 -0
- package/src/ui/components/primitives/Dialog.tsx +69 -0
- package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/ui/components/primitives/DialogActions.tsx +39 -0
- package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
- package/src/ui/components/primitives/IconButton.tsx +58 -0
- package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/ui/components/primitives/StatusBar.tsx +23 -0
- package/src/ui/components/primitives/Switch.stories.tsx +17 -0
- package/src/ui/components/primitives/Switch.tsx +32 -0
- package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
- package/src/ui/components/primitives/Tabs.tsx +101 -0
- package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/ui/components/primitives/Toolbar.tsx +14 -0
- package/src/ui/components/primitives/Tree.stories.tsx +124 -0
- package/src/ui/components/primitives/Tree.tsx +133 -0
- package/src/ui/components/primitives/frame.test.ts +12 -0
- package/src/ui/components/primitives/frame.ts +17 -0
- package/src/ui/components/primitives/icons.ts +61 -0
- package/src/ui/components/primitives/tree-model.test.ts +99 -0
- package/src/ui/components/primitives/tree-model.ts +137 -0
- package/src/ui/components/syntax-highlight.ts +133 -0
- package/src/ui/components/truncate-text.ts +7 -0
- package/src/ui/components/truncate-title.test.ts +31 -0
- package/src/ui/components/truncate-title.ts +15 -0
- package/src/ui/use-frame-measure.ts +74 -0
- package/src/ui/use-terminal-virtualizer.ts +259 -0
- package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
- package/src/workbench/components/ChangesColumn.tsx +203 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
- package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.stories.tsx +86 -0
- package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.tsx +544 -0
- package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
- package/src/workbench/components/FileContentsView.stories.tsx +16 -0
- package/src/workbench/components/FileContentsView.test.tsx +29 -0
- package/src/workbench/components/FileContentsView.tsx +100 -0
- package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
- package/src/workbench/components/GridTabContent.stories.tsx +59 -0
- package/src/workbench/components/GridTabContent.tsx +193 -0
- package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
- package/src/workbench/components/ProjectTreeView.tsx +151 -0
- package/src/workbench/components/TreePane.stories.tsx +106 -0
- package/src/workbench/components/TreePane.tsx +155 -0
- package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
- package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
- package/src/workbench/components/WelcomePlayground.tsx +138 -0
- package/src/workbench/components/editor-grid.test.ts +184 -0
- package/src/workbench/components/editor-grid.ts +281 -0
- package/src/workbench/components/file-tree.test.ts +60 -0
- package/src/workbench/components/file-tree.ts +112 -0
- package/src/workbench/use-changes-workbench.ts +205 -0
- package/src/workbench/use-remember-layout.ts +31 -0
- package/src/workbench/use-repo-changes.ts +91 -0
- package/src/workbench/workbench-snapshot.test.ts +99 -0
- package/src/workbench/workbench-snapshot.ts +59 -0
- package/src/App.diff.test.tsx +0 -99
- package/src/App.export.test.tsx +0 -88
- package/src/App.plan-review.test.tsx +0 -259
- package/src/App.readonly.test.tsx +0 -148
- package/src/App.rendering.test.tsx +0 -108
- package/src/App.submit-confirm.test.tsx +0 -183
- package/src/App.test.tsx +0 -218
- package/src/App.tsx +0 -1249
- package/src/config.test.ts +0 -116
- package/src/config.ts +0 -150
- package/src/editor.ts +0 -34
- package/src/herdr.test.ts +0 -68
- package/src/herdr.ts +0 -34
- package/src/keymap.test.ts +0 -270
- package/src/keymap.ts +0 -173
- package/src/run.ts +0 -30
- package/src/serve.ts +0 -75
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/theme.ts +0 -46
- package/src/view-diff.test.ts +0 -55
- package/src/view-diff.ts +0 -86
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
defaultLayout,
|
|
4
|
+
layoutFromPanes,
|
|
5
|
+
pairLayout,
|
|
6
|
+
planLayout,
|
|
7
|
+
pullRequestReviewLayout,
|
|
8
|
+
reviewLayout,
|
|
9
|
+
} from "./launch-layout";
|
|
10
|
+
|
|
11
|
+
describe("launch layout factories", () => {
|
|
12
|
+
test("review opens the diff zoomed with the Changes panel", () => {
|
|
13
|
+
// Assert
|
|
14
|
+
expect(reviewLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("pair opens the Project tree focused beside a zoomed Changes editor", () => {
|
|
18
|
+
expect(pairLayout()).toEqual({
|
|
19
|
+
threads: true,
|
|
20
|
+
rightSidebar: "changes",
|
|
21
|
+
zoomChanges: true,
|
|
22
|
+
projectMode: "tree",
|
|
23
|
+
focusPane: "project",
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("pull request review keeps its brief beside the Changes panel", () => {
|
|
28
|
+
// Assert
|
|
29
|
+
expect(pullRequestReviewLayout()).toEqual({
|
|
30
|
+
threads: true,
|
|
31
|
+
rightSidebar: "changes",
|
|
32
|
+
zoomChanges: false,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("plan fills the middle with the thread pane and no right region", () => {
|
|
37
|
+
// Assert
|
|
38
|
+
expect(planLayout()).toEqual({ threads: true, rightSidebar: "off", zoomChanges: false });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the first-run default lands on the inbox with the diff zoomed", () => {
|
|
42
|
+
// Assert
|
|
43
|
+
expect(defaultLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("layoutFromPanes", () => {
|
|
48
|
+
test("the Changes editor wins the right region when open", () => {
|
|
49
|
+
// Assert
|
|
50
|
+
expect(
|
|
51
|
+
layoutFromPanes({ threads: true, changesOpen: true, projectOpen: true, zoomChanges: true }),
|
|
52
|
+
).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("the Project tree holds the right region when the Changes editor is closed", () => {
|
|
56
|
+
// Assert
|
|
57
|
+
expect(
|
|
58
|
+
layoutFromPanes({
|
|
59
|
+
threads: false,
|
|
60
|
+
changesOpen: false,
|
|
61
|
+
projectOpen: true,
|
|
62
|
+
zoomChanges: false,
|
|
63
|
+
}),
|
|
64
|
+
).toEqual({ threads: false, rightSidebar: "project", zoomChanges: false });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("the right region is off when neither pane is open", () => {
|
|
68
|
+
// Assert
|
|
69
|
+
expect(
|
|
70
|
+
layoutFromPanes({
|
|
71
|
+
threads: true,
|
|
72
|
+
changesOpen: false,
|
|
73
|
+
projectOpen: false,
|
|
74
|
+
zoomChanges: false,
|
|
75
|
+
}),
|
|
76
|
+
).toEqual({ threads: true, rightSidebar: "off", zoomChanges: false });
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pane composition a launch opens with: whether the Threads sidebar is open,
|
|
3
|
+
* what the right region shows, and whether the Changes editor is zoomed. Each
|
|
4
|
+
* command picks a layout through a factory; a bare launch restores the last one
|
|
5
|
+
* the user left (persisted in config), falling back to the default.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface LaunchLayout {
|
|
9
|
+
threads: boolean;
|
|
10
|
+
rightSidebar: "changes" | "project" | "off";
|
|
11
|
+
zoomChanges: boolean;
|
|
12
|
+
projectMode?: "changes" | "tree";
|
|
13
|
+
focusPane?: "threads" | "thread" | "changes" | "project";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `cueloop diff`: the diff front and center. */
|
|
17
|
+
export function reviewLayout(): LaunchLayout {
|
|
18
|
+
return { threads: true, rightSidebar: "changes", zoomChanges: true };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `cueloop pair`: browse project files beside the zoomed Changes editor. */
|
|
22
|
+
export function pairLayout(): LaunchLayout {
|
|
23
|
+
return {
|
|
24
|
+
threads: true,
|
|
25
|
+
rightSidebar: "changes",
|
|
26
|
+
zoomChanges: true,
|
|
27
|
+
projectMode: "tree",
|
|
28
|
+
focusPane: "project",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `cueloop review <pr>`: PR context beside the Changes panel. */
|
|
33
|
+
export function pullRequestReviewLayout(): LaunchLayout {
|
|
34
|
+
return { threads: true, rightSidebar: "changes", zoomChanges: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `cueloop plan` / `cueloop reply`: the thread pane fills the middle; no Changes panel. */
|
|
38
|
+
export function planLayout(): LaunchLayout {
|
|
39
|
+
return { threads: true, rightSidebar: "off", zoomChanges: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The first-run default, before any layout has been remembered: the inbox with the diff zoomed. */
|
|
43
|
+
export function defaultLayout(): LaunchLayout {
|
|
44
|
+
return { threads: true, rightSidebar: "changes", zoomChanges: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read back the layout the panes currently sit in, to remember it for the next bare launch. */
|
|
48
|
+
export function layoutFromPanes(panes: {
|
|
49
|
+
threads: boolean;
|
|
50
|
+
changesOpen: boolean;
|
|
51
|
+
projectOpen: boolean;
|
|
52
|
+
zoomChanges: boolean;
|
|
53
|
+
}): LaunchLayout {
|
|
54
|
+
const rightSidebar = panes.changesOpen ? "changes" : panes.projectOpen ? "project" : "off";
|
|
55
|
+
|
|
56
|
+
return { threads: panes.threads, rightSidebar, zoomChanges: panes.zoomChanges };
|
|
57
|
+
}
|
|
@@ -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 "../testing/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
|
+
}
|
package/src/app/run.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Real-terminal entry: render the App and resolve when the user quits. */
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { createCliRenderer } from "@opentui/core";
|
|
5
|
+
import { createRoot } from "@opentui/react";
|
|
6
|
+
import { App } from "./App";
|
|
7
|
+
import { closePrototypeBrowser } from "../prototype/prototype-browser";
|
|
8
|
+
import type { Appearance } from "../appearance/theme-presets";
|
|
9
|
+
import type { DiffFileContents, Thread } from "@cueloop/schema";
|
|
10
|
+
import type { ThreadClient } from "@cueloop/daemon/client";
|
|
11
|
+
import { loadConfig } from "../settings/config";
|
|
12
|
+
import { perfMark } from "../performance/perf-timings";
|
|
13
|
+
import { reportPerfMarks } from "../performance/perf-report";
|
|
14
|
+
import { defaultLayout, type LaunchLayout } from "./launch-layout";
|
|
15
|
+
import {
|
|
16
|
+
ClientExtensionRegistry,
|
|
17
|
+
loadInstalledClientExtensions,
|
|
18
|
+
} from "../integrations/client-extension-registry";
|
|
19
|
+
|
|
20
|
+
export interface RunClientOptions {
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
initialReview?: { session: Thread; diff: { patch: string; files: DiffFileContents[] } };
|
|
23
|
+
initialClient?: ThreadClient;
|
|
24
|
+
home?: string;
|
|
25
|
+
layout?: LaunchLayout;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** OSC background query budget: long enough for a terminal to answer, short enough that one that never
|
|
29
|
+
* does not stall the launch; a non-answering terminal falls back to dark after this. */
|
|
30
|
+
const THEME_QUERY_TIMEOUT_MS = 100;
|
|
31
|
+
|
|
32
|
+
export async function runClient(options: RunClientOptions): Promise<number> {
|
|
33
|
+
// mouse movement reporting makes multiplexers forward drags to the app,
|
|
34
|
+
// so the renderer's native selection is the drag driver
|
|
35
|
+
// a create-command dictates its layout; a bare inbox launch restores the remembered one, then the
|
|
36
|
+
// default; opening a specific thread carries no layout, so its own pane sync drives the composition
|
|
37
|
+
const layout =
|
|
38
|
+
options.layout ??
|
|
39
|
+
(options.sessionId === undefined ? (loadConfig().ui.layout ?? defaultLayout()) : undefined);
|
|
40
|
+
const renderer = await createCliRenderer({ enableMouseMovement: true });
|
|
41
|
+
const extensionRegistry = new ClientExtensionRegistry();
|
|
42
|
+
|
|
43
|
+
void loadInstalledClientExtensions(extensionRegistry).catch((error) => {
|
|
44
|
+
extensionRegistry.reportError(`Client extension discovery: ${String(error)}`);
|
|
45
|
+
});
|
|
46
|
+
// a full screen of measured elements each holds a frame listener; lift the default-10 ceiling so a
|
|
47
|
+
// busy view does not trip a false leak warning, while a runaway subscription still would
|
|
48
|
+
renderer.setMaxListeners(64);
|
|
49
|
+
perfMark("renderer");
|
|
50
|
+
// re-assert mouse reporting on focus-in: a multiplexer can drop it, stranding the pointer in native selection
|
|
51
|
+
renderer.on("focus", () => {
|
|
52
|
+
renderer.useMouse = false;
|
|
53
|
+
renderer.useMouse = true;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return new Promise<number>((resolve) => {
|
|
57
|
+
let exited = false;
|
|
58
|
+
const shutdown = async (code: number): Promise<void> => {
|
|
59
|
+
if (exited) return;
|
|
60
|
+
exited = true;
|
|
61
|
+
renderer.destroy();
|
|
62
|
+
// close the warm prototype Chromium before exit so it never orphans; the
|
|
63
|
+
// await also gives the renderer time to flush its terminal-restore bytes
|
|
64
|
+
await closePrototypeBrowser();
|
|
65
|
+
resolve(code);
|
|
66
|
+
process.exit(code);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// a closed pane/terminal hangs up (or the parent kills us); exit instead of
|
|
70
|
+
// lingering as an orphan whose pty read loop keeps burning CPU
|
|
71
|
+
process.once("SIGHUP", () => void shutdown(0));
|
|
72
|
+
process.once("SIGTERM", () => void shutdown(0));
|
|
73
|
+
|
|
74
|
+
const root = createRoot(renderer);
|
|
75
|
+
const renderApp = (appearance: Appearance): void => {
|
|
76
|
+
root.render(
|
|
77
|
+
React.createElement(App, {
|
|
78
|
+
home: options.home,
|
|
79
|
+
sessionId: options.sessionId,
|
|
80
|
+
initialReview: options.initialReview,
|
|
81
|
+
initialClient: options.initialClient,
|
|
82
|
+
appearance,
|
|
83
|
+
layout,
|
|
84
|
+
extensionRegistry,
|
|
85
|
+
onExit: shutdown,
|
|
86
|
+
onReady: () => {
|
|
87
|
+
perfMark("firstFrame");
|
|
88
|
+
reportPerfMarks("startup");
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Paint immediately with the default appearance; the OSC theme query resolves off the
|
|
95
|
+
// first-paint path and upgrades the theme in place when the terminal answers, so launch
|
|
96
|
+
// never blocks on it. A non-answering terminal simply stays dark.
|
|
97
|
+
renderApp("dark");
|
|
98
|
+
perfMark("render");
|
|
99
|
+
|
|
100
|
+
void renderer
|
|
101
|
+
.waitForThemeMode(THEME_QUERY_TIMEOUT_MS)
|
|
102
|
+
.then((mode) => {
|
|
103
|
+
perfMark("themeQuery");
|
|
104
|
+
// the query can answer after a fast quit; never render onto a destroyed renderer
|
|
105
|
+
if (!exited && mode && mode !== "dark") renderApp(mode);
|
|
106
|
+
})
|
|
107
|
+
.catch(() => {});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -10,14 +10,14 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { Client } from "ssh2";
|
|
12
12
|
import { DaemonServer } from "@cueloop/daemon";
|
|
13
|
-
import type {
|
|
13
|
+
import type { Thread } from "@cueloop/schema";
|
|
14
14
|
import { serveClient, type ServeHandle } from "./serve";
|
|
15
15
|
|
|
16
16
|
const PLAN = "# Rollout Plan\n\nShip the store move behind a flag.\n";
|
|
17
17
|
|
|
18
18
|
let home: string;
|
|
19
19
|
let daemon: DaemonServer;
|
|
20
|
-
let session:
|
|
20
|
+
let session: Thread;
|
|
21
21
|
let handle: ServeHandle;
|
|
22
22
|
|
|
23
23
|
beforeEach(async () => {
|
|
@@ -38,7 +38,11 @@ afterEach(async () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
/** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
|
|
41
|
-
function sshCapture(
|
|
41
|
+
function sshCapture(
|
|
42
|
+
port: number,
|
|
43
|
+
until: (frame: string) => boolean,
|
|
44
|
+
timeoutMs = 8000,
|
|
45
|
+
): Promise<string> {
|
|
42
46
|
return new Promise((resolve, reject) => {
|
|
43
47
|
const conn = new Client();
|
|
44
48
|
let out = "";
|
|
@@ -48,15 +52,17 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
|
|
|
48
52
|
done = true;
|
|
49
53
|
clearTimeout(timer);
|
|
50
54
|
conn.end();
|
|
51
|
-
|
|
55
|
+
if (err) reject(err);
|
|
56
|
+
else resolve(out);
|
|
52
57
|
};
|
|
53
58
|
const timer = setTimeout(() => finish(new Error(`timed out; captured:\n${out}`)), timeoutMs);
|
|
59
|
+
|
|
54
60
|
conn
|
|
55
61
|
.on("ready", () => {
|
|
56
62
|
conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
|
|
57
63
|
if (err) return finish(err);
|
|
58
|
-
stream.on("data", (
|
|
59
|
-
out +=
|
|
64
|
+
stream.on("data", (chunk: Buffer) => {
|
|
65
|
+
out += chunk.toString("utf8");
|
|
60
66
|
if (until(out)) finish();
|
|
61
67
|
});
|
|
62
68
|
stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
|
|
@@ -70,6 +76,7 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
|
|
|
70
76
|
|
|
71
77
|
describe("cueloop serve", () => {
|
|
72
78
|
test("listens on localhost, persists a host key under CUELOOP_HOME/ssh", () => {
|
|
79
|
+
// Assert
|
|
73
80
|
expect(handle.host).toBe("127.0.0.1");
|
|
74
81
|
expect(handle.port).toBeGreaterThan(0);
|
|
75
82
|
expect(handle.fingerprints.length).toBe(1);
|
|
@@ -77,19 +84,24 @@ describe("cueloop serve", () => {
|
|
|
77
84
|
expect(existsSync(join(home, "ssh", "host_key"))).toBe(true);
|
|
78
85
|
});
|
|
79
86
|
|
|
80
|
-
test("an anonymous ssh client gets the
|
|
81
|
-
|
|
87
|
+
test("an anonymous ssh client gets the read-only TUI for the session", async () => {
|
|
88
|
+
// Act
|
|
89
|
+
const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan"));
|
|
90
|
+
|
|
91
|
+
// Assert - the viewer sees the session; read-only is enforced behaviorally
|
|
92
|
+
// (covered by the observer-primitives tests), not by a header badge
|
|
82
93
|
expect(bytes).toContain("cueloop");
|
|
83
94
|
expect(bytes).toContain("Rollout Plan");
|
|
84
|
-
// observer chrome, not the controller's mutating hint bar
|
|
85
|
-
expect(bytes).toContain("observer - read-only");
|
|
86
95
|
});
|
|
87
96
|
|
|
88
97
|
test("two observers can watch at once", async () => {
|
|
98
|
+
// Act
|
|
89
99
|
const [a, b] = await Promise.all([
|
|
90
|
-
sshCapture(handle.port, (
|
|
91
|
-
sshCapture(handle.port, (
|
|
100
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
101
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
92
102
|
]);
|
|
103
|
+
|
|
104
|
+
// Assert
|
|
93
105
|
expect(a).toContain("Rollout Plan");
|
|
94
106
|
expect(b).toContain("Rollout Plan");
|
|
95
107
|
});
|
package/src/app/serve.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH-served TUI: `cueloop serve` lets teammates join a
|
|
3
|
+
* thread over plain ssh. Every SSH connection renders <App> in
|
|
4
|
+
* observer mode (readOnly) against the same local daemon; the one writable
|
|
5
|
+
* controller stays the local owner's own `cueloop` TUI.
|
|
6
|
+
*
|
|
7
|
+
* Trust model - tunnel of trust, no passwords, no keys:
|
|
8
|
+
* the server authenticates nobody ("open" auth). Access control is the act of
|
|
9
|
+
* sharing the address deliberately: it binds to 127.0.0.1 by default, so a
|
|
10
|
+
* remote teammate reaches it only through a channel the owner opened on
|
|
11
|
+
* purpose (an SSH tunnel, a tailnet address via --host). Anyone who can reach
|
|
12
|
+
* the port can watch - and only watch: observers cannot mutate the session.
|
|
13
|
+
* The host key is generated once under CUELOOP_HOME/ssh/host_key (0600) so
|
|
14
|
+
* fingerprints stay stable across restarts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import React from "react";
|
|
20
|
+
import { createRoot } from "@opentui/react";
|
|
21
|
+
import { createServer } from "@opentui/ssh";
|
|
22
|
+
import { cueloopHome } from "@cueloop/daemon";
|
|
23
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
24
|
+
import type { Artifact } from "@cueloop/schema";
|
|
25
|
+
import { App } from "./App";
|
|
26
|
+
import { snapshotWorkbench } from "../workbench/workbench-snapshot";
|
|
27
|
+
|
|
28
|
+
/** OSC background query budget: brief so a terminal that never answers falls back to dark. */
|
|
29
|
+
const THEME_QUERY_TIMEOUT_MS = 200;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The frozen diff an observer reads for a served workbench thread: captured once at serve time, so a
|
|
33
|
+
* late local edit never shifts it. Undefined when nothing is served or the thread is not a workbench.
|
|
34
|
+
*/
|
|
35
|
+
async function captureFrozenArtifact(
|
|
36
|
+
home: string,
|
|
37
|
+
sessionId: string | undefined,
|
|
38
|
+
): Promise<Artifact | undefined> {
|
|
39
|
+
if (sessionId === undefined) return undefined;
|
|
40
|
+
|
|
41
|
+
const probe = await DaemonClient.connect({ home, autostart: true });
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const thread = await probe.sessionGet(sessionId).catch(() => undefined);
|
|
45
|
+
|
|
46
|
+
if (thread === undefined) return undefined;
|
|
47
|
+
|
|
48
|
+
const snapshot = await snapshotWorkbench(thread, (root, vcs) => probe.repoDiff(root, vcs));
|
|
49
|
+
|
|
50
|
+
return snapshot === thread ? undefined : snapshot.artifact;
|
|
51
|
+
} finally {
|
|
52
|
+
probe.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ServeOptions {
|
|
57
|
+
port?: number;
|
|
58
|
+
host?: string;
|
|
59
|
+
sessionId?: string;
|
|
60
|
+
home?: string;
|
|
61
|
+
banner?: boolean;
|
|
62
|
+
onError?: (cause: unknown) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ServeHandle {
|
|
66
|
+
host: string;
|
|
67
|
+
port: number;
|
|
68
|
+
fingerprints: string[];
|
|
69
|
+
close(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
|
|
73
|
+
const home = options.home ?? cueloopHome();
|
|
74
|
+
const sshDir = join(home, "ssh");
|
|
75
|
+
|
|
76
|
+
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
77
|
+
|
|
78
|
+
const servedArtifact = await captureFrozenArtifact(home, options.sessionId);
|
|
79
|
+
|
|
80
|
+
const server = createServer({
|
|
81
|
+
// password-less by design; see the trust model in the module comment
|
|
82
|
+
auth: "open",
|
|
83
|
+
hostKey: { path: join(sshDir, "host_key") },
|
|
84
|
+
startupBanner: options.banner ?? true,
|
|
85
|
+
idleTimeout: "2h",
|
|
86
|
+
onError: options.onError,
|
|
87
|
+
}).serve(async (session) => {
|
|
88
|
+
// the observer's terminal reports its own background, so a light-terminal
|
|
89
|
+
// watcher gets the readable variant too
|
|
90
|
+
const appearance =
|
|
91
|
+
(await session.renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
|
|
92
|
+
const root = createRoot(session.renderer);
|
|
93
|
+
|
|
94
|
+
root.render(
|
|
95
|
+
React.createElement(App, {
|
|
96
|
+
home,
|
|
97
|
+
sessionId: options.sessionId,
|
|
98
|
+
readOnly: true,
|
|
99
|
+
appearance,
|
|
100
|
+
servedArtifact,
|
|
101
|
+
// q disconnects only this observer, never the server
|
|
102
|
+
onExit: () => session.end(),
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
session.onClose(() => root.unmount());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
|
|
109
|
+
|
|
110
|
+
return { ...info, close: () => server.close() };
|
|
111
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK, type Theme } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../../stories/story";
|
|
4
|
+
import { ThemeProvider } from "./theme-context";
|
|
5
|
+
import { Card } from "../../ui/components/primitives/Card";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "Foundations/Theme" };
|
|
8
|
+
|
|
9
|
+
/** A high-contrast variant to prove tokens flow through the provider. */
|
|
10
|
+
const HIGH_CONTRAST: Theme = { ...DARK, accent: "#00ffff", elevated: "#000000", border: "#ffffff" };
|
|
11
|
+
|
|
12
|
+
export const ContextDefault: Story = {
|
|
13
|
+
render: () => (
|
|
14
|
+
<Card title=" default dark theme " contentRows={1}>
|
|
15
|
+
<text>no provider needed</text>
|
|
16
|
+
</Card>
|
|
17
|
+
),
|
|
18
|
+
expectedColors: [DARK.elevated],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const ProviderSwap: Story = {
|
|
22
|
+
render: () => (
|
|
23
|
+
<ThemeProvider theme={HIGH_CONTRAST}>
|
|
24
|
+
<Card title=" provider theme " contentRows={1} borderColor={HIGH_CONTRAST.accent}>
|
|
25
|
+
<text>tokens come from the provider</text>
|
|
26
|
+
</Card>
|
|
27
|
+
</ThemeProvider>
|
|
28
|
+
),
|
|
29
|
+
expectedColors: ["#00ffff", "#000000"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const PropOverride: Story = {
|
|
33
|
+
render: () => (
|
|
34
|
+
<ThemeProvider theme={DARK}>
|
|
35
|
+
<Card
|
|
36
|
+
title=" prop-overridden theme "
|
|
37
|
+
contentRows={1}
|
|
38
|
+
theme={HIGH_CONTRAST}
|
|
39
|
+
borderColor={HIGH_CONTRAST.accent}
|
|
40
|
+
>
|
|
41
|
+
<text>the theme prop beats the context</text>
|
|
42
|
+
</Card>
|
|
43
|
+
</ThemeProvider>
|
|
44
|
+
),
|
|
45
|
+
expectedColors: ["#00ffff"],
|
|
46
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider theming for the component system. The context default is the
|
|
3
|
+
* built-in dark theme, so embedding a component (or rendering a story) needs
|
|
4
|
+
* no provider at all. One ThemeProvider swap switches the live theme; every
|
|
5
|
+
* component also accepts an optional `theme` prop that overrides the context
|
|
6
|
+
* for explicit control. Components consume named tokens only - a hardcoded
|
|
7
|
+
* color inside components/ is a review rejection.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { createContext, useContext } from "react";
|
|
11
|
+
import { DARK, type Theme } from "../theme";
|
|
12
|
+
|
|
13
|
+
const ThemeContext = createContext<Theme>(DARK);
|
|
14
|
+
|
|
15
|
+
export function ThemeProvider({
|
|
16
|
+
theme,
|
|
17
|
+
children,
|
|
18
|
+
}: {
|
|
19
|
+
theme: Theme;
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
}): React.ReactNode {
|
|
22
|
+
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useTheme(): Theme {
|
|
26
|
+
return useContext(ThemeContext);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Token resolution for a component: the explicit `theme` prop wins over the
|
|
31
|
+
* provider context, which defaults to the built-in dark theme.
|
|
32
|
+
*/
|
|
33
|
+
export function useComponentTheme(override?: Theme): Theme {
|
|
34
|
+
const contextTheme = useContext(ThemeContext);
|
|
35
|
+
|
|
36
|
+
return override ?? contextTheme;
|
|
37
|
+
}
|