@cueloop/client 0.1.0-alpha.76 → 0.1.0-alpha.78

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 (82) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +14 -2
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.identity.test.tsx +78 -0
  6. package/src/App.plan-review.test.tsx +16 -0
  7. package/src/App.share-choice.test.tsx +105 -0
  8. package/src/App.submit-confirm.test.tsx +34 -5
  9. package/src/App.test.tsx +2 -2
  10. package/src/App.toast.test.tsx +3 -1
  11. package/src/App.tsx +180 -5
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  14. package/src/ansi-screen.test.ts +28 -0
  15. package/src/ansi-screen.ts +72 -0
  16. package/src/app-screens.tsx +20 -3
  17. package/src/app-view-model.ts +4 -0
  18. package/src/components/AnnotatableFileView.tsx +10 -2
  19. package/src/components/AnnotationCards.tsx +40 -6
  20. package/src/components/AnsiScreen.stories.tsx +12 -0
  21. package/src/components/AnsiScreen.tsx +28 -0
  22. package/src/components/CompletionOverlay.stories.tsx +15 -1
  23. package/src/components/CompletionOverlay.tsx +22 -5
  24. package/src/components/ConfirmCard.stories.tsx +2 -0
  25. package/src/components/ConfirmCard.tsx +45 -18
  26. package/src/components/ConfirmDialog.tsx +11 -12
  27. package/src/components/DiffContentView.tsx +9 -5
  28. package/src/components/FileContentsView.tsx +1 -1
  29. package/src/components/GridTabContent.tsx +2 -0
  30. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  31. package/src/components/ManageAccessDialog.test.tsx +94 -0
  32. package/src/components/ManageAccessDialog.tsx +122 -0
  33. package/src/components/PromptDialog.test.tsx +2 -2
  34. package/src/components/PromptDialog.tsx +9 -10
  35. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  36. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  37. package/src/components/ShareChoiceDialog.tsx +80 -0
  38. package/src/components/SlashComposer.stories.tsx +37 -0
  39. package/src/components/SlashComposer.tsx +87 -0
  40. package/src/components/ThreadTree.tsx +11 -6
  41. package/src/components/ThreadView.selection.test.tsx +88 -1
  42. package/src/components/ThreadView.slash.test.tsx +0 -1
  43. package/src/components/ThreadView.tsx +71 -8
  44. package/src/components/Toast.tsx +0 -5
  45. package/src/components/WalkWizard.stories.tsx +2 -2
  46. package/src/components/WalkWizard.tsx +10 -12
  47. package/src/components/__snapshots__/stories.test.tsx.snap +277 -61
  48. package/src/components/primitives/Card.tsx +3 -0
  49. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  50. package/src/components/primitives/DialogActions.tsx +39 -0
  51. package/src/components/session-tree.ts +7 -6
  52. package/src/components/stories-app.tsx +13 -4
  53. package/src/components/stories.test.tsx +8 -1
  54. package/src/components/story-tree.test.ts +32 -24
  55. package/src/components/story.ts +32 -15
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +44 -1
  58. package/src/diff-intraline.test.ts +75 -2
  59. package/src/diff-intraline.ts +95 -26
  60. package/src/github-identity.test.ts +58 -0
  61. package/src/github-identity.ts +41 -0
  62. package/src/intent-dispatch.test.ts +17 -2
  63. package/src/intent-dispatch.ts +7 -1
  64. package/src/keymap.test.ts +2 -2
  65. package/src/keymap.ts +1 -1
  66. package/src/pasted-image.test.ts +67 -0
  67. package/src/pasted-image.ts +60 -0
  68. package/src/run.ts +31 -19
  69. package/src/serve.ts +1 -1
  70. package/src/share.ts +11 -3
  71. package/src/slash-palette.test.ts +17 -0
  72. package/src/slash-palette.ts +11 -0
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.derivation.test.ts +62 -0
  75. package/src/thread-controller.live-diff.test.ts +1 -0
  76. package/src/thread-controller.optimistic.test.ts +69 -0
  77. package/src/thread-controller.share.test.ts +36 -2
  78. package/src/thread-controller.switch-cache.test.ts +146 -0
  79. package/src/thread-controller.tree.test.ts +1 -0
  80. package/src/thread-controller.ts +171 -24
  81. package/src/use-annotation-surface.tsx +53 -29
  82. package/src/use-settings-dialog.tsx +24 -0
