@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66

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 (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. package/src/components/plan-sheet-run-style.ts +0 -88
@@ -0,0 +1,147 @@
1
+ // The right-hand Changes column: the diff's changed files as a directory tree.
2
+ // A click on a file scrolls the diff to that file. Renders nothing when collapsed
3
+ // so the diff reclaims the width. Mirrors the left Threads column on the far side.
4
+ // Co-located here: the state hook that opens it by context and the header toggle.
5
+
6
+ import React, { useMemo, useState } from "react";
7
+ import type { DiffFileContents, ReviewSession } from "@cueloop/schema";
8
+ import type { Theme } from "../theme";
9
+ import type { DiffRow } from "../view-diff";
10
+ import { Tree } from "./primitives/Tree";
11
+ import { allFolderIds } from "./primitives/tree-model";
12
+ import { IconButton } from "./primitives/IconButton";
13
+ import { NERD } from "./primitives/icons";
14
+ import { buildFileTree } from "./file-tree";
15
+
16
+ export interface DiffColumnsState {
17
+ changesOpen: boolean;
18
+ toggleChanges: () => void;
19
+ /** The path under the diff cursor, highlighted in the Changes tree. */
20
+ currentFilePath?: string;
21
+ scrollToFile: (path: string) => void;
22
+ }
23
+
24
+ /**
25
+ * Owns the right Changes column for a diff: context opens it when you enter a
26
+ * diff thread, and a file click scrolls the sheet to that file's first row.
27
+ * Resets on session change via the guarded render-time pattern, no effect.
28
+ */
29
+ export function useDiffColumns(params: {
30
+ session: ReviewSession | null;
31
+ rows: DiffRow[];
32
+ cursor: number;
33
+ setCursor: (index: number) => void;
34
+ }): DiffColumnsState {
35
+ const { session, rows, cursor, setCursor } = params;
36
+ const contextDefault = session?.artifact.type === "diff";
37
+ const [changesOpen, setChangesOpen] = useState(contextDefault);
38
+ const [seenSessionId, setSeenSessionId] = useState(session?.id);
39
+
40
+ // entering another thread lets context decide again; within a thread the manual toggle stays
41
+ if (session?.id !== seenSessionId) {
42
+ setSeenSessionId(session?.id);
43
+ setChangesOpen(contextDefault);
44
+ }
45
+
46
+ return {
47
+ changesOpen,
48
+ toggleChanges: () => setChangesOpen((open) => !open),
49
+ currentFilePath: rows[cursor]?.file,
50
+ scrollToFile: (path: string) => {
51
+ const fileRowIndex = rows.findIndex((row) => row.kind === "file" && row.file === path);
52
+
53
+ if (fileRowIndex >= 0) setCursor(fileRowIndex);
54
+ },
55
+ };
56
+ }
57
+
58
+ /** The header toggle for the Changes column; shown only for a diff, where files exist to browse. */
59
+ export function DiffChangesToggle({
60
+ isDiff,
61
+ open,
62
+ onToggle,
63
+ theme,
64
+ }: {
65
+ isDiff: boolean;
66
+ open: boolean;
67
+ onToggle: () => void;
68
+ theme?: Theme;
69
+ }): React.ReactNode {
70
+ if (!isDiff) return null;
71
+
72
+ return (
73
+ <IconButton
74
+ glyph={open ? NERD.sidebarRight : NERD.sidebarRightOff}
75
+ onPress={onToggle}
76
+ marginLeft={2}
77
+ theme={theme}
78
+ />
79
+ );
80
+ }
81
+
82
+ export interface ChangesColumnProps {
83
+ open: boolean;
84
+ files?: readonly DiffFileContents[];
85
+ /** The path whose diff the sheet is scrolled to, highlighted in the tree. */
86
+ selectedPath?: string;
87
+ onSelectFile: (path: string) => void;
88
+ width?: number;
89
+ theme?: Theme;
90
+ }
91
+
92
+ export function ChangesColumn({
93
+ open,
94
+ files = [],
95
+ selectedPath,
96
+ onSelectFile,
97
+ width = 32,
98
+ theme,
99
+ }: ChangesColumnProps): React.ReactNode {
100
+ const nodes = useMemo(() => buildFileTree(files), [files]);
101
+ // every folder opens by default so each changed file is reachable; the user
102
+ // only ever names the folders they fold shut, so a new file set stays open
103
+ const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>(new Set());
104
+ const expandedIds = useMemo(() => {
105
+ const expanded = allFolderIds(nodes);
106
+
107
+ for (const id of collapsedIds) expanded.delete(id);
108
+
109
+ return expanded;
110
+ }, [nodes, collapsedIds]);
111
+
112
+ if (!open) return null;
113
+
114
+ return (
115
+ <box
116
+ style={{
117
+ width,
118
+ flexDirection: "column",
119
+ borderStyle: "single",
120
+ border: ["left"],
121
+ borderColor: theme?.border,
122
+ }}
123
+ >
124
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
125
+ <Tree
126
+ nodes={nodes}
127
+ expandedIds={expandedIds}
128
+ selectedId={selectedPath}
129
+ flattenEmptyDirectories
130
+ showStatus
131
+ onSelect={onSelectFile}
132
+ onToggle={(id) =>
133
+ setCollapsedIds((current) => {
134
+ const next = new Set(current);
135
+
136
+ if (next.has(id)) next.delete(id);
137
+ else next.add(id);
138
+
139
+ return next;
140
+ })
141
+ }
142
+ theme={theme}
143
+ />
144
+ </scrollbox>
145
+ </box>
146
+ );
147
+ }
@@ -3,7 +3,7 @@ import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { CodeBlock } from "./CodeBlock";
5
5
 
6
- export const meta: StoryMeta = { title: "CodeBlock" };
6
+ export const meta: StoryMeta = { title: "Primitives/CodeBlock" };
7
7
 
8
8
  const SNIPPET = `export function gate(full: number) {
9
9
  // threshold
@@ -3,7 +3,7 @@ import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { CompletionOverlay } from "./CompletionOverlay";
5
5
 
6
- export const meta: StoryMeta = { title: "CompletionOverlay" };
6
+ export const meta: StoryMeta = { title: "Overlays/CompletionOverlay" };
7
7
 
8
8
  export const ApprovedPrompt: Story = {
9
9
  render: () => <CompletionOverlay verdict="approve" completion={{ phase: "prompt" }} status="" />,
@@ -3,7 +3,7 @@ import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { ConfirmCard } from "./ConfirmCard";
5
5
 
6
- export const meta: StoryMeta = { title: "ConfirmCard" };
6
+ export const meta: StoryMeta = { title: "Cards/ConfirmCard" };
7
7
 
8
8
  const callbacks = {
9
9
  onInput: () => {},
@@ -98,11 +98,10 @@ export function ConfirmCard({
98
98
 
99
99
  return (
100
100
  <Card
101
- title=" submit review "
101
+ title=" send message "
102
102
  contentRows={CONFIRM_CONTENT_ROWS + (viewedSummary !== undefined ? 2 : 0)}
103
103
  borderColor={tokens.text}
104
- backgroundColor="transparent"
105
- marginRight={1}
104
+ backgroundColor={tokens.elevated}
106
105
  theme={theme}
107
106
  >
108
107
  {viewedSummary !== undefined ? <text fg={tokens.textDim}>{viewedSummary}</text> : null}
@@ -118,7 +117,7 @@ export function ConfirmCard({
118
117
  <box style={{ height: 1 }} />
119
118
  <Toolbar>
120
119
  <Button variant="solid" marginRight={2} onPress={onSubmit} theme={theme}>
121
- {" Submit "}
120
+ {" send message "}
122
121
  </Button>
123
122
  <Button onPress={onCancel} theme={theme}>
124
123
  {" Cancel "}
@@ -3,7 +3,7 @@ import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { ConfirmDialog } from "./ConfirmDialog";
5
5
 
6
- export const meta: StoryMeta = { title: "ConfirmDialog" };
6
+ export const meta: StoryMeta = { title: "Overlays/ConfirmDialog" };
7
7
 
8
8
  const callbacks = { onConfirm: () => {}, onCancel: () => {} };
9
9
 
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "./story";
4
4
  import { DiffSheet } from "./DiffSheet";
5
5
  import { FIXTURE_PATCH, fixtureDiffRows } from "./story-fixtures";
6
6
 
7
- export const meta: StoryMeta = { title: "DiffSheet" };
7
+ export const meta: StoryMeta = { title: "Surfaces/DiffSheet" };
8
8
 
9
9
  export const SignedRows: Story = {
10
10
  render: () => <DiffSheet rows={fixtureDiffRows()} cursor={0} annotations={[]} />,
@@ -2,9 +2,10 @@ import React from "react";
2
2
  import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { InboxList } from "./InboxList";
5
+ import { groupInbox } from "./session-tree";
5
6
  import { fixtureDiffSession, fixturePlanSession } from "./story-fixtures";
6
7
 
7
- export const meta: StoryMeta = { title: "InboxList" };
8
+ export const meta: StoryMeta = { title: "Surfaces/InboxList" };
8
9
 
9
10
  /** InboxList is the body under App's header/menu chrome; a frame gives it height. */
10
11
  function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode {
@@ -12,19 +13,33 @@ function BodyFrame({ children }: { children: React.ReactNode }): React.ReactNode
12
13
  }
13
14
 
14
15
  export const TwoPending: Story = {
15
- render: () => (
16
- <BodyFrame>
17
- <InboxList inbox={[fixturePlanSession(), fixtureDiffSession()]} cursor={0} />
18
- </BodyFrame>
19
- ),
20
- expectedColors: [DARK.cursorBackground],
16
+ render: () => {
17
+ const plan = fixturePlanSession();
18
+ // one repo-bound thread (a project) and one standalone thread
19
+ const projectPlan = {
20
+ ...plan,
21
+ workspace: {
22
+ ...plan.workspace,
23
+ rootCommit: "root-1",
24
+ remote: "git@github.com:acme/widget.git",
25
+ },
26
+ };
27
+ const { rows } = groupInbox([projectPlan, fixtureDiffSession()]);
28
+
29
+ return (
30
+ <BodyFrame>
31
+ <InboxList rows={rows} cursor={0} />
32
+ </BodyFrame>
33
+ );
34
+ },
35
+ expectedColors: [DARK.accent],
21
36
  size: { width: 80, height: 12 },
22
37
  };
23
38
 
24
39
  export const Empty: Story = {
25
40
  render: () => (
26
41
  <BodyFrame>
27
- <InboxList inbox={[]} cursor={0} />
42
+ <InboxList rows={[]} cursor={0} />
28
43
  </BodyFrame>
29
44
  ),
30
45
  size: { width: 80, height: 8 },
@@ -1,28 +1,35 @@
1
1
  /**
2
- * The pending-review inbox: a cursor-driven session list. Selection stays with
3
- * the keyboard grammar; the list only renders the snapshot. The selected row
4
- * carries a [delete] word-button (click, or `d` in the grammar) that asks the
5
- * caller to confirm removal. App supplies the surrounding header and MenuBar
6
- * chrome (the same as plan/diff review); this is the flex-growing body.
2
+ * The pending-review inbox: a Projects and Threads tree. Selection stays with
3
+ * the keyboard grammar - the cursor indexes the flat thread order and this
4
+ * component only renders the snapshot. The selected thread carries a [delete]
5
+ * word-button (click, or `d` in the grammar). App supplies the surrounding
6
+ * header and MenuBar chrome; this is the flex-growing body.
7
7
  */
8
8
 
9
9
  import React from "react";
10
- import type { ReviewSession } from "@cueloop/schema";
11
10
  import type { Theme } from "../theme";
12
11
  import { useComponentTheme } from "./theme-context";
13
12
  import { Button } from "./primitives/Button";
13
+ import { NERD } from "./primitives/icons";
14
+ import type { InboxRow } from "./session-tree";
14
15
 
15
16
  export interface InboxListProps {
16
- inbox: ReviewSession[];
17
+ rows: InboxRow[];
17
18
  cursor: number;
18
- /** Ask to delete a plan (the selected row's [delete] button). */
19
+ /** The open thread's id; highlights it instead of the cursor (the left sidebar case). */
20
+ activeId?: string;
21
+ /** Open a thread by clicking its row (the left sidebar case). */
22
+ onSelect?: (sessionId: string) => void;
23
+ /** Ask to delete a thread (the selected row's [delete] button). */
19
24
  onRequestDelete?: (id: string, title: string) => void;
20
25
  theme?: Theme;
21
26
  }
22
27
 
23
28
  export function InboxList({
24
- inbox,
29
+ rows,
25
30
  cursor,
31
+ activeId,
32
+ onSelect,
26
33
  onRequestDelete,
27
34
  theme,
28
35
  }: InboxListProps): React.ReactNode {
@@ -30,28 +37,47 @@ export function InboxList({
30
37
 
31
38
  return (
32
39
  <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
33
- {inbox.length === 0 ? (
40
+ {rows.length === 0 ? (
34
41
  <text fg={tokens.textDim}>nothing waiting for review</text>
35
42
  ) : (
36
- inbox.map((session, index) => {
37
- const selected = index === cursor;
38
- const title = session.artifact.meta.title ?? session.id;
43
+ rows.map((row) => {
44
+ if (row.kind === "section") {
45
+ return (
46
+ <text key={row.id} fg={tokens.textDim}>
47
+ {row.label}
48
+ </text>
49
+ );
50
+ }
51
+
52
+ if (row.kind === "project") {
53
+ return (
54
+ <text key={row.id}>
55
+ <span fg={tokens.blue}>{` ${NERD.folderOpen} `}</span>
56
+ <span fg={tokens.textMuted}>{row.label}</span>
57
+ </text>
58
+ );
59
+ }
60
+
61
+ const selected =
62
+ activeId !== undefined ? activeId === row.session.id : row.selectionIndex === cursor;
63
+ const title = row.session.artifact.meta.title ?? row.session.id;
39
64
 
40
65
  return (
41
66
  <box
42
- key={session.id}
67
+ key={row.id}
68
+ onMouseUp={onSelect ? () => onSelect(row.session.id) : undefined}
43
69
  style={{
44
70
  flexDirection: "row",
45
- backgroundColor: selected ? tokens.cursorBackground : undefined,
71
+ backgroundColor: selected ? tokens.elevated : undefined,
46
72
  }}
47
73
  >
48
- <text fg={selected ? tokens.text : tokens.textMuted}>
49
- {selected ? "> " : " "}
50
- {title} · {session.workspace.branch} · {session.artifact.type}
74
+ <text fg={selected ? tokens.accent : tokens.textMuted}>
75
+ {" "}
76
+ {title}
51
77
  </text>
52
78
  <box style={{ flexGrow: 1 }} />
53
79
  {selected && onRequestDelete ? (
54
- <Button onPress={() => onRequestDelete(session.id, title)} theme={theme}>
80
+ <Button onPress={() => onRequestDelete(row.session.id, title)} theme={theme}>
55
81
  {" [delete] "}
56
82
  </Button>
57
83
  ) : null}
@@ -3,7 +3,7 @@ import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { KeybindsDialog } from "./KeybindsDialog";
5
5
 
6
- export const meta: StoryMeta = { title: "KeybindsDialog" };
6
+ export const meta: StoryMeta = { title: "Overlays/KeybindsDialog" };
7
7
 
8
8
  const SECTIONS = [
9
9
  {
@@ -10,13 +10,52 @@ export interface KeybindsDialogProps {
10
10
  theme?: Theme;
11
11
  }
12
12
 
13
+ /** Rows a section takes: its title, its entries, and the blank line after it. */
14
+ function sectionRows(section: CheatsheetSection): number {
15
+ return 1 + section.entries.length + 1;
16
+ }
17
+
18
+ /**
19
+ * Split the sections over `count` columns, filling each column to the
20
+ * balanced share of rows before starting the next, so a long cheatsheet
21
+ * reads top to bottom, then left to right.
22
+ */
23
+ function columnsOf(sections: CheatsheetSection[], count: number): CheatsheetSection[][] {
24
+ const total = sections.reduce((sum, section) => sum + sectionRows(section), 0);
25
+ const share = Math.ceil(total / count);
26
+ const columns: CheatsheetSection[][] = [[]];
27
+ let filled = 0;
28
+
29
+ for (const section of sections) {
30
+ const column = columns[columns.length - 1]!;
31
+
32
+ if (filled > 0 && filled + sectionRows(section) > share && columns.length < count) {
33
+ columns.push([section]);
34
+ filled = sectionRows(section);
35
+ } else {
36
+ column.push(section);
37
+ filled += sectionRows(section);
38
+ }
39
+ }
40
+
41
+ return columns;
42
+ }
43
+
44
+ const COLUMN_WIDTH = 48;
45
+
13
46
  export function KeybindsDialog({ sections, theme }: KeybindsDialogProps): React.ReactNode {
14
47
  const tokens = useComponentTheme(theme);
15
48
  const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
16
- const contentRows =
17
- sections.reduce((sum, section) => sum + 1 + section.entries.length + 1, 0) + 1;
18
- const width = Math.min(52, terminalWidth - 6);
19
- const height = Math.min(terminalHeight - 4, contentRows + 3);
49
+ const totalRows = sections.reduce((sum, section) => sum + sectionRows(section), 0) + 1;
50
+ // a cheatsheet taller than the terminal folds into a second column
51
+ const columnCount =
52
+ totalRows + 3 > terminalHeight - 4 && terminalWidth >= COLUMN_WIDTH * 2 + 6 ? 2 : 1;
53
+ const columns = columnsOf(sections, columnCount);
54
+ const tallest = Math.max(
55
+ ...columns.map((column) => column.reduce((sum, section) => sum + sectionRows(section), 0)),
56
+ );
57
+ const width = Math.min(COLUMN_WIDTH * columnCount + 4, terminalWidth - 6);
58
+ const height = Math.min(terminalHeight - 4, tallest + 1 + 3);
20
59
 
21
60
  return (
22
61
  <Dialog
@@ -36,19 +75,24 @@ export function KeybindsDialog({ sections, theme }: KeybindsDialogProps): React.
36
75
  paddingTop: 1,
37
76
  }}
38
77
  >
39
- {sections.map((section) => (
40
- <box key={section.title} style={{ flexDirection: "column" }}>
41
- <text fg={tokens.accent}>{section.title}</text>
42
- {section.entries.map((entry, index) => (
43
- <text key={index}>
44
- <span fg={tokens.text}>{entry.keys.padEnd(12)}</span>
45
- <span fg={tokens.textMuted}>{entry.label}</span>
46
- </text>
47
- ))}
48
- <text> </text>
49
- </box>
50
- ))}
51
- <box style={{ flexGrow: 1 }} />
78
+ <box style={{ flexDirection: "row", flexGrow: 1 }}>
79
+ {columns.map((column, columnIndex) => (
80
+ <box key={columnIndex} style={{ flexDirection: "column", width: COLUMN_WIDTH }}>
81
+ {column.map((section) => (
82
+ <box key={section.title} style={{ flexDirection: "column" }}>
83
+ <text fg={tokens.accent}>{section.title}</text>
84
+ {section.entries.map((entry, index) => (
85
+ <text key={index}>
86
+ <span fg={tokens.text}>{entry.keys.padEnd(12)}</span>
87
+ <span fg={tokens.textMuted}>{entry.label}</span>
88
+ </text>
89
+ ))}
90
+ <text> </text>
91
+ </box>
92
+ ))}
93
+ </box>
94
+ ))}
95
+ </box>
52
96
  <box style={{ flexDirection: "row" }}>
53
97
  <box style={{ flexGrow: 1 }} />
54
98
  <text fg={tokens.textDim}>[esc]</text>
@@ -4,7 +4,7 @@ import { DEFAULT_QUICK_ACTIONS } from "../config";
4
4
  import type { Story, StoryMeta } from "./story";
5
5
  import { MarkerPopover } from "./MarkerPopover";
6
6
 
7
- export const meta: StoryMeta = { title: "MarkerPopover" };
7
+ export const meta: StoryMeta = { title: "Overlays/MarkerPopover" };
8
8
 
9
9
  const callbacks = {
10
10
  onComment: () => {},
@@ -3,9 +3,9 @@ import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { MenuBar } from "./MenuBar";
5
5
 
6
- export const meta: StoryMeta = { title: "MenuBar" };
6
+ export const meta: StoryMeta = { title: "Chrome/MenuBar" };
7
7
 
8
- const callbacks = { onToggle: () => {}, onSettings: () => {}, onKeybinds: () => {} };
8
+ const callbacks = { onSettings: () => {}, onKeybinds: () => {} };
9
9
 
10
10
  function BottomFrame({ children }: { children: React.ReactNode }): React.ReactNode {
11
11
  return (
@@ -17,19 +17,10 @@ function BottomFrame({ children }: { children: React.ReactNode }): React.ReactNo
17
17
  );
18
18
  }
19
19
 
20
- export const Closed: Story = {
21
- render: () => (
22
- <BottomFrame>
23
- <MenuBar open={false} version="0.1.0-alpha.32" {...callbacks} />
24
- </BottomFrame>
25
- ),
26
- size: { width: 60, height: 8 },
27
- };
28
-
29
20
  export const Open: Story = {
30
21
  render: () => (
31
22
  <BottomFrame>
32
- <MenuBar open version="0.1.0-alpha.32" {...callbacks} />
23
+ <MenuBar open {...callbacks} />
33
24
  </BottomFrame>
34
25
  ),
35
26
  expectedColors: [DARK.text],
@@ -1,75 +1,44 @@
1
1
  import React from "react";
2
- import { useTerminalDimensions } from "@opentui/react";
3
2
  import type { Theme } from "../theme";
4
3
  import { useComponentTheme } from "./theme-context";
5
4
  import { FRAME_BORDER_STYLE } from "./primitives/frame";
6
5
 
7
6
  export interface MenuBarProps {
8
7
  open: boolean;
9
- version: string;
10
- /** The transient status line, shown between the menu and the version. */
11
- status?: string;
12
- onToggle: () => void;
13
8
  onSettings: () => void;
14
9
  onKeybinds: () => void;
15
10
  theme?: Theme;
16
11
  }
17
12
 
18
- const DROPUP_OPTION_ROWS = 2;
13
+ const DROPDOWN_OPTION_ROWS = 2;
19
14
 
20
- export function MenuBar({
21
- open,
22
- version,
23
- status,
24
- onToggle,
25
- onSettings,
26
- onKeybinds,
27
- theme,
28
- }: MenuBarProps): React.ReactNode {
15
+ /** The settings gear's drop-down: Settings and Keybinds, below the top-left gear. */
16
+ export function MenuBar({ open, onSettings, onKeybinds, theme }: MenuBarProps): React.ReactNode {
29
17
  const tokens = useComponentTheme(theme);
30
- const { height: terminalHeight } = useTerminalDimensions();
31
- const dropupHeight = DROPUP_OPTION_ROWS + 2;
18
+ const dropdownHeight = DROPDOWN_OPTION_ROWS + 2;
19
+
20
+ if (!open) return null;
32
21
 
33
22
  return (
34
- <>
35
- {open ? (
36
- <box
37
- style={{
38
- position: "absolute",
39
- left: 1,
40
- top: Math.max(0, terminalHeight - 1 - dropupHeight),
41
- width: 16,
42
- height: dropupHeight,
43
- border: true,
44
- borderStyle: FRAME_BORDER_STYLE,
45
- borderColor: tokens.text,
46
- flexDirection: "column",
47
- }}
48
- >
49
- <box onMouseUp={onSettings}>
50
- <text fg={tokens.text}> Settings</text>
51
- </box>
52
- <box onMouseUp={onKeybinds}>
53
- <text fg={tokens.text}> Keybinds</text>
54
- </box>
55
- </box>
56
- ) : null}
57
- <box
58
- style={{
59
- flexDirection: "row",
60
- height: 1,
61
- backgroundColor: tokens.panel,
62
- paddingLeft: 1,
63
- paddingRight: 1,
64
- }}
65
- >
66
- <box onMouseUp={onToggle}>
67
- <text fg={tokens.textDim}>menu</text>
68
- </box>
69
- {status ? <text fg={tokens.accent}>{` ${status}`}</text> : null}
70
- <box style={{ flexGrow: 1 }} />
71
- <text fg={tokens.textDim}>v{version}</text>
23
+ <box
24
+ style={{
25
+ position: "absolute",
26
+ left: 1,
27
+ top: 2,
28
+ width: 16,
29
+ height: dropdownHeight,
30
+ border: true,
31
+ borderStyle: FRAME_BORDER_STYLE,
32
+ borderColor: tokens.text,
33
+ flexDirection: "column",
34
+ }}
35
+ >
36
+ <box onMouseUp={onSettings}>
37
+ <text fg={tokens.text}> Settings</text>
38
+ </box>
39
+ <box onMouseUp={onKeybinds}>
40
+ <text fg={tokens.text}> Keybinds</text>
72
41
  </box>
73
- </>
42
+ </box>
74
43
  );
75
44
  }
@@ -3,7 +3,7 @@ import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { PromptDialog } from "./PromptDialog";
5
5
 
6
- export const meta: StoryMeta = { title: "PromptDialog" };
6
+ export const meta: StoryMeta = { title: "Overlays/PromptDialog" };
7
7
 
8
8
  export const RenameAuthor: Story = {
9
9
  render: () => (
@@ -3,7 +3,7 @@ import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { PrototypeSheet } from "./PrototypeSheet";
5
5
 
6
- export const meta: StoryMeta = { title: "PrototypeSheet" };
6
+ export const meta: StoryMeta = { title: "Surfaces/PrototypeSheet" };
7
7
 
8
8
  // The story renderer reports no kitty graphics, so the sheet shows its fallback.
9
9
  export const Unsupported: Story = {