@cueloop/client 0.1.0-alpha.96 → 0.1.0-alpha.98
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/package.json +5 -5
- package/src/annotations/components/AnnotationCards.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +29 -6
- package/src/annotations/use-annotation-surface.tsx +245 -30
- package/src/app/App.thread-agent.test.ts +12 -0
- package/src/app/App.tsx +217 -97
- package/src/app/app-screens.tsx +2 -0
- package/src/app/components/AppShell.zoom.test.tsx +4 -3
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/diff/components/AnnotatableFileView.tsx +6 -0
- package/src/diff/components/DiffContentView.tsx +9 -0
- package/src/markdown/components/ThreadView.tsx +60 -16
- package/src/settings/config.test.ts +15 -0
- package/src/settings/config.ts +14 -4
- package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
- package/src/thread/agent-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +332 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +110 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +780 -0
- package/src/thread/components/AgentThreadPane.tsx +356 -0
- package/src/thread/components/ThreadFooter.tsx +3 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +25 -5
- package/src/thread/thread-controller.ts +34 -0
- package/src/thread/use-thread-agent.test.tsx +65 -0
- package/src/thread/use-thread-agent.ts +101 -0
- package/src/workbench/components/GridTabContent.tsx +6 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../../appearance/theme";
|
|
3
|
+
import type { Story, StoryMeta } from "../../stories/story";
|
|
4
|
+
import { AgentConfigControls } from "./AgentConfigControls";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "Chrome/AgentConfigControls" };
|
|
7
|
+
|
|
8
|
+
export const Choices: Story = {
|
|
9
|
+
size: { width: 40, height: 2 },
|
|
10
|
+
expectedColors: [DARK.textMuted],
|
|
11
|
+
render: () => (
|
|
12
|
+
<AgentConfigControls
|
|
13
|
+
theme={DARK}
|
|
14
|
+
onConfigure={() => {}}
|
|
15
|
+
state={{
|
|
16
|
+
threadId: "thread",
|
|
17
|
+
phase: { kind: "idle" },
|
|
18
|
+
messages: [],
|
|
19
|
+
tools: [],
|
|
20
|
+
comments: [],
|
|
21
|
+
configOptions: [
|
|
22
|
+
{
|
|
23
|
+
id: "model",
|
|
24
|
+
name: "Model",
|
|
25
|
+
currentValue: "test",
|
|
26
|
+
options: [{ value: "test", name: "Test model" }],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "effort",
|
|
30
|
+
name: "Reasoning",
|
|
31
|
+
category: "thought_level",
|
|
32
|
+
currentValue: "medium",
|
|
33
|
+
options: [{ value: "medium", name: "Medium" }],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
),
|
|
39
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { DARK } from "../../appearance/theme";
|
|
5
|
+
import { RootOverlayProvider } from "../../ui/components/RootOverlay";
|
|
6
|
+
import { MenuControlProvider, useMenuControlState } from "../../ui/components/menu-control";
|
|
7
|
+
import { locateText, waitForText, settle } from "../../testing/test-support";
|
|
8
|
+
import { AgentConfigControls } from "./AgentConfigControls";
|
|
9
|
+
import type { ThreadAgentState } from "@cueloop/schema";
|
|
10
|
+
|
|
11
|
+
const initial: ThreadAgentState = {
|
|
12
|
+
threadId: "test",
|
|
13
|
+
phase: { kind: "idle" },
|
|
14
|
+
comments: [],
|
|
15
|
+
messages: [],
|
|
16
|
+
tools: [],
|
|
17
|
+
configOptions: [
|
|
18
|
+
{
|
|
19
|
+
id: "model",
|
|
20
|
+
name: "Model",
|
|
21
|
+
currentValue: "first",
|
|
22
|
+
options: [
|
|
23
|
+
{ value: "first", name: "First model" },
|
|
24
|
+
{ value: "second", name: "Second model" },
|
|
25
|
+
...Array.from({ length: 18 }, (_, index) => ({
|
|
26
|
+
value: `extra-${index}`,
|
|
27
|
+
name: `Extra model ${index}`,
|
|
28
|
+
})),
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "effort",
|
|
33
|
+
name: "Reasoning",
|
|
34
|
+
category: "thought_level",
|
|
35
|
+
currentValue: "medium",
|
|
36
|
+
options: [
|
|
37
|
+
{ value: "medium", name: "Medium" },
|
|
38
|
+
{ value: "high", name: "High" },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
test("model and reasoning choices share the existing overlay and close after selection", async () => {
|
|
45
|
+
const choices: string[] = [];
|
|
46
|
+
function TestMenus(): React.ReactNode {
|
|
47
|
+
const menu = useMenuControlState();
|
|
48
|
+
const [state, setState] = useState(initial);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<MenuControlProvider value={menu}>
|
|
52
|
+
<RootOverlayProvider>
|
|
53
|
+
<box style={{ height: 20, flexDirection: "column" }}>
|
|
54
|
+
<box style={{ flexGrow: 1 }}>
|
|
55
|
+
<text>Thread body</text>
|
|
56
|
+
</box>
|
|
57
|
+
<AgentConfigControls
|
|
58
|
+
state={state}
|
|
59
|
+
theme={DARK}
|
|
60
|
+
onConfigure={(id, value) => {
|
|
61
|
+
choices.push(`${id}:${value}`);
|
|
62
|
+
setState({
|
|
63
|
+
...state,
|
|
64
|
+
configOptions: state.configOptions?.map((option) =>
|
|
65
|
+
option.id === id ? { ...option, currentValue: value } : option,
|
|
66
|
+
),
|
|
67
|
+
});
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
</box>
|
|
71
|
+
</RootOverlayProvider>
|
|
72
|
+
</MenuControlProvider>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const setup = await testRender(<TestMenus />, { width: 80, height: 20 });
|
|
76
|
+
const click = async (text: string) => {
|
|
77
|
+
const point = locateText(setup, text);
|
|
78
|
+
|
|
79
|
+
await setup.mockMouse.click(point.column, point.row);
|
|
80
|
+
await settle(setup);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await waitForText(setup, "First model");
|
|
85
|
+
await click("First model");
|
|
86
|
+
await waitForText(setup, "Second model");
|
|
87
|
+
await click("Second model");
|
|
88
|
+
expect(choices).toEqual(["model:second"]);
|
|
89
|
+
expect(setup.captureCharFrame()).not.toContain("First model");
|
|
90
|
+
await click("Medium");
|
|
91
|
+
await waitForText(setup, "High");
|
|
92
|
+
await click("High");
|
|
93
|
+
expect(choices).toEqual(["model:second", "effort:high"]);
|
|
94
|
+
expect(setup.captureCharFrame()).not.toContain("Medium");
|
|
95
|
+
await click("Second model");
|
|
96
|
+
expect(setup.captureCharFrame()).not.toContain("Extra model 17");
|
|
97
|
+
const model = locateText(setup, "First model");
|
|
98
|
+
for (let count = 0; count < 12; count++)
|
|
99
|
+
await setup.mockMouse.scroll(model.column, model.row, "down");
|
|
100
|
+
await settle(setup);
|
|
101
|
+
await waitForText(setup, "Extra model 17");
|
|
102
|
+
await click("Extra model 17");
|
|
103
|
+
expect(choices.at(-1)).toBe("model:extra-17");
|
|
104
|
+
await click("Extra model 17");
|
|
105
|
+
await click("Thread body");
|
|
106
|
+
expect(setup.captureCharFrame()).not.toContain("First model");
|
|
107
|
+
} finally {
|
|
108
|
+
setup.renderer.destroy();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
import type { BoxRenderable } from "@opentui/core";
|
|
3
|
+
import type { ThreadAgentState, AgentConfigOption } from "@cueloop/schema";
|
|
4
|
+
import type { Theme } from "../../appearance/theme";
|
|
5
|
+
import { useMenuControl } from "../../ui/components/menu-control";
|
|
6
|
+
import { useRootOverlay } from "../../ui/components/RootOverlay";
|
|
7
|
+
import { useFrameMeasure } from "../../ui/use-frame-measure";
|
|
8
|
+
|
|
9
|
+
/** Model and reasoning menus reuse the shell's measured, mutually exclusive overlay. */
|
|
10
|
+
export function AgentConfigControls({
|
|
11
|
+
state,
|
|
12
|
+
theme,
|
|
13
|
+
onConfigure,
|
|
14
|
+
}: {
|
|
15
|
+
state: ThreadAgentState;
|
|
16
|
+
theme: Theme;
|
|
17
|
+
onConfigure: (id: string, value: string) => void;
|
|
18
|
+
}): React.ReactNode {
|
|
19
|
+
return (
|
|
20
|
+
<box style={{ flexDirection: "row", gap: 2, marginRight: 2 }}>
|
|
21
|
+
{state.configOptions
|
|
22
|
+
?.filter(
|
|
23
|
+
(option) =>
|
|
24
|
+
option.category === "thought_level" ||
|
|
25
|
+
option.id === "model" ||
|
|
26
|
+
option.id === "provider",
|
|
27
|
+
)
|
|
28
|
+
.map((option) => (
|
|
29
|
+
<AgentConfigMenu
|
|
30
|
+
key={option.id}
|
|
31
|
+
option={option}
|
|
32
|
+
theme={theme}
|
|
33
|
+
disabled={state.phase.kind === "running" || state.phase.kind === "permission"}
|
|
34
|
+
onConfigure={onConfigure}
|
|
35
|
+
/>
|
|
36
|
+
))}
|
|
37
|
+
</box>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function AgentConfigMenu({
|
|
42
|
+
option,
|
|
43
|
+
theme,
|
|
44
|
+
disabled,
|
|
45
|
+
onConfigure,
|
|
46
|
+
}: {
|
|
47
|
+
option: AgentConfigOption;
|
|
48
|
+
theme: Theme;
|
|
49
|
+
disabled: boolean;
|
|
50
|
+
onConfigure: (id: string, value: string) => void;
|
|
51
|
+
}): React.ReactNode {
|
|
52
|
+
const menu = useMenuControl();
|
|
53
|
+
const overlay = useRootOverlay();
|
|
54
|
+
const ref = useRef<BoxRenderable | null>(null);
|
|
55
|
+
const open = menu.openMenuId === `agent-config:${option.id}`;
|
|
56
|
+
const anchor = useFrameMeasure(
|
|
57
|
+
() => ({ x: ref.current?.x ?? 0, y: ref.current?.y ?? 0 }),
|
|
58
|
+
(a, b) => a.x === b.x && a.y === b.y,
|
|
59
|
+
{ x: 0, y: 0 },
|
|
60
|
+
open,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!open) return;
|
|
65
|
+
overlay.setOverlay(
|
|
66
|
+
"agent-config",
|
|
67
|
+
<box
|
|
68
|
+
onMouseUp={menu.closeMenu}
|
|
69
|
+
style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%" }}
|
|
70
|
+
>
|
|
71
|
+
<box
|
|
72
|
+
onMouseUp={(event) => event.stopPropagation()}
|
|
73
|
+
style={{
|
|
74
|
+
position: "absolute",
|
|
75
|
+
left: Math.max(0, anchor.x - 20),
|
|
76
|
+
top: Math.max(0, anchor.y - Math.min(10, option.options.length) - 2),
|
|
77
|
+
width: 32,
|
|
78
|
+
border: true,
|
|
79
|
+
borderColor: theme.border,
|
|
80
|
+
backgroundColor: theme.elevated,
|
|
81
|
+
flexDirection: "column",
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<scrollbox style={{ height: Math.min(10, option.options.length) }}>
|
|
85
|
+
{option.options.map((choice) => (
|
|
86
|
+
<text
|
|
87
|
+
key={choice.value}
|
|
88
|
+
fg={choice.value === option.currentValue ? theme.accent : theme.text}
|
|
89
|
+
onMouseUp={() => {
|
|
90
|
+
onConfigure(option.id, choice.value);
|
|
91
|
+
menu.closeMenu();
|
|
92
|
+
}}
|
|
93
|
+
>{`${choice.value === option.currentValue ? "✓ " : " "}${choice.name}`}</text>
|
|
94
|
+
))}
|
|
95
|
+
</scrollbox>
|
|
96
|
+
</box>
|
|
97
|
+
</box>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return () => overlay.clearOverlay("agent-config");
|
|
101
|
+
}, [open, anchor, option, theme, menu, overlay, onConfigure]);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<box
|
|
105
|
+
ref={ref}
|
|
106
|
+
onMouseUp={disabled ? undefined : () => menu.toggleMenu(`agent-config:${option.id}`)}
|
|
107
|
+
>
|
|
108
|
+
<text fg={disabled ? theme.textDim : theme.textMuted}>
|
|
109
|
+
{option.options.find((choice) => choice.value === option.currentValue)?.name ??
|
|
110
|
+
option.currentValue}
|
|
111
|
+
</text>
|
|
112
|
+
</box>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { SCHEMA_VERSION, type Thread, type ThreadAgentState } from "@cueloop/schema";
|
|
3
|
+
import { DARK } from "../../appearance/theme";
|
|
4
|
+
import type { Story, StoryMeta } from "../../stories/story";
|
|
5
|
+
import type { ThreadAgentClient } from "../use-thread-agent";
|
|
6
|
+
import { AgentThreadPane } from "./AgentThreadPane";
|
|
7
|
+
|
|
8
|
+
/** Native agent Thread states run without spawning a process or contacting a model. */
|
|
9
|
+
export const meta: StoryMeta = { title: "Surfaces/AgentThreadPane" };
|
|
10
|
+
const noop = () => {};
|
|
11
|
+
const thread: Thread = {
|
|
12
|
+
schemaVersion: SCHEMA_VERSION,
|
|
13
|
+
id: "agent-story",
|
|
14
|
+
workspace: { repoRoot: "/project", branch: "main" },
|
|
15
|
+
artifact: { type: "plan", content: "Keep the retry bounded.", meta: {} },
|
|
16
|
+
revisions: [],
|
|
17
|
+
annotations: [],
|
|
18
|
+
message: null,
|
|
19
|
+
status: "pending",
|
|
20
|
+
createdAt: "2026-10-04T00:00:00Z",
|
|
21
|
+
};
|
|
22
|
+
const state: ThreadAgentState = {
|
|
23
|
+
threadId: thread.id,
|
|
24
|
+
harness: { id: "fx", label: "fx" },
|
|
25
|
+
phase: { kind: "idle" },
|
|
26
|
+
comments: [],
|
|
27
|
+
messages: [
|
|
28
|
+
{
|
|
29
|
+
id: "question",
|
|
30
|
+
role: "user",
|
|
31
|
+
text: "Why does the retry survive cancellation?",
|
|
32
|
+
complete: true,
|
|
33
|
+
revision: 1,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "answer",
|
|
37
|
+
role: "agent",
|
|
38
|
+
text: "The timer survives cancellation. Clear it when the request ends.",
|
|
39
|
+
complete: true,
|
|
40
|
+
revision: 1,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
tools: [
|
|
44
|
+
{
|
|
45
|
+
id: "read",
|
|
46
|
+
turnId: "question",
|
|
47
|
+
title: "Read retry.ts",
|
|
48
|
+
kind: "read",
|
|
49
|
+
status: "completed",
|
|
50
|
+
output: "",
|
|
51
|
+
locations: [],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function renderAgentStory(value: ThreadAgentState): React.ReactNode {
|
|
57
|
+
const client: ThreadAgentClient = {
|
|
58
|
+
agentGet: async () => value,
|
|
59
|
+
agentPrompt: async () => value,
|
|
60
|
+
agentCancel: async () => value,
|
|
61
|
+
agentComment: async () => value,
|
|
62
|
+
agentPermission: async () => value,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<AgentThreadPane
|
|
67
|
+
thread={thread}
|
|
68
|
+
client={client}
|
|
69
|
+
focused
|
|
70
|
+
theme={DARK}
|
|
71
|
+
onActiveChange={noop}
|
|
72
|
+
onOpenFile={noop}
|
|
73
|
+
>
|
|
74
|
+
<text>Keep the retry bounded.</text>
|
|
75
|
+
</AgentThreadPane>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A completed answer is a selectable document with grouped tool activity. */
|
|
80
|
+
export const CompletedAnswer: Story = {
|
|
81
|
+
render: () => renderAgentStory(state),
|
|
82
|
+
size: { width: 80, height: 28 },
|
|
83
|
+
expectedColors: [DARK.text],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** A permission request stays beside the conversation until the reviewer responds. */
|
|
87
|
+
export const WaitingPermission: Story = {
|
|
88
|
+
render: () =>
|
|
89
|
+
renderAgentStory({
|
|
90
|
+
...state,
|
|
91
|
+
phase: {
|
|
92
|
+
kind: "permission",
|
|
93
|
+
permission: {
|
|
94
|
+
id: "permission",
|
|
95
|
+
title: "Update retry.ts",
|
|
96
|
+
options: [
|
|
97
|
+
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
|
|
98
|
+
{ optionId: "reject", name: "Reject once", kind: "reject_once" },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
size: { width: 80, height: 28 },
|
|
104
|
+
expectedColors: [DARK.blue],
|
|
105
|
+
};
|