@@ -7,11 +7,19 @@
7
7
  */
8
8
 
9
9
  import React, { useContext, useLayoutEffect, useRef, useState } from "react";
10
- import type { BoxRenderable, KeyBinding, TextareaRenderable } from "@opentui/core";
10
+ import { usePaste } from "@opentui/react";
11
+ import type {
12
+ BoxRenderable,
13
+ KeyBinding,
14
+ MouseEvent as TerminalMouseEvent,
15
+ TextareaRenderable,
16
+ } from "@opentui/core";
11
17
  import type { Annotation } from "@cueloop/schema";
12
18
  import type { Theme } from "../theme";
19
+ import { useTooltip } from "./Tooltip";
13
20
  import { lighten } from "../annotation-palette";
14
21
  import { useFrameMeasure } from "../use-frame-measure";
22
+ import { imagePlaceholder, looksLikeBinaryPaste } from "../pasted-image";
15
23
  import { skillReferenceRanges, type SlashItem } from "../slash-palette";
16
24
  import { PaletteNamesContext } from "../skills";
17
25
  import { referenceStyleFor } from "./syntax-highlight";
@@ -29,7 +37,7 @@ const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
29
37
  { name: "return", shift: true, action: "newline" },
30
38
  ];
31
39
 
32
- function composeRowCount(text: string, contentWidth: number): number {
40
+ export function composeRowCount(text: string, contentWidth: number): number {
33
41
  const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
34
42
  let visualRowCount = 0;
35
43
 
@@ -48,6 +56,7 @@ export function Composer({
48
56
  onSave,
49
57
  onReady,
50
58
  onInput,
59
+ placeholder,
51
60
  }: {
52
61
  seed: string;
53
62
  glyph: string;
@@ -55,12 +64,23 @@ export function Composer({
55
64
  onSave: (body: string) => void;
56
65
  onReady: () => void;
57
66
  onInput: (text: string, caret: number) => void;
67
+ placeholder?: string;
58
68
  }): React.ReactNode {
59
69
  const editorRef = useRef<TextareaRenderable | null>(null);
70
+ const pastedImageCount = useRef(0);
60
71
  const [rows, setRows] = useState(1);
61
72
  // action and skill names whose "/name" references paint in the accent color
62
73
  const referenceNames = useContext(PaletteNamesContext);
63
74
 
75
+ usePaste((event) => {
76
+ const editor = editorRef.current;
77
+
78
+ if (!editor || !editor.focused || !looksLikeBinaryPaste(event.bytes)) return;
79
+ event.preventDefault();
80
+ pastedImageCount.current += 1;
81
+ editor.editBuffer.insertText(imagePlaceholder(pastedImageCount.current));
82
+ });
83
+
64
84
  const paintReferences = (editor: TextareaRenderable): void => {
65
85
  const { styleId } = referenceStyleFor(tokens);
66
86
 
@@ -92,6 +112,8 @@ export function Composer({
92
112
  ref={editorRef}
93
113
  focused
94
114
  initialValue={seed}
115
+ placeholder={placeholder}
116
+ placeholderColor={tokens.textDim}
95
117
  cursorStyle={{ style: "block", blinking: true }}
96
118
  keyBindings={COMPOSE_KEY_BINDINGS}
97
119
  onSubmit={() => onSave(editorRef.current?.plainText ?? "")}
@@ -241,7 +263,7 @@ export function DiscussionCard({
241
263
  });
242
264
 
243
265
  return (
244
- <box style={{ flexDirection: "row", marginTop: 1, marginLeft: 2 }}>
266
+ <box style={{ flexDirection: "row", marginTop: 0, marginLeft: 2 }}>
245
267
  <text selectable={false} style={{ flexShrink: 0, width: 1 }}>
246
268
  {edgeRows.map((row, index) => (
247
269
  <span key={index} fg={row.color}>
@@ -281,22 +303,34 @@ export function DiscussionCard({
281
303
  export function CommentRow({
282
304
  annotation,
283
305
  tokens,
306
+ authorLabel,
284
307
  }: {
285
308
  annotation: Annotation;
286
309
  tokens: Theme;
310
+ /** The author's resolved display name, shown as a tooltip when the dot is hovered. */
311
+ authorLabel?: string;
287
312
  }): React.ReactNode {
288
313
  // own comments (no author) wear the filled dot, collaborators the outline
289
314
  const own = annotation.author === undefined;
290
315
  const glyph = own ? "●" : "○";
291
316
  const glyphColor = own ? tokens.text : tokens.textMuted;
317
+ const { showTooltip, hideTooltip } = useTooltip();
318
+ const dotHover = authorLabel
319
+ ? {
320
+ onMouseOver: (event: TerminalMouseEvent) => showTooltip(authorLabel, event.x, event.y),
321
+ onMouseOut: hideTooltip,
322
+ }
323
+ : {};
292
324
 
293
325
  return (
294
326
  <box style={{ flexDirection: "column" }}>
295
327
  {annotation.body.split("\n").map((line, lineIndex) => (
296
328
  <box key={lineIndex} style={{ flexDirection: "row" }}>
297
- <text selectable={false} fg={glyphColor} style={{ flexShrink: 0 }}>
298
- {lineIndex === 0 ? `${glyph} ` : " "}
299
- </text>
329
+ <box style={{ flexShrink: 0 }} {...(lineIndex === 0 ? dotHover : {})}>
330
+ <text selectable={false} fg={glyphColor}>
331
+ {lineIndex === 0 ? `${glyph} ` : " "}
332
+ </text>
333
+ </box>
300
334
  <text fg={tokens.text} style={{ wrapMode: "word", flexGrow: 1, flexShrink: 1 }}>
301
335
  {line}
302
336
  </text>
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import type { Story, StoryMeta } from "./story";
3
+ import { AnsiScreen } from "./AnsiScreen";
4
+
5
+ export const meta: StoryMeta = { title: "Primitives/AnsiScreen" };
6
+
7
+ const POSITIONED = "\x1b[2;4HHello from\x1b[4;6Hraw, positioned ANSI";
8
+
9
+ export const Positioned: Story = {
10
+ render: () => <AnsiScreen ansi={POSITIONED} cols={40} rows={6} />,
11
+ size: { width: 44, height: 8 },
12
+ };
@@ -0,0 +1,28 @@
1
+ /** Paints a raw ANSI screen (cursor-positioned text) as a terminal would, for previewing SSH surfaces. */
2
+
3
+ import React from "react";
4
+ import type { Theme } from "../theme";
5
+ import { ansiScreenToLines } from "../ansi-screen";
6
+ import { useComponentTheme } from "./theme-context";
7
+
8
+ export interface AnsiScreenProps {
9
+ ansi: string;
10
+ cols: number;
11
+ rows: number;
12
+ theme?: Theme;
13
+ }
14
+
15
+ export function AnsiScreen({ ansi, cols, rows, theme }: AnsiScreenProps): React.ReactNode {
16
+ const tokens = useComponentTheme(theme);
17
+ const lines = ansiScreenToLines(ansi, cols, rows);
18
+
19
+ return (
20
+ <box style={{ flexDirection: "column" }}>
21
+ {lines.map((line, index) => (
22
+ <text key={index} fg={tokens.text} selectable={false} style={{ wrapMode: "none" }}>
23
+ {line === "" ? " " : line}
24
+ </text>
25
+ ))}
26
+ </box>
27
+ );
28
+ }
@@ -5,8 +5,19 @@ import { CompletionOverlay } from "./CompletionOverlay";
5
5
 
6
6
  export const meta: StoryMeta = { title: "Overlays/CompletionOverlay" };
7
7
 
8
+ const noop = () => {};
9
+
8
10
  export const ApprovedPrompt: Story = {
9
- render: () => <CompletionOverlay verdict="approve" completion={{ phase: "prompt" }} status="" />,
11
+ render: () => (
12
+ <CompletionOverlay
13
+ verdict="approve"
14
+ completion={{ phase: "prompt" }}
15
+ status=""
16
+ onClose={noop}
17
+ onBackToPlan={noop}
18
+ onAlways={noop}
19
+ />
20
+ ),
10
21
  expectedColors: [DARK.green],
11
22
  };
12
23
 
@@ -17,6 +28,9 @@ export const FeedbackCounting: Story = {
17
28
  completion={{ phase: "counting", remaining: 3 }}
18
29
  status="exported to vault/cueloop/plan.md"
19
30
  returnsTo="agent/worker-3"
31
+ onClose={noop}
32
+ onBackToPlan={noop}
33
+ onAlways={noop}
20
34
  />
21
35
  ),
22
36
  expectedColors: [DARK.accent],
@@ -8,6 +8,8 @@ import React from "react";
8
8
  import type { VerdictKind } from "@cueloop/schema";
9
9
  import type { Theme } from "../theme";
10
10
  import { useComponentTheme } from "./theme-context";
11
+ import { Toolbar } from "./primitives/Toolbar";
12
+ import { Button } from "./primitives/Button";
11
13
 
12
14
  export interface CompletionOverlayProps {
13
15
  verdict: VerdictKind;
@@ -16,6 +18,9 @@ export interface CompletionOverlayProps {
16
18
  status: string;
17
19
  /** Where focus goes on close (the agent's pane), when known. */
18
20
  returnsTo?: string;
21
+ onClose: () => void;
22
+ onBackToPlan: () => void;
23
+ onAlways: () => void;
19
24
  theme?: Theme;
20
25
  }
21
26
 
@@ -24,6 +29,9 @@ export function CompletionOverlay({
24
29
  completion,
25
30
  status,
26
31
  returnsTo,
32
+ onClose,
33
+ onBackToPlan,
34
+ onAlways,
27
35
  theme,
28
36
  }: CompletionOverlayProps): React.ReactNode {
29
37
  const tokens = useComponentTheme(theme);
@@ -49,12 +57,21 @@ export function CompletionOverlay({
49
57
  </text>
50
58
  {returnsTo ? <text fg={tokens.textDim}>returning to {returnsTo} on close</text> : null}
51
59
  {status ? <text fg={tokens.textDim}>{status}</text> : null}
60
+ {completion.phase === "counting" ? (
61
+ <text fg={tokens.textDim}>closing in {completion.remaining}s</text>
62
+ ) : null}
52
63
  <text> </text>
53
- <text fg={tokens.textDim}>
54
- close [return]
55
- {completion.phase === "counting" ? ` · closing in ${completion.remaining}s` : ""}
56
- {" · return to plan [esc] · always [a]"}
57
- </text>
64
+ <Toolbar>
65
+ <Button variant="solid" marginRight={2} onPress={onClose} theme={theme}>
66
+ {" close "}
67
+ </Button>
68
+ <Button marginRight={2} onPress={onBackToPlan} theme={theme}>
69
+ {" back to plan "}
70
+ </Button>
71
+ <Button onPress={onAlways} theme={theme}>
72
+ {" always "}
73
+ </Button>
74
+ </Toolbar>
58
75
  </box>
59
76
  );
60
77
  }
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { DARK } from "../theme";
3
+ import { DEFAULT_QUICK_ACTIONS } from "../config";
3
4
  import type { Story, StoryMeta } from "./story";
4
5
  import { ConfirmCard } from "./ConfirmCard";
5
6
 
@@ -10,6 +11,7 @@ const callbacks = {
10
11
  onSelectVerdict: () => {},
11
12
  onSubmit: () => {},
12
13
  onCancel: () => {},
14
+ quickActions: DEFAULT_QUICK_ACTIONS,
13
15
  };
14
16
 
15
17
  export const ApproveDefault: Story = {
@@ -5,13 +5,21 @@
5
5
  * derives from the content rows through Card, so layout and render never drift.
6
6
  */
7
7
 
8
- import React from "react";
8
+ import React, { useContext } from "react";
9
+ import { useTerminalDimensions } from "@opentui/react";
9
10
  import type { VerdictKind } from "@cueloop/schema";
10
11
  import type { Theme } from "../theme";
12
+ import type { QuickAction } from "../config";
13
+ import { SlashSkillsContext } from "../skills";
14
+ import { activeSlashToken, mergeSlashItems, slashFilter, slashItemsFrom } from "../slash-palette";
11
15
  import { useComponentTheme } from "./theme-context";
12
16
  import { Card } from "./primitives/Card";
13
- import { Button } from "./primitives/Button";
14
- import { Toolbar } from "./primitives/Toolbar";
17
+ import { composeRowCount } from "./AnnotationCards";
18
+ import { SlashComposer } from "./SlashComposer";
19
+ import { DialogActions } from "./primitives/DialogActions";
20
+
21
+ const SUBMIT_CARD_MAX_WIDTH = 48;
22
+ const PALETTE_WINDOW = 5;
15
23
 
16
24
  export const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
17
25
 
@@ -34,6 +42,7 @@ export interface ConfirmCardProps {
34
42
  onSelectVerdict: (verdict: VerdictKind) => void;
35
43
  onSubmit: () => void;
36
44
  onCancel: () => void;
45
+ quickActions: QuickAction[];
37
46
  theme?: Theme;
38
47
  }
39
48
 
@@ -66,7 +75,7 @@ function VerdictSelector({
66
75
  const tokens = useComponentTheme(theme);
67
76
 
68
77
  return (
69
- <box style={{ flexDirection: "row", height: 1 }}>
78
+ <box style={{ flexDirection: "row", height: 1, width: "100%", justifyContent: "center" }}>
70
79
  {VERDICTS.map((candidate) => (
71
80
  <box
72
81
  key={candidate}
@@ -92,14 +101,33 @@ export function ConfirmCard({
92
101
  onSelectVerdict,
93
102
  onSubmit,
94
103
  onCancel,
104
+ quickActions,
95
105
  theme,
96
106
  }: ConfirmCardProps): React.ReactNode {
97
107
  const tokens = useComponentTheme(theme);
108
+ const skills = useContext(SlashSkillsContext);
109
+ const { width: terminalWidth } = useTerminalDimensions();
110
+ const cardWidth = Math.max(24, Math.min(terminalWidth - 6, SUBMIT_CARD_MAX_WIDTH));
111
+
112
+ const composerRows = composeRowCount(summary, cardWidth - 4);
113
+ const token = activeSlashToken(summary, summary.length);
114
+ const paletteItems =
115
+ token !== null
116
+ ? slashFilter(mergeSlashItems(slashItemsFrom(quickActions), skills), token.slice(1))
117
+ : [];
118
+ const paletteRows =
119
+ paletteItems.length > 0
120
+ ? Math.min(PALETTE_WINDOW, paletteItems.length) +
121
+ (paletteItems.length > PALETTE_WINDOW ? 1 : 0)
122
+ : 0;
123
+ const contentRows =
124
+ CONFIRM_CONTENT_ROWS - 1 + composerRows + paletteRows + (viewedSummary !== undefined ? 2 : 0);
98
125
 
99
126
  return (
100
127
  <Card
101
- contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 2 : 0)}
102
- borderColor={tokens.text}
128
+ contentRows={contentRows}
129
+ width={cardWidth}
130
+ borderColor={tokens.accent}
103
131
  backgroundColor={tokens.elevated}
104
132
  theme={theme}
105
133
  >
@@ -107,21 +135,20 @@ export function ConfirmCard({
107
135
  {viewedSummary !== undefined ? <box style={{ height: 1 }} /> : null}
108
136
  <VerdictSelector verdict={verdict} onSelectVerdict={onSelectVerdict} theme={theme} />
109
137
  <box style={{ height: 1 }} />
110
- <input
111
- focused
112
- value={summary}
113
- onInput={onInput}
138
+ <SlashComposer
139
+ seed={summary}
140
+ glyph=""
141
+ quickActions={quickActions}
142
+ tokens={tokens}
114
143
  placeholder="summary for the agent (optional)"
144
+ onSubmit={(text) => {
145
+ onInput(text);
146
+ onSubmit();
147
+ }}
148
+ onInput={onInput}
115
149
  />
116
150
  <box style={{ height: 1 }} />
117
- <Toolbar>
118
- <Button variant="solid" marginRight={2} onPress={onSubmit} theme={theme}>
119
- {" send message "}
120
- </Button>
121
- <Button onPress={onCancel} theme={theme}>
122
- {" Cancel "}
123
- </Button>
124
- </Toolbar>
151
+ <DialogActions confirmLabel="send" onConfirm={onSubmit} onCancel={onCancel} theme={theme} />
125
152
  </Card>
126
153
  );
127
154
  }
@@ -10,8 +10,7 @@ import { useTerminalDimensions } from "@opentui/react";
10
10
  import type { Theme } from "../theme";
11
11
  import { useComponentTheme } from "./theme-context";
12
12
  import { Dialog } from "./primitives/Dialog";
13
- import { Button } from "./primitives/Button";
14
- import { Toolbar } from "./primitives/Toolbar";
13
+ import { DialogActions } from "./primitives/DialogActions";
15
14
 
16
15
  export interface ConfirmDialogProps {
17
16
  isOpen: boolean;
@@ -28,8 +27,8 @@ export function ConfirmDialog({
28
27
  isOpen,
29
28
  title,
30
29
  message,
31
- confirmLabel = "Delete",
32
- cancelLabel = "Cancel",
30
+ confirmLabel = "delete",
31
+ cancelLabel = "cancel",
33
32
  onConfirm,
34
33
  onCancel,
35
34
  theme,
@@ -45,6 +44,7 @@ export function ConfirmDialog({
45
44
  title={title}
46
45
  width={Math.min(54, terminalWidth - 6)}
47
46
  height={7}
47
+ background={tokens.elevated}
48
48
  onDismiss={onCancel}
49
49
  theme={theme}
50
50
  >
@@ -59,14 +59,13 @@ export function ConfirmDialog({
59
59
  >
60
60
  <text fg={tokens.text}>{message}</text>
61
61
  <box style={{ flexGrow: 1 }} />
62
- <Toolbar>
63
- <Button variant="solid" onPress={onConfirm} theme={theme}>
64
- {` ${confirmLabel} `}
65
- </Button>
66
- <Button onPress={onCancel} theme={theme}>
67
- {` ${cancelLabel} esc `}
68
- </Button>
69
- </Toolbar>
62
+ <DialogActions
63
+ confirmLabel={confirmLabel}
64
+ cancelLabel={cancelLabel}
65
+ onConfirm={onConfirm}
66
+ onCancel={onCancel}
67
+ theme={theme}
68
+ />
70
69
  </box>
71
70
  </Dialog>
72
71
  );
@@ -10,7 +10,7 @@
10
10
 
11
11
  import React, { useEffect, useMemo, useRef, useState } from "react";
12
12
  import { createTextAttributes, type KeyEvent, type ScrollBoxRenderable } from "@opentui/core";
13
- import type { Thread } from "@cueloop/schema";
13
+ import type { Annotation, Thread } from "@cueloop/schema";
14
14
  import { diffRowText, fileChangeCounts, type DiffRow, type Mark } from "../view-diff";
15
15
  import type { TextSpan } from "../thread-selection";
16
16
  import type { QuickAction } from "../config";
@@ -18,7 +18,7 @@ import type { Theme } from "../theme";
18
18
  import { useComponentTheme } from "./theme-context";
19
19
  import { IconButton } from "./primitives/IconButton";
20
20
  import { NERD } from "./primitives/icons";
21
- import { intralineRunsByRow, type IntralineRun } from "../diff-intraline";
21
+ import { createIntralineResolver, type IntralineRun } from "../diff-intraline";
22
22
  import { highlightDiffRows, type SyntaxSpan } from "../diff-syntax";
23
23
  import { splitDiffRows, type SplitLine, type SplitRow } from "../split-diff";
24
24
  import { UNDERLINE, type AnnotationPalette } from "../annotation-palette";
@@ -95,6 +95,8 @@ export interface DiffContentViewProps {
95
95
  onAnnotate: (span: TextSpan, body: string) => void;
96
96
  onReply: (rootAnnotationId: string, body: string) => void;
97
97
  onUpdateAnnotation: (id: string, body: string) => void;
98
+ /** The author's display name for a comment's hover tooltip. */
99
+ resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
98
100
  leaderCombos?: readonly string[];
99
101
  onLeaderCommand?: (key: KeyEvent) => void;
100
102
  onExit: () => void;
@@ -489,6 +491,7 @@ export function DiffContentView({
489
491
  onAnnotate,
490
492
  onReply,
491
493
  onUpdateAnnotation,
494
+ resolveAuthorLabel,
492
495
  leaderCombos,
493
496
  onLeaderCommand,
494
497
  onExit,
@@ -524,6 +527,7 @@ export function DiffContentView({
524
527
  onAnnotate,
525
528
  onReply,
526
529
  onUpdateAnnotation,
530
+ resolveAuthorLabel,
527
531
  leaderCombos,
528
532
  onLeaderCommand,
529
533
  onExit,
@@ -531,7 +535,7 @@ export function DiffContentView({
531
535
  const { palette } = surface;
532
536
  const numberWidth = useMemo(() => lineNumberWidth(rows), [rows]);
533
537
  const unifiedGutterColumns = numberWidth * 2 + UNIFIED_GUTTER_CHROME;
534
- const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
538
+ const intralineResolver = useMemo(() => createIntralineResolver(rows), [rows]);
535
539
  const syntaxByRow = useSyntaxHighlights(rows);
536
540
  const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
537
541
  const localStats = useMemo(() => fileChangeCounts(rows), [rows]);
@@ -591,13 +595,13 @@ export function DiffContentView({
591
595
  const fgByColumn = foregroundColumns(
592
596
  text,
593
597
  rowBaseColor(row, tokens),
594
- intralineByRow.get(rowIndex),
598
+ intralineResolver.runsForRow(rowIndex),
595
599
  syntaxByRow.get(rowIndex),
596
600
  tokens,
597
601
  );
598
602
  const emphasisBgByColumn = emphasisBackgroundColumns(
599
603
  text,
600
- intralineByRow.get(rowIndex),
604
+ intralineResolver.runsForRow(rowIndex),
601
605
  rowEmphasisBackground(row, tokens),
602
606
  );
603
607
  const rowBg = rowBackground(row, tokens);
@@ -1,5 +1,5 @@
1
1
  // A Changes/Project file tab in contents mode: a workspace file rendered read-only with line
2
- // numbers, for surfaces with no review session to anchor notes to (the bare-launch welcome shell).
2
+ // numbers, for surfaces with no thread to anchor notes to (the bare-launch welcome shell).
3
3
  // Loads on mount and whenever the path changes; a null read renders a centered "File deleted".
4
4
  // Syntax highlighting comes from the native code renderable (tree-sitter), the language
5
5
  // auto-detected from the path; unknown languages simply render unstyled.
@@ -34,6 +34,7 @@ export type DiffSurfaceProps = Pick<
34
34
  | "onAnnotate"
35
35
  | "onReply"
36
36
  | "onUpdateAnnotation"
37
+ | "resolveAuthorLabel"
37
38
  | "leaderCombos"
38
39
  | "onLeaderCommand"
39
40
  | "onExit"
@@ -121,6 +122,7 @@ export function GridTabContent(props: {
121
122
  onAddComment={(anchor, body) => props.onAddFileComment(filePath, anchor, body)}
122
123
  onReply={props.surface.onReply}
123
124
  onUpdateAnnotation={props.surface.onUpdateAnnotation}
125
+ resolveAuthorLabel={props.surface.resolveAuthorLabel}
124
126
  onExit={props.surface.onExit}
125
127
  theme={props.theme}
126
128
  />
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ManageAccessDialog } from "./ManageAccessDialog";
5
+
6
+ export const meta: StoryMeta = { title: "Dialogs/ManageAccess" };
7
+
8
+ export const WithHandles: Story = {
9
+ render: () => (
10
+ <ManageAccessDialog
11
+ isOpen
12
+ logins={["octocat", "hubot"]}
13
+ onAdd={() => {}}
14
+ onRemove={() => {}}
15
+ onCreateLink={() => {}}
16
+ onClose={() => {}}
17
+ theme={DARK}
18
+ />
19
+ ),
20
+ size: { width: 60, height: 18 },
21
+ };
22
+
23
+ export const Empty: Story = {
24
+ render: () => (
25
+ <ManageAccessDialog
26
+ isOpen
27
+ logins={[]}
28
+ onAdd={() => {}}
29
+ onRemove={() => {}}
30
+ onCreateLink={() => {}}
31
+ onClose={() => {}}
32
+ theme={DARK}
33
+ />
34
+ ),
35
+ size: { width: 60, height: 18 },
36
+ };
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import React, { useState } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { DARK } from "../theme";
5
+ import { clickText, locateTextInFrame, settle, typeText } from "../test-support";
6
+ import { ManageAccessDialog } from "./ManageAccessDialog";
7
+
8
+ function AccessHarness({ initial }: { initial: string[] }): React.ReactNode {
9
+ const [logins, setLogins] = useState<string[]>(initial);
10
+ const [open, setOpen] = useState(true);
11
+
12
+ return (
13
+ <box style={{ width: 60, height: 18 }}>
14
+ <ManageAccessDialog
15
+ isOpen={open}
16
+ logins={logins}
17
+ onAdd={(login) => setLogins((prev) => [...prev, login])}
18
+ onRemove={(login) => setLogins((prev) => prev.filter((entry) => entry !== login))}
19
+ onCreateLink={() => {}}
20
+ onClose={() => setOpen(false)}
21
+ theme={DARK}
22
+ />
23
+ </box>
24
+ );
25
+ }
26
+
27
+ async function renderAccess(initial: string[]): Promise<Awaited<ReturnType<typeof testRender>>> {
28
+ const setup = await testRender(<AccessHarness initial={initial} />, { width: 60, height: 18 });
29
+
30
+ await settle(setup);
31
+
32
+ return setup;
33
+ }
34
+
35
+ describe("ManageAccessDialog", () => {
36
+ test("renders the allowlist of GitHub handles", async () => {
37
+ const setup = await renderAccess(["octocat", "hubot"]);
38
+ const frame = setup.captureCharFrame();
39
+
40
+ expect(locateTextInFrame(frame, "@octocat")).not.toBeNull();
41
+ expect(locateTextInFrame(frame, "@hubot")).not.toBeNull();
42
+ });
43
+
44
+ test("clicking a row removes that handle from the allowlist", async () => {
45
+ const setup = await renderAccess(["octocat", "hubot"]);
46
+
47
+ await clickText(setup, "@octocat");
48
+
49
+ expect(locateTextInFrame(setup.captureCharFrame(), "@octocat")).toBeNull();
50
+ expect(locateTextInFrame(setup.captureCharFrame(), "@hubot")).not.toBeNull();
51
+ });
52
+
53
+ test("typing a handle and pressing add appends it to the allowlist", async () => {
54
+ const setup = await renderAccess([]);
55
+
56
+ await typeText(setup, "octocat");
57
+ await clickText(setup, "add");
58
+
59
+ expect(locateTextInFrame(setup.captureCharFrame(), "@octocat")).not.toBeNull();
60
+ });
61
+
62
+ test("clicking create link publishes the private link", async () => {
63
+ let created = false;
64
+ const setup = await testRender(
65
+ <box style={{ width: 60, height: 18 }}>
66
+ <ManageAccessDialog
67
+ isOpen
68
+ logins={["octocat"]}
69
+ onAdd={() => {}}
70
+ onRemove={() => {}}
71
+ onCreateLink={() => {
72
+ created = true;
73
+ }}
74
+ onClose={() => {}}
75
+ theme={DARK}
76
+ />
77
+ </box>,
78
+ { width: 60, height: 18 },
79
+ );
80
+
81
+ await settle(setup);
82
+ await clickText(setup, "create link");
83
+
84
+ expect(created).toBe(true);
85
+ });
86
+
87
+ test("clicking close dismisses the dialog", async () => {
88
+ const setup = await renderAccess([]);
89
+
90
+ await clickText(setup, "close");
91
+
92
+ expect(locateTextInFrame(setup.captureCharFrame(), "Manage access")).toBeNull();
93
+ });
94
+ });