@sealant/mend 0.9.0 → 0.10.1

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/dist/review.js CHANGED
@@ -2,12 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/reac
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { RGBA, SyntaxStyle, } from "@opentui/core";
4
4
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
- import { useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
6
6
  import { useEffect, useMemo, useRef, useState } from "react";
7
7
  import { assembleReviewInstruction, buildReviewFiles, visibleWhitespace, } from "./review-model.js";
8
8
  import { commentRange, deliverReview } from "./review-workflow.js";
9
+ import { isPendingId, pendingId } from "./shared.js";
9
10
  import { openUrl } from "./terminal.js";
10
- import { ADD_WASH, AMBER, BG, COBALT, DELETE_WASH, FAINT, GREEN, INK, INK_2, MUTED, PANEL, RED, RULE, SYNTAX_FUNCTION, SYNTAX_KEYWORD, SYNTAX_NUMBER, SYNTAX_STRING, WASH, } from "./tui-theme.js";
11
+ import { ADD_WASH, COBALT, DELETE_WASH, FAINT, GREEN, INK, INK_2, MUTED, RED, RULE, SYNTAX_FUNCTION, SYNTAX_KEYWORD, SYNTAX_NUMBER, SYNTAX_STRING, WASH, } from "./tui-theme.js";
11
12
  const REVIEW_KEY = (changeId) => ["review", changeId];
12
13
  const EDITOR_KEY_BINDINGS = [{ name: "return", ctrl: true, action: "submit" }];
13
14
  const syntaxStyle = SyntaxStyle.fromStyles({
@@ -66,44 +67,43 @@ const orderedComments = (comments) => comments.toSorted((a, b) => {
66
67
  const byState = commentPriority(a) - commentPriority(b);
67
68
  return byState === 0 ? b.createdAt.localeCompare(a.createdAt) : byState;
68
69
  });
69
- const stateColor = (state) => state === "draft" || state === "open" ? AMBER : state === "addressed" ? INK_2 : FAINT;
70
- const stateGlyph = (state) => (state === "open" ? "●" : "○");
70
+ const stateColor = (state) => state === "draft" || state === "open" ? INK : state === "addressed" ? INK_2 : FAINT;
71
71
  const anchorOf = (comment) => comment.file === null
72
72
  ? "change"
73
73
  : `${comment.file}${comment.line === null ? "" : `:${comment.line}${comment.endLine === null || comment.endLine === comment.line ? "" : `-${comment.endLine}`}`}`;
74
74
  const passFact = (pass) => {
75
75
  const label = pass.kind === "suggest" ? "suggestions" : pass.kind;
76
76
  if (pass.status === "running")
77
- return { text: `● ${label} running`, color: COBALT };
77
+ return { text: `${label} running`, color: INK_2 };
78
78
  if (pass.status === "failed")
79
- return { text: `● ${label} failed`, color: RED };
79
+ return { text: `${label} failed`, color: RED };
80
80
  return {
81
- text: `● ${label} observed · ${pass.findings ?? 0} ${pass.kind === "tour" ? "stops" : "drafts"}`,
82
- color: GREEN,
81
+ text: `${label} observed · ${pass.findings ?? 0} ${pass.kind === "tour" ? "stops" : "drafts"}`,
82
+ color: MUTED,
83
83
  };
84
84
  };
85
- const FileRow = ({ file, selected }) => (_jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }), _jsx("span", { fg: INK, children: file.path }), file.binary ? _jsx("span", { fg: AMBER, children: " \u00B7 binary" }) : null, file.likelyGenerated ? _jsx("span", { fg: FAINT, children: " \u00B7 inferred \u00B7 likely generated" }) : null] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsxs("span", { fg: GREEN, children: ["+", file.additions] }), _jsx("span", { fg: FAINT, children: " " }), _jsxs("span", { fg: RED, children: ["\u2212", file.deletions] }), _jsxs("span", { fg: FAINT, children: [" ", "\u00B7 ", file.status, " \u00B7 ", file.hunks.length, " hunks"] })] })] }));
86
- const CommentRow = ({ comment, selected, }) => (_jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }), _jsxs("span", { fg: stateColor(comment.state), children: [stateGlyph(comment.state), " ", comment.state] }), _jsxs("span", { fg: FAINT, children: [" \u00B7 ", anchorOf(comment)] })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsx("span", { fg: comment.authorKind === "mend" ? COBALT : MUTED, children: comment.authorKind === "mend" ? "Mend · " : "You · " }), _jsx("span", { fg: INK, children: truncate(comment.body, 27) })] })] }));
85
+ const FileRow = ({ file, selected }) => (_jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }), _jsx("span", { fg: INK, children: file.path }), file.binary ? _jsx("span", { fg: MUTED, children: " \u00B7 binary" }) : null, file.likelyGenerated ? _jsx("span", { fg: FAINT, children: " \u00B7 inferred \u00B7 likely generated" }) : null] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsxs("span", { fg: GREEN, children: ["+", file.additions] }), _jsx("span", { fg: FAINT, children: " " }), _jsxs("span", { fg: RED, children: ["\u2212", file.deletions] }), _jsxs("span", { fg: FAINT, children: [" ", "\u00B7 ", file.status, " \u00B7 ", file.hunks.length, " hunks"] })] })] }));
86
+ const CommentRow = ({ comment, selected, }) => (_jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }), _jsx("span", { fg: stateColor(comment.state), children: comment.state }), _jsxs("span", { fg: FAINT, children: [" \u00B7 ", anchorOf(comment)] })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsx("span", { fg: MUTED, children: comment.authorKind === "mend" ? "Mend · " : "You · " }), _jsx("span", { fg: INK, children: truncate(comment.body, 27) })] })] }));
87
87
  const Description = ({ data, tourIndex, stale, width, height, }) => {
88
88
  const passFacts = data.passes.map(passFact);
89
89
  const tourStop = data.tour?.stops[tourIndex] ?? null;
90
90
  const stopEvidence = tourStop?.evidence[0] ?? null;
91
- return (_jsxs("box", { height: height, border: true, borderStyle: "rounded", borderColor: stale ? AMBER : data.tour === null ? RULE : COBALT, title: " change description ", titleAlignment: "left", backgroundColor: PANEL, flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, bg: "transparent", children: data.tour === null ? (_jsx("span", { fg: INK, children: "No description yet \u2014 t composes one from the diff and session record." })) : (_jsxs(_Fragment, { children: [_jsx("span", { fg: COBALT, children: "Mend uses inference \u00B7 " }), stale ? _jsx("span", { fg: AMBER, children: "diff changed since \u00B7 " }) : null, _jsx("span", { fg: INK, children: truncate(data.tour.summary, Math.max(20, width - 50)) })] })) }), height >= 5 && data.tour !== null ? (_jsx("text", { height: 1, bg: "transparent", children: tourStop === null ? (_jsxs("span", { fg: FAINT, children: ["inferred approach \u00B7 ", truncate(data.tour.approach ?? "No tour stops yet.", width - 24)] })) : (_jsxs(_Fragment, { children: [_jsxs("span", { fg: COBALT, children: ["stop ", tourIndex + 1, "/", data.tour.stops.length, " \u00B7 ", tourStop.title] }), _jsxs("span", { fg: FAINT, children: [tourStop.file === null
91
+ return (_jsxs("box", { height: height, border: true, borderStyle: "rounded", borderColor: RULE, title: " change description ", titleAlignment: "left", backgroundColor: "transparent", flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, bg: "transparent", children: data.tour === null ? (_jsx("span", { fg: INK, children: "No description yet \u2014 t composes one from the diff and session record." })) : (_jsxs(_Fragment, { children: [_jsx("span", { fg: MUTED, children: "Mend uses inference \u00B7 " }), stale ? _jsx("span", { fg: MUTED, children: "diff changed since \u00B7 " }) : null, _jsx("span", { fg: INK, children: truncate(data.tour.summary, Math.max(20, width - 50)) })] })) }), height >= 5 && data.tour !== null ? (_jsx("text", { height: 1, bg: "transparent", children: tourStop === null ? (_jsxs("span", { fg: FAINT, children: ["inferred approach \u00B7 ", truncate(data.tour.approach ?? "No tour stops yet.", width - 24)] })) : (_jsxs(_Fragment, { children: [_jsxs("span", { fg: INK_2, children: ["stop ", tourIndex + 1, "/", data.tour.stops.length, " \u00B7 ", tourStop.title] }), _jsxs("span", { fg: FAINT, children: [tourStop.file === null
92
92
  ? " · change"
93
- : ` · ${tourStop.file}${tourStop.line === null ? "" : `:${tourStop.line}`}`, tourStop.grounded ? " · direct record" : " · inferred reading"] })] })) })) : null, height >= 6 && tourStop !== null ? (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: COBALT, children: tourStop.grounded ? "record-grounded narration · " : "inferred narration · " }), _jsx("span", { fg: MUTED, children: truncate(tourStop.narration, Math.max(20, width - 24)) })] })) : null, height >= 7 && tourStop !== null ? (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: stopEvidence === null ? AMBER : COBALT, children: stopEvidence === null
93
+ : ` · ${tourStop.file}${tourStop.line === null ? "" : `:${tourStop.line}`}`, tourStop.grounded ? " · direct record" : " · inferred reading"] })] })) })) : null, height >= 6 && tourStop !== null ? (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: MUTED, children: tourStop.grounded ? "record-grounded narration · " : "inferred narration · " }), _jsx("span", { fg: MUTED, children: truncate(tourStop.narration, Math.max(20, width - 24)) })] })) : null, height >= 7 && tourStop !== null ? (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: MUTED, children: stopEvidence === null
94
94
  ? "no direct evidence · "
95
- : `evidence seq ${stopEvidence.sequence} · ` }), _jsx("span", { fg: MUTED, children: truncate(stopEvidence?.excerpt ?? "this stop is labeled inferred", Math.max(20, width - 26)) })] })) : null, _jsxs("text", { height: 1, bg: "transparent", children: [passFacts.length === 0 ? (_jsx("span", { fg: FAINT, children: "Mend has not read this change." })) : (passFacts.map((fact, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, _jsx("span", { fg: fact.color, children: fact.text })] }, `${fact.text}-${index}`)))), data.followUp?.status === "pending" ? _jsx("span", { fg: AMBER, children: " \u00B7 follow-up pending" }) : null] })] }));
95
+ : `evidence seq ${stopEvidence.sequence} · ` }), _jsx("span", { fg: MUTED, children: truncate(stopEvidence?.excerpt ?? "this stop is labeled inferred", Math.max(20, width - 26)) })] })) : null, _jsxs("text", { height: 1, bg: "transparent", children: [passFacts.length === 0 ? (_jsx("span", { fg: FAINT, children: "Mend has not read this change." })) : (passFacts.map((fact, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, _jsx("span", { fg: fact.color, children: fact.text })] }, `${fact.text}-${index}`)))), data.followUp?.status === "pending" ? _jsx("span", { fg: INK_2, children: " \u00B7 follow-up pending" }) : null] })] }));
96
96
  };
