@cueloop/client 0.1.0-alpha.72 → 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.
- package/package.json +5 -5
- package/src/App.tsx +35 -170
- package/src/App.workbench.test.tsx +103 -2
- package/src/app-screens.tsx +84 -10
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.tsx +32 -6
- package/src/components/BareWorkbenchFileView.tsx +1 -1
- package/src/components/ConfirmCard.tsx +0 -1
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +165 -0
- package/src/components/SettingsDialog.tsx +4 -13
- package/src/components/ThreadTree.tsx +6 -18
- package/src/components/__snapshots__/stories.test.tsx.snap +112 -68
- package/src/components/primitives/Card.tsx +1 -1
- package/src/components/primitives/frame.test.ts +5 -5
- package/src/components/primitives/frame.ts +5 -7
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +36 -2
- package/src/components/quick-actions-editor.tsx +104 -29
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config.test.ts +36 -0
- package/src/config.ts +38 -8
- package/src/index.ts +2 -0
- package/src/intent-dispatch.test.ts +1 -0
- package/src/launch-layout.test.ts +52 -0
- package/src/launch-layout.ts +42 -0
- package/src/run.ts +10 -0
- package/src/serve.ts +31 -0
- package/src/thread-controller.live-diff.test.ts +119 -0
- package/src/thread-controller.ts +88 -27
- package/src/use-changes-workbench.ts +41 -4
- package/src/use-remember-layout.ts +31 -0
- package/src/use-settings-dialog.tsx +11 -2
- package/src/view-diff.test.ts +73 -2
- package/src/view-diff.ts +38 -0
- package/src/workbench-snapshot.test.ts +81 -0
- package/src/workbench-snapshot.ts +29 -0
- package/src/components/fade-title.test.ts +0 -49
- 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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
+
}
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
}),
|