@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.40
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 +32 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +316 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +123 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
package/src/keymap.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keyboard grammar as a pure reducer: view state in, intents out.
|
|
3
|
+
* App builds a KeyState per key event, calls reduceKey, and dispatches the
|
|
4
|
+
* intents to controller verbs and view-state setters - no branch bodies live
|
|
5
|
+
* in the key handler. Diff and plan reviews share one path for annotation
|
|
6
|
+
* navigation, deletion, and submit; the read-only rule is one gate here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { actionFor } from "./config";
|
|
10
|
+
|
|
11
|
+
export type Intent =
|
|
12
|
+
| { type: "exit" }
|
|
13
|
+
| { type: "status"; message: string }
|
|
14
|
+
| { type: "move"; to: "down" | "up" | "top" | "bottom" }
|
|
15
|
+
| { type: "inboxMove"; to: "down" | "up" }
|
|
16
|
+
| { type: "openSession" }
|
|
17
|
+
| { type: "requestDeleteSession" }
|
|
18
|
+
| { type: "openRename" }
|
|
19
|
+
| { type: "confirmDialog" }
|
|
20
|
+
| { type: "startSpan" }
|
|
21
|
+
| { type: "spanKey"; name: string }
|
|
22
|
+
| { type: "spanCut" }
|
|
23
|
+
| { type: "openSpanActions" }
|
|
24
|
+
| { type: "moveSpanAction"; direction: -1 | 1 }
|
|
25
|
+
| { type: "pickSpanAction"; index?: number }
|
|
26
|
+
| { type: "closeSpanActions" }
|
|
27
|
+
| { type: "openCompose"; kind: "comment"; from: "cursor" | "span" }
|
|
28
|
+
| { type: "openSubmit" }
|
|
29
|
+
| { type: "share" }
|
|
30
|
+
| { type: "cut" }
|
|
31
|
+
| { type: "edit" }
|
|
32
|
+
| { type: "editCard" }
|
|
33
|
+
| { type: "rejectHunk" }
|
|
34
|
+
| { type: "rejectChange" }
|
|
35
|
+
| { type: "restoreCuration" }
|
|
36
|
+
| { type: "nextAnnotation" }
|
|
37
|
+
| { type: "prevAnnotation" }
|
|
38
|
+
| { type: "walkStart" }
|
|
39
|
+
| { type: "walkForward" }
|
|
40
|
+
| { type: "walkBack" }
|
|
41
|
+
| { type: "walkLeave" }
|
|
42
|
+
| { type: "removeAnnotation" }
|
|
43
|
+
| { type: "deselect" }
|
|
44
|
+
| { type: "closeOverlay" }
|
|
45
|
+
| { type: "saveCompose" }
|
|
46
|
+
| { type: "submitVerdict" }
|
|
47
|
+
| { type: "cycleVerdict"; direction: -1 | 1 }
|
|
48
|
+
| { type: "finishReview" }
|
|
49
|
+
| { type: "optInAutoClose" }
|
|
50
|
+
| { type: "dismissCompletion" }
|
|
51
|
+
| { type: "cycleReviewPanel" }
|
|
52
|
+
| { type: "resizeReviewPanel"; direction: -1 | 1 };
|
|
53
|
+
|
|
54
|
+
export interface KeyInput {
|
|
55
|
+
name: string;
|
|
56
|
+
shift: boolean;
|
|
57
|
+
/** Option/Alt arrives as `meta` in this terminal stack (never as a raw alt). */
|
|
58
|
+
meta?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface KeyState {
|
|
62
|
+
/** Loaded keymap (config.ts): action -> key combos. */
|
|
63
|
+
keys: Record<string, string[]>;
|
|
64
|
+
readOnly: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Owner-only verbs a share collaborator lacks (undefined = owner, allowed).
|
|
67
|
+
* A collaborator annotates but cannot edit the plan (cut / $EDITOR runs on
|
|
68
|
+
* the gateway) or submit an agent verdict (there is no agent on a share).
|
|
69
|
+
*/
|
|
70
|
+
canEditPlan?: boolean;
|
|
71
|
+
canSubmitVerdict?: boolean;
|
|
72
|
+
/** Owner-only: publish the plan as a share. A collaborator never re-shares. */
|
|
73
|
+
canShare?: boolean;
|
|
74
|
+
/** Layer that owns keys before the grammar runs. */
|
|
75
|
+
overlay:
|
|
76
|
+
| "none"
|
|
77
|
+
| "walk"
|
|
78
|
+
| "compose"
|
|
79
|
+
| "submit"
|
|
80
|
+
| "confirm"
|
|
81
|
+
| "prompt"
|
|
82
|
+
| "spanActions"
|
|
83
|
+
| "completion-prompt"
|
|
84
|
+
| "completion-counting";
|
|
85
|
+
view: "inbox" | "plan" | "diff";
|
|
86
|
+
/** Plan-only span selection sub-mode. */
|
|
87
|
+
spanMode: boolean;
|
|
88
|
+
/** The walk cursor sits on the end card - return offers the submit action. */
|
|
89
|
+
walkAtEnd: boolean;
|
|
90
|
+
resolved: boolean;
|
|
91
|
+
hasInboxItems: boolean;
|
|
92
|
+
annotationCount: number;
|
|
93
|
+
hasFocusedAnnotation: boolean;
|
|
94
|
+
/** Cursor sits on annotatable text: a work block (plan) or a code row (diff). */
|
|
95
|
+
cursorAnnotatable: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
99
|
+
const MUTATING_ACTIONS = new Set([
|
|
100
|
+
"comment",
|
|
101
|
+
"cut",
|
|
102
|
+
"edit",
|
|
103
|
+
"delete_annotation",
|
|
104
|
+
"submit",
|
|
105
|
+
"walk",
|
|
106
|
+
"share",
|
|
107
|
+
"reject_hunk",
|
|
108
|
+
"restore_curation",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const SPAN_KEYS = new Set(["l", "h", "w", "b", "$", "0"]);
|
|
112
|
+
|
|
113
|
+
function status(message: string): Intent[] {
|
|
114
|
+
return [{ type: "status", message }];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reduce one key event to intents. `resolvedAction` is the binding-layer
|
|
119
|
+
* resolution (key-bindings.ts over @opentui/keymap) when the caller has one;
|
|
120
|
+
* without it the reducer falls back to the plain reverse lookup, so the
|
|
121
|
+
* grammar stays testable as a pure function.
|
|
122
|
+
*/
|
|
123
|
+
export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
|
|
124
|
+
const name = key.name;
|
|
125
|
+
// compose/submit overlays own the keys via focused inputs; only escape,
|
|
126
|
+
// return, and verdict arrows route through the grammar
|
|
127
|
+
if (state.overlay === "compose" || state.overlay === "submit") {
|
|
128
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
129
|
+
if (name === "return" || name === "enter") {
|
|
130
|
+
// In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
|
|
131
|
+
// focused textarea owns that; only a bare ⏎ saves. The submit overlay
|
|
132
|
+
// keeps its plain ⏎ submit.
|
|
133
|
+
if (state.overlay === "compose") {
|
|
134
|
+
if (key.shift || key.meta) return [];
|
|
135
|
+
return [{ type: "saveCompose" }];
|
|
136
|
+
}
|
|
137
|
+
return [{ type: "submitVerdict" }];
|
|
138
|
+
}
|
|
139
|
+
if (state.overlay === "submit" && (name === "left" || name === "right")) {
|
|
140
|
+
return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
|
|
141
|
+
}
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
// a modal confirm owns the keys: ⏎ commits the action, escape backs out
|
|
145
|
+
if (state.overlay === "confirm") {
|
|
146
|
+
if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
|
|
147
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
// a text prompt: the focused input owns typing; only ⏎ save and esc route here
|
|
151
|
+
if (state.overlay === "prompt") {
|
|
152
|
+
if (name === "return" || name === "enter") return [{ type: "confirmDialog" }];
|
|
153
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
// the quick-actions list owns its keys: j/k move, ⏎ picks the highlighted
|
|
157
|
+
// action (inserting its preset comment), escape returns to the span toolbar
|
|
158
|
+
if (state.overlay === "spanActions") {
|
|
159
|
+
if (name === "j" || name === "down") return [{ type: "moveSpanAction", direction: 1 }];
|
|
160
|
+
if (name === "k" || name === "up") return [{ type: "moveSpanAction", direction: -1 }];
|
|
161
|
+
if (name === "return" || name === "enter") return [{ type: "pickSpanAction" }];
|
|
162
|
+
if (name === "escape") return [{ type: "closeSpanActions" }];
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
|
|
166
|
+
if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
|
|
167
|
+
if (name === "a") return [{ type: "optInAutoClose" }];
|
|
168
|
+
if (name === "escape") return [{ type: "dismissCompletion" }];
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
// the walk wizard owns its keys while active: ] advances (marking the
|
|
172
|
+
// current file viewed), [ steps back, escape leaves keeping progress, and
|
|
173
|
+
// return on the end card hands over to the submit confirm
|
|
174
|
+
if (state.overlay === "walk") {
|
|
175
|
+
if (name === "]") return [{ type: "walkForward" }];
|
|
176
|
+
if (name === "[") return [{ type: "walkBack" }];
|
|
177
|
+
if (name === "escape") return [{ type: "walkLeave" }];
|
|
178
|
+
if ((name === "return" || name === "enter") && state.walkAtEnd) {
|
|
179
|
+
return [{ type: "walkLeave" }, { type: "openSubmit" }];
|
|
180
|
+
}
|
|
181
|
+
if (name === "q") return [{ type: "exit" }];
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
|
|
185
|
+
if (action === "quit") return [{ type: "exit" }];
|
|
186
|
+
// the ONE read-only rule: any mutating attempt answers instead of acting
|
|
187
|
+
// (span-mode c, x, and a are hardwired keys, so they gate by name as well)
|
|
188
|
+
const mutating =
|
|
189
|
+
MUTATING_ACTIONS.has(action ?? "") ||
|
|
190
|
+
(state.spanMode && (name === "c" || name === "x" || name === "a"));
|
|
191
|
+
if (state.readOnly && mutating) return status("observer - read-only");
|
|
192
|
+
|
|
193
|
+
// share is a session-level verb: it works from any view, owner only
|
|
194
|
+
if (action === "share") {
|
|
195
|
+
if (state.canShare === false) return status("only the plan owner can share");
|
|
196
|
+
return [{ type: "share" }];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (state.view === "inbox") return inboxGrammar(state, name);
|
|
200
|
+
// span mode owns its single-letter keys (b slides the span back) before the
|
|
201
|
+
// review-panel controls claim them
|
|
202
|
+
if (state.spanMode) return spanGrammar(state, name);
|
|
203
|
+
// the review panel rides both plan and diff reviews; collapsing and resizing
|
|
204
|
+
// are view state, so the read-only gate above lets them through
|
|
205
|
+
const reviewPanel = reviewPanelGrammar(action);
|
|
206
|
+
if (reviewPanel) return reviewPanel;
|
|
207
|
+
if (state.view === "diff") return diffGrammar(state, action);
|
|
208
|
+
return planGrammar(state, action, name);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The review-panel controls: cycle the mode, widen and narrow the rail. */
|
|
212
|
+
function reviewPanelGrammar(action: string | undefined): Intent[] | null {
|
|
213
|
+
if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
|
|
214
|
+
if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
|
|
215
|
+
if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
220
|
+
if (!state.hasInboxItems) return [];
|
|
221
|
+
if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
|
|
222
|
+
if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
|
|
223
|
+
if (name === "return" || name === "enter") return [{ type: "openSession" }];
|
|
224
|
+
if (name === "d") return [{ type: "requestDeleteSession" }];
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
229
|
+
const navigation = navigationIntent(action);
|
|
230
|
+
if (navigation) return navigation;
|
|
231
|
+
if (action === "walk") {
|
|
232
|
+
// marking viewed writes the session record, so a resolved review answers
|
|
233
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
234
|
+
return [{ type: "walkStart" }];
|
|
235
|
+
}
|
|
236
|
+
if (action === "comment") {
|
|
237
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
238
|
+
if (!state.cursorAnnotatable) return status("move to a code line to comment");
|
|
239
|
+
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
240
|
+
}
|
|
241
|
+
// cut rejects the change under the cursor, reject_hunk the whole hunk; both
|
|
242
|
+
// write the working copy, so they gate on owner like a plan edit.
|
|
243
|
+
if (action === "cut" || action === "reject_hunk") {
|
|
244
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
245
|
+
if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
|
|
246
|
+
return [{ type: action === "reject_hunk" ? "rejectHunk" : "rejectChange" }];
|
|
247
|
+
}
|
|
248
|
+
// restore un-does a curated-out rejection from the rail; same owner gate as reject
|
|
249
|
+
if (action === "restore_curation") {
|
|
250
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
251
|
+
if (state.canEditPlan === false) return status("only the diff owner can curate hunks");
|
|
252
|
+
return [{ type: "restoreCuration" }];
|
|
253
|
+
}
|
|
254
|
+
const shared = annotationCluster(state, action);
|
|
255
|
+
if (shared) return shared;
|
|
256
|
+
if (action === "span" || action === "edit") {
|
|
257
|
+
return status("plan-only verb - diff review uses c on a line");
|
|
258
|
+
}
|
|
259
|
+
return [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function spanGrammar(state: KeyState, name: string): Intent[] {
|
|
263
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
264
|
+
if (SPAN_KEYS.has(name)) return [{ type: "spanKey", name }];
|
|
265
|
+
// c comments, x cuts, a opens quick-actions - all mutate, so a resolved
|
|
266
|
+
// review is read-only, like the plan grammar
|
|
267
|
+
if (name === "c" || name === "x" || name === "a") {
|
|
268
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
269
|
+
if (name === "c") return [{ type: "openCompose", kind: "comment", from: "span" }];
|
|
270
|
+
if (name === "a") return [{ type: "openSpanActions" }];
|
|
271
|
+
// partial-span cut is not in the working-copy model, so cut removes the
|
|
272
|
+
// whole block the span sits in; owner-only, like plan cut
|
|
273
|
+
if (state.canEditPlan === false) return [];
|
|
274
|
+
return [{ type: "spanCut" }];
|
|
275
|
+
}
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
|
|
280
|
+
const navigation = navigationIntent(action);
|
|
281
|
+
if (navigation) return navigation;
|
|
282
|
+
if (action === "walk") return status("the guided walk is a diff-review mode");
|
|
283
|
+
if (name === "escape") return [{ type: "deselect" }];
|
|
284
|
+
if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
|
|
285
|
+
if (action === "comment") {
|
|
286
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
287
|
+
if (!state.cursorAnnotatable) return status("text is cut - restore it first");
|
|
288
|
+
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
289
|
+
}
|
|
290
|
+
// restore un-does a rail removal (a cut block); a plan edit, so owner-only
|
|
291
|
+
if (action === "restore_curation") {
|
|
292
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
293
|
+
if (state.canEditPlan === false) return [];
|
|
294
|
+
return [{ type: "restoreCuration" }];
|
|
295
|
+
}
|
|
296
|
+
if (action === "cut" || action === "edit") {
|
|
297
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
298
|
+
// the document selects, the rail edits: with a card selected, Cut deletes
|
|
299
|
+
// the annotation and edit rewrites the card body in place
|
|
300
|
+
if (state.hasFocusedAnnotation)
|
|
301
|
+
return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
|
|
302
|
+
// editing the plan itself is the owner's verb; a share viewer only annotates,
|
|
303
|
+
// and has no edit affordance, so the key is silent rather than a nag
|
|
304
|
+
if (state.canEditPlan === false) return [];
|
|
305
|
+
return [{ type: action }];
|
|
306
|
+
}
|
|
307
|
+
return annotationCluster(state, action) ?? [];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Cursor movement is the same intent everywhere; views clamp their own bounds. */
|
|
311
|
+
function navigationIntent(action: string | undefined): Intent[] | null {
|
|
312
|
+
if (action === "down" || action === "up" || action === "top" || action === "bottom") {
|
|
313
|
+
return [{ type: "move", to: action }];
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Annotation navigation, deletion, and submit: one path for plan and diff. */
|
|
319
|
+
function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
|
|
320
|
+
if (action === "next_annotation" || action === "prev_annotation") {
|
|
321
|
+
if (!state.annotationCount) return status("no annotations");
|
|
322
|
+
return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
|
|
323
|
+
}
|
|
324
|
+
if (action === "delete_annotation") {
|
|
325
|
+
if (state.resolved || !state.hasFocusedAnnotation) return [];
|
|
326
|
+
return [{ type: "removeAnnotation" }];
|
|
327
|
+
}
|
|
328
|
+
if (action === "rename") {
|
|
329
|
+
if (!state.hasFocusedAnnotation) return status("select a collaborator's note to rename them");
|
|
330
|
+
return [{ type: "openRename" }];
|
|
331
|
+
}
|
|
332
|
+
if (action === "submit") {
|
|
333
|
+
// a collaborator's notes union back as they go; there is no verdict to submit
|
|
334
|
+
if (state.canSubmitVerdict === false)
|
|
335
|
+
return status("shared view - your notes save as you go; q to leave");
|
|
336
|
+
return state.resolved ? [] : [{ type: "openSubmit" }];
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
REVIEW_DEFAULT_WIDTH,
|
|
4
|
+
REVIEW_MAX_WIDTH,
|
|
5
|
+
REVIEW_MIN_WIDTH,
|
|
6
|
+
REVIEW_PLAN_MIN_WIDTH,
|
|
7
|
+
REVIEW_RESIZE_STEP,
|
|
8
|
+
clampWidth,
|
|
9
|
+
cycleReviewPanelMode,
|
|
10
|
+
maxWidthForTerminal,
|
|
11
|
+
resolveReviewWidth,
|
|
12
|
+
reviewRowsToDivider,
|
|
13
|
+
toggleReviewPanelMode,
|
|
14
|
+
widthFromMouseColumn,
|
|
15
|
+
} from "./review-panel";
|
|
16
|
+
|
|
17
|
+
describe("clampWidth", () => {
|
|
18
|
+
test("keeps a width inside the bounds untouched", () => {
|
|
19
|
+
expect(clampWidth(REVIEW_DEFAULT_WIDTH)).toBe(REVIEW_DEFAULT_WIDTH);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("clamps below the minimum and above the maximum", () => {
|
|
23
|
+
expect(clampWidth(REVIEW_MIN_WIDTH - 10)).toBe(REVIEW_MIN_WIDTH);
|
|
24
|
+
expect(clampWidth(REVIEW_MAX_WIDTH + 10)).toBe(REVIEW_MAX_WIDTH);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rounds a fractional column to a whole one", () => {
|
|
28
|
+
expect(clampWidth(30.7)).toBe(31);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("honors explicit bounds", () => {
|
|
32
|
+
expect(clampWidth(100, 10, 20)).toBe(20);
|
|
33
|
+
expect(clampWidth(5, 10, 20)).toBe(10);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("cycleReviewPanelMode", () => {
|
|
38
|
+
test("cycles expanded -> compact -> hidden -> expanded", () => {
|
|
39
|
+
expect(cycleReviewPanelMode("expanded")).toBe("compact");
|
|
40
|
+
expect(cycleReviewPanelMode("compact")).toBe("hidden");
|
|
41
|
+
expect(cycleReviewPanelMode("hidden")).toBe("expanded");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("toggleReviewPanelMode", () => {
|
|
46
|
+
test("the chevron toggles only between expanded and compact, never hidden", () => {
|
|
47
|
+
expect(toggleReviewPanelMode("expanded")).toBe("compact");
|
|
48
|
+
expect(toggleReviewPanelMode("compact")).toBe("expanded");
|
|
49
|
+
// a hidden panel has no chevron; a stray toggle still opens it
|
|
50
|
+
expect(toggleReviewPanelMode("hidden")).toBe("expanded");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("widthFromMouseColumn", () => {
|
|
55
|
+
test("measures the rail from the mouse column to the right edge", () => {
|
|
56
|
+
// divider dragged to column 80 of a 120-wide terminal -> a 40-col rail
|
|
57
|
+
expect(widthFromMouseColumn(80, 120)).toBe(40);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("clamps the dragged width to the bounds", () => {
|
|
61
|
+
expect(widthFromMouseColumn(0, 120)).toBe(REVIEW_MAX_WIDTH);
|
|
62
|
+
expect(widthFromMouseColumn(119, 120)).toBe(REVIEW_MIN_WIDTH);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("cannot drag the rail wide enough to starve the plan on a narrow terminal", () => {
|
|
66
|
+
// dragging to the far left edge of a 60-col terminal still leaves the plan its minimum
|
|
67
|
+
expect(widthFromMouseColumn(0, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the resize step widens and narrows within the clamp", () => {
|
|
71
|
+
expect(clampWidth(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP)).toBe(
|
|
72
|
+
REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP,
|
|
73
|
+
);
|
|
74
|
+
expect(clampWidth(REVIEW_MIN_WIDTH - REVIEW_RESIZE_STEP)).toBe(REVIEW_MIN_WIDTH);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("maxWidthForTerminal", () => {
|
|
79
|
+
test("allows the full rail on a wide terminal", () => {
|
|
80
|
+
expect(maxWidthForTerminal(120)).toBe(REVIEW_MAX_WIDTH);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("reserves the plan its minimum on a narrow terminal", () => {
|
|
84
|
+
// 60-col terminal: 30 for the plan and 1 for the divider leaves 29 for the rail
|
|
85
|
+
expect(maxWidthForTerminal(60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("never drops below the rail minimum, however narrow the terminal", () => {
|
|
89
|
+
expect(maxWidthForTerminal(40)).toBe(REVIEW_MIN_WIDTH);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("resolveReviewWidth", () => {
|
|
94
|
+
test("leaves a width that already fits untouched", () => {
|
|
95
|
+
expect(resolveReviewWidth(REVIEW_DEFAULT_WIDTH, 120)).toBe(REVIEW_DEFAULT_WIDTH);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("shrinks a wide persisted width so the plan keeps its room", () => {
|
|
99
|
+
// a saved 50-col rail on a 60-col terminal would leave the plan 9 cols; clamp it back
|
|
100
|
+
expect(resolveReviewWidth(REVIEW_MAX_WIDTH, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("reviewRowsToDivider", () => {
|
|
105
|
+
test("builds one vertical glyph per row", () => {
|
|
106
|
+
expect(reviewRowsToDivider(3)).toBe("│\n│\n│");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("never collapses to an empty column", () => {
|
|
110
|
+
expect(reviewRowsToDivider(0)).toBe("│");
|
|
111
|
+
expect(reviewRowsToDivider(-4)).toBe("│");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure geometry and state for the resizable review panel. The panel shows the
|
|
3
|
+
* review rail in one of three modes and the expanded rail resizes within a
|
|
4
|
+
* clamp; App owns the React state and the mouse wiring, this module owns the
|
|
5
|
+
* arithmetic so the layout math stays unit-tested and out of the JSX. Bounds
|
|
6
|
+
* and the compact width carry over verbatim from the approved prototype.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Expanded (full rail), compact (count + dots strip), or hidden (nothing). */
|
|
10
|
+
export type ReviewPanelMode = "expanded" | "compact" | "hidden";
|
|
11
|
+
|
|
12
|
+
/** Expanded-rail width bounds, in terminal columns. */
|
|
13
|
+
export const REVIEW_MIN_WIDTH = 24;
|
|
14
|
+
export const REVIEW_MAX_WIDTH = 50;
|
|
15
|
+
export const REVIEW_DEFAULT_WIDTH = 34;
|
|
16
|
+
/**
|
|
17
|
+
* Columns the plan/diff column keeps for itself: the expanded rail yields past
|
|
18
|
+
* this so a narrow terminal (or a wide persisted width) can never crowd the
|
|
19
|
+
* plan out to nothing - the whole reason the panel is resizable. On terminals
|
|
20
|
+
* wide enough for the full 50-col rail this bound never binds.
|
|
21
|
+
*/
|
|
22
|
+
export const REVIEW_PLAN_MIN_WIDTH = 30;
|
|
23
|
+
/** The compact strip is a fixed narrow column: count on top, one dot per card. */
|
|
24
|
+
export const REVIEW_COMPACT_WIDTH = 6;
|
|
25
|
+
/** Keyboard resize step, in columns, for the widen/narrow keys. */
|
|
26
|
+
export const REVIEW_RESIZE_STEP = 2;
|
|
27
|
+
|
|
28
|
+
/** Round a desired width to a whole column and clamp it to the bounds. */
|
|
29
|
+
export function clampWidth(
|
|
30
|
+
desiredWidth: number,
|
|
31
|
+
min = REVIEW_MIN_WIDTH,
|
|
32
|
+
max = REVIEW_MAX_WIDTH,
|
|
33
|
+
): number {
|
|
34
|
+
return Math.max(min, Math.min(max, Math.round(desiredWidth)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The widest the expanded rail may be on a given terminal, reserving the plan
|
|
39
|
+
* its minimum and one column for the divider. Never drops below REVIEW_MIN_WIDTH
|
|
40
|
+
* (an extremely narrow terminal is the auto-collapse case, handled elsewhere).
|
|
41
|
+
*/
|
|
42
|
+
export function maxWidthForTerminal(terminalWidth: number): number {
|
|
43
|
+
return Math.max(
|
|
44
|
+
REVIEW_MIN_WIDTH,
|
|
45
|
+
Math.min(REVIEW_MAX_WIDTH, terminalWidth - REVIEW_PLAN_MIN_WIDTH - 1),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Clamp a desired rail width to the bounds AND the terminal, so the plan keeps its room. */
|
|
50
|
+
export function resolveReviewWidth(desiredWidth: number, terminalWidth: number): number {
|
|
51
|
+
return clampWidth(desiredWidth, REVIEW_MIN_WIDTH, maxWidthForTerminal(terminalWidth));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The keybinding cycle: expanded -> compact -> hidden -> expanded. */
|
|
55
|
+
export function cycleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
|
|
56
|
+
return mode === "expanded" ? "compact" : mode === "compact" ? "hidden" : "expanded";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The clickable chevron toggles only between expanded and compact - hidden is
|
|
61
|
+
* reachable by keybinding alone, so it never leaves a leftover tab behind. A
|
|
62
|
+
* toggle from hidden still opens the panel rather than doing nothing.
|
|
63
|
+
*/
|
|
64
|
+
export function toggleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
|
|
65
|
+
return mode === "expanded" ? "compact" : "expanded";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Rail width from an absolute mouse column while dragging the divider: the rail
|
|
70
|
+
* spans from the mouse column to the terminal's right edge, then clamps.
|
|
71
|
+
*/
|
|
72
|
+
export function widthFromMouseColumn(mouseColumn: number, terminalWidth: number): number {
|
|
73
|
+
return resolveReviewWidth(terminalWidth - mouseColumn, terminalWidth);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The divider is a single-column stack of vertical glyphs (a text block, not a
|
|
78
|
+
* bordered box - a one-column box with a border renders a full box). One glyph
|
|
79
|
+
* per row, at least one so the column never collapses.
|
|
80
|
+
*/
|
|
81
|
+
export function reviewRowsToDivider(rows: number): string {
|
|
82
|
+
return Array.from({ length: Math.max(1, rows) }, () => "│").join("\n");
|
|
83
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -10,17 +10,21 @@ export interface RunClientOptions {
|
|
|
10
10
|
home?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export async function runClient(
|
|
14
|
-
|
|
13
|
+
export async function runClient(options: RunClientOptions): Promise<number> {
|
|
14
|
+
// mouse movement reporting makes multiplexers forward drags to the app,
|
|
15
|
+
// so the renderer's native selection is the drag driver
|
|
16
|
+
const renderer = await createCliRenderer({ enableMouseMovement: true });
|
|
15
17
|
return new Promise<number>((resolve) => {
|
|
16
18
|
createRoot(renderer).render(
|
|
17
19
|
React.createElement(App, {
|
|
18
|
-
home:
|
|
19
|
-
sessionId:
|
|
20
|
+
home: options.home,
|
|
21
|
+
sessionId: options.sessionId,
|
|
20
22
|
onExit: (code: number) => {
|
|
21
23
|
renderer.destroy();
|
|
22
24
|
resolve(code);
|
|
23
|
-
|
|
25
|
+
// one microtask between destroy and exit lets the renderer flush
|
|
26
|
+
// its terminal-restore sequences before the process dies
|
|
27
|
+
queueMicrotask(() => process.exit(code));
|
|
24
28
|
},
|
|
25
29
|
}),
|
|
26
30
|
);
|
package/src/serve.test.ts
CHANGED
|
@@ -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,16 @@ 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);
|
|
54
59
|
conn
|
|
55
60
|
.on("ready", () => {
|
|
56
61
|
conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
|
|
57
62
|
if (err) return finish(err);
|
|
58
|
-
stream.on("data", (
|
|
59
|
-
out +=
|
|
63
|
+
stream.on("data", (chunk: Buffer) => {
|
|
64
|
+
out += chunk.toString("utf8");
|
|
60
65
|
if (until(out)) finish();
|
|
61
66
|
});
|
|
62
67
|
stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
|
|
@@ -70,6 +75,7 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
|
|
|
70
75
|
|
|
71
76
|
describe("cueloop serve", () => {
|
|
72
77
|
test("listens on localhost, persists a host key under CUELOOP_HOME/ssh", () => {
|
|
78
|
+
// Assert
|
|
73
79
|
expect(handle.host).toBe("127.0.0.1");
|
|
74
80
|
expect(handle.port).toBeGreaterThan(0);
|
|
75
81
|
expect(handle.fingerprints.length).toBe(1);
|
|
@@ -78,18 +84,27 @@ describe("cueloop serve", () => {
|
|
|
78
84
|
});
|
|
79
85
|
|
|
80
86
|
test("an anonymous ssh client gets the observer TUI for the session", async () => {
|
|
81
|
-
|
|
87
|
+
// Act
|
|
88
|
+
const bytes = await sshCapture(
|
|
89
|
+
handle.port,
|
|
90
|
+
(frame) => frame.includes("Rollout Plan") && frame.includes("observer"),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
82
94
|
expect(bytes).toContain("cueloop");
|
|
83
95
|
expect(bytes).toContain("Rollout Plan");
|
|
84
|
-
// observer chrome
|
|
85
|
-
expect(bytes).toContain("observer
|
|
96
|
+
// observer chrome: the header badge marks the read-only session
|
|
97
|
+
expect(bytes).toContain("observer");
|
|
86
98
|
});
|
|
87
99
|
|
|
88
100
|
test("two observers can watch at once", async () => {
|
|
101
|
+
// Act
|
|
89
102
|
const [a, b] = await Promise.all([
|
|
90
|
-
sshCapture(handle.port, (
|
|
91
|
-
sshCapture(handle.port, (
|
|
103
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
104
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
92
105
|
]);
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
93
108
|
expect(a).toContain("Rollout Plan");
|
|
94
109
|
expect(b).toContain("Rollout Plan");
|
|
95
110
|
});
|
package/src/serve.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSH-served TUI
|
|
2
|
+
* SSH-served TUI: `cueloop serve` lets teammates join a
|
|
3
3
|
* review session over plain ssh. Every SSH connection renders <App> in
|
|
4
4
|
* observer mode (readOnly) against the same local daemon; the one writable
|
|
5
5
|
* controller stays the local owner's own `cueloop` TUI.
|
|
@@ -44,8 +44,8 @@ export interface ServeHandle {
|
|
|
44
44
|
close(): Promise<void>;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export async function serveClient(
|
|
48
|
-
const home =
|
|
47
|
+
export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
|
|
48
|
+
const home = options.home ?? cueloopHome();
|
|
49
49
|
const sshDir = join(home, "ssh");
|
|
50
50
|
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
51
51
|
|
|
@@ -53,15 +53,15 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
|
|
|
53
53
|
// password-less by design; see the trust model in the module comment
|
|
54
54
|
auth: "open",
|
|
55
55
|
hostKey: { path: join(sshDir, "host_key") },
|
|
56
|
-
startupBanner:
|
|
56
|
+
startupBanner: options.banner ?? true,
|
|
57
57
|
idleTimeout: "2h",
|
|
58
|
-
onError:
|
|
58
|
+
onError: options.onError,
|
|
59
59
|
}).serve((session) => {
|
|
60
60
|
const root = createRoot(session.renderer);
|
|
61
61
|
root.render(
|
|
62
62
|
React.createElement(App, {
|
|
63
63
|
home,
|
|
64
|
-
sessionId:
|
|
64
|
+
sessionId: options.sessionId,
|
|
65
65
|
readOnly: true,
|
|
66
66
|
// q disconnects only this observer, never the server
|
|
67
67
|
onExit: () => session.end(),
|
|
@@ -70,6 +70,6 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
|
|
|
70
70
|
session.onClose(() => root.unmount());
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
const info = await server.listen(
|
|
73
|
+
const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
|
|
74
74
|
return { ...info, close: () => server.close() };
|
|
75
75
|
}
|