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

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 (59) hide show
  1. package/package.json +5 -5
  2. package/src/App.focus.test.ts +147 -0
  3. package/src/App.regression.test.tsx +94 -0
  4. package/src/App.test.tsx +11 -8
  5. package/src/App.tsx +476 -220
  6. package/src/App.visual.test.tsx +57 -0
  7. package/src/App.workbench.test.tsx +41 -22
  8. package/src/app-screens.tsx +168 -123
  9. package/src/components/AppShell.brand.test.tsx +1 -1
  10. package/src/components/AppShell.tsx +14 -1
  11. package/src/components/ChangesColumn.tsx +40 -12
  12. package/src/components/ChangesFileTree.test.tsx +61 -0
  13. package/src/components/ConfirmDialog.tsx +8 -1
  14. package/src/components/DiffContentView.tsx +7 -1
  15. package/src/components/DiscussionMarkerRail.tsx +2 -2
  16. package/src/components/EditorGrid.tsx +84 -26
  17. package/src/components/GridTabContent.tsx +10 -1
  18. package/src/components/PanelColumn.tsx +3 -0
  19. package/src/components/ProjectTreeView.test.tsx +39 -12
  20. package/src/components/ProjectTreeView.tsx +38 -10
  21. package/src/components/PromptDialog.test.tsx +61 -0
  22. package/src/components/PromptDialog.tsx +16 -3
  23. package/src/components/RootOverlay.stories.tsx +1 -0
  24. package/src/components/RootOverlay.tsx +19 -6
  25. package/src/components/SettingsDialog.stories.tsx +3 -3
  26. package/src/components/SettingsDialog.tsx +3 -4
  27. package/src/components/ThreadFooter.test.tsx +12 -7
  28. package/src/components/ThreadFooter.tsx +18 -4
  29. package/src/components/ThreadTree.tsx +79 -36
  30. package/src/components/ThreadView.slash.test.tsx +36 -0
  31. package/src/components/ThreadView.tsx +7 -1
  32. package/src/components/WelcomePlayground.tsx +2 -0
  33. package/src/components/__snapshots__/stories.test.tsx.snap +60 -51
  34. package/src/components/menu-control.stories.tsx +35 -0
  35. package/src/components/menu-control.tsx +43 -0
  36. package/src/components/primitives/Dialog.test.tsx +37 -0
  37. package/src/components/primitives/Dialog.tsx +5 -0
  38. package/src/components/primitives/icons.ts +1 -1
  39. package/src/components/quick-actions-editor.stories.tsx +2 -0
  40. package/src/components/quick-actions-editor.test.tsx +35 -1
  41. package/src/components/quick-actions-editor.tsx +23 -22
  42. package/src/components/session-tree.test.ts +3 -3
  43. package/src/components/session-tree.ts +1 -1
  44. package/src/config.ts +1 -0
  45. package/src/diff-intraline.ts +26 -5
  46. package/src/perf/otlp-trace.test.ts +116 -0
  47. package/src/perf/otlp-trace.ts +130 -0
  48. package/src/perf/perf-report.ts +17 -0
  49. package/src/perf/perf-timings.test.ts +18 -0
  50. package/src/perf/perf-timings.ts +38 -0
  51. package/src/run.ts +22 -2
  52. package/src/thread-chords.test.ts +33 -1
  53. package/src/thread-chords.ts +114 -29
  54. package/src/thread-controller.ts +4 -3
  55. package/src/use-annotation-surface.tsx +22 -0
  56. package/src/use-frame-measure.ts +4 -1
  57. package/src/use-settings-dialog.tsx +9 -7
  58. package/src/visual/contrast.test.ts +35 -0
  59. package/src/visual/contrast.ts +33 -0
@@ -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
@@ -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,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import React, { useEffect, useRef } from "react";
16
- import type { ScrollBoxRenderable } from "@opentui/core";
16
+ import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
17
17
  import type { Thread } from "@cueloop/schema";
18
18
  import { displayText, type DisplayBlock, type Mark } from "../view-plan";
19
19
  import type { TextSpan } from "../thread-selection";
@@ -127,6 +127,8 @@ 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
+ leaderCombos?: readonly string[];
131
+ onLeaderCommand?: (key: KeyEvent) => void;
130
132
  onExit: () => void;
131
133
  theme?: Theme;
132
134
  }
@@ -148,6 +150,8 @@ export function ThreadView({
148
150
  onAnnotate,
149
151
  onReply,
150
152
  onUpdateAnnotation,
153
+ leaderCombos,
154
+ onLeaderCommand,
151
155
  onExit,
152
156
  theme,
153
157
  }: ThreadViewProps): React.ReactNode {
@@ -174,6 +178,8 @@ export function ThreadView({
174
178
  onAnnotate,
175
179
  onReply,
176
180
  onUpdateAnnotation,
181
+ leaderCombos,
182
+ onLeaderCommand,
177
183
  onExit,
178
184
  });
179
185
  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) => [