@cueloop/client 0.1.0-alpha.78 → 0.1.0-alpha.79

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 (92) hide show
  1. package/package.json +14 -13
  2. package/src/App.diff.test.tsx +9 -4
  3. package/src/App.focus.test.ts +1 -59
  4. package/src/App.identity.test.tsx +3 -3
  5. package/src/App.plan-render.test.tsx +209 -0
  6. package/src/App.plan-review.test.tsx +75 -26
  7. package/src/App.readonly.test.tsx +6 -5
  8. package/src/App.share-dialog.test.tsx +172 -0
  9. package/src/App.submit-confirm.test.tsx +6 -4
  10. package/src/App.test.tsx +31 -28
  11. package/src/App.toast.test.tsx +11 -4
  12. package/src/App.tsx +259 -274
  13. package/src/App.walk.test.tsx +9 -8
  14. package/src/App.workbench.test.tsx +86 -0
  15. package/src/annotation-palette.ts +1 -0
  16. package/src/app-screens.tsx +12 -6
  17. package/src/app-view-model.ts +0 -1
  18. package/src/components/ConfirmDialog.stories.tsx +3 -3
  19. package/src/components/DiffContentView.tsx +14 -7
  20. package/src/components/DiscussionMarkerRail.tsx +9 -4
  21. package/src/components/GridTabContent.tsx +8 -2
  22. package/src/components/HandleChipsInput.stories.tsx +24 -0
  23. package/src/components/HandleChipsInput.test.tsx +35 -0
  24. package/src/components/HandleChipsInput.tsx +145 -0
  25. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  26. package/src/components/MarkdownGridBlock.tsx +72 -0
  27. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  28. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  29. package/src/components/MarkdownThreadEditor.tsx +162 -0
  30. package/src/components/NavModeHint.stories.tsx +40 -0
  31. package/src/components/NavModeHint.tsx +31 -0
  32. package/src/components/SettingsDialog.tsx +1 -0
  33. package/src/components/ShareDialog.stories.tsx +121 -0
  34. package/src/components/ShareDialog.tsx +552 -0
  35. package/src/components/ThreadFooter.tsx +4 -1
  36. package/src/components/ThreadTree.tsx +1 -1
  37. package/src/components/ThreadView.slash.test.tsx +0 -35
  38. package/src/components/ThreadView.tsx +142 -68
  39. package/src/components/Toast.stories.tsx +7 -0
  40. package/src/components/Toast.tsx +15 -3
  41. package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
  42. package/src/components/primitives/Button.tsx +6 -2
  43. package/src/components/primitives/Card.tsx +2 -2
  44. package/src/components/primitives/Dialog.tsx +2 -2
  45. package/src/components/primitives/Switch.stories.tsx +17 -0
  46. package/src/components/primitives/Switch.tsx +32 -0
  47. package/src/components/primitives/Tree.tsx +5 -1
  48. package/src/components/primitives/frame.ts +5 -0
  49. package/src/components/share-dialog-store.ts +58 -0
  50. package/src/components/stories.test.tsx +4 -3
  51. package/src/config.test.ts +18 -0
  52. package/src/config.ts +8 -1
  53. package/src/diff-fold.ts +1 -1
  54. package/src/intent-dispatch.test.ts +12 -3
  55. package/src/intent-dispatch.ts +10 -25
  56. package/src/key-bindings.ts +0 -6
  57. package/src/keymap.ts +0 -1
  58. package/src/markdown-editor-syntax-style.ts +50 -0
  59. package/src/markdown-grid.test.ts +101 -0
  60. package/src/markdown-grid.ts +203 -0
  61. package/src/markdown-highlight.test.ts +61 -0
  62. package/src/markdown-highlight.ts +122 -0
  63. package/src/prototype-browser.test.ts +9 -1
  64. package/src/prototype-browser.ts +22 -0
  65. package/src/run.ts +8 -6
  66. package/src/share.ts +15 -1
  67. package/src/test-support.ts +14 -0
  68. package/src/thread-chords.test.ts +55 -178
  69. package/src/thread-chords.ts +78 -169
  70. package/src/thread-controller.curate.test.ts +1 -0
  71. package/src/thread-controller.live-diff.test.ts +3 -0
  72. package/src/thread-controller.share.test.ts +60 -5
  73. package/src/thread-controller.switch-cache.test.ts +30 -0
  74. package/src/thread-controller.tree.test.ts +6 -1
  75. package/src/thread-controller.ts +229 -54
  76. package/src/use-annotation-surface.tsx +45 -36
  77. package/src/use-settings-dialog.tsx +14 -3
  78. package/src/use-thread-body-editing.tsx +58 -0
  79. package/src/view-plan.test.ts +56 -6
  80. package/src/view-plan.ts +154 -5
  81. package/src/App.share-choice.test.tsx +0 -105
  82. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  83. package/src/components/ManageAccessDialog.test.tsx +0 -94
  84. package/src/components/ManageAccessDialog.tsx +0 -122
  85. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  86. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  87. package/src/components/ShareChoiceDialog.tsx +0 -80
  88. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  89. package/src/components/ThreadsSidebar.tsx +0 -71
  90. package/src/components/agent-launcher.stories.tsx +0 -26
  91. package/src/components/agent-launcher.test.tsx +0 -50
  92. package/src/components/agent-launcher.tsx +0 -149
