@cueloop/client 0.1.0-alpha.65 → 0.1.0-alpha.67

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 (160) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +79 -112
  4. package/src/App.plan-review.test.tsx +23 -205
  5. package/src/App.prototype.test.tsx +1 -8
  6. package/src/App.readonly.test.tsx +9 -27
  7. package/src/App.submit-confirm.test.tsx +19 -72
  8. package/src/App.test.tsx +136 -31
  9. package/src/App.tsx +491 -322
  10. package/src/App.walk.test.tsx +15 -16
  11. package/src/App.workbench.test.tsx +390 -0
  12. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  13. package/src/annotation-palette.ts +61 -0
  14. package/src/app-screens.tsx +197 -77
  15. package/src/app-view-model.ts +9 -69
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +1 -1
  18. package/src/components/AnnotationCards.stories.tsx +87 -0
  19. package/src/components/AnnotationCards.tsx +303 -0
  20. package/src/components/AppHeader.stories.tsx +50 -0
  21. package/src/components/AppHeader.tsx +83 -0
  22. package/src/components/AppShell.stories.tsx +89 -0
  23. package/src/components/AppShell.tsx +239 -0
  24. package/src/components/AppShell.zoom.test.tsx +94 -0
  25. package/src/components/Breadcrumb.stories.tsx +1 -1
  26. package/src/components/ChangesColumn.stories.tsx +39 -0
  27. package/src/components/ChangesColumn.tsx +170 -0
  28. package/src/components/CodeBlock.stories.tsx +1 -1
  29. package/src/components/CompletionOverlay.stories.tsx +1 -1
  30. package/src/components/ConfirmCard.stories.tsx +1 -1
  31. package/src/components/ConfirmCard.tsx +3 -4
  32. package/src/components/ConfirmDialog.stories.tsx +1 -1
  33. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  34. package/src/components/DiffSheet.split.test.tsx +140 -0
  35. package/src/components/DiffSheet.stories.tsx +56 -54
  36. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  37. package/src/components/DiffSheet.tsx +607 -276
  38. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  39. package/src/components/DiscussionMarkerRail.tsx +181 -0
  40. package/src/components/EditorGrid.stories.tsx +59 -0
  41. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  42. package/src/components/EditorGrid.tsx +338 -0
  43. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  44. package/src/components/FileContentsView.stories.tsx +16 -0
  45. package/src/components/FileContentsView.test.tsx +29 -0
  46. package/src/components/FileContentsView.tsx +78 -0
  47. package/src/components/InboxList.stories.tsx +23 -8
  48. package/src/components/InboxList.tsx +188 -33
  49. package/src/components/KeybindsDialog.stories.tsx +1 -1
  50. package/src/components/MarkerPopover.stories.tsx +1 -1
  51. package/src/components/MenuBar.stories.tsx +3 -12
  52. package/src/components/MenuBar.tsx +25 -56
  53. package/src/components/PaneGrid.stories.tsx +84 -0
  54. package/src/components/PaneGrid.tsx +128 -0
  55. package/src/components/PanelColumn.stories.tsx +25 -0
  56. package/src/components/PanelColumn.tsx +95 -0
  57. package/src/components/ProjectTreeView.stories.tsx +25 -0
  58. package/src/components/ProjectTreeView.test.tsx +32 -0
  59. package/src/components/ProjectTreeView.tsx +82 -0
  60. package/src/components/PromptDialog.stories.tsx +1 -1
  61. package/src/components/PromptDialog.tsx +8 -1
  62. package/src/components/PrototypeSheet.stories.tsx +1 -1
  63. package/src/components/RootOverlay.stories.tsx +44 -0
  64. package/src/components/RootOverlay.tsx +35 -0
  65. package/src/components/SettingsDialog.stories.tsx +7 -1
  66. package/src/components/SettingsDialog.tsx +114 -67
  67. package/src/components/SettingsRows.stories.tsx +1 -1
  68. package/src/components/ShellHeader.stories.tsx +50 -0
  69. package/src/components/ShellHeader.tsx +137 -0
  70. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  71. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  72. package/src/components/SurfaceScrollbar.tsx +93 -0
  73. package/src/components/ThreadFooter.stories.tsx +30 -0
  74. package/src/components/ThreadFooter.test.tsx +31 -0
  75. package/src/components/ThreadFooter.tsx +54 -0
  76. package/src/components/ThreadView.slash.test.tsx +113 -0
  77. package/src/components/ThreadView.stories.tsx +1 -1
  78. package/src/components/ThreadView.tsx +62 -1352
  79. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  80. package/src/components/ThreadsSidebar.tsx +70 -0
  81. package/src/components/Toast.stories.tsx +1 -1
  82. package/src/components/Tooltip.stories.tsx +26 -0
  83. package/src/components/Tooltip.tsx +72 -0
  84. package/src/components/TreePane.stories.tsx +1 -1
  85. package/src/components/WalkWizard.stories.tsx +1 -1
  86. package/src/components/WelcomeSurface.stories.tsx +12 -0
  87. package/src/components/WelcomeSurface.tsx +52 -0
  88. package/src/components/__snapshots__/stories.test.tsx.snap +732 -572
  89. package/src/components/agent-launcher.stories.tsx +1 -1
  90. package/src/components/diff-sheet-layout.test.ts +125 -55
  91. package/src/components/diff-sheet-layout.ts +3 -103
  92. package/src/components/editor-grid.test.ts +86 -0
  93. package/src/components/editor-grid.ts +138 -0
  94. package/src/components/fade-title.test.ts +49 -0
  95. package/src/components/fade-title.ts +61 -0
  96. package/src/components/file-tree.test.ts +60 -0
  97. package/src/components/file-tree.ts +112 -0
  98. package/src/components/pages/Workspace.stories.tsx +181 -0
  99. package/src/components/primitives/Button.stories.tsx +1 -1
  100. package/src/components/primitives/Card.stories.tsx +1 -1
  101. package/src/components/primitives/Dialog.stories.tsx +1 -1
  102. package/src/components/primitives/IconButton.stories.tsx +36 -0
  103. package/src/components/primitives/IconButton.tsx +62 -0
  104. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  105. package/src/components/primitives/Tabs.stories.tsx +1 -1
  106. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  107. package/src/components/primitives/Tree.stories.tsx +124 -0
  108. package/src/components/primitives/Tree.tsx +96 -0
  109. package/src/components/primitives/icons.ts +61 -0
  110. package/src/components/primitives/tree-model.test.ts +99 -0
  111. package/src/components/primitives/tree-model.ts +137 -0
  112. package/src/components/quick-actions-editor.stories.tsx +1 -1
  113. package/src/components/session-tree.test.ts +134 -0
  114. package/src/components/session-tree.ts +108 -0
  115. package/src/components/stories-app.tsx +142 -82
  116. package/src/components/story-tree.test.ts +100 -0
  117. package/src/components/story.ts +55 -0
  118. package/src/components/theme-context.stories.tsx +1 -1
  119. package/src/config.test.ts +20 -49
  120. package/src/config.ts +26 -28
  121. package/src/diff-fold.test.ts +78 -0
  122. package/src/diff-fold.ts +126 -0
  123. package/src/diff-syntax.test.ts +16 -0
  124. package/src/diff-syntax.ts +6 -3
  125. package/src/discussions.ts +79 -0
  126. package/src/intent-dispatch.test.ts +31 -11
  127. package/src/intent-dispatch.ts +93 -48
  128. package/src/key-bindings.test.ts +1 -1
  129. package/src/keymap.test.ts +3 -25
  130. package/src/keymap.ts +17 -18
  131. package/src/mark-runs.ts +120 -0
  132. package/src/pty.ts +10 -2
  133. package/src/run.ts +17 -7
  134. package/src/serve.test.ts +4 -8
  135. package/src/session-controller.curate.test.ts +75 -0
  136. package/src/session-controller.share.test.ts +1 -0
  137. package/src/session-controller.tree.test.ts +26 -0
  138. package/src/session-controller.ts +160 -31
  139. package/src/slash-palette.ts +83 -0
  140. package/src/split-diff.test.ts +110 -0
  141. package/src/split-diff.ts +149 -0
  142. package/src/test-support.test.ts +3 -3
  143. package/src/test-support.ts +39 -8
  144. package/src/theme.ts +4 -4
  145. package/src/thread-chords.test.ts +25 -9
  146. package/src/thread-chords.ts +33 -8
  147. package/src/use-annotation-surface.tsx +837 -0
  148. package/src/use-changes-workbench.ts +151 -0
  149. package/src/use-frame-measure.ts +38 -0
  150. package/src/use-settings-dialog.tsx +23 -24
  151. package/src/use-terminal-virtualizer.ts +233 -0
  152. package/src/view-diff.ts +114 -0
  153. package/src/App.review-panel.test.tsx +0 -140
  154. package/src/App.tree.test.tsx +0 -224
  155. package/src/components/ReviewPanel.stories.tsx +0 -100
  156. package/src/components/ReviewPanel.tsx +0 -103
  157. package/src/components/ReviewRail.stories.tsx +0 -232
  158. package/src/components/ReviewRail.tsx +0 -327
  159. package/src/review-panel.test.ts +0 -113
  160. package/src/review-panel.ts +0 -83
