@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +109 -0
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -13
  5. package/src/App.plan-review.test.tsx +232 -0
  6. package/src/App.readonly.test.tsx +6 -21
  7. package/src/App.rendering.test.tsx +100 -0
  8. package/src/App.submit-confirm.test.tsx +165 -0
  9. package/src/App.test.tsx +20 -58
  10. package/src/App.tsx +0 -0
  11. package/src/App.walk.test.tsx +208 -0
  12. package/src/components/AnnotationCard.stories.tsx +75 -0
  13. package/src/components/AnnotationCard.tsx +136 -0
  14. package/src/components/Breadcrumb.stories.tsx +19 -0
  15. package/src/components/Breadcrumb.tsx +38 -0
  16. package/src/components/CodeBlock.stories.tsx +25 -0
  17. package/src/components/CodeBlock.tsx +68 -0
  18. package/src/components/CompletionOverlay.stories.tsx +23 -0
  19. package/src/components/CompletionOverlay.tsx +49 -0
  20. package/src/components/ComposeBar.stories.tsx +18 -0
  21. package/src/components/ComposeBar.tsx +54 -0
  22. package/src/components/ConfirmCard.stories.tsx +37 -0
  23. package/src/components/ConfirmCard.tsx +117 -0
  24. package/src/components/DiffSheet.stories.tsx +44 -0
  25. package/src/components/DiffSheet.tsx +243 -0
  26. package/src/components/InboxList.stories.tsx +18 -0
  27. package/src/components/InboxList.tsx +42 -0
  28. package/src/components/PlanSheet.stories.tsx +81 -0
  29. package/src/components/PlanSheet.tsx +309 -0
  30. package/src/components/ReviewRail.stories.tsx +103 -0
  31. package/src/components/ReviewRail.tsx +158 -0
  32. package/src/components/SettingsDialog.stories.tsx +63 -0
  33. package/src/components/SettingsDialog.tsx +132 -0
  34. package/src/components/SettingsRows.stories.tsx +18 -0
  35. package/src/components/SettingsRows.tsx +92 -0
  36. package/src/components/WalkWizard.stories.tsx +89 -0
  37. package/src/components/WalkWizard.tsx +146 -0
  38. package/src/components/__snapshots__/stories.test.tsx.snap +1319 -0
  39. package/src/components/primitives/Button.stories.tsx +32 -0
  40. package/src/components/primitives/Button.tsx +44 -0
  41. package/src/components/primitives/Card.stories.tsx +25 -0
  42. package/src/components/primitives/Card.tsx +58 -0
  43. package/src/components/primitives/Dialog.stories.tsx +24 -0
  44. package/src/components/primitives/Dialog.tsx +52 -0
  45. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  46. package/src/components/primitives/StatusBar.tsx +22 -0
  47. package/src/components/primitives/Tabs.stories.tsx +29 -0
  48. package/src/components/primitives/Tabs.tsx +97 -0
  49. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  50. package/src/components/primitives/Toolbar.tsx +14 -0
  51. package/src/components/stories-app.tsx +94 -0
  52. package/src/components/stories.test.tsx +51 -0
  53. package/src/components/story-fixtures.ts +90 -0
  54. package/src/components/story.ts +66 -0
  55. package/src/components/syntax-highlight.ts +57 -0
  56. package/src/components/theme-context.stories.tsx +41 -0
  57. package/src/components/theme-context.tsx +30 -0
  58. package/src/components/truncate-text.ts +6 -0
  59. package/src/config.test.ts +54 -29
  60. package/src/config.ts +53 -13
  61. package/src/editor.test.ts +110 -0
  62. package/src/editor.ts +101 -13
  63. package/src/herdr.test.ts +37 -0
  64. package/src/herdr.ts +21 -0
  65. package/src/integrations.test.ts +29 -0
  66. package/src/integrations.ts +28 -0
  67. package/src/key-bindings.test.ts +120 -0
  68. package/src/key-bindings.ts +353 -0
  69. package/src/keymap.test.ts +307 -0
  70. package/src/keymap.ts +204 -0
  71. package/src/run.ts +9 -5
  72. package/src/serve.test.ts +6 -6
  73. package/src/serve.ts +7 -7
  74. package/src/session-controller.ts +496 -0
  75. package/src/test-support.ts +87 -0
  76. package/src/theme.ts +23 -1
  77. package/src/view-diff.test.ts +8 -8
  78. package/src/view-diff.ts +22 -22
  79. package/src/view-plan.test.ts +179 -0
  80. package/src/view-plan.ts +362 -0
  81. package/src/walk.test.ts +110 -0
  82. package/src/walk.ts +65 -0
  83. package/src/view.test.ts +0 -157
  84. package/src/view.ts +0 -0
