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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +79 -112
  4. package/src/App.plan-review.test.tsx +23 -205
  5. package/src/App.prototype.test.tsx +1 -8
  6. package/src/App.readonly.test.tsx +9 -27
  7. package/src/App.submit-confirm.test.tsx +19 -72
  8. package/src/App.test.tsx +136 -31
  9. package/src/App.tsx +491 -322
  10. package/src/App.walk.test.tsx +15 -16
  11. package/src/App.workbench.test.tsx +390 -0
  12. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  13. package/src/annotation-palette.ts +61 -0
  14. package/src/app-screens.tsx +197 -77
  15. package/src/app-view-model.ts +9 -69
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +1 -1
  18. package/src/components/AnnotationCards.stories.tsx +87 -0
  19. package/src/components/AnnotationCards.tsx +303 -0
  20. package/src/components/AppHeader.stories.tsx +50 -0
  21. package/src/components/AppHeader.tsx +83 -0
  22. package/src/components/AppShell.stories.tsx +89 -0
  23. package/src/components/AppShell.tsx +239 -0
  24. package/src/components/AppShell.zoom.test.tsx +94 -0
  25. package/src/components/Breadcrumb.stories.tsx +1 -1
  26. package/src/components/ChangesColumn.stories.tsx +39 -0
  27. package/src/components/ChangesColumn.tsx +170 -0
  28. package/src/components/CodeBlock.stories.tsx +1 -1
  29. package/src/components/CompletionOverlay.stories.tsx +1 -1
  30. package/src/components/ConfirmCard.stories.tsx +1 -1
  31. package/src/components/ConfirmCard.tsx +3 -4
  32. package/src/components/ConfirmDialog.stories.tsx +1 -1
  33. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  34. package/src/components/DiffSheet.split.test.tsx +140 -0
  35. package/src/components/DiffSheet.stories.tsx +56 -54
  36. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  37. package/src/components/DiffSheet.tsx +607 -276
  38. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  39. package/src/components/DiscussionMarkerRail.tsx +181 -0
  40. package/src/components/EditorGrid.stories.tsx +59 -0
  41. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  42. package/src/components/EditorGrid.tsx +338 -0
  43. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  44. package/src/components/FileContentsView.stories.tsx +16 -0
  45. package/src/components/FileContentsView.test.tsx +29 -0
  46. package/src/components/FileContentsView.tsx +78 -0
  47. package/src/components/InboxList.stories.tsx +23 -8
  48. package/src/components/InboxList.tsx +188 -33
  49. package/src/components/KeybindsDialog.stories.tsx +1 -1
  50. package/src/components/MarkerPopover.stories.tsx +1 -1
  51. package/src/components/MenuBar.stories.tsx +3 -12
  52. package/src/components/MenuBar.tsx +25 -56
  53. package/src/components/PaneGrid.stories.tsx +84 -0
  54. package/src/components/PaneGrid.tsx +128 -0
  55. package/src/components/PanelColumn.stories.tsx +25 -0
  56. package/src/components/PanelColumn.tsx +95 -0
  57. package/src/components/ProjectTreeView.stories.tsx +25 -0
  58. package/src/components/ProjectTreeView.test.tsx +32 -0
  59. package/src/components/ProjectTreeView.tsx +82 -0
  60. package/src/components/PromptDialog.stories.tsx +1 -1
  61. package/src/components/PromptDialog.tsx +8 -1
  62. package/src/components/PrototypeSheet.stories.tsx +1 -1
  63. package/src/components/RootOverlay.stories.tsx +44 -0
  64. package/src/components/RootOverlay.tsx +35 -0
  65. package/src/components/SettingsDialog.stories.tsx +7 -1
  66. package/src/components/SettingsDialog.tsx +114 -67
  67. package/src/components/SettingsRows.stories.tsx +1 -1
  68. package/src/components/ShellHeader.stories.tsx +50 -0
  69. package/src/components/ShellHeader.tsx +137 -0
  70. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  71. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  72. package/src/components/SurfaceScrollbar.tsx +93 -0
  73. package/src/components/ThreadFooter.stories.tsx +30 -0
  74. package/src/components/ThreadFooter.test.tsx +31 -0
  75. package/src/components/ThreadFooter.tsx +54 -0
  76. package/src/components/ThreadView.slash.test.tsx +113 -0
  77. package/src/components/ThreadView.stories.tsx +1 -1
  78. package/src/components/ThreadView.tsx +62 -1352
  79. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  80. package/src/components/ThreadsSidebar.tsx +70 -0
  81. package/src/components/Toast.stories.tsx +1 -1
  82. package/src/components/Tooltip.stories.tsx +26 -0
  83. package/src/components/Tooltip.tsx +72 -0
  84. package/src/components/TreePane.stories.tsx +1 -1
  85. package/src/components/WalkWizard.stories.tsx +1 -1
  86. package/src/components/WelcomeSurface.stories.tsx +12 -0
  87. package/src/components/WelcomeSurface.tsx +52 -0
  88. package/src/components/__snapshots__/stories.test.tsx.snap +732 -572
  89. package/src/components/agent-launcher.stories.tsx +1 -1
  90. package/src/components/diff-sheet-layout.test.ts +125 -55
  91. package/src/components/diff-sheet-layout.ts +3 -103
  92. package/src/components/editor-grid.test.ts +86 -0
  93. package/src/components/editor-grid.ts +138 -0
  94. package/src/components/fade-title.test.ts +49 -0
  95. package/src/components/fade-title.ts +61 -0
  96. package/src/components/file-tree.test.ts +60 -0
  97. package/src/components/file-tree.ts +112 -0
  98. package/src/components/pages/Workspace.stories.tsx +181 -0
  99. package/src/components/primitives/Button.stories.tsx +1 -1
  100. package/src/components/primitives/Card.stories.tsx +1 -1
  101. package/src/components/primitives/Dialog.stories.tsx +1 -1
  102. package/src/components/primitives/IconButton.stories.tsx +36 -0
  103. package/src/components/primitives/IconButton.tsx +62 -0
  104. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  105. package/src/components/primitives/Tabs.stories.tsx +1 -1
  106. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  107. package/src/components/primitives/Tree.stories.tsx +124 -0
  108. package/src/components/primitives/Tree.tsx +96 -0
  109. package/src/components/primitives/icons.ts +61 -0
  110. package/src/components/primitives/tree-model.test.ts +99 -0
  111. package/src/components/primitives/tree-model.ts +137 -0
  112. package/src/components/quick-actions-editor.stories.tsx +1 -1
  113. package/src/components/session-tree.test.ts +134 -0
  114. package/src/components/session-tree.ts +108 -0
  115. package/src/components/stories-app.tsx +142 -82
  116. package/src/components/story-tree.test.ts +100 -0
  117. package/src/components/story.ts +55 -0
  118. package/src/components/theme-context.stories.tsx +1 -1
  119. package/src/config.test.ts +20 -49
  120. package/src/config.ts +26 -28
  121. package/src/diff-fold.test.ts +78 -0
  122. package/src/diff-fold.ts +126 -0
  123. package/src/diff-syntax.test.ts +16 -0
  124. package/src/diff-syntax.ts +6 -3
  125. package/src/discussions.ts +79 -0
  126. package/src/intent-dispatch.test.ts +31 -11
  127. package/src/intent-dispatch.ts +93 -48
  128. package/src/key-bindings.test.ts +1 -1
  129. package/src/keymap.test.ts +3 -25
  130. package/src/keymap.ts +17 -18
  131. package/src/mark-runs.ts +120 -0
  132. package/src/pty.ts +10 -2
  133. package/src/run.ts +17 -7
  134. package/src/serve.test.ts +4 -8
  135. package/src/session-controller.curate.test.ts +75 -0
  136. package/src/session-controller.share.test.ts +1 -0
  137. package/src/session-controller.tree.test.ts +26 -0
  138. package/src/session-controller.ts +160 -31
  139. package/src/slash-palette.ts +83 -0
  140. package/src/split-diff.test.ts +110 -0
  141. package/src/split-diff.ts +149 -0
  142. package/src/test-support.test.ts +3 -3
  143. package/src/test-support.ts +39 -8
  144. package/src/theme.ts +4 -4
  145. package/src/thread-chords.test.ts +25 -9
  146. package/src/thread-chords.ts +33 -8
  147. package/src/use-annotation-surface.tsx +837 -0
  148. package/src/use-changes-workbench.ts +151 -0
  149. package/src/use-frame-measure.ts +38 -0
  150. package/src/use-settings-dialog.tsx +23 -24
  151. package/src/use-terminal-virtualizer.ts +233 -0
  152. package/src/view-diff.ts +114 -0
  153. package/src/App.review-panel.test.tsx +0 -140
  154. package/src/App.tree.test.tsx +0 -224
  155. package/src/components/ReviewPanel.stories.tsx +0 -100
  156. package/src/components/ReviewPanel.tsx +0 -103
  157. package/src/components/ReviewRail.stories.tsx +0 -232
  158. package/src/components/ReviewRail.tsx +0 -327
  159. package/src/review-panel.test.ts +0 -113
  160. package/src/review-panel.ts +0 -83
