@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,34 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ShareChoiceDialog } from "./ShareChoiceDialog";
5
+
6
+ export const meta: StoryMeta = { title: "Dialogs/ShareChoice" };
7
+
8
+ export const PublicSelected: Story = {
9
+ render: () => (
10
+ <ShareChoiceDialog
11
+ isOpen
12
+ selectedIndex={0}
13
+ onPublicShare={() => {}}
14
+ onPrivateShare={() => {}}
15
+ onClose={() => {}}
16
+ theme={DARK}
17
+ />
18
+ ),
19
+ size: { width: 60, height: 12 },
20
+ };
21
+
22
+ export const PrivateSelected: Story = {
23
+ render: () => (
24
+ <ShareChoiceDialog
25
+ isOpen
26
+ selectedIndex={1}
27
+ onPublicShare={() => {}}
28
+ onPrivateShare={() => {}}
29
+ onClose={() => {}}
30
+ theme={DARK}
31
+ />
32
+ ),
33
+ size: { width: 60, height: 12 },
34
+ };
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import React from "react";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { DARK } from "../theme";
5
+ import { clickText, settle } from "../test-support";
6
+ import { ShareChoiceDialog } from "./ShareChoiceDialog";
7
+
8
+ interface ShareCounts {
9
+ publicShares: number;
10
+ privateShares: number;
11
+ closes: number;
12
+ }
13
+
14
+ async function renderDialog(selectedIndex = 0): Promise<{
15
+ setup: Awaited<ReturnType<typeof testRender>>;
16
+ counts: ShareCounts;
17
+ }> {
18
+ const counts: ShareCounts = { publicShares: 0, privateShares: 0, closes: 0 };
19
+ const setup = await testRender(
20
+ <box style={{ width: 60, height: 12 }}>
21
+ <ShareChoiceDialog
22
+ isOpen
23
+ selectedIndex={selectedIndex}
24
+ onPublicShare={() => {
25
+ counts.publicShares += 1;
26
+ }}
27
+ onPrivateShare={() => {
28
+ counts.privateShares += 1;
29
+ }}
30
+ onClose={() => {
31
+ counts.closes += 1;
32
+ }}
33
+ theme={DARK}
34
+ />
35
+ </box>,
36
+ { width: 60, height: 12 },
37
+ );
38
+
39
+ await settle(setup);
40
+
41
+ return { setup, counts };
42
+ }
43
+
44
+ describe("ShareChoiceDialog", () => {
45
+ test("offers both the public and private choice", async () => {
46
+ // Arrange + Act
47
+ const { setup } = await renderDialog();
48
+
49
+ // Assert
50
+ const frame = setup.captureCharFrame();
51
+
52
+ expect(frame).toContain("public link");
53
+ expect(frame).toContain("private link");
54
+ });
55
+
56
+ test("clicking the public choice publishes and closes", async () => {
57
+ // Arrange
58
+ const { setup, counts } = await renderDialog();
59
+
60
+ // Act
61
+ await clickText(setup, "public link");
62
+
63
+ // Assert
64
+ expect(counts.publicShares).toBe(1);
65
+ expect(counts.privateShares).toBe(0);
66
+ expect(counts.closes).toBe(1);
67
+ });
68
+
69
+ test("clicking the private choice opens manage-access and closes", async () => {
70
+ // Arrange
71
+ const { setup, counts } = await renderDialog();
72
+
73
+ // Act
74
+ await clickText(setup, "private link");
75
+
76
+ // Assert
77
+ expect(counts.privateShares).toBe(1);
78
+ expect(counts.publicShares).toBe(0);
79
+ expect(counts.closes).toBe(1);
80
+ });
81
+
82
+ test("renders nothing while closed", async () => {
83
+ // Arrange + Act
84
+ const setup = await testRender(
85
+ <box style={{ width: 60, height: 12 }}>
86
+ <ShareChoiceDialog
87
+ isOpen={false}
88
+ selectedIndex={0}
89
+ onPublicShare={() => {}}
90
+ onPrivateShare={() => {}}
91
+ onClose={() => {}}
92
+ theme={DARK}
93
+ />
94
+ </box>,
95
+ { width: 60, height: 12 },
96
+ );
97
+
98
+ await settle(setup);
99
+
100
+ // Assert
101
+ expect(setup.captureCharFrame()).not.toContain("public link");
102
+ });
103
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The share public/private choice, as an app-level overlay the share shortcut
3
+ * opens from any view. Public publishes a link for anyone; private opens the
4
+ * manage-access allowlist surface. The header share button and the shortcut both
5
+ * open this one surface. Selection moves with the keyboard (owned by the App) or
6
+ * a pointer click on a row.
7
+ */
8
+
9
+ import React from "react";
10
+ import { useTerminalDimensions } from "@opentui/react";
11
+ import type { Theme } from "../theme";
12
+ import { useComponentTheme } from "./theme-context";
13
+ import { Dialog } from "./primitives/Dialog";
14
+
15
+ export type ShareChoice = "public" | "private";
16
+
17
+ interface ShareChoiceRow {
18
+ choice: ShareChoice;
19
+ label: string;
20
+ }
21
+
22
+ export const SHARE_CHOICES: ShareChoiceRow[] = [
23
+ { choice: "public", label: "public link" },
24
+ { choice: "private", label: "private link" },
25
+ ];
26
+
27
+ export interface ShareChoiceDialogProps {
28
+ isOpen: boolean;
29
+ /** The highlighted row; the App drives it from the shareChoice mode. */
30
+ selectedIndex: number;
31
+ /** Publish a public link and copy its connection line. */
32
+ onPublicShare: () => void;
33
+ /** Open the private-share manage-access allowlist surface. */
34
+ onPrivateShare: () => void;
35
+ onClose: () => void;
36
+ theme?: Theme;
37
+ }
38
+
39
+ export function ShareChoiceDialog({
40
+ isOpen,
41
+ selectedIndex,
42
+ onPublicShare,
43
+ onPrivateShare,
44
+ onClose,
45
+ theme,
46
+ }: ShareChoiceDialogProps): React.ReactNode {
47
+ const tokens = useComponentTheme(theme);
48
+ const { width: terminalWidth } = useTerminalDimensions();
49
+
50
+ const pick = (choice: ShareChoice): void => {
51
+ onClose();
52
+ if (choice === "public") onPublicShare();
53
+ else onPrivateShare();
54
+ };
55
+
56
+ if (!isOpen) return null;
57
+
58
+ return (
59
+ <Dialog
60
+ isOpen
61
+ title=" Share "
62
+ width={Math.min(46, terminalWidth - 6)}
63
+ height={7}
64
+ background={tokens.elevated}
65
+ onDismiss={onClose}
66
+ theme={theme}
67
+ >
68
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}>
69
+ {SHARE_CHOICES.map((row, index) => (
70
+ <box key={row.choice} onMouseUp={() => pick(row.choice)} style={{ flexDirection: "row" }}>
71
+ <text fg={index === selectedIndex ? tokens.accent : tokens.text}>
72
+ {index === selectedIndex ? "› " : " "}
73
+ {row.label}
74
+ </text>
75
+ </box>
76
+ ))}
77
+ <box style={{ flexGrow: 1 }} />
78
+ <text fg={tokens.textDim}>↑↓ choose · enter select · esc cancel</text>
79
+ </box>
80
+ </Dialog>
81
+ );
82
+ }
@@ -1,10 +1,11 @@
1
- /** The thread footer keeps its repo/branch context on one line: a long branch truncates to fit rather
2
- * than wrapping and pushing the send control onto a second row. */
1
+ /** The thread footer keeps its repo/branch context on one line: a long branch truncates to an
2
+ * ellipsis rather than wrapping and pushing the send control onto a second row. */
3
3
 
4
4
  import { test, expect } from "bun:test";
5
5
  import React from "react";
6
6
  import { testRender } from "@opentui/react/test-utils";
7
7
  import { ThreadFooter } from "./ThreadFooter";
8
+ import { settle } from "../test-support";
8
9
  import { DARK } from "../theme";
9
10
 
10
11
  test("a long branch truncates on one line and keeps the send control in place", async () => {
@@ -20,12 +21,16 @@ test("a long branch truncates on one line and keeps the send control in place",
20
21
  </box>,
21
22
  { width: 60, height: 3 },
22
23
  );
23
- await setup.renderOnce();
24
+ await settle(setup);
25
+ await settle(setup);
24
26
 
25
- const lines = setup.captureCharFrame().split("\n");
26
- const footerRow = lines.find((line) => line.includes("cueloop"))!;
27
+ const frame = setup.captureCharFrame();
28
+ const footerRow = frame.split("\n").find((line) => line.includes("cueloop"))!;
27
29
  // the repo/branch context and the send control share the single footer row
28
30
  expect(footerRow).toContain("send message");
29
- // the branch is clipped, not wrapped: it never reaches its tail on a second row
30
- expect(setup.captureCharFrame()).not.toContain("long-name");
31
+ // the branch is clipped to an ellipsis, not wrapped onto a second row
32
+ expect(footerRow).toContain("…");
33
+ expect(frame).not.toContain("long-name");
34
+
35
+ setup.renderer.destroy();
31
36
  });
@@ -2,8 +2,11 @@
2
2
  // Changes column when a zoom drops the thread column. It carries the repo/branch
3
3
  // context on the left and a written, clickable "Send message" control on the right.
4
4
 
5
- import React from "react";
5
+ import React, { useRef } from "react";
6
+ import type { BoxRenderable } from "@opentui/core";
6
7
  import { DARK, type Theme } from "../theme";
8
+ import { useFrameMeasure } from "../use-frame-measure";
9
+ import { truncateTitle } from "./truncate-title";
7
10
  import { NERD } from "./primitives/icons";
8
11
 
9
12
  export interface ThreadFooterProps {
@@ -15,6 +18,9 @@ export interface ThreadFooterProps {
15
18
  theme?: Theme;
16
19
  }
17
20
 
21
+ const ICON_COLUMNS = 2; // folder glyph + its trailing space
22
+ const SEPARATOR = " / ";
23
+
18
24
  export function ThreadFooter({
19
25
  repo,
20
26
  branch,
@@ -23,6 +29,14 @@ export function ThreadFooter({
23
29
  theme,
24
30
  }: ThreadFooterProps): React.ReactNode {
25
31
  const tokens = theme ?? DARK;
32
+ const boxRef = useRef<BoxRenderable | null>(null);
33
+ const width = useFrameMeasure(
34
+ () => boxRef.current?.width ?? 0,
35
+ (left, right) => left === right,
36
+ 0,
37
+ );
38
+ const branchBudget = width - ICON_COLUMNS - repo.length - SEPARATOR.length;
39
+ const clippedBranch = width > 0 ? truncateTitle(branch, Math.max(0, branchBudget)) : branch;
26
40
 
27
41
  return (
28
42
  <box
@@ -38,11 +52,11 @@ export function ThreadFooter({
38
52
  borderColor: tokens.border,
39
53
  }}
40
54
  >
41
- <box style={{ flexShrink: 1, minWidth: 0, marginRight: 2 }}>
42
- <text truncate>
55
+ <box ref={boxRef} style={{ flexShrink: 1, minWidth: 0, marginRight: 2 }}>
56
+ <text wrapMode="none">
43
57
  <span fg={tokens.blue}>{`${NERD.folderClosed} `}</span>
44
58
  <span fg={tokens.textMuted}>{repo}</span>
45
- <span fg={tokens.textDim}>{` / ${branch}`}</span>
59
+ <span fg={tokens.textDim}>{`${SEPARATOR}${clippedBranch}`}</span>
46
60
  </text>
47
61
  </box>
48
62
  <box style={{ flexGrow: 1 }} />
@@ -1,14 +1,19 @@
1
1
  /**
2
- * The sidebar thread tree: a Pinned / Projects / Threads listing. Selection stays
2
+ * The sidebar thread tree: a Starred / Projects / Threads listing. Selection stays
3
3
  * with the keyboard grammar - the cursor indexes the flat thread order and this
4
4
  * component renders the snapshot. A hovered or selected thread reveals a kebab
5
- * that opens an inline pin / rename / delete menu; a long title clips to an
6
- * ellipsis rather than wrapping. App supplies the surrounding chrome.
5
+ * that opens a floating pin / rename / delete menu below the row (so the list
6
+ * never shifts); a long title clips to an ellipsis rather than wrapping. App
7
+ * supplies the surrounding chrome.
7
8
  */
8
9
 
9
- import React, { useState } from "react";
10
+ import React, { useEffect, useRef, useState } from "react";
11
+ import type { BoxRenderable } from "@opentui/core";
10
12
  import type { Theme } from "../theme";
11
13
  import { useComponentTheme } from "./theme-context";
14
+ import { useFrameMeasure } from "../use-frame-measure";
15
+ import { useRootOverlay } from "./RootOverlay";
16
+ import { useMenuControl } from "./menu-control";
12
17
  import { IconButton } from "./primitives/IconButton";
13
18
  import { NERD } from "./primitives/icons";
14
19
  import { truncateTitle } from "./truncate-title";
@@ -19,7 +24,8 @@ export interface ThreadTreeProps {
19
24
  cursor: number;
20
25
  /** The open thread's id; highlights it instead of the cursor (the left sidebar case). */
21
26
  activeId?: string;
22
- /** Ids of pinned threads; a pinned row carries the pin glyph and the menu offers Unpin. */
27
+ focused?: boolean;
28
+ /** Ids of starred threads; a starred row carries the star glyph and the menu offers Unstar. */
23
29
  pinnedIds?: ReadonlySet<string>;
24
30
  /** The column width, so titles fade to fit one line. */
25
31
  width?: number;
@@ -27,13 +33,15 @@ export interface ThreadTreeProps {
27
33
  onSelect?: (sessionId: string) => void;
28
34
  /** Ask to delete a thread (the menu's Delete, wired to the confirm dialog). */
29
35
  onRequestDelete?: (id: string, title: string) => void;
30
- /** Toggle a thread's pinned state (the menu's Pin/Unpin). */
36
+ /** Toggle a thread's starred state (the menu's Star/Unstar). */
31
37
  onPin?: (id: string) => void;
32
38
  /** Rename a thread's title (the menu's Rename). */
33
39
  onRename?: (id: string, title: string) => void;
34
40
  theme?: Theme;
35
41
  }
36
42
 
43
+ const MENU_WIDTH = 12;
44
+
37
45
  function ActionsMenu({
38
46
  pinned,
39
47
  onPin,
@@ -47,31 +55,28 @@ function ActionsMenu({
47
55
  onDelete?: () => void;
48
56
  tokens: Theme;
49
57
  }): React.ReactNode {
50
- const item = (
51
- glyph: string,
52
- label: string,
53
- color: string,
54
- onPick?: () => void,
55
- ): React.ReactNode =>
58
+ const item = (label: string, color: string, onPick?: () => void): React.ReactNode =>
56
59
  onPick !== undefined ? (
57
- <box onMouseUp={onPick} style={{ flexDirection: "row", paddingLeft: 4, paddingRight: 1 }}>
58
- <text fg={color}>{`${glyph} ${label}`}</text>
60
+ <box onMouseUp={onPick} style={{ paddingLeft: 1, paddingRight: 1 }}>
61
+ <text fg={color}>{label}</text>
59
62
  </box>
60
63
  ) : null;
61
64
 
62
65
  return (
63
66
  <box
67
+ onMouseUp={(event) => event.stopPropagation()}
64
68
  style={{
69
+ width: MENU_WIDTH,
65
70
  flexDirection: "column",
66
- marginLeft: 3,
71
+ border: true,
67
72
  borderStyle: "single",
68
- border: ["left"],
69
73
  borderColor: tokens.border,
74
+ backgroundColor: tokens.elevated,
70
75
  }}
71
76
  >
72
- {item(NERD.pin, pinned ? "Unpin" : "Pin", tokens.text, onPin)}
73
- {item(NERD.file, "Rename", tokens.text, onRename)}
74
- {item(NERD.close, "Delete", tokens.red, onDelete)}
77
+ {item(pinned ? "unstar" : "star", tokens.text, onPin)}
78
+ {item("rename", tokens.text, onRename)}
79
+ {item("delete", tokens.red, onDelete)}
75
80
  </box>
76
81
  );
77
82
  }
@@ -94,13 +99,57 @@ interface ThreadRowProps {
94
99
  function ThreadRow(props: ThreadRowProps): React.ReactNode {
95
100
  const { title, selected, pinned, titleWidth, menuOpen, onToggleMenu, tokens, theme } = props;
96
101
  const [hovered, setHovered] = useState(false);
102
+ const rowRef = useRef<BoxRenderable | null>(null);
103
+ const { setOverlay, clearOverlay } = useRootOverlay();
104
+ const anchor = useFrameMeasure(
105
+ () => ({
106
+ x: rowRef.current?.x ?? 0,
107
+ y: rowRef.current?.y ?? 0,
108
+ width: rowRef.current?.width ?? 0,
109
+ }),
110
+ (left, right) => left.x === right.x && left.y === right.y && left.width === right.width,
111
+ { x: 0, y: 0, width: 0 },
112
+ menuOpen,
113
+ );
97
114
  const clippedTitle = truncateTitle(title, titleWidth);
98
115
  const hasActions =
99
116
  props.onPin !== undefined || props.onRename !== undefined || props.onDelete !== undefined;
100
117
 
118
+ useEffect(() => {
119
+ if (!menuOpen) return;
120
+ // the full-screen box closes the menu on an outside click; the menu drops from under the kebab
121
+ setOverlay(
122
+ "thread-menu",
123
+ <box
124
+ onMouseUp={onToggleMenu}
125
+ style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%" }}
126
+ >
127
+ <box
128
+ style={{
129
+ position: "absolute",
130
+ top: anchor.y + 1,
131
+ left: Math.max(0, anchor.x + anchor.width - MENU_WIDTH),
132
+ }}
133
+ >
134
+ <ActionsMenu
135
+ pinned={pinned}
136
+ onPin={props.onPin}
137
+ onRename={props.onRename}
138
+ onDelete={props.onDelete}
139
+ tokens={tokens}
140
+ />
141
+ </box>
142
+ </box>,
143
+ );
144
+
145
+ return () => clearOverlay("thread-menu");
146
+ // eslint-disable-next-line react-hooks/exhaustive-deps
147
+ }, [menuOpen, anchor, pinned, tokens]);
148
+
101
149
  return (
102
150
  <box style={{ flexDirection: "column" }} onMouseOut={() => setHovered(false)}>
103
151
  <box
152
+ ref={rowRef}
104
153
  onMouseUp={props.onSelect}
105
154
  onMouseOver={() => setHovered(true)}
106
155
  style={{
@@ -109,7 +158,7 @@ function ThreadRow(props: ThreadRowProps): React.ReactNode {
109
158
  }}
110
159
  >
111
160
  <text wrapMode="none">
112
- <span fg={tokens.textDim}>{pinned ? ` ${NERD.pin} ` : " "}</span>
161
+ <span fg={tokens.textDim}>{pinned ? ` ${NERD.star} ` : " "}</span>
113
162
  <span fg={selected ? tokens.accent : tokens.textMuted}>{clippedTitle}</span>
114
163
  </text>
115
164
  <box style={{ flexGrow: 1 }} />
@@ -123,15 +172,6 @@ function ThreadRow(props: ThreadRowProps): React.ReactNode {
123
172
  />
124
173
  ) : null}
125
174
  </box>
126
- {menuOpen ? (
127
- <ActionsMenu
128
- pinned={pinned}
129
- onPin={props.onPin}
130
- onRename={props.onRename}
131
- onDelete={props.onDelete}
132
- tokens={tokens}
133
- />
134
- ) : null}
135
175
  </box>
136
176
  );
137
177
  }
@@ -140,6 +180,7 @@ export function ThreadTree({
140
180
  rows,
141
181
  cursor,
142
182
  activeId,
183
+ focused = false,
143
184
  pinnedIds,
144
185
  width = 30,
145
186
  onSelect,
@@ -149,21 +190,21 @@ export function ThreadTree({
149
190
  theme,
150
191
  }: ThreadTreeProps): React.ReactNode {
151
192
  const tokens = useComponentTheme(theme);
152
- const [actionsForId, setActionsForId] = useState<string | null>(null);
193
+ const menuControl = useMenuControl();
153
194
  // leave room for the 3-char prefix, the border, and the hover kebab so a row
154
195
  // never wraps: the title fades to fit whatever is left
155
196
  const titleWidth = Math.max(8, width - 9);
156
- const closeMenu = (): void => setActionsForId(null);
197
+ const closeMenu = (): void => menuControl.closeMenu();
157
198
 
158
199
  return (
159
200
  <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1 }}>
160
201
  {rows.length === 0 ? (
161
202
  <text fg={tokens.textDim}>no threads</text>
162
203
  ) : (
163
- rows.map((row) => {
204
+ rows.map((row, index) => {
164
205
  if (row.kind === "section") {
165
206
  return (
166
- <text key={row.id} fg={tokens.textDim}>
207
+ <text key={row.id} fg={tokens.textDim} style={{ marginTop: index > 0 ? 1 : 0 }}>
167
208
  {row.label}
168
209
  </text>
169
210
  );
@@ -185,11 +226,13 @@ export function ThreadTree({
185
226
  <ThreadRow
186
227
  key={row.id}
187
228
  title={title}
188
- selected={activeId !== undefined ? activeId === id : row.selectionIndex === cursor}
229
+ selected={
230
+ focused || activeId === undefined ? row.selectionIndex === cursor : activeId === id
231
+ }
189
232
  pinned={pinnedIds?.has(id) ?? false}
190
233
  titleWidth={titleWidth}
191
- menuOpen={actionsForId === id}
192
- onToggleMenu={() => setActionsForId(actionsForId === id ? null : id)}
234
+ menuOpen={menuControl.openMenuId === `thread:${id}`}
235
+ onToggleMenu={() => menuControl.toggleMenu(`thread:${id}`)}
193
236
  onSelect={onSelect ? () => onSelect(id) : undefined}
194
237
  onPin={onPin ? () => (onPin(id), closeMenu()) : undefined}
195
238
  onRename={onRename ? () => (onRename(id, title), closeMenu()) : undefined}
@@ -60,6 +60,42 @@ async function mountComposerWithSkills(skills: SlashItem[] = [], width = 72) {
60
60
 
61
61
  const mountComposer = () => mountComposerWithSkills();
62
62
 
63
+ describe("the command leader", () => {
64
+ test("the leader chord then a letter runs a command instead of composing a comment", async () => {
65
+ const commands: string[] = [];
66
+ const display = buildDisplay(PLAN, undefined);
67
+ const setup = await testRender(
68
+ <ThreadView
69
+ session={fixturePlanSession({
70
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
71
+ })}
72
+ display={display}
73
+ marks={marksByDisplay([], display)}
74
+ quickActions={[]}
75
+ observer={false}
76
+ leaderCombos={["ctrl+g"]}
77
+ onLeaderCommand={(key) => commands.push(key.name)}
78
+ onAnnotate={() => {}}
79
+ onReply={() => {}}
80
+ onUpdateAnnotation={() => {}}
81
+ onExit={() => {}}
82
+ />,
83
+ { width: 72, height: 20 },
84
+ );
85
+ await settle(setup);
86
+
87
+ setup.mockInput.pressKey("g", { ctrl: true });
88
+ setup.mockInput.pressKey("x");
89
+ await settle(setup);
90
+
91
+ // the command fired and no composer opened (a bare "x" would have started a draft)
92
+ expect(commands).toEqual(["x"]);
93
+ expect(setup.captureCharFrame()).not.toContain("enter save");
94
+
95
+ setup.renderer.destroy();
96
+ });
97
+ });
98
+
63
99
  describe("skills in the palette", () => {
64
100
  test("a skill from context lists in the / palette and a pick inserts its /name", async () => {
65
101
  const setup = await mountComposerWithSkills([
@@ -13,8 +13,8 @@
13
13
  */
14
14
 
15
15
  import React, { useEffect, useRef } from "react";
16
- import type { ScrollBoxRenderable } from "@opentui/core";
17
- import type { Thread } from "@cueloop/schema";
16
+ import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
17
+ import type { Annotation, Thread } from "@cueloop/schema";
18
18
  import { displayText, type DisplayBlock, type Mark } from "../view-plan";
19
19
  import type { TextSpan } from "../thread-selection";
20
20
  import type { QuickAction } from "../config";
@@ -127,6 +127,10 @@ export interface ThreadViewProps {
127
127
  onAnnotate: (span: TextSpan, body: string) => void;
128
128
  onReply: (rootAnnotationId: string, body: string) => void;
129
129
  onUpdateAnnotation: (id: string, body: string) => void;
130
+ /** The author's display name for a comment's hover tooltip. */
131
+ resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
132
+ leaderCombos?: readonly string[];
133
+ onLeaderCommand?: (key: KeyEvent) => void;
130
134
  onExit: () => void;
131
135
  theme?: Theme;
132
136
  }
@@ -148,6 +152,9 @@ export function ThreadView({
148
152
  onAnnotate,
149
153
  onReply,
150
154
  onUpdateAnnotation,
155
+ resolveAuthorLabel,
156
+ leaderCombos,
157
+ onLeaderCommand,
151
158
  onExit,
152
159
  theme,
153
160
  }: ThreadViewProps): React.ReactNode {
@@ -174,6 +181,9 @@ export function ThreadView({
174
181
  onAnnotate,
175
182
  onReply,
176
183
  onUpdateAnnotation,
184
+ resolveAuthorLabel,
185
+ leaderCombos,
186
+ onLeaderCommand,
177
187
  onExit,
178
188
  });
179
189
  const { palette, discussions } = surface;
@@ -22,6 +22,7 @@ export interface WelcomePlaygroundProps {
22
22
  quickActions: QuickAction[];
23
23
  /** Reports whether the playground composer is open, so the shell suspends its inbox keys while typing. */
24
24
  onComposingChange?: (composing: boolean) => void;
25
+ suspended?: boolean;
25
26
  theme?: Theme;
26
27
  }
27
28
 
@@ -86,6 +87,7 @@ export function WelcomePlayground(props: WelcomePlaygroundProps): React.ReactNod
86
87
  observer={false}
87
88
  commentsEnabled
88
89
  fileView
90
+ suspended={props.suspended}
89
91
  onComposingChange={props.onComposingChange}
90
92
  onAnnotate={(span, body) =>
91
93
  setAnnotations((current) => [