@cueloop/client 0.1.0-alpha.20 → 0.1.0-alpha.21
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.plan-review.test.tsx +61 -0
- package/src/App.rendering.test.tsx +6 -2
- package/src/App.review-panel.test.tsx +97 -0
- package/src/App.tsx +95 -22
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.tsx +66 -13
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.tsx +14 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
- package/src/components/primitives/Card.tsx +2 -1
- package/src/components/primitives/Dialog.tsx +2 -1
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +2 -1
- package/src/config.test.ts +51 -1
- package/src/config.ts +57 -22
- package/src/keymap.test.ts +44 -4
- package/src/keymap.ts +30 -5
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/session-controller.ts +17 -1
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.21",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
10
|
+
"@cueloop/daemon": "0.1.0-alpha.21",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.21",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.21",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.21",
|
|
14
14
|
"@opentui/core": "^0.5.1",
|
|
15
15
|
"@opentui/keymap": "0.5.1",
|
|
16
16
|
"@opentui/react": "^0.5.1",
|
|
@@ -147,6 +147,67 @@ describe("inline compose keeps the anchor painted", () => {
|
|
|
147
147
|
}, 15_000);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
describe("compose newline convention", () => {
|
|
151
|
+
// Option/Alt and Shift reach the App as `meta`/`shift` on the return key.
|
|
152
|
+
// modifyOtherKeys encoding preserves those modifiers so the grammar and the
|
|
153
|
+
// focused textarea can tell a bare submit from a newline.
|
|
154
|
+
async function renderModifierAwareApp() {
|
|
155
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
156
|
+
width: 120,
|
|
157
|
+
height: 32,
|
|
158
|
+
otherModifiersMode: true,
|
|
159
|
+
});
|
|
160
|
+
await waitForText(setup, "cueloop");
|
|
161
|
+
return setup;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function pressReturnWith(setup: Setup, modifiers: { shift?: boolean; meta?: boolean }): Promise<void> {
|
|
165
|
+
setup.mockInput.pressKey("RETURN", modifiers);
|
|
166
|
+
await settle(setup);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
test("option+return inserts a newline; a bare return submits the multiline body", async () => {
|
|
170
|
+
const setup = await renderModifierAwareApp();
|
|
171
|
+
await toContextParagraph(setup);
|
|
172
|
+
await press(setup, "v");
|
|
173
|
+
await press(setup, "l");
|
|
174
|
+
await press(setup, "c");
|
|
175
|
+
await waitForText(setup, 'comment on "The daemon"');
|
|
176
|
+
await type(setup, "first line");
|
|
177
|
+
await pressReturnWith(setup, { meta: true }); // option/alt+return -> newline
|
|
178
|
+
await type(setup, "second line");
|
|
179
|
+
// still composing: the newline did not submit
|
|
180
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
181
|
+
await press(setup, "enter"); // bare return -> save
|
|
182
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
183
|
+
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("first line\nsecond line");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("shift+return still inserts a newline (existing muscle memory)", async () => {
|
|
187
|
+
const setup = await renderModifierAwareApp();
|
|
188
|
+
await toContextParagraph(setup);
|
|
189
|
+
await press(setup, "c");
|
|
190
|
+
await type(setup, "alpha");
|
|
191
|
+
await pressReturnWith(setup, { shift: true }); // shift+return -> newline
|
|
192
|
+
await type(setup, "beta");
|
|
193
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
194
|
+
await press(setup, "enter");
|
|
195
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
196
|
+
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("alpha\nbeta");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("escape cancels the composer without saving", async () => {
|
|
200
|
+
const setup = await renderModifierAwareApp();
|
|
201
|
+
await toContextParagraph(setup);
|
|
202
|
+
await press(setup, "c");
|
|
203
|
+
await waitForText(setup, 'comment on "');
|
|
204
|
+
await type(setup, "never saved");
|
|
205
|
+
await press(setup, "escape");
|
|
206
|
+
await waitForTextGone(setup, 'comment on "');
|
|
207
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
150
211
|
describe("the document selects, the rail edits", () => {
|
|
151
212
|
test("e edits the selected card in place; enter saves through the controller", async () => {
|
|
152
213
|
const setup = await renderApp();
|
|
@@ -69,8 +69,12 @@ describe("code blocks", () => {
|
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
describe("block spacing", () => {
|
|
72
|
+
// the review panel's full-height divider paints a `│` on every row, so read
|
|
73
|
+
// the plan column (everything left of the divider) before asserting spacing
|
|
74
|
+
const planColumn = (line: string): string => line.split("│")[0]!.trimEnd();
|
|
75
|
+
|
|
72
76
|
test("a code block never glues to the list item above it", async () => {
|
|
73
|
-
const lines = (await frame()).split("\n").map(
|
|
77
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
74
78
|
const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
|
|
75
79
|
const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
|
|
76
80
|
expect(listItemLineIndex).toBeGreaterThan(-1);
|
|
@@ -79,7 +83,7 @@ describe("block spacing", () => {
|
|
|
79
83
|
});
|
|
80
84
|
|
|
81
85
|
test("a heading never sits directly on the previous block", async () => {
|
|
82
|
-
const lines = (await frame()).split("\n").map(
|
|
86
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
83
87
|
const gate = lines.findIndex((line) => line.trim() === "Gate");
|
|
84
88
|
expect(gate).toBeGreaterThan(0);
|
|
85
89
|
expect(lines[gate - 1]).toBe("");
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review panel end to end: the b key cycles expanded -> compact -> hidden
|
|
3
|
+
* -> expanded on the live App, and the collapse mode persists to the user
|
|
4
|
+
* config so it survives a restart. Rendering details are locked by the
|
|
5
|
+
* ReviewPanel stories; this suite proves the App wiring and the persistence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
14
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
15
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
16
|
+
import { App } from "./App";
|
|
17
|
+
import { loadConfig } from "./config";
|
|
18
|
+
import { press, waitForText } from "./test-support";
|
|
19
|
+
|
|
20
|
+
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
21
|
+
|
|
22
|
+
let home: string;
|
|
23
|
+
let server: DaemonServer;
|
|
24
|
+
let session: ReviewSession;
|
|
25
|
+
let configPath: string;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
|
|
29
|
+
configPath = join(home, "config.toml");
|
|
30
|
+
process.env.CUELOOP_CONFIG = configPath;
|
|
31
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
32
|
+
server.start();
|
|
33
|
+
session = server.core.sessionCreate({
|
|
34
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
35
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
delete process.env.CUELOOP_CONFIG;
|
|
40
|
+
server.stop();
|
|
41
|
+
rmSync(home, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
async function renderApp() {
|
|
45
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
|
|
46
|
+
await waitForText(setup, "Review");
|
|
47
|
+
return setup;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("review panel", () => {
|
|
51
|
+
test("b cycles expanded -> compact -> hidden -> expanded", async () => {
|
|
52
|
+
const setup = await renderApp();
|
|
53
|
+
expect(setup.captureCharFrame()).toContain("Submit review"); // expanded rail
|
|
54
|
+
|
|
55
|
+
await press(setup, "b");
|
|
56
|
+
let frame = setup.captureCharFrame();
|
|
57
|
+
expect(frame).toContain("«"); // compact strip shows the expand chevron
|
|
58
|
+
expect(frame).not.toContain("Submit review");
|
|
59
|
+
|
|
60
|
+
await press(setup, "b");
|
|
61
|
+
frame = setup.captureCharFrame();
|
|
62
|
+
expect(frame).not.toContain("«"); // hidden: no strip, no divider
|
|
63
|
+
expect(frame).not.toContain("│");
|
|
64
|
+
|
|
65
|
+
await press(setup, "b");
|
|
66
|
+
expect(setup.captureCharFrame()).toContain("Submit review"); // back to expanded
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the collapse mode persists to the user config", async () => {
|
|
70
|
+
const setup = await renderApp();
|
|
71
|
+
await press(setup, "b"); // -> compact
|
|
72
|
+
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("] widens the rail and persists the new width", async () => {
|
|
76
|
+
const setup = await renderApp();
|
|
77
|
+
const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
78
|
+
await press(setup, "]");
|
|
79
|
+
const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
80
|
+
expect(widened).toBeGreaterThan(startWidth);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
|
|
84
|
+
const setup = await renderApp();
|
|
85
|
+
await press(setup, "b"); // -> compact
|
|
86
|
+
await press(setup, "b"); // -> hidden
|
|
87
|
+
expect(setup.captureCharFrame()).not.toContain("Submit review"); // rail is gone
|
|
88
|
+
|
|
89
|
+
await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
|
|
90
|
+
const frame = setup.captureCharFrame();
|
|
91
|
+
expect(frame).toContain("submit review"); // the confirm card is visible again
|
|
92
|
+
expect(frame).toContain("[Approve]");
|
|
93
|
+
|
|
94
|
+
// the reveal is live-only: the saved panel preference stays as the user left it
|
|
95
|
+
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/App.tsx
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
13
13
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
14
|
-
import type { Clock } from "@opentui/core";
|
|
14
|
+
import type { Clock, MouseEvent } from "@opentui/core";
|
|
15
15
|
import { isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
|
|
16
16
|
import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view-plan";
|
|
17
17
|
import { noteForFile, viewedCount } from "./walk";
|
|
@@ -26,13 +26,29 @@ import { StatusBar } from "./components/primitives/StatusBar";
|
|
|
26
26
|
import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
|
|
27
27
|
import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
|
|
28
28
|
import { DiffSheet } from "./components/DiffSheet";
|
|
29
|
-
import {
|
|
29
|
+
import { annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
|
|
30
|
+
import { ReviewPanel } from "./components/ReviewPanel";
|
|
31
|
+
import {
|
|
32
|
+
REVIEW_DEFAULT_WIDTH,
|
|
33
|
+
REVIEW_RESIZE_STEP,
|
|
34
|
+
cycleReviewPanelMode,
|
|
35
|
+
resolveReviewWidth,
|
|
36
|
+
toggleReviewPanelMode,
|
|
37
|
+
widthFromMouseColumn,
|
|
38
|
+
type ReviewPanelMode,
|
|
39
|
+
} from "./review-panel";
|
|
30
40
|
import { VERDICTS } from "./components/ConfirmCard";
|
|
31
41
|
import { CompletionOverlay } from "./components/CompletionOverlay";
|
|
32
42
|
import { InboxList } from "./components/InboxList";
|
|
33
43
|
import { ComposeBar } from "./components/ComposeBar";
|
|
34
44
|
import { WalkWizard } from "./components/WalkWizard";
|
|
35
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The breadcrumb header and the status bar each occupy one terminal row; the
|
|
48
|
+
* review layout (plan column, divider, rail) gets the rows that remain.
|
|
49
|
+
*/
|
|
50
|
+
const CHROME_ROWS = 2;
|
|
51
|
+
|
|
36
52
|
export interface AppProps {
|
|
37
53
|
home?: string;
|
|
38
54
|
sessionId?: string;
|
|
@@ -78,7 +94,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
78
94
|
controller.getSnapshot,
|
|
79
95
|
);
|
|
80
96
|
const renderer = useRenderer();
|
|
81
|
-
const { width: terminalWidth } = useTerminalDimensions();
|
|
97
|
+
const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
|
|
82
98
|
|
|
83
99
|
// ── view state ──────────────────────────────
|
|
84
100
|
const [cursor, setCursor] = useState(0);
|
|
@@ -86,6 +102,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
86
102
|
const [mode, setMode] = useState<Mode>({ type: "normal" });
|
|
87
103
|
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
|
|
88
104
|
const [railTab, setRailTab] = useState<"review" | "agent">("review");
|
|
105
|
+
// review panel layout: mode + expanded width are client view state, loaded
|
|
106
|
+
// from and persisted to the user config so they survive a restart. The ref
|
|
107
|
+
// mirrors the width so the drag-end persist reads the latest value.
|
|
108
|
+
const [reviewMode, setReviewMode] = useState<ReviewPanelMode>("expanded");
|
|
109
|
+
const [reviewWidth, setReviewWidth] = useState(REVIEW_DEFAULT_WIDTH);
|
|
110
|
+
const [dividerDragging, setDividerDragging] = useState(false);
|
|
111
|
+
const reviewWidthRef = useRef(REVIEW_DEFAULT_WIDTH);
|
|
89
112
|
// ~2s focus pulse on the document highlight when a rail card is activated
|
|
90
113
|
const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
|
|
91
114
|
const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -103,6 +126,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
103
126
|
keysRef.current = config.keys;
|
|
104
127
|
keyBindings.setKeys(config.keys);
|
|
105
128
|
setTheme(config.theme);
|
|
129
|
+
setReviewMode(config.ui.reviewState);
|
|
130
|
+
setReviewWidth(config.ui.reviewWidth);
|
|
131
|
+
reviewWidthRef.current = config.ui.reviewWidth;
|
|
106
132
|
controller.applyConfig(config);
|
|
107
133
|
}, [session?.workspace.repoRoot, controller, keyBindings]);
|
|
108
134
|
useEffect(
|
|
@@ -257,7 +283,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
257
283
|
case "openSubmit":
|
|
258
284
|
if (!session) return;
|
|
259
285
|
liveInput.current = "";
|
|
260
|
-
// the confirm card lives in the review
|
|
286
|
+
// the confirm card lives in the expanded review rail; a compact or hidden
|
|
287
|
+
// panel would swallow the whole submit flow, so force the rail open (live
|
|
288
|
+
// only - the saved panel preference is left untouched)
|
|
289
|
+
setReviewMode("expanded");
|
|
261
290
|
setRailTab("review");
|
|
262
291
|
return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
|
|
263
292
|
case "cut":
|
|
@@ -317,7 +346,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
317
346
|
return void setMode({ type: "normal" });
|
|
318
347
|
case "cycleVerdict": {
|
|
319
348
|
if (mode.type !== "submit") return;
|
|
320
|
-
const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.
|
|
349
|
+
const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
|
|
321
350
|
return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
|
|
322
351
|
}
|
|
323
352
|
case "finishReview":
|
|
@@ -326,6 +355,18 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
326
355
|
return controller.optInAutoClose();
|
|
327
356
|
case "dismissCompletion":
|
|
328
357
|
return controller.dismissCompletion();
|
|
358
|
+
case "cycleReviewPanel": {
|
|
359
|
+
const next = cycleReviewPanelMode(reviewMode);
|
|
360
|
+
setReviewMode(next);
|
|
361
|
+
return controller.saveReviewPanel({ mode: next });
|
|
362
|
+
}
|
|
363
|
+
case "resizeReviewPanel": {
|
|
364
|
+
if (reviewMode !== "expanded") return;
|
|
365
|
+
const next = resolveReviewWidth(reviewWidth + intent.direction * REVIEW_RESIZE_STEP, terminalWidth);
|
|
366
|
+
reviewWidthRef.current = next;
|
|
367
|
+
setReviewWidth(next);
|
|
368
|
+
return controller.saveReviewPanel({ width: next });
|
|
369
|
+
}
|
|
329
370
|
}
|
|
330
371
|
};
|
|
331
372
|
|
|
@@ -360,7 +401,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
360
401
|
};
|
|
361
402
|
keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
|
|
362
403
|
const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
|
|
363
|
-
for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift }, action))
|
|
404
|
+
for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift, meta: !!key.meta }, action))
|
|
405
|
+
dispatch(intent);
|
|
364
406
|
});
|
|
365
407
|
|
|
366
408
|
// ── render ──────────────────────────────────
|
|
@@ -449,6 +491,18 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
449
491
|
dispatch({ type: "openSubmit" });
|
|
450
492
|
};
|
|
451
493
|
|
|
494
|
+
// the clickable chevron toggles expanded <-> compact; the keybinding (b)
|
|
495
|
+
// cycles all three including hidden
|
|
496
|
+
const onToggleReviewPanel = (): void => {
|
|
497
|
+
const next = toggleReviewPanelMode(reviewMode);
|
|
498
|
+
setReviewMode(next);
|
|
499
|
+
controller.saveReviewPanel({ mode: next });
|
|
500
|
+
};
|
|
501
|
+
// grabbing the divider only arms a drag when there is a width to drag
|
|
502
|
+
const onDividerGrab = (): void => {
|
|
503
|
+
if (reviewMode === "expanded") setDividerDragging(true);
|
|
504
|
+
};
|
|
505
|
+
|
|
452
506
|
const submitConfirmState =
|
|
453
507
|
mode.type === "submit"
|
|
454
508
|
? {
|
|
@@ -507,11 +561,23 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
507
561
|
: focusedAnnotationId !== undefined
|
|
508
562
|
? "card"
|
|
509
563
|
: "normal";
|
|
510
|
-
const railWidth = terminalWidth >= 100 ? 34 : 28;
|
|
511
564
|
|
|
512
565
|
return (
|
|
513
566
|
<ThemeProvider theme={theme}>
|
|
514
|
-
<box
|
|
567
|
+
<box
|
|
568
|
+
style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg }}
|
|
569
|
+
onMouseDrag={(event: MouseEvent) => {
|
|
570
|
+
if (!dividerDragging || reviewMode !== "expanded") return;
|
|
571
|
+
const next = widthFromMouseColumn(event.x, terminalWidth);
|
|
572
|
+
reviewWidthRef.current = next;
|
|
573
|
+
setReviewWidth(next);
|
|
574
|
+
}}
|
|
575
|
+
onMouseUp={() => {
|
|
576
|
+
if (!dividerDragging) return;
|
|
577
|
+
setDividerDragging(false);
|
|
578
|
+
controller.saveReviewPanel({ width: reviewWidthRef.current });
|
|
579
|
+
}}
|
|
580
|
+
>
|
|
515
581
|
<Breadcrumb items={headerItems} />
|
|
516
582
|
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
517
583
|
{isDiff ? (
|
|
@@ -537,20 +603,27 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
|
|
|
537
603
|
onEditRequest={onEditRequest}
|
|
538
604
|
/>
|
|
539
605
|
)}
|
|
540
|
-
<
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
606
|
+
<ReviewPanel
|
|
607
|
+
mode={reviewMode}
|
|
608
|
+
width={resolveReviewWidth(reviewWidth, terminalWidth)}
|
|
609
|
+
height={terminalHeight - CHROME_ROWS}
|
|
610
|
+
dragging={dividerDragging}
|
|
611
|
+
onDividerGrab={onDividerGrab}
|
|
612
|
+
onToggle={onToggleReviewPanel}
|
|
613
|
+
railRef={railRef}
|
|
614
|
+
rail={{
|
|
615
|
+
session: activeSession,
|
|
616
|
+
selectedId: focusedAnnotationId,
|
|
617
|
+
resolvedIds: isDiff ? null : resolvedIds,
|
|
618
|
+
railTab,
|
|
619
|
+
pendingCount,
|
|
620
|
+
cardEdit: cardEditState,
|
|
621
|
+
submitConfirm: submitConfirmState,
|
|
622
|
+
onTabChange: setRailTab,
|
|
623
|
+
onSelectCard: selectCardFromRail,
|
|
624
|
+
onActivateCard: openCardEdit,
|
|
625
|
+
onSubmitRequest,
|
|
626
|
+
}}
|
|
554
627
|
/>
|
|
555
628
|
</box>
|
|
556
629
|
{mode.type === "compose" && isDiff ? (
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's auto-grow row math as a pure function: hard newlines and soft
|
|
3
|
+
* wrapping both add rows, growth stops at the cap, and a non-positive width
|
|
4
|
+
* (before layout is known) degrades to a hard-newline count. The live textarea
|
|
5
|
+
* scrolls internally past the cap, so this only needs to get the growth curve
|
|
6
|
+
* and the ceiling right.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { COMPOSE_MAX_ROWS, composeRowCount } from "./AnnotationCard";
|
|
11
|
+
|
|
12
|
+
describe("composeRowCount", () => {
|
|
13
|
+
const width = 10;
|
|
14
|
+
|
|
15
|
+
test("empty and short text are one row", () => {
|
|
16
|
+
expect(composeRowCount("", width)).toBe(1);
|
|
17
|
+
expect(composeRowCount("hi", width)).toBe(1);
|
|
18
|
+
expect(composeRowCount("exactly-10", width)).toBe(1); // length 10 / width 10
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("hard newlines add rows", () => {
|
|
22
|
+
expect(composeRowCount("a\nb", width)).toBe(2);
|
|
23
|
+
expect(composeRowCount("a\nb\nc", width)).toBe(3);
|
|
24
|
+
// a blank trailing line still occupies a row
|
|
25
|
+
expect(composeRowCount("a\n", width)).toBe(2);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a soft-wrapped long line grows the box", () => {
|
|
29
|
+
expect(composeRowCount("a".repeat(11), width)).toBe(2); // ceil(11/10)
|
|
30
|
+
expect(composeRowCount("a".repeat(25), width)).toBe(3); // ceil(25/10)
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("wrapping and hard newlines combine", () => {
|
|
34
|
+
// 1 row for "short" plus ceil(21/10)=3 rows for the long line
|
|
35
|
+
expect(composeRowCount("short\n" + "b".repeat(21), width)).toBe(4);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("growth is capped", () => {
|
|
39
|
+
expect(composeRowCount("a".repeat(100), width)).toBe(COMPOSE_MAX_ROWS);
|
|
40
|
+
expect(composeRowCount("l\ni\nn\ne\ns\nx", width)).toBe(COMPOSE_MAX_ROWS);
|
|
41
|
+
expect(composeRowCount("a\nb", width, 1)).toBe(1); // custom cap
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a non-positive width counts hard newlines only, never divides by zero", () => {
|
|
45
|
+
expect(composeRowCount("a".repeat(100), 0)).toBe(1);
|
|
46
|
+
expect(composeRowCount("a\nb\nc", 0)).toBe(3);
|
|
47
|
+
expect(composeRowCount("a".repeat(100), -5)).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* its body in place. Compose, saved, and re-edit share this rendering path,
|
|
5
5
|
* so the kind-color and quote treatment can never drift between them.
|
|
6
6
|
*
|
|
7
|
-
* Bodies are multiline: the composer is a textarea (wrap, undo/redo
|
|
8
|
-
* shift+⏎
|
|
7
|
+
* Bodies are multiline: the composer is a textarea (wrap, undo/redo). Plain ⏎
|
|
8
|
+
* stays the save key while ⌥/Alt+⏎ and shift+⏎ insert a new line (the Slack
|
|
9
|
+
* convention). The box auto-grows with the wrapped text up to a cap, then
|
|
10
|
+
* scrolls internally.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
|
-
import React, { useEffect, useRef } from "react";
|
|
13
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
12
14
|
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
13
15
|
import type { Theme } from "../theme";
|
|
14
16
|
import { useComponentTheme } from "./theme-context";
|
|
@@ -43,24 +45,63 @@ export interface AnnotationCardProps {
|
|
|
43
45
|
theme?: Theme;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* ⏎ submits (which keeps the textarea from inserting its default newline; the
|
|
50
|
+
* grammar owns the actual save). ⌥/Alt+⏎ and shift+⏎ insert a newline so the
|
|
51
|
+
* body can grow multiline without leaving the composer.
|
|
52
|
+
*
|
|
53
|
+
* Option/Alt reaches a binding as `meta`, not `option`: a binding key here is
|
|
54
|
+
* built from name+ctrl+shift+meta+super only, so the newline binding matches on
|
|
55
|
+
* `meta`. This also overrides the textarea default that maps meta+⏎ to submit.
|
|
56
|
+
*/
|
|
47
57
|
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
48
58
|
{ name: "return", action: "submit" },
|
|
59
|
+
{ name: "return", meta: true, action: "newline" },
|
|
49
60
|
{ name: "return", shift: true, action: "newline" },
|
|
50
61
|
];
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
/** The composer never grows past this many rows; beyond it the textarea scrolls. */
|
|
64
|
+
export const COMPOSE_MAX_ROWS = 4;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Visible row count for the composer, so a soft-wrapped long line grows the box
|
|
68
|
+
* just like a hard newline does. Each logical line takes `ceil(length /
|
|
69
|
+
* contentWidth)` visual rows (at least one), summed over the hard-newline lines
|
|
70
|
+
* and capped. A non-positive width (before layout is known) counts hard
|
|
71
|
+
* newlines only. Beyond the cap the textarea scrolls internally and keeps the
|
|
72
|
+
* caret line in view.
|
|
73
|
+
*/
|
|
74
|
+
export function composeRowCount(text: string, contentWidth: number, cap: number = COMPOSE_MAX_ROWS): number {
|
|
75
|
+
const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
|
|
76
|
+
let visualRowCount = 0;
|
|
77
|
+
for (const line of text.split("\n")) {
|
|
78
|
+
visualRowCount += Math.max(1, Math.ceil(line.length / usableWidth));
|
|
79
|
+
}
|
|
80
|
+
return Math.min(cap, Math.max(1, visualRowCount));
|
|
54
81
|
}
|
|
55
82
|
|
|
56
|
-
function DraftEditor({
|
|
83
|
+
function DraftEditor({
|
|
84
|
+
draft,
|
|
85
|
+
rows,
|
|
86
|
+
onRowsChange,
|
|
87
|
+
theme,
|
|
88
|
+
}: {
|
|
89
|
+
draft: AnnotationDraft;
|
|
90
|
+
/** Current visible height in rows; owned by the parent so the card frame agrees. */
|
|
91
|
+
rows: number;
|
|
92
|
+
onRowsChange: (rows: number) => void;
|
|
93
|
+
theme?: Theme;
|
|
94
|
+
}): React.ReactNode {
|
|
57
95
|
const tokens = useComponentTheme(theme);
|
|
58
96
|
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
59
97
|
// re-edit opens with the caret after the existing body, like a text field
|
|
60
98
|
const initialTextLength = useRef(draft.text.length);
|
|
61
99
|
useEffect(() => {
|
|
62
100
|
const editor = editorRef.current;
|
|
63
|
-
if (editor)
|
|
101
|
+
if (!editor) return;
|
|
102
|
+
editor.cursorOffset = initialTextLength.current;
|
|
103
|
+
// the box is laid out now, so its width is known: size to the wrapped body
|
|
104
|
+
onRowsChange(composeRowCount(editor.plainText, editor.width));
|
|
64
105
|
}, []);
|
|
65
106
|
return (
|
|
66
107
|
<>
|
|
@@ -72,10 +113,12 @@ function DraftEditor({ draft, theme }: { draft: AnnotationDraft; theme?: Theme }
|
|
|
72
113
|
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
73
114
|
onContentChange={() => {
|
|
74
115
|
const editor = editorRef.current;
|
|
75
|
-
if (editor)
|
|
116
|
+
if (!editor) return;
|
|
117
|
+
draft.onInput(editor.plainText);
|
|
118
|
+
onRowsChange(composeRowCount(editor.plainText, editor.width));
|
|
76
119
|
}}
|
|
77
120
|
style={{
|
|
78
|
-
height:
|
|
121
|
+
height: rows,
|
|
79
122
|
backgroundColor: tokens.elevated,
|
|
80
123
|
focusedBackgroundColor: tokens.elevated,
|
|
81
124
|
textColor: tokens.text,
|
|
@@ -97,18 +140,28 @@ function DraftEditor({ draft, theme }: { draft: AnnotationDraft; theme?: Theme }
|
|
|
97
140
|
export function AnnotationCard({ id, kind, quote, draft, saved, theme }: AnnotationCardProps): React.ReactNode {
|
|
98
141
|
const tokens = useComponentTheme(theme);
|
|
99
142
|
const kindColor = kind === "suggestion" ? tokens.green : tokens.accent;
|
|
143
|
+
const activeDraft = draft ?? saved?.editing ?? null;
|
|
144
|
+
// The composer's visible height, grown from the wrapped text. It is shared
|
|
145
|
+
// with the card frame (contentRows) so the border never clips the textarea
|
|
146
|
+
// or the toolbar. Seeded from hard newlines, then corrected once the mounted
|
|
147
|
+
// textarea reports its measured width.
|
|
148
|
+
const [editorRowCount, setEditorRowCount] = useState(() =>
|
|
149
|
+
// Width 0 is the "layout not known yet" contract: count hard newlines only,
|
|
150
|
+
// until the mounted textarea reports its measured width below.
|
|
151
|
+
composeRowCount(activeDraft?.text ?? "", 0),
|
|
152
|
+
);
|
|
100
153
|
if (draft) {
|
|
101
154
|
const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
|
|
102
155
|
return (
|
|
103
156
|
<Card
|
|
104
157
|
title={` ${verb} "${truncateToSingleLine(quote, 40)}" `}
|
|
105
|
-
contentRows={
|
|
158
|
+
contentRows={editorRowCount + 1}
|
|
106
159
|
borderColor={kindColor}
|
|
107
160
|
marginLeft={2}
|
|
108
161
|
marginRight={2}
|
|
109
162
|
theme={theme}
|
|
110
163
|
>
|
|
111
|
-
<DraftEditor draft={draft} theme={theme} />
|
|
164
|
+
<DraftEditor draft={draft} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
|
|
112
165
|
</Card>
|
|
113
166
|
);
|
|
114
167
|
}
|
|
@@ -127,7 +180,7 @@ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: Annotat
|
|
|
127
180
|
</text>
|
|
128
181
|
<text fg={tokens.textDim}> "{truncateToSingleLine(quote, 26)}"</text>
|
|
129
182
|
{card.editing ? (
|
|
130
|
-
<DraftEditor draft={card.editing} theme={theme} />
|
|
183
|
+
<DraftEditor draft={card.editing} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
|
|
131
184
|
) : (
|
|
132
185
|
<text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}> {truncateToSingleLine(card.body, 28)}</text>
|
|
133
186
|
)}
|