@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
package/src/serve.test.ts
CHANGED
|
@@ -38,7 +38,7 @@ afterEach(async () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
/** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
|
|
41
|
-
function sshCapture(port: number, until: (
|
|
41
|
+
function sshCapture(port: number, until: (frame: string) => boolean, timeoutMs = 8000): Promise<string> {
|
|
42
42
|
return new Promise((resolve, reject) => {
|
|
43
43
|
const conn = new Client();
|
|
44
44
|
let out = "";
|
|
@@ -55,8 +55,8 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
|
|
|
55
55
|
.on("ready", () => {
|
|
56
56
|
conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
|
|
57
57
|
if (err) return finish(err);
|
|
58
|
-
stream.on("data", (
|
|
59
|
-
out +=
|
|
58
|
+
stream.on("data", (chunk: Buffer) => {
|
|
59
|
+
out += chunk.toString("utf8");
|
|
60
60
|
if (until(out)) finish();
|
|
61
61
|
});
|
|
62
62
|
stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
|
|
@@ -78,7 +78,7 @@ describe("cueloop serve", () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
test("an anonymous ssh client gets the observer TUI for the session", async () => {
|
|
81
|
-
const bytes = await sshCapture(handle.port, (
|
|
81
|
+
const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan") && frame.includes("observer"));
|
|
82
82
|
expect(bytes).toContain("cueloop");
|
|
83
83
|
expect(bytes).toContain("Rollout Plan");
|
|
84
84
|
// observer chrome, not the controller's mutating hint bar
|
|
@@ -87,8 +87,8 @@ describe("cueloop serve", () => {
|
|
|
87
87
|
|
|
88
88
|
test("two observers can watch at once", async () => {
|
|
89
89
|
const [a, b] = await Promise.all([
|
|
90
|
-
sshCapture(handle.port, (
|
|
91
|
-
sshCapture(handle.port, (
|
|
90
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
91
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
92
92
|
]);
|
|
93
93
|
expect(a).toContain("Rollout Plan");
|
|
94
94
|
expect(b).toContain("Rollout Plan");
|
package/src/serve.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSH-served TUI
|
|
2
|
+
* SSH-served TUI: `cueloop serve` lets teammates join a
|
|
3
3
|
* review session over plain ssh. Every SSH connection renders <App> in
|
|
4
4
|
* observer mode (readOnly) against the same local daemon; the one writable
|
|
5
5
|
* controller stays the local owner's own `cueloop` TUI.
|
|
@@ -44,8 +44,8 @@ export interface ServeHandle {
|
|
|
44
44
|
close(): Promise<void>;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export async function serveClient(
|
|
48
|
-
const home =
|
|
47
|
+
export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
|
|
48
|
+
const home = options.home ?? cueloopHome();
|
|
49
49
|
const sshDir = join(home, "ssh");
|
|
50
50
|
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
51
51
|
|
|
@@ -53,15 +53,15 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
|
|
|
53
53
|
// password-less by design; see the trust model in the module comment
|
|
54
54
|
auth: "open",
|
|
55
55
|
hostKey: { path: join(sshDir, "host_key") },
|
|
56
|
-
startupBanner:
|
|
56
|
+
startupBanner: options.banner ?? true,
|
|
57
57
|
idleTimeout: "2h",
|
|
58
|
-
onError:
|
|
58
|
+
onError: options.onError,
|
|
59
59
|
}).serve((session) => {
|
|
60
60
|
const root = createRoot(session.renderer);
|
|
61
61
|
root.render(
|
|
62
62
|
React.createElement(App, {
|
|
63
63
|
home,
|
|
64
|
-
sessionId:
|
|
64
|
+
sessionId: options.sessionId,
|
|
65
65
|
readOnly: true,
|
|
66
66
|
// q disconnects only this observer, never the server
|
|
67
67
|
onExit: () => session.end(),
|
|
@@ -70,6 +70,6 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
|
|
|
70
70
|
session.onClose(() => root.unmount());
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
const info = await server.listen(
|
|
73
|
+
const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
|
|
74
74
|
return { ...info, close: () => server.close() };
|
|
75
75
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The review-session controller
|
|
2
|
+
* The review-session controller: every daemon round-trip and mutation
|
|
3
3
|
* verb behind one React-free object. It owns connect/autostart/subscribe,
|
|
4
4
|
* the session/inbox/status/error snapshot, optimistic apply, the mutation
|
|
5
5
|
* verbs (cut/edit/annotate/submit/...), and the post-submit completion
|
|
@@ -22,8 +22,9 @@ import {
|
|
|
22
22
|
} from "@cueloop/schema";
|
|
23
23
|
import { Registry, type Exporter } from "@cueloop/extension-api";
|
|
24
24
|
import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
|
|
25
|
-
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view";
|
|
25
|
+
import { buildDisplay, nextWorkBlock, type DisplayBlock } from "./view-plan";
|
|
26
26
|
import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
27
|
+
import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
|
|
27
28
|
import { editInEditor } from "./editor";
|
|
28
29
|
import { focusHerdrPane, returnPaneFor } from "./herdr";
|
|
29
30
|
import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
|
|
@@ -53,6 +54,12 @@ export interface ControllerSnapshot {
|
|
|
53
54
|
* hand-off - the reconciliation banner count. 0 = no banner.
|
|
54
55
|
*/
|
|
55
56
|
editOrphanCount: number;
|
|
57
|
+
/**
|
|
58
|
+
* The guided walk's cursor (diff sessions): which wizard step is on
|
|
59
|
+
* screen. index === file count is the end card. null = not walking; the
|
|
60
|
+
* viewed set itself rides the session record, so leaving loses nothing.
|
|
61
|
+
*/
|
|
62
|
+
walk: { index: number } | null;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
export interface ReviewControllerOptions {
|
|
@@ -74,27 +81,36 @@ export interface ReviewController {
|
|
|
74
81
|
connect(): void;
|
|
75
82
|
close(): void;
|
|
76
83
|
/** Loaded config parts the controller acts on: auto-close and exporters. */
|
|
77
|
-
applyConfig(
|
|
78
|
-
setStatus(
|
|
84
|
+
applyConfig(config: CueloopConfig): void;
|
|
85
|
+
setStatus(message: string): void;
|
|
79
86
|
/** Derived projections, cached per session identity. */
|
|
80
87
|
display(): DisplayBlock[];
|
|
81
88
|
rows(): DiffRow[];
|
|
89
|
+
/** The walk's step list, derived from the diff rows. */
|
|
90
|
+
files(): WalkFile[];
|
|
82
91
|
working(): string;
|
|
83
92
|
/** Open a session from the inbox. */
|
|
84
93
|
open(id: string): void;
|
|
85
94
|
/** Cut the block under the cursor, or restore a cut one. */
|
|
86
|
-
cut(
|
|
95
|
+
cut(displayIndex: number): void;
|
|
87
96
|
/** The $EDITOR hand-off on the working copy. */
|
|
88
97
|
edit(): void;
|
|
89
98
|
/**
|
|
90
99
|
* Anchor and store an annotation; both plan and diff anchor constructions.
|
|
91
100
|
* Returns the minted annotation id so the view can select the new card.
|
|
92
101
|
*/
|
|
93
|
-
annotate(kind: "comment" | "suggestion",
|
|
102
|
+
annotate(kind: "comment" | "suggestion", displayIndex: number, start: number, end: number, body: string): string | undefined;
|
|
94
103
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
95
104
|
updateAnnotation(id: string, body: string): void;
|
|
96
105
|
removeAnnotation(id: string): void;
|
|
97
106
|
setWorkingCopy(content: string | undefined): void;
|
|
107
|
+
/** Enter the guided walk at the first unviewed file (diff sessions). */
|
|
108
|
+
walkStart(): void;
|
|
109
|
+
/** Mark the current file viewed (persists with the session) and advance. */
|
|
110
|
+
walkForward(): void;
|
|
111
|
+
walkBack(): void;
|
|
112
|
+
/** Leave the walk; the viewed set stays on the session record. */
|
|
113
|
+
walkLeave(): void;
|
|
98
114
|
/** Resolve the review, run the export, start the completion hand-back. */
|
|
99
115
|
submit(verdict: VerdictKind, summary: string): void;
|
|
100
116
|
/** Close the review and, inside herdr, bounce focus back to the agent. */
|
|
@@ -104,21 +120,22 @@ export interface ReviewController {
|
|
|
104
120
|
optInAutoClose(): void;
|
|
105
121
|
}
|
|
106
122
|
|
|
107
|
-
export function createReviewController(
|
|
108
|
-
return new Controller(
|
|
123
|
+
export function createReviewController(options: ReviewControllerOptions): ReviewController {
|
|
124
|
+
return new Controller(options);
|
|
109
125
|
}
|
|
110
126
|
|
|
111
127
|
class Controller implements ReviewController {
|
|
112
128
|
readonly readOnly: boolean;
|
|
113
129
|
private client: DaemonClient | null = null;
|
|
114
130
|
private closed = false;
|
|
115
|
-
private
|
|
131
|
+
private snapshot: ControllerSnapshot = {
|
|
116
132
|
session: null,
|
|
117
133
|
inbox: null,
|
|
118
134
|
status: "",
|
|
119
135
|
error: null,
|
|
120
136
|
completion: { phase: "idle" },
|
|
121
137
|
editOrphanCount: 0,
|
|
138
|
+
walk: null,
|
|
122
139
|
};
|
|
123
140
|
private listeners = new Set<() => void>();
|
|
124
141
|
private autoClose: AutoClose = "off";
|
|
@@ -128,11 +145,15 @@ class Controller implements ReviewController {
|
|
|
128
145
|
private countdown: TimerHandle | undefined;
|
|
129
146
|
/** Projections keyed by session identity so renders reuse one computation. */
|
|
130
147
|
private derivedFor: ReviewSession | null = null;
|
|
131
|
-
private derived: { display: DisplayBlock[]; rows: DiffRow[]
|
|
148
|
+
private derived: { display: DisplayBlock[]; rows: DiffRow[]; files: WalkFile[] } = {
|
|
149
|
+
display: [],
|
|
150
|
+
rows: [],
|
|
151
|
+
files: [],
|
|
152
|
+
};
|
|
132
153
|
|
|
133
|
-
constructor(private readonly
|
|
134
|
-
this.readOnly =
|
|
135
|
-
this.clock =
|
|
154
|
+
constructor(private readonly options: ReviewControllerOptions) {
|
|
155
|
+
this.readOnly = options.readOnly ?? false;
|
|
156
|
+
this.clock = options.clock ?? new SystemClock();
|
|
136
157
|
}
|
|
137
158
|
|
|
138
159
|
subscribe = (listener: () => void): (() => void) => {
|
|
@@ -140,28 +161,28 @@ class Controller implements ReviewController {
|
|
|
140
161
|
return () => this.listeners.delete(listener);
|
|
141
162
|
};
|
|
142
163
|
|
|
143
|
-
getSnapshot = (): ControllerSnapshot => this.
|
|
164
|
+
getSnapshot = (): ControllerSnapshot => this.snapshot;
|
|
144
165
|
|
|
145
166
|
private update(patch: Partial<ControllerSnapshot>): void {
|
|
146
|
-
this.
|
|
147
|
-
for (const
|
|
167
|
+
this.snapshot = { ...this.snapshot, ...patch };
|
|
168
|
+
for (const listener of this.listeners) listener();
|
|
148
169
|
}
|
|
149
170
|
|
|
150
171
|
connect(): void {
|
|
151
172
|
void (async () => {
|
|
152
173
|
try {
|
|
153
|
-
const client = await DaemonClient.connect({ home: this.
|
|
174
|
+
const client = await DaemonClient.connect({ home: this.options.home, autostart: true });
|
|
154
175
|
if (this.closed) return void client.close();
|
|
155
176
|
this.client = client;
|
|
156
|
-
client.onEvent((
|
|
177
|
+
client.onEvent((event) => {
|
|
157
178
|
// another controller/observer changed state: re-fetch
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
160
|
-
if (!this.
|
|
179
|
+
const session = this.snapshot.session;
|
|
180
|
+
if (session && event.sessionId === session.id) void this.refreshSession(session.id);
|
|
181
|
+
if (!this.options.sessionId) void this.refreshInbox();
|
|
161
182
|
});
|
|
162
183
|
await client.subscribe();
|
|
163
|
-
if (this.
|
|
164
|
-
this.update({ session: await client.sessionGet(this.
|
|
184
|
+
if (this.options.sessionId) {
|
|
185
|
+
this.update({ session: await client.sessionGet(this.options.sessionId) });
|
|
165
186
|
} else {
|
|
166
187
|
this.update({ inbox: await client.sessionList({ status: "pending" }) });
|
|
167
188
|
}
|
|
@@ -182,28 +203,30 @@ class Controller implements ReviewController {
|
|
|
182
203
|
this.countdown = undefined;
|
|
183
204
|
}
|
|
184
205
|
|
|
185
|
-
applyConfig(
|
|
186
|
-
this.autoClose =
|
|
187
|
-
this.obsidian =
|
|
206
|
+
applyConfig(config: CueloopConfig): void {
|
|
207
|
+
this.autoClose = config.ui.autoClose;
|
|
208
|
+
this.obsidian = config.integrations.obsidian;
|
|
188
209
|
// bundled integrations register through the public extension API
|
|
189
210
|
const registry = new Registry();
|
|
190
|
-
void registry.load("obsidian", createObsidianExtension(
|
|
191
|
-
this.exporters =
|
|
211
|
+
void registry.load("obsidian", createObsidianExtension(config.integrations.obsidian)).then((record) => {
|
|
212
|
+
this.exporters = record.exporters;
|
|
192
213
|
});
|
|
193
214
|
}
|
|
194
215
|
|
|
195
|
-
setStatus(
|
|
196
|
-
this.update({ status:
|
|
216
|
+
setStatus(message: string): void {
|
|
217
|
+
this.update({ status: message });
|
|
197
218
|
}
|
|
198
219
|
|
|
199
220
|
// ── derived projections ─────────────────────
|
|
200
221
|
private ensureDerived(): void {
|
|
201
|
-
const
|
|
202
|
-
if (this.derivedFor ===
|
|
203
|
-
this.derivedFor =
|
|
222
|
+
const session = this.snapshot.session;
|
|
223
|
+
if (this.derivedFor === session) return;
|
|
224
|
+
this.derivedFor = session;
|
|
225
|
+
const rows = session && session.artifact.type === "diff" ? diffRows(session.artifact.content) : [];
|
|
204
226
|
this.derived = {
|
|
205
|
-
display:
|
|
206
|
-
rows
|
|
227
|
+
display: session ? buildDisplay(session.artifact.content, session.workingCopy) : [],
|
|
228
|
+
rows,
|
|
229
|
+
files: walkFiles(rows),
|
|
207
230
|
};
|
|
208
231
|
}
|
|
209
232
|
|
|
@@ -217,9 +240,14 @@ class Controller implements ReviewController {
|
|
|
217
240
|
return this.derived.rows;
|
|
218
241
|
}
|
|
219
242
|
|
|
243
|
+
files(): WalkFile[] {
|
|
244
|
+
this.ensureDerived();
|
|
245
|
+
return this.derived.files;
|
|
246
|
+
}
|
|
247
|
+
|
|
220
248
|
working(): string {
|
|
221
|
-
const
|
|
222
|
-
return
|
|
249
|
+
const session = this.snapshot.session;
|
|
250
|
+
return session ? (session.workingCopy ?? session.artifact.content) : "";
|
|
223
251
|
}
|
|
224
252
|
|
|
225
253
|
// Refreshes race the connection teardown: an event can arrive while close()
|
|
@@ -242,40 +270,41 @@ class Controller implements ReviewController {
|
|
|
242
270
|
}
|
|
243
271
|
|
|
244
272
|
/** Optimistic apply: the daemon response is the next session snapshot. */
|
|
245
|
-
private apply(
|
|
246
|
-
|
|
247
|
-
this.setStatus(String(
|
|
273
|
+
private apply(mutation: Promise<ReviewSession>): void {
|
|
274
|
+
mutation.then((session) => this.update({ session })).catch((error: unknown) =>
|
|
275
|
+
this.setStatus(String(error instanceof Error ? error.message : error)),
|
|
248
276
|
);
|
|
249
277
|
}
|
|
250
278
|
|
|
251
279
|
// ── verbs ───────────────────────────────────
|
|
252
280
|
open(id: string): void {
|
|
253
|
-
|
|
281
|
+
this.locallyViewed.clear();
|
|
282
|
+
const cached = this.snapshot.inbox?.find((candidate) => candidate.id === id);
|
|
254
283
|
if (cached) this.update({ session: cached });
|
|
255
284
|
else void this.refreshSession(id);
|
|
256
285
|
}
|
|
257
286
|
|
|
258
|
-
cut(
|
|
259
|
-
const session = this.
|
|
287
|
+
cut(displayIndex: number): void {
|
|
288
|
+
const session = this.snapshot.session;
|
|
260
289
|
if (!session || session.status === "resolved") return;
|
|
261
|
-
const
|
|
262
|
-
if (!
|
|
290
|
+
const block = this.display()[displayIndex];
|
|
291
|
+
if (!block) return;
|
|
263
292
|
const working = this.working();
|
|
264
|
-
if (
|
|
265
|
-
const line = restoreLine(nextWorkBlock(this.display(),
|
|
293
|
+
if (block.type === "del") {
|
|
294
|
+
const line = restoreLine(nextWorkBlock(this.display(), displayIndex), working.split("\n").length);
|
|
266
295
|
// restoreBlock returns undefined when the block structure round-trips
|
|
267
296
|
// to the submitted revision - the working copy is gone
|
|
268
|
-
const restored = restoreBlock(session.artifact.content, working,
|
|
297
|
+
const restored = restoreBlock(session.artifact.content, working, block.base!, line);
|
|
269
298
|
this.setWorkingCopy(restored);
|
|
270
299
|
this.setStatus("cut restored");
|
|
271
|
-
} else if (
|
|
272
|
-
this.setWorkingCopy(cutBlock(working,
|
|
300
|
+
} else if (block.work) {
|
|
301
|
+
this.setWorkingCopy(cutBlock(working, block.work));
|
|
273
302
|
this.setStatus("block cut - it serializes into the diff");
|
|
274
303
|
}
|
|
275
304
|
}
|
|
276
305
|
|
|
277
306
|
edit(): void {
|
|
278
|
-
const session = this.
|
|
307
|
+
const session = this.snapshot.session;
|
|
279
308
|
if (!session || session.status === "resolved") return;
|
|
280
309
|
try {
|
|
281
310
|
const result = editInEditor(this.working(), "plan.md");
|
|
@@ -305,21 +334,21 @@ class Controller implements ReviewController {
|
|
|
305
334
|
|
|
306
335
|
annotate(
|
|
307
336
|
kind: "comment" | "suggestion",
|
|
308
|
-
|
|
337
|
+
displayIndex: number,
|
|
309
338
|
start: number,
|
|
310
339
|
end: number,
|
|
311
340
|
body: string,
|
|
312
341
|
): string | undefined {
|
|
313
|
-
const session = this.
|
|
342
|
+
const session = this.snapshot.session;
|
|
314
343
|
if (!session) return undefined;
|
|
315
344
|
let anchor;
|
|
316
345
|
if (session.artifact.type === "diff") {
|
|
317
|
-
anchor = { ...diffRowAnchor(this.rows(),
|
|
346
|
+
anchor = { ...diffRowAnchor(this.rows(), displayIndex), blockIndex: displayIndex };
|
|
318
347
|
} else {
|
|
319
348
|
const display = this.display();
|
|
320
|
-
const workBlocks = display.filter((
|
|
321
|
-
const
|
|
322
|
-
anchor = makeAnchor(workBlocks,
|
|
349
|
+
const workBlocks = display.filter((entry) => entry.work).map((entry) => entry.work!);
|
|
350
|
+
const workBlockIndex = display.slice(0, displayIndex + 1).filter((entry) => entry.work).length - 1;
|
|
351
|
+
anchor = makeAnchor(workBlocks, workBlockIndex, start, end);
|
|
323
352
|
}
|
|
324
353
|
const annotationId = newAnnotationId();
|
|
325
354
|
this.apply(
|
|
@@ -335,7 +364,7 @@ class Controller implements ReviewController {
|
|
|
335
364
|
}
|
|
336
365
|
|
|
337
366
|
updateAnnotation(id: string, body: string): void {
|
|
338
|
-
const session = this.
|
|
367
|
+
const session = this.snapshot.session;
|
|
339
368
|
if (!session) return;
|
|
340
369
|
const existing = session.annotations.find((annotation) => annotation.id === id);
|
|
341
370
|
if (!existing) return;
|
|
@@ -352,21 +381,66 @@ class Controller implements ReviewController {
|
|
|
352
381
|
}
|
|
353
382
|
|
|
354
383
|
removeAnnotation(id: string): void {
|
|
355
|
-
const session = this.
|
|
384
|
+
const session = this.snapshot.session;
|
|
356
385
|
if (!session) return;
|
|
357
386
|
this.apply(this.client!.sessionRemoveAnnotation(session.id, id));
|
|
358
387
|
this.setStatus("annotation deleted");
|
|
359
388
|
}
|
|
360
389
|
|
|
361
390
|
setWorkingCopy(content: string | undefined): void {
|
|
362
|
-
const session = this.
|
|
391
|
+
const session = this.snapshot.session;
|
|
363
392
|
if (!session) return;
|
|
364
393
|
this.apply(this.client!.sessionSetWorkingCopy(session.id, content));
|
|
365
394
|
}
|
|
366
395
|
|
|
396
|
+
// ── the guided walk ─────────────────────────
|
|
397
|
+
// Marks sent but possibly not yet reflected in the session snapshot; the
|
|
398
|
+
// union keeps the walk title truthful and rapid advances from losing marks.
|
|
399
|
+
private locallyViewed = new Set<string>();
|
|
400
|
+
|
|
401
|
+
private viewedSet(): Set<string> {
|
|
402
|
+
return new Set([...(this.snapshot.session?.viewedPaths ?? []), ...this.locallyViewed]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
walkStart(): void {
|
|
406
|
+
const session = this.snapshot.session;
|
|
407
|
+
if (!session || session.artifact.type !== "diff") return;
|
|
408
|
+
const files = this.files();
|
|
409
|
+
if (files.length === 0) return this.setStatus("nothing to walk - the diff is empty");
|
|
410
|
+
// resume at the first unviewed file; a finished walk reopens on the end card
|
|
411
|
+
this.update({ walk: { index: firstUnviewedIndex(files, this.viewedSet()) } });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
walkForward(): void {
|
|
415
|
+
const walk = this.snapshot.walk;
|
|
416
|
+
const session = this.snapshot.session;
|
|
417
|
+
if (!walk || !session) return;
|
|
418
|
+
const files = this.files();
|
|
419
|
+
const current = files[walk.index];
|
|
420
|
+
if (!current) return; // already on the end card
|
|
421
|
+
// advancing IS the viewed mark: the step is complete once you move past
|
|
422
|
+
// it; the daemon verb merges, so only the new path travels
|
|
423
|
+
if (!this.viewedSet().has(current.path)) {
|
|
424
|
+
this.locallyViewed.add(current.path);
|
|
425
|
+
this.apply(this.client!.sessionSetViewed(session.id, [current.path]));
|
|
426
|
+
}
|
|
427
|
+
this.update({ walk: { index: walk.index + 1 } });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
walkBack(): void {
|
|
431
|
+
const walk = this.snapshot.walk;
|
|
432
|
+
if (!walk) return;
|
|
433
|
+
this.update({ walk: { index: Math.max(0, walk.index - 1) } });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
walkLeave(): void {
|
|
437
|
+
if (this.snapshot.walk) this.update({ walk: null });
|
|
438
|
+
}
|
|
439
|
+
|
|
367
440
|
submit(verdict: VerdictKind, summary: string): void {
|
|
368
|
-
const session = this.
|
|
441
|
+
const session = this.snapshot.session;
|
|
369
442
|
if (!session) return;
|
|
443
|
+
this.walkLeave();
|
|
370
444
|
this.client!.sessionResolve(session.id, verdict, summary)
|
|
371
445
|
.then((resolved) => {
|
|
372
446
|
// The completion overlay heading already states the verdict, so the
|
|
@@ -376,8 +450,8 @@ class Controller implements ReviewController {
|
|
|
376
450
|
const obsidian = this.obsidian;
|
|
377
451
|
const exporter = this.exporters.get("obsidian");
|
|
378
452
|
if (obsidian && exporter && shouldExport(obsidian.exportOn, verdict)) {
|
|
379
|
-
void exporter(resolved).then((
|
|
380
|
-
this.setStatus(
|
|
453
|
+
void exporter(resolved).then((exportResult) => {
|
|
454
|
+
this.setStatus(exportResult.success && exportResult.path ? `exported to ${exportResult.path}` : `export failed: ${exportResult.error ?? "unknown"}`);
|
|
381
455
|
});
|
|
382
456
|
}
|
|
383
457
|
// Hand the reviewer back to the agent. The default is a visible
|
|
@@ -388,7 +462,7 @@ class Controller implements ReviewController {
|
|
|
388
462
|
else if (typeof delay === "number") this.startCounting(delay);
|
|
389
463
|
else this.startCounting(DEFAULT_AUTO_CLOSE);
|
|
390
464
|
})
|
|
391
|
-
.catch((
|
|
465
|
+
.catch((error: unknown) => this.setStatus(String(error instanceof Error ? error.message : error)));
|
|
392
466
|
}
|
|
393
467
|
|
|
394
468
|
// ── completion hand-back ────────────────────
|
|
@@ -400,9 +474,9 @@ class Controller implements ReviewController {
|
|
|
400
474
|
|
|
401
475
|
finishReview(): void {
|
|
402
476
|
this.clearCountdown();
|
|
403
|
-
const pane = returnPaneFor(this.
|
|
477
|
+
const pane = returnPaneFor(this.snapshot.session?.artifact.meta.herdrPane);
|
|
404
478
|
if (pane) focusHerdrPane(pane);
|
|
405
|
-
this.
|
|
479
|
+
this.options.onExit?.(0);
|
|
406
480
|
}
|
|
407
481
|
|
|
408
482
|
dismissCompletion(): void {
|
package/src/theme.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Semantic theme tokens
|
|
2
|
+
* Semantic theme tokens. Values carry over from the design-system
|
|
3
3
|
* prototypes (tokens.css). One built-in dark theme for now; the theme
|
|
4
4
|
* registry (palette-derived themes, terminal theme, user overrides) layers
|
|
5
5
|
* on top of these slots without touching consumers.
|
|
@@ -27,6 +27,25 @@ export interface Theme {
|
|
|
27
27
|
backdrop: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The dimmed variant behind the walk wizard: every reading color drops to
|
|
32
|
+
* the dim token so the file list stays legible context, not competition.
|
|
33
|
+
* Layout tokens (backgrounds, cursor) keep their values.
|
|
34
|
+
*/
|
|
35
|
+
export function dimmedTheme(theme: Theme): Theme {
|
|
36
|
+
return {
|
|
37
|
+
...theme,
|
|
38
|
+
text: theme.textDim,
|
|
39
|
+
textMuted: theme.textDim,
|
|
40
|
+
accent: theme.textDim,
|
|
41
|
+
green: theme.textDim,
|
|
42
|
+
red: theme.textDim,
|
|
43
|
+
blue: theme.textDim,
|
|
44
|
+
insFg: theme.textDim,
|
|
45
|
+
delFg: theme.textDim,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
30
49
|
export const DARK: Theme = {
|
|
31
50
|
bg: "#1f1f29",
|
|
32
51
|
panel: "#1a1a23",
|
package/src/view-diff.test.ts
CHANGED
|
@@ -22,22 +22,22 @@ index 111..222 100644
|
|
|
22
22
|
describe("diffRows", () => {
|
|
23
23
|
test("flattens files, hunks, and signed lines with line numbers", () => {
|
|
24
24
|
const rows = diffRows(PATCH);
|
|
25
|
-
expect(rows[0]).toMatchObject({
|
|
26
|
-
expect(rows[1]!.
|
|
27
|
-
const del = rows.find((
|
|
25
|
+
expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
|
|
26
|
+
expect(rows[1]!.kind).toBe("hunk");
|
|
27
|
+
const del = rows.find((row) => row.kind === "del")!;
|
|
28
28
|
expect(del.text).toContain("private items = [];");
|
|
29
29
|
expect(del.oldLine).toBe(3);
|
|
30
|
-
const adds = rows.filter((
|
|
30
|
+
const adds = rows.filter((row) => row.kind === "add");
|
|
31
31
|
expect(adds[0]!.text).toContain("new Map()");
|
|
32
32
|
expect(adds[0]!.newLine).toBe(3);
|
|
33
33
|
expect(adds[1]!.newLine).toBe(4);
|
|
34
34
|
// two hunks
|
|
35
|
-
expect(rows.filter((
|
|
35
|
+
expect(rows.filter((row) => row.kind === "hunk").length).toBe(2);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test("context lines carry both line numbers", () => {
|
|
39
39
|
const rows = diffRows(PATCH);
|
|
40
|
-
const ctx = rows.find((
|
|
40
|
+
const ctx = rows.find((row) => row.kind === "ctx")!;
|
|
41
41
|
expect(ctx.oldLine).toBe(1);
|
|
42
42
|
expect(ctx.newLine).toBe(1);
|
|
43
43
|
});
|
|
@@ -46,8 +46,8 @@ describe("diffRows", () => {
|
|
|
46
46
|
describe("diffRowAnchor", () => {
|
|
47
47
|
test("quote is the line, neighbors are the context selectors", () => {
|
|
48
48
|
const rows = diffRows(PATCH);
|
|
49
|
-
const
|
|
50
|
-
const anchor = diffRowAnchor(rows,
|
|
49
|
+
const rowIndex = rows.findIndex((row) => row.text.includes("new Map()"));
|
|
50
|
+
const anchor = diffRowAnchor(rows, rowIndex);
|
|
51
51
|
expect(anchor.quote).toContain("new Map()");
|
|
52
52
|
expect(anchor.prefix.length).toBeGreaterThan(0);
|
|
53
53
|
expect(anchor.suffix.length).toBeGreaterThan(0);
|
package/src/view-diff.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Diff artifact projection
|
|
2
|
+
* Diff artifact projection: flatten @pierre/diffs' parsed
|
|
3
3
|
* patch model into render rows. Line-anchored annotations use the same
|
|
4
4
|
* quote-primary anchors as plans: quote = the line content, prefix/suffix =
|
|
5
5
|
* the neighbor lines - so the whole anchor/feedback pipeline is shared.
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
|
|
9
9
|
|
|
10
|
-
export type
|
|
10
|
+
export type DiffRowKind = "file" | "hunk" | "ctx" | "add" | "del";
|
|
11
11
|
|
|
12
12
|
export interface DiffRow {
|
|
13
|
-
|
|
13
|
+
kind: DiffRowKind;
|
|
14
14
|
text: string;
|
|
15
15
|
file: string;
|
|
16
16
|
oldLine?: number;
|
|
@@ -22,35 +22,35 @@ export function diffRows(patchText: string): DiffRow[] {
|
|
|
22
22
|
const patches = parsePatchFiles(patchText);
|
|
23
23
|
for (const patch of patches) {
|
|
24
24
|
for (const file of patch.files) {
|
|
25
|
-
rows.push({
|
|
25
|
+
rows.push({ kind: "file", text: fileLabel(file), file: file.name });
|
|
26
26
|
for (const hunk of file.hunks) {
|
|
27
|
-
rows.push({
|
|
27
|
+
rows.push({ kind: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
|
|
28
28
|
let oldLine = hunk.deletionStart;
|
|
29
29
|
let newLine = hunk.additionStart;
|
|
30
|
-
for (const
|
|
31
|
-
if (
|
|
32
|
-
for (let i = 0; i <
|
|
30
|
+
for (const segment of hunk.hunkContent) {
|
|
31
|
+
if (segment.type === "context") {
|
|
32
|
+
for (let i = 0; i < segment.lines; i++) {
|
|
33
33
|
rows.push({
|
|
34
|
-
|
|
35
|
-
text: file.additionLines[
|
|
34
|
+
kind: "ctx",
|
|
35
|
+
text: file.additionLines[segment.additionLineIndex + i] ?? "",
|
|
36
36
|
file: file.name,
|
|
37
37
|
oldLine: oldLine++,
|
|
38
38
|
newLine: newLine++,
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
} else {
|
|
42
|
-
for (let i = 0; i <
|
|
42
|
+
for (let i = 0; i < segment.deletions; i++) {
|
|
43
43
|
rows.push({
|
|
44
|
-
|
|
45
|
-
text: file.deletionLines[
|
|
44
|
+
kind: "del",
|
|
45
|
+
text: file.deletionLines[segment.deletionLineIndex + i] ?? "",
|
|
46
46
|
file: file.name,
|
|
47
47
|
oldLine: oldLine++,
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
|
-
for (let i = 0; i <
|
|
50
|
+
for (let i = 0; i < segment.additions; i++) {
|
|
51
51
|
rows.push({
|
|
52
|
-
|
|
53
|
-
text: file.additionLines[
|
|
52
|
+
kind: "add",
|
|
53
|
+
text: file.additionLines[segment.additionLineIndex + i] ?? "",
|
|
54
54
|
file: file.name,
|
|
55
55
|
newLine: newLine++,
|
|
56
56
|
});
|
|
@@ -68,14 +68,14 @@ function fileLabel(file: FileDiffMetadata): string {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/** Quote-primary anchor for a diff row: neighbors as context selectors. */
|
|
71
|
-
export function diffRowAnchor(rows: DiffRow[],
|
|
72
|
-
const row = rows[
|
|
73
|
-
const prev = rows[
|
|
74
|
-
const next = rows[
|
|
71
|
+
export function diffRowAnchor(rows: DiffRow[], rowIndex: number): { quote: string; prefix: string; suffix: string } {
|
|
72
|
+
const row = rows[rowIndex]!;
|
|
73
|
+
const prev = rows[rowIndex - 1];
|
|
74
|
+
const next = rows[rowIndex + 1];
|
|
75
75
|
return {
|
|
76
76
|
quote: row.text,
|
|
77
|
-
prefix: prev && (prev.
|
|
78
|
-
suffix: next && (next.
|
|
77
|
+
prefix: prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del") ? prev.text.slice(-24) : "",
|
|
78
|
+
suffix: next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del") ? next.text.slice(0, 24) : "",
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|