@cueloop/client 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +1 -1
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +16 -16
- package/src/App.test.tsx +1 -1
- package/src/App.tsx +193 -139
- package/src/App.walk.test.tsx +208 -0
- package/src/components/AnnotationCard.tsx +4 -4
- package/src/components/ComposeBar.tsx +2 -2
- package/src/components/ConfirmCard.stories.tsx +15 -0
- package/src/components/ConfirmCard.tsx +8 -1
- package/src/components/DiffSheet.tsx +8 -8
- package/src/components/PlanSheet.stories.tsx +3 -3
- package/src/components/PlanSheet.tsx +31 -31
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +130 -0
- package/src/components/stories.test.tsx +1 -1
- package/src/components/story-fixtures.ts +1 -1
- package/src/components/story.ts +1 -1
- package/src/components/syntax-highlight.ts +1 -1
- package/src/components/theme-context.tsx +1 -1
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +29 -29
- package/src/config.ts +13 -12
- package/src/editor.ts +1 -1
- package/src/herdr.ts +1 -1
- package/src/key-bindings.test.ts +18 -1
- package/src/key-bindings.ts +23 -2
- package/src/keymap.test.ts +138 -101
- package/src/keymap.ts +81 -56
- package/src/run.ts +3 -3
- package/src/serve.test.ts +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +140 -66
- package/src/theme.ts +20 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/{view.test.ts → view-plan.test.ts} +48 -48
- package/src/view-plan.ts +362 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
- package/src/components/format.ts +0 -6
- package/src/view.ts +0 -341
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided walk in the virtual terminal: the wizard steps every
|
|
3
|
+
* changed file with a plain progress title, ] marks viewed and persists with
|
|
4
|
+
* the session, esc keeps progress, w resumes at the first unviewed file
|
|
5
|
+
* (across App instances - the daemon round-trip), the agent-note block
|
|
6
|
+
* renders only for files carrying a note, and the end card hands over to the
|
|
7
|
+
* submit confirm with the honest viewed count.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import React from "react";
|
|
15
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
16
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
17
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
18
|
+
import { App } from "./App";
|
|
19
|
+
import { DARK } from "./theme";
|
|
20
|
+
import { press, waitForState, waitForText, waitForTextGone } from "./test-support";
|
|
21
|
+
|
|
22
|
+
const PATCH = `diff --git a/src/a.ts b/src/a.ts
|
|
23
|
+
index 111..222 100644
|
|
24
|
+
--- a/src/a.ts
|
|
25
|
+
+++ b/src/a.ts
|
|
26
|
+
@@ -1,2 +1,2 @@
|
|
27
|
+
export const a = 1;
|
|
28
|
+
-export const b = 2;
|
|
29
|
+
+export const b = 3;
|
|
30
|
+
diff --git a/src/b.ts b/src/b.ts
|
|
31
|
+
index 333..444 100644
|
|
32
|
+
--- a/src/b.ts
|
|
33
|
+
+++ b/src/b.ts
|
|
34
|
+
@@ -1,2 +1,2 @@
|
|
35
|
+
// header
|
|
36
|
+
-old line
|
|
37
|
+
+new line
|
|
38
|
+
diff --git a/src/c.ts b/src/c.ts
|
|
39
|
+
index 555..666 100644
|
|
40
|
+
--- a/src/c.ts
|
|
41
|
+
+++ b/src/c.ts
|
|
42
|
+
@@ -1,1 +1,2 @@
|
|
43
|
+
keep
|
|
44
|
+
+added tail
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
let home: string;
|
|
48
|
+
let server: DaemonServer;
|
|
49
|
+
let session: ReviewSession;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-walk-"));
|
|
53
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
54
|
+
server.start();
|
|
55
|
+
session = server.core.sessionCreate({
|
|
56
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
57
|
+
artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
server.stop();
|
|
62
|
+
rmSync(home, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function renderApp() {
|
|
66
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 34 });
|
|
67
|
+
// Wait for session content, not just the chrome: pressing w before the diff
|
|
68
|
+
// loads drops the walk intent (no session, no diff view) and hangs the test.
|
|
69
|
+
await waitForText(setup, "src/a.ts");
|
|
70
|
+
return setup;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
74
|
+
|
|
75
|
+
/** Foreground colors (hex) of every styled span containing the needle. */
|
|
76
|
+
function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
77
|
+
const foregrounds: string[] = [];
|
|
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
|
+
foregrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return foregrounds;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("the guided walk", () => {
|
|
89
|
+
test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
|
|
90
|
+
const setup = await renderApp();
|
|
91
|
+
await press(setup, "w");
|
|
92
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
93
|
+
expect(setup.captureCharFrame()).toContain("src/a.ts");
|
|
94
|
+
expect(setup.captureCharFrame()).toContain("+1 -1");
|
|
95
|
+
|
|
96
|
+
await press(setup, "]");
|
|
97
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
98
|
+
// the viewed mark rides the session record, not the client
|
|
99
|
+
await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
|
|
100
|
+
|
|
101
|
+
// [ steps back; the revisited card shows its viewed marker
|
|
102
|
+
await press(setup, "[");
|
|
103
|
+
await waitForText(setup, "file 1 of 3 · 1 viewed");
|
|
104
|
+
expect(setup.captureCharFrame()).toContain("· viewed");
|
|
105
|
+
await press(setup, "]");
|
|
106
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
107
|
+
|
|
108
|
+
await press(setup, "]");
|
|
109
|
+
await waitForText(setup, "file 3 of 3 · 2 viewed");
|
|
110
|
+
await press(setup, "]");
|
|
111
|
+
await waitForText(setup, "walk complete");
|
|
112
|
+
expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
|
|
113
|
+
expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
114
|
+
|
|
115
|
+
// the end card's return leaves the walk and opens the rail confirm
|
|
116
|
+
await press(setup, "enter");
|
|
117
|
+
await waitForText(setup, "submit review");
|
|
118
|
+
expect(setup.captureCharFrame()).toContain("3/3 files viewed");
|
|
119
|
+
await press(setup, "enter");
|
|
120
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("esc keeps progress and w resumes at the first unviewed file", async () => {
|
|
124
|
+
const setup = await renderApp();
|
|
125
|
+
await press(setup, "w");
|
|
126
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
127
|
+
await press(setup, "]");
|
|
128
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
129
|
+
|
|
130
|
+
await press(setup, "escape");
|
|
131
|
+
await waitForTextGone(setup, "file 2 of 3");
|
|
132
|
+
// the sheet is back at full strength: no wizard card on screen
|
|
133
|
+
expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
|
|
134
|
+
|
|
135
|
+
await press(setup, "w");
|
|
136
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a half-walked review resumes across App instances via the daemon", async () => {
|
|
140
|
+
const first = await renderApp();
|
|
141
|
+
await press(first, "w");
|
|
142
|
+
await waitForText(first, "file 1 of 3 · 0 viewed");
|
|
143
|
+
await press(first, "]");
|
|
144
|
+
await press(first, "]");
|
|
145
|
+
await waitForText(first, "file 3 of 3 · 2 viewed");
|
|
146
|
+
await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
|
|
147
|
+
first.renderer.destroy();
|
|
148
|
+
|
|
149
|
+
// a fresh client reads the viewed set back from the session record
|
|
150
|
+
const second = await renderApp();
|
|
151
|
+
await press(second, "w");
|
|
152
|
+
await waitForText(second, "file 3 of 3 · 2 viewed");
|
|
153
|
+
expect(second.captureCharFrame()).toContain("src/c.ts");
|
|
154
|
+
// two full App boots with daemon round-trips need more than the default budget
|
|
155
|
+
}, 15_000);
|
|
156
|
+
|
|
157
|
+
test("the agent-note block renders only for files carrying a note", async () => {
|
|
158
|
+
server.core.sessionAnnotate(session.id, {
|
|
159
|
+
id: "note-b",
|
|
160
|
+
kind: "note",
|
|
161
|
+
anchor: { quote: "src/b.ts", prefix: "", suffix: "" },
|
|
162
|
+
body: "Swaps the stale line for the new constant.",
|
|
163
|
+
});
|
|
164
|
+
const setup = await renderApp();
|
|
165
|
+
await press(setup, "w");
|
|
166
|
+
await waitForText(setup, "file 1 of 3");
|
|
167
|
+
expect(setup.captureCharFrame()).not.toContain("agent note");
|
|
168
|
+
|
|
169
|
+
await press(setup, "]");
|
|
170
|
+
await waitForText(setup, "agent note");
|
|
171
|
+
expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
|
|
172
|
+
|
|
173
|
+
// a note is agent context, not reviewer feedback: it neither counts nor
|
|
174
|
+
// flips the default verdict, and never comes back in the feedback doc
|
|
175
|
+
await press(setup, "escape");
|
|
176
|
+
await waitForTextGone(setup, "agent note");
|
|
177
|
+
await press(setup, "enter");
|
|
178
|
+
await waitForText(setup, "0 annotations");
|
|
179
|
+
await press(setup, "enter");
|
|
180
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
181
|
+
const resolved = server.core.sessionGet(session.id);
|
|
182
|
+
expect(resolved.verdict!.kind).toBe("approve");
|
|
183
|
+
expect(resolved.verdict!.feedback).not.toContain("Swaps the stale line");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
|
|
187
|
+
const setup = await renderApp();
|
|
188
|
+
// before the walk the sheet's added line wears the insertion color
|
|
189
|
+
await waitForText(setup, "+new line");
|
|
190
|
+
expect(foregroundsOf(setup, "+new line")).toContain(DARK.insFg);
|
|
191
|
+
|
|
192
|
+
await press(setup, "w");
|
|
193
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
194
|
+
// dimmed: the sheet line drops to the dim token...
|
|
195
|
+
expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
|
|
196
|
+
// ...while the wizard preview keeps the insertion color
|
|
197
|
+
expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insFg);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("walking a resolved review answers read-only", async () => {
|
|
201
|
+
server.core.sessionResolve(session.id, "approve", "");
|
|
202
|
+
const setup = await renderApp();
|
|
203
|
+
await waitForText(setup, "resolved");
|
|
204
|
+
await press(setup, "w");
|
|
205
|
+
await waitForText(setup, "review submitted - read-only");
|
|
206
|
+
expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -15,7 +15,7 @@ import { useComponentTheme } from "./theme-context";
|
|
|
15
15
|
import { Card } from "./primitives/Card";
|
|
16
16
|
import { Button } from "./primitives/Button";
|
|
17
17
|
import { Toolbar } from "./primitives/Toolbar";
|
|
18
|
-
import {
|
|
18
|
+
import { truncateToSingleLine } from "./truncate-text";
|
|
19
19
|
|
|
20
20
|
export interface AnnotationDraft {
|
|
21
21
|
text: string;
|
|
@@ -101,7 +101,7 @@ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: Annotat
|
|
|
101
101
|
const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
|
|
102
102
|
return (
|
|
103
103
|
<Card
|
|
104
|
-
title={` ${verb} "${
|
|
104
|
+
title={` ${verb} "${truncateToSingleLine(quote, 40)}" `}
|
|
105
105
|
contentRows={editorRows(draft.text) + 1}
|
|
106
106
|
borderColor={kindColor}
|
|
107
107
|
marginLeft={2}
|
|
@@ -125,11 +125,11 @@ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: Annotat
|
|
|
125
125
|
{card.isBlocking ? <span fg={tokens.red}> · BLOCKING</span> : null}
|
|
126
126
|
<span fg={tokens.textDim}>{card.isOrphan ? " · ORPHANED" : " · pending"}</span>
|
|
127
127
|
</text>
|
|
128
|
-
<text fg={tokens.textDim}> "{
|
|
128
|
+
<text fg={tokens.textDim}> "{truncateToSingleLine(quote, 26)}"</text>
|
|
129
129
|
{card.editing ? (
|
|
130
130
|
<DraftEditor draft={card.editing} theme={theme} />
|
|
131
131
|
) : (
|
|
132
|
-
<text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}> {
|
|
132
|
+
<text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}> {truncateToSingleLine(card.body, 28)}</text>
|
|
133
133
|
)}
|
|
134
134
|
</box>
|
|
135
135
|
);
|
|
@@ -8,7 +8,7 @@ import React, { useRef } from "react";
|
|
|
8
8
|
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
9
9
|
import type { Theme } from "../theme";
|
|
10
10
|
import { useComponentTheme } from "./theme-context";
|
|
11
|
-
import {
|
|
11
|
+
import { truncateToSingleLine } from "./truncate-text";
|
|
12
12
|
|
|
13
13
|
export interface ComposeBarProps {
|
|
14
14
|
kind: "comment" | "suggestion";
|
|
@@ -30,7 +30,7 @@ export function ComposeBar({ kind, quote, text, onInput, theme }: ComposeBarProp
|
|
|
30
30
|
return (
|
|
31
31
|
<box style={{ height: 1 + editorRows, backgroundColor: tokens.elevated, flexDirection: "column", paddingLeft: 1 }}>
|
|
32
32
|
<text fg={kind === "suggestion" ? tokens.green : tokens.accent}>
|
|
33
|
-
{kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{
|
|
33
|
+
{kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncateToSingleLine(quote, 60)}” · ⏎ save · esc cancel
|
|
34
34
|
</text>
|
|
35
35
|
<textarea
|
|
36
36
|
ref={editorRef}
|
|
@@ -20,3 +20,18 @@ export const RequestChanges: Story = {
|
|
|
20
20
|
expectedColors: [DARK.red],
|
|
21
21
|
size: { width: 40, height: 14 },
|
|
22
22
|
};
|
|
23
|
+
|
|
24
|
+
export const WithViewedSummary: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<ConfirmCard
|
|
27
|
+
verdict="approve"
|
|
28
|
+
summary=""
|
|
29
|
+
annotationCount={1}
|
|
30
|
+
blockingCount={0}
|
|
31
|
+
viewedSummary="2/3 files viewed"
|
|
32
|
+
{...callbacks}
|
|
33
|
+
/>
|
|
34
|
+
),
|
|
35
|
+
expectedColors: [DARK.green, DARK.textDim],
|
|
36
|
+
size: { width: 40, height: 14 },
|
|
37
|
+
};
|
|
@@ -28,6 +28,11 @@ export interface ConfirmCardProps {
|
|
|
28
28
|
summary: string;
|
|
29
29
|
annotationCount: number;
|
|
30
30
|
blockingCount: number;
|
|
31
|
+
/**
|
|
32
|
+
* The guided walk's honest coverage line for diff sessions, e.g.
|
|
33
|
+
* "2/3 files viewed". Undefined = no walk data, the row does not render.
|
|
34
|
+
*/
|
|
35
|
+
viewedSummary?: string;
|
|
31
36
|
onInput: (summary: string) => void;
|
|
32
37
|
onSelectVerdict: (verdict: VerdictKind) => void;
|
|
33
38
|
onSubmit: () => void;
|
|
@@ -76,6 +81,7 @@ export function ConfirmCard({
|
|
|
76
81
|
summary,
|
|
77
82
|
annotationCount,
|
|
78
83
|
blockingCount,
|
|
84
|
+
viewedSummary,
|
|
79
85
|
onInput,
|
|
80
86
|
onSelectVerdict,
|
|
81
87
|
onSubmit,
|
|
@@ -86,12 +92,13 @@ export function ConfirmCard({
|
|
|
86
92
|
return (
|
|
87
93
|
<Card
|
|
88
94
|
title=" submit review "
|
|
89
|
-
contentRows={CONFIRM_CONTENT_ROWS}
|
|
95
|
+
contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 1 : 0)}
|
|
90
96
|
borderColor={tokens.accent}
|
|
91
97
|
marginRight={1}
|
|
92
98
|
theme={theme}
|
|
93
99
|
>
|
|
94
100
|
<text fg={tokens.textDim}>{`${annotationCount} annotations · ${blockingCount} blocking`}</text>
|
|
101
|
+
{viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
|
|
95
102
|
<box style={{ height: 1 }} />
|
|
96
103
|
<VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
|
|
97
104
|
<box style={{ height: 1 }} />
|
|
@@ -13,7 +13,7 @@ import type { Annotation } from "@cueloop/schema";
|
|
|
13
13
|
import type { DiffRow } from "../view-diff";
|
|
14
14
|
import type { Theme } from "../theme";
|
|
15
15
|
import { useComponentTheme } from "./theme-context";
|
|
16
|
-
import {
|
|
16
|
+
import { truncateToSingleLine } from "./truncate-text";
|
|
17
17
|
|
|
18
18
|
export interface DiffSheetProps {
|
|
19
19
|
rows: DiffRow[];
|
|
@@ -43,7 +43,7 @@ function segmentRows(rows: DiffRow[], annotatedByRow: Map<number, Annotation>):
|
|
|
43
43
|
};
|
|
44
44
|
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
45
45
|
const row = rows[rowIndex]!;
|
|
46
|
-
if (row.
|
|
46
|
+
if (row.kind === "file" || row.kind === "hunk") {
|
|
47
47
|
closeChunk(null);
|
|
48
48
|
segments.push({ kind: "header", rowIndex, row });
|
|
49
49
|
chunkStart = rowIndex + 1;
|
|
@@ -79,7 +79,7 @@ function DiffChunk({
|
|
|
79
79
|
const lineNumbers = useMemo(() => {
|
|
80
80
|
const numbers = new Map<number, number>();
|
|
81
81
|
segment.rows.forEach((row, lineIndex) => {
|
|
82
|
-
const gutterNumber = row.
|
|
82
|
+
const gutterNumber = row.kind === "del" ? row.oldLine : row.newLine;
|
|
83
83
|
if (gutterNumber !== undefined) numbers.set(lineIndex, gutterNumber);
|
|
84
84
|
});
|
|
85
85
|
return numbers;
|
|
@@ -132,8 +132,8 @@ function DiffChunk({
|
|
|
132
132
|
>
|
|
133
133
|
<text style={{ wrapMode: "none" }} selectable={false}>
|
|
134
134
|
{segment.rows.map((row, lineIndex) => {
|
|
135
|
-
const sign = row.
|
|
136
|
-
const foreground = row.
|
|
135
|
+
const sign = row.kind === "add" ? "+" : row.kind === "del" ? "-" : " ";
|
|
136
|
+
const foreground = row.kind === "add" ? tokens.insFg : row.kind === "del" ? tokens.delFg : tokens.textMuted;
|
|
137
137
|
const isCursorRow = lineIndex === cursorInChunk;
|
|
138
138
|
const isAnnotatedRow = segment.annotation !== null && lineIndex === segment.rows.length - 1;
|
|
139
139
|
return (
|
|
@@ -152,7 +152,7 @@ function DiffChunk({
|
|
|
152
152
|
<text>
|
|
153
153
|
<span fg={tokens.textDim}>{" "}</span>
|
|
154
154
|
<span fg={segment.annotation.id === focusedAnnotationId ? tokens.text : tokens.accent}>
|
|
155
|
-
◆ {
|
|
155
|
+
◆ {truncateToSingleLine(segment.annotation.body, 70)}
|
|
156
156
|
</span>
|
|
157
157
|
</text>
|
|
158
158
|
) : null}
|
|
@@ -168,7 +168,7 @@ export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, them
|
|
|
168
168
|
const byRow = new Map<number, Annotation>();
|
|
169
169
|
for (const annotation of annotations) {
|
|
170
170
|
const rowIndex = rows.findIndex(
|
|
171
|
-
(row) => row.text === annotation.anchor.quote && (row.
|
|
171
|
+
(row) => row.text === annotation.anchor.quote && (row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
|
|
172
172
|
);
|
|
173
173
|
if (rowIndex !== -1) byRow.set(rowIndex, annotation);
|
|
174
174
|
}
|
|
@@ -213,7 +213,7 @@ export function DiffSheet({ rows, cursor, annotations, focusedAnnotationId, them
|
|
|
213
213
|
{segments.map((segment, segmentIndex) => {
|
|
214
214
|
if (segment.kind === "header") {
|
|
215
215
|
const isCursor = segment.rowIndex === cursor;
|
|
216
|
-
if (segment.row.
|
|
216
|
+
if (segment.row.kind === "file") {
|
|
217
217
|
return (
|
|
218
218
|
<text key={segmentIndex} fg={tokens.text} bg={isCursor ? tokens.cursorBg : tokens.panel}>
|
|
219
219
|
{isCursor ? "▎" : " "}■ {rowLine(segment.row)}
|
|
@@ -32,7 +32,7 @@ export const SpanSelection: Story = {
|
|
|
32
32
|
display={fixtureDisplay()}
|
|
33
33
|
marks={new Map()}
|
|
34
34
|
cursor={2}
|
|
35
|
-
activeSpan={{
|
|
35
|
+
activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
|
|
36
36
|
compose={null}
|
|
37
37
|
editOrphanCount={0}
|
|
38
38
|
{...callbacks}
|
|
@@ -49,10 +49,10 @@ export const InlineCompose: Story = {
|
|
|
49
49
|
display={fixtureDisplay()}
|
|
50
50
|
marks={new Map()}
|
|
51
51
|
cursor={2}
|
|
52
|
-
activeSpan={{
|
|
52
|
+
activeSpan={{ displayIndex: 2, start: 0, end: 10 }}
|
|
53
53
|
compose={{
|
|
54
54
|
kind: "comment",
|
|
55
|
-
|
|
55
|
+
displayIndex: 2,
|
|
56
56
|
quote: "The daemon",
|
|
57
57
|
draft: { text: "Which daemon?", onInput: () => {}, onSave: () => {}, onCancel: () => {} },
|
|
58
58
|
}}
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type Mark,
|
|
24
24
|
type SpanState,
|
|
25
25
|
type StyleRun,
|
|
26
|
-
} from "../view";
|
|
26
|
+
} from "../view-plan";
|
|
27
27
|
import type { Theme } from "../theme";
|
|
28
28
|
import { useComponentTheme } from "./theme-context";
|
|
29
29
|
import { CodeBlock } from "./CodeBlock";
|
|
@@ -32,7 +32,7 @@ import { Button } from "./primitives/Button";
|
|
|
32
32
|
import { Toolbar } from "./primitives/Toolbar";
|
|
33
33
|
|
|
34
34
|
export interface PlanSelection {
|
|
35
|
-
|
|
35
|
+
displayIndex: number;
|
|
36
36
|
start: number;
|
|
37
37
|
end: number;
|
|
38
38
|
}
|
|
@@ -43,12 +43,12 @@ export interface PlanSheetHandle {
|
|
|
43
43
|
/** Anchor/extend the renderer's native selection from keyboard span offsets. */
|
|
44
44
|
driveSpanSelection(span: SpanState): void;
|
|
45
45
|
clearSelection(): void;
|
|
46
|
-
revealBlock(
|
|
46
|
+
revealBlock(displayIndex: number): void;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export interface PlanComposeState {
|
|
50
50
|
kind: "comment" | "suggestion";
|
|
51
|
-
|
|
51
|
+
displayIndex: number;
|
|
52
52
|
quote: string;
|
|
53
53
|
draft: AnnotationDraft;
|
|
54
54
|
}
|
|
@@ -59,10 +59,10 @@ export interface PlanSheetProps {
|
|
|
59
59
|
marks: Map<number, Mark[]>;
|
|
60
60
|
cursor: number;
|
|
61
61
|
/** Extra selection-style paint on one block (keyboard span or compose anchor). */
|
|
62
|
-
activeSpan: {
|
|
62
|
+
activeSpan: { displayIndex: number; start: number; end: number } | null;
|
|
63
63
|
compose: PlanComposeState | null;
|
|
64
64
|
editOrphanCount: number;
|
|
65
|
-
onLineActivate: (
|
|
65
|
+
onLineActivate: (displayIndex: number) => void;
|
|
66
66
|
onEditRequest: () => void;
|
|
67
67
|
theme?: Theme;
|
|
68
68
|
}
|
|
@@ -110,17 +110,17 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
110
110
|
readSelection: (): PlanSelection | null => {
|
|
111
111
|
if (!renderer?.hasSelection) return null;
|
|
112
112
|
const ordered = [...blockRefs.current.entries()].sort(([a], [b]) => a - b);
|
|
113
|
-
for (const [
|
|
113
|
+
for (const [displayIndex, blockRef] of ordered) {
|
|
114
114
|
const selection = blockRef.renderable.getSelection();
|
|
115
115
|
if (!selection || selection.end <= selection.start) continue;
|
|
116
116
|
const range = workRangeForRendered(blockRef.runs, selection.start, selection.end);
|
|
117
|
-
if (range) return {
|
|
117
|
+
if (range) return { displayIndex, ...range };
|
|
118
118
|
}
|
|
119
119
|
return null;
|
|
120
120
|
},
|
|
121
121
|
driveSpanSelection: (span: SpanState): void => {
|
|
122
122
|
if (!renderer) return;
|
|
123
|
-
const blockRef = blockRefs.current.get(span.
|
|
123
|
+
const blockRef = blockRefs.current.get(span.displayIndex);
|
|
124
124
|
if (!blockRef) return;
|
|
125
125
|
const renderedStart = renderedOffsetFor(blockRef.runs, span.start);
|
|
126
126
|
const renderedEnd = renderedOffsetFor(blockRef.runs, span.end - 1);
|
|
@@ -133,48 +133,48 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
133
133
|
clearSelection: (): void => {
|
|
134
134
|
renderer?.clearSelection();
|
|
135
135
|
},
|
|
136
|
-
revealBlock: (
|
|
136
|
+
revealBlock: (displayIndex: number): void => {
|
|
137
137
|
try {
|
|
138
|
-
scrollRef.current?.scrollChildIntoView(`plan-block-${
|
|
138
|
+
scrollRef.current?.scrollChildIntoView(`plan-block-${displayIndex}`);
|
|
139
139
|
} catch {
|
|
140
140
|
// reveal is best-effort; selection state is already correct
|
|
141
141
|
}
|
|
142
142
|
},
|
|
143
143
|
}));
|
|
144
144
|
|
|
145
|
-
const registerBlock = (
|
|
146
|
-
if (renderable) blockRefs.current.set(
|
|
147
|
-
else blockRefs.current.delete(
|
|
145
|
+
const registerBlock = (displayIndex: number, renderable: TextRenderable | null, runs: StyleRun[]): void => {
|
|
146
|
+
if (renderable) blockRefs.current.set(displayIndex, { renderable, runs });
|
|
147
|
+
else blockRefs.current.delete(displayIndex);
|
|
148
148
|
};
|
|
149
149
|
// stale refs must not survive a shrinking display list
|
|
150
150
|
useEffect(() => {
|
|
151
|
-
for (const
|
|
152
|
-
if (
|
|
151
|
+
for (const displayIndex of blockRefs.current.keys()) {
|
|
152
|
+
if (displayIndex >= display.length) blockRefs.current.delete(displayIndex);
|
|
153
153
|
}
|
|
154
154
|
}, [display]);
|
|
155
155
|
|
|
156
156
|
const children: React.ReactNode[] = [];
|
|
157
|
-
for (let
|
|
158
|
-
const block = display[
|
|
159
|
-
const isCursor =
|
|
160
|
-
const gap = topGap(display[
|
|
157
|
+
for (let displayIndex = 0; displayIndex < display.length; displayIndex++) {
|
|
158
|
+
const block = display[displayIndex]!;
|
|
159
|
+
const isCursor = displayIndex === cursor;
|
|
160
|
+
const gap = topGap(display[displayIndex - 1], block);
|
|
161
161
|
if (block.kind === "code") {
|
|
162
162
|
children.push(
|
|
163
163
|
<CodeBlock
|
|
164
|
-
key={
|
|
165
|
-
id={`plan-block-${
|
|
164
|
+
key={displayIndex}
|
|
165
|
+
id={`plan-block-${displayIndex}`}
|
|
166
166
|
language={(block.work ?? block.base)?.lang}
|
|
167
167
|
content={displayText(block)}
|
|
168
168
|
isCursor={isCursor}
|
|
169
169
|
marginTop={gap}
|
|
170
|
-
isAnnotated={(marks.get(
|
|
170
|
+
isAnnotated={(marks.get(displayIndex) ?? []).length > 0}
|
|
171
171
|
changeTag={block.type !== "same" ? tagLabel(block) : undefined}
|
|
172
172
|
theme={theme}
|
|
173
173
|
/>,
|
|
174
174
|
);
|
|
175
175
|
} else {
|
|
176
|
-
const blockMarks = [...(marks.get(
|
|
177
|
-
if (activeSpan && activeSpan.
|
|
176
|
+
const blockMarks = [...(marks.get(displayIndex) ?? [])];
|
|
177
|
+
if (activeSpan && activeSpan.displayIndex === displayIndex) {
|
|
178
178
|
blockMarks.push({ start: activeSpan.start, end: activeSpan.end, role: "kspan" });
|
|
179
179
|
}
|
|
180
180
|
const runs = overlayMarks(blockRuns(block, true), blockMarks);
|
|
@@ -182,7 +182,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
182
182
|
const mappedRuns: StyleRun[] =
|
|
183
183
|
block.type !== "same" ? [...runs, { text: ` [${tagLabel(block)}]`, role: "plain", start: null }] : runs;
|
|
184
184
|
children.push(
|
|
185
|
-
<box key={
|
|
185
|
+
<box key={displayIndex} id={`plan-block-${displayIndex}`} style={{ flexDirection: "row", marginTop: gap }}>
|
|
186
186
|
<text selectable={false}>
|
|
187
187
|
<span fg={isCursor ? tokens.accent : tokens.textDim}>{isCursor ? "▎ " : " "}</span>
|
|
188
188
|
<span fg={tokens.textDim}>{marker(block)}</span>
|
|
@@ -193,8 +193,8 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
193
193
|
selectionBg={tokens.accent}
|
|
194
194
|
selectionFg={tokens.accentInk}
|
|
195
195
|
style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}
|
|
196
|
-
ref={(renderable: TextRenderable | null) => registerBlock(
|
|
197
|
-
onMouseUp={() => onLineActivate(
|
|
196
|
+
ref={(renderable: TextRenderable | null) => registerBlock(displayIndex, renderable, mappedRuns)}
|
|
197
|
+
onMouseUp={() => onLineActivate(displayIndex)}
|
|
198
198
|
>
|
|
199
199
|
{runs.map((run, runIndex) => (
|
|
200
200
|
<span key={runIndex} {...runStyle(run, block, tokens)}>
|
|
@@ -206,9 +206,9 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
|
|
|
206
206
|
</box>,
|
|
207
207
|
);
|
|
208
208
|
}
|
|
209
|
-
if (compose && compose.
|
|
209
|
+
if (compose && compose.displayIndex === displayIndex) {
|
|
210
210
|
children.push(
|
|
211
|
-
<AnnotationCard key={`compose-${
|
|
211
|
+
<AnnotationCard key={`compose-${displayIndex}`} kind={compose.kind} quote={compose.quote} draft={compose.draft} theme={theme} />,
|
|
212
212
|
);
|
|
213
213
|
}
|
|
214
214
|
}
|
|
@@ -274,7 +274,7 @@ function topGap(previous: DisplayBlock | undefined, current: DisplayBlock): numb
|
|
|
274
274
|
|
|
275
275
|
function marker(block: DisplayBlock): string {
|
|
276
276
|
if (block.kind === "li") return "- ";
|
|
277
|
-
if (block.kind === "oli") return `${block.
|
|
277
|
+
if (block.kind === "oli") return `${block.orderedItemNumber ?? 1}. `;
|
|
278
278
|
if (block.kind === "quote") return "▏ ";
|
|
279
279
|
return "";
|
|
280
280
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { WalkFile } from "../walk";
|
|
4
|
+
import type { Story, StoryMeta } from "./story";
|
|
5
|
+
import { WalkWizard } from "./WalkWizard";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "WalkWizard" };
|
|
8
|
+
|
|
9
|
+
const FILES: WalkFile[] = [
|
|
10
|
+
{
|
|
11
|
+
path: "packages/daemon/src/store.ts",
|
|
12
|
+
added: 12,
|
|
13
|
+
removed: 3,
|
|
14
|
+
preview: [
|
|
15
|
+
{ sign: "+", text: "const viewed = new Set<string>();" },
|
|
16
|
+
{ sign: "+", text: "record.viewedPaths = [...viewed];" },
|
|
17
|
+
{ sign: "-", text: "// viewed tracking lives in the client only" },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
path: "packages/client/src/walk.ts",
|
|
22
|
+
added: 48,
|
|
23
|
+
removed: 0,
|
|
24
|
+
preview: [{ sign: "+", text: "export function firstUnviewedIndex(files, viewed) {" }],
|
|
25
|
+
},
|
|
26
|
+
{ path: "packages/schema/src/types.ts", added: 3, removed: 0, preview: [{ sign: "+", text: "viewedPaths?: string[];" }] },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const noop = (): void => {};
|
|
30
|
+
|
|
31
|
+
export const FirstFile: Story = {
|
|
32
|
+
render: () => (
|
|
33
|
+
<WalkWizard
|
|
34
|
+
files={FILES}
|
|
35
|
+
index={0}
|
|
36
|
+
viewedPaths={new Set()}
|
|
37
|
+
terminalWidth={80}
|
|
38
|
+
onSubmitRequest={noop}
|
|
39
|
+
onBack={noop}
|
|
40
|
+
/>
|
|
41
|
+
),
|
|
42
|
+
// the main card wears the accent border; stats carry the +/- colors
|
|
43
|
+
expectedColors: [DARK.accent, DARK.green, DARK.red, DARK.insFg, DARK.delFg],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const WithAgentNote: Story = {
|
|
47
|
+
render: () => (
|
|
48
|
+
<WalkWizard
|
|
49
|
+
files={FILES}
|
|
50
|
+
index={1}
|
|
51
|
+
viewedPaths={new Set(["packages/daemon/src/store.ts"])}
|
|
52
|
+
note="New module: pure helpers that pick the next unviewed file. No IO, unit tested."
|
|
53
|
+
terminalWidth={80}
|
|
54
|
+
onSubmitRequest={noop}
|
|
55
|
+
onBack={noop}
|
|
56
|
+
/>
|
|
57
|
+
),
|
|
58
|
+
// the agent-note block wears the plain gray border token, not the accent
|
|
59
|
+
expectedColors: [DARK.accent, DARK.border, DARK.textMuted],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const EndCard: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<WalkWizard
|
|
65
|
+
files={FILES}
|
|
66
|
+
index={3}
|
|
67
|
+
viewedPaths={new Set(FILES.map((file) => file.path))}
|
|
68
|
+
terminalWidth={80}
|
|
69
|
+
onSubmitRequest={noop}
|
|
70
|
+
onBack={noop}
|
|
71
|
+
/>
|
|
72
|
+
),
|
|
73
|
+
// the end card wears the green border and the solid submit button
|
|
74
|
+
expectedColors: [DARK.green, DARK.accent, DARK.accentInk],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const EndCardPartialPass: Story = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<WalkWizard
|
|
80
|
+
files={FILES}
|
|
81
|
+
index={3}
|
|
82
|
+
viewedPaths={new Set(["packages/daemon/src/store.ts"])}
|
|
83
|
+
terminalWidth={80}
|
|
84
|
+
onSubmitRequest={noop}
|
|
85
|
+
onBack={noop}
|
|
86
|
+
/>
|
|
87
|
+
),
|
|
88
|
+
expectedColors: [DARK.green],
|
|
89
|
+
};
|