@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.34

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.
Files changed (94) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +130 -4
  4. package/src/App.export.test.tsx +22 -4
  5. package/src/App.plan-review.test.tsx +127 -27
  6. package/src/App.readonly.test.tsx +31 -8
  7. package/src/App.rendering.test.tsx +12 -3
  8. package/src/App.review-panel.test.tsx +11 -3
  9. package/src/App.submit-confirm.test.tsx +12 -7
  10. package/src/App.test.tsx +46 -9
  11. package/src/App.toast.test.tsx +10 -3
  12. package/src/App.tsx +400 -100
  13. package/src/App.walk.test.tsx +30 -10
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +28 -9
  17. package/src/components/AnnotationCard.tsx +53 -38
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +22 -5
  24. package/src/components/ConfirmDialog.tsx +9 -1
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +18 -2
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +184 -98
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.tsx +19 -3
  32. package/src/components/MarkerPopover.stories.tsx +58 -0
  33. package/src/components/MarkerPopover.tsx +102 -0
  34. package/src/components/MenuBar.stories.tsx +5 -1
  35. package/src/components/MenuBar.tsx +18 -2
  36. package/src/components/PlanSheet.stories.tsx +34 -2
  37. package/src/components/PlanSheet.tsx +141 -32
  38. package/src/components/PromptDialog.tsx +18 -2
  39. package/src/components/ReviewPanel.stories.tsx +13 -3
  40. package/src/components/ReviewPanel.tsx +8 -4
  41. package/src/components/ReviewRail.stories.tsx +145 -72
  42. package/src/components/ReviewRail.tsx +177 -38
  43. package/src/components/SettingsDialog.stories.tsx +51 -3
  44. package/src/components/SettingsDialog.tsx +8 -3
  45. package/src/components/SettingsRows.tsx +19 -3
  46. package/src/components/Toast.tsx +17 -2
  47. package/src/components/WalkWizard.stories.tsx +13 -2
  48. package/src/components/WalkWizard.tsx +9 -3
  49. package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
  50. package/src/components/diff-sheet-layout.test.ts +127 -0
  51. package/src/components/diff-sheet-layout.ts +167 -0
  52. package/src/components/primitives/Button.tsx +5 -1
  53. package/src/components/primitives/Dialog.tsx +9 -1
  54. package/src/components/primitives/Tabs.tsx +24 -4
  55. package/src/components/stories-app.tsx +34 -6
  56. package/src/components/story-fixtures.ts +11 -3
  57. package/src/components/story.ts +3 -1
  58. package/src/components/syntax-highlight.ts +47 -12
  59. package/src/components/theme-context.stories.tsx +6 -1
  60. package/src/components/theme-context.tsx +7 -1
  61. package/src/config.test.ts +178 -2
  62. package/src/config.ts +121 -23
  63. package/src/diff-hunk-curate.test.ts +315 -0
  64. package/src/diff-hunk-curate.ts +233 -0
  65. package/src/diff-intraline.test.ts +164 -0
  66. package/src/diff-intraline.ts +132 -0
  67. package/src/diff-syntax.test.ts +96 -0
  68. package/src/diff-syntax.ts +154 -0
  69. package/src/editor.test.ts +13 -4
  70. package/src/editor.ts +31 -5
  71. package/src/herdr.ts +8 -2
  72. package/src/index.ts +9 -1
  73. package/src/integrations.ts +3 -1
  74. package/src/intent-dispatch.test.ts +193 -9
  75. package/src/intent-dispatch.ts +151 -18
  76. package/src/key-bindings.test.ts +19 -9
  77. package/src/key-bindings.ts +36 -14
  78. package/src/keymap.test.ts +171 -26
  79. package/src/keymap.ts +83 -16
  80. package/src/review-panel.test.ts +3 -1
  81. package/src/review-panel.ts +9 -2
  82. package/src/serve.test.ts +11 -3
  83. package/src/session-controller.curate.test.ts +253 -0
  84. package/src/session-controller.share.test.ts +65 -17
  85. package/src/session-controller.ts +301 -26
  86. package/src/share.ts +41 -8
  87. package/src/test-support.ts +16 -6
  88. package/src/theme-presets.ts +168 -0
  89. package/src/theme.ts +1 -3
  90. package/src/view-diff.ts +15 -4
  91. package/src/view-plan.test.ts +40 -6
  92. package/src/view-plan.ts +71 -28
  93. package/src/walk.test.ts +7 -2
  94. package/src/walk.ts +7 -2
