@cueloop/client 0.1.0-alpha.82 → 0.1.0-alpha.84
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.diff.test.tsx +4 -3
- package/src/App.export.test.tsx +1 -1
- package/src/App.readonly.test.tsx +1 -1
- package/src/App.submit-confirm.test.tsx +59 -17
- package/src/App.test.tsx +77 -3
- package/src/App.tsx +15 -7
- package/src/App.walk.test.tsx +1 -1
- package/src/components/ConfirmCard.tsx +11 -14
- package/src/components/DiffContentView.tsx +3 -2
- package/src/components/ProjectTreeView.test.tsx +17 -1
- package/src/components/ProjectTreeView.tsx +16 -2
- package/src/components/ThreadView.selection.test.tsx +31 -2
- package/src/components/ThreadView.tsx +4 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +23 -23
- package/src/config.ts +2 -2
- package/src/intent-dispatch.test.ts +2 -2
- package/src/intent-dispatch.ts +2 -4
- package/src/keymap.ts +0 -2
- package/src/thread-controller.curate.test.ts +36 -10
- package/src/thread-controller.switch-cache.test.ts +1 -1
- package/src/thread-controller.ts +175 -46
- package/src/tree-view.ts +6 -1
- package/src/use-annotation-surface.tsx +72 -12
- package/src/use-terminal-virtualizer.ts +26 -0
- package/src/view-plan.test.ts +66 -1
- package/src/view-plan.ts +281 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.84",
|
|
4
4
|
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
33
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
30
|
+
"@cueloop/daemon": "0.1.0-alpha.84",
|
|
31
|
+
"@cueloop/extension-api": "0.1.0-alpha.84",
|
|
32
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.84",
|
|
33
|
+
"@cueloop/schema": "0.1.0-alpha.84",
|
|
34
34
|
"@opentui/core": "^0.5.11",
|
|
35
35
|
"@opentui/keymap": "0.5.11",
|
|
36
36
|
"@opentui/react": "^0.5.11",
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -96,9 +96,9 @@ describe("diff review", () => {
|
|
|
96
96
|
|
|
97
97
|
// Act - submit with the session chord (cmd+enter, no composer open), cycle to request changes
|
|
98
98
|
await pressKey(setup, "RETURN", { meta: true });
|
|
99
|
-
await waitForText(setup, "[
|
|
99
|
+
await waitForText(setup, "[approve]");
|
|
100
100
|
await press(setup, "right");
|
|
101
|
-
await waitForText(setup, "[
|
|
101
|
+
await waitForText(setup, "[changes]");
|
|
102
102
|
await pressKey(setup, "RETURN", { meta: true });
|
|
103
103
|
|
|
104
104
|
// Assert
|
|
@@ -195,10 +195,11 @@ describe("diff review", () => {
|
|
|
195
195
|
await navCommand(setup, "x");
|
|
196
196
|
|
|
197
197
|
// Assert - the single change is gone, so the curated working copy is empty
|
|
198
|
-
await
|
|
198
|
+
await waitForState(setup, () => server.core.sessionGet(withFiles.id).workingCopy === "");
|
|
199
199
|
const stored = server.core.sessionGet(withFiles.id);
|
|
200
200
|
|
|
201
201
|
expect(stored.workingCopy).toBe("");
|
|
202
|
+
expect(setup.captureCharFrame()).not.toContain("change rejected");
|
|
202
203
|
});
|
|
203
204
|
|
|
204
205
|
test("a diff opens with the Changes column listing every changed file", async () => {
|
package/src/App.export.test.tsx
CHANGED
|
@@ -77,7 +77,7 @@ describe("obsidian export on resolve", () => {
|
|
|
77
77
|
await pressKey(setup, "RETURN", { meta: true }); // open the submit overlay, keep the default message (approve)
|
|
78
78
|
|
|
79
79
|
// Assert
|
|
80
|
-
await waitForText(setup, "[
|
|
80
|
+
await waitForText(setup, "[approve]");
|
|
81
81
|
|
|
82
82
|
// Act
|
|
83
83
|
await pressKey(setup, "RETURN", { meta: true }); // submit
|
|
@@ -114,7 +114,7 @@ describe("observer primitives are blocked", () => {
|
|
|
114
114
|
|
|
115
115
|
// no draft card and no submit card ever appear
|
|
116
116
|
expect(frame).not.toContain("● c");
|
|
117
|
-
expect(frame).not.toContain("[
|
|
117
|
+
expect(frame).not.toContain("[approve]");
|
|
118
118
|
expect(snapshot()).toEqual(before);
|
|
119
119
|
});
|
|
120
120
|
}
|
|
@@ -84,7 +84,7 @@ function seedAnnotations(count: number): void {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
describe("send message confirm", () => {
|
|
87
|
-
test("cmd+enter opens the send-message confirm overlay", async () => {
|
|
87
|
+
test("cmd+enter opens the send-message confirm overlay with stable message choices", async () => {
|
|
88
88
|
// Arrange
|
|
89
89
|
const setup = await renderApp();
|
|
90
90
|
|
|
@@ -95,12 +95,31 @@ describe("send message confirm", () => {
|
|
|
95
95
|
await pressKey(setup, "RETURN", { meta: true });
|
|
96
96
|
|
|
97
97
|
// Assert - the overlay: message selector and word-buttons
|
|
98
|
-
await waitForText(setup, "[
|
|
99
|
-
const
|
|
98
|
+
await waitForText(setup, "[approve]"); // nothing pending: approve default
|
|
99
|
+
const initialFrame = setup.captureCharFrame();
|
|
100
|
+
const initialSelector = initialFrame
|
|
101
|
+
.split("\n")
|
|
102
|
+
.find((row) => row.includes("[comment] [approve] [changes]"));
|
|
103
|
+
|
|
104
|
+
expect(initialSelector).toBeDefined();
|
|
105
|
+
expect(initialFrame).toContain(" send ");
|
|
106
|
+
expect(initialFrame).toContain(" cancel ");
|
|
100
107
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
// Act - move the active choice across the selector
|
|
109
|
+
await press(setup, "right");
|
|
110
|
+
const changesSelector = setup
|
|
111
|
+
.captureCharFrame()
|
|
112
|
+
.split("\n")
|
|
113
|
+
.find((row) => row.includes("[comment] [approve] [changes]"));
|
|
114
|
+
await press(setup, "right");
|
|
115
|
+
const commentSelector = setup
|
|
116
|
+
.captureCharFrame()
|
|
117
|
+
.split("\n")
|
|
118
|
+
.find((row) => row.includes("[comment] [approve] [changes]"));
|
|
119
|
+
|
|
120
|
+
// Assert - active styling does not move or resize any choice
|
|
121
|
+
expect(changesSelector).toBe(initialSelector);
|
|
122
|
+
expect(commentSelector).toBe(initialSelector);
|
|
104
123
|
});
|
|
105
124
|
|
|
106
125
|
test("left/right cycles the message selector in the overlay", async () => {
|
|
@@ -110,25 +129,25 @@ describe("send message confirm", () => {
|
|
|
110
129
|
await pressKey(setup, "RETURN", { meta: true });
|
|
111
130
|
|
|
112
131
|
// Assert
|
|
113
|
-
await waitForText(setup, "[
|
|
132
|
+
await waitForText(setup, "[approve]");
|
|
114
133
|
|
|
115
134
|
// Act
|
|
116
135
|
await press(setup, "right");
|
|
117
136
|
|
|
118
137
|
// Assert
|
|
119
|
-
await waitForText(setup, "[
|
|
138
|
+
await waitForText(setup, "[changes]");
|
|
120
139
|
|
|
121
140
|
// Act
|
|
122
141
|
await press(setup, "right");
|
|
123
142
|
|
|
124
143
|
// Assert
|
|
125
|
-
await waitForText(setup, "[
|
|
144
|
+
await waitForText(setup, "[comment]");
|
|
126
145
|
|
|
127
146
|
// Act
|
|
128
147
|
await press(setup, "left");
|
|
129
148
|
|
|
130
149
|
// Assert
|
|
131
|
-
await waitForText(setup, "[
|
|
150
|
+
await waitForText(setup, "[changes]");
|
|
132
151
|
});
|
|
133
152
|
|
|
134
153
|
test("esc closes the overlay", async () => {
|
|
@@ -138,13 +157,13 @@ describe("send message confirm", () => {
|
|
|
138
157
|
await pressKey(setup, "RETURN", { meta: true });
|
|
139
158
|
|
|
140
159
|
// Assert
|
|
141
|
-
await waitForText(setup, "[
|
|
160
|
+
await waitForText(setup, "[approve]");
|
|
142
161
|
|
|
143
162
|
// Act
|
|
144
163
|
await press(setup, "escape");
|
|
145
164
|
|
|
146
165
|
// Assert - a bare ESC settles after the parser's escape-sequence window
|
|
147
|
-
const frame = await waitForTextGone(setup, "[
|
|
166
|
+
const frame = await waitForTextGone(setup, "[approve]");
|
|
148
167
|
|
|
149
168
|
expect(frame).not.toContain(" cancel ");
|
|
150
169
|
});
|
|
@@ -158,9 +177,9 @@ describe("send message confirm", () => {
|
|
|
158
177
|
await pressKey(setup, "RETURN", { meta: true });
|
|
159
178
|
|
|
160
179
|
// Assert - opens on the default message, then cycle to request changes
|
|
161
|
-
await waitForText(setup, "[
|
|
180
|
+
await waitForText(setup, "[approve]");
|
|
162
181
|
await press(setup, "right");
|
|
163
|
-
await waitForText(setup, "[
|
|
182
|
+
await waitForText(setup, "[changes]");
|
|
164
183
|
|
|
165
184
|
// Act
|
|
166
185
|
await setup.mockInput.typeText("Tighten the steps.");
|
|
@@ -174,12 +193,35 @@ describe("send message confirm", () => {
|
|
|
174
193
|
expect(stored.message!.outcome).toBe("changes_requested");
|
|
175
194
|
});
|
|
176
195
|
|
|
196
|
+
test("Comment sends feedback while leaving the Thread open", async () => {
|
|
197
|
+
seedAnnotations(1);
|
|
198
|
+
const setup = await renderApp();
|
|
199
|
+
|
|
200
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
201
|
+
await waitForText(setup, "[approve]");
|
|
202
|
+
await press(setup, "left");
|
|
203
|
+
await waitForText(setup, "[comment]");
|
|
204
|
+
|
|
205
|
+
await setup.mockInput.typeText("For now.");
|
|
206
|
+
await pressKey(setup, "RETURN", { meta: true });
|
|
207
|
+
|
|
208
|
+
await waitForText(setup, "comment sent - thread stays open");
|
|
209
|
+
const stored = server.core.sessionGet(session.id);
|
|
210
|
+
|
|
211
|
+
expect(stored.status).toBe("pending");
|
|
212
|
+
expect(stored.message!.outcome).toBe("comment");
|
|
213
|
+
expect(stored.message!.annotations?.map((annotation) => annotation.id)).toEqual([
|
|
214
|
+
"a_confirm_1",
|
|
215
|
+
]);
|
|
216
|
+
expect(setup.captureCharFrame()).not.toContain("feedback sent");
|
|
217
|
+
});
|
|
218
|
+
|
|
177
219
|
test("typing / in the summary opens the skills/actions palette", async () => {
|
|
178
220
|
// Arrange
|
|
179
221
|
seedAnnotations(1);
|
|
180
222
|
const setup = await renderApp();
|
|
181
223
|
await pressKey(setup, "RETURN", { meta: true });
|
|
182
|
-
await waitForText(setup, "[
|
|
224
|
+
await waitForText(setup, "[approve]");
|
|
183
225
|
|
|
184
226
|
// Act
|
|
185
227
|
await setup.mockInput.typeText("/restate");
|
|
@@ -192,7 +234,7 @@ describe("send message confirm", () => {
|
|
|
192
234
|
// Arrange
|
|
193
235
|
const setup = await renderApp();
|
|
194
236
|
await pressKey(setup, "RETURN", { meta: true });
|
|
195
|
-
await waitForText(setup, "[
|
|
237
|
+
await waitForText(setup, "[approve]");
|
|
196
238
|
|
|
197
239
|
// Act - a burst of control bytes stands in for the binary an image paste delivers
|
|
198
240
|
await setup.mockInput.typeText("look at this ");
|
|
@@ -215,7 +257,7 @@ describe("send message confirm", () => {
|
|
|
215
257
|
// Assert - no overlay, and the session stays pending
|
|
216
258
|
const frame = setup.captureCharFrame();
|
|
217
259
|
|
|
218
|
-
expect(frame).not.toContain("[
|
|
260
|
+
expect(frame).not.toContain("[approve]");
|
|
219
261
|
expect(frame).not.toContain(" cancel ");
|
|
220
262
|
expect(server.core.sessionGet(session.id).status).toBe("pending");
|
|
221
263
|
});
|
package/src/App.test.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import React from "react";
|
|
|
8
8
|
import { DaemonServer } from "@cueloop/daemon";
|
|
9
9
|
import type { Thread } from "@cueloop/schema";
|
|
10
10
|
import { App } from "./App";
|
|
11
|
+
import { DARK } from "./theme";
|
|
11
12
|
import {
|
|
12
13
|
clickText,
|
|
13
14
|
dragText,
|
|
@@ -33,6 +34,8 @@ The daemon persists sessions to disk atomically.
|
|
|
33
34
|
|
|
34
35
|
- move the store
|
|
35
36
|
- add recovery
|
|
37
|
+
|
|
38
|
+
Use the **safe** mode.
|
|
36
39
|
`;
|
|
37
40
|
|
|
38
41
|
let home: string;
|
|
@@ -67,6 +70,25 @@ async function renderApp(sessionId?: string) {
|
|
|
67
70
|
});
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
74
|
+
|
|
75
|
+
function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
76
|
+
const foregrounds: string[] = [];
|
|
77
|
+
|
|
78
|
+
for (const line of setup.captureSpans().lines) {
|
|
79
|
+
for (const span of line.spans) {
|
|
80
|
+
if (!span.text.includes(needle)) continue;
|
|
81
|
+
const [red, green, blue] = span.fg.toInts();
|
|
82
|
+
|
|
83
|
+
foregrounds.push(
|
|
84
|
+
"#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return foregrounds;
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
describe("plan rendering", () => {
|
|
71
93
|
test("renders the plan with headings, list markers, and the footer", async () => {
|
|
72
94
|
// Arrange
|
|
@@ -168,6 +190,58 @@ describe("thread view grammar", () => {
|
|
|
168
190
|
await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
|
|
169
191
|
});
|
|
170
192
|
|
|
193
|
+
test("Cut removes only the marked characters instead of their whole block", async () => {
|
|
194
|
+
const setup = await renderApp();
|
|
195
|
+
|
|
196
|
+
await dragText(setup, "move the store", "move the store", "move".length);
|
|
197
|
+
await navCommand(setup, "x");
|
|
198
|
+
await waitForState(setup, () =>
|
|
199
|
+
(server.core.sessionGet(session.id).workingCopy ?? "").includes("- the store"),
|
|
200
|
+
);
|
|
201
|
+
const working = server.core.sessionGet(session.id).workingCopy ?? "";
|
|
202
|
+
const textCuts = server.core.sessionGet(session.id).textCuts;
|
|
203
|
+
|
|
204
|
+
expect(working).not.toContain("move the store");
|
|
205
|
+
expect(working).toContain("- the store");
|
|
206
|
+
expect(working).toContain("- add recovery");
|
|
207
|
+
expect(textCuts).toHaveLength(1);
|
|
208
|
+
expect(textCuts?.[0]?.quote).toBe("move");
|
|
209
|
+
expect(foregroundsOf(setup, "the store")).not.toContain(DARK.green);
|
|
210
|
+
expect(setup.captureCharFrame()).not.toContain("selection cut");
|
|
211
|
+
|
|
212
|
+
await pressKey(setup, "x");
|
|
213
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
|
|
214
|
+
|
|
215
|
+
expect(server.core.sessionGet(session.id).textCuts).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a large partial Cut keeps its surviving prefix plain in one modified block", async () => {
|
|
219
|
+
const original = "A seeded plan so cueloop dev always has a thread to open.";
|
|
220
|
+
const shortened = server.core.sessionCreate({
|
|
221
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
222
|
+
artifact: { type: "plan", content: original, meta: { title: "Partial Cut" } },
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
server.core.sessionSetWorkingCopy(shortened.id, "A seed");
|
|
226
|
+
const setup = await renderApp(shortened.id);
|
|
227
|
+
|
|
228
|
+
await waitForText(setup, "A seed");
|
|
229
|
+
expect(foregroundsOf(setup, "seed")).toContain(DARK.text);
|
|
230
|
+
expect(foregroundsOf(setup, "seed")).not.toContain(DARK.green);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("Cut maps rendered inline Markdown back to its exact source characters", async () => {
|
|
234
|
+
const setup = await renderApp();
|
|
235
|
+
|
|
236
|
+
await dragText(setup, "safe", "safe", "safe".length);
|
|
237
|
+
await navCommand(setup, "x");
|
|
238
|
+
await waitForState(setup, () =>
|
|
239
|
+
(server.core.sessionGet(session.id).workingCopy ?? "").includes("Use the **** mode."),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
expect(server.core.sessionGet(session.id).workingCopy).toContain("Use the **** mode.");
|
|
243
|
+
});
|
|
244
|
+
|
|
171
245
|
test("ctrl+e opens the inline editor; the header toggles to normal and leaving tracks the edit", async () => {
|
|
172
246
|
// Arrange
|
|
173
247
|
const setup = await renderApp();
|
|
@@ -204,11 +278,11 @@ describe("submit", () => {
|
|
|
204
278
|
await pressKey(setup, "RETURN", { meta: true });
|
|
205
279
|
|
|
206
280
|
// Assert - the card opens on the default message, approve
|
|
207
|
-
await waitForText(setup, "[
|
|
281
|
+
await waitForText(setup, "[approve]");
|
|
208
282
|
|
|
209
283
|
// Act - cycle to request changes, then send with a summary
|
|
210
284
|
await press(setup, "right");
|
|
211
|
-
await waitForText(setup, "[
|
|
285
|
+
await waitForText(setup, "[changes]");
|
|
212
286
|
await type(setup, "Expand the steps.");
|
|
213
287
|
await pressKey(setup, "RETURN", { meta: true });
|
|
214
288
|
|
|
@@ -231,7 +305,7 @@ describe("submit", () => {
|
|
|
231
305
|
await pressKey(setup, "RETURN", { meta: true });
|
|
232
306
|
|
|
233
307
|
// Assert
|
|
234
|
-
await waitForText(setup, "[
|
|
308
|
+
await waitForText(setup, "[approve]"); // no pending items → approve default
|
|
235
309
|
|
|
236
310
|
// Act
|
|
237
311
|
await pressKey(setup, "RETURN", { meta: true });
|
package/src/App.tsx
CHANGED
|
@@ -674,7 +674,6 @@ export function App({
|
|
|
674
674
|
const name = github.name?.trim() || github.login;
|
|
675
675
|
|
|
676
676
|
applyIdentity({ name, provider: "github" });
|
|
677
|
-
controller.setStatus(`identity synced from GitHub - ${name}`);
|
|
678
677
|
});
|
|
679
678
|
};
|
|
680
679
|
|
|
@@ -859,10 +858,6 @@ export function App({
|
|
|
859
858
|
|
|
860
859
|
setDiffView(next);
|
|
861
860
|
persistDiffView(next);
|
|
862
|
-
// every toggle names the new mode; picking split on a narrow pane also says it needs the wide layout
|
|
863
|
-
if (next === "stacked") controller.setStatus("stacked diff");
|
|
864
|
-
else if (workbench.zoomed) controller.setStatus("split diff");
|
|
865
|
-
else controller.setStatus("split diff shows when zoomed");
|
|
866
861
|
},
|
|
867
862
|
openShareDialog: () => {
|
|
868
863
|
if (!isOwner) return controller.setStatus("only the plan owner can share");
|
|
@@ -896,7 +891,13 @@ export function App({
|
|
|
896
891
|
}, [session, navigablePanes, focusedPane]);
|
|
897
892
|
const cyclePanes = (backward: boolean): void =>
|
|
898
893
|
setFocusedPane((current) => nextFocusPane(current, navigablePanes, backward));
|
|
899
|
-
const runNavCommand = (
|
|
894
|
+
const runNavCommand = (
|
|
895
|
+
key: { name: string; shift?: boolean },
|
|
896
|
+
selection: {
|
|
897
|
+
start: { blockIndex: number; char: number };
|
|
898
|
+
end: { blockIndex: number; char: number };
|
|
899
|
+
} | null = null,
|
|
900
|
+
): boolean => {
|
|
900
901
|
const intent = resolveNavKey(key, {
|
|
901
902
|
isOwner,
|
|
902
903
|
resolved,
|
|
@@ -905,7 +906,14 @@ export function App({
|
|
|
905
906
|
});
|
|
906
907
|
|
|
907
908
|
if (!intent) return false;
|
|
908
|
-
|
|
909
|
+
if (intent.type === "cut" && selection) {
|
|
910
|
+
controller.cut(
|
|
911
|
+
selection.start.blockIndex,
|
|
912
|
+
selection.start.char,
|
|
913
|
+
selection.end.char,
|
|
914
|
+
selection.end.blockIndex,
|
|
915
|
+
);
|
|
916
|
+
} else dispatch(intent);
|
|
909
917
|
|
|
910
918
|
return true;
|
|
911
919
|
};
|
package/src/App.walk.test.tsx
CHANGED
|
@@ -253,7 +253,7 @@ describe("the guided walk", () => {
|
|
|
253
253
|
await waitForTextGone(setup, "agent note");
|
|
254
254
|
await pressKey(setup, "RETURN", { meta: true });
|
|
255
255
|
// nothing pending despite the note, so the confirm card defaults to approve
|
|
256
|
-
await waitForText(setup, "[
|
|
256
|
+
await waitForText(setup, "[approve]");
|
|
257
257
|
await pressKey(setup, "RETURN", { meta: true });
|
|
258
258
|
|
|
259
259
|
// Assert
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import React, { useContext } from "react";
|
|
9
9
|
import { useTerminalDimensions } from "@opentui/react";
|
|
10
|
-
import type
|
|
10
|
+
import { MESSAGE_OUTCOMES, type MessageOutcome } from "@cueloop/schema";
|
|
11
11
|
import type { Theme } from "../theme";
|
|
12
12
|
import type { QuickAction } from "../config";
|
|
13
13
|
import { SlashSkillsContext } from "../skills";
|
|
@@ -18,15 +18,14 @@ import { composeRowCount } from "./AnnotationCards";
|
|
|
18
18
|
import { SlashComposer } from "./SlashComposer";
|
|
19
19
|
import { DialogActions } from "./primitives/DialogActions";
|
|
20
20
|
|
|
21
|
-
const SUBMIT_CARD_MAX_WIDTH =
|
|
21
|
+
const SUBMIT_CARD_MAX_WIDTH = 52;
|
|
22
22
|
const PALETTE_WINDOW = 5;
|
|
23
23
|
|
|
24
|
-
export const MESSAGE_OUTCOMES: MessageOutcome[] = ["approved", "changes_requested"];
|
|
25
|
-
|
|
26
24
|
/** Selector words in the confirm card - one word per message. */
|
|
27
25
|
export const MESSAGE_OUTCOME_LABEL: Record<MessageOutcome, string> = {
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
comment: "comment",
|
|
27
|
+
approved: "approve",
|
|
28
|
+
changes_requested: "changes",
|
|
30
29
|
};
|
|
31
30
|
|
|
32
31
|
export interface ConfirmCardProps {
|
|
@@ -57,10 +56,10 @@ export function outcomeColor(message: MessageOutcome, tokens: Theme): string {
|
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
/**
|
|
60
|
-
* The message selector: one row of pressable words
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
59
|
+
* The message selector: one row of pressable words in reading order.
|
|
60
|
+
* Selection stays controlled by the grammar (←/→ cycle the message); a click
|
|
61
|
+
* selects directly. Every message keeps its brackets so active color never changes the row's
|
|
62
|
+
* width or moves a neighboring choice.
|
|
64
63
|
*/
|
|
65
64
|
function MessageSelector({
|
|
66
65
|
message,
|
|
@@ -82,9 +81,7 @@ function MessageSelector({
|
|
|
82
81
|
onMouseUp={() => onSelectMessage(candidate)}
|
|
83
82
|
>
|
|
84
83
|
<text fg={candidate === message ? outcomeColor(candidate, tokens) : tokens.textDim}>
|
|
85
|
-
{candidate
|
|
86
|
-
? `[${MESSAGE_OUTCOME_LABEL[candidate]}]`
|
|
87
|
-
: ` ${MESSAGE_OUTCOME_LABEL[candidate]} `}
|
|
84
|
+
{`[${MESSAGE_OUTCOME_LABEL[candidate]}]`}
|
|
88
85
|
</text>
|
|
89
86
|
</box>
|
|
90
87
|
))}
|
|
@@ -106,7 +103,7 @@ export function ConfirmCard({
|
|
|
106
103
|
const tokens = useComponentTheme(theme);
|
|
107
104
|
const skills = useContext(SlashSkillsContext);
|
|
108
105
|
const { width: terminalWidth } = useTerminalDimensions();
|
|
109
|
-
const cardWidth = Math.max(24, Math.min(terminalWidth -
|
|
106
|
+
const cardWidth = Math.max(24, Math.min(terminalWidth - 2, SUBMIT_CARD_MAX_WIDTH));
|
|
110
107
|
|
|
111
108
|
const composerRows = composeRowCount(summary, cardWidth - 4);
|
|
112
109
|
const token = activeSlashToken(summary, summary.length);
|
|
@@ -24,7 +24,7 @@ import { splitDiffRows, type SplitLine, type SplitRow } from "../split-diff";
|
|
|
24
24
|
import { UNDERLINE, type AnnotationPalette } from "../annotation-palette";
|
|
25
25
|
import { lineMarkRanges, runsFor, wrapLines, type MarkRange } from "../mark-runs";
|
|
26
26
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
27
|
-
import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
|
|
27
|
+
import { scrollBoxDragViewport, useTerminalVirtualizer } from "../use-terminal-virtualizer";
|
|
28
28
|
import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
|
|
29
29
|
import { NavModeHint } from "./NavModeHint";
|
|
30
30
|
import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
@@ -98,7 +98,7 @@ export interface DiffContentViewProps {
|
|
|
98
98
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
99
99
|
/** The author's display name for a comment's hover tooltip. */
|
|
100
100
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
101
|
-
onNavCommand?: (key: KeyEvent) => boolean;
|
|
101
|
+
onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
|
|
102
102
|
onExit: () => void;
|
|
103
103
|
/** Row indices the owner rejected during curation; drawn struck through. */
|
|
104
104
|
rejectedRows?: Set<number>;
|
|
@@ -527,6 +527,7 @@ export function DiffContentView({
|
|
|
527
527
|
onAnnotate,
|
|
528
528
|
onReply,
|
|
529
529
|
onUpdateAnnotation,
|
|
530
|
+
dragViewport: () => scrollBoxDragViewport(scrollRef.current),
|
|
530
531
|
resolveAuthorLabel,
|
|
531
532
|
onNavCommand,
|
|
532
533
|
onExit,
|
|
@@ -5,7 +5,7 @@ import { test, expect } from "bun:test";
|
|
|
5
5
|
import React from "react";
|
|
6
6
|
import { testRender } from "@opentui/react/test-utils";
|
|
7
7
|
import { ProjectTreeView } from "./ProjectTreeView";
|
|
8
|
-
import { settle, waitForText } from "../test-support";
|
|
8
|
+
import { locateText, settle, waitForText } from "../test-support";
|
|
9
9
|
import { DARK } from "../theme";
|
|
10
10
|
|
|
11
11
|
test("tab expands a folder, then j moves down and tab opens the file", async () => {
|
|
@@ -57,3 +57,19 @@ test("without focus the keyboard does nothing", async () => {
|
|
|
57
57
|
|
|
58
58
|
setup.renderer.destroy();
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
test("the empty project hint stays centered in the panel", async () => {
|
|
62
|
+
const setup = await testRender(
|
|
63
|
+
<ProjectTreeView loadFiles={async () => []} onSelectFile={() => {}} theme={DARK} />,
|
|
64
|
+
{ width: 40, height: 12 },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
await waitForText(setup, "empty");
|
|
68
|
+
const position = locateText(setup, "empty");
|
|
69
|
+
|
|
70
|
+
expect(position.column).toBeGreaterThanOrEqual(17);
|
|
71
|
+
expect(position.column).toBeLessThanOrEqual(18);
|
|
72
|
+
expect(position.row).toBeGreaterThanOrEqual(5);
|
|
73
|
+
expect(position.row).toBeLessThanOrEqual(6);
|
|
74
|
+
setup.renderer.destroy();
|
|
75
|
+
});
|
|
@@ -78,14 +78,28 @@ export function ProjectTreeView({
|
|
|
78
78
|
|
|
79
79
|
if (paths === null) {
|
|
80
80
|
return (
|
|
81
|
-
<box
|
|
81
|
+
<box
|
|
82
|
+
style={{
|
|
83
|
+
flexGrow: 1,
|
|
84
|
+
flexDirection: "column",
|
|
85
|
+
alignItems: "center",
|
|
86
|
+
justifyContent: "center",
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
82
89
|
<text fg={tokens.textDim}>loading...</text>
|
|
83
90
|
</box>
|
|
84
91
|
);
|
|
85
92
|
}
|
|
86
93
|
if (paths.length === 0) {
|
|
87
94
|
return (
|
|
88
|
-
<box
|
|
95
|
+
<box
|
|
96
|
+
style={{
|
|
97
|
+
flexGrow: 1,
|
|
98
|
+
flexDirection: "column",
|
|
99
|
+
alignItems: "center",
|
|
100
|
+
justifyContent: "center",
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
89
103
|
<text fg={tokens.textDim}>empty</text>
|
|
90
104
|
</box>
|
|
91
105
|
);
|
|
@@ -58,7 +58,7 @@ function session(annotations: Annotation[] = []) {
|
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
async function mount(annotations: Annotation[] = []): Promise<void> {
|
|
61
|
+
async function mount(annotations: Annotation[] = [], height = 24): Promise<void> {
|
|
62
62
|
annotate = mock<AnnotateHandler>(() => {});
|
|
63
63
|
reply = mock<ReplyHandler>(() => {});
|
|
64
64
|
const display = buildDisplay(PLAN, undefined);
|
|
@@ -75,7 +75,7 @@ async function mount(annotations: Annotation[] = []): Promise<void> {
|
|
|
75
75
|
onUpdateAnnotation={() => {}}
|
|
76
76
|
onExit={() => {}}
|
|
77
77
|
/>,
|
|
78
|
-
{ width: 64, height
|
|
78
|
+
{ width: 64, height },
|
|
79
79
|
);
|
|
80
80
|
await settle(setup);
|
|
81
81
|
await settle(setup);
|
|
@@ -391,6 +391,20 @@ describe("marking across rows", () => {
|
|
|
391
391
|
expect(rows.get(origin.row + 1)).toBe("session, t");
|
|
392
392
|
});
|
|
393
393
|
|
|
394
|
+
test("shift+down extends a mark to the same column on the next visual row", async () => {
|
|
395
|
+
const origin = stanzaOrigin();
|
|
396
|
+
const column = STANZA_ROWS[0]!.indexOf("written");
|
|
397
|
+
|
|
398
|
+
await setup.mockMouse.click(origin.column + column, origin.row);
|
|
399
|
+
await settle(setup);
|
|
400
|
+
setup.mockInput.pressKey("ARROW_DOWN", { shift: true });
|
|
401
|
+
await settle(setup);
|
|
402
|
+
|
|
403
|
+
expect(highlightedAcrossRows()).toBe(
|
|
404
|
+
STANZA.slice(stanzaOffset(0, column), stanzaOffset(1, column)).replace("\n", " "),
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
|
|
394
408
|
test("a backward drag up two rows normalizes to the same characters", async () => {
|
|
395
409
|
// Arrange: press inside "crash" on row three
|
|
396
410
|
const origin = stanzaOrigin();
|
|
@@ -463,6 +477,21 @@ describe("marking across rows", () => {
|
|
|
463
477
|
// Assert
|
|
464
478
|
expect(highlightedByRow().size).toBe(0);
|
|
465
479
|
});
|
|
480
|
+
|
|
481
|
+
test("holding a mark at the viewport edge scrolls and extends it", async () => {
|
|
482
|
+
setup.renderer.destroy();
|
|
483
|
+
await mount([], 10);
|
|
484
|
+
const origin = locate("daemon");
|
|
485
|
+
|
|
486
|
+
await setup.mockMouse.pressDown(origin.column, origin.row);
|
|
487
|
+
await setup.mockMouse.moveTo(63, 9);
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
489
|
+
await settle(setup);
|
|
490
|
+
await setup.mockMouse.release(63, 9);
|
|
491
|
+
|
|
492
|
+
expect(setup.captureCharFrame()).not.toContain("# Plan");
|
|
493
|
+
expect(highlightedText().length).toBeGreaterThan("daemon".length);
|
|
494
|
+
});
|
|
466
495
|
});
|
|
467
496
|
|
|
468
497
|
describe("marking across blocks", () => {
|
|
@@ -35,7 +35,7 @@ import { BOLD, CUT, ITALIC, UNDERLINE } from "../annotation-palette";
|
|
|
35
35
|
import { wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
|
|
36
36
|
import { MarkdownGridBlock } from "./MarkdownGridBlock";
|
|
37
37
|
import { useFrameMeasure } from "../use-frame-measure";
|
|
38
|
-
import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
|
|
38
|
+
import { scrollBoxDragViewport, useTerminalVirtualizer } from "../use-terminal-virtualizer";
|
|
39
39
|
import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
|
|
40
40
|
import { NavModeHint } from "./NavModeHint";
|
|
41
41
|
import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
|
|
@@ -158,7 +158,7 @@ export interface ThreadViewProps {
|
|
|
158
158
|
onUpdateAnnotation: (id: string, body: string) => void;
|
|
159
159
|
/** The author's display name for a comment's hover tooltip. */
|
|
160
160
|
resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
|
|
161
|
-
onNavCommand?: (key: KeyEvent) => boolean;
|
|
161
|
+
onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
|
|
162
162
|
onExit: () => void;
|
|
163
163
|
theme?: Theme;
|
|
164
164
|
}
|
|
@@ -192,6 +192,7 @@ export function ThreadView({
|
|
|
192
192
|
textAt: (blockIndex) => renderedText(display[blockIndex]!),
|
|
193
193
|
annotatable: () => true,
|
|
194
194
|
};
|
|
195
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
195
196
|
const surface = useAnnotationSurface({
|
|
196
197
|
source,
|
|
197
198
|
session,
|
|
@@ -209,12 +210,12 @@ export function ThreadView({
|
|
|
209
210
|
onAnnotate,
|
|
210
211
|
onReply,
|
|
211
212
|
onUpdateAnnotation,
|
|
213
|
+
dragViewport: () => scrollBoxDragViewport(scrollRef.current),
|
|
212
214
|
resolveAuthorLabel,
|
|
213
215
|
onNavCommand,
|
|
214
216
|
onExit,
|
|
215
217
|
});
|
|
216
218
|
const { palette, discussions } = surface;
|
|
217
|
-
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
218
219
|
const { height: terminalHeight } = useTerminalDimensions();
|
|
219
220
|
const viewWidth = useFrameMeasure(
|
|
220
221
|
() => scrollRef.current?.content?.width ?? 0,
|