@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61
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.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +37 -33
- package/src/App.appearance.test.tsx +51 -0
- package/src/App.autoclose.test.tsx +73 -47
- package/src/App.diff.test.tsx +169 -22
- package/src/App.export.test.tsx +49 -37
- package/src/App.plan-review.test.tsx +714 -0
- package/src/App.prototype.test.tsx +194 -0
- package/src/App.readonly.test.tsx +69 -35
- package/src/App.rendering.test.tsx +59 -31
- package/src/App.review-panel.test.tsx +143 -0
- package/src/App.submit-confirm.test.tsx +229 -0
- package/src/App.test.tsx +139 -68
- package/src/App.toast.test.tsx +77 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +270 -0
- package/src/app-view-model.ts +307 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +250 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +129 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffSheet.scroll.test.tsx +135 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +65 -0
- package/src/components/DiffSheet.tsx +374 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +64 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +59 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +109 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +75 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +422 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +80 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +450 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +103 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +318 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +147 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-sheet-layout.test.ts +128 -0
- package/src/components/diff-sheet-layout.ts +184 -0
- package/src/components/plan-sheet-run-style.ts +90 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +138 -0
- package/src/components/stories-app.tsx +124 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +75 -0
- package/src/components/syntax-highlight.ts +102 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +193 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +448 -31
- package/src/config.ts +339 -57
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +253 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +176 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +135 -9
- package/src/ghostty-terminal.test.ts +84 -0
- package/src/ghostty-terminal.ts +176 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +17 -21
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +499 -0
- package/src/intent-dispatch.ts +585 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +452 -0
- package/src/keymap.test.ts +635 -0
- package/src/keymap.ts +404 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +289 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +237 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +16 -5
- package/src/serve.test.ts +25 -9
- package/src/serve.ts +19 -8
- package/src/session-controller.curate.test.ts +260 -0
- package/src/session-controller.share.test.ts +320 -0
- package/src/session-controller.ts +993 -0
- package/src/share.ts +120 -0
- package/src/test-support.test.ts +60 -0
- package/src/test-support.ts +197 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +187 -0
- package/src/theme.ts +64 -17
- package/src/use-settings-dialog.tsx +275 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +22 -8
- package/src/view-diff.ts +39 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +554 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
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: "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
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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 { ReviewSession } 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: ReviewSession;
|
|
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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Annotation } from "@cueloop/schema";
|
|
3
|
+
import type { DiffRow } from "../view-diff";
|
|
4
|
+
import type { IntralineRun } from "../diff-intraline";
|
|
5
|
+
import type { SyntaxSpan } from "../diff-syntax";
|
|
6
|
+
import { DARK } from "../theme";
|
|
7
|
+
import {
|
|
8
|
+
annotatedRowsByIndex,
|
|
9
|
+
coloredRowSpans,
|
|
10
|
+
rowContentOffsets,
|
|
11
|
+
rowLine,
|
|
12
|
+
segmentRows,
|
|
13
|
+
} from "./diff-sheet-layout";
|
|
14
|
+
|
|
15
|
+
function row(kind: DiffRow["kind"], text: string, extra: Partial<DiffRow> = {}): DiffRow {
|
|
16
|
+
return { kind, text, file: "a.ts", ...extra };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function annotation(quote: string): Annotation {
|
|
20
|
+
return {
|
|
21
|
+
id: "a1",
|
|
22
|
+
kind: "comment",
|
|
23
|
+
body: "note",
|
|
24
|
+
anchor: { quote, prefix: "", suffix: "" },
|
|
25
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("rowLine", () => {
|
|
30
|
+
test("strips the trailing newline the patch carries", () => {
|
|
31
|
+
// Arrange / Act / Assert
|
|
32
|
+
expect(rowLine(row("add", "const x = 1;\n"))).toBe("const x = 1;");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("annotatedRowsByIndex", () => {
|
|
37
|
+
test("maps an annotation to the code row whose text matches its quote", () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
const rows = [row("file", "a.ts"), row("ctx", "keep"), row("add", "new line")];
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
const byRow = annotatedRowsByIndex(rows, [annotation("new line")]);
|
|
43
|
+
|
|
44
|
+
// Assert
|
|
45
|
+
expect([...byRow.keys()]).toEqual([2]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("segmentRows", () => {
|
|
50
|
+
test("splits header rows out and closes a chunk after an annotated row", () => {
|
|
51
|
+
// Arrange
|
|
52
|
+
const rows = [
|
|
53
|
+
row("file", "a.ts"),
|
|
54
|
+
row("hunk", "@@"),
|
|
55
|
+
row("ctx", "keep"),
|
|
56
|
+
row("add", "changed"),
|
|
57
|
+
];
|
|
58
|
+
const annotatedByRow = new Map<number, Annotation>([[3, annotation("changed")]]);
|
|
59
|
+
|
|
60
|
+
// Act
|
|
61
|
+
const segments = segmentRows(rows, annotatedByRow);
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
64
|
+
expect(segments.map((segment) => segment.kind)).toEqual(["header", "header", "chunk"]);
|
|
65
|
+
const chunk = segments[2];
|
|
66
|
+
|
|
67
|
+
expect(chunk?.kind).toBe("chunk");
|
|
68
|
+
if (chunk?.kind === "chunk") expect(chunk.annotation?.id).toBe("a1");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("closes a chunk at the compose row so a draft card can follow it", () => {
|
|
72
|
+
// Arrange - two code rows, composing on the first
|
|
73
|
+
const rows = [row("add", "one"), row("add", "two")];
|
|
74
|
+
|
|
75
|
+
// Act
|
|
76
|
+
const segments = segmentRows(rows, new Map(), 0);
|
|
77
|
+
|
|
78
|
+
// Assert - the first row is its own chunk, the second a separate chunk
|
|
79
|
+
expect(segments).toHaveLength(2);
|
|
80
|
+
expect(segments.every((segment) => segment.kind === "chunk")).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("rowContentOffsets", () => {
|
|
85
|
+
test("counts one row per header and an extra row for a chunk's annotation card", () => {
|
|
86
|
+
// Arrange
|
|
87
|
+
const rows = [row("file", "a.ts"), row("add", "changed")];
|
|
88
|
+
const annotatedByRow = new Map<number, Annotation>([[1, annotation("changed")]]);
|
|
89
|
+
const segments = segmentRows(rows, annotatedByRow);
|
|
90
|
+
|
|
91
|
+
// Act
|
|
92
|
+
const offsets = rowContentOffsets(segments);
|
|
93
|
+
|
|
94
|
+
// Assert - header at y0, its chunk row at y1; the card would sit at y2
|
|
95
|
+
expect(offsets[0]).toBe(0);
|
|
96
|
+
expect(offsets[1]).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("coloredRowSpans", () => {
|
|
101
|
+
test("a changed word keeps the diff color while unchanged text dims", () => {
|
|
102
|
+
// Arrange - "old" is the change, " tail" unchanged, on a modified line
|
|
103
|
+
const runs: IntralineRun[] = [
|
|
104
|
+
{ text: "old", changed: true },
|
|
105
|
+
{ text: " tail", changed: false },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
// Act
|
|
109
|
+
const spans = coloredRowSpans("old tail", runs, undefined, DARK.deletedForeground, DARK);
|
|
110
|
+
|
|
111
|
+
// Assert
|
|
112
|
+
expect(spans).toEqual([
|
|
113
|
+
{ text: "old", foreground: DARK.deletedForeground },
|
|
114
|
+
{ text: " tail", foreground: DARK.textDim },
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("syntax color applies to unchanged text when there is no intra-line run", () => {
|
|
119
|
+
// Arrange - a keyword span over the whole word, no intra-line runs
|
|
120
|
+
const syntax: SyntaxSpan[] = [{ start: 0, end: 6, group: "keyword" }];
|
|
121
|
+
|
|
122
|
+
// Act
|
|
123
|
+
const spans = coloredRowSpans("return", undefined, syntax, DARK.textMuted, DARK);
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
expect(spans).toEqual([{ text: "return", foreground: DARK.accent }]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure layout logic for the diff review sheet, split out so it can be tested
|
|
3
|
+
* without rendering: grouping rows into chunks around headers and annotations,
|
|
4
|
+
* resolving each row's colored spans (syntax under the intra-line change color),
|
|
5
|
+
* mapping annotations to rows, and the content-y offset of each row for scroll.
|
|
6
|
+
* DiffSheet.tsx is the thin renderer over these.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Annotation } from "@cueloop/schema";
|
|
10
|
+
import type { DiffRow } from "../view-diff";
|
|
11
|
+
import type { Theme } from "../theme";
|
|
12
|
+
import type { IntralineRun } from "../diff-intraline";
|
|
13
|
+
import type { SyntaxSpan } from "../diff-syntax";
|
|
14
|
+
import { colorForSyntaxGroup } from "./syntax-highlight";
|
|
15
|
+
|
|
16
|
+
/** A header row rendered on its own line, or a contiguous run of code rows. */
|
|
17
|
+
export type DiffSegment =
|
|
18
|
+
| { kind: "header"; rowIndex: number; row: DiffRow }
|
|
19
|
+
| { kind: "chunk"; firstRowIndex: number; rows: DiffRow[]; annotation: Annotation | null };
|
|
20
|
+
|
|
21
|
+
/** One rendered span of a row: its text and its resolved foreground color. */
|
|
22
|
+
export interface ColoredSpan {
|
|
23
|
+
text: string;
|
|
24
|
+
foreground: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Row text carries the patch's trailing newline; rendering strips it. */
|
|
28
|
+
export function rowLine(row: DiffRow): string {
|
|
29
|
+
return row.text.replace(/\n$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The annotation anchored to each row, matched by its verbatim quote. */
|
|
33
|
+
export function annotatedRowsByIndex(
|
|
34
|
+
rows: DiffRow[],
|
|
35
|
+
annotations: Annotation[],
|
|
36
|
+
): Map<number, Annotation> {
|
|
37
|
+
const byRow = new Map<number, Annotation>();
|
|
38
|
+
|
|
39
|
+
for (const annotation of annotations) {
|
|
40
|
+
const rowIndex = rows.findIndex(
|
|
41
|
+
(row) =>
|
|
42
|
+
row.text === annotation.anchor.quote &&
|
|
43
|
+
(row.kind === "ctx" || row.kind === "add" || row.kind === "del"),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (rowIndex !== -1) byRow.set(rowIndex, annotation);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return byRow;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Group rows into header segments and code chunks; a chunk closes after an
|
|
53
|
+
* annotated or composed row so a card can render directly below that line. */
|
|
54
|
+
export function segmentRows(
|
|
55
|
+
rows: DiffRow[],
|
|
56
|
+
annotatedByRow: Map<number, Annotation>,
|
|
57
|
+
composeRowIndex?: number,
|
|
58
|
+
): DiffSegment[] {
|
|
59
|
+
const segments: DiffSegment[] = [];
|
|
60
|
+
let chunk: DiffRow[] = [];
|
|
61
|
+
let chunkStart = 0;
|
|
62
|
+
|
|
63
|
+
const closeChunk = (annotation: Annotation | null): void => {
|
|
64
|
+
if (chunk.length) {
|
|
65
|
+
segments.push({ kind: "chunk", firstRowIndex: chunkStart, rows: chunk, annotation });
|
|
66
|
+
}
|
|
67
|
+
chunk = [];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
71
|
+
const row = rows[rowIndex]!;
|
|
72
|
+
|
|
73
|
+
if (row.kind === "file" || row.kind === "hunk") {
|
|
74
|
+
closeChunk(null);
|
|
75
|
+
segments.push({ kind: "header", rowIndex, row });
|
|
76
|
+
chunkStart = rowIndex + 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!chunk.length) chunkStart = rowIndex;
|
|
80
|
+
chunk.push(row);
|
|
81
|
+
|
|
82
|
+
const annotation = annotatedByRow.get(rowIndex);
|
|
83
|
+
|
|
84
|
+
if (annotation) {
|
|
85
|
+
closeChunk(annotation);
|
|
86
|
+
chunkStart = rowIndex + 1;
|
|
87
|
+
} else if (rowIndex === composeRowIndex) {
|
|
88
|
+
closeChunk(null);
|
|
89
|
+
chunkStart = rowIndex + 1;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
closeChunk(null);
|
|
93
|
+
|
|
94
|
+
return segments;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The content-y offset of each row index, so the cursor can be scrolled into
|
|
98
|
+
* view; a chunk's annotation adds one row for its card. */
|
|
99
|
+
export function rowContentOffsets(segments: DiffSegment[]): number[] {
|
|
100
|
+
const offsets: number[] = [];
|
|
101
|
+
let contentY = 0;
|
|
102
|
+
|
|
103
|
+
for (const segment of segments) {
|
|
104
|
+
if (segment.kind === "header") {
|
|
105
|
+
offsets[segment.rowIndex] = contentY;
|
|
106
|
+
contentY += 1;
|
|
107
|
+
} else {
|
|
108
|
+
segment.rows.forEach((_, lineIndex) => {
|
|
109
|
+
offsets[segment.firstRowIndex + lineIndex] = contentY + lineIndex;
|
|
110
|
+
});
|
|
111
|
+
contentY += segment.rows.length + (segment.annotation ? 1 : 0);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return offsets;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Colored spans for one row, resolved per character: an intra-line changed word
|
|
120
|
+
* keeps the diff color, otherwise the syntax color applies, falling back to the
|
|
121
|
+
* dim token on the unchanged part of a modified line or the row's base color.
|
|
122
|
+
* Adjacent same-color characters coalesce so a row stays a few spans.
|
|
123
|
+
*/
|
|
124
|
+
export function coloredRowSpans(
|
|
125
|
+
text: string,
|
|
126
|
+
intralineRuns: IntralineRun[] | undefined,
|
|
127
|
+
syntaxSpans: SyntaxSpan[] | undefined,
|
|
128
|
+
baseColor: string,
|
|
129
|
+
tokens: Theme,
|
|
130
|
+
): ColoredSpan[] {
|
|
131
|
+
const changed = changedColumns(text.length, intralineRuns);
|
|
132
|
+
const syntaxColorByColumn = syntaxColorColumns(text.length, syntaxSpans, tokens);
|
|
133
|
+
const isModifiedLine = intralineRuns !== undefined;
|
|
134
|
+
|
|
135
|
+
const spans: ColoredSpan[] = [];
|
|
136
|
+
|
|
137
|
+
for (let column = 0; column < text.length; column++) {
|
|
138
|
+
const color = changed[column]
|
|
139
|
+
? baseColor
|
|
140
|
+
: (syntaxColorByColumn[column] ?? (isModifiedLine ? tokens.textDim : baseColor));
|
|
141
|
+
const previous = spans[spans.length - 1];
|
|
142
|
+
|
|
143
|
+
if (previous && previous.foreground === color) previous.text += text[column];
|
|
144
|
+
else spans.push({ text: text[column]!, foreground: color });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return spans;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Which columns are the intra-line change on a modified line. */
|
|
151
|
+
function changedColumns(length: number, intralineRuns: IntralineRun[] | undefined): boolean[] {
|
|
152
|
+
const changed = Array.from({ length }, () => false);
|
|
153
|
+
|
|
154
|
+
if (!intralineRuns) return changed;
|
|
155
|
+
let offset = 0;
|
|
156
|
+
|
|
157
|
+
for (const run of intralineRuns) {
|
|
158
|
+
if (run.changed)
|
|
159
|
+
for (let index = 0; index < run.text.length; index++) changed[offset + index] = true;
|
|
160
|
+
offset += run.text.length;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return changed;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The syntax color per column, or undefined where a column has no token. */
|
|
167
|
+
function syntaxColorColumns(
|
|
168
|
+
length: number,
|
|
169
|
+
syntaxSpans: SyntaxSpan[] | undefined,
|
|
170
|
+
tokens: Theme,
|
|
171
|
+
): Array<string | undefined> {
|
|
172
|
+
const colors: Array<string | undefined> = Array.from({ length }, () => undefined);
|
|
173
|
+
|
|
174
|
+
for (const span of syntaxSpans ?? []) {
|
|
175
|
+
const color = colorForSyntaxGroup(span.group, tokens);
|
|
176
|
+
|
|
177
|
+
if (!color) continue;
|
|
178
|
+
for (let column = span.start; column < span.end && column < length; column++) {
|
|
179
|
+
colors[column] = color;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return colors;
|
|
184
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createTextAttributes } from "@opentui/core";
|
|
2
|
+
import type { DisplayBlock, StyleRun } from "../view-plan";
|
|
3
|
+
import type { Theme } from "../theme";
|
|
4
|
+
|
|
5
|
+
/** A cut block reads as removed: struck through and grayed, never red. */
|
|
6
|
+
const CUT_ATTRIBUTES = createTextAttributes({ strikethrough: true, dim: true });
|
|
7
|
+
|
|
8
|
+
/** Inline-emphasis attributes, reused per run so the render loop allocates none. */
|
|
9
|
+
const STRONG_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
10
|
+
const EM_ATTRIBUTES = createTextAttributes({ italic: true });
|
|
11
|
+
const STRIKE_ATTRIBUTES = createTextAttributes({ strikethrough: true });
|
|
12
|
+
const LINK_ATTRIBUTES = createTextAttributes({ underline: true });
|
|
13
|
+
/** Block-level base attributes: headings read bold, blockquotes read italic. */
|
|
14
|
+
const HEADING_ATTRIBUTES = createTextAttributes({ bold: true });
|
|
15
|
+
const QUOTE_ATTRIBUTES = createTextAttributes({ italic: true });
|
|
16
|
+
|
|
17
|
+
interface BlockBaseStyle {
|
|
18
|
+
baseFg: string;
|
|
19
|
+
baseAttributes: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isHeadingBlock(block: DisplayBlock): boolean {
|
|
23
|
+
return block.kind === "h1" || block.kind === "h2" || block.kind === "h3";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// headings are all bold; level reads from descending brightness alone (a
|
|
27
|
+
// terminal cannot scale font size), leaving the salmon accent to annotations
|
|
28
|
+
function headingForeground(block: DisplayBlock, tokens: Theme): string | undefined {
|
|
29
|
+
if (block.kind === "h1") return tokens.text;
|
|
30
|
+
if (block.kind === "h2") return tokens.textMuted;
|
|
31
|
+
if (block.kind === "h3") return tokens.textDim;
|
|
32
|
+
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function blockBaseStyle(block: DisplayBlock, tokens: Theme): BlockBaseStyle {
|
|
37
|
+
const isHeading = isHeadingBlock(block);
|
|
38
|
+
const isQuote = block.kind === "quote";
|
|
39
|
+
const headingFg = headingForeground(block, tokens);
|
|
40
|
+
// block-level base: headings bold, quotes muted italic; inline roles compose on top
|
|
41
|
+
const baseFg = headingFg ?? (isQuote ? tokens.textMuted : tokens.text);
|
|
42
|
+
const baseAttributes = (isHeading ? HEADING_ATTRIBUTES : 0) | (isQuote ? QUOTE_ATTRIBUTES : 0);
|
|
43
|
+
|
|
44
|
+
return { baseFg, baseAttributes };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function inlineRoleStyle(
|
|
48
|
+
run: StyleRun,
|
|
49
|
+
base: BlockBaseStyle,
|
|
50
|
+
tokens: Theme,
|
|
51
|
+
): { fg?: string; bg?: string; attributes?: number } {
|
|
52
|
+
const { baseFg, baseAttributes } = base;
|
|
53
|
+
const withBase = (roleAttributes: number): number => baseAttributes | roleAttributes;
|
|
54
|
+
|
|
55
|
+
switch (run.role) {
|
|
56
|
+
case "ins":
|
|
57
|
+
return { fg: tokens.insertedForeground, attributes: baseAttributes || undefined };
|
|
58
|
+
case "del":
|
|
59
|
+
return { fg: tokens.deletedForeground };
|
|
60
|
+
case "mark-comment":
|
|
61
|
+
return { fg: tokens.text, bg: tokens.markCommentBackground };
|
|
62
|
+
case "mark-focus":
|
|
63
|
+
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
64
|
+
case "kspan":
|
|
65
|
+
return { fg: tokens.accentInk, bg: tokens.accent };
|
|
66
|
+
case "strong":
|
|
67
|
+
return { fg: baseFg, attributes: withBase(STRONG_ATTRIBUTES) };
|
|
68
|
+
case "em":
|
|
69
|
+
return { fg: baseFg, attributes: withBase(EM_ATTRIBUTES) };
|
|
70
|
+
case "code":
|
|
71
|
+
return { fg: tokens.text, bg: tokens.elevated };
|
|
72
|
+
case "strike":
|
|
73
|
+
return { fg: tokens.textMuted, attributes: withBase(STRIKE_ATTRIBUTES) };
|
|
74
|
+
case "link":
|
|
75
|
+
return { fg: tokens.blue, attributes: withBase(LINK_ATTRIBUTES) };
|
|
76
|
+
default:
|
|
77
|
+
return { fg: baseFg, attributes: baseAttributes || undefined };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function runStyle(
|
|
82
|
+
run: StyleRun,
|
|
83
|
+
block: DisplayBlock,
|
|
84
|
+
tokens: Theme,
|
|
85
|
+
): { fg?: string; bg?: string; attributes?: number } {
|
|
86
|
+
// a cut block reads as removed: every run struck through and grayed, never red
|
|
87
|
+
if (block.type === "del") return { fg: tokens.textDim, attributes: CUT_ATTRIBUTES };
|
|
88
|
+
|
|
89
|
+
return inlineRoleStyle(run, blockBaseStyle(block, tokens), tokens);
|
|
90
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../story";
|
|
4
|
+
import { Button } from "./Button";
|
|
5
|
+
import { Toolbar } from "./Toolbar";
|
|
6
|
+
|
|
7
|
+
export const meta: StoryMeta = { title: "primitives/Button" };
|
|
8
|
+
|
|
9
|
+
export const Variants: Story = {
|
|
10
|
+
render: () => (
|
|
11
|
+
<Toolbar>
|
|
12
|
+
<Button variant="solid" marginRight={2} onPress={() => {}}>
|
|
13
|
+
{" Save "}
|
|
14
|
+
</Button>
|
|
15
|
+
<Button marginRight={2} onPress={() => {}}>
|
|
16
|
+
{" Cancel esc "}
|
|
17
|
+
</Button>
|
|
18
|
+
<Button variant="accent-text" onPress={() => {}}>
|
|
19
|
+
{"Submit review"}
|
|
20
|
+
</Button>
|
|
21
|
+
</Toolbar>
|
|
22
|
+
),
|
|
23
|
+
expectedColors: [DARK.accent, DARK.accentInk, DARK.textDim],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const Disabled: Story = {
|
|
27
|
+
render: () => (
|
|
28
|
+
<Button isDisabled onPress={() => {}}>
|
|
29
|
+
{" Edit "}
|
|
30
|
+
</Button>
|
|
31
|
+
),
|
|
32
|
+
};
|