@@ -1,122 +0,0 @@
1
- /**
2
- * Owner surface for a private share's allowlist: the GitHub logins allowed to
3
- * open the private link. Type a handle and add it; click a row to remove one.
4
- * Editing persists through the controller, so the list survives a reload.
5
- */
6
-
7
- import React, { useEffect, useRef, useState } from "react";
8
- import { useKeyboard } from "@opentui/react";
9
- import { useTerminalDimensions } from "@opentui/react";
10
- import type { KeyBinding, TextareaRenderable } from "@opentui/core";
11
- import type { Theme } from "../theme";
12
- import { useComponentTheme } from "./theme-context";
13
- import { Dialog } from "./primitives/Dialog";
14
- import { Button } from "./primitives/Button";
15
- import { Toolbar } from "./primitives/Toolbar";
16
-
17
- // enter adds the handle rather than inserting a newline into the one-line field
18
- const ADD_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
19
-
20
- export interface ManageAccessDialogProps {
21
- isOpen: boolean;
22
- logins: string[];
23
- onAdd: (login: string) => void;
24
- onRemove: (login: string) => void;
25
- /** Publish the private link with the current allowlist and copy its connection line. */
26
- onCreateLink: () => void;
27
- onClose: () => void;
28
- theme?: Theme;
29
- }
30
-
31
- export function ManageAccessDialog({
32
- isOpen,
33
- logins,
34
- onAdd,
35
- onRemove,
36
- onCreateLink,
37
- onClose,
38
- theme,
39
- }: ManageAccessDialogProps): React.ReactNode {
40
- const tokens = useComponentTheme(theme);
41
- const { width: terminalWidth } = useTerminalDimensions();
42
- const inputRef = useRef<TextareaRenderable | null>(null);
43
- const [draft, setDraft] = useState("");
44
- const [inputGeneration, setInputGeneration] = useState(0);
45
-
46
- useEffect(() => {
47
- inputRef.current?.focus();
48
- }, [inputGeneration]);
49
-
50
- const addDraft = (): void => {
51
- const handle = draft.trim().replace(/^@/, "").toLowerCase();
52
-
53
- if (handle && !logins.includes(handle)) onAdd(handle);
54
- setDraft("");
55
- setInputGeneration((generation) => generation + 1);
56
- };
57
-
58
- useKeyboard((key) => {
59
- if (isOpen && key.name === "escape") onClose();
60
- });
61
-
62
- if (!isOpen) return null;
63
-
64
- return (
65
- <Dialog
66
- isOpen
67
- title=" Manage access "
68
- width={Math.min(54, terminalWidth - 6)}
69
- height={Math.min(18, 9 + logins.length)}
70
- background={tokens.elevated}
71
- onDismiss={onClose}
72
- theme={theme}
73
- >
74
- <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}>
75
- <text fg={tokens.textDim}>Only these GitHub users can open the private link.</text>
76
- <box style={{ height: 1 }} />
77
- {logins.length === 0 ? (
78
- <text fg={tokens.textDim}>no one yet - add a handle below</text>
79
- ) : (
80
- logins.map((login) => (
81
- <box key={login} onMouseUp={() => onRemove(login)} style={{ flexDirection: "row" }}>
82
- <text fg={tokens.text}>{`@${login}`}</text>
83
- <box style={{ flexGrow: 1 }} />
84
- <text fg={tokens.textDim}>remove</text>
85
- </box>
86
- ))
87
- )}
88
- <box style={{ flexGrow: 1 }} />
89
- <box style={{ flexDirection: "row" }}>
90
- <text fg={tokens.textDim}>{"@"}</text>
91
- <textarea
92
- key={inputGeneration}
93
- ref={inputRef}
94
- focused
95
- placeholder="handle"
96
- keyBindings={ADD_KEY_BINDINGS}
97
- onContentChange={() => setDraft(inputRef.current?.plainText ?? "")}
98
- style={{
99
- height: 1,
100
- flexGrow: 1,
101
- backgroundColor: tokens.elevated,
102
- focusedBackgroundColor: tokens.elevated,
103
- textColor: tokens.text,
104
- focusedTextColor: tokens.text,
105
- }}
106
- />
107
- </box>
108
- <Toolbar>
109
- <Button marginRight={2} onPress={addDraft} theme={theme}>
110
- {" add "}
111
- </Button>
112
- <Button variant="solid" marginRight={2} onPress={onCreateLink} theme={theme}>
113
- {" create link "}
114
- </Button>
115
- <Button onPress={onClose} theme={theme}>
116
- {" close "}
117
- </Button>
118
- </Toolbar>
119
- </box>
120
- </Dialog>
121
- );
122
- }
@@ -1,34 +0,0 @@
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
- };
@@ -1,103 +0,0 @@
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
- });
@@ -1,80 +0,0 @@
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>
78
- </Dialog>
79
- );
80
- }
@@ -1,36 +0,0 @@
1
- import React from "react";
2
- import { DARK } from "../theme";
3
- import type { Story, StoryMeta } from "./story";
4
- import { ThreadsSidebar } from "./ThreadsSidebar";
5
- import { groupInbox } from "./session-tree";
6
- import { fixtureDiffSession, fixturePlanSession } from "./story-fixtures";
7
-
8
- export const meta: StoryMeta = { title: "Surfaces/ThreadsSidebar" };
9
-
10
- function Frame({ children }: { children: React.ReactNode }): React.ReactNode {
11
- return <box style={{ width: "100%", height: "100%", flexDirection: "row" }}>{children}</box>;
12
- }
13
-
14
- const plan = fixturePlanSession();
15
- const projectPlan = {
16
- ...plan,
17
- workspace: { ...plan.workspace, rootCommit: "root-1", remote: "git@github.com:acme/widget.git" },
18
- };
19
- const { rows, ordered } = groupInbox([projectPlan, fixtureDiffSession()]);
20
-
21
- export const Open: Story = {
22
- render: () => (
23
- <Frame>
24
- <ThreadsSidebar
25
- open
26
- rows={rows}
27
- cursor={0}
28
- activeId={ordered[0]!.id}
29
- onSelect={() => {}}
30
- theme={DARK}
31
- />
32
- </Frame>
33
- ),
34
- expectedColors: [DARK.accent, DARK.border],
35
- size: { width: 60, height: 12 },
36
- };
@@ -1,71 +0,0 @@
1
- // The left Projects and Threads column: a collapsible, scrollable tree beside the
2
- // thread. A click jumps to that thread; the open thread stays highlighted. Renders
3
- // nothing when collapsed so the thread reclaims the width.
4
-
5
- import { ScrollArea } from "./ScrollArea";
6
- import React from "react";
7
- import type { Theme } from "../theme";
8
- import { ThreadTree } from "./ThreadTree";
9
- import type { InboxRow } from "./session-tree";
10
-
11
- export interface ThreadsSidebarProps {
12
- open: boolean;
13
- rows: InboxRow[];
14
- cursor: number;
15
- /** The open thread's id, highlighted in the list. */
16
- activeId?: string;
17
- /** Ids of pinned threads. */
18
- pinnedIds?: ReadonlySet<string>;
19
- onSelect: (sessionId: string) => void;
20
- /** Ask to delete a thread (the row menu's Delete, wired to the confirm dialog). */
21
- onRequestDelete?: (id: string, title: string) => void;
22
- /** Toggle a thread's pinned state (the row menu's Pin/Unpin). */
23
- onPin?: (id: string) => void;
24
- /** Rename a thread's title (the row menu's Rename). */
25
- onRename?: (id: string, title: string) => void;
26
- width?: number;
27
- theme?: Theme;
28
- }
29
-
30
- export function ThreadsSidebar({
31
- open,
32
- rows,
33
- cursor,
34
- activeId,
35
- pinnedIds,
36
- onSelect,
37
- onRequestDelete,
38
- onPin,
39
- onRename,
40
- width = 30,
41
- theme,
42
- }: ThreadsSidebarProps): React.ReactNode {
43
- if (!open) return null;
44
-
45
- return (
46
- <box
47
- style={{
48
- width,
49
- flexDirection: "column",
50
- borderStyle: "single",
51
- border: ["right"],
52
- borderColor: theme?.border,
53
- }}
54
- >
55
- <ScrollArea>
56
- <ThreadTree
57
- rows={rows}
58
- cursor={cursor}
59
- activeId={activeId}
60
- pinnedIds={pinnedIds}
61
- width={width}
62
- onSelect={onSelect}
63
- onRequestDelete={onRequestDelete}
64
- onPin={onPin}
65
- onRename={onRename}
66
- theme={theme}
67
- />
68
- </ScrollArea>
69
- </box>
70
- );
71
- }
@@ -1,26 +0,0 @@
1
- import React from "react";
2
- import { DARK } from "../theme";
3
- import type { Story, StoryMeta } from "./story";
4
- import { AgentLauncher } from "./agent-launcher";
5
- import { fixturePlanSession } from "./story-fixtures";
6
-
7
- export const meta: StoryMeta = { title: "Chrome/AgentLauncher" };
8
-
9
- /** The launcher expects the rail's narrow, height-constrained column. */
10
- function RailFrame({ children }: { children: React.ReactNode }): React.ReactNode {
11
- return (
12
- <box style={{ width: 40, height: 24, paddingLeft: 2, backgroundColor: DARK.panel }}>
13
- {children}
14
- </box>
15
- );
16
- }
17
-
18
- export const Launcher: Story = {
19
- render: () => (
20
- <RailFrame>
21
- <AgentLauncher session={fixturePlanSession()} onLaunchHarness={() => {}} theme={DARK} />
22
- </RailFrame>
23
- ),
24
- expectedColors: [DARK.accent],
25
- size: { width: 40, height: 24 },
26
- };
@@ -1,50 +0,0 @@
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 { fixturePlanSession } from "./story-fixtures";
6
- import { waitForText } from "../test-support";
7
- import { AgentLauncher, HARNESS_LAUNCHERS, planHandoffBriefing } from "./agent-launcher";
8
-
9
- describe("planHandoffBriefing", () => {
10
- test("references the session by id and directs the agent to read then annotate, not rewrite", () => {
11
- // Act
12
- const briefing = planHandoffBriefing("ses_42");
13
-
14
- // Assert
15
- expect(briefing).toContain("cueloop session get ses_42");
16
- expect(briefing).toContain("cueloop session annotate ses_42");
17
- expect(briefing).toContain("Do not rewrite the plan");
18
- });
19
- });
20
-
21
- describe("HARNESS_LAUNCHERS", () => {
22
- test("carries the three harnesses with real binary commands (never the `cc` alias)", () => {
23
- // Assert - `cc` resolves to the system C compiler when spawned on a PTY, so
24
- // the Claude harness must run `claude`, the real binary, not the shell alias
25
- expect(HARNESS_LAUNCHERS.map((harness) => harness.command)).toEqual(["claude", "pi", "codex"]);
26
- });
27
- });
28
-
29
- describe("AgentLauncher", () => {
30
- test("renders a button for every harness, in order", async () => {
31
- // Arrange
32
- const setup = await testRender(
33
- <box style={{ width: 44, height: 20, flexDirection: "column" }}>
34
- <AgentLauncher session={fixturePlanSession()} onLaunchHarness={() => {}} theme={DARK} />
35
- </box>,
36
- { width: 44, height: 20 },
37
- );
38
-
39
- // Act
40
- await waitForText(setup, "Claude Code");
41
- const frame = setup.captureCharFrame();
42
-
43
- // Assert - all three launcher names present, in launch order
44
- const names = HARNESS_LAUNCHERS.map((harness) => harness.name);
45
- const positions = names.map((name) => frame.indexOf(name));
46
-
47
- expect(positions.every((position) => position >= 0)).toBe(true);
48
- expect(positions).toEqual(positions.toSorted((left, right) => left - right));
49
- });
50
- });
@@ -1,149 +0,0 @@
1
- /**
2
- * The Agent rail launcher: bring-your-own review harness. Branded claude code /
3
- * pi / codex cards that, on click, run that harness in a herdr split beside the
4
- * review (see launchHarnessInSplit) so a reviewer can ask an agent about the plan
5
- * without leaving the tab. A plan-context toggle seeds a briefing into the split.
6
- */
7
-
8
- import React, { useEffect, useRef, useState } from "react";
9
- import type { Thread } from "@cueloop/schema";
10
- import type { Theme } from "../theme";
11
- import { useComponentTheme } from "./theme-context";
12
- import { FRAME_BORDER_STYLE } from "./primitives/frame";
13
- import {
14
- embeddedTerminalAvailable,
15
- registerTerminalPane,
16
- type TerminalPaneRenderable,
17
- } from "./terminal-pane";
18
-
19
- registerTerminalPane();
20
-
21
- /** A running in-tab agent terminal, handed to the app so it can route keys and detach. */
22
- export interface AgentTerminalHandle {
23
- write: (data: string) => void;
24
- detach: () => void;
25
- }
26
-
27
- /** One launchable harness: its display name and the rail command. */
28
- export interface HarnessLauncher {
29
- id: string;
30
- name: string;
31
- /** The shell command that starts the harness, run in the split. */
32
- command: string;
33
- }
34
-
35
- /** Off-white for the running-terminal header, shared by every harness. */
36
- const HARNESS_HEADER_COLOR = "#e4e6ec";
37
-
38
- /** The bring-your-own harnesses, in launch order. The command is the real binary
39
- * name spawned on a PTY (not a shell alias, so `claude` - never `cc`, which is
40
- * the system C compiler). */
41
- export const HARNESS_LAUNCHERS: HarnessLauncher[] = [
42
- { id: "claude", name: "Claude Code", command: "claude" },
43
- { id: "pi", name: "Pi", command: "pi" },
44
- { id: "codex", name: "OpenAI Codex", command: "codex" },
45
- ];
46
-
47
- /** The briefing typed into a launched harness when the plan-context toggle is on. */
48
- export function planHandoffBriefing(sessionId: string): string {
49
- return `Review this cueloop plan: read it with 'cueloop session get ${sessionId}', then comment with 'cueloop session annotate ${sessionId} --author me --quote "<exact span>" --body "<comment>"'. Do not rewrite the plan.`;
50
- }
51
-
52
- /** Props for the Agent tab body: the session under review plus the launch callbacks. */
53
- export interface AgentLauncherProps {
54
- session: Thread;
55
- /**
56
- * Fallback launch when no embedded terminal ships for this platform: run the
57
- * harness in a herdr split. seedText is the plan-context briefing.
58
- */
59
- onLaunchHarness: (command: string, seedText?: string) => void;
60
- /** Notifies the app of the in-tab terminal handle (or null when detached) so it can route keys. */
61
- onAgentTerminal?: (handle: AgentTerminalHandle | null) => void;
62
- theme?: Theme;
63
- }
64
-
65
- /**
66
- * The Agent tab body: branded launcher cards plus a plan-context toggle. Replaces
67
- * the old dead agent/status/revision placeholder (now a dim footer line). When a
68
- * prebuilt libghostty-vt ships for the platform, a picked harness runs embedded
69
- * inside this tab; otherwise it falls back to a herdr split.
70
- */
71
- export function AgentLauncher({
72
- session,
73
- onLaunchHarness,
74
- onAgentTerminal,
75
- theme,
76
- }: AgentLauncherProps): React.ReactNode {
77
- const tokens = useComponentTheme(theme);
78
- const [seedContext, setSeedContext] = useState(true);
79
- const [running, setRunning] = useState<{ harness: HarnessLauncher; seed?: string } | null>(null);
80
- const paneRef = useRef<TerminalPaneRenderable | null>(null);
81
-
82
- const launch = (harness: HarnessLauncher): void => {
83
- const seed = seedContext ? planHandoffBriefing(session.id) : undefined;
84
-
85
- if (embeddedTerminalAvailable()) setRunning({ harness, seed });
86
- else onLaunchHarness(harness.command, seed);
87
- };
88
- const detach = (): void => {
89
- // The reconciler's removeChild detaches the pane without destroying it, so
90
- // kill the child + free the VT here or they leak. shutdown() is idempotent.
91
- paneRef.current?.shutdown();
92
- setRunning(null);
93
- };
94
-
95
- useEffect(() => {
96
- if (!running) return onAgentTerminal?.(null);
97
- onAgentTerminal?.({ write: (data) => paneRef.current?.write(data), detach });
98
-
99
- return () => onAgentTerminal?.(null);
100
- // eslint-disable-next-line react-hooks/exhaustive-deps
101
- }, [running]);
102
-
103
- if (running) {
104
- return (
105
- <box style={{ flexDirection: "column", flexGrow: 1 }}>
106
- <box style={{ flexDirection: "row" }} onMouseUp={detach}>
107
- <text fg={HARNESS_HEADER_COLOR}>{running.harness.name}</text>
108
- <box style={{ flexGrow: 1 }} />
109
- <text fg={tokens.textDim}>✕ detach</text>
110
- </box>
111
- {React.createElement("terminalPane", {
112
- ref: paneRef,
113
- command: running.harness.command,
114
- cwd: session.artifact.meta.cwd ?? process.cwd(),
115
- seedText: running.seed,
116
- onExit: detach,
117
- style: { flexGrow: 1 },
118
- })}
119
- </box>
120
- );
121
- }
122
-
123
- return (
124
- <box style={{ flexDirection: "column", flexGrow: 1 }}>
125
- {HARNESS_LAUNCHERS.map((harness) => (
126
- <box
127
- key={harness.id}
128
- style={{
129
- flexDirection: "row",
130
- height: 3,
131
- border: true,
132
- borderStyle: FRAME_BORDER_STYLE,
133
- backgroundColor: "transparent",
134
- borderColor: tokens.text,
135
- paddingLeft: 1,
136
- paddingRight: 1,
137
- }}
138
- onMouseUp={() => launch(harness)}
139
- >
140
- <text fg={tokens.textMuted}>{harness.name}</text>
141
- </box>
142
- ))}
143
- <box style={{ flexDirection: "row" }} onMouseUp={() => setSeedContext((on) => !on)}>
144
- <text fg={tokens.textDim}>plan context: </text>
145
- <text fg={tokens.accent}>{seedContext ? "seed the plan" : "none"}</text>
146
- </box>
147
- </box>
148
- );
149
- }