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

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 (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. package/src/components/agent-launcher.tsx +0 -149
@@ -12,6 +12,7 @@ import { DARK } from "./theme";
12
12
  import {
13
13
  isolateUserConfig,
14
14
  press,
15
+ navCommand,
15
16
  pressKey,
16
17
  renderReadyApp,
17
18
  waitForState,
@@ -104,7 +105,7 @@ describe("the guided walk", () => {
104
105
  const setup = await renderApp();
105
106
 
106
107
  // Act
107
- await pressKey(setup, "k", { meta: true });
108
+ await navCommand(setup, "k");
108
109
 
109
110
  // Assert
110
111
  await waitForText(setup, "file 1 of 3 · 0 viewed");
@@ -172,7 +173,7 @@ describe("the guided walk", () => {
172
173
  // Arrange
173
174
  const setup = await renderApp();
174
175
 
175
- await pressKey(setup, "k", { meta: true });
176
+ await navCommand(setup, "k");
176
177
  await waitForText(setup, "file 1 of 3 · 0 viewed");
177
178
  await press(setup, "]");
178
179
  await waitForText(setup, "file 2 of 3 · 1 viewed");
@@ -186,7 +187,7 @@ describe("the guided walk", () => {
186
187
  expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
187
188
 
188
189
  // Act
189
- await pressKey(setup, "k", { meta: true });
190
+ await navCommand(setup, "k");
190
191
 
191
192
  // Assert
192
193
  await waitForText(setup, "file 2 of 3 · 1 viewed");
@@ -196,7 +197,7 @@ describe("the guided walk", () => {
196
197
  // Arrange
197
198
  const first = await renderApp();
198
199
 
199
- await pressKey(first, "k", { meta: true });
200
+ await navCommand(first, "k");
200
201
  await waitForText(first, "file 1 of 3 · 0 viewed");
201
202
  await press(first, "]");
202
203
  await press(first, "]");
@@ -211,7 +212,7 @@ describe("the guided walk", () => {
211
212
  // a fresh client reads the viewed set back from the session record
212
213
  const second = await renderApp();
213
214
 
214
- await pressKey(second, "k", { meta: true });
215
+ await navCommand(second, "k");
215
216
 
216
217
  // Assert
217
218
  await waitForText(second, "file 3 of 3 · 2 viewed");
@@ -232,7 +233,7 @@ describe("the guided walk", () => {
232
233
  const setup = await renderApp();
233
234
 
234
235
  // Act
235
- await pressKey(setup, "k", { meta: true });
236
+ await navCommand(setup, "k");
236
237
 
237
238
  // Assert
238
239
  await waitForText(setup, "file 1 of 3");
@@ -273,7 +274,7 @@ describe("the guided walk", () => {
273
274
  expect(foregroundsOf(setup, "new")).toContain(DARK.insertedForeground);
274
275
 
275
276
  // Act
276
- await pressKey(setup, "k", { meta: true });
277
+ await navCommand(setup, "k");
277
278
 
278
279
  // Assert
279
280
  await waitForText(setup, "file 1 of 3 · 0 viewed");
@@ -289,7 +290,7 @@ describe("the guided walk", () => {
289
290
  const setup = await renderApp();
290
291
 
291
292
  // Act - renderApp already waited for the diff to load; the session is resolved
292
- await pressKey(setup, "k", { meta: true });
293
+ await navCommand(setup, "k");
293
294
 
294
295
  // Assert
295
296
  await waitForText(setup, "review submitted - read-only");
@@ -21,10 +21,12 @@ import {
21
21
  dragText,
22
22
  isolateUserConfig,
23
23
  locateText,
24
+ press,
24
25
  pressKey,
25
26
  typeText,
26
27
  waitForState,
27
28
  waitForText,
29
+ waitForTextGone,
28
30
  renderReadyApp,
29
31
  } from "./test-support";
30
32
  import { NERD } from "./components/primitives/icons";
@@ -98,6 +100,7 @@ type Setup = Awaited<ReturnType<typeof renderApp>>;
98
100
 
99
101
  /** The header controls sit on the top content row of the compact header. */
100
102
  const HEADER_ROW = 0;
103
+ const BARE_NAV_HINT = "c comment · z fold · type to leave";
101
104
 
102
105
  /** Column of a Project-header toggle, located by its own glyph so spacing changes never break the click. */
103
106
  function toggleColumn(setup: Setup, glyph: string): number {
@@ -496,6 +499,89 @@ describe("the bare-launch welcome shell", () => {
496
499
  expect(frame).toContain("edited in the working tree");
497
500
  });
498
501
 
502
+ test("nav mode on the Welcome tab offers only comment and fold; a diff letter types", async () => {
503
+ const setup = await renderReadyApp(
504
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
505
+ { width: 160, height: 28 },
506
+ );
507
+ await waitForText(setup, "quick brown fox");
508
+ const line = locateText(setup, "quick brown fox");
509
+ await setup.mockMouse.click(line.column, line.row);
510
+
511
+ // esc enters nav; the footer names what this surface can do, not the diff commands
512
+ await press(setup, "escape");
513
+ await waitForText(setup, BARE_NAV_HINT);
514
+ expect(setup.captureCharFrame()).not.toContain("x reject");
515
+
516
+ // a diff letter has no command here, so it leaves nav and types, as the footer says
517
+ await pressKey(setup, "d");
518
+ await waitForText(setup, "● d");
519
+ await press(setup, "escape");
520
+ await waitForTextGone(setup, "● d");
521
+
522
+ // c opens a comment on the caret word; z folds the saved card
523
+ await press(setup, "escape");
524
+ await waitForText(setup, BARE_NAV_HINT);
525
+ await pressKey(setup, "c");
526
+ await typeText(setup, "note");
527
+ await waitForText(setup, "● note");
528
+ await pressKey(setup, "RETURN", { meta: true });
529
+ await waitForText(setup, "note");
530
+ await press(setup, "escape");
531
+ await waitForText(setup, BARE_NAV_HINT);
532
+ await pressKey(setup, "z");
533
+ await waitForText(setup, "○ 1 comment ›");
534
+
535
+ setup.renderer.destroy();
536
+ });
537
+
538
+ test("nav mode on a bare changed-file diff has no reject or walk; x types instead", async () => {
539
+ const setup = await renderReadyApp(
540
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
541
+ { width: 160, height: 28 },
542
+ );
543
+ await waitForText(setup, "README.md");
544
+ const readme = locateText(setup, "README.md");
545
+ await setup.mockMouse.click(readme.column, readme.row);
546
+ await waitForText(setup, "edited in the working tree");
547
+ const line = locateText(setup, "edited in the working tree");
548
+ await setup.mockMouse.click(line.column, line.row);
549
+
550
+ await press(setup, "escape");
551
+ await waitForText(setup, BARE_NAV_HINT);
552
+ expect(setup.captureCharFrame()).not.toContain("x reject");
553
+ await pressKey(setup, "x");
554
+ await waitForText(setup, "● x");
555
+ const frame = setup.captureCharFrame();
556
+ expect(frame).toContain("edited in the working tree");
557
+ expect(frame).not.toContain("change rejected");
558
+
559
+ setup.renderer.destroy();
560
+ });
561
+
562
+ test("nav mode on a project file offers only comment and fold", async () => {
563
+ const setup = await renderReadyApp(
564
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
565
+ { width: 160, height: 28 },
566
+ );
567
+ await waitForText(setup, "README.md");
568
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
569
+ await waitForText(setup, "README.md");
570
+ const readme = locateText(setup, "README.md");
571
+ await setup.mockMouse.click(readme.column, readme.row);
572
+ await waitForText(setup, "edited in the working tree");
573
+ const line = locateText(setup, "edited in the working tree");
574
+ await setup.mockMouse.click(line.column, line.row);
575
+
576
+ await press(setup, "escape");
577
+ await waitForText(setup, BARE_NAV_HINT);
578
+ expect(setup.captureCharFrame()).not.toContain("x reject");
579
+ await pressKey(setup, "d");
580
+ await waitForText(setup, "● d");
581
+
582
+ setup.renderer.destroy();
583
+ });
584
+
499
585
  test("a bare launch rides a Welcome tab in the editor while the Thread pane waits empty", async () => {
500
586
  const setup = await renderWelcome();
501
587
 
@@ -11,6 +11,7 @@ import type { Theme } from "./theme";
11
11
  export const UNDERLINE = createTextAttributes({ underline: true });
12
12
  export const CUT = createTextAttributes({ strikethrough: true, dim: true });
13
13
  export const BOLD = createTextAttributes({ bold: true });
14
+ export const ITALIC = createTextAttributes({ italic: true });
14
15
 
15
16
  /** The marked-words treatment: a violet backdrop under an underline. */
16
17
  const MARK_BACKDROP_DARK = "#463852";
@@ -371,12 +371,8 @@ export function NoThreadShell(props: {
371
371
  {menuChrome}
372
372
  <ConfirmDialog
373
373
  isOpen={confirming !== null}
374
- title=" Delete plan "
375
- message={
376
- confirming
377
- ? `Delete "${confirming.title}"? This removes the plan and its review.`
378
- : ""
379
- }
374
+ title=" delete thread "
375
+ message={confirming ? `Delete "${confirming.title}"? This removes the thread.` : ""}
380
376
  onConfirm={() => {
381
377
  if (confirming) controller.deleteSession(confirming.sessionId);
382
378
  setMode({ type: "normal" });
@@ -523,6 +519,16 @@ export function TrailingOverlays(props: {
523
519
  theme={theme}
524
520
  />
525
521
  ) : null}
522
+ {mode.type === "confirmDelete" ? (
523
+ <ConfirmDialog
524
+ isOpen
525
+ title=" delete thread "
526
+ message={`Delete "${mode.title}"? This removes the thread.`}
527
+ onConfirm={() => dispatch({ type: "confirmDialog" })}
528
+ onCancel={() => setMode({ type: "normal" })}
529
+ theme={theme}
530
+ />
531
+ ) : null}
526
532
  {mode.type === "nameSelf" ? (
527
533
  <PromptDialog
528
534
  isOpen
@@ -58,7 +58,6 @@ export function resolveOverlay(
58
58
  mode.type === "treePrompt"
59
59
  )
60
60
  return "prompt";
61
- if (mode.type === "shareChoice") return "shareChoice";
62
61
  if (mode.type === "spanActions") return "spanActions";
63
62
  if (completionPhase === "prompt") return "completion-prompt";
64
63
  if (completionPhase === "counting") return "completion-counting";
@@ -7,12 +7,12 @@ export const meta: StoryMeta = { title: "Overlays/ConfirmDialog" };
7
7
 
8
8
  const callbacks = { onConfirm: () => {}, onCancel: () => {} };
9
9
 
10
- export const DeletePlan: Story = {
10
+ export const DeleteThread: Story = {
11
11
  render: () => (
12
12
  <ConfirmDialog
13
13
  isOpen
14
- title=" Delete plan "
15
- message={'Delete "Auth rollout"? This removes the plan and its review.'}
14
+ title=" delete thread "
15
+ message={'Delete "Auth rollout"? This removes the thread.'}
16
16
  {...callbacks}
17
17
  />
18
18
  ),
@@ -26,6 +26,7 @@ import { lineMarkRanges, runsFor, wrapLines, type MarkRange } from "../mark-runs
26
26
  import { useFrameMeasure } from "../use-frame-measure";
27
27
  import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
28
28
  import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
29
+ import { NavModeHint } from "./NavModeHint";
29
30
  import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
30
31
  import { coloredRowSpans } from "./diff-content-view-layout";
31
32
 
@@ -97,8 +98,7 @@ export interface DiffContentViewProps {
97
98
  onUpdateAnnotation: (id: string, body: string) => void;
98
99
  /** The author's display name for a comment's hover tooltip. */
99
100
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
100
- leaderCombos?: readonly string[];
101
- onLeaderCommand?: (key: KeyEvent) => void;
101
+ onNavCommand?: (key: KeyEvent) => boolean;
102
102
  onExit: () => void;
103
103
  /** Row indices the owner rejected during curation; drawn struck through. */
104
104
  rejectedRows?: Set<number>;
@@ -385,7 +385,8 @@ function foregroundColumns(
385
385
  const columns: string[] = [];
386
386
 
387
387
  for (const span of coloredRowSpans(text, intraline, syntax, baseColor, tokens)) {
388
- for (let index = 0; index < span.text.length; index++) columns.push(span.foreground);
388
+ // one color per code unit, matching the column model that indexes text by .length
389
+ columns.push(...Array.from({ length: span.text.length }, () => span.foreground));
389
390
  }
390
391
 
391
392
  return columns;
@@ -492,8 +493,7 @@ export function DiffContentView({
492
493
  onReply,
493
494
  onUpdateAnnotation,
494
495
  resolveAuthorLabel,
495
- leaderCombos,
496
- onLeaderCommand,
496
+ onNavCommand,
497
497
  onExit,
498
498
  rejectedRows = EMPTY_REJECTED,
499
499
  fold,
@@ -528,8 +528,7 @@ export function DiffContentView({
528
528
  onReply,
529
529
  onUpdateAnnotation,
530
530
  resolveAuthorLabel,
531
- leaderCombos,
532
- onLeaderCommand,
531
+ onNavCommand,
533
532
  onExit,
534
533
  });
535
534
  const { palette } = surface;
@@ -856,10 +855,18 @@ export function DiffContentView({
856
855
  >
857
856
  {materialized}
858
857
  </scrollbox>
858
+ {suspended ? null : (
859
+ <NavModeHint
860
+ navMode={surface.navMode}
861
+ surface={onNavCommand ? "diff" : "bare"}
862
+ theme={tokens}
863
+ />
864
+ )}
859
865
  </box>
860
866
  <DiscussionMarkerRail
861
867
  discussions={surface.discussions}
862
868
  spanQuote={surface.spanQuote}
869
+ focusedKey={surface.focusedDiscussion}
863
870
  onJump={surface.jumpToDiscussion}
864
871
  scrollbox={scrollRef}
865
872
  theme={theme}
@@ -25,12 +25,14 @@ interface HoveredMarker {
25
25
  function ScrollMarkers({
26
26
  discussions,
27
27
  hovered,
28
+ focused,
28
29
  tokens,
29
30
  onHover,
30
31
  onJump,
31
32
  }: {
32
33
  discussions: Discussion[];
33
34
  hovered: string | null;
35
+ focused: string | null;
34
36
  tokens: Theme;
35
37
  onHover: (marker: HoveredMarker | null) => void;
36
38
  onJump: (key: string) => void;
@@ -50,8 +52,10 @@ function ScrollMarkers({
50
52
  return index >= 0 && index < discussions.length ? index : null;
51
53
  };
52
54
  const hoveredIndex = discussions.findIndex((discussion) => discussion.key === hovered);
55
+ const focusedIndex = discussions.findIndex((discussion) => discussion.key === focused);
56
+ const isActive = (index: number): boolean => index === hoveredIndex || index === focusedIndex;
53
57
  const colorFor = (index: number): string => {
54
- if (index === hoveredIndex) return tokens.text;
58
+ if (isActive(index)) return tokens.text;
55
59
  if (hoveredIndex >= 0 && discussions.length >= 5 && Math.abs(index - hoveredIndex) === 1) {
56
60
  return tokens.textMuted;
57
61
  }
@@ -92,7 +96,7 @@ function ScrollMarkers({
92
96
  style={{ position: "absolute", top: rowFor(index), left: 0 }}
93
97
  fg={colorFor(index)}
94
98
  >
95
- {(index === hoveredIndex ? "●" : "○").padStart(2)}
99
+ {(isActive(index) ? "●" : "○").padStart(2)}
96
100
  </text>
97
101
  ))}
98
102
  </box>
@@ -101,10 +105,9 @@ function ScrollMarkers({
101
105
 
102
106
  export interface DiscussionMarkerRailProps {
103
107
  discussions: Discussion[];
104
- /** The text a discussion's span covers, for the hover preview. */
105
108
  spanQuote: (span: TextSpan) => string;
109
+ focusedKey?: string | null;
106
110
  onJump: (key: string) => void;
107
- /** The surface's scrollbox; when given, its scrollbar draws past the dots as the panel's rightmost column. */
108
111
  scrollbox?: RefObject<ScrollBoxRenderable | null>;
109
112
  theme?: Theme;
110
113
  }
@@ -112,6 +115,7 @@ export interface DiscussionMarkerRailProps {
112
115
  export function DiscussionMarkerRail({
113
116
  discussions,
114
117
  spanQuote,
118
+ focusedKey = null,
115
119
  onJump,
116
120
  scrollbox,
117
121
  theme,
@@ -171,6 +175,7 @@ export function DiscussionMarkerRail({
171
175
  <ScrollMarkers
172
176
  discussions={discussions}
173
177
  hovered={hovered?.key ?? null}
178
+ focused={focusedKey}
174
179
  tokens={tokens}
175
180
  onHover={setHovered}
176
181
  onJump={onJump}
@@ -35,8 +35,7 @@ export type DiffSurfaceProps = Pick<
35
35
  | "onReply"
36
36
  | "onUpdateAnnotation"
37
37
  | "resolveAuthorLabel"
38
- | "leaderCombos"
39
- | "onLeaderCommand"
38
+ | "onNavCommand"
40
39
  | "onExit"
41
40
  >;
42
41
 
@@ -49,6 +48,8 @@ export function ChangesTabBody(props: {
49
48
  fileStats?: ReadonlyMap<string, { additions: number; deletions: number }>;
50
49
  split?: boolean;
51
50
  dimmed: boolean;
51
+ /** Rows the sibling Thread pane spends on its footer, so this empty hint centers level with it. */
52
+ emptyBottomPadding?: number;
52
53
  theme: Theme;
53
54
  }): React.ReactNode {
54
55
  const session = props.surface.session;
@@ -65,6 +66,7 @@ export function ChangesTabBody(props: {
65
66
  flexDirection: "column",
66
67
  alignItems: "center",
67
68
  justifyContent: "center",
69
+ paddingBottom: props.emptyBottomPadding,
68
70
  }}
69
71
  >
70
72
  <text fg={props.theme.textDim}>No changes</text>
@@ -99,6 +101,8 @@ export function GridTabContent(props: {
99
101
  readFile: (path: string) => Promise<string | null>;
100
102
  /** Persist a comment on a project file; the file view built the anchor against its own lines. */
101
103
  onAddFileComment: (path: string, anchor: Anchor, body: string) => void;
104
+ /** Rows the sibling Thread pane spends on its footer, so the empty hint centers level with it. */
105
+ emptyBottomPadding?: number;
102
106
  theme: Theme;
103
107
  }): React.ReactNode {
104
108
  const { tab } = props;
@@ -138,6 +142,7 @@ export function GridTabContent(props: {
138
142
  fileStats={props.fileStats}
139
143
  split={props.split}
140
144
  dimmed={props.dimmed}
145
+ emptyBottomPadding={props.emptyBottomPadding}
141
146
  theme={props.theme}
142
147
  />
143
148
  );
@@ -170,6 +175,7 @@ export function GridTabContent(props: {
170
175
  fileStats={props.fileStats}
171
176
  split={props.split}
172
177
  dimmed={props.dimmed}
178
+ emptyBottomPadding={props.emptyBottomPadding}
173
179
  theme={props.theme}
174
180
  />
175
181
  );
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { HandleChipsInput } from "./HandleChipsInput";
5
+
6
+ export const meta: StoryMeta = { title: "Inputs/HandleChips" };
7
+
8
+ export const WithHandles: Story = {
9
+ render: () => (
10
+ <box style={{ padding: 1, backgroundColor: DARK.elevated }}>
11
+ <HandleChipsInput logins={["octocat", "hubot"]} onChange={() => {}} theme={DARK} />
12
+ </box>
13
+ ),
14
+ size: { width: 48, height: 6 },
15
+ };
16
+
17
+ export const Empty: Story = {
18
+ render: () => (
19
+ <box style={{ padding: 1, backgroundColor: DARK.elevated }}>
20
+ <HandleChipsInput logins={[]} onChange={() => {}} theme={DARK} />
21
+ </box>
22
+ ),
23
+ size: { width: 48, height: 6 },
24
+ };
@@ -0,0 +1,35 @@
1
+ /** Backspace on the empty field pulls the last chip back in to edit, rather than deleting it. */
2
+
3
+ import React, { useState } from "react";
4
+ import { describe, expect, test } from "bun:test";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import { DARK } from "../theme";
7
+ import { press, settle } from "../test-support";
8
+ import { HandleChipsInput } from "./HandleChipsInput";
9
+
10
+ /** A controlled host so onChange edits flow back into the rendered list, like the wizard. */
11
+ function Harness({ initial }: { initial: string[] }): React.ReactNode {
12
+ const [logins, setLogins] = useState(initial);
13
+
14
+ return <HandleChipsInput logins={logins} onChange={setLogins} theme={DARK} />;
15
+ }
16
+
17
+ describe("HandleChipsInput", () => {
18
+ test("backspace on the empty field restores the last chip as editable text", async () => {
19
+ // Arrange - one committed chip and an empty input showing its placeholder
20
+ const setup = await testRender(<Harness initial={["octocat"]} />, { width: 48, height: 6 });
21
+
22
+ await settle(setup);
23
+ expect(setup.captureCharFrame()).toContain("handle");
24
+
25
+ // Act - backspace with the input empty
26
+ await press(setup, "backspace");
27
+
28
+ // Assert - the handle is back in the field (placeholder gone), not deleted
29
+ const frame = setup.captureCharFrame();
30
+
31
+ expect(frame).toContain("octocat");
32
+ expect(frame).not.toContain("handle");
33
+ setup.renderer.destroy();
34
+ });
35
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * A tag-style input for GitHub logins: each added handle is an inline chip with
3
+ * a hover × to remove, and a trailing one-line field where enter adds the next
4
+ * and backspace on an empty field drops the last. Controlled - the caller owns
5
+ * the list and receives every change.
6
+ */
7
+
8
+ import React, { useEffect, useRef, useState } from "react";
9
+ import { useKeyboard } from "@opentui/react";
10
+ import type { KeyBinding, TextareaRenderable } from "@opentui/core";
11
+ import type { Theme } from "../theme";
12
+ import { useComponentTheme } from "./theme-context";
13
+ import { NERD } from "./primitives/icons";
14
+
15
+ // enter adds the handle rather than inserting a newline into the one-line field
16
+ const ADD_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
17
+
18
+ function HandleChip({
19
+ login,
20
+ onRemove,
21
+ tokens,
22
+ }: {
23
+ login: string;
24
+ onRemove: () => void;
25
+ tokens: Theme;
26
+ }): React.ReactNode {
27
+ const [hovered, setHovered] = useState(false);
28
+
29
+ return (
30
+ <box
31
+ onMouseOver={() => setHovered(true)}
32
+ onMouseOut={() => setHovered(false)}
33
+ style={{
34
+ flexDirection: "row",
35
+ flexShrink: 0,
36
+ marginRight: 2,
37
+ paddingLeft: 1,
38
+ backgroundColor: tokens.border,
39
+ }}
40
+ >
41
+ <text fg={tokens.text} style={{ wrapMode: "none" }}>{`@${login}`}</text>
42
+ <box
43
+ onMouseUp={(event) => {
44
+ event.stopPropagation();
45
+ onRemove();
46
+ }}
47
+ style={{ paddingLeft: 1, paddingRight: 1 }}
48
+ >
49
+ <text fg={tokens.textDim}>{hovered ? NERD.close : " "}</text>
50
+ </box>
51
+ </box>
52
+ );
53
+ }
54
+
55
+ export interface HandleChipsInputProps {
56
+ logins: string[];
57
+ onChange: (logins: string[]) => void;
58
+ /** Whether this field owns the cursor and backspace; false shows the chips without a blinking caret. */
59
+ focused?: boolean;
60
+ /** Fired on backspace when the field is empty and no chips remain, so the caller can collapse the section. */
61
+ onEmptyBackspace?: () => void;
62
+ theme?: Theme;
63
+ }
64
+
65
+ export function HandleChipsInput({
66
+ logins,
67
+ onChange,
68
+ focused = true,
69
+ onEmptyBackspace,
70
+ theme,
71
+ }: HandleChipsInputProps): React.ReactNode {
72
+ const tokens = useComponentTheme(theme);
73
+ const inputRef = useRef<TextareaRenderable | null>(null);
74
+ // the text the current field generation mounts with; a remount reseeds it
75
+ const [seed, setSeed] = useState("");
76
+ const [inputGeneration, setInputGeneration] = useState(0);
77
+
78
+ useEffect(() => {
79
+ const input = inputRef.current;
80
+
81
+ if (!input || !focused) return;
82
+ input.focus();
83
+ input.cursorOffset = seed.length; // land the caret at the end of the seeded text
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [inputGeneration, focused]);
86
+
87
+ const reseed = (text: string): void => {
88
+ setSeed(text);
89
+ setInputGeneration((generation) => generation + 1);
90
+ };
91
+
92
+ const addDraft = (): void => {
93
+ const handle = (inputRef.current?.plainText ?? "").trim().replace(/^@/, "").toLowerCase();
94
+
95
+ if (handle && !logins.includes(handle)) onChange([...logins, handle]);
96
+ reseed("");
97
+ };
98
+
99
+ useKeyboard((key) => {
100
+ if (!focused) return;
101
+ if (key.name !== "backspace" || (inputRef.current?.plainText ?? "").length > 0) return;
102
+ // backspace on an empty field pulls the last chip back in to edit; with none left, collapse
103
+ if (logins.length > 0) {
104
+ const last = logins[logins.length - 1]!;
105
+
106
+ onChange(logins.slice(0, -1));
107
+ reseed(last);
108
+ } else {
109
+ onEmptyBackspace?.();
110
+ }
111
+ });
112
+
113
+ return (
114
+ <box style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center" }}>
115
+ {logins.map((login) => (
116
+ <HandleChip
117
+ key={login}
118
+ login={login}
119
+ onRemove={() => onChange(logins.filter((entry) => entry !== login))}
120
+ tokens={tokens}
121
+ />
122
+ ))}
123
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
124
+ <text fg={tokens.textDim}>{"@"}</text>
125
+ <textarea
126
+ key={inputGeneration}
127
+ ref={inputRef}
128
+ focused
129
+ initialValue={seed}
130
+ placeholder="handle"
131
+ keyBindings={ADD_KEY_BINDINGS}
132
+ onSubmit={addDraft}
133
+ style={{
134
+ height: 1,
135
+ width: 16,
136
+ backgroundColor: tokens.elevated,
137
+ focusedBackgroundColor: tokens.elevated,
138
+ textColor: tokens.text,
139
+ focusedTextColor: tokens.text,
140
+ }}
141
+ />
142
+ </box>
143
+ </box>
144
+ );
145
+ }
@@ -0,0 +1,33 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import { buildDisplay, type DisplayBlock } from "../view-plan";
4
+ import type { Story, StoryMeta } from "./story";
5
+ import { MarkdownGridBlock } from "./MarkdownGridBlock";
6
+
7
+ export const meta: StoryMeta = { title: "Surfaces/MarkdownGridBlock" };
8
+
9
+ /** The first display block of a kind, for a story that renders one grid in isolation. */
10
+ function firstBlock(markdown: string, kind: DisplayBlock["kind"]): DisplayBlock {
11
+ return buildDisplay(markdown).find((block) => block.kind === kind)!;
12
+ }
13
+
14
+ const TABLE = firstBlock(
15
+ "| Name | Size |\n| :--- | ---: |\n| daemon | 12 |\n| client | 7 |",
16
+ "table",
17
+ );
18
+ const FRONTMATTER = firstBlock(
19
+ "---\ntitle: Migration Plan\nowner: platform team\nstatus: in review\n---\n\n# Plan\n",
20
+ "frontmatter",
21
+ );
22
+
23
+ export const Table: Story = {
24
+ render: () => <MarkdownGridBlock block={TABLE} theme={DARK} contentWidth={60} />,
25
+ expectedColors: [DARK.text, DARK.textDim],
26
+ size: { width: 62, height: 6 },
27
+ };
28
+
29
+ export const Frontmatter: Story = {
30
+ render: () => <MarkdownGridBlock block={FRONTMATTER} theme={DARK} contentWidth={60} />,
31
+ expectedColors: [DARK.text, DARK.textDim],
32
+ size: { width: 62, height: 9 },
33
+ };