@@ -10,7 +10,13 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import { DARK } from "./theme";
13
- import { isolateUserConfig, press, waitForState, waitForText, waitForTextGone } from "./test-support";
13
+ import {
14
+ isolateUserConfig,
15
+ press,
16
+ waitForState,
17
+ waitForText,
18
+ waitForTextGone,
19
+ } from "./test-support";
14
20
 
15
21
  const PATCH = `diff --git a/src/a.ts b/src/a.ts
16
22
  index 111..222 100644
@@ -59,7 +65,10 @@ afterEach(() => {
59
65
  });
60
66
 
61
67
  async function renderApp() {
62
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 34 });
68
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
69
+ width: 120,
70
+ height: 34,
71
+ });
63
72
  // Wait for session content, not just the chrome: pressing w before the diff
64
73
  // loads drops the walk intent (no session, no diff view) and hangs the test.
65
74
  await waitForText(setup, "src/a.ts");
@@ -75,7 +84,9 @@ function foregroundsOf(setup: Setup, needle: string): string[] {
75
84
  for (const span of line.spans) {
76
85
  if (!span.text.includes(needle)) continue;
77
86
  const [red, green, blue] = span.fg.toInts();
78
- foregrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
87
+ foregrounds.push(
88
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
89
+ );
79
90
  }
80
91
  }
81
92
  return foregrounds;
@@ -100,7 +111,9 @@ describe("the guided walk", () => {
100
111
  // Assert
101
112
  await waitForText(setup, "file 2 of 3 · 1 viewed");
102
113
  // the viewed mark rides the session record, not the client
103
- await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
114
+ await waitForState(setup, () =>
115
+ (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"),
116
+ );
104
117
 
105
118
  // Act
106
119
  // [ steps back; the revisited card shows its viewed marker
@@ -128,7 +141,11 @@ describe("the guided walk", () => {
128
141
  // Assert
129
142
  await waitForText(setup, "walk complete");
130
143
  expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
131
- expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
144
+ expect(server.core.sessionGet(session.id).viewedPaths).toEqual([
145
+ "src/a.ts",
146
+ "src/b.ts",
147
+ "src/c.ts",
148
+ ]);
132
149
 
133
150
  // Act
134
151
  // the end card's return leaves the walk and opens the rail confirm
@@ -176,7 +193,10 @@ describe("the guided walk", () => {
176
193
  await press(first, "]");
177
194
  await press(first, "]");
178
195
  await waitForText(first, "file 3 of 3 · 2 viewed");
179
- await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
196
+ await waitForState(
197
+ first,
198
+ () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2,
199
+ );
180
200
  first.renderer.destroy();
181
201
 
182
202
  // Act
@@ -237,17 +257,17 @@ describe("the guided walk", () => {
237
257
  const setup = await renderApp();
238
258
 
239
259
  // Assert
240
- // before the walk the sheet's added line wears the insertion color
260
+ // before the walk the changed word on the added line wears the insertion color
241
261
  await waitForText(setup, "+new line");
242
- expect(foregroundsOf(setup, "+new line")).toContain(DARK.insertedForeground);
262
+ expect(foregroundsOf(setup, "new")).toContain(DARK.insertedForeground);
243
263
 
244
264
  // Act
245
265
  await press(setup, "w");
246
266
 
247
267
  // Assert
248
268
  await waitForText(setup, "file 1 of 3 · 0 viewed");
249
- // dimmed: the sheet line drops to the dim token...
250
- expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
269
+ // dimmed: the sheet's changed word drops to the dim token...
270
+ expect(foregroundsOf(setup, "new")).toEqual([DARK.textDim]);
251
271
  // ...while the wizard preview keeps the insertion color
252
272
  expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insertedForeground);
253
273
  });
@@ -13,7 +13,9 @@ export const ANONYMOUS_LABEL = "-- anonymous --";
13
13
 
14
14
  /** A short, stable handle from a raw author id when no name is known. */
15
15
  export function shortHandle(authorId: string): string {
16
- const base = authorId.startsWith(FINGERPRINT_PREFIX) ? authorId.slice(FINGERPRINT_PREFIX.length) : authorId;
16
+ const base = authorId.startsWith(FINGERPRINT_PREFIX)
17
+ ? authorId.slice(FINGERPRINT_PREFIX.length)
18
+ : authorId;
17
19
  return base.slice(0, 8) || authorId;
18
20
  }
19
21
 
@@ -22,7 +24,11 @@ export function shortHandle(authorId: string): string {
22
24
  * own name, then anonymous for a known-but-unnamed participant, then a short
23
25
  * handle for an id we hold no identity for.
24
26
  */
25
- export function resolveDisplayName(authorId: string, participants: Identity[] | undefined, overrides: Record<string, string>): string {
27
+ export function resolveDisplayName(
28
+ authorId: string,
29
+ participants: Identity[] | undefined,
30
+ overrides: Record<string, string>,
31
+ ): string {
26
32
  const override = overrides[authorId];
27
33
  if (override) return override;
28
34
  const identity = participants?.find((participant) => participant.id === authorId);
package/src/clipboard.ts CHANGED
@@ -16,7 +16,11 @@ export function clipboardCommands(platform: NodeJS.Platform = process.platform):
16
16
  export async function copyToClipboard(text: string): Promise<boolean> {
17
17
  for (const command of clipboardCommands()) {
18
18
  try {
19
- const proc = Bun.spawn(command, { stdin: Buffer.from(text), stdout: "ignore", stderr: "ignore" });
19
+ const proc = Bun.spawn(command, {
20
+ stdin: Buffer.from(text),
21
+ stdout: "ignore",
22
+ stderr: "ignore",
23
+ });
20
24
  if ((await proc.exited) === 0) return true;
21
25
  } catch {
22
26
  // tool not installed - try the next one
@@ -9,34 +9,51 @@ const draftCallbacks = { onInput: () => {}, onSave: () => {}, onCancel: () => {}
9
9
 
10
10
  export const CommentDraft: Story = {
11
11
  render: () => (
12
- <AnnotationCard kind="comment" quote="persists sessions" draft={{ text: "Which daemon?", ...draftCallbacks }} />
12
+ <AnnotationCard
13
+ kind="comment"
14
+ quote="persists sessions"
15
+ draft={{ text: "Which daemon?", ...draftCallbacks }}
16
+ />
13
17
  ),
14
18
  expectedColors: [DARK.accent],
15
19
  };
16
20
 
17
- export const SuggestionDraft: Story = {
21
+ export const SavedSelected: Story = {
18
22
  render: () => (
19
- <AnnotationCard kind="suggestion" quote="move the store" draft={{ text: "", ...draftCallbacks }} />
23
+ <AnnotationCard
24
+ kind="comment"
25
+ quote="persists sessions"
26
+ saved={{
27
+ body: "Which daemon owns this?",
28
+ isSelected: true,
29
+ isOrphan: false,
30
+ isBlocking: false,
31
+ author: "me",
32
+ editing: null,
33
+ onPress: () => {},
34
+ }}
35
+ />
20
36
  ),
21
- expectedColors: [DARK.green],
37
+ expectedColors: [DARK.accent],
22
38
  };
23
39
 
24
- export const SavedSelected: Story = {
40
+ export const SavedCollaborator: Story = {
25
41
  render: () => (
26
42
  <AnnotationCard
27
43
  kind="comment"
28
44
  quote="persists sessions"
29
45
  saved={{
30
46
  body: "Which daemon owns this?",
31
- isSelected: true,
47
+ isSelected: false,
32
48
  isOrphan: false,
33
49
  isBlocking: false,
50
+ author: "Priya",
34
51
  editing: null,
35
52
  onPress: () => {},
36
53
  }}
37
54
  />
38
55
  ),
39
- expectedColors: [DARK.elevated],
56
+ expectedColors: [DARK.border],
40
57
  };
41
58
 
42
59
  export const SavedOrphanBlocking: Story = {
@@ -49,24 +66,26 @@ export const SavedOrphanBlocking: Story = {
49
66
  isSelected: false,
50
67
  isOrphan: true,
51
68
  isBlocking: true,
69
+ author: "me",
52
70
  editing: null,
53
71
  onPress: () => {},
54
72
  }}
55
73
  />
56
74
  ),
57
- expectedColors: [DARK.red],
75
+ expectedColors: [DARK.border],
58
76
  };
59
77
 
60
78
  export const SavedEditing: Story = {
61
79
  render: () => (
62
80
  <AnnotationCard
63
- kind="suggestion"
81
+ kind="comment"
64
82
  quote="move the store"
65
83
  saved={{
66
84
  body: "old body",
67
85
  isSelected: true,
68
86
  isOrphan: false,
69
87
  isBlocking: false,
88
+ author: "me",
70
89
  editing: { text: "new body", ...draftCallbacks },
71
90
  onPress: () => {},
72
91
  }}
@@ -32,10 +32,8 @@ export interface AnnotationSaved {
32
32
  isSelected: boolean;
33
33
  isOrphan: boolean;
34
34
  isBlocking: boolean;
35
- /** A collaborator's resolved display name; own notes carry none. Set = name-in-border, blue. */
36
- authorLabel?: string;
37
- /** The viewer's own note when collaborators are also present (e.g. "me"). Set = tag-in-border, accent. */
38
- selfLabel?: string;
35
+ /** Border-title author: a collaborator's display name, or "me" for the reviewer's own note. */
36
+ author: string;
39
37
  /** Non-null while the card body is being rewritten in place. */
40
38
  editing: AnnotationDraft | null;
41
39
  onPress: () => void;
@@ -76,7 +74,11 @@ export const COMPOSE_MAX_ROWS = 4;
76
74
  * newlines only. Beyond the cap the textarea scrolls internally and keeps the
77
75
  * caret line in view.
78
76
  */
79
- export function composeRowCount(text: string, contentWidth: number, cap: number = COMPOSE_MAX_ROWS): number {
77
+ export function composeRowCount(
78
+ text: string,
79
+ contentWidth: number,
80
+ cap: number = COMPOSE_MAX_ROWS,
81
+ ): number {
80
82
  const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
81
83
  let visualRowCount = 0;
82
84
  for (const line of text.split("\n")) {
@@ -107,7 +109,7 @@ function DraftEditor({
107
109
  editor.cursorOffset = initialTextLength.current;
108
110
  // the box is laid out now, so its width is known: size to the wrapped body
109
111
  onRowsChange(composeRowCount(editor.plainText, editor.width));
110
- }, []);
112
+ }, [onRowsChange]);
111
113
  return (
112
114
  <>
113
115
  <textarea
@@ -135,16 +137,23 @@ function DraftEditor({
135
137
  {" Save "}
136
138
  </Button>
137
139
  <Button onPress={draft.onCancel} theme={theme}>
138
- {" Cancel esc "}
140
+ {" Cancel "}
139
141
  </Button>
140
142
  </Toolbar>
141
143
  </>
142
144
  );
143
145
  }
144
146
 
145
- export function AnnotationCard({ id, kind, quote, draft, saved, theme }: AnnotationCardProps): React.ReactNode {
147
+ export function AnnotationCard({
148
+ id,
149
+ kind,
150
+ quote,
151
+ draft,
152
+ saved,
153
+ theme,
154
+ }: AnnotationCardProps): React.ReactNode {
146
155
  const tokens = useComponentTheme(theme);
147
- const kindColor = kind === "suggestion" ? tokens.green : tokens.accent;
156
+ const kindColor = tokens.accent;
148
157
  const activeDraft = draft ?? saved?.editing ?? null;
149
158
  // The composer's visible height, grown from the wrapped text. It is shared
150
159
  // with the card frame (contentRows) so the border never clips the textarea
@@ -156,57 +165,63 @@ export function AnnotationCard({ id, kind, quote, draft, saved, theme }: Annotat
156
165
  composeRowCount(activeDraft?.text ?? "", 0),
157
166
  );
158
167
  if (draft) {
159
- const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
168
+ const verb = "comment on";
160
169
  return (
161
170
  <Card
162
171
  title={` ${verb} "${truncateToSingleLine(quote, 40)}" `}
163
172
  contentRows={editorRowCount + 1}
164
173
  borderColor={kindColor}
165
174
  backgroundColor="transparent"
166
- marginLeft={2}
167
- marginRight={2}
168
175
  theme={theme}
169
176
  >
170
- <DraftEditor draft={draft} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
177
+ <DraftEditor
178
+ draft={draft}
179
+ rows={editorRowCount}
180
+ onRowsChange={setEditorRowCount}
181
+ theme={theme}
182
+ />
171
183
  </Card>
172
184
  );
173
185
  }
174
186
  const card = saved!;
175
- const collaboratorName = card.authorLabel !== undefined && card.authorLabel !== "" ? card.authorLabel : undefined;
176
- const ownTag = card.selfLabel !== undefined && card.selfLabel !== "" ? card.selfLabel : undefined;
177
- const borderLabel = collaboratorName ?? ownTag;
178
- const borderColor = ownTag ? tokens.accent : tokens.blue;
179
- const indent = borderLabel ? "" : " ";
180
- const marker = card.isSelected ? "▸ " : indent;
181
- // Fixed content rows (a bordered box must declare a height or it collapses): header + quote + body, or the editor.
182
- const contentRows = card.editing ? editorRowCount + 3 : 3;
183
- // The border + its side padding eat this much content width, so text truncates shorter.
184
- const borderInset = borderLabel ? 4 : 0;
187
+ // border = "what + who" (+ any exceptional flag); body = the content only
188
+ const flags = [card.isBlocking ? "BLOCKING" : null, card.isOrphan ? "ORPHANED" : null].filter(
189
+ (flag): flag is string => flag !== null,
190
+ );
191
+ const title = ` ${[kind.toUpperCase(), card.author, ...flags].join(" · ")} `;
192
+ // every saved card is bordered, so the border + side padding always eat this width
193
+ const borderInset = 4;
194
+ // a bordered box must declare a height or it collapses: quote + body, or the editor
195
+ const contentRows = card.editing ? editorRowCount + 2 : 2;
185
196
  return (
186
197
  <box
187
198
  id={id}
188
- title={borderLabel ? ` ${borderLabel} ` : undefined}
199
+ title={title}
189
200
  style={{
190
201
  flexDirection: "column",
191
- marginBottom: 0,
192
- backgroundColor: card.isSelected && !card.editing ? tokens.elevated : undefined,
193
- ...(borderLabel
194
- ? { height: cardHeight(contentRows), border: true, borderStyle: FRAME_BORDER_STYLE, borderColor, paddingLeft: 1, paddingRight: 1 }
195
- : {}),
202
+ height: cardHeight(contentRows),
203
+ border: true,
204
+ borderStyle: FRAME_BORDER_STYLE,
205
+ // cards stay transparent in every theme; selection is the bright
206
+ // kind-colored border, while unselected cards recede to a dim border
207
+ borderColor: card.isSelected || card.editing ? kindColor : tokens.border,
208
+ paddingLeft: 1,
209
+ paddingRight: 1,
196
210
  }}
197
211
  onMouseUp={card.onPress}
198
212
  >
199
- <text fg={card.isSelected ? tokens.text : kindColor}>
200
- {marker}
201
- {kind.toUpperCase()}
202
- {card.isBlocking ? <span fg={tokens.red}> · BLOCKING</span> : null}
203
- <span fg={tokens.textDim}>{card.isOrphan ? " · ORPHANED" : " · pending"}</span>
204
- </text>
205
- <text fg={tokens.textDim}>{indent}"{truncateToSingleLine(quote, 26 - borderInset)}"</text>
213
+ <text fg={tokens.textDim}>"{truncateToSingleLine(quote, 26 - borderInset)}"</text>
206
214
  {card.editing ? (
207
- <DraftEditor draft={card.editing} rows={editorRowCount} onRowsChange={setEditorRowCount} theme={theme} />
215
+ <DraftEditor
216
+ draft={card.editing}
217
+ rows={editorRowCount}
218
+ onRowsChange={setEditorRowCount}
219
+ theme={theme}
220
+ />
208
221
  ) : (
209
- <text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}>{indent}{truncateToSingleLine(card.body, 28 - borderInset)}</text>
222
+ <text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}>
223
+ {truncateToSingleLine(card.body, 28 - borderInset)}
224
+ </text>
210
225
  )}
211
226
  </box>
212
227
  );
@@ -23,7 +23,13 @@ export interface BreadcrumbProps {
23
23
  export function Breadcrumb({ items, theme }: BreadcrumbProps): React.ReactNode {
24
24
  const tokens = useComponentTheme(theme);
25
25
  const toneColor = (tone?: BreadcrumbTone): string =>
26
- tone === "accent" ? tokens.accent : tone === "green" ? tokens.green : tone === "text" ? tokens.text : tokens.textDim;
26
+ tone === "accent"
27
+ ? tokens.accent
28
+ : tone === "green"
29
+ ? tokens.green
30
+ : tone === "text"
31
+ ? tokens.text
32
+ : tokens.textDim;
27
33
  return (
28
34
  <box style={{ height: 1, backgroundColor: tokens.panel, paddingLeft: 1, flexDirection: "row" }}>
29
35
  <text fg={tokens.text}>
@@ -16,10 +16,27 @@ export const Typescript: Story = {
16
16
  };
17
17
 
18
18
  export const CursorAnnotatedEdited: Story = {
19
- render: () => <CodeBlock language="ts" content={SNIPPET} isCursor isAnnotated changeTag="edited" marginTop={1} />,
19
+ render: () => (
20
+ <CodeBlock
21
+ language="ts"
22
+ content={SNIPPET}
23
+ isCursor
24
+ isAnnotated
25
+ changeTag="edited"
26
+ marginTop={1}
27
+ />
28
+ ),
20
29
  expectedColors: [DARK.accent],
21
30
  };
22
31
 
23
32
  export const UnknownLanguage: Story = {
24
- render: () => <CodeBlock language="brainfog" content={"plain verbatim lines\n with indentation"} />,
33
+ render: () => (
34
+ <CodeBlock language="brainfog" content={"plain verbatim lines\n with indentation"} />
35
+ ),
36
+ };
37
+
38
+ /** A cut code block: struck-through gray content, no [cut] tag. */
39
+ export const Cut: Story = {
40
+ render: () => <CodeBlock language="ts" content={SNIPPET} cut />,
41
+ expectedColors: [DARK.elevated],
25
42
  };
@@ -6,10 +6,14 @@
6
6
  */
7
7
 
8
8
  import React from "react";
9
+ import { createTextAttributes } from "@opentui/core";
9
10
  import type { Theme } from "../theme";
10
11
  import { useComponentTheme } from "./theme-context";
11
12
  import { filetypeFor, syntaxStyleFor } from "./syntax-highlight";
12
13
 
14
+ /** A cut code block reads as removed: struck through and grayed, never red. */
15
+ const CUT_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
16
+
13
17
  export interface CodeBlockProps {
14
18
  id?: string;
15
19
  /** Markdown fence info string ("tsx", "python", ...). */
@@ -20,7 +24,9 @@ export interface CodeBlockProps {
20
24
  marginTop?: number;
21
25
  isAnnotated?: boolean;
22
26
  /** Working-copy change tag rendered after the language label. */
23
- changeTag?: "cut" | "new" | "edited";
27
+ changeTag?: "new" | "edited";
28
+ /** A cut (removed) block: struck-through gray content, no tag. */
29
+ cut?: boolean;
24
30
  theme?: Theme;
25
31
  }
26
32
 
@@ -32,10 +38,11 @@ export function CodeBlock({
32
38
  marginTop = 0,
33
39
  isAnnotated = false,
34
40
  changeTag,
41
+ cut = false,
35
42
  theme,
36
43
  }: CodeBlockProps): React.ReactNode {
37
44
  const tokens = useComponentTheme(theme);
38
- const tagColor = changeTag === "cut" ? tokens.red : changeTag === "new" ? tokens.green : tokens.accent;
45
+ const tagColor = changeTag === "new" ? tokens.green : tokens.accent;
39
46
  return (
40
47
  <box id={id} style={{ flexDirection: "column", marginTop }}>
41
48
  <text>
@@ -55,13 +62,21 @@ export function CodeBlock({
55
62
  marginLeft: 2,
56
63
  }}
57
64
  >
58
- <code
59
- content={content}
60
- filetype={filetypeFor(language)}
61
- syntaxStyle={syntaxStyleFor(tokens)}
62
- selectable={false}
63
- style={{ wrapMode: "none", fg: tokens.textMuted, bg: tokens.elevated }}
64
- />
65
+ {cut ? (
66
+ <text style={{ wrapMode: "none" }} selectable={false}>
67
+ <span fg={tokens.textDim} attributes={CUT_ATTRIBUTES}>
68
+ {content}
69
+ </span>
70
+ </text>
71
+ ) : (
72
+ <code
73
+ content={content}
74
+ filetype={filetypeFor(language)}
75
+ syntaxStyle={syntaxStyleFor(tokens)}
76
+ selectable={false}
77
+ style={{ wrapMode: "none", fg: tokens.textMuted, bg: tokens.elevated }}
78
+ />
79
+ )}
65
80
  </box>
66
81
  </box>
67
82
  );
@@ -19,7 +19,13 @@ export interface CompletionOverlayProps {
19
19
  theme?: Theme;
20
20
  }
21
21
 
22
- export function CompletionOverlay({ verdict, completion, status, returnsTo, theme }: CompletionOverlayProps): React.ReactNode {
22
+ export function CompletionOverlay({
23
+ verdict,
24
+ completion,
25
+ status,
26
+ returnsTo,
27
+ theme,
28
+ }: CompletionOverlayProps): React.ReactNode {
23
29
  const tokens = useComponentTheme(theme);
24
30
  const approved = verdict === "approve";
25
31
  return (
@@ -33,9 +39,13 @@ export function CompletionOverlay({ verdict, completion, status, returnsTo, them
33
39
  alignItems: "center",
34
40
  }}
35
41
  >
36
- <text fg={approved ? tokens.green : tokens.accent}>{approved ? "review approved" : "feedback sent"}</text>
42
+ <text fg={approved ? tokens.green : tokens.accent}>
43
+ {approved ? "review approved" : "feedback sent"}
44
+ </text>
37
45
  <text> </text>
38
- <text fg={tokens.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
46
+ <text fg={tokens.text}>
47
+ The agent has your {approved ? "approval" : "feedback"} and is unblocked.
48
+ </text>
39
49
  {returnsTo ? <text fg={tokens.textDim}>returning to {returnsTo} on close</text> : null}
40
50
  {status ? <text fg={tokens.textDim}>{status}</text> : null}
41
51
  <text> </text>
@@ -5,17 +5,36 @@ import { ConfirmCard } from "./ConfirmCard";
5
5
 
6
6
  export const meta: StoryMeta = { title: "ConfirmCard" };
7
7
 
8
- const callbacks = { onInput: () => {}, onSelectVerdict: () => {}, onSubmit: () => {}, onCancel: () => {} };
8
+ const callbacks = {
9
+ onInput: () => {},
10
+ onSelectVerdict: () => {},
11
+ onSubmit: () => {},
12
+ onCancel: () => {},
13
+ };
9
14
 
10
15
  export const ApproveDefault: Story = {
11
- render: () => <ConfirmCard verdict="approve" summary="" annotationCount={0} blockingCount={0} {...callbacks} />,
16
+ render: () => (
17
+ <ConfirmCard
18
+ verdict="approve"
19
+ summary=""
20
+ annotationCount={0}
21
+ blockingCount={0}
22
+ {...callbacks}
23
+ />
24
+ ),
12
25
  expectedColors: [DARK.green, DARK.accent],
13
26
  size: { width: 40, height: 14 },
14
27
  };
15
28
 
16
29
  export const RequestChanges: Story = {
17
30
  render: () => (
18
- <ConfirmCard verdict="request_changes" summary="Tighten the steps." annotationCount={3} blockingCount={1} {...callbacks} />
31
+ <ConfirmCard
32
+ verdict="request_changes"
33
+ summary="Tighten the steps."
34
+ annotationCount={3}
35
+ blockingCount={1}
36
+ {...callbacks}
37
+ />
19
38
  ),
20
39
  expectedColors: [DARK.red],
21
40
  size: { width: 40, height: 14 },
@@ -44,7 +44,11 @@ export interface ConfirmCardProps {
44
44
  const CONFIRM_CONTENT_ROWS = 7;
45
45
 
46
46
  export function verdictColor(verdict: VerdictKind, tokens: Theme): string {
47
- return verdict === "approve" ? tokens.green : verdict === "request_changes" ? tokens.red : tokens.blue;
47
+ return verdict === "approve"
48
+ ? tokens.green
49
+ : verdict === "request_changes"
50
+ ? tokens.red
51
+ : tokens.blue;
48
52
  }
49
53
 
50
54
  /**
@@ -66,9 +70,15 @@ function VerdictSelector({
66
70
  return (
67
71
  <box style={{ flexDirection: "row", height: 1 }}>
68
72
  {VERDICTS.map((candidate) => (
69
- <box key={candidate} style={{ paddingRight: 1 }} onMouseUp={() => onSelectVerdict(candidate)}>
73
+ <box
74
+ key={candidate}
75
+ style={{ paddingRight: 1 }}
76
+ onMouseUp={() => onSelectVerdict(candidate)}
77
+ >
70
78
  <text fg={candidate === verdict ? verdictColor(candidate, tokens) : tokens.textDim}>
71
- {candidate === verdict ? `[${VERDICT_LABEL[candidate]}]` : ` ${VERDICT_LABEL[candidate]} `}
79
+ {candidate === verdict
80
+ ? `[${VERDICT_LABEL[candidate]}]`
81
+ : ` ${VERDICT_LABEL[candidate]} `}
72
82
  </text>
73
83
  </box>
74
84
  ))}
@@ -98,12 +108,19 @@ export function ConfirmCard({
98
108
  marginRight={1}
99
109
  theme={theme}
100
110
  >
101
- <text fg={tokens.textDim}>{`${annotationCount} annotations · ${blockingCount} blocking`}</text>
111
+ <text
112
+ fg={tokens.textDim}
113
+ >{`${annotationCount} annotations · ${blockingCount} blocking`}</text>
102
114
  {viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
103
115
  <box style={{ height: 1 }} />
104
116
  <VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
105
117
  <box style={{ height: 1 }} />
106
- <input focused value={summary} onInput={onInput} placeholder="summary for the agent (optional)" />
118
+ <input
119
+ focused
120
+ value={summary}
121
+ onInput={onInput}
122
+ placeholder="summary for the agent (optional)"
123
+ />
107
124
  <box style={{ height: 1 }} />
108
125
  <Toolbar>
109
126
  <Button variant="solid" marginRight={2} onPress={onSubmit} theme={theme}>
@@ -39,7 +39,15 @@ export function ConfirmDialog({
39
39
  if (!isOpen) return null;
40
40
  return (
41
41
  <Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
42
- <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
42
+ <box
43
+ style={{
44
+ flexDirection: "column",
45
+ flexGrow: 1,
46
+ paddingLeft: 1,
47
+ paddingRight: 1,
48
+ paddingTop: 1,
49
+ }}
50
+ >
43
51
  <text fg={tokens.text}>{message}</text>
44
52
  <box style={{ flexGrow: 1 }} />
45
53
  <Toolbar>