@cueloop/client 0.1.0-alpha.65 → 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 (78) hide show
  1. package/package.json +5 -5
  2. package/src/App.diff.test.tsx +8 -73
  3. package/src/App.plan-review.test.tsx +4 -185
  4. package/src/App.prototype.test.tsx +1 -8
  5. package/src/App.readonly.test.tsx +6 -28
  6. package/src/App.submit-confirm.test.tsx +19 -72
  7. package/src/App.test.tsx +70 -24
  8. package/src/App.tsx +177 -254
  9. package/src/App.walk.test.tsx +1 -1
  10. package/src/app-screens.tsx +40 -51
  11. package/src/app-view-model.ts +2 -4
  12. package/src/components/AnnotationCard.stories.tsx +1 -1
  13. package/src/components/AppShell.stories.tsx +84 -0
  14. package/src/components/AppShell.tsx +127 -0
  15. package/src/components/Breadcrumb.stories.tsx +1 -1
  16. package/src/components/ChangesColumn.stories.tsx +39 -0
  17. package/src/components/ChangesColumn.tsx +147 -0
  18. package/src/components/CodeBlock.stories.tsx +1 -1
  19. package/src/components/CompletionOverlay.stories.tsx +1 -1
  20. package/src/components/ConfirmCard.stories.tsx +1 -1
  21. package/src/components/ConfirmCard.tsx +3 -4
  22. package/src/components/ConfirmDialog.stories.tsx +1 -1
  23. package/src/components/DiffSheet.stories.tsx +1 -1
  24. package/src/components/InboxList.stories.tsx +23 -8
  25. package/src/components/InboxList.tsx +45 -19
  26. package/src/components/KeybindsDialog.stories.tsx +1 -1
  27. package/src/components/MarkerPopover.stories.tsx +1 -1
  28. package/src/components/MenuBar.stories.tsx +3 -12
  29. package/src/components/MenuBar.tsx +25 -56
  30. package/src/components/PromptDialog.stories.tsx +1 -1
  31. package/src/components/PrototypeSheet.stories.tsx +1 -1
  32. package/src/components/ReviewPanel.stories.tsx +1 -1
  33. package/src/components/ReviewRail.stories.tsx +1 -1
  34. package/src/components/SettingsDialog.stories.tsx +7 -1
  35. package/src/components/SettingsDialog.tsx +112 -66
  36. package/src/components/SettingsRows.stories.tsx +1 -1
  37. package/src/components/ShellHeader.stories.tsx +50 -0
  38. package/src/components/ShellHeader.tsx +137 -0
  39. package/src/components/ThreadFooter.stories.tsx +30 -0
  40. package/src/components/ThreadFooter.tsx +49 -0
  41. package/src/components/ThreadView.slash.test.tsx +113 -0
  42. package/src/components/ThreadView.stories.tsx +1 -1
  43. package/src/components/ThreadView.tsx +99 -11
  44. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  45. package/src/components/ThreadsSidebar.tsx +57 -0
  46. package/src/components/Toast.stories.tsx +1 -1
  47. package/src/components/TreePane.stories.tsx +1 -1
  48. package/src/components/WalkWizard.stories.tsx +1 -1
  49. package/src/components/__snapshots__/stories.test.tsx.snap +348 -138
  50. package/src/components/agent-launcher.stories.tsx +1 -1
  51. package/src/components/file-tree.test.ts +45 -0
  52. package/src/components/file-tree.ts +57 -0
  53. package/src/components/pages/Workspace.stories.tsx +181 -0
  54. package/src/components/primitives/Button.stories.tsx +1 -1
  55. package/src/components/primitives/Card.stories.tsx +1 -1
  56. package/src/components/primitives/Dialog.stories.tsx +1 -1
  57. package/src/components/primitives/IconButton.stories.tsx +36 -0
  58. package/src/components/primitives/IconButton.tsx +43 -0
  59. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  60. package/src/components/primitives/Tabs.stories.tsx +1 -1
  61. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  62. package/src/components/primitives/Tree.stories.tsx +124 -0
  63. package/src/components/primitives/Tree.tsx +87 -0
  64. package/src/components/primitives/icons.ts +38 -0
  65. package/src/components/primitives/tree-model.test.ts +99 -0
  66. package/src/components/primitives/tree-model.ts +137 -0
  67. package/src/components/quick-actions-editor.stories.tsx +1 -1
  68. package/src/components/session-tree.test.ts +114 -0
  69. package/src/components/session-tree.ts +94 -0
  70. package/src/components/stories-app.tsx +142 -82
  71. package/src/components/story-tree.test.ts +100 -0
  72. package/src/components/story.ts +55 -0
  73. package/src/components/theme-context.stories.tsx +1 -1
  74. package/src/session-controller.ts +9 -4
  75. package/src/theme.ts +4 -4
  76. package/src/use-settings-dialog.tsx +9 -0
  77. package/src/App.review-panel.test.tsx +0 -140
  78. package/src/App.tree.test.tsx +0 -224