97
97
  const CompactTourEvidence = ({ stop, index, total, width, }) => {
98
98
  const evidence = stop.evidence[0] ?? null;
99
- return (_jsxs("box", { height: 5, border: true, borderStyle: "rounded", borderColor: stop.grounded ? COBALT : RULE, title: ` tour stop ${index + 1}/${total} · ${stop.grounded ? "direct record" : "inferred reading"} `, titleAlignment: "left", backgroundColor: PANEL, flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(stop.narration, Math.max(12, width - 6)) }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: evidence === null ? AMBER : COBALT, children: evidence === null ? "no direct record link · " : `seq ${evidence.sequence} · ` }), _jsx("span", { fg: MUTED, children: truncate(evidence?.excerpt ?? "the narration is an inferred reading", Math.max(12, width - 26)) })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ", previous stop \u00B7 . next stop" })] }));
99
+ return (_jsxs("box", { height: 5, border: true, borderStyle: "rounded", borderColor: RULE, title: ` tour stop ${index + 1}/${total} · ${stop.grounded ? "direct record" : "inferred reading"} `, titleAlignment: "left", backgroundColor: "transparent", flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(stop.narration, Math.max(12, width - 6)) }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: MUTED, children: evidence === null ? "no direct record link · " : `seq ${evidence.sequence} · ` }), _jsx("span", { fg: MUTED, children: truncate(evidence?.excerpt ?? "the narration is an inferred reading", Math.max(12, width - 26)) })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ", previous stop \u00B7 . next stop" })] }));
100
100
  };
