@cueloop/client 0.1.0-alpha.77 → 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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
@@ -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
  }
@@ -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
  ),
@@ -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
  );
@@ -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";
@@ -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,14 +528,13 @@ 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;
536
535
  const numberWidth = useMemo(() => lineNumberWidth(rows), [rows]);
537
536
  const unifiedGutterColumns = numberWidth * 2 + UNIFIED_GUTTER_CHROME;
538
- const intralineByRow = useMemo(() => intralineRunsByRow(rows), [rows]);
537
+ const intralineResolver = useMemo(() => createIntralineResolver(rows), [rows]);
539
538
  const syntaxByRow = useSyntaxHighlights(rows);
540
539
  const splitRows = useMemo(() => (split ? splitDiffRows(rows) : []), [split, rows]);
541
540
  const localStats = useMemo(() => fileChangeCounts(rows), [rows]);
@@ -595,13 +594,13 @@ export function DiffContentView({
595
594
  const fgByColumn = foregroundColumns(
596
595
  text,
597
596
  rowBaseColor(row, tokens),
598
- intralineByRow.get(rowIndex),
597
+ intralineResolver.runsForRow(rowIndex),
599
598
  syntaxByRow.get(rowIndex),
600
599
  tokens,
601
600
  );
602
601
  const emphasisBgByColumn = emphasisBackgroundColumns(
603
602
  text,
604
- intralineByRow.get(rowIndex),
603
+ intralineResolver.runsForRow(rowIndex),
605
604
  rowEmphasisBackground(row, tokens),
606
605
  );
607
606
  const rowBg = rowBackground(row, tokens);
@@ -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}
@@ -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.
@@ -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
+ };