@@ -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
  {
@@ -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 = {
@@ -5,7 +5,7 @@ import type { Story, StoryMeta } from "./story";
5
5
  import { ReviewPanel } from "./ReviewPanel";
6
6
  import { FIXTURE_ANNOTATIONS, fixturePlanSession } from "./story-fixtures";
7
7
 
8
- export const meta: StoryMeta = { title: "ReviewPanel" };
8
+ export const meta: StoryMeta = { title: "Surfaces/ReviewPanel" };
9
9
 
10
10
  /** An empty Tree tab: the stories here show the review tab. */
11
11
  const tree = {
@@ -4,7 +4,7 @@ import type { Story, StoryMeta } from "./story";
4
4
  import { ReviewRail } from "./ReviewRail";
5
5
  import { FIXTURE_ANNOTATIONS, fixtureDiffSession, fixturePlanSession } from "./story-fixtures";
6
6
 
7
- export const meta: StoryMeta = { title: "ReviewRail" };
7
+ export const meta: StoryMeta = { title: "Surfaces/ReviewRail" };
8
8
 
9
9
  /** An empty Tree tab: the stories here show the review tab. */
10
10
  const tree = {
@@ -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,75 @@ 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
+ onSelect={onCategorySelect}
127
+ onToggle={onCategorySelect}
128
+ theme={theme}
129
+ />
89
130
  </box>
90
131
  <box style={{ width: 1, backgroundColor: tokens.border }} />
91
132
  <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 1 }}>
92
133
  <text fg={tokens.text}>{category.name}</text>
93
134
  <text fg={tokens.textDim}>{category.description}</text>
94
135
  <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];
136
+ {onKeybinds ? <KeybindsBody sections={keybindsSections} tokens={tokens} /> : null}
137
+ {onKeybinds ? null : category.customBody}
138
+ {onKeybinds
139
+ ? null
140
+ : category.rows.map((row, rowIndex) => {
141
+ const isActive = activeZone === "body" && rowIndex === activeRowIndex;
142
+ const value = values[row.key];
99
143
 
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
- }
144
+ if (row.kind === "toggle") {
145
+ return (
146
+ <ToggleRow
147
+ key={row.key}
148
+ label={row.label}
149
+ value={value === true}
150
+ isActive={isActive}
151
+ onPress={() => onRowActivate(row)}
152
+ theme={theme}
153
+ />
154
+ );
155
+ }
156
+ if (row.kind === "cycle") {
157
+ return (
158
+ <CycleRow
159
+ key={row.key}
160
+ label={row.label}
161
+ value={String(value ?? "")}
162
+ isActive={isActive}
163
+ onPress={() => onRowActivate(row)}
164
+ theme={theme}
165
+ />
166
+ );
167
+ }
124
168
 
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
- })}
169
+ return (
170
+ <TextRow
171
+ key={row.key}
172
+ label={row.label}
173
+ value={String(value ?? "")}
174
+ isActive={isActive}
175
+ onPress={() => onRowActivate(row)}
176
+ theme={theme}
177
+ />
178
+ );
179
+ })}
136
180
  </box>
137
181
  </box>
138
- <box style={{ flexDirection: "row", height: 1, paddingLeft: 1 }}>
182
+ <box style={{ flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1 }}>
139
183
  <text fg={tokens.textDim}>
140
184
  {activeZone === "nav"
141
- ? "j/k category · l/enter into settings · esc close"
185
+ ? "j/k nav · l/tab open · esc close"
142
186
  : "j/k row · l/space change · h back · esc close"}
143
187
  </text>
188
+ <box style={{ flexGrow: 1 }} />
189
+ <text fg={tokens.textDim}>{`cueloop v${version}`}</text>
144
190
  </box>
145
191
  </Dialog>
146
192
  );
@@ -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: () => (