101
- const EvidenceCard = ({ comment, width, }) => (_jsxs("box", { height: Math.min(6, 4 + Math.min(2, comment.evidence.length)), border: true, borderStyle: "rounded", borderColor: comment.state === "draft" ? AMBER : RULE, title: ` ${anchorOf(comment)} · ${comment.authorKind === "mend" ? "Mend" : "You"} `, titleAlignment: "left", backgroundColor: PANEL, flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(comment.body, Math.max(12, width - 6)) }), comment.suggestion === null ? null : (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: COBALT, children: "proposed \u00B7 " }), _jsx("span", { fg: MUTED, children: truncate(comment.suggestion, Math.max(12, width - 17)) })] })), comment.evidence.slice(0, 2).map((evidence) => (_jsxs("text", { height: 1, bg: "transparent", children: [_jsxs("span", { fg: FAINT, children: ["seq ", evidence.sequence, " \u00B7 "] }), _jsx("span", { fg: MUTED, children: truncate(evidence.excerpt, Math.max(12, width - 18)) })] }, `${evidence.sequence}-${evidence.excerpt}`)))] }));
101
+ const EvidenceCard = ({ comment, width, }) => (_jsxs("box", { height: Math.min(6, 4 + Math.min(2, comment.evidence.length)), border: true, borderStyle: "rounded", borderColor: RULE, title: ` ${anchorOf(comment)} · ${comment.authorKind === "mend" ? "Mend" : "You"} `, titleAlignment: "left", backgroundColor: "transparent", flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(comment.body, Math.max(12, width - 6)) }), comment.suggestion === null ? null : (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: MUTED, children: "proposed \u00B7 " }), _jsx("span", { fg: MUTED, children: truncate(comment.suggestion, Math.max(12, width - 17)) })] })), comment.evidence.slice(0, 2).map((evidence) => (_jsxs("text", { height: 1, bg: "transparent", children: [_jsxs("span", { fg: FAINT, children: ["seq ", evidence.sequence, " \u00B7 "] }), _jsx("span", { fg: MUTED, children: truncate(evidence.excerpt, Math.max(12, width - 18)) })] }, `${evidence.sequence}-${evidence.excerpt}`)))] }));
102
102
  const EditorPanel = ({ editor, refValue, busy, onSubmit, }) => {
103
103
  const title = editor.kind === "send"
104
104
  ? " send review to session · edit before sending "
105
105
  : ` comment · ${editor.file === null ? "change" : `${editor.file}:${editor.line ?? ""}`} `;
106
- return (_jsxs("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: title, titleAlignment: "left", backgroundColor: BG, height: editor.kind === "send" ? 16 : 8, flexShrink: 0, flexDirection: "column", children: [_jsx("textarea", { ref: refValue, focused: true, initialValue: editor.kind === "send" ? editor.instruction : "", placeholder: editor.kind === "send" ? "Review instruction…" : "What should change, and why?", backgroundColor: BG, focusedBackgroundColor: BG, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, keyBindings: [...EDITOR_KEY_BINDINGS], flexGrow: 1, onSubmit: onSubmit }), _jsx("text", { height: 1, bg: "transparent", children: _jsxs("span", { fg: FAINT, children: [" ctrl+enter ", busy ? "working…" : "save", " \u00B7 esc cancel"] }) })] }));
106
+ return (_jsxs("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: title, titleAlignment: "left", backgroundColor: "transparent", height: editor.kind === "send" ? 16 : 8, flexShrink: 0, flexDirection: "column", children: [_jsx("textarea", { ref: refValue, focused: true, initialValue: editor.kind === "send" ? editor.instruction : (editor.initialBody ?? ""), placeholder: editor.kind === "send" ? "Review instruction…" : "What should change, and why?", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, keyBindings: [...EDITOR_KEY_BINDINGS], flexGrow: 1, onSubmit: onSubmit }), _jsx("text", { height: 1, bg: "transparent", children: _jsxs("span", { fg: FAINT, children: [" ctrl+enter ", busy ? "working…" : "save", " \u00B7 esc cancel"] }) })] }));
107
107
  };
108
108
  const errorMessage = (error) => error instanceof Error ? error.message : error === null ? "" : String(error);
109
109
  export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQuit, }) {
@@ -167,6 +167,68 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
167
167
  const refresh = () => {
168
168
  void queryClient.invalidateQueries({ queryKey: REVIEW_KEY(changeId) });
169
169
  };
170
+ /** Converge on the server's truth once, after the LAST in-flight mutation. */
171
+ const settleRefresh = () => {
172
+ if (queryClient.isMutating() === 1)
173
+ refresh();
174
+ };
175
+ const patchReview = (f) => {
176
+ const current = queryClient.getQueryData(REVIEW_KEY(changeId));
177
+ if (current !== undefined)
178
+ queryClient.setQueryData(REVIEW_KEY(changeId), f(current));
179
+ };
180
+ // A state flip (accept, address, dismiss, reopen) lands in the list at the
181
+ // keystroke — triage never waits on a round trip. An error refetches the
182
+ // truth back; the settled refetch reconciles everything else.
183
+ const commentStateMutation = useMutation({
184
+ mutationFn: (vars) => ctx.api("POST", `/changes/${changeId}/comments/${vars.commentId}/state`, {
185
+ state: vars.state,
186
+ }),
187
+ onMutate: async (vars) => {
188
+ await queryClient.cancelQueries({ queryKey: REVIEW_KEY(changeId) });
189
+ patchReview((current) => ({
190
+ ...current,
191
+ comments: current.comments.map((comment) => comment.id === vars.commentId ? { ...comment, state: vars.state } : comment),
192
+ }));
193
+ },
194
+ onError: (error) => {
195
+ setStatus(errorMessage(error));
196
+ refresh();
197
+ },
198
+ onSettled: settleRefresh,
199
+ });
200
+ // A new comment appears in the list the moment ctrl+enter lands and the
201
+ // editor closes; a failed save reopens the editor with the text intact.
202
+ const commentCreateMutation = useMutation({
203
+ mutationFn: (vars) => ctx.api("POST", `/changes/${changeId}/reviews/${vars.sliceId}/comments`, {
204
+ target: vars.target,
205
+ body: vars.body,
206
+ }),
207
+ onMutate: async (vars) => {
208
+ await queryClient.cancelQueries({ queryKey: REVIEW_KEY(changeId) });
209
+ patchReview((current) => ({
210
+ ...current,
211
+ comments: [vars.optimistic, ...current.comments],
212
+ }));
213
+ setEditor(null);
214
+ setStatus(vars.optimistic.file === null ? "Change comment added" : "Inline comment added");
215
+ },
216
+ onError: (error, vars) => {
217
+ patchReview((current) => ({
218
+ ...current,
219
+ comments: current.comments.filter((comment) => comment.id !== vars.optimistic.id),
220
+ }));
221
+ setEditor({
222
+ kind: "comment",
223
+ file: vars.optimistic.file,
224
+ line: vars.optimistic.line,
225
+ endLine: vars.optimistic.endLine,
226
+ initialBody: vars.body,
227
+ });
228
+ setStatus(errorMessage(error));
229
+ },
230
+ onSettled: settleRefresh,
231
+ });
170
232
  useEffect(() => {
171
233
  fileScrollRef.current?.scrollTo(Math.max(0, fileIndex * 2 - 2));
172
234
  }, [fileIndex]);
@@ -317,7 +379,9 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
317
379
  const openSendEditor = () => {
318
380
  if (data === undefined)
319
381
  return;
320
- const sendable = data.comments.filter((comment) => comment.state === "open" && comment.sentToSessionId === null);
382
+ // A pending id is an optimistic row the server has not named yet — it
383
+ // cannot ride a follow-up until the settle refetch replaces it.
384
+ const sendable = data.comments.filter((comment) => comment.state === "open" && comment.sentToSessionId === null && !isPendingId(comment.id));
321
385
  if (sendable.length === 0) {
322
386
  setStatus("No unsent open comments — accept a draft or write a comment first");
323
387
  return;
@@ -337,58 +401,74 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
337
401
  setStatus("Write something before saving");
338
402
  return;
339
403
  }
404
+ if (data === undefined) {
405
+ setStatus("The pinned Review is not available.");
406
+ return;
407
+ }
408
+ if (editor.kind === "comment") {
409
+ const anchorFile = data.anchorFiles.find((file) => (file.newPath ?? file.oldPath) === editor.file);
410
+ const endLine = editor.endLine ?? editor.line;
411
+ const hunk = editor.line === null || endLine === null
412
+ ? null
413
+ : (anchorFile?.hunks.find((candidate) => candidate.newLines > 0 &&
414
+ editor.line !== null &&
415
+ editor.line >= candidate.newStart &&
416
+ endLine <= candidate.newStart + candidate.newLines - 1) ?? null);
417
+ if (editor.file !== null && (anchorFile === undefined || hunk === null)) {
418
+ setStatus("The selected line is outside the pinned Review anchor.");
419
+ return;
420
+ }
421
+ commentCreateMutation.mutate({
422
+ sliceId: data.sliceId,
423
+ body: value,
424
+ target: editor.file === null
425
+ ? {
426
+ oldPath: null,
427
+ newPath: null,
428
+ side: null,
429
+ startLine: null,
430
+ endLine: null,
431
+ hunkContextHash: null,
432
+ }
433
+ : {
434
+ oldPath: anchorFile?.oldPath ?? null,
435
+ newPath: anchorFile?.newPath ?? null,
436
+ side: "new",
437
+ startLine: editor.line,
438
+ endLine,
439
+ hunkContextHash: hunk?.contextHash ?? null,
440
+ },
441
+ optimistic: {
442
+ id: pendingId(),
443
+ file: editor.file,
444
+ line: editor.line,
445
+ endLine: editor.endLine,
446
+ authorKind: "reviewer",
447
+ authorName: "You",
448
+ body: value,
449
+ suggestion: null,
450
+ state: "open",
451
+ evidence: [],
452
+ sentToSessionId: null,
453
+ createdAt: new Date().toISOString(),
454
+ },
455
+ });
456
+ return;
457
+ }
458
+ // Delivery stays pessimistic: it starts a run against the session, and
459
+ // pretending it happened before the server agreed would fake a fact.
340
460
  setWorking(true);
341
461
  try {
342
- if (editor.kind === "comment") {
343
- if (data === undefined)
344
- throw new Error("The pinned Review is not available.");
345
- const anchorFile = data.anchorFiles.find((file) => (file.newPath ?? file.oldPath) === editor.file);
346
- const endLine = editor.endLine ?? editor.line;
347
- const hunk = editor.line === null || endLine === null
348
- ? null
349
- : (anchorFile?.hunks.find((candidate) => candidate.newLines > 0 &&
350
- editor.line !== null &&
351
- editor.line >= candidate.newStart &&
352
- endLine <= candidate.newStart + candidate.newLines - 1) ?? null);
353
- if (editor.file !== null && (anchorFile === undefined || hunk === null)) {
354
- throw new Error("The selected line is outside the pinned Review anchor.");
355
- }
356
- await ctx.api("POST", `/changes/${changeId}/reviews/${data.sliceId}/comments`, {
357
- target: editor.file === null
358
- ? {
359
- oldPath: null,
360
- newPath: null,
361
- side: null,
362
- startLine: null,
363
- endLine: null,
364
- hunkContextHash: null,
365
- }
366
- : {
367
- oldPath: anchorFile?.oldPath ?? null,
368
- newPath: anchorFile?.newPath ?? null,
369
- side: "new",
370
- startLine: editor.line,
371
- endLine,
372
- hunkContextHash: hunk?.contextHash ?? null,
373
- },
374
- body: value,
375
- });
376
- setStatus(editor.file === null ? "Change comment saved" : "Inline comment saved");
377
- }
378
- else {
379
- if (data === undefined)
380
- throw new Error("The pinned Review is not available.");
381
- await deliverReview(ctx.api, session.id, {
382
- reviewSliceId: data.sliceId,
383
- checkpointAId: data.checkpointAId,
384
- checkpointBId: data.checkpointBId,
385
- diffDigest: data.diffDigest,
386
- commentIds: editor.commentIds,
387
- instruction: value,
388
- idempotencyKey: editor.idempotencyKey,
389
- });
390
- setStatus("Follow-up delivery requested · retry keeps the same run");
391
- }
462
+ await deliverReview(ctx.api, session.id, {
463
+ reviewSliceId: data.sliceId,
464
+ checkpointAId: data.checkpointAId,
465
+ checkpointBId: data.checkpointBId,
466
+ diffDigest: data.diffDigest,
467
+ commentIds: editor.commentIds,
468
+ instruction: value,
469
+ idempotencyKey: editor.idempotencyKey,
470
+ });
471
+ setStatus("Follow-up delivery requested · retry keeps the same run");
392
472
  setEditor(null);
393
473
  refresh();
394
474
  }
@@ -402,7 +482,12 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
402
482
  const updateComment = (state) => {
403
483
  if (selectedComment === null)
404
484
  return;
405
- void runAction(`${state} ${anchorOf(selectedComment)}`, () => ctx.api("POST", `/changes/${changeId}/comments/${selectedComment.id}/state`, { state }));
485
+ if (isPendingId(selectedComment.id)) {
486
+ setStatus("That comment is still saving — one moment");
487
+ return;
488
+ }
489
+ setStatus(`${state} · ${anchorOf(selectedComment)}`);
490
+ commentStateMutation.mutate({ commentId: selectedComment.id, state });
406
491
  };
407
492
  const deliverAndRelaunch = () => {
408
493
  const followUp = data?.followUp ?? null;
@@ -548,7 +633,7 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
548
633
  return setShowWhitespace((current) => !current);
549
634
  });
550
635
  if (data === undefined) {
551
- return (_jsx("box", { flexGrow: 1, backgroundColor: BG, alignItems: "center", justifyContent: "center", children: _jsx("text", { bg: "transparent", children: _jsx("span", { fg: failureReason === null ? COBALT : AMBER, children: failureReason === null
636
+ return (_jsx("box", { flexGrow: 1, backgroundColor: "transparent", alignItems: "center", justifyContent: "center", children: _jsx("text", { bg: "transparent", children: _jsx("span", { fg: failureReason === null ? INK_2 : MUTED, children: failureReason === null
552
637
  ? "Loading the live change…"
553
638
  : `${errorMessage(failureReason)} · retrying` }) }) }));
554
639
  }
@@ -571,35 +656,35 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
571
656
  ? " comments · ↑↓/jk move · enter anchor · a accept/address · x dismiss · u reopen · tab pane"
572
657
  : ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`
573
658
  : " ctrl+enter save · esc cancel";
574
- const sidebar = wide ? (_jsxs("box", { width: 36, flexShrink: 0, flexDirection: "column", backgroundColor: BG, children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "files" ? COBALT : RULE, title: ` files · ${files.length} `, titleAlignment: "left", backgroundColor: PANEL, flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: fileScrollRef, focused: focus === "files", flexGrow: 1, minHeight: 0, style: {
575
- rootOptions: { backgroundColor: PANEL, border: false },
576
- wrapperOptions: { backgroundColor: PANEL },
577
- viewportOptions: { backgroundColor: PANEL },
578
- contentOptions: { backgroundColor: PANEL },
579
- }, children: files.map((file, index) => (_jsxs("box", { flexShrink: 0, flexDirection: "column", children: [index === 0 || files[index - 1]?.status !== file.status ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ` ${file.status}` })) : null, _jsx(FileRow, { file: file, selected: index === fileIndex })] }, file.path))) }) }), _jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "comments" ? COBALT : RULE, title: ` comments · ${openCount} open${draftCount > 0 ? ` · ${draftCount} draft` : ""} `, titleAlignment: "left", backgroundColor: PANEL, flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: commentScrollRef, focused: focus === "comments", flexGrow: 1, minHeight: 0, style: {
580
- rootOptions: { backgroundColor: PANEL, border: false },
581
- wrapperOptions: { backgroundColor: PANEL },
582
- viewportOptions: { backgroundColor: PANEL },
583
- contentOptions: { backgroundColor: PANEL },
659
+ const sidebar = wide ? (_jsxs("box", { width: 36, flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "files" ? COBALT : RULE, title: ` files · ${files.length} `, titleAlignment: "left", backgroundColor: "transparent", flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: fileScrollRef, focused: focus === "files", flexGrow: 1, minHeight: 0, style: {
660
+ rootOptions: { backgroundColor: "transparent", border: false },
661
+ wrapperOptions: { backgroundColor: "transparent" },
662
+ viewportOptions: { backgroundColor: "transparent" },
663
+ contentOptions: { backgroundColor: "transparent" },
664
+ }, children: files.map((file, index) => (_jsxs("box", { flexShrink: 0, flexDirection: "column", children: [index === 0 || files[index - 1]?.status !== file.status ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ` ${file.status}` })) : null, _jsx(FileRow, { file: file, selected: index === fileIndex })] }, file.path))) }) }), _jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "comments" ? COBALT : RULE, title: ` comments · ${openCount} open${draftCount > 0 ? ` · ${draftCount} draft` : ""} `, titleAlignment: "left", backgroundColor: "transparent", flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: commentScrollRef, focused: focus === "comments", flexGrow: 1, minHeight: 0, style: {
665
+ rootOptions: { backgroundColor: "transparent", border: false },
666
+ wrapperOptions: { backgroundColor: "transparent" },
667
+ viewportOptions: { backgroundColor: "transparent" },
668
+ contentOptions: { backgroundColor: "transparent" },
584
669
  }, children: comments.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no review comments yet" })) : (comments.map((comment, index) => (_jsx(CommentRow, { comment: comment, selected: index === commentIndex }, comment.id)))) }) })] })) : null;
585
- return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: BG, children: [_jsxs("box", { height: 2, flexShrink: 0, flexDirection: "column", backgroundColor: BG, children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsx("span", { fg: FAINT, children: " / " }), _jsx("span", { fg: MUTED, children: projectName }), _jsx("span", { fg: FAINT, children: " / " }), _jsx("span", { fg: COBALT, children: "review" }), _jsxs("span", { fg: FAINT, children: [" ", "\u00B7 ", session.harness, " ", session.id.slice(0, 8)] }), isFetching ? _jsx("span", { fg: COBALT, children: " \u00B7 syncing" }) : null, _jsx("span", { fg: checkpointState === "failed" ? AMBER : FAINT, children: checkpointState === "recording"
670
+ return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 2, flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsx("span", { fg: FAINT, children: " / " }), _jsx("span", { fg: MUTED, children: projectName }), _jsx("span", { fg: FAINT, children: " / " }), _jsx("span", { fg: INK, children: "review" }), _jsxs("span", { fg: FAINT, children: [" ", "\u00B7 ", session.harness, " ", session.id.slice(0, 8)] }), isFetching ? _jsx("span", { fg: FAINT, children: " \u00B7 syncing" }) : null, _jsx("span", { fg: checkpointState === "failed" ? MUTED : FAINT, children: checkpointState === "recording"
586
671
  ? " · checkpointing"
587
672
  : checkpointState === "failed"
588
673
  ? " · checkpoint incomplete"
589
- : " · checkpoint recorded" })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsxs("span", { fg: FAINT, children: [" ", data.wire.change.branch.replace(/^mend\/session\//, "session ")] }), _jsxs("span", { fg: FAINT, children: [" \u00B7 worktree vs ", data.wire.change.baseSha.slice(0, 12), " \u00B7 "] }), _jsxs("span", { fg: MUTED, children: [files.length, " files"] }), _jsxs("span", { fg: GREEN, children: [" +", additions] }), _jsxs("span", { fg: RED, children: [" \u2212", deletions] }), _jsxs("span", { fg: AMBER, children: [" \u00B7 ", openCount, " open"] })] })] }), _jsx(Description, { data: data, tourIndex: activeTourIndex, stale: tourStale, width: width, height: descriptionHeight }), _jsxs("box", { flexGrow: 1, minHeight: 0, flexDirection: "row", backgroundColor: BG, children: [sidebar, _jsxs("box", { flexGrow: 1, minWidth: 0, minHeight: 0, flexDirection: "column", backgroundColor: BG, children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "diff" ? COBALT : RULE, title: selectedFile === null
674
+ : " · checkpoint recorded" })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsxs("span", { fg: FAINT, children: [" ", data.wire.change.branch.replace(/^mend\/session\//, "session ")] }), _jsxs("span", { fg: FAINT, children: [" \u00B7 worktree vs ", data.wire.change.baseSha.slice(0, 12), " \u00B7 "] }), _jsxs("span", { fg: MUTED, children: [files.length, " files"] }), _jsxs("span", { fg: GREEN, children: [" +", additions] }), _jsxs("span", { fg: RED, children: [" \u2212", deletions] }), _jsxs("span", { fg: MUTED, children: [" \u00B7 ", openCount, " open"] })] })] }), _jsx(Description, { data: data, tourIndex: activeTourIndex, stale: tourStale, width: width, height: descriptionHeight }), _jsxs("box", { flexGrow: 1, minHeight: 0, flexDirection: "row", backgroundColor: "transparent", children: [sidebar, _jsxs("box", { flexGrow: 1, minWidth: 0, minHeight: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "diff" ? COBALT : RULE, title: selectedFile === null
590
675
  ? " diff "
591
- : ` ${selectedFile.path} · ${view}${selectedFile.likelyGenerated ? " · inferred likely generated" : ""}${showWhitespace ? " · whitespace" : ""}${wrap ? " · wrapped" : ""} `, titleAlignment: "left", backgroundColor: PANEL, flexGrow: 1, minHeight: 0, children: selectedFile === null || selectedFile.patch === "" || selectedFile.binary ? (_jsx("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: _jsx("text", { fg: FAINT, bg: "transparent", children: files.length === 0
676
+ : ` ${selectedFile.path} · ${view}${selectedFile.likelyGenerated ? " · inferred likely generated" : ""}${showWhitespace ? " · whitespace" : ""}${wrap ? " · wrapped" : ""} `, titleAlignment: "left", backgroundColor: "transparent", flexGrow: 1, minHeight: 0, children: selectedFile === null || selectedFile.patch === "" || selectedFile.binary ? (_jsx("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: _jsx("text", { fg: FAINT, bg: "transparent", children: files.length === 0
592
677
  ? "The worktree matches its base — nothing to review yet."
593
678
  : selectedFile?.binary === true
594
679
  ? "Binary change — text diff unavailable; statistics remain reviewable."
595
680
  : "This file has no renderable text patch." }) })) : (_jsx("scrollbox", { ref: diffScrollRef, focused: focus === "diff", flexGrow: 1, minHeight: 0, scrollX: true, style: {
596
- rootOptions: { backgroundColor: PANEL, border: false },
597
- wrapperOptions: { backgroundColor: PANEL },
598
- viewportOptions: { backgroundColor: PANEL },
599
- contentOptions: { backgroundColor: PANEL },
681
+ rootOptions: { backgroundColor: "transparent", border: false },
682
+ wrapperOptions: { backgroundColor: "transparent" },
683
+ viewportOptions: { backgroundColor: "transparent" },
684
+ contentOptions: { backgroundColor: "transparent" },
600
685
  }, children: _jsx("diff", { ref: diffRef, diff: displayPatch, view: view, syncScroll: true, ...(selectedFile.filetype === undefined
601
686
  ? {}
602
- : { filetype: selectedFile.filetype }), syntaxStyle: syntaxStyle, wrapMode: wrap ? "word" : "none", showLineNumbers: true, fg: INK, lineNumberFg: FAINT, lineNumberBg: "transparent", contextBg: "transparent", contextContentBg: "transparent", addedBg: ADD_WASH, addedContentBg: ADD_WASH, removedBg: DELETE_WASH, removedContentBg: DELETE_WASH, addedSignColor: GREEN, removedSignColor: RED, addedLineNumberBg: "transparent", removedLineNumberBg: "transparent", selectionBg: WASH, selectionFg: INK, width: "100%", height: displayRows, flexShrink: 0 }, `${selectedFile.path}-${view}-${wrap}-${showWhitespace}`) })) }), focus === "comments" && detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : height < 30 && activeTourStop !== null ? (_jsx(CompactTourEvidence, { stop: activeTourStop, index: activeTourIndex, total: tourStops.length, width: width })) : detailComment === null ? null : (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width }))] })] }), editor === null ? null : (_jsx(EditorPanel, { editor: editor, refValue: editorRef, busy: busy, onSubmit: () => void submitEditor() })), _jsx("text", { height: 1, fg: status === "" ? FAINT : AMBER, bg: "transparent", children: status === ""
687
+ : { filetype: selectedFile.filetype }), syntaxStyle: syntaxStyle, wrapMode: wrap ? "word" : "none", showLineNumbers: true, fg: INK, lineNumberFg: FAINT, lineNumberBg: "transparent", contextBg: "transparent", contextContentBg: "transparent", addedBg: ADD_WASH, addedContentBg: ADD_WASH, removedBg: DELETE_WASH, removedContentBg: DELETE_WASH, addedSignColor: GREEN, removedSignColor: RED, addedLineNumberBg: "transparent", removedLineNumberBg: "transparent", selectionBg: WASH, selectionFg: INK, width: "100%", height: displayRows, flexShrink: 0 }, `${selectedFile.path}-${view}-${wrap}-${showWhitespace}`) })) }), focus === "comments" && detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : height < 30 && activeTourStop !== null ? (_jsx(CompactTourEvidence, { stop: activeTourStop, index: activeTourIndex, total: tourStops.length, width: width })) : detailComment === null ? null : (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width }))] })] }), editor === null ? null : (_jsx(EditorPanel, { editor: editor, refValue: editorRef, busy: busy, onSubmit: () => void submitEditor() })), _jsx("text", { height: 1, fg: status === "" ? FAINT : INK_2, bg: "transparent", children: status === ""
603
688
  ? ` m read · g suggest · t tour · ,/. tour stops · s draft review${data.followUp?.status === "pending" ? " · y deliver & relaunch" : ""} · o web · r refresh`
604
689
  : ` ${status}` }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footer })] }));
605
690
  }
package/dist/shared.js CHANGED
@@ -20,6 +20,14 @@ export const CONTINUE_COMMANDS = {
20
20
  };
21
21
  /** Mirror of @mend/domain/workbench harness-launch.ts — the CLI ships dependency-free. */
22
22
  export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
23
+ /**
24
+ * Ids of optimistic cache rows the server has not named yet — a session still
25
+ * provisioning, a review comment still saving. Anything that would send such
26
+ * an id to the server checks here first and waits instead.
27
+ */
28
+ export const PENDING_ID_PREFIX = "pending:";
29
+ export const pendingId = () => `${PENDING_ID_PREFIX}${crypto.randomUUID()}`;
30
+ export const isPendingId = (id) => id.startsWith(PENDING_ID_PREFIX);
23
31
  const LAUNCH_ERROR = {
24
32
  project: null,
25
33
  prompt: null,
package/dist/tui-theme.js CHANGED
@@ -1,25 +1,36 @@
1
1
  /**
2
- * Evidence Review's canonical dark tokens, transcribed from
3
- * packages/ui/src/styles/globals.css because a terminal cannot consume CSS
4
- * custom properties. Component code imports semantic names from here; this
5
- * file is the CLI's single color authority.
2
+ * The TUI's theme: a drawn interface with NO painted background — the
3
+ * terminal's own ground shows through everything, and the borders alone
4
+ * draw the panes. The only fills left are the ones that state something:
5
+ * the selection row and the diff add/delete washes. Chrome is near-mono;
6
+ * color appears only where it is a fact. Component code imports semantic
7
+ * names from here; this file is the CLI's single color authority.
6
8
  */
7
- export const BG = "#1c1c1f";
8
- export const PANEL = "#232327";
9
- export const INK = "#eceae6";
10
- export const INK_2 = "#c2c0bd";
11
- export const MUTED = "#9a978f";
12
- export const FAINT = "#76746d";
13
- export const RULE = "#2e2c28";
14
- export const COBALT = "#5781ea";
15
- export const WASH = "#1a2238";
16
- export const AMBER = "#d9a93a";
17
- export const GREEN = "#5faf78";
18
- export const RED = "#e0726a";
19
- export const ADD_WASH = "#29312f";
20
- export const DELETE_WASH = "#342a2d";
9
+ /** Primary text. */
10
+ export const INK = "#eeece8";
11
+ /** Secondary text. */
12
+ export const INK_2 = "#c9c7c2";
13
+ /** Tertiary text — labels, meta. */
14
+ export const MUTED = "#a3a09a";
15
+ /** The quietest legible step; anything darker reads as broken. */
16
+ export const FAINT = "#807d76";
17
+ /** Hairlines and panel borders — visible, not vanishing. */
18
+ export const RULE = "#37374a";
19
+ /**
20
+ * Interaction and selection — the ONE accent, and the only place chrome is
21
+ * allowed color: the focused pane border and the selection gutter bar.
22
+ */
23
+ export const COBALT = "#6f96f5";
24
+ /** The selection wash: a neutral lifted row — visible, not colorful. */
25
+ export const WASH = "#2c2c35";
26
+ export const AMBER = "#e2ab42";
27
+ export const GREEN = "#69c189";
28
+ export const RED = "#e87f76";
29
+ /** Diff line washes — strong enough to scan, never louder than the ink. */
30
+ export const ADD_WASH = "#20362a";
31
+ export const DELETE_WASH = "#3c2b2e";
21
32
  /** Syntax colors are centralized too; they are subordinate to semantic UI color. */
22
- export const SYNTAX_KEYWORD = "#c8a0e8";
23
- export const SYNTAX_STRING = "#8fc49f";
24
- export const SYNTAX_NUMBER = "#dfbd78";
25
- export const SYNTAX_FUNCTION = "#8eb7ef";
33
+ export const SYNTAX_KEYWORD = "#cda6f2";
34
+ export const SYNTAX_STRING = "#98cfa8";
35
+ export const SYNTAX_NUMBER = "#e6c37e";
36
+ export const SYNTAX_FUNCTION = "#94bdf7";
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The dashboard's side of `/api/events` (the same SSE stream the web app
3
+ * reads): parse frames out of the byte stream, drop the `: ping` heartbeats,
4
+ * and map each pointer event to the query families it can actually stale —
5
+ * mirroring apps/web/src/lib/workbench-events.ts, reduced to the TUI's two
6
+ * cache families. Pure functions; dashboard.tsx owns the socket and the
7
+ * QueryClient.
8
+ */
9
+ /**
10
+ * An incremental SSE frame splitter: feed decoded text chunks, get back the
11
+ * `data:` payloads of every frame completed so far. Comment-only frames (the
12
+ * heartbeat) produce nothing.
13
+ */
14
+ export const createSseParser = () => {
15
+ let buffer = "";
16
+ return {
17
+ push: (chunk) => {
18
+ buffer += chunk;
19
+ const payloads = [];
20
+ for (;;) {
21
+ const boundary = buffer.indexOf("\n\n");
22
+ if (boundary === -1)
23
+ break;
24
+ const frame = buffer.slice(0, boundary);
25
+ buffer = buffer.slice(boundary + 2);
26
+ const data = frame
27
+ .split("\n")
28
+ .filter((line) => line.startsWith("data:"))
29
+ .map((line) => line.slice("data:".length).replace(/^ /, ""))
30
+ .join("\n");
31
+ if (data !== "")
32
+ payloads.push(data);
33
+ }
34
+ return payloads;
35
+ },
36
+ };
37
+ };
38
+ /**
39
+ * Which families one event payload stales. `session-progress` is deliberately
40
+ * nothing — it fires per record line while an agent works, and the dashboard
41
+ * renders no live lines; treating it as a pointer refetched the entire
42
+ * workbench on a 250ms loop for as long as a session was busy. Unknown types
43
+ * are also nothing, like the web: every state change the TUI renders emits
44
+ * one of the types below, and `r` remains the manual override.
45
+ */
46
+ export const eventFamilies = (payload) => {
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(payload);
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ if (typeof parsed !== "object" || parsed === null || !("type" in parsed))
55
+ return [];
56
+ switch (parsed.type) {
57
+ case "project":
58
+ case "session-process":
59
+ return ["workbench"];
60
+ // Session facts feed both surfaces: rows and annotations on the
61
+ // dashboard; status and the follow-up banner inside a review.
62
+ case "session":
63
+ case "agent-conversation":
64
+ // A comment moves the review AND the dashboard's open-comment counts.
65
+ case "session-change":
66
+ case "review-comment":
67
+ return ["workbench", "review"];
68
+ default:
69
+ return [];
70
+ }
71
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
5
5
  "keywords": [
6
6
  "claude-code",