@cueloop/client 0.1.0-alpha.73 → 0.1.0-alpha.74

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 (40) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +35 -170
  3. package/src/App.workbench.test.tsx +103 -2
  4. package/src/app-screens.tsx +84 -10
  5. package/src/components/AppShell.brand.test.tsx +58 -0
  6. package/src/components/AppShell.tsx +32 -6
  7. package/src/components/BareWorkbenchFileView.tsx +1 -1
  8. package/src/components/ConfirmCard.tsx +0 -1
  9. package/src/components/GridTabContent.stories.tsx +59 -0
  10. package/src/components/GridTabContent.tsx +165 -0
  11. package/src/components/SettingsDialog.tsx +4 -13
  12. package/src/components/ThreadTree.tsx +6 -18
  13. package/src/components/__snapshots__/stories.test.tsx.snap +112 -68
  14. package/src/components/primitives/Card.tsx +1 -1
  15. package/src/components/primitives/frame.test.ts +5 -5
  16. package/src/components/primitives/frame.ts +5 -7
  17. package/src/components/quick-actions-editor.stories.tsx +2 -0
  18. package/src/components/quick-actions-editor.test.tsx +36 -2
  19. package/src/components/quick-actions-editor.tsx +104 -29
  20. package/src/components/truncate-title.test.ts +31 -0
  21. package/src/components/truncate-title.ts +15 -0
  22. package/src/config.test.ts +36 -0
  23. package/src/config.ts +38 -8
  24. package/src/index.ts +2 -0
  25. package/src/intent-dispatch.test.ts +1 -0
  26. package/src/launch-layout.test.ts +52 -0
  27. package/src/launch-layout.ts +42 -0
  28. package/src/run.ts +10 -0
  29. package/src/serve.ts +31 -0
  30. package/src/thread-controller.live-diff.test.ts +119 -0
  31. package/src/thread-controller.ts +88 -27
  32. package/src/use-changes-workbench.ts +41 -4
  33. package/src/use-remember-layout.ts +31 -0
  34. package/src/use-settings-dialog.tsx +11 -2
  35. package/src/view-diff.test.ts +73 -2
  36. package/src/view-diff.ts +38 -0
  37. package/src/workbench-snapshot.test.ts +81 -0
  38. package/src/workbench-snapshot.ts +29 -0
  39. package/src/components/fade-title.test.ts +0 -49
  40. package/src/components/fade-title.ts +0 -61
@@ -6,7 +6,7 @@
6
6
  * the app owns the actions, the selection, and which row is expanded.
7
7
  */
8
8
 
