@cueloop/client 0.1.0-alpha.66 → 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 (115) hide show
  1. package/package.json +6 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +71 -39
  4. package/src/App.plan-review.test.tsx +20 -21
  5. package/src/App.readonly.test.tsx +7 -3
  6. package/src/App.test.tsx +73 -14
  7. package/src/App.tsx +375 -129
  8. package/src/App.walk.test.tsx +14 -15
  9. package/src/App.workbench.test.tsx +390 -0
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/annotation-palette.ts +61 -0
  12. package/src/app-screens.tsx +176 -45
  13. package/src/app-view-model.ts +7 -65
  14. package/src/components/AnnotationCard.tsx +1 -1
  15. package/src/components/AnnotationCards.stories.tsx +87 -0
  16. package/src/components/AnnotationCards.tsx +303 -0
  17. package/src/components/AppHeader.stories.tsx +50 -0
  18. package/src/components/AppHeader.tsx +83 -0
  19. package/src/components/AppShell.stories.tsx +47 -42
  20. package/src/components/AppShell.tsx +212 -100
  21. package/src/components/AppShell.zoom.test.tsx +94 -0
  22. package/src/components/ChangesColumn.tsx +49 -26
  23. package/src/components/DiffSheet.scroll.test.tsx +38 -55
  24. package/src/components/DiffSheet.split.test.tsx +140 -0
  25. package/src/components/DiffSheet.stories.tsx +55 -53
  26. package/src/components/DiffSheet.syntax.test.tsx +24 -7
  27. package/src/components/DiffSheet.tsx +607 -276
  28. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  29. package/src/components/DiscussionMarkerRail.tsx +181 -0
  30. package/src/components/EditorGrid.stories.tsx +59 -0
  31. package/src/components/EditorGrid.tabs.test.tsx +105 -0
  32. package/src/components/EditorGrid.tsx +338 -0
  33. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  34. package/src/components/FileContentsView.stories.tsx +16 -0
  35. package/src/components/FileContentsView.test.tsx +29 -0
  36. package/src/components/FileContentsView.tsx +78 -0
  37. package/src/components/InboxList.tsx +159 -30
  38. package/src/components/PaneGrid.stories.tsx +84 -0
  39. package/src/components/PaneGrid.tsx +128 -0
  40. package/src/components/PanelColumn.stories.tsx +25 -0
  41. package/src/components/PanelColumn.tsx +95 -0
  42. package/src/components/ProjectTreeView.stories.tsx +25 -0
  43. package/src/components/ProjectTreeView.test.tsx +32 -0
  44. package/src/components/ProjectTreeView.tsx +82 -0
  45. package/src/components/PromptDialog.tsx +8 -1
  46. package/src/components/RootOverlay.stories.tsx +44 -0
  47. package/src/components/RootOverlay.tsx +35 -0
  48. package/src/components/SettingsDialog.tsx +2 -1
  49. package/src/components/SurfaceScrollbar.stories.tsx +34 -0
  50. package/src/components/SurfaceScrollbar.test.tsx +111 -0
  51. package/src/components/SurfaceScrollbar.tsx +93 -0
  52. package/src/components/ThreadFooter.stories.tsx +2 -2
  53. package/src/components/ThreadFooter.test.tsx +31 -0
  54. package/src/components/ThreadFooter.tsx +12 -7
  55. package/src/components/ThreadView.tsx +62 -1440
  56. package/src/components/ThreadsSidebar.tsx +14 -1
  57. package/src/components/Tooltip.stories.tsx +26 -0
  58. package/src/components/Tooltip.tsx +72 -0
  59. package/src/components/WelcomeSurface.stories.tsx +12 -0
  60. package/src/components/WelcomeSurface.tsx +52 -0
  61. package/src/components/__snapshots__/stories.test.tsx.snap +509 -559
  62. package/src/components/diff-sheet-layout.test.ts +125 -55
  63. package/src/components/diff-sheet-layout.ts +3 -103
  64. package/src/components/editor-grid.test.ts +86 -0
  65. package/src/components/editor-grid.ts +138 -0
  66. package/src/components/fade-title.test.ts +49 -0
  67. package/src/components/fade-title.ts +61 -0
  68. package/src/components/file-tree.test.ts +15 -0
  69. package/src/components/file-tree.ts +59 -4
  70. package/src/components/pages/Workspace.stories.tsx +2 -2
  71. package/src/components/primitives/IconButton.tsx +21 -2
  72. package/src/components/primitives/Tree.tsx +13 -4
  73. package/src/components/primitives/icons.ts +27 -4
  74. package/src/components/session-tree.test.ts +20 -0
  75. package/src/components/session-tree.ts +16 -2
  76. package/src/components/stories-app.tsx +2 -2
  77. package/src/config.test.ts +20 -49
  78. package/src/config.ts +26 -28
  79. package/src/diff-fold.test.ts +78 -0
  80. package/src/diff-fold.ts +126 -0
  81. package/src/diff-syntax.test.ts +16 -0
  82. package/src/diff-syntax.ts +6 -3
  83. package/src/discussions.ts +79 -0
  84. package/src/intent-dispatch.test.ts +31 -11
  85. package/src/intent-dispatch.ts +93 -48
  86. package/src/key-bindings.test.ts +1 -1
  87. package/src/keymap.test.ts +3 -25
  88. package/src/keymap.ts +17 -18
  89. package/src/mark-runs.ts +120 -0
  90. package/src/pty.ts +10 -2
  91. package/src/run.ts +17 -7
  92. package/src/serve.test.ts +4 -8
  93. package/src/session-controller.curate.test.ts +75 -0
  94. package/src/session-controller.share.test.ts +1 -0
  95. package/src/session-controller.tree.test.ts +26 -0
  96. package/src/session-controller.ts +153 -29
  97. package/src/slash-palette.ts +83 -0
  98. package/src/split-diff.test.ts +110 -0
  99. package/src/split-diff.ts +149 -0
  100. package/src/test-support.test.ts +3 -3
  101. package/src/test-support.ts +39 -8
  102. package/src/thread-chords.test.ts +25 -9
  103. package/src/thread-chords.ts +33 -8
  104. package/src/use-annotation-surface.tsx +837 -0
  105. package/src/use-changes-workbench.ts +151 -0
  106. package/src/use-frame-measure.ts +38 -0
  107. package/src/use-settings-dialog.tsx +14 -24
  108. package/src/use-terminal-virtualizer.ts +233 -0
  109. package/src/view-diff.ts +114 -0
  110. package/src/components/ReviewPanel.stories.tsx +0 -100
  111. package/src/components/ReviewPanel.tsx +0 -103
  112. package/src/components/ReviewRail.stories.tsx +0 -232
  113. package/src/components/ReviewRail.tsx +0 -327
  114. package/src/review-panel.test.ts +0 -113
  115. package/src/review-panel.ts +0 -83