@@ -0,0 +1,50 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ShellHeader } from "./ShellHeader";
5
+ import { NERD } from "./primitives/icons";
6
+
7
+ export const meta: StoryMeta = { title: "Layout/ShellHeader" };
8
+
9
+ function Frame({ children }: { children: React.ReactNode }): React.ReactNode {
10
+ return <box style={{ width: "100%", backgroundColor: DARK.panel }}>{children}</box>;
11
+ }
12
+
13
+ export const ReviewSurface: Story = {
14
+ render: () => (
15
+ <Frame>
16
+ <ShellHeader
17
+ leftIcons={[{ glyph: NERD.settings }]}
18
+ leftLabel="Read Cueloop Repository"
19
+ tabs={[{ label: "Changes", active: true }]}
20
+ rightIcons={[
21
+ { glyph: NERD.expand },
22
+ { glyph: NERD.diff, active: true },
23
+ { glyph: NERD.listTree },
24
+ ]}
25
+ onToggleSidebar={() => {}}
26
+ onToggleInspector={() => {}}
27
+ />
28
+ </Frame>
29
+ ),
30
+ expectedColors: [DARK.accent, DARK.textMuted],
31
+ size: { width: 120, height: 3 },
32
+ };
33
+
34
+ export const InspectorCollapsed: Story = {
35
+ render: () => (
36
+ <Frame>
37
+ <ShellHeader
38
+ leftIcons={[{ glyph: NERD.settings }]}
39
+ leftLabel="Read Cueloop Repository"
40
+ rightIcons={[{ glyph: NERD.search }, { glyph: NERD.expand }]}
41
+ onToggleSidebar={() => {}}
42
+ sidebarOpen={false}
43
+ onToggleInspector={() => {}}
44
+ inspectorOpen={false}
45
+ />
46
+ </Frame>
47
+ ),
48
+ expectedColors: [DARK.textMuted],
49
+ size: { width: 120, height: 3 },
50
+ };
@@ -0,0 +1,137 @@
1
+ // The app shell's top bar: left panel toggle + icons + label, an optional title, right icons + panel toggle.
2
+
3
+ import React from "react";
4
+ import { DARK, type Theme } from "../theme";
5
+ import { NERD } from "./primitives/icons";
6
+
7
+ export interface ShellTab {
8
+ label: string;
9
+ active?: boolean;
10
+ }
11
+
12
+ // A header icon; active paints it in the brand accent to mark the current view or mode.
13
+ export interface ShellIcon {
14
+ glyph: string;
15
+ active?: boolean;
16
+ onPress?: () => void;
17
+ }
18
+
19
+ export interface ShellHeaderProps {
20
+ leftIcons?: readonly ShellIcon[];
21
+ leftLabel?: string;
22
+ titleIcon?: string;
23
+ title?: string;
24
+ tabs?: readonly ShellTab[];
25
+ rightIcons?: readonly ShellIcon[];
26
+ sidebarOpen?: boolean;
27
+ onToggleSidebar?: () => void;
28
+ inspectorOpen?: boolean;
29
+ onToggleInspector?: () => void;
30
+ theme?: Theme;
31
+ }
32
+
33
+ function IconView({
34
+ icon,
35
+ tokens,
36
+ marginRight,
37
+ }: {
38
+ icon: ShellIcon;
39
+ tokens: Theme;
40
+ marginRight: number;
41
+ }): React.ReactNode {
42
+ const color = icon.active ? tokens.accent : tokens.textMuted;
43
+
44
+ // alignSelf center keeps the clickable box on the same row as sibling icons
45
+ return (
46
+ <box onMouseUp={icon.onPress} style={{ alignSelf: "center", marginRight }}>
47
+ <text fg={color}>{icon.glyph}</text>
48
+ </box>
49
+ );
50
+ }
51
+
52
+ export function ShellHeader({
53
+ leftIcons = [],
54
+ leftLabel,
55
+ titleIcon,
56
+ title,
57
+ tabs = [],
58
+ rightIcons = [],
59
+ sidebarOpen = true,
60
+ onToggleSidebar,
61
+ inspectorOpen = true,
62
+ onToggleInspector,
63
+ theme,
64
+ }: ShellHeaderProps): React.ReactNode {
65
+ const tokens = theme ?? DARK;
66
+
67
+ return (
68
+ <box
69
+ style={{
70
+ flexDirection: "row",
71
+ height: "100%",
72
+ alignItems: "center",
73
+ paddingLeft: 2,
74
+ paddingRight: 2,
75
+ }}
76
+ >
77
+ <box style={{ flexDirection: "row", alignItems: "center" }}>
78
+ {onToggleSidebar !== undefined ? (
79
+ <IconView
80
+ icon={{
81
+ glyph: sidebarOpen ? NERD.sidebarLeft : NERD.sidebarLeftOff,
82
+ onPress: onToggleSidebar,
83
+ }}
84
+ tokens={tokens}
85
+ marginRight={3}
86
+ />
87
+ ) : null}
88
+ {leftIcons.map((icon, index) => (
89
+ <IconView
90
+ key={`left-${index}-${icon.glyph}`}
91
+ icon={icon}
92
+ tokens={tokens}
93
+ marginRight={3}
94
+ />
95
+ ))}
96
+ {leftLabel !== undefined ? (
97
+ <text fg={tokens.textMuted}>
98
+ {leftLabel}
99
+ {" "}
100
+ </text>
101
+ ) : null}
102
+ {titleIcon !== undefined ? <text fg={tokens.textDim}>{titleIcon} </text> : null}
103
+ {title !== undefined ? <text fg={tokens.text}>{title}</text> : null}
104
+ </box>
105
+ <box style={{ flexGrow: 1 }} />
106
+ <box style={{ flexDirection: "row", alignItems: "center" }}>
107
+ {tabs.map((tab, index) => (
108
+ <text
109
+ key={`tab-${index}-${tab.label}`}
110
+ fg={tab.active ? tokens.accent : tokens.textMuted}
111
+ >
112
+ {tab.label}
113
+ {" "}
114
+ </text>
115
+ ))}
116
+ {rightIcons.map((icon, index) => (
117
+ <IconView
118
+ key={`right-${index}-${icon.glyph}`}
119
+ icon={icon}
120
+ tokens={tokens}
121
+ marginRight={3}
122
+ />
123
+ ))}
124
+ {onToggleInspector !== undefined ? (
125
+ <IconView
126
+ icon={{
127
+ glyph: inspectorOpen ? NERD.sidebarRight : NERD.sidebarRightOff,
128
+ onPress: onToggleInspector,
129
+ }}
130
+ tokens={tokens}
131
+ marginRight={0}
132
+ />
133
+ ) : null}
134
+ </box>
135
+ </box>
136
+ );
137
+ }
@@ -0,0 +1,34 @@
1
+ import React, { useRef } from "react";
2
+ import type { ScrollBoxRenderable } from "@opentui/core";
3
+ import { DARK } from "../theme";
4
+ import { SurfaceScrollbar } from "./SurfaceScrollbar";
5
+ import type { Story, StoryMeta } from "./story";
6
+
7
+ export const meta: StoryMeta = { title: "Chrome/SurfaceScrollbar" };
8
+
9
+ function TallSurface(): React.ReactNode {
10
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
11
+
12
+ return (
13
+ <box style={{ width: 24, height: 8, flexDirection: "row", backgroundColor: DARK.background }}>
14
+ <scrollbox
15
+ ref={scrollRef}
16
+ style={{ flexGrow: 1 }}
17
+ focused={false}
18
+ verticalScrollbarOptions={{ visible: false }}
19
+ >
20
+ {Array.from({ length: 24 }, (_unused, index) => (
21
+ <text key={index} fg={DARK.textMuted}>{`row ${index + 1}`}</text>
22
+ ))}
23
+ </scrollbox>
24
+ <SurfaceScrollbar scrollbox={scrollRef} />
25
+ </box>
26
+ );
27
+ }
28
+
29
+ // The mirrored bar: content three times the viewport puts a thumb a third as tall at the top.
30
+ export const ThumbAtTop: Story = {
31
+ render: () => <TallSurface />,
32
+ expectedColors: [DARK.textDim],
33
+ size: { width: 24, height: 8 },
34
+ };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The mirrored scrollbar: thumb geometry follows the scroll state, and on a surface it
3
+ * draws as the panel's rightmost column, past the discussion dots - not inside the
4
+ * scrollbox's own viewport row.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import React from "react";
10
+ import { DiffSheet } from "./DiffSheet";
11
+ import { diffRows, marksByRows } from "../view-diff";
12
+ import { allowEventLoopUpdates } from "../test-support";
13
+ import { fixtureDiffSession } from "./story-fixtures";
14
+ import { DARK } from "../theme";
15
+ import { thumbGeometry } from "./SurfaceScrollbar";
16
+
17
+ const noop = (): void => {};
18
+
19
+ function hex(color: { toInts(): [number, number, number, number] }): string {
20
+ const [red, green, blue] = color.toInts();
21
+
22
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
23
+ }
24
+
25
+ describe("thumbGeometry", () => {
26
+ test("nothing to scroll draws no thumb", () => {
27
+ // Arrange / Act / Assert - content fits the viewport
28
+ expect(thumbGeometry({ top: 0, height: 10, viewport: 14 }, 14)).toBeNull();
29
+ });
30
+
31
+ test("the thumb's height is the viewport's share of the content, at least one row", () => {
32
+ // Act
33
+ const tall = thumbGeometry({ top: 0, height: 44, viewport: 14 }, 14);
34
+ const tiny = thumbGeometry({ top: 0, height: 10_000, viewport: 14 }, 14);
35
+
36
+ // Assert
37
+ expect(tall).toEqual({ row: 0, height: 4 });
38
+ expect(tiny?.height).toBe(1);
39
+ });
40
+
41
+ test("the thumb reaches the track's end at the maximum scroll offset", () => {
42
+ // Act - scrolled to the bottom
43
+ const thumb = thumbGeometry({ top: 30, height: 44, viewport: 14 }, 14);
44
+
45
+ // Assert - its last row is the track's last row
46
+ expect(thumb).toEqual({ row: 10, height: 4 });
47
+ });
48
+ });
49
+
50
+ describe("the diff sheet's scrollbar", () => {
51
+ const body = Array.from({ length: 40 }, (_unused, index) => `+line ${index} of the change;`).join(
52
+ "\n",
53
+ );
54
+ const patch = `diff --git a/x.ts b/x.ts\n--- a/x.ts\n+++ b/x.ts\n@@ -0,0 +1,40 @@\n${body}\n`;
55
+
56
+ test("draws as the rightmost column, past the discussion dot", async () => {
57
+ // Arrange - a diff taller than the viewport, with one discussion so the dot rail has a dot
58
+ const rows = diffRows(patch);
59
+ const session = fixtureDiffSession({
60
+ annotations: [
61
+ {
62
+ id: "a1",
63
+ kind: "comment",
64
+ body: "hm",
65
+ anchor: { quote: "line 7 of the change;", prefix: "", suffix: "" },
66
+ createdAt: "2026-01-01T00:00:00Z",
67
+ },
68
+ ],
69
+ });
70
+ const width = 60;
71
+ const setup = await testRender(
72
+ <DiffSheet
73
+ rows={rows}
74
+ session={session}
75
+ marks={marksByRows(session.annotations, rows)}
76
+ quickActions={[]}
77
+ observer={false}
78
+ onAnnotate={noop}
79
+ onReply={noop}
80
+ onUpdateAnnotation={noop}
81
+ onExit={noop}
82
+ />,
83
+ { width, height: 14 },
84
+ );
85
+
86
+ allowEventLoopUpdates();
87
+ await setup.waitForVisualIdle();
88
+ const frame = setup.captureCharFrame().split("\n");
89
+ const thumbRows = setup.captureSpans().lines.filter((line) => {
90
+ let column = 0;
91
+
92
+ for (const span of line.spans) {
93
+ column += span.text.length;
94
+ if (column >= width) return span.bg !== undefined && hex(span.bg) === DARK.textDim;
95
+ }
96
+
97
+ return false;
98
+ }).length;
99
+
100
+ // Assert - the thumb occupies the last column at the top (scroll offset 0), and the dot sits
101
+ // to its left in the rail, so the order reads content, dots, scrollbar
102
+ expect(thumbRows).toBe(4);
103
+ const dotLine = frame.find((line) => line.includes("○"));
104
+
105
+ expect(dotLine).toBeDefined();
106
+ expect(dotLine!.indexOf("○")).toBeLessThan(width - 1);
107
+ // let the async highlight settle before the renderer goes away
108
+ await new Promise((resolve) => setTimeout(resolve, 100));
109
+ setup.renderer.destroy();
110
+ });
111
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * A one-cell vertical scrollbar that mirrors a scrollbox's scroll state, so the bar can sit
3
+ * where the layout wants it (the panel's rightmost column, past the discussion dots) instead
4
+ * of inside the scrollbox's own viewport row. Reads the scroll position each frame, draws the
5
+ * thumb, and a click or drag on the track scrolls the box.
6
+ */
7
+
8
+ import React, { useRef, type RefObject } from "react";
9
+ import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core";
10
+ import type { Theme } from "../theme";
11
+ import { useFrameMeasure } from "../use-frame-measure";
12
+ import { useComponentTheme } from "./theme-context";
13
+
14
+ interface ScrollState {
15
+ top: number;
16
+ height: number;
17
+ viewport: number;
18
+ }
19
+
20
+ /** The thumb's row and height for a viewport of `track` rows over the scroll state. */
21
+ export interface ThumbGeometry {
22
+ row: number;
23
+ height: number;
24
+ }
25
+
26
+ /** Where the thumb sits: proportional to the scroll offset, never shorter than one row, or null when nothing scrolls. */
27
+ export function thumbGeometry(state: ScrollState, track: number): ThumbGeometry | null {
28
+ if (track <= 0 || state.height <= state.viewport || state.viewport <= 0) return null;
29
+ const height = Math.max(1, Math.round((state.viewport / state.height) * track));
30
+ const maxTop = state.height - state.viewport;
31
+ const row = Math.round((state.top / maxTop) * (track - height));
32
+
33
+ return { row: Math.min(track - height, Math.max(0, row)), height };
34
+ }
35
+
36
+ export interface SurfaceScrollbarProps {
37
+ scrollbox: RefObject<ScrollBoxRenderable | null>;
38
+ theme?: Theme;
39
+ }
40
+
41
+ export function SurfaceScrollbar({ scrollbox, theme }: SurfaceScrollbarProps): React.ReactNode {
42
+ const tokens = useComponentTheme(theme);
43
+ const trackRef = useRef<BoxRenderable | null>(null);
44
+ const state = useFrameMeasure<ScrollState>(
45
+ () => ({
46
+ top: scrollbox.current?.scrollTop ?? 0,
47
+ height: scrollbox.current?.scrollHeight ?? 0,
48
+ viewport: scrollbox.current?.viewport.height ?? 0,
49
+ }),
50
+ (left, right) =>
51
+ left.top === right.top && left.height === right.height && left.viewport === right.viewport,
52
+ { top: 0, height: 0, viewport: 0 },
53
+ );
54
+ const track = useFrameMeasure(
55
+ () => trackRef.current?.height ?? 0,
56
+ (left, right) => left === right,
57
+ 0,
58
+ );
59
+ const thumb = thumbGeometry(state, track);
60
+
61
+ // a row on the track maps to the scroll offset that puts the thumb's top there
62
+ const scrollToTrackRow = (screenY: number): void => {
63
+ const box = scrollbox.current;
64
+
65
+ if (!box || !thumb) return;
66
+ const row = screenY - (trackRef.current?.y ?? 0) - Math.floor(thumb.height / 2);
67
+ const ratio = Math.min(1, Math.max(0, row / Math.max(1, track - thumb.height)));
68
+
69
+ box.scrollTo(Math.round(ratio * (state.height - state.viewport)));
70
+ };
71
+
72
+ return (
73
+ <box
74
+ ref={trackRef}
75
+ style={{ width: 1, flexShrink: 0, flexDirection: "column" }}
76
+ onMouseDown={(event) => scrollToTrackRow(event.y)}
77
+ onMouseDrag={(event) => scrollToTrackRow(event.y)}
78
+ >
79
+ {thumb ? (
80
+ <box
81
+ style={{
82
+ position: "absolute",
83
+ top: thumb.row,
84
+ left: 0,
85
+ width: 1,
86
+ height: thumb.height,
87
+ backgroundColor: tokens.textDim,
88
+ }}
89
+ />
90
+ ) : null}
91
+ </box>
92
+ );
93
+ }
@@ -0,0 +1,30 @@
1
+ import React from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { ThreadFooter } from "./ThreadFooter";
5
+
6
+ export const meta: StoryMeta = { title: "Chrome/ThreadFooter" };
7
+
8
+ function Frame({ children }: { children: React.ReactNode }): React.ReactNode {
9
+ return <box style={{ width: "100%", backgroundColor: DARK.panel }}>{children}</box>;
10
+ }
11
+
12
+ export const Default: Story = {
13
+ render: () => (
14
+ <Frame>
15
+ <ThreadFooter repo="cueloop" branch="main" onSubmit={() => {}} />
16
+ </Frame>
17
+ ),
18
+ expectedColors: [DARK.accent, DARK.textMuted],
19
+ size: { width: 80, height: 2 },
20
+ };
21
+
22
+ export const ReadOnly: Story = {
23
+ render: () => (
24
+ <Frame>
25
+ <ThreadFooter repo="cueloop" branch="design/tui" canSubmit={false} onSubmit={() => {}} />
26
+ </Frame>
27
+ ),
28
+ expectedColors: [DARK.textDim],
29
+ size: { width: 80, height: 2 },
30
+ };
@@ -0,0 +1,31 @@
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. */
3
+
4
+ import { test, expect } from "bun:test";
5
+ import React from "react";
6
+ import { testRender } from "@opentui/react/test-utils";
7
+ import { ThreadFooter } from "./ThreadFooter";
8
+ import { DARK } from "../theme";
9
+
10
+ test("a long branch truncates on one line and keeps the send control in place", async () => {
11
+ const setup = await testRender(
12
+ <box style={{ width: 60, height: 3, backgroundColor: DARK.background }}>
13
+ <box style={{ flexGrow: 1 }} />
14
+ <ThreadFooter
15
+ repo="cueloop"
16
+ branch="design/tui-tree-and-edgy-really-long-name"
17
+ onSubmit={() => {}}
18
+ theme={DARK}
19
+ />
20
+ </box>,
21
+ { width: 60, height: 3 },
22
+ );
23
+ await setup.renderOnce();
24
+
25
+ const lines = setup.captureCharFrame().split("\n");
26
+ const footerRow = lines.find((line) => line.includes("cueloop"))!;
27
+ // the repo/branch context and the send control share the single footer row
28
+ 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
+ });
@@ -0,0 +1,54 @@
1
+ // The thread's bottom band: a header-like bar shown only under the always-visible
2
+ // thread column. It carries the repo/branch context on the left and a written,
3
+ // clickable "Send message" control on the right.
4
+
5
+ import React from "react";
6
+ import { DARK, type Theme } from "../theme";
7
+ import { NERD } from "./primitives/icons";
8
+
9
+ export interface ThreadFooterProps {
10
+ repo: string;
11
+ branch: string;
12
+ onSubmit?: () => void;
13
+ /** When false the send control dims and does not fire (e.g. an observer). */
14
+ canSubmit?: boolean;
15
+ theme?: Theme;
16
+ }
17
+
18
+ export function ThreadFooter({
19
+ repo,
20
+ branch,
21
+ onSubmit,
22
+ canSubmit = true,
23
+ theme,
24
+ }: ThreadFooterProps): React.ReactNode {
25
+ const tokens = theme ?? DARK;
26
+
27
+ return (
28
+ <box
29
+ style={{
30
+ flexDirection: "row",
31
+ height: 2,
32
+ alignItems: "center",
33
+ paddingLeft: 1,
34
+ paddingRight: 1,
35
+ backgroundColor: tokens.panel,
36
+ borderStyle: "single",
37
+ border: ["top"],
38
+ borderColor: tokens.border,
39
+ }}
40
+ >
41
+ <box style={{ flexShrink: 1, minWidth: 0, marginRight: 2 }}>
42
+ <text truncate>
43
+ <span fg={tokens.blue}>{`${NERD.folderClosed} `}</span>
44
+ <span fg={tokens.textMuted}>{repo}</span>
45
+ <span fg={tokens.textDim}>{` / ${branch}`}</span>
46
+ </text>
47
+ </box>
48
+ <box style={{ flexGrow: 1 }} />
49
+ <box onMouseUp={canSubmit ? onSubmit : undefined} style={{ flexShrink: 0 }}>
50
+ <text fg={canSubmit ? tokens.accent : tokens.textDim}>send message</text>
51
+ </box>
52
+ </box>
53
+ );
54
+ }
@@ -0,0 +1,113 @@
1
+ /** Inline skill completion in the composer (#25): the trailing "/word" token and its match. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import React from "react";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import type { QuickAction } from "../config";
7
+ import { settle, typeText } from "../test-support";
8
+ import { buildDisplay, marksByDisplay } from "../view-plan";
9
+ import { fixturePlanSession } from "./story-fixtures";
10
+ import { inlineSlashToken, resolveInlineSuggestion, ThreadView } from "./ThreadView";
11
+
12
+ const SKILLS: QuickAction[] = [
13
+ { prompt: "typescript magician" },
14
+ { prompt: "implement" },
15
+ { prompt: "write discoverable code" },
16
+ ];
17
+
18
+ const PLAN = "# Plan\n\nRefine the store before the rewrite lands.\n";
19
+
20
+ async function mountComposer() {
21
+ const display = buildDisplay(PLAN, undefined);
22
+ const setup = await testRender(
23
+ <ThreadView
24
+ session={fixturePlanSession({
25
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
26
+ })}
27
+ display={display}
28
+ marks={marksByDisplay([], display)}
29
+ quickActions={SKILLS}
30
+ observer={false}
31
+ onAnnotate={() => {}}
32
+ onReply={() => {}}
33
+ onUpdateAnnotation={() => {}}
34
+ onExit={() => {}}
35
+ />,
36
+ { width: 72, height: 20 },
37
+ );
38
+
39
+ await settle(setup);
40
+ await settle(setup);
41
+
42
+ return setup;
43
+ }
44
+
45
+ describe("inlineSlashToken", () => {
46
+ test("finds the trailing slash token when text precedes it", () => {
47
+ expect(inlineSlashToken("please run /type")).toBe("/type");
48
+ });
49
+
50
+ test("ignores a draft that is itself a leading slash (that is the palette)", () => {
51
+ expect(inlineSlashToken("/type")).toBeNull();
52
+ });
53
+
54
+ test("is newline-safe: a token at the start of a later line still resolves", () => {
55
+ expect(inlineSlashToken("first line\n/impl")).toBe("/impl");
56
+ });
57
+
58
+ test("returns null when the trailing word is not a slash token", () => {
59
+ expect(inlineSlashToken("just some prose")).toBeNull();
60
+ });
61
+
62
+ test("a bare slash mid-sentence is a valid, empty-query token", () => {
63
+ expect(inlineSlashToken("run /")).toBe("/");
64
+ });
65
+ });
66
+
67
+ describe("resolveInlineSuggestion", () => {
68
+ test("offers the fuzzy closest skill for the trailing token", () => {
69
+ const inline = resolveInlineSuggestion(false, "run /type", SKILLS);
70
+
71
+ expect(inline?.token).toBe("/type");
72
+ expect(inline?.suggestion.name).toBe("typescript-magician");
73
+ });
74
+
75
+ test("prefers a prefix match over a subsequence match", () => {
76
+ const inline = resolveInlineSuggestion(false, "run /impl", SKILLS);
77
+
78
+ expect(inline?.suggestion.name).toBe("implement");
79
+ });
80
+
81
+ test("stays silent while the palette owns a leading slash", () => {
82
+ expect(resolveInlineSuggestion(true, "/type", SKILLS)).toBeNull();
83
+ });
84
+
85
+ test("returns null when nothing matches the token", () => {
86
+ expect(resolveInlineSuggestion(false, "run /zzzz", SKILLS)).toBeNull();
87
+ });
88
+ });
89
+
90
+ describe("inline completion in the composer", () => {
91
+ test("typing an inline slash shows the tab-hint, and tab completes to the full name", async () => {
92
+ // Arrange - open a composer and type text with a trailing slash token
93
+ const setup = await mountComposer();
94
+
95
+ await typeText(setup, "run /type");
96
+
97
+ // Assert - the hint offers the closest skill, marked with the tab glyph
98
+ const withHint = setup.captureCharFrame();
99
+
100
+ expect(withHint).toContain("⇥");
101
+ expect(withHint).toContain("/typescript-magician");
102
+
103
+ // Act - tab completes the token
104
+ setup.mockInput.pressKey("TAB");
105
+ await settle(setup);
106
+
107
+ // Assert - the hint is gone and the composer now carries the full skill name
108
+ const completed = setup.captureCharFrame();
109
+
110
+ expect(completed).not.toContain("⇥");
111
+ expect(completed).toContain("typescript-magician");
112
+ });
113
+ });
@@ -5,7 +5,7 @@ import type { Story, StoryMeta } from "./story";
5
5
  import { fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtures";
6
6
  import { ThreadView } from "./ThreadView";
7
7
 
8
- export const meta: StoryMeta = { title: "ThreadView" };
8
+ export const meta: StoryMeta = { title: "Surfaces/ThreadView" };
9
9
 
10
10
  export const InlineThreads: Story = {
11
11
  render: () => (