@cueloop/client 0.1.0-alpha.75 → 0.1.0-alpha.77

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 (82) hide show
  1. package/package.json +5 -5
  2. package/src/App.focus.test.ts +147 -0
  3. package/src/App.identity.test.tsx +78 -0
  4. package/src/App.regression.test.tsx +94 -0
  5. package/src/App.share-choice.test.tsx +105 -0
  6. package/src/App.test.tsx +11 -8
  7. package/src/App.toast.test.tsx +3 -1
  8. package/src/App.tsx +648 -221
  9. package/src/App.visual.test.tsx +57 -0
  10. package/src/App.workbench.test.tsx +41 -22
  11. package/src/app-screens.tsx +181 -123
  12. package/src/app-view-model.ts +1 -0
  13. package/src/components/AnnotatableFileView.tsx +9 -1
  14. package/src/components/AnnotationCards.tsx +22 -4
  15. package/src/components/AppShell.brand.test.tsx +1 -1
  16. package/src/components/AppShell.tsx +14 -1
  17. package/src/components/ChangesColumn.tsx +40 -12
  18. package/src/components/ChangesFileTree.test.tsx +61 -0
  19. package/src/components/ConfirmDialog.tsx +8 -1
  20. package/src/components/DiffContentView.tsx +12 -2
  21. package/src/components/DiscussionMarkerRail.tsx +2 -2
  22. package/src/components/EditorGrid.tsx +84 -26
  23. package/src/components/GridTabContent.tsx +12 -1
  24. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  25. package/src/components/ManageAccessDialog.test.tsx +94 -0
  26. package/src/components/ManageAccessDialog.tsx +120 -0
  27. package/src/components/PanelColumn.tsx +3 -0
  28. package/src/components/ProjectTreeView.test.tsx +39 -12
  29. package/src/components/ProjectTreeView.tsx +38 -10
  30. package/src/components/PromptDialog.test.tsx +61 -0
  31. package/src/components/PromptDialog.tsx +16 -3
  32. package/src/components/RootOverlay.stories.tsx +1 -0
  33. package/src/components/RootOverlay.tsx +19 -6
  34. package/src/components/SettingsDialog.stories.tsx +3 -3
  35. package/src/components/SettingsDialog.tsx +3 -4
  36. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  37. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  38. package/src/components/ShareChoiceDialog.tsx +82 -0
  39. package/src/components/ThreadFooter.test.tsx +12 -7
  40. package/src/components/ThreadFooter.tsx +18 -4
  41. package/src/components/ThreadTree.tsx +79 -36
  42. package/src/components/ThreadView.slash.test.tsx +36 -0
  43. package/src/components/ThreadView.tsx +12 -2
  44. package/src/components/WelcomePlayground.tsx +2 -0
  45. package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
  46. package/src/components/menu-control.stories.tsx +35 -0
  47. package/src/components/menu-control.tsx +43 -0
  48. package/src/components/primitives/Dialog.test.tsx +37 -0
  49. package/src/components/primitives/Dialog.tsx +5 -0
  50. package/src/components/primitives/icons.ts +1 -1
  51. package/src/components/quick-actions-editor.stories.tsx +2 -0
  52. package/src/components/quick-actions-editor.test.tsx +35 -1
  53. package/src/components/quick-actions-editor.tsx +23 -22
  54. package/src/components/session-tree.test.ts +3 -3
  55. package/src/components/session-tree.ts +1 -1
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +45 -1
  58. package/src/diff-intraline.ts +26 -5
  59. package/src/github-identity.test.ts +58 -0
  60. package/src/github-identity.ts +41 -0
  61. package/src/intent-dispatch.test.ts +17 -2
  62. package/src/intent-dispatch.ts +7 -1
  63. package/src/keymap.ts +1 -0
  64. package/src/perf/otlp-trace.test.ts +116 -0
  65. package/src/perf/otlp-trace.ts +130 -0
  66. package/src/perf/perf-report.ts +17 -0
  67. package/src/perf/perf-timings.test.ts +18 -0
  68. package/src/perf/perf-timings.ts +38 -0
  69. package/src/run.ts +22 -2
  70. package/src/share.ts +10 -2
  71. package/src/thread-chords.test.ts +33 -1
  72. package/src/thread-chords.ts +114 -29
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.live-diff.test.ts +1 -0
  75. package/src/thread-controller.share.test.ts +36 -2
  76. package/src/thread-controller.tree.test.ts +1 -0
  77. package/src/thread-controller.ts +18 -3
  78. package/src/use-annotation-surface.tsx +32 -2
  79. package/src/use-frame-measure.ts +4 -1
  80. package/src/use-settings-dialog.tsx +33 -7
  81. package/src/visual/contrast.test.ts +35 -0
  82. package/src/visual/contrast.ts +33 -0
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import React, { useState } from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { DARK } from "../theme";
5
+ import { clickText, locateTextInFrame, settle, typeText } from "../test-support";
6
+ import { ManageAccessDialog } from "./ManageAccessDialog";
7
+
8
+ function AccessHarness({ initial }: { initial: string[] }): React.ReactNode {
9
+ const [logins, setLogins] = useState<string[]>(initial);
10
+ const [open, setOpen] = useState(true);
11
+
12
+ return (
13
+ <box style={{ width: 60, height: 18 }}>
14
+ <ManageAccessDialog
15
+ isOpen={open}
16
+ logins={logins}
17
+ onAdd={(login) => setLogins((prev) => [...prev, login])}
18
+ onRemove={(login) => setLogins((prev) => prev.filter((entry) => entry !== login))}
19
+ onCreateLink={() => {}}
20
+ onClose={() => setOpen(false)}
21
+ theme={DARK}
22
+ />
23
+ </box>
24
+ );
25
+ }
26
+
27
+ async function renderAccess(initial: string[]): Promise<Awaited<ReturnType<typeof testRender>>> {
28
+ const setup = await testRender(<AccessHarness initial={initial} />, { width: 60, height: 18 });
29
+
30
+ await settle(setup);
31
+
32
+ return setup;
33
+ }
34
+
35
+ describe("ManageAccessDialog", () => {
36
+ test("renders the allowlist of GitHub handles", async () => {
37
+ const setup = await renderAccess(["octocat", "hubot"]);
38
+ const frame = setup.captureCharFrame();
39
+
40
+ expect(locateTextInFrame(frame, "@octocat")).not.toBeNull();
41
+ expect(locateTextInFrame(frame, "@hubot")).not.toBeNull();
42
+ });
43
+
44
+ test("clicking a row removes that handle from the allowlist", async () => {
45
+ const setup = await renderAccess(["octocat", "hubot"]);
46
+
47
+ await clickText(setup, "@octocat");
48
+
49
+ expect(locateTextInFrame(setup.captureCharFrame(), "@octocat")).toBeNull();
50
+ expect(locateTextInFrame(setup.captureCharFrame(), "@hubot")).not.toBeNull();
51
+ });
52
+
53
+ test("typing a handle and pressing add appends it to the allowlist", async () => {
54
+ const setup = await renderAccess([]);
55
+
56
+ await typeText(setup, "octocat");
57
+ await clickText(setup, "add");
58
+
59
+ expect(locateTextInFrame(setup.captureCharFrame(), "@octocat")).not.toBeNull();
60
+ });
61
+
62
+ test("clicking create link publishes the private link", async () => {
63
+ let created = false;
64
+ const setup = await testRender(
65
+ <box style={{ width: 60, height: 18 }}>
66
+ <ManageAccessDialog
67
+ isOpen
68
+ logins={["octocat"]}
69
+ onAdd={() => {}}
70
+ onRemove={() => {}}
71
+ onCreateLink={() => {
72
+ created = true;
73
+ }}
74
+ onClose={() => {}}
75
+ theme={DARK}
76
+ />
77
+ </box>,
78
+ { width: 60, height: 18 },
79
+ );
80
+
81
+ await settle(setup);
82
+ await clickText(setup, "create link");
83
+
84
+ expect(created).toBe(true);
85
+ });
86
+
87
+ test("clicking close dismisses the dialog", async () => {
88
+ const setup = await renderAccess([]);
89
+
90
+ await clickText(setup, "close");
91
+
92
+ expect(locateTextInFrame(setup.captureCharFrame(), "Manage access")).toBeNull();
93
+ });
94
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Owner surface for a private share's allowlist: the GitHub logins allowed to
3
+ * open the private link. Type a handle and add it; click a row to remove one.
4
+ * Editing persists through the controller, so the list survives a reload.
5
+ */
6
+
7
+ import React, { useEffect, useRef, useState } from "react";
8
+ import { useKeyboard } from "@opentui/react";
9
+ import { useTerminalDimensions } 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 { Dialog } from "./primitives/Dialog";
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
+ export interface ManageAccessDialogProps {
19
+ isOpen: boolean;
20
+ logins: string[];
21
+ onAdd: (login: string) => void;
22
+ onRemove: (login: string) => void;
23
+ /** Publish the private link with the current allowlist and copy its connection line. */
24
+ onCreateLink: () => void;
25
+ onClose: () => void;
26
+ theme?: Theme;
27
+ }
28
+
29
+ export function ManageAccessDialog({
30
+ isOpen,
31
+ logins,
32
+ onAdd,
33
+ onRemove,
34
+ onCreateLink,
35
+ onClose,
36
+ theme,
37
+ }: ManageAccessDialogProps): React.ReactNode {
38
+ const tokens = useComponentTheme(theme);
39
+ const { width: terminalWidth } = useTerminalDimensions();
40
+ const inputRef = useRef<TextareaRenderable | null>(null);
41
+ const [draft, setDraft] = useState("");
42
+ const [inputGeneration, setInputGeneration] = useState(0);
43
+
44
+ useEffect(() => {
45
+ inputRef.current?.focus();
46
+ }, [inputGeneration]);
47
+
48
+ const addDraft = (): void => {
49
+ const handle = draft.trim().replace(/^@/, "").toLowerCase();
50
+
51
+ if (handle && !logins.includes(handle)) onAdd(handle);
52
+ setDraft("");
53
+ setInputGeneration((generation) => generation + 1);
54
+ };
55
+
56
+ useKeyboard((key) => {
57
+ if (isOpen && key.name === "escape") onClose();
58
+ });
59
+
60
+ if (!isOpen) return null;
61
+
62
+ return (
63
+ <Dialog
64
+ isOpen
65
+ title=" Manage access "
66
+ width={Math.min(54, terminalWidth - 6)}
67
+ height={Math.min(18, 9 + logins.length)}
68
+ background={tokens.elevated}
69
+ onDismiss={onClose}
70
+ theme={theme}
71
+ >
72
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}>
73
+ <text fg={tokens.textDim}>Only these GitHub users can open the private link.</text>
74
+ <box style={{ height: 1 }} />
75
+ {logins.length === 0 ? (
76
+ <text fg={tokens.textDim}>no one yet - add a handle below</text>
77
+ ) : (
78
+ logins.map((login) => (
79
+ <box key={login} onMouseUp={() => onRemove(login)} style={{ flexDirection: "row" }}>
80
+ <text fg={tokens.text}>{`@${login}`}</text>
81
+ <box style={{ flexGrow: 1 }} />
82
+ <text fg={tokens.textDim}>remove</text>
83
+ </box>
84
+ ))
85
+ )}
86
+ <box style={{ flexGrow: 1 }} />
87
+ <box style={{ flexDirection: "row" }}>
88
+ <text fg={tokens.textDim}>{"@"}</text>
89
+ <textarea
90
+ key={inputGeneration}
91
+ ref={inputRef}
92
+ focused
93
+ placeholder="handle"
94
+ keyBindings={ADD_KEY_BINDINGS}
95
+ onContentChange={() => setDraft(inputRef.current?.plainText ?? "")}
96
+ style={{
97
+ height: 1,
98
+ flexGrow: 1,
99
+ backgroundColor: tokens.elevated,
100
+ focusedBackgroundColor: tokens.elevated,
101
+ textColor: tokens.text,
102
+ focusedTextColor: tokens.text,
103
+ }}
104
+ />
105
+ </box>
106
+ <box style={{ flexDirection: "row" }}>
107
+ <box onMouseUp={addDraft} style={{ marginRight: 3 }}>
108
+ <text fg={tokens.accent}>add</text>
109
+ </box>
110
+ <box onMouseUp={onCreateLink} style={{ marginRight: 3 }}>
111
+ <text fg={tokens.accent}>create link</text>
112
+ </box>
113
+ <box onMouseUp={onClose}>
114
+ <text fg={tokens.textDim}>close</text>
115
+ </box>
116
+ </box>
117
+ </box>
118
+ </Dialog>
119
+ );
120
+ }
@@ -44,6 +44,7 @@ export interface PanelColumnProps {
44
44
  header: React.ReactNode;
45
45
  /** Right-aligned header controls (toggles, search). */
46
46
  headerRight?: React.ReactNode;
47
+ onFocus?: () => void;
47
48
  children: React.ReactNode;
48
49
  theme?: Theme;
49
50
  }
@@ -53,11 +54,13 @@ export function PanelColumn({
53
54
  border,
54
55
  header,
55
56
  headerRight,
57
+ onFocus,
56
58
  children,
57
59
  theme,
58
60
  }: PanelColumnProps): React.ReactNode {
59
61
  return (
60
62
  <box
63
+ onMouseDown={onFocus}
61
64
  style={{
62
65
  width,
63
66
  // grow panes split evenly and clip wide content (a long diff line) rather than stretching
@@ -1,32 +1,59 @@
1
- import { expect, test } from "bun:test";
1
+ /** The Project tree navigates by keyboard when focused: j/k move the cursor, tab folds a folder or
2
+ * opens a file. */
3
+
4
+ import { test, expect } from "bun:test";
2
5
  import React from "react";
3
6
  import { testRender } from "@opentui/react/test-utils";
4
7
  import { ProjectTreeView } from "./ProjectTreeView";
5
- import { clickText, waitForText } from "../test-support";
8
+ import { settle, waitForText } from "../test-support";
6
9
  import { DARK } from "../theme";
7
10
 
8
- test("renders the loaded files as a tree and opens a file on click", async () => {
11
+ test("tab expands a folder, then j moves down and tab opens the file", async () => {
9
12
  const opened: string[] = [];
10
13
  const setup = await testRender(
11
14
  <ProjectTreeView
12
- loadFiles={() => Promise.resolve(["src/App.tsx", "README.md"])}
15
+ loadFiles={async () => ["src/a.ts", "README.md"]}
13
16
  onSelectFile={(path) => opened.push(path)}
17
+ focused
14
18
  theme={DARK}
15
19
  />,
16
- { width: 40, height: 16 },
20
+ { width: 40, height: 12 },
17
21
  );
22
+ await settle(setup);
23
+ await waitForText(setup, "src");
24
+
25
+ // cursor starts on the "src" folder; tab expands it
26
+ setup.mockInput.pressKey("TAB");
27
+ await waitForText(setup, "a.ts");
28
+
29
+ // j moves onto the file, tab opens it
30
+ setup.mockInput.pressKey("j");
31
+ await settle(setup);
32
+ setup.mockInput.pressKey("TAB");
33
+ await settle(setup);
18
34
 
19
- await waitForText(setup, "README.md");
20
- await clickText(setup, "README.md");
35
+ expect(opened).toEqual(["src/a.ts"]);
21
36
 
22
- expect(opened).toContain("README.md");
37
+ setup.renderer.destroy();
23
38
  });
24
39
 
25
- test("shows an empty hint when there are no files", async () => {
40
+ test("without focus the keyboard does nothing", async () => {
41
+ const opened: string[] = [];
26
42
  const setup = await testRender(
27
- <ProjectTreeView loadFiles={() => Promise.resolve([])} onSelectFile={() => {}} theme={DARK} />,
28
- { width: 40, height: 8 },
43
+ <ProjectTreeView
44
+ loadFiles={async () => ["src/a.ts"]}
45
+ onSelectFile={(path) => opened.push(path)}
46
+ theme={DARK}
47
+ />,
48
+ { width: 40, height: 12 },
29
49
  );
50
+ await settle(setup);
51
+ await waitForText(setup, "src");
52
+
53
+ setup.mockInput.pressKey("TAB");
54
+ await settle(setup);
55
+ expect(setup.captureCharFrame()).not.toContain("a.ts");
56
+ expect(opened).toEqual([]);
30
57
 
31
- await waitForText(setup, "empty");
58
+ setup.renderer.destroy();
32
59
  });
@@ -4,31 +4,62 @@
4
4
 
5
5
  import { ScrollArea } from "./ScrollArea";
6
6
  import React, { useEffect, useMemo, useRef, useState } from "react";
7
+ import { useKeyboard } from "@opentui/react";
7
8
  import type { Theme } from "../theme";
8
9
  import { useComponentTheme } from "./theme-context";
9
10
  import { Tree } from "./primitives/Tree";
11
+ import { flattenTree } from "./primitives/tree-model";
10
12
  import { buildPathTree } from "./file-tree";
11
13
 
12
14
  export interface ProjectTreeViewProps {
13
15
  loadFiles: () => Promise<string[]>;
14
16
  onSelectFile: (path: string) => void;
17
+ /** The pane owns the keyboard: j/k and arrows move the cursor, tab/enter open or fold. */
18
+ focused?: boolean;
15
19
  theme?: Theme;
16
20
  }
17
21
 
18
22
  export function ProjectTreeView({
19
23
  loadFiles,
20
24
  onSelectFile,
25
+ focused = false,
21
26
  theme,
22
27
  }: ProjectTreeViewProps): React.ReactNode {
23
28
  const tokens = useComponentTheme(theme);
24
29
  const [paths, setPaths] = useState<readonly string[] | null>(null);
25
30
  const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set());
31
+ const [cursor, setCursor] = useState(0);
26
32
  const nodes = useMemo(() => (paths ? buildPathTree(paths) : []), [paths]);
33
+ const rows = useMemo(() => flattenTree(nodes, { expandedIds }), [nodes, expandedIds]);
34
+ const cursorIndex = Math.min(cursor, Math.max(0, rows.length - 1));
27
35
  const loadRef = useRef(loadFiles);
28
36
  useEffect(() => {
29
37
  loadRef.current = loadFiles;
30
38
  });
31
39
 
40
+ const toggle = (id: string): void =>
41
+ setExpandedIds((current) => {
42
+ const next = new Set(current);
43
+
44
+ if (next.has(id)) next.delete(id);
45
+ else next.add(id);
46
+
47
+ return next;
48
+ });
49
+
50
+ useKeyboard((key) => {
51
+ if (!focused || rows.length === 0) return;
52
+ if (key.name === "j" || key.name === "down")
53
+ return setCursor(Math.min(cursorIndex + 1, rows.length - 1));
54
+ if (key.name === "k" || key.name === "up") return setCursor(Math.max(cursorIndex - 1, 0));
55
+ const row = rows[cursorIndex];
56
+ if (!row) return;
57
+ if (key.name === "tab" || key.name === "return" || key.name === "enter")
58
+ return row.isFolder ? toggle(row.id) : onSelectFile(row.id);
59
+ if (key.name === "l" && row.isFolder && !expandedIds.has(row.id)) return toggle(row.id);
60
+ if (key.name === "h" && row.isFolder && expandedIds.has(row.id)) return toggle(row.id);
61
+ });
62
+
32
63
  useEffect(() => {
33
64
  let alive = true;
34
65
  void loadRef.current().then(
@@ -65,17 +96,14 @@ export function ProjectTreeView({
65
96
  <Tree
66
97
  nodes={nodes}
67
98
  expandedIds={expandedIds}
68
- onSelect={onSelectFile}
69
- onToggle={(id) =>
70
- setExpandedIds((current) => {
71
- const next = new Set(current);
72
-
73
- if (next.has(id)) next.delete(id);
74
- else next.add(id);
99
+ selectedId={focused ? rows[cursorIndex]?.id : undefined}
100
+ onSelect={(id) => {
101
+ const index = rows.findIndex((row) => row.id === id);
75
102
 
76
- return next;
77
- })
78
- }
103
+ if (index >= 0) setCursor(index);
104
+ onSelectFile(id);
105
+ }}
106
+ onToggle={toggle}
79
107
  theme={theme}
80
108
  />
81
109
  </ScrollArea>
@@ -0,0 +1,61 @@
1
+ /** The prompt dialog's footer actions are mouse-clickable: clicking "save" commits and "cancel"
2
+ * dismisses, the same as pressing enter or esc. */
3
+
4
+ import { test, expect } from "bun:test";
5
+ import React from "react";
6
+ import { testRender } from "@opentui/react/test-utils";
7
+ import { PromptDialog } from "./PromptDialog";
8
+ import { locateText, settle, typeText } from "../test-support";
9
+ import { DARK } from "../theme";
10
+
11
+ test("the input is focused on open, so typing lands in the dialog", async () => {
12
+ const inputs: string[] = [];
13
+ const setup = await testRender(
14
+ <PromptDialog
15
+ isOpen
16
+ title=" rename thread "
17
+ label="new title for this thread:"
18
+ value="hi"
19
+ onInput={(text) => inputs.push(text)}
20
+ theme={DARK}
21
+ />,
22
+ { width: 60, height: 12 },
23
+ );
24
+ await settle(setup);
25
+
26
+ await typeText(setup, "!");
27
+ expect(inputs.at(-1)).toBe("hi!");
28
+
29
+ setup.renderer.destroy();
30
+ });
31
+
32
+ test("clicking save fires onSave and clicking cancel fires onCancel", async () => {
33
+ let saved = 0;
34
+ let cancelled = 0;
35
+ const setup = await testRender(
36
+ <PromptDialog
37
+ isOpen
38
+ title=" rename thread "
39
+ label="new title for this thread:"
40
+ value="a title"
41
+ onInput={() => {}}
42
+ onSave={() => (saved += 1)}
43
+ onCancel={() => (cancelled += 1)}
44
+ theme={DARK}
45
+ />,
46
+ { width: 60, height: 12 },
47
+ );
48
+ await settle(setup);
49
+
50
+ const save = locateText(setup, "enter save");
51
+ await setup.mockMouse.click(save.column, save.row);
52
+ expect(saved).toBe(1);
53
+ expect(cancelled).toBe(0);
54
+
55
+ const cancel = locateText(setup, "esc cancel");
56
+ await setup.mockMouse.click(cancel.column, cancel.row);
57
+ expect(cancelled).toBe(1);
58
+ expect(saved).toBe(1);
59
+
60
+ setup.renderer.destroy();
61
+ });
@@ -22,6 +22,8 @@ export interface PromptDialogProps {
22
22
  value: string;
23
23
  placeholder?: string;
24
24
  onInput: (text: string) => void;
25
+ onSave?: () => void;
26
+ onCancel?: () => void;
25
27
  theme?: Theme;
26
28
  }
27
29
 
@@ -32,6 +34,8 @@ export function PromptDialog({
32
34
  value,
33
35
  placeholder,
34
36
  onInput,
37
+ onSave,
38
+ onCancel,
35
39
  theme,
36
40
  }: PromptDialogProps): React.ReactNode {
37
41
  const tokens = useComponentTheme(theme);
@@ -39,8 +43,9 @@ export function PromptDialog({
39
43
  const inputRef = useRef<TextareaRenderable | null>(null);
40
44
 
41
45
  useEffect(() => {
42
- // open with the caret after the seeded value, like a text field
43
- if (inputRef.current) inputRef.current.cursorOffset = value.length;
46
+ if (!inputRef.current) return;
47
+ inputRef.current.focus();
48
+ inputRef.current.cursorOffset = value.length;
44
49
  // eslint-disable-next-line react-hooks/exhaustive-deps
45
50
  }, []);
46
51
  if (!isOpen) return null;
@@ -52,6 +57,7 @@ export function PromptDialog({
52
57
  width={Math.min(54, terminalWidth - 6)}
53
58
  height={7}
54
59
  background={tokens.elevated}
60
+ onDismiss={onCancel}
55
61
  theme={theme}
56
62
  >
57
63
  <box
@@ -80,7 +86,14 @@ export function PromptDialog({
80
86
  }}
81
87
  />
82
88
  <box style={{ flexGrow: 1 }} />
83
- <text fg={tokens.textDim}>enter save · esc cancel</text>
89
+ <box style={{ flexDirection: "row" }}>
90
+ <box onMouseUp={onSave} style={{ marginRight: 3 }}>
91
+ <text fg={tokens.textDim}>enter save</text>
92
+ </box>
93
+ <box onMouseUp={onCancel}>
94
+ <text fg={tokens.textDim}>esc cancel</text>
95
+ </box>
96
+ </box>
84
97
  </box>
85
98
  </Dialog>
86
99
  );
@@ -11,6 +11,7 @@ function OverlayDemo(): React.ReactNode {
11
11
 
12
12
  useEffect(() => {
13
13
  setOverlay(
14
+ "demo",
14
15
  <box
15
16
  style={{
16
17
  position: "absolute",
@@ -1,13 +1,14 @@
1
1
  // A root overlay slot: OpenTUI has no z-index, so an overlay drawn inside a pane is sliced by the
2
2
  // next pane's border. Anything pushed here renders after every pane (last in draw order), so it sits
3
3
  // on top of the whole shell - the same trick the tooltip uses. Callers position their node with
4
- // absolute screen coordinates.
4
+ // absolute screen coordinates and hold a stable key, so several overlays (a menu, a hover preview)
5
+ // coexist instead of clobbering one shared slot.
5
6
 
6
7
  import React, { createContext, useContext, useMemo, useState } from "react";
7
8
 
8
9
  interface RootOverlayApi {
9
- setOverlay: (node: React.ReactNode) => void;
10
- clearOverlay: () => void;
10
+ setOverlay: (key: string, node: React.ReactNode) => void;
11
+ clearOverlay: (key: string) => void;
11
12
  }
12
13
 
13
14
  const RootOverlayContext = createContext<RootOverlayApi>({
@@ -20,16 +21,28 @@ export function useRootOverlay(): RootOverlayApi {
20
21
  }
21
22
 
22
23
  export function RootOverlayProvider({ children }: { children: React.ReactNode }): React.ReactNode {
23
- const [overlay, setOverlay] = useState<React.ReactNode>(null);
24
+ const [overlays, setOverlays] = useState<Map<string, React.ReactNode>>(new Map());
24
25
  const api = useMemo<RootOverlayApi>(
25
- () => ({ setOverlay, clearOverlay: () => setOverlay(null) }),
26
+ () => ({
27
+ setOverlay: (key, node) => setOverlays((current) => new Map(current).set(key, node)),
28
+ clearOverlay: (key) =>
29
+ setOverlays((current) => {
30
+ if (!current.has(key)) return current;
31
+ const next = new Map(current);
32
+ next.delete(key);
33
+
34
+ return next;
35
+ }),
36
+ }),
26
37
  [],
27
38
  );
28
39
 
29
40
  return (
30
41
  <RootOverlayContext.Provider value={api}>
31
42
  {children}
32
- {overlay}
43
+ {[...overlays].map(([key, node]) => (
44
+ <React.Fragment key={key}>{node}</React.Fragment>
45
+ ))}
33
46
  </RootOverlayContext.Provider>
34
47
  );
35
48
  }
@@ -10,7 +10,6 @@ const CATEGORIES: SettingsCategory[] = [
10
10
  {
11
11
  id: "general",
12
12
  name: "General",
13
- description: "identity and submission",
14
13
  rows: [
15
14
  { key: "displayName", label: "Display name", kind: "text" },
16
15
  {
@@ -24,7 +23,6 @@ const CATEGORIES: SettingsCategory[] = [
24
23
  {
25
24
  id: "display",
26
25
  name: "Display",
27
- description: "plan width and chrome",
28
26
  rows: [
29
27
  {
30
28
  key: "planWidth",
@@ -38,7 +36,6 @@ const CATEGORIES: SettingsCategory[] = [
38
36
  {
39
37
  id: "appearance",
40
38
  name: "Appearance",
41
- description: "the color theme",
42
39
  rows: [
43
40
  {
44
41
  key: "theme",
@@ -71,6 +68,7 @@ export const NavZone: Story = {
71
68
  activeZone="nav"
72
69
  onCategorySelect={() => {}}
73
70
  onRowActivate={() => {}}
71
+ onClose={() => {}}
74
72
  />
75
73
  ),
76
74
  expectedColors: [DARK.accent],
@@ -90,6 +88,7 @@ export const BodyZone: Story = {
90
88
  activeZone="body"
91
89
  onCategorySelect={() => {}}
92
90
  onRowActivate={() => {}}
91
+ onClose={() => {}}
93
92
  />
94
93
  ),
95
94
  expectedColors: [DARK.green],
@@ -109,6 +108,7 @@ export const AppearanceThemed: Story = {
109
108
  activeZone="body"
110
109
  onCategorySelect={() => {}}
111
110
  onRowActivate={() => {}}
111
+ onClose={() => {}}
112
112
  theme={THEME_PRESETS["rose-pine-moon"]}
113
113
  />
114
114
  ),
@@ -21,7 +21,6 @@ import { CycleRow, TextRow, ToggleRow, type SettingsRowDescriptor } from "./Sett
21
21
  export interface SettingsCategory {
22
22
  id: string;
23
23
  name: string;
24
- description: string;
25
24
  rows: SettingsRowDescriptor[];
26
25
  /** A bespoke body (e.g. the quick-actions editor) rendered instead of typed rows. */
27
26
  customBody?: React.ReactNode;
@@ -45,6 +44,7 @@ export interface SettingsDialogProps {
45
44
  activeZone: "nav" | "body";
46
45
  onCategorySelect: (categoryId: string) => void;
47
46
  onRowActivate: (row: SettingsRowDescriptor) => void;
47
+ onClose: () => void;
48
48
  theme?: Theme;
49
49
  }
50
50
 
@@ -88,6 +88,7 @@ export function SettingsDialog({
88
88
  activeZone,
89
89
  onCategorySelect,
90
90
  onRowActivate,
91
+ onClose,
91
92
  theme,
92
93
  }: SettingsDialogProps): React.ReactNode {
93
94
  const tokens = useComponentTheme(theme);
@@ -107,6 +108,7 @@ export function SettingsDialog({
107
108
  width={Math.min(76, terminalWidth - 6)}
108
109
  height={Math.min(22, terminalHeight - 4)}
109
110
  background={tokens.elevated}
111
+ onDismiss={onClose}
110
112
  theme={theme}
111
113
  >
112
114
  <box style={{ flexDirection: "row", flexGrow: 1 }}>
@@ -123,9 +125,6 @@ export function SettingsDialog({
123
125
  </box>
124
126
  <box style={{ borderStyle: "single", border: ["left"], borderColor: tokens.border }} />
125
127
  <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 1 }}>
126
- <text fg={tokens.text}>{category.name}</text>
127
- <text fg={tokens.textDim}>{category.description}</text>
128
- <box style={{ height: 1 }} />
129
128
  {onKeybinds ? <KeybindsBody sections={keybindsSections} tokens={tokens} /> : null}
130
129
  {onKeybinds ? null : category.customBody}
131
130
  {onKeybinds