9
- import React, { useEffect, useRef } from "react";
9
+ import React, { useEffect, useRef, useState } from "react";
10
10
  import type { KeyBinding, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core";
11
11
  import type { QuickAction } from "../config";
12
12
  import type { Theme } from "../theme";
@@ -22,8 +22,9 @@ export interface QuickActionsEditorProps {
22
22
  selectedIndex: number;
23
23
  /** The row whose system-prompt input is open and focused, or null. */
24
24
  expandedIndex: number | null;
25
- /** Select and toggle a row's system-prompt input open/closed. */
25
+ /** Select and toggle a row's editor open/closed. */
26
26
  onToggleExpand: (index: number) => void;
27
+ onEditPrompt: (index: number, prompt: string) => void;
27
28
  onEditMetadata: (index: number, metadata: string) => void;
28
29
  onReset: () => void;
29
30
  onAdd: () => void;
@@ -35,6 +36,7 @@ export function QuickActionsEditor({
35
36
  selectedIndex,
36
37
  expandedIndex,
37
38
  onToggleExpand,
39
+ onEditPrompt,
38
40
  onEditMetadata,
39
41
  onReset,
40
42
  onAdd,
@@ -67,6 +69,7 @@ export function QuickActionsEditor({
67
69
  isSelected={index === selectedIndex}
68
70
  isExpanded={index === expandedIndex}
69
71
  onToggleExpand={() => onToggleExpand(index)}
72
+ onEditPrompt={(prompt) => onEditPrompt(index, prompt)}
70
73
  onEditMetadata={(metadata) => onEditMetadata(index, metadata)}
71
74
  theme={theme}
72
75
  />
@@ -89,6 +92,7 @@ function ActionRow({
89
92
  isSelected,
90
93
  isExpanded,
91
94
  onToggleExpand,
95
+ onEditPrompt,
92
96
  onEditMetadata,
93
97
  theme,
94
98
  }: {
@@ -97,11 +101,26 @@ function ActionRow({
97
101
  isSelected: boolean;
98
102
  isExpanded: boolean;
99
103
  onToggleExpand: () => void;
104
+ onEditPrompt: (prompt: string) => void;
100
105
  onEditMetadata: (metadata: string) => void;
101
106
  theme?: Theme;
102
107
  }): React.ReactNode {
103
108
  const tokens = useComponentTheme(theme);
104
- const inputRef = useRef<TextareaRenderable | null>(null);
109
+
110
+ if (isExpanded) {
111
+ return (
112
+ <box id={rowId} style={{ flexDirection: "column" }}>
113
+ <ActionEditor
114
+ action={action}
115
+ isSelected={isSelected}
116
+ onEditPrompt={onEditPrompt}
117
+ onEditMetadata={onEditMetadata}
118
+ onDone={onToggleExpand}
119
+ tokens={tokens}
120
+ />
121
+ </box>
122
+ );
123
+ }
105
124
 
106
125
  return (
107
126
  <box id={rowId} style={{ flexDirection: "column" }}>
@@ -109,33 +128,9 @@ function ActionRow({
109
128
  style={{ backgroundColor: isSelected ? tokens.border : undefined }}
110
129
  onMouseUp={onToggleExpand}
111
130
  >
112
- <text fg={isSelected ? tokens.text : tokens.textMuted}>
113
- {`${isExpanded ? "▾ " : "▸ "}${action.prompt}`}
114
- </text>
131
+ <text fg={isSelected ? tokens.text : tokens.textMuted}>{`▸ ${action.prompt}`}</text>
115
132
  </box>
116
- {isExpanded ? (
117
- <box style={{ flexDirection: "row", paddingLeft: 2 }}>
118
- <box style={{ width: 2, flexShrink: 0 }}>
119
- <text fg={tokens.textDim}>{">"}</text>
120
- </box>
121
- <textarea
122
- ref={inputRef}
123
- focused
124
- initialValue={action.metadata ?? ""}
125
- placeholder="extra system prompt appended to this comment (optional)"
126
- keyBindings={ACTION_INPUT_KEY_BINDINGS}
127
- onContentChange={() => onEditMetadata(inputRef.current?.plainText ?? "")}
128
- style={{
129
- height: 1,
130
- flexGrow: 1,
131
- backgroundColor: tokens.elevated,
132
- focusedBackgroundColor: tokens.elevated,
133
- textColor: tokens.text,
134
- focusedTextColor: tokens.text,
135
- }}
136
- />
137
- </box>
138
- ) : action.metadata ? (
133
+ {action.metadata ? (
139
134
  <box style={{ paddingLeft: 2 }}>
140
135
  <text fg={tokens.textDim}>{truncateMetadata(action.metadata)}</text>
141
136
  </box>
@@ -144,6 +139,86 @@ function ActionRow({
144
139
  );
145
140
  }
146
141
 
142
+ /**
143
+ * The two-field editor for an expanded row: the action title over its system
144
+ * prompt. Mounts fresh per expand, so focus starts on the title without a reset
145
+ * effect; ⏎ steps title -> description -> close, and a click focuses either.
146
+ */
147
+ function ActionEditor({
148
+ action,
149
+ isSelected,
150
+ onEditPrompt,
151
+ onEditMetadata,
152
+ onDone,
153
+ tokens,
154
+ }: {
155
+ action: QuickAction;
156
+ isSelected: boolean;
157
+ onEditPrompt: (prompt: string) => void;
158
+ onEditMetadata: (metadata: string) => void;
159
+ onDone: () => void;
160
+ tokens: Theme;
161
+ }): React.ReactNode {
162
+ const promptRef = useRef<TextareaRenderable | null>(null);
163
+ const metadataRef = useRef<TextareaRenderable | null>(null);
164
+ const [activeField, setActiveField] = useState<"prompt" | "metadata">("prompt");
165
+
166
+ // the focused field types from its end, not from the caret parked at the start
167
+ useEffect(() => {
168
+ const editor = activeField === "prompt" ? promptRef.current : metadataRef.current;
169
+
170
+ if (editor) editor.cursorOffset = editor.plainText.length;
171
+ }, [activeField]);
172
+
173
+ const fieldStyle = {
174
+ height: 1,
175
+ flexGrow: 1,
176
+ backgroundColor: tokens.elevated,
177
+ focusedBackgroundColor: tokens.elevated,
178
+ textColor: tokens.text,
179
+ focusedTextColor: tokens.text,
180
+ } as const;
181
+
182
+ return (
183
+ <>
184
+ <box
185
+ style={{ flexDirection: "row", backgroundColor: isSelected ? tokens.border : undefined }}
186
+ >
187
+ <box style={{ width: 2, flexShrink: 0 }}>
188
+ <text fg={isSelected ? tokens.text : tokens.textMuted}>{"▾ "}</text>
189
+ </box>
190
+ <textarea
191
+ ref={promptRef}
192
+ focused={activeField === "prompt"}
193
+ initialValue={action.prompt}
194
+ placeholder="action title"
195
+ keyBindings={ACTION_INPUT_KEY_BINDINGS}
196
+ onMouseUp={() => setActiveField("prompt")}
197
+ onSubmit={() => setActiveField("metadata")}
198
+ onContentChange={() => onEditPrompt(promptRef.current?.plainText ?? "")}
199
+ style={fieldStyle}
200
+ />
201
+ </box>
202
+ <box style={{ flexDirection: "row", paddingLeft: 2 }}>
203
+ <box style={{ width: 2, flexShrink: 0 }}>
204
+ <text fg={tokens.textDim}>{">"}</text>
205
+ </box>
206
+ <textarea
207
+ ref={metadataRef}
208
+ focused={activeField === "metadata"}
209
+ initialValue={action.metadata ?? ""}
210
+ placeholder="extra system prompt appended to this comment (optional)"
211
+ keyBindings={ACTION_INPUT_KEY_BINDINGS}
212
+ onMouseUp={() => setActiveField("metadata")}
213
+ onSubmit={onDone}
214
+ onContentChange={() => onEditMetadata(metadataRef.current?.plainText ?? "")}
215
+ style={fieldStyle}
216
+ />
217
+ </box>
218
+ </>
219
+ );
220
+ }
221
+
147
222
  /** A one-line preview of the system prompt under a collapsed row. */
148
223
  function truncateMetadata(metadata: string): string {
149
224
  return metadata.length > 44 ? `${metadata.slice(0, 43)}…` : metadata;
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { truncateTitle } from "./truncate-title";
3
+
4
+ describe("truncateTitle", () => {
5
+ test("a title that fits is returned unchanged", () => {
6
+ expect(truncateTitle("short", 20)).toBe("short");
7
+ });
8
+
9
+ test("a long title is clipped to the width with a trailing ellipsis", () => {
10
+ const clipped = truncateTitle("Review the accent change in the theme", 20);
11
+
12
+ expect(clipped).toHaveLength(20);
13
+ expect(clipped.endsWith("…")).toBe(true);
14
+ expect(clipped).toBe("Review the accent c…");
15
+ });
16
+
17
+ test("newlines collapse to spaces so the row never wraps", () => {
18
+ expect(truncateTitle("line one\nline two", 40)).toBe("line one line two");
19
+ });
20
+
21
+ test("a zero width yields an empty string", () => {
22
+ expect(truncateTitle("anything", 0)).toBe("");
23
+ });
24
+
25
+ test("clips by code point, never splitting an astral character into a replacement glyph", () => {
26
+ const clipped = truncateTitle("🚀🚀🚀🚀🚀", 3);
27
+
28
+ expect(clipped).toBe("🚀🚀…");
29
+ expect(clipped).not.toContain("�");
30
+ });
31
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Fits a thread title on one line: unchanged when it fits, otherwise clipped to
3
+ * an ellipsis. Slices by code point, not UTF-16 unit, so an emoji or other
4
+ * astral character is never split into a replacement glyph.
5
+ */
6
+ export function truncateTitle(title: string, maxWidth: number): string {
7
+ const oneLine = title.replace(/\n/g, " ");
8
+
9
+ if (maxWidth <= 0) return "";
10
+ const characters = [...oneLine];
11
+
12
+ if (characters.length <= maxWidth) return oneLine;
13
+
14
+ return `${characters.slice(0, Math.max(0, maxWidth - 1)).join("")}…`;
15
+ }
@@ -9,6 +9,7 @@ import {
9
9
  loadConfig,
10
10
  persistAuthorName,
11
11
  persistActions,
12
+ persistLayout,
12
13
  persistPins,
13
14
  persistTheme,
14
15
  quickActionBody,
@@ -317,6 +318,41 @@ describe("loadConfig", () => {
317
318
  rmSync(dir, { recursive: true, force: true });
318
319
  }
319
320
  });
321
+
322
+ test("persistLayout round-trips the remembered pane composition through the config file", () => {
323
+ // Arrange
324
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-layout-"));
325
+ const path = join(dir, "config.toml");
326
+
327
+ try {
328
+ // Act
329
+ persistLayout({ threads: false, rightSidebar: "project", zoomChanges: false }, path);
330
+
331
+ // Assert
332
+ expect(loadConfig({ userConfigPath: path }).ui.layout).toEqual({
333
+ threads: false,
334
+ rightSidebar: "project",
335
+ zoomChanges: false,
336
+ });
337
+
338
+ // Act
339
+ persistLayout({ threads: true, rightSidebar: "off", zoomChanges: true }, path);
340
+
341
+ // Assert
342
+ expect(loadConfig({ userConfigPath: path }).ui.layout).toEqual({
343
+ threads: true,
344
+ rightSidebar: "off",
345
+ zoomChanges: true,
346
+ });
347
+ } finally {
348
+ rmSync(dir, { recursive: true, force: true });
349
+ }
350
+ });
351
+
352
+ test("layout is unset until the user changes one", () => {
353
+ // Assert
354
+ expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.layout).toBeUndefined();
355
+ });
320
356
  });
321
357
 
322
358
  describe("integrations.obsidian config", () => {
package/src/config.ts CHANGED
@@ -14,6 +14,7 @@ import { dirname } from "node:path";
14
14
  import * as v from "valibot";
15
15
  import { DARK, type Theme } from "./theme";
16
16
  import { DEFAULT_THEME_NAME, isThemeName, themeForName, type ThemeName } from "./theme-presets";
17
+ import type { LaunchLayout } from "./launch-layout";
17
18
 
18
19
  export interface KeymapConfig {
19
20
  [action: string]: string | string[];
@@ -108,6 +109,8 @@ export interface CueloopConfig {
108
109
  diffView: DiffViewMode;
109
110
  /** Session ids the user has pinned to the top of the sidebar; client-local view state. */
110
111
  pins: string[];
112
+ /** The last pane layout a bare launch restores; unset until the user changes one. */
113
+ layout?: LaunchLayout;
111
114
  };
112
115
  /** Planner-local author renames: identity id → display name ([authors] table). */
113
116
  authors: Record<string, string>;
@@ -192,6 +195,16 @@ const UiSchema = v.object({
192
195
  theme: v.fallback(v.optional(v.string()), undefined),
193
196
  diff_view: v.fallback(v.optional(v.picklist(["split", "stacked", "unified"])), undefined),
194
197
  pins: v.fallback(v.optional(v.array(v.string())), undefined),
198
+ layout: v.fallback(
199
+ v.optional(
200
+ v.object({
201
+ threads: v.fallback(v.optional(v.boolean()), undefined),
202
+ right_sidebar: v.fallback(v.optional(v.picklist(["changes", "project", "off"])), undefined),
203
+ zoom_changes: v.fallback(v.optional(v.boolean()), undefined),
204
+ }),
205
+ ),
206
+ undefined,
207
+ ),
195
208
  });
196
209
  const ObsidianSchema = v.object({
197
210
  vault: v.fallback(v.optional(v.string()), undefined),
@@ -241,6 +254,23 @@ function mergeObsidian(
241
254
  if (obsidian.exportOn !== undefined) target.exportOn = obsidian.exportOn;
242
255
  }
243
256
 
257
+ /** Fold a parsed `[ui]` table onto the accumulated ui config, field by present field. */
258
+ function applyUi(ui: CueloopConfig["ui"], parsed: v.InferOutput<typeof UiSchema>): void {
259
+ if (parsed.auto_close !== undefined) ui.autoClose = parsed.auto_close;
260
+ if (parsed.editor?.trim()) ui.editor = parsed.editor.trim();
261
+ // "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
262
+ if (parsed.diff_view !== undefined)
263
+ ui.diffView = parsed.diff_view === "unified" ? "stacked" : parsed.diff_view;
264
+ if (parsed.pins !== undefined) ui.pins = parsed.pins;
265
+ if (parsed.layout !== undefined) {
266
+ ui.layout = {
267
+ threads: parsed.layout.threads ?? true,
268
+ rightSidebar: parsed.layout.right_sidebar ?? "changes",
269
+ zoomChanges: parsed.layout.zoom_changes ?? false,
270
+ };
271
+ }
272
+ }
273
+
244
274
  function layer(
245
275
  base: CueloopConfig,
246
276
  raw: v.InferOutput<typeof ConfigDocumentSchema>,
@@ -280,14 +310,7 @@ function layer(
280
310
  out.keys[action] = Array.isArray(combo.output) ? combo.output : [combo.output];
281
311
  }
282
312
  }
283
- if (ui.success) {
284
- if (ui.output.auto_close !== undefined) out.ui.autoClose = ui.output.auto_close;
285
- if (ui.output.editor?.trim()) out.ui.editor = ui.output.editor.trim();
286
- // "unified" is the pre-rename spelling of "stacked"; keep loading it so an upgrade never flips the layout
287
- if (ui.output.diff_view !== undefined)
288
- out.ui.diffView = ui.output.diff_view === "unified" ? "stacked" : ui.output.diff_view;
289
- if (ui.output.pins !== undefined) out.ui.pins = ui.output.pins;
290
- }
313
+ if (ui.success) applyUi(out.ui, ui.output);
291
314
  if (integrations.success && integrations.output.obsidian) {
292
315
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
293
316
  }
@@ -455,6 +478,13 @@ export function persistPins(ids: readonly string[], userConfigPath?: string): vo
455
478
  persistUiSetting("pins", `[${ids.map(tomlString).join(", ")}]`, userConfigPath);
456
479
  }
457
480
 
481
+ /** Persist the last pane layout (`[ui] layout`) so a bare launch restores it. */
482
+ export function persistLayout(layout: LaunchLayout, userConfigPath?: string): void {
483
+ const table = `{ threads = ${layout.threads}, right_sidebar = "${layout.rightSidebar}", zoom_changes = ${layout.zoomChanges} }`;
484
+
485
+ persistUiSetting("layout", table, userConfigPath);
486
+ }
487
+
458
488
  function escapeRegExp(text: string): string {
459
489
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
460
490
  }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { runClient, type RunClientOptions } from "./run";
2
+ export { snapshotWorkbench, type RepoDiff } from "./workbench-snapshot";
3
+ export { defaultLayout, planLayout, reviewLayout, type LaunchLayout } from "./launch-layout";
2
4
  export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
3
5
  export { App, type AppProps } from "./App";
4
6
  export {
@@ -96,6 +96,7 @@ function baseController(): ReviewController {
96
96
  annotate: mock(() => undefined),
97
97
  addComment: mock(() => undefined),
98
98
  commentOnWorkbench: mock(() => Promise.resolve()),
99
+ commentOnWorkbenchDiff: mock(() => Promise.resolve()),
99
100
  reply: mock(() => undefined),
100
101
  annotatePrototype: mock(() => undefined),
101
102
  updateAnnotation: mock(),
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defaultLayout, layoutFromPanes, planLayout, reviewLayout } from "./launch-layout";
3
+
4
+ describe("launch layout factories", () => {
5
+ test("review opens the diff zoomed with the Changes panel", () => {
6
+ // Assert
7
+ expect(reviewLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
8
+ });
9
+
10
+ test("plan fills the middle with the thread pane and no right region", () => {
11
+ // Assert
12
+ expect(planLayout()).toEqual({ threads: true, rightSidebar: "off", zoomChanges: false });
13
+ });
14
+
15
+ test("the first-run default lands on the inbox with the diff zoomed", () => {
16
+ // Assert
17
+ expect(defaultLayout()).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
18
+ });
19
+ });
20
+
21
+ describe("layoutFromPanes", () => {
22
+ test("the Changes editor wins the right region when open", () => {
23
+ // Assert
24
+ expect(
25
+ layoutFromPanes({ threads: true, changesOpen: true, projectOpen: true, zoomChanges: true }),
26
+ ).toEqual({ threads: true, rightSidebar: "changes", zoomChanges: true });
27
+ });
28
+
29
+ test("the Project tree holds the right region when the Changes editor is closed", () => {
30
+ // Assert
31
+ expect(
32
+ layoutFromPanes({
33
+ threads: false,
34
+ changesOpen: false,
35
+ projectOpen: true,
36
+ zoomChanges: false,
37
+ }),
38
+ ).toEqual({ threads: false, rightSidebar: "project", zoomChanges: false });
39
+ });
40
+
41
+ test("the right region is off when neither pane is open", () => {
42
+ // Assert
43
+ expect(
44
+ layoutFromPanes({
45
+ threads: true,
46
+ changesOpen: false,
47
+ projectOpen: false,
48
+ zoomChanges: false,
49
+ }),
50
+ ).toEqual({ threads: true, rightSidebar: "off", zoomChanges: false });
51
+ });
52
+ });
@@ -0,0 +1,42 @@
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
+ /** The Threads sidebar is open. */
10
+ threads: boolean;
11
+ /** The right region: the Changes editor, the Project tree, or closed. */
12
+ rightSidebar: "changes" | "project" | "off";
13
+ /** The Changes editor is zoomed - the Thread pane hides so the diff fills the middle. */
14
+ zoomChanges: boolean;
15
+ }
16
+
17
+ /** `cueloop diff` / `cueloop review`: the diff front and center. */
18
+ export function reviewLayout(): LaunchLayout {
19
+ return { threads: true, rightSidebar: "changes", zoomChanges: true };
20
+ }
21
+
22
+ /** `cueloop plan` / `cueloop reply`: the thread pane fills the middle; no Changes panel. */
23
+ export function planLayout(): LaunchLayout {
24
+ return { threads: true, rightSidebar: "off", zoomChanges: false };
25
+ }
26
+
27
+ /** The first-run default, before any layout has been remembered: the inbox with the diff zoomed. */
28
+ export function defaultLayout(): LaunchLayout {
29
+ return { threads: true, rightSidebar: "changes", zoomChanges: true };
30
+ }
31
+
32
+ /** Read back the layout the panes currently sit in, to remember it for the next bare launch. */
33
+ export function layoutFromPanes(panes: {
34
+ threads: boolean;
35
+ changesOpen: boolean;
36
+ projectOpen: boolean;
37
+ zoomChanges: boolean;
38
+ }): LaunchLayout {
39
+ const rightSidebar = panes.changesOpen ? "changes" : panes.projectOpen ? "project" : "off";
40
+
41
+ return { threads: panes.threads, rightSidebar, zoomChanges: panes.zoomChanges };
42
+ }
package/src/run.ts CHANGED
@@ -4,10 +4,14 @@ import React from "react";
4
4
  import { createCliRenderer } from "@opentui/core";
5
5
  import { createRoot } from "@opentui/react";
6
6
  import { App } from "./App";
7
+ import { loadConfig } from "./config";
8
+ import { defaultLayout, type LaunchLayout } from "./launch-layout";
7
9
 
8
10
  export interface RunClientOptions {
9
11
  sessionId?: string;
10
12
  home?: string;
13
+ /** The layout a create-command opens in; omit to restore the remembered one, then the default. */
14
+ layout?: LaunchLayout;
11
15
  }
12
16
 
13
17
  /** OSC background query budget: brief so a terminal that never answers falls back to dark. */
@@ -16,6 +20,11 @@ const THEME_QUERY_TIMEOUT_MS = 200;
16
20
  export async function runClient(options: RunClientOptions): Promise<number> {
17
21
  // mouse movement reporting makes multiplexers forward drags to the app,
18
22
  // so the renderer's native selection is the drag driver
23
+ // a create-command dictates its layout; a bare inbox launch restores the remembered one, then the
24
+ // default; opening a specific thread carries no layout, so its own pane sync drives the composition
25
+ const layout =
26
+ options.layout ??
27
+ (options.sessionId === undefined ? (loadConfig().ui.layout ?? defaultLayout()) : undefined);
19
28
  const renderer = await createCliRenderer({ enableMouseMovement: true });
20
29
  const appearance =
21
30
  (await renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
@@ -42,6 +51,7 @@ export async function runClient(options: RunClientOptions): Promise<number> {
42
51
  home: options.home,
43
52
  sessionId: options.sessionId,
44
53
  appearance,
54
+ layout,
45
55
  onExit: shutdown,
46
56
  }),
47
57
  );
package/src/serve.ts CHANGED
@@ -20,11 +20,39 @@ import React from "react";
20
20
  import { createRoot } from "@opentui/react";
21
21
  import { createServer } from "@opentui/ssh";
22
22
  import { cueloopHome } from "@cueloop/daemon";
23
+ import { DaemonClient } from "@cueloop/daemon/client";
24
+ import type { Artifact } from "@cueloop/schema";
23
25
  import { App } from "./App";
26
+ import { snapshotWorkbench } from "./workbench-snapshot";
24
27
 
25
28
  /** OSC background query budget: brief so a terminal that never answers falls back to dark. */
26
29
  const THEME_QUERY_TIMEOUT_MS = 200;
27
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) => probe.repoDiff(root));
49
+
50
+ return snapshot === thread ? undefined : snapshot.artifact;
51
+ } finally {
52
+ probe.close();
53
+ }
54
+ }
55
+
28
56
  export interface ServeOptions {
29
57
  /** TCP port for the SSH listener; 0 picks an ephemeral port. Default 2222. */
30
58
  port?: number;
@@ -53,6 +81,8 @@ export async function serveClient(options: ServeOptions = {}): Promise<ServeHand
53
81
 
54
82
  mkdirSync(sshDir, { recursive: true, mode: 0o700 });
55
83
 
84
+ const servedArtifact = await captureFrozenArtifact(home, options.sessionId);
85
+
56
86
  const server = createServer({
57
87
  // password-less by design; see the trust model in the module comment
58
88
  auth: "open",
@@ -73,6 +103,7 @@ export async function serveClient(options: ServeOptions = {}): Promise<ServeHand
73
103
  sessionId: options.sessionId,
74
104
  readOnly: true,
75
105
  appearance,
106
+ servedArtifact,
76
107
  // q disconnects only this observer, never the server
77
108
  onExit: () => session.end(),
78
109
  }),