@@ -0,0 +1,95 @@
1
+ // A workbench panel: one full-height grid column with a file-tab header cell on
2
+ // top and a body below. The header row carries the tabs on the left and optional
3
+ // controls on the right, underlined by a thin rule in the same divider gray so the
4
+ // header bottoms join the side rules into one grid. The side border runs from the top.
5
+
6
+ import React, { useState } from "react";
7
+ import type { Theme } from "../theme";
8
+ import { NERD, HEADER_UNDERLINE_CHARS } from "./primitives/icons";
9
+
10
+ export interface FileTabProps {
11
+ label: string;
12
+ active?: boolean;
13
+ /** When set, a close control reveals on hover (a disposable tab). */
14
+ onClose?: () => void;
15
+ theme?: Theme;
16
+ }
17
+
18
+ /** A file tab: the label in the accent when active, with a hover-revealed close. */
19
+ export function FileTab({ label, active, onClose, theme }: FileTabProps): React.ReactNode {
20
+ const [hovered, setHovered] = useState(false);
21
+
22
+ return (
23
+ <box
24
+ style={{ flexDirection: "row", paddingRight: 1 }}
25
+ onMouseOver={() => setHovered(true)}
26
+ onMouseOut={() => setHovered(false)}
27
+ >
28
+ <text fg={active ? theme?.accent : theme?.textDim}>{label}</text>
29
+ {onClose ? (
30
+ <box onMouseUp={onClose} style={{ paddingLeft: 1 }}>
31
+ <text fg={theme?.textDim}>{hovered ? NERD.close : " "}</text>
32
+ </box>
33
+ ) : null}
34
+ </box>
35
+ );
36
+ }
37
+
38
+ export interface PanelColumnProps {
39
+ /** Fixed column width; omit for the flex-growing center panel. */
40
+ width?: number;
41
+ /** Draw the divider on this side (the sidebar borders right, the rest border left). */
42
+ border?: "left" | "right";
43
+ /** Tabs and controls for the header cell. */
44
+ header: React.ReactNode;
45
+ /** Right-aligned header controls (toggles, search). */
46
+ headerRight?: React.ReactNode;
47
+ children: React.ReactNode;
48
+ theme?: Theme;
49
+ }
50
+
51
+ export function PanelColumn({
52
+ width,
53
+ border,
54
+ header,
55
+ headerRight,
56
+ children,
57
+ theme,
58
+ }: PanelColumnProps): React.ReactNode {
59
+ return (
60
+ <box
61
+ style={{
62
+ width,
63
+ // grow panes split evenly and clip wide content (a long diff line) rather than stretching
64
+ flexGrow: width === undefined ? 1 : undefined,
65
+ flexBasis: width === undefined ? 0 : undefined,
66
+ minWidth: 0,
67
+ flexDirection: "column",
68
+ borderStyle: "single",
69
+ border: border ? [border] : [],
70
+ borderColor: theme?.border,
71
+ }}
72
+ >
73
+ <box
74
+ customBorderChars={HEADER_UNDERLINE_CHARS}
75
+ style={{
76
+ flexDirection: "row",
77
+ height: 2,
78
+ paddingLeft: 1,
79
+ // right controls need the same breathing room as the left, or the rightmost icon renders
80
+ // squeezed against the terminal's last column
81
+ paddingRight: 1,
82
+ backgroundColor: theme?.panel,
83
+ borderStyle: "single",
84
+ border: ["bottom"],
85
+ borderColor: theme?.border,
86
+ }}
87
+ >
88
+ {header}
89
+ <box style={{ flexGrow: 1 }} />
90
+ {headerRight}
91
+ </box>
92
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>{children}</box>
93
+ </box>
94
+ );
95
+ }
@@ -0,0 +1,25 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ProjectTreeView } from "./ProjectTreeView";
5
+
6
+ export const meta: StoryMeta = { title: "Surfaces/ProjectTreeView" };
7
+
8
+ const SAMPLE = [
9
+ "packages/client/src/App.tsx",
10
+ "packages/client/src/components/AppShell.tsx",
11
+ "packages/daemon/src/api.ts",
12
+ "README.md",
13
+ ];
14
+
15
+ export const Loaded: Story = {
16
+ render: () => (
17
+ <ProjectTreeView
18
+ loadFiles={() => Promise.resolve(SAMPLE)}
19
+ onSelectFile={() => {}}
20
+ theme={DARK}
21
+ />
22
+ ),
23
+ expectedColors: [DARK.textDim],
24
+ size: { width: 40, height: 16 },
25
+ };
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { ProjectTreeView } from "./ProjectTreeView";
5
+ import { clickText, waitForText } from "../test-support";
6
+ import { DARK } from "../theme";
7
+
8
+ test("renders the loaded files as a tree and opens a file on click", async () => {
9
+ const opened: string[] = [];
10
+ const setup = await testRender(
11
+ <ProjectTreeView
12
+ loadFiles={() => Promise.resolve(["src/App.tsx", "README.md"])}
13
+ onSelectFile={(path) => opened.push(path)}
14
+ theme={DARK}
15
+ />,
16
+ { width: 40, height: 16 },
17
+ );
18
+
19
+ await waitForText(setup, "README.md");
20
+ await clickText(setup, "README.md");
21
+
22
+ expect(opened).toContain("README.md");
23
+ });
24
+
25
+ test("shows an empty hint when there are no files", async () => {
26
+ const setup = await testRender(
27
+ <ProjectTreeView loadFiles={() => Promise.resolve([])} onSelectFile={() => {}} theme={DARK} />,
28
+ { width: 40, height: 8 },
29
+ );
30
+
31
+ await waitForText(setup, "empty");
32
+ });
@@ -0,0 +1,82 @@
1
+ // The Project pane in tree mode: the workspace's tracked files as a collapsible directory tree.
2
+ // Folders start collapsed and expand on click; a file click opens it as a tab. The caller keys this
3
+ // on the session id so switching sessions remounts it and never shows the previous repository's paths.
4
+
5
+ import React, { useEffect, useMemo, useRef, useState } from "react";
6
+ import type { Theme } from "../theme";
7
+ import { useComponentTheme } from "./theme-context";
8
+ import { Tree } from "./primitives/Tree";
9
+ import { buildPathTree } from "./file-tree";
10
+
11
+ export interface ProjectTreeViewProps {
12
+ loadFiles: () => Promise<string[]>;
13
+ onSelectFile: (path: string) => void;
14
+ theme?: Theme;
15
+ }
16
+
17
+ export function ProjectTreeView({
18
+ loadFiles,
19
+ onSelectFile,
20
+ theme,
21
+ }: ProjectTreeViewProps): React.ReactNode {
22
+ const tokens = useComponentTheme(theme);
23
+ const [paths, setPaths] = useState<readonly string[] | null>(null);
24
+ const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set());
25
+ const nodes = useMemo(() => (paths ? buildPathTree(paths) : []), [paths]);
26
+ const loadRef = useRef(loadFiles);
27
+ useEffect(() => {
28
+ loadRef.current = loadFiles;
29
+ });
30
+
31
+ useEffect(() => {
32
+ let alive = true;
33
+ void loadRef.current().then(
34
+ (files) => {
35
+ if (alive) setPaths(files);
36
+ },
37
+ () => {
38
+ if (alive) setPaths([]);
39
+ },
40
+ );
41
+
42
+ return () => {
43
+ alive = false;
44
+ };
45
+ }, []);
46
+
47
+ if (paths === null) {
48
+ return (
49
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
50
+ <text fg={tokens.textDim}>loading...</text>
51
+ </box>
52
+ );
53
+ }
54
+ if (paths.length === 0) {
55
+ return (
56
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
57
+ <text fg={tokens.textDim}>empty</text>
58
+ </box>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
64
+ <Tree
65
+ nodes={nodes}
66
+ expandedIds={expandedIds}
67
+ onSelect={onSelectFile}
68
+ onToggle={(id) =>
69
+ setExpandedIds((current) => {
70
+ const next = new Set(current);
71
+
72
+ if (next.has(id)) next.delete(id);
73
+ else next.add(id);
74
+
75
+ return next;
76
+ })
77
+ }
78
+ theme={theme}
79
+ />
80
+ </scrollbox>
81
+ );
82
+ }
@@ -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: () => (
@@ -46,7 +46,14 @@ export function PromptDialog({
46
46
  if (!isOpen) return null;
47
47
 
48
48
  return (
49
- <Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
49
+ <Dialog
50
+ isOpen
51
+ title={title}
52
+ width={Math.min(54, terminalWidth - 6)}
53
+ height={7}
54
+ background={tokens.elevated}
55
+ theme={theme}
56
+ >
50
57
  <box
51
58
  style={{
52
59
  flexDirection: "column",
@@ -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 = {
@@ -0,0 +1,44 @@
1
+ import React, { useEffect } from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { RootOverlayProvider, useRootOverlay } from "./RootOverlay";
5
+
6
+ export const meta: StoryMeta = { title: "Layout/RootOverlay" };
7
+
8
+ // The provider renders whatever a descendant pushes after the rest of the tree, so it draws on top.
9
+ function OverlayDemo(): React.ReactNode {
10
+ const { setOverlay } = useRootOverlay();
11
+
12
+ useEffect(() => {
13
+ setOverlay(
14
+ <box
15
+ style={{
16
+ position: "absolute",
17
+ top: 2,
18
+ left: 6,
19
+ borderStyle: "single",
20
+ borderColor: DARK.border,
21
+ backgroundColor: DARK.elevated,
22
+ paddingLeft: 1,
23
+ paddingRight: 1,
24
+ }}
25
+ >
26
+ <text fg={DARK.text}>floating overlay</text>
27
+ </box>,
28
+ );
29
+ }, [setOverlay]);
30
+
31
+ return <text fg={DARK.textDim}>base content under the overlay</text>;
32
+ }
33
+
34
+ export const FloatingNode: Story = {
35
+ render: () => (
36
+ <RootOverlayProvider>
37
+ <box style={{ padding: 1, backgroundColor: DARK.panel }}>
38
+ <OverlayDemo />
39
+ </box>
40
+ </RootOverlayProvider>
41
+ ),
42
+ expectedColors: [DARK.text],
43
+ size: { width: 40, height: 6 },
44
+ };
@@ -0,0 +1,35 @@
1
+ // A root overlay slot: OpenTUI has no z-index, so an overlay drawn inside a pane is sliced by the
2
+ // next pane's border. Anything pushed here renders after every pane (last in draw order), so it sits
3
+ // on top of the whole shell - the same trick the tooltip uses. Callers position their node with
4
+ // absolute screen coordinates.
5
+
6
+ import React, { createContext, useContext, useMemo, useState } from "react";
7
+
8
+ interface RootOverlayApi {
9
+ setOverlay: (node: React.ReactNode) => void;
10
+ clearOverlay: () => void;
11
+ }
12
+
13
+ const RootOverlayContext = createContext<RootOverlayApi>({
14
+ setOverlay: () => {},
15
+ clearOverlay: () => {},
16
+ });
17
+
18
+ export function useRootOverlay(): RootOverlayApi {
19
+ return useContext(RootOverlayContext);
20
+ }
21
+
22
+ export function RootOverlayProvider({ children }: { children: React.ReactNode }): React.ReactNode {
23
+ const [overlay, setOverlay] = useState<React.ReactNode>(null);
24
+ const api = useMemo<RootOverlayApi>(
25
+ () => ({ setOverlay, clearOverlay: () => setOverlay(null) }),
26
+ [],
27
+ );
28
+
29
+ return (
30
+ <RootOverlayContext.Provider value={api}>
31
+ {children}
32
+ {overlay}
33
+ </RootOverlayContext.Provider>
34
+ );
35
+ }
@@ -4,7 +4,7 @@ import { THEME_PRESETS } from "../theme-presets";
4
4
  import type { Story, StoryMeta } from "./story";
5
5
  import { SettingsDialog, type SettingsCategory } from "./SettingsDialog";
6
6
 
7
- export const meta: StoryMeta = { title: "SettingsDialog" };
7
+ export const meta: StoryMeta = { title: "Overlays/SettingsDialog" };
8
8
 
9
9
  const CATEGORIES: SettingsCategory[] = [
10
10
  {
@@ -62,6 +62,8 @@ export const NavZone: Story = {
62
62
  render: () => (
63
63
  <SettingsDialog
64
64
  isOpen
65
+ version="0.1.0-alpha.32"
66
+ keybindsSections={[]}
65
67
  categories={CATEGORIES}
66
68
  values={VALUES}
67
69
  activeCategoryId="general"
@@ -79,6 +81,8 @@ export const BodyZone: Story = {
79
81
  render: () => (
80
82
  <SettingsDialog
81
83
  isOpen
84
+ version="0.1.0-alpha.32"
85
+ keybindsSections={[]}
82
86
  categories={CATEGORIES}
83
87
  values={VALUES}
84
88
  activeCategoryId="display"
@@ -96,6 +100,8 @@ export const AppearanceThemed: Story = {
96
100
  render: () => (
97
101
  <SettingsDialog
98
102
  isOpen
103
+ version="0.1.0-alpha.32"
104
+ keybindsSections={[]}
99
105
  categories={CATEGORIES}
100
106
  values={VALUES}
101
107
  activeCategoryId="appearance"
@@ -1,17 +1,20 @@
1
1
  /**
2
- * The settings overlay, promoted from the design prototype: a centered
3
- * Dialog with a left category nav and a right body of typed rows, all driven
4
- * by one settings record. The component is controlled - the caller owns the
5
- * open flag, the active category/row/zone, and value changes - so the app's
6
- * keyboard grammar (and the stories catalog) can drive it without a second
7
- * key handler inside. The modal sizes itself from the terminal dimensions.
2
+ * The settings overlay: a centered Dialog whose left nav is a tree - a Settings
3
+ * group over its categories, plus Keybinds as a sibling leaf - and whose right
4
+ * body shows the active category's typed rows or the keybinds cheatsheet. The
5
+ * component is controlled: the caller owns the open flag, the active
6
+ * category/row/zone, and value changes, so the app's keyboard grammar (and the
7
+ * stories catalog) drive it without a second key handler inside.
8
8
  */
9
9
 
10
10
  import React from "react";
11
11
  import { useTerminalDimensions } from "@opentui/react";
12
12
  import type { Theme } from "../theme";
13
+ import type { CheatsheetSection } from "../key-bindings";
13
14
  import { useComponentTheme } from "./theme-context";
14
15
  import { Dialog } from "./primitives/Dialog";
16
+ import { Tree } from "./primitives/Tree";
17
+ import type { TreeNode } from "./primitives/tree-model";
15
18
  import { CycleRow, TextRow, ToggleRow, type SettingsRowDescriptor } from "./SettingsRows";
16
19
 
17
20
  export interface SettingsCategory {
@@ -25,10 +28,17 @@ export interface SettingsCategory {
25
28
 
26
29
  export type SettingsValues = Record<string, string | boolean>;
27
30
 
31
+ /** The category id whose right pane shows the keybinds cheatsheet rather than rows. */
32
+ const KEYBINDS_CATEGORY_ID = "keybinds";
33
+
28
34
  export interface SettingsDialogProps {
29
35
  isOpen: boolean;
36
+ /** The client version, shown in the dialog footer (its home now the menu bar is gone). */
37
+ version: string;
30
38
  categories: SettingsCategory[];
31
39
  values: SettingsValues;
40
+ /** The keybinds cheatsheet, shown when the Keybinds leaf is active. */
41
+ keybindsSections: CheatsheetSection[];
32
42
  activeCategoryId: string;
33
43
  activeRowIndex: number;
34
44
  activeZone: "nav" | "body";
@@ -37,10 +47,50 @@ export interface SettingsDialogProps {
37
47
  theme?: Theme;
38
48
  }
39
49
 
50
+ /** The left nav tree: a Settings group over its categories, then Keybinds as a leaf. */
51
+ function navTree(categories: SettingsCategory[]): TreeNode[] {
52
+ const children = categories
53
+ .filter((category) => category.id !== KEYBINDS_CATEGORY_ID)
54
+ .map((category) => ({ id: category.id, label: category.name }));
55
+ const nodes: TreeNode[] = [{ id: "settings", label: "Settings", children }];
56
+ const keybinds = categories.find((category) => category.id === KEYBINDS_CATEGORY_ID);
57
+
58
+ if (keybinds !== undefined) nodes.push({ id: keybinds.id, label: keybinds.name });
59
+
60
+ return nodes;
61
+ }
62
+
63
+ function KeybindsBody({
64
+ sections,
65
+ tokens,
66
+ }: {
67
+ sections: CheatsheetSection[];
68
+ tokens: Theme;
69
+ }): React.ReactNode {
70
+ return (
71
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
72
+ {sections.map((section) => (
73
+ <box key={section.title} style={{ flexDirection: "column" }}>
74
+ <text fg={tokens.accent}>{section.title}</text>
75
+ {section.entries.map((entry, index) => (
76
+ <text key={`${section.title}-${index}`}>
77
+ <span fg={tokens.text}>{entry.keys.padEnd(12)}</span>
78
+ <span fg={tokens.textMuted}>{entry.label}</span>
79
+ </text>
80
+ ))}
81
+ <text> </text>
82
+ </box>
83
+ ))}
84
+ </scrollbox>
85
+ );
86
+ }
87
+
40
88
  export function SettingsDialog({
41
89
  isOpen,
90
+ version,
42
91
  categories,
43
92
  values,
93
+ keybindsSections,
44
94
  activeCategoryId,
45
95
  activeRowIndex,
46
96
  activeZone,
@@ -56,6 +106,7 @@ export function SettingsDialog({
56
106
  categories.find((candidate) => candidate.id === activeCategoryId) ?? categories[0];
57
107
 
58
108
  if (!category) return null;
109
+ const onKeybinds = category.id === KEYBINDS_CATEGORY_ID;
59
110
 
60
111
  return (
61
112
  <Dialog
@@ -67,80 +118,76 @@ export function SettingsDialog({
67
118
  theme={theme}
68
119
  >
69
120
  <box style={{ flexDirection: "row", flexGrow: 1 }}>
70
- <box style={{ flexDirection: "column", width: 18, paddingLeft: 1, paddingRight: 1 }}>
71
- <text fg={tokens.textDim}>SETTINGS</text>
72
- {categories.map((candidate) => {
73
- const isActive = candidate.id === category.id;
74
-
75
- return (
76
- <box
77
- key={candidate.id}
78
- style={{
79
- backgroundColor: isActive && activeZone === "nav" ? tokens.border : undefined,
80
- }}
81
- onMouseUp={() => onCategorySelect(candidate.id)}
82
- >
83
- <text
84
- fg={isActive ? tokens.accent : tokens.textMuted}
85
- >{`${isActive ? "› " : " "}${candidate.name}`}</text>
86
- </box>
87
- );
88
- })}
121
+ <box style={{ flexDirection: "column", width: 20, paddingLeft: 1, paddingRight: 1 }}>
122
+ <Tree
123
+ nodes={navTree(categories)}
124
+ expandedIds={new Set(["settings"])}
125
+ selectedId={category.id}
126
+ hideIcons
127
+ onSelect={onCategorySelect}
128
+ onToggle={onCategorySelect}
129
+ theme={theme}
130
+ />
89
131
  </box>
90
- <box style={{ width: 1, backgroundColor: tokens.border }} />
132
+ <box style={{ borderStyle: "single", border: ["left"], borderColor: tokens.border }} />
91
133
  <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 1 }}>
92
134
  <text fg={tokens.text}>{category.name}</text>
93
135
  <text fg={tokens.textDim}>{category.description}</text>
94
136
  <box style={{ height: 1 }} />
95
- {category.customBody}
96
- {category.rows.map((row, rowIndex) => {
97
- const isActive = activeZone === "body" && rowIndex === activeRowIndex;
98
- const value = values[row.key];
137
+ {onKeybinds ? <KeybindsBody sections={keybindsSections} tokens={tokens} /> : null}
138
+ {onKeybinds ? null : category.customBody}
139
+ {onKeybinds
140
+ ? null
141
+ : category.rows.map((row, rowIndex) => {
142
+ const isActive = activeZone === "body" && rowIndex === activeRowIndex;
143
+ const value = values[row.key];
99
144
 
100
- if (row.kind === "toggle") {
101
- return (
102
- <ToggleRow
103
- key={row.key}
104
- label={row.label}
105
- value={value === true}
106
- isActive={isActive}
107
- onPress={() => onRowActivate(row)}
108
- theme={theme}
109
- />
110
- );
111
- }
112
- if (row.kind === "cycle") {
113
- return (
114
- <CycleRow
115
- key={row.key}
116
- label={row.label}
117
- value={String(value ?? "")}
118
- isActive={isActive}
119
- onPress={() => onRowActivate(row)}
120
- theme={theme}
121
- />
122
- );
123
- }
145
+ if (row.kind === "toggle") {
146
+ return (
147
+ <ToggleRow
148
+ key={row.key}
149
+ label={row.label}
150
+ value={value === true}
151
+ isActive={isActive}
152
+ onPress={() => onRowActivate(row)}
153
+ theme={theme}
154
+ />
155
+ );
156
+ }
157
+ if (row.kind === "cycle") {
158
+ return (
159
+ <CycleRow
160
+ key={row.key}
161
+ label={row.label}
162
+ value={String(value ?? "")}
163
+ isActive={isActive}
164
+ onPress={() => onRowActivate(row)}
165
+ theme={theme}
166
+ />
167
+ );
168
+ }
124
169
 
125
- return (
126
- <TextRow
127
- key={row.key}
128
- label={row.label}
129
- value={String(value ?? "")}
130
- isActive={isActive}
131
- onPress={() => onRowActivate(row)}
132
- theme={theme}
133
- />
134
- );
135
- })}
170
+ return (
171
+ <TextRow
172
+ key={row.key}
173
+ label={row.label}
174
+ value={String(value ?? "")}
175
+ isActive={isActive}
176
+ onPress={() => onRowActivate(row)}
177
+ theme={theme}
178
+ />
179
+ );
180
+ })}
136
181
  </box>
137
182
  </box>
138
- <box style={{ flexDirection: "row", height: 1, paddingLeft: 1 }}>
183
+ <box style={{ flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1 }}>
139
184
  <text fg={tokens.textDim}>
140
185
  {activeZone === "nav"
141
- ? "j/k category · l/enter into settings · esc close"
186
+ ? "j/k nav · l/tab open · esc close"
142
187
  : "j/k row · l/space change · h back · esc close"}
143
188
  </text>
189
+ <box style={{ flexGrow: 1 }} />
190
+ <text fg={tokens.textDim}>{`cueloop v${version}`}</text>
144
191
  </box>
145
192
  </Dialog>
146
193
  );
@@ -3,7 +3,7 @@ import { DARK } from "../theme";
3
3
  import type { Story, StoryMeta } from "./story";
4
4
  import { CycleRow, TextRow, ToggleRow } from "./SettingsRows";
5
5
 
6
- export const meta: StoryMeta = { title: "SettingsRows" };
6
+ export const meta: StoryMeta = { title: "Chrome/SettingsRows" };
7
7
 
8
8
  export const AllKinds: Story = {
9
9
  render: () => (