package/src/keymap.ts ADDED
@@ -0,0 +1,204 @@
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: "startSpan" }
18
+ | { type: "spanKey"; name: string }
19
+ | { type: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
20
+ | { type: "openSubmit" }
21
+ | { type: "cut" }
22
+ | { type: "edit" }
23
+ | { type: "editCard" }
24
+ | { type: "nextAnnotation" }
25
+ | { type: "prevAnnotation" }
26
+ | { type: "walkStart" }
27
+ | { type: "walkForward" }
28
+ | { type: "walkBack" }
29
+ | { type: "walkLeave" }
30
+ | { type: "removeAnnotation" }
31
+ | { type: "deselect" }
32
+ | { type: "closeOverlay" }
33
+ | { type: "saveCompose" }
34
+ | { type: "submitVerdict" }
35
+ | { type: "cycleVerdict"; dir: -1 | 1 }
36
+ | { type: "finishReview" }
37
+ | { type: "optInAutoClose" }
38
+ | { type: "dismissCompletion" };
39
+
40
+ export interface KeyInput {
41
+ name: string;
42
+ shift: boolean;
43
+ }
44
+
45
+ export interface KeyState {
46
+ /** Loaded keymap (config.ts): action -> key combos. */
47
+ keys: Record<string, string[]>;
48
+ readOnly: boolean;
49
+ /** Layer that owns keys before the grammar runs. */
50
+ overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
51
+ view: "inbox" | "plan" | "diff";
52
+ /** Plan-only span selection sub-mode. */
53
+ spanMode: boolean;
54
+ /** The walk cursor sits on the end card - return offers the submit action. */
55
+ walkAtEnd: boolean;
56
+ resolved: boolean;
57
+ hasInboxItems: boolean;
58
+ annotationCount: number;
59
+ hasFocusedAnnotation: boolean;
60
+ /** Cursor sits on annotatable text: a work block (plan) or a code row (diff). */
61
+ cursorAnnotatable: boolean;
62
+ }
63
+
64
+ /** Verbs that write session state; an observer never reaches their handlers. */
65
+ const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk"]);
66
+
67
+ const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
68
+
69
+ function status(message: string): Intent[] {
70
+ return [{ type: "status", message }];
71
+ }
72
+
73
+ /**
74
+ * Reduce one key event to intents. `resolvedAction` is the binding-layer
75
+ * resolution (key-bindings.ts over @opentui/keymap) when the caller has one;
76
+ * without it the reducer falls back to the plain reverse lookup, so the
77
+ * grammar stays testable as a pure function.
78
+ */
79
+ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
80
+ const name = key.name;
81
+ // compose/submit overlays own the keys via focused inputs; only escape,
82
+ // return, and verdict arrows route through the grammar
83
+ if (state.overlay === "compose" || state.overlay === "submit") {
84
+ if (name === "escape") return [{ type: "closeOverlay" }];
85
+ if (name === "return" || name === "enter") {
86
+ return [state.overlay === "compose" ? { type: "saveCompose" } : { type: "submitVerdict" }];
87
+ }
88
+ if (state.overlay === "submit" && (name === "left" || name === "right")) {
89
+ return [{ type: "cycleVerdict", dir: name === "left" ? -1 : 1 }];
90
+ }
91
+ return [];
92
+ }
93
+ if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
94
+ if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
95
+ if (name === "a") return [{ type: "optInAutoClose" }];
96
+ if (name === "escape") return [{ type: "dismissCompletion" }];
97
+ return [];
98
+ }
99
+ // the walk wizard owns its keys while active: ] advances (marking the
100
+ // current file viewed), [ steps back, escape leaves keeping progress, and
101
+ // return on the end card hands over to the submit confirm
102
+ if (state.overlay === "walk") {
103
+ if (name === "]") return [{ type: "walkForward" }];
104
+ if (name === "[") return [{ type: "walkBack" }];
105
+ if (name === "escape") return [{ type: "walkLeave" }];
106
+ if ((name === "return" || name === "enter") && state.walkAtEnd) {
107
+ return [{ type: "walkLeave" }, { type: "openSubmit" }];
108
+ }
109
+ if (name === "q") return [{ type: "exit" }];
110
+ return [];
111
+ }
112
+ const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
113
+ if (action === "quit") return [{ type: "exit" }];
114
+ // the ONE read-only rule: any mutating attempt answers instead of acting
115
+ // (span-mode c/s are hardwired keys, so they gate by name as well)
116
+ const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
117
+ if (state.readOnly && mutating) return status("observer - read-only");
118
+
119
+ if (state.view === "inbox") return inboxGrammar(state, name);
120
+ if (state.view === "diff") return diffGrammar(state, action);
121
+ if (state.spanMode) return spanGrammar(name);
122
+ return planGrammar(state, action, name);
123
+ }
124
+
125
+ function inboxGrammar(state: KeyState, name: string): Intent[] {
126
+ if (!state.hasInboxItems) return [];
127
+ if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
128
+ if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
129
+ if (name === "return" || name === "enter") return [{ type: "openSession" }];
130
+ return [];
131
+ }
132
+
133
+ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
134
+ const navigation = navigationIntent(action);
135
+ if (navigation) return navigation;
136
+ if (action === "walk") {
137
+ // marking viewed writes the session record, so a resolved review answers
138
+ if (state.resolved) return status("review submitted - read-only");
139
+ return [{ type: "walkStart" }];
140
+ }
141
+ if (action === "comment") {
142
+ if (state.resolved) return status("review submitted - read-only");
143
+ if (!state.cursorAnnotatable) return status("move to a code line to comment");
144
+ return [{ type: "openCompose", kind: "comment", from: "cursor" }];
145
+ }
146
+ const shared = annotationCluster(state, action);
147
+ if (shared) return shared;
148
+ if (action === "span" || action === "cut" || action === "edit" || action === "suggest") {
149
+ return status("plan-only verb - diff review uses c on a line");
150
+ }
151
+ return [];
152
+ }
153
+
154
+ function spanGrammar(name: string): Intent[] {
155
+ if (name === "escape") return [{ type: "closeOverlay" }];
156
+ if (SPAN_KEYS.includes(name)) return [{ type: "spanKey", name }];
157
+ if (name === "c" || name === "s") {
158
+ return [{ type: "openCompose", kind: name === "s" ? "suggestion" : "comment", from: "span" }];
159
+ }
160
+ return [];
161
+ }
162
+
163
+ function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
164
+ const navigation = navigationIntent(action);
165
+ if (navigation) return navigation;
166
+ if (action === "walk") return status("the guided walk is a diff-review mode");
167
+ if (name === "escape") return [{ type: "deselect" }];
168
+ if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
169
+ if (action === "comment" || action === "suggest") {
170
+ if (state.resolved) return status("review submitted - read-only");
171
+ if (!state.cursorAnnotatable) return status("text is cut - restore it first");
172
+ return [{ type: "openCompose", kind: action === "suggest" ? "suggestion" : "comment", from: "cursor" }];
173
+ }
174
+ if (action === "cut" || action === "edit") {
175
+ if (state.resolved) return status("review submitted - read-only");
176
+ // the document selects, the rail edits: with a card selected, Cut deletes
177
+ // the annotation and edit rewrites the card body in place
178
+ if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
179
+ return [{ type: action }];
180
+ }
181
+ return annotationCluster(state, action) ?? [];
182
+ }
183
+
184
+ /** Cursor movement is the same intent everywhere; views clamp their own bounds. */
185
+ function navigationIntent(action: string | undefined): Intent[] | null {
186
+ if (action === "down" || action === "up" || action === "top" || action === "bottom") {
187
+ return [{ type: "move", to: action }];
188
+ }
189
+ return null;
190
+ }
191
+
192
+ /** Annotation navigation, deletion, and submit: one path for plan and diff. */
193
+ function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
194
+ if (action === "next_annotation" || action === "prev_annotation") {
195
+ if (!state.annotationCount) return status("no annotations");
196
+ return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
197
+ }
198
+ if (action === "delete_annotation") {
199
+ if (state.resolved || !state.hasFocusedAnnotation) return [];
200
+ return [{ type: "removeAnnotation" }];
201
+ }
202
+ if (action === "submit") return state.resolved ? [] : [{ type: "openSubmit" }];
203
+ return null;
204
+ }
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(opts: RunClientOptions): Promise<number> {
14
- const renderer = await createCliRenderer();
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: opts.home,
19
- sessionId: opts.sessionId,
20
+ home: options.home,
21
+ sessionId: options.sessionId,
20
22
  onExit: (code: number) => {
21
23
  renderer.destroy();
22
24
  resolve(code);
23
- process.exit(code);
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,7 @@ afterEach(async () => {
38
38
  });
39
39
 
40
40
  /** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
41
- function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 8000): Promise<string> {
41
+ function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs = 8000): Promise<string> {
42
42
  return new Promise((resolve, reject) => {
43
43
  const conn = new Client();
44
44
  let out = "";
@@ -55,8 +55,8 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
55
55
  .on("ready", () => {
56
56
  conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
57
57
  if (err) return finish(err);
58
- stream.on("data", (d: Buffer) => {
59
- out += d.toString("utf8");
58
+ stream.on("data", (chunk: Buffer) => {
59
+ out += chunk.toString("utf8");
60
60
  if (until(out)) finish();
61
61
  });
62
62
  stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
@@ -78,7 +78,7 @@ describe("cueloop serve", () => {
78
78
  });
79
79
 
80
80
  test("an anonymous ssh client gets the observer TUI for the session", async () => {
81
- const bytes = await sshCapture(handle.port, (s) => s.includes("Rollout Plan") && s.includes("observer"));
81
+ const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan") && frame.includes("observer"));
82
82
  expect(bytes).toContain("cueloop");
83
83
  expect(bytes).toContain("Rollout Plan");
84
84
  // observer chrome, not the controller's mutating hint bar
@@ -87,8 +87,8 @@ describe("cueloop serve", () => {
87
87
 
88
88
  test("two observers can watch at once", async () => {
89
89
  const [a, b] = await Promise.all([
90
- sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
91
- sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
90
+ sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
91
+ sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
92
92
  ]);
93
93
  expect(a).toContain("Rollout Plan");
94
94
  expect(b).toContain("Rollout Plan");
package/src/serve.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SSH-served TUI (#22 share path): `cueloop serve` lets teammates join a
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(opts: ServeOptions = {}): Promise<ServeHandle> {
48
- const home = opts.home ?? cueloopHome();
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: opts.banner ?? true,
56
+ startupBanner: options.banner ?? true,
57
57
  idleTimeout: "2h",
58
- onError: opts.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: opts.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(opts.port ?? 2222, opts.host ?? "127.0.0.1");
73
+ const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
74
74
  return { ...info, close: () => server.close() };
75
75
  }