@@ -0,0 +1,82 @@
1
+ // The Project pane in tree mode: the workspace's tracked files as a collapsible directory tree.
2
+ // Folders start collapsed and expand on click; a file click opens it as a tab. The caller keys this
3
+ // on the session id so switching sessions remounts it and never shows the previous repository's paths.
4
+
5
+ import React, { useEffect, useMemo, useRef, useState } from "react";
6
+ import type { Theme } from "../theme";
7
+ import { useComponentTheme } from "./theme-context";
8
+ import { Tree } from "./primitives/Tree";
9
+ import { buildPathTree } from "./file-tree";
10
+
11
+ export interface ProjectTreeViewProps {
12
+ loadFiles: () => Promise<string[]>;
13
+ onSelectFile: (path: string) => void;
14
+ theme?: Theme;
15
+ }
16
+
17
+ export function ProjectTreeView({
18
+ loadFiles,
19
+ onSelectFile,
20
+ theme,
21
+ }: ProjectTreeViewProps): React.ReactNode {
22
+ const tokens = useComponentTheme(theme);
23
+ const [paths, setPaths] = useState<readonly string[] | null>(null);
24
+ const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set());
25
+ const nodes = useMemo(() => (paths ? buildPathTree(paths) : []), [paths]);
26
+ const loadRef = useRef(loadFiles);
27
+ useEffect(() => {
28
+ loadRef.current = loadFiles;
29
+ });
30
+
31
+ useEffect(() => {
32
+ let alive = true;
33
+ void loadRef.current().then(
34
+ (files) => {
35
+ if (alive) setPaths(files);
36
+ },
37
+ () => {
38
+ if (alive) setPaths([]);
39
+ },
40
+ );
41
+
42
+ return () => {
43
+ alive = false;
44
+ };
45
+ }, []);
46
+
47
+ if (paths === null) {
48
+ return (
49
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
50
+ <text fg={tokens.textDim}>loading...</text>
51
+ </box>
52
+ );
53
+ }
54
+ if (paths.length === 0) {
55
+ return (
56
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
57
+ <text fg={tokens.textDim}>empty</text>
58
+ </box>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <scrollbox style={{ flexGrow: 1 }} focused={false}>
64
+ <Tree
65
+ nodes={nodes}
66
+ expandedIds={expandedIds}
67
+ onSelect={onSelectFile}
68
+ onToggle={(id) =>
69
+ setExpandedIds((current) => {
70
+ const next = new Set(current);
71
+
72
+ if (next.has(id)) next.delete(id);
73
+ else next.add(id);
74
+
75
+ return next;
76
+ })
77
+ }
78
+ theme={theme}
79
+ />
80
+ </scrollbox>
81
+ );
82
+ }
@@ -46,7 +46,14 @@ export function PromptDialog({
46
46
  if (!isOpen) return null;
47
47
 
48
48
  return (
49
- <Dialog isOpen title={title} width={Math.min(54, terminalWidth - 6)} height={7} theme={theme}>
49
+ <Dialog
50
+ isOpen
51
+ title={title}
52
+ width={Math.min(54, terminalWidth - 6)}
53
+ height={7}
54
+ background={tokens.elevated}
55
+ theme={theme}
56
+ >
50
57
  <box
51
58
  style={{
52
59
  flexDirection: "column",
@@ -0,0 +1,44 @@
1
+ import React, { useEffect } from "react";
2
+ import { DARK } from "../theme";
3
+ import type { Story, StoryMeta } from "./story";
4
+ import { RootOverlayProvider, useRootOverlay } from "./RootOverlay";
5
+
6
+ export const meta: StoryMeta = { title: "Layout/RootOverlay" };
7
+
8
+ // The provider renders whatever a descendant pushes after the rest of the tree, so it draws on top.
9
+ function OverlayDemo(): React.ReactNode {
10
+ const { setOverlay } = useRootOverlay();
11
+
12
+ useEffect(() => {
13
+ setOverlay(
14
+ <box
15
+ style={{
16
+ position: "absolute",
17
+ top: 2,
18
+ left: 6,
19
+ borderStyle: "single",
20
+ borderColor: DARK.border,
21
+ backgroundColor: DARK.elevated,
22
+ paddingLeft: 1,
23
+ paddingRight: 1,
24
+ }}
25
+ >
26
+ <text fg={DARK.text}>floating overlay</text>
27
+ </box>,
28
+ );
29
+ }, [setOverlay]);
30
+
31
+ return <text fg={DARK.textDim}>base content under the overlay</text>;
32
+ }
33
+
34
+ export const FloatingNode: Story = {
35
+ render: () => (
36
+ <RootOverlayProvider>
37
+ <box style={{ padding: 1, backgroundColor: DARK.panel }}>
38
+ <OverlayDemo />
39
+ </box>
40
+ </RootOverlayProvider>
41
+ ),
42
+ expectedColors: [DARK.text],
43
+ size: { width: 40, height: 6 },
44
+ };
@@ -0,0 +1,35 @@
1
+ // A root overlay slot: OpenTUI has no z-index, so an overlay drawn inside a pane is sliced by the
2
+ // next pane's border. Anything pushed here renders after every pane (last in draw order), so it sits
3
+ // on top of the whole shell - the same trick the tooltip uses. Callers position their node with
4
+ // absolute screen coordinates.
5
+
6
+ import React, { createContext, useContext, useMemo, useState } from "react";
7
+
8
+ interface RootOverlayApi {
9
+ setOverlay: (node: React.ReactNode) => void;
10
+ clearOverlay: () => void;
11
+ }
12
+
13
+ const RootOverlayContext = createContext<RootOverlayApi>({
14
+ setOverlay: () => {},
15
+ clearOverlay: () => {},
16
+ });
17
+
18
+ export function useRootOverlay(): RootOverlayApi {
19
+ return useContext(RootOverlayContext);
20
+ }
21
+
22
+ export function RootOverlayProvider({ children }: { children: React.ReactNode }): React.ReactNode {
23
+ const [overlay, setOverlay] = useState<React.ReactNode>(null);
24
+ const api = useMemo<RootOverlayApi>(
25
+ () => ({ setOverlay, clearOverlay: () => setOverlay(null) }),
26
+ [],
27
+ );
28
+
29
+ return (
30
+ <RootOverlayContext.Provider value={api}>
31
+ {children}
32
+ {overlay}
33
+ </RootOverlayContext.Provider>
34
+ );
35
+ }
@@ -123,12 +123,13 @@ export function SettingsDialog({
123
123
  nodes={navTree(categories)}
124
124
  expandedIds={new Set(["settings"])}
125
125
  selectedId={category.id}
126
+ hideIcons
126
127
  onSelect={onCategorySelect}
127
128
  onToggle={onCategorySelect}
128
129
  theme={theme}
129
130
  />
130
131
  </box>
131
- <box style={{ width: 1, backgroundColor: tokens.border }} />
132
+ <box style={{ borderStyle: "single", border: ["left"], borderColor: tokens.border }} />
132
133
  <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 1 }}>
133
134
  <text fg={tokens.text}>{category.name}</text>
134
135
  <text fg={tokens.textDim}>{category.description}</text>
@@ -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
+ }
@@ -16,7 +16,7 @@ export const Default: Story = {
16
16
  </Frame>
17
17
  ),
18
18
  expectedColors: [DARK.accent, DARK.textMuted],
19
- size: { width: 80, height: 1 },
19
+ size: { width: 80, height: 2 },
20
20
  };
21
21
 
22
22
  export const ReadOnly: Story = {
@@ -26,5 +26,5 @@ export const ReadOnly: Story = {
26
26
  </Frame>
27
27
  ),
28
28
  expectedColors: [DARK.textDim],
29
- size: { width: 80, height: 1 },
29
+ size: { width: 80, height: 2 },
30
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
+ });
@@ -28,20 +28,25 @@ export function ThreadFooter({
28
28
  <box
29
29
  style={{
30
30
  flexDirection: "row",
31
- height: 1,
31
+ height: 2,
32
32
  alignItems: "center",
33
33
  paddingLeft: 1,
34
34
  paddingRight: 1,
35
35
  backgroundColor: tokens.panel,
36
+ borderStyle: "single",
37
+ border: ["top"],
38
+ borderColor: tokens.border,
36
39
  }}
37
40
  >
38
- <text>
39
- <span fg={tokens.blue}>{`${NERD.folderClosed} `}</span>
40
- <span fg={tokens.textMuted}>{repo}</span>
41
- <span fg={tokens.textDim}>{` / ${branch}`}</span>
42
- </text>
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>
43
48
  <box style={{ flexGrow: 1 }} />
44
- <box onMouseUp={canSubmit ? onSubmit : undefined}>
49
+ <box onMouseUp={canSubmit ? onSubmit : undefined} style={{ flexShrink: 0 }}>
45
50
  <text fg={canSubmit ? tokens.accent : tokens.textDim}>send message</text>
46
51
  </box>
47
52
  </box>