@cueloop/client 0.1.0-alpha.101 → 0.1.0-alpha.103
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/annotation-palette.ts +1 -0
- package/src/annotations/components/AnnotationCards.tsx +14 -1
- package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
- package/src/annotations/discussions.ts +1 -0
- package/src/annotations/thread-selection.ts +6 -0
- package/src/annotations/use-annotation-surface.tsx +154 -19
- package/src/app/App.deleted-scroll.test.tsx +3 -0
- package/src/app/App.harness-settings.test.tsx +77 -0
- package/src/app/App.plan-render.test.tsx +2 -0
- package/src/app/App.plan-review.test.tsx +1 -0
- package/src/app/App.test.tsx +1 -0
- package/src/app/App.tsx +72 -6
- package/src/app/App.visual.test.tsx +1 -0
- package/src/app/App.walk.test.tsx +1 -0
- package/src/app/App.workbench.test.tsx +1 -0
- package/src/app/app-screens.tsx +1 -0
- package/src/app/app-view-model.ts +7 -0
- package/src/app/components/WorkspacePanels.tsx +1 -0
- package/src/app/intent-dispatch.test.ts +1 -0
- package/src/app/intent-dispatch.ts +26 -0
- package/src/app/ready-signal.ts +2 -0
- package/src/app/run.ts +2 -0
- package/src/app/serve.test.ts +4 -0
- package/src/diff/components/AnnotatableFileView.tsx +3 -2
- package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
- package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
- package/src/diff/components/DiffContentView.tsx +28 -3
- package/src/diff/components/diff-content-view-layout.ts +3 -0
- package/src/diff/diff-fold.ts +4 -0
- package/src/diff/diff-hunk-curate.ts +3 -0
- package/src/diff/diff-intraline.ts +6 -0
- package/src/diff/diff-syntax.ts +8 -0
- package/src/diff/split-diff.ts +6 -0
- package/src/diff/view-diff.ts +18 -0
- package/src/diff/walk.ts +3 -0
- package/src/integrations/attribution.ts +2 -0
- package/src/integrations/client-extension-registry.ts +4 -0
- package/src/integrations/clipboard.ts +1 -0
- package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
- package/src/integrations/components/ShareDialog.tsx +28 -0
- package/src/integrations/editor.ts +3 -0
- package/src/integrations/github-identity.test.ts +1 -0
- package/src/integrations/github-identity.ts +1 -0
- package/src/integrations/herdr.ts +2 -0
- package/src/integrations/pasted-image.ts +2 -0
- package/src/integrations/share.ts +4 -0
- package/src/keyboard/components/HandleChipsInput.tsx +4 -0
- package/src/keyboard/components/SlashComposer.tsx +3 -0
- package/src/keyboard/key-bindings.ts +3 -0
- package/src/keyboard/keymap.ts +51 -0
- package/src/keyboard/skills.ts +2 -0
- package/src/keyboard/slash-palette.ts +8 -0
- package/src/keyboard/thread-chords.ts +5 -0
- package/src/keyboard/use-shared-keyboard.ts +1 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
- package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
- package/src/markdown/components/ThreadView.tsx +16 -3
- package/src/markdown/mark-runs.ts +4 -0
- package/src/markdown/markdown-editor-syntax-style.ts +1 -0
- package/src/markdown/markdown-grid.ts +6 -0
- package/src/markdown/markdown-highlight.ts +1 -0
- package/src/markdown/thread-code-syntax.ts +3 -0
- package/src/markdown/view-plan.ts +52 -0
- package/src/performance/perf-report.ts +1 -0
- package/src/performance/perf-timings.ts +1 -0
- package/src/prototype/components/PrototypeContentView.tsx +17 -0
- package/src/prototype/prototype-browser.ts +11 -0
- package/src/settings/components/SettingsDialog.tsx +3 -0
- package/src/settings/config.test.ts +41 -0
- package/src/settings/config.ts +44 -0
- package/src/settings/use-settings-dialog.test.tsx +98 -0
- package/src/settings/use-settings-dialog.tsx +38 -1
- package/src/settings/use-thread-harness-setting.test.tsx +87 -0
- package/src/settings/use-thread-harness-setting.ts +86 -0
- package/src/stories/stories.test.tsx +4 -0
- package/src/stories/story.ts +6 -0
- package/src/terminal/ansi-screen.ts +5 -0
- package/src/terminal/components/terminal-pane.ts +12 -0
- package/src/terminal/ghostty-terminal.ts +10 -0
- package/src/terminal/kitty-image.ts +3 -0
- package/src/terminal/pty.ts +9 -0
- package/src/testing/test-support.test.ts +2 -0
- package/src/testing/test-support.ts +16 -0
- package/src/thread/agent-continuation.test.ts +2 -0
- package/src/thread/agent-transcript.ts +18 -10
- package/src/thread/components/AgentConfigControls.tsx +1 -0
- package/src/thread/components/AgentThreadPane.test.tsx +257 -1
- package/src/thread/components/AgentThreadPane.tsx +48 -16
- package/src/thread/components/ThreadTree.tsx +1 -0
- package/src/thread/components/thread-tree.test.ts +1 -0
- package/src/thread/thread-controller.curate.test.ts +2 -0
- package/src/thread/thread-controller.ts +123 -0
- package/src/thread/tree-view.ts +4 -0
- package/src/thread/use-thread-agent.test.tsx +1 -0
- package/src/thread/use-thread-agent.ts +5 -0
- package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
- package/src/ui/components/OverlayScrollbar.tsx +5 -0
- package/src/ui/components/PromptDialog.tsx +2 -0
- package/src/ui/components/RootOverlay.tsx +1 -0
- package/src/ui/components/ScrollArea.tsx +2 -0
- package/src/ui/components/primitives/Button.tsx +1 -0
- package/src/ui/components/primitives/Tabs.tsx +1 -0
- package/src/ui/components/primitives/Tree.tsx +4 -0
- package/src/ui/components/primitives/tree-model.ts +7 -0
- package/src/ui/components/syntax-highlight.ts +5 -0
- package/src/ui/components/truncate-title.ts +1 -0
- package/src/ui/use-frame-measure.ts +3 -0
- package/src/ui/use-terminal-virtualizer.ts +6 -0
- package/src/workbench/components/ChangesColumn.tsx +7 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
- package/src/workbench/components/EditorGrid.tsx +8 -0
- package/src/workbench/components/FileContentsView.tsx +2 -0
- package/src/workbench/components/GridTabContent.tsx +2 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
- package/src/workbench/components/ProjectTreeView.tsx +8 -0
- package/src/workbench/components/TreePane.tsx +2 -0
- package/src/workbench/components/editor-grid.ts +3 -0
- package/src/workbench/components/file-tree.ts +2 -0
- package/src/workbench/use-changes-workbench.ts +5 -0
- package/src/workbench/use-remember-layout.ts +2 -0
- package/src/workbench/use-repo-changes.ts +2 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import React, { useState } from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import type { ThreadAgentClient } from "../thread/use-thread-agent";
|
|
8
|
+
import type { ThreadAgentState } from "@cueloop/schema";
|
|
9
|
+
import { clickText, isolateUserConfig, waitForText } from "../testing/test-support";
|
|
10
|
+
import { useThreadHarnessSetting } from "./use-thread-harness-setting";
|
|
11
|
+
|
|
12
|
+
function TestHarnessSwitchQueue({ client }: { client: ThreadAgentClient }) {
|
|
13
|
+
const [preferred, setPreferred] = useState("pending");
|
|
14
|
+
const setting = useThreadHarnessSetting({
|
|
15
|
+
thread: { id: "switch-queue" },
|
|
16
|
+
enabled: true,
|
|
17
|
+
owner: true,
|
|
18
|
+
preferred: "pi",
|
|
19
|
+
client,
|
|
20
|
+
onState() {},
|
|
21
|
+
onPreferred: setPreferred,
|
|
22
|
+
onError: (message) => setPreferred(message),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<box>
|
|
27
|
+
<text onMouseDown={() => setting.switchHarness("fx")}>Select fx</text>
|
|
28
|
+
<text onMouseDown={() => setting.switchHarness("pi")}>Select pi</text>
|
|
29
|
+
<text>Preference: {preferred}</text>
|
|
30
|
+
</box>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("a delayed switch cannot overtake the latest click or persist an older preference", async () => {
|
|
35
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-switch-queue-"));
|
|
36
|
+
const restore = isolateUserConfig(home);
|
|
37
|
+
const started = Promise.withResolvers<void>();
|
|
38
|
+
const release = Promise.withResolvers<void>();
|
|
39
|
+
const calls: string[] = [];
|
|
40
|
+
const state: ThreadAgentState = {
|
|
41
|
+
threadId: "switch-queue",
|
|
42
|
+
phase: { kind: "idle" },
|
|
43
|
+
messages: [],
|
|
44
|
+
comments: [],
|
|
45
|
+
tools: [],
|
|
46
|
+
};
|
|
47
|
+
const setup = await testRender(
|
|
48
|
+
<TestHarnessSwitchQueue
|
|
49
|
+
client={{
|
|
50
|
+
agentGet: async () => state,
|
|
51
|
+
agentPrompt: async () => state,
|
|
52
|
+
agentCancel: async () => state,
|
|
53
|
+
agentComment: async () => state,
|
|
54
|
+
agentPermission: async () => state,
|
|
55
|
+
async agentConfigure(params) {
|
|
56
|
+
calls.push(params.value!);
|
|
57
|
+
|
|
58
|
+
if (calls.length === 1) {
|
|
59
|
+
started.resolve();
|
|
60
|
+
await release.promise;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return state;
|
|
64
|
+
},
|
|
65
|
+
}}
|
|
66
|
+
/>,
|
|
67
|
+
{ width: 60, height: 10 },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await waitForText(setup, "Select fx");
|
|
72
|
+
await clickText(setup, "Select fx");
|
|
73
|
+
await started.promise;
|
|
74
|
+
await clickText(setup, "Select pi");
|
|
75
|
+
await clickText(setup, "Select fx");
|
|
76
|
+
await clickText(setup, "Select pi");
|
|
77
|
+
expect(calls).toEqual(["fx"]);
|
|
78
|
+
release.resolve();
|
|
79
|
+
await waitForText(setup, "Preference: pi");
|
|
80
|
+
expect(calls).toEqual(["fx", "pi"]);
|
|
81
|
+
} finally {
|
|
82
|
+
release.resolve();
|
|
83
|
+
setup.renderer.destroy();
|
|
84
|
+
restore();
|
|
85
|
+
rmSync(home, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useRef, useEffect } from "react";
|
|
2
|
+
import { DaemonClient } from "@cueloop/daemon/client";
|
|
3
|
+
import type { ThreadAgentState, Thread } from "@cueloop/schema";
|
|
4
|
+
import type { ThreadAgentClient } from "../thread/use-thread-agent";
|
|
5
|
+
import { persistThreadHarness } from "./config";
|
|
6
|
+
|
|
7
|
+
interface ThreadHarnessSettingOptions {
|
|
8
|
+
home?: string;
|
|
9
|
+
thread?: Pick<Thread, "id"> | null;
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
owner: boolean;
|
|
12
|
+
state?: ThreadAgentState;
|
|
13
|
+
preferred: "pi" | "fx";
|
|
14
|
+
client?: ThreadAgentClient;
|
|
15
|
+
onState: (state: ThreadAgentState) => void;
|
|
16
|
+
onPreferred: (harness: "pi" | "fx") => void;
|
|
17
|
+
onError: (message: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Preferences apply to new Threads; a bound Thread switches through the owner's daemon. */
|
|
21
|
+
export function useThreadHarnessSetting(options: ThreadHarnessSettingOptions) {
|
|
22
|
+
const latest = useRef(options);
|
|
23
|
+
const generation = useRef(0);
|
|
24
|
+
const pending = useRef(Promise.resolve());
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
latest.current = options;
|
|
28
|
+
});
|
|
29
|
+
const threadId = options.thread?.id;
|
|
30
|
+
const current = options.state?.threadId === threadId ? options.state : undefined;
|
|
31
|
+
const selected =
|
|
32
|
+
current?.handoff?.requestedTarget ??
|
|
33
|
+
current?.handoff?.target ??
|
|
34
|
+
current?.harness?.id ??
|
|
35
|
+
options.preferred;
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
enabled: options.enabled && options.owner,
|
|
39
|
+
harness: selected === "fx" ? ("fx" as const) : ("pi" as const),
|
|
40
|
+
switchHarness(harness: "pi" | "fx") {
|
|
41
|
+
const request = latest.current;
|
|
42
|
+
const requestedId = request.thread?.id;
|
|
43
|
+
|
|
44
|
+
if (!request.enabled || !request.owner) return;
|
|
45
|
+
|
|
46
|
+
const requestedGeneration = ++generation.current;
|
|
47
|
+
|
|
48
|
+
pending.current = pending.current.then(async () => {
|
|
49
|
+
if (generation.current !== requestedGeneration) return;
|
|
50
|
+
|
|
51
|
+
let connection: DaemonClient | undefined;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (requestedId) {
|
|
55
|
+
const api =
|
|
56
|
+
request.client ??
|
|
57
|
+
(connection = await DaemonClient.connect({ home: request.home, autostart: true }));
|
|
58
|
+
|
|
59
|
+
if (generation.current !== requestedGeneration) return;
|
|
60
|
+
|
|
61
|
+
if (!api.agentConfigure) throw new Error("Thread harness switching is unavailable");
|
|
62
|
+
|
|
63
|
+
const state = await api.agentConfigure({
|
|
64
|
+
id: requestedId,
|
|
65
|
+
configId: "harness",
|
|
66
|
+
value: harness,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (generation.current !== requestedGeneration) return;
|
|
70
|
+
|
|
71
|
+
if (latest.current.thread?.id === requestedId) latest.current.onState(state);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
persistThreadHarness(harness);
|
|
75
|
+
latest.current.onPreferred(harness);
|
|
76
|
+
} catch (failure) {
|
|
77
|
+
if (generation.current !== requestedGeneration) return;
|
|
78
|
+
|
|
79
|
+
latest.current.onError(failure instanceof Error ? failure.message : String(failure));
|
|
80
|
+
} finally {
|
|
81
|
+
connection?.close();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -18,6 +18,7 @@ const stories = await loadStories();
|
|
|
18
18
|
/** Hex of a painted color; undefined for unpainted (transparent) spans. */
|
|
19
19
|
function hexOf(color: RGBA | null | undefined): string | undefined {
|
|
20
20
|
if (!color) return undefined;
|
|
21
|
+
|
|
21
22
|
const [red, green, blue] = color.toInts();
|
|
22
23
|
|
|
23
24
|
return "#" + [red, green, blue].map((part) => part!.toString(16).padStart(2, "0")).join("");
|
|
@@ -52,6 +53,7 @@ describe("stories catalog", () => {
|
|
|
52
53
|
|
|
53
54
|
// Assert
|
|
54
55
|
expect(frame).toMatchSnapshot(`${moduleTitle}/${storyName}`);
|
|
56
|
+
|
|
55
57
|
if (story.expectedColors?.length) {
|
|
56
58
|
const seenColors = new Set<string>();
|
|
57
59
|
|
|
@@ -61,6 +63,7 @@ describe("stories catalog", () => {
|
|
|
61
63
|
const backgroundHex = hexOf(span.bg);
|
|
62
64
|
|
|
63
65
|
if (foregroundHex) seenColors.add(foregroundHex);
|
|
66
|
+
|
|
64
67
|
if (backgroundHex) seenColors.add(backgroundHex);
|
|
65
68
|
}
|
|
66
69
|
}
|
|
@@ -68,6 +71,7 @@ describe("stories catalog", () => {
|
|
|
68
71
|
expect(seenColors).toContain(expectedColor.toLowerCase());
|
|
69
72
|
}
|
|
70
73
|
}
|
|
74
|
+
|
|
71
75
|
setup.renderer.destroy();
|
|
72
76
|
});
|
|
73
77
|
}
|
package/src/stories/story.ts
CHANGED
|
@@ -65,6 +65,7 @@ async function collectStories(
|
|
|
65
65
|
|
|
66
66
|
for (const [exportName, exported] of Object.entries(moduleExports)) {
|
|
67
67
|
if (exportName === "meta" || !isStory(exported)) continue;
|
|
68
|
+
|
|
68
69
|
loaded.push({ section, moduleTitle, storyName: exportName, story: exported });
|
|
69
70
|
}
|
|
70
71
|
}
|
|
@@ -88,6 +89,7 @@ interface StoryTreeFolder extends TreeNode {
|
|
|
88
89
|
function hoistSingleStoryFolders(nodes: TreeNode[]): TreeNode[] {
|
|
89
90
|
return nodes.map((node) => {
|
|
90
91
|
if (node.children === undefined) return node;
|
|
92
|
+
|
|
91
93
|
const children = hoistSingleStoryFolders(node.children);
|
|
92
94
|
const onlyChild = children.length === 1 ? children[0] : undefined;
|
|
93
95
|
|
|
@@ -126,6 +128,7 @@ export function buildStoryTree(stories: LoadedStory[]): TreeNode[] {
|
|
|
126
128
|
foldersByPath.set(path, folder);
|
|
127
129
|
siblings.push(folder);
|
|
128
130
|
}
|
|
131
|
+
|
|
129
132
|
siblings = folder.children;
|
|
130
133
|
}
|
|
131
134
|
|
|
@@ -143,8 +146,11 @@ export function componentFilesMissingStories(): string[] {
|
|
|
143
146
|
|
|
144
147
|
for (const file of files) {
|
|
145
148
|
if (file.endsWith(".stories.tsx") || file.endsWith(".test.tsx")) continue;
|
|
149
|
+
|
|
146
150
|
if (file.endsWith(".prototype.tsx")) continue;
|
|
151
|
+
|
|
147
152
|
if (file === "stories-app.tsx") continue;
|
|
153
|
+
|
|
148
154
|
const storiesSibling = file.replace(/\.tsx$/, ".stories.tsx");
|
|
149
155
|
|
|
150
156
|
if (!files.includes(storiesSibling)) missing.push(file);
|
|
@@ -12,6 +12,7 @@ export function ansiScreenToLines(ansi: string, cols: number, rows: number): str
|
|
|
12
12
|
|
|
13
13
|
const put = (character: string): void => {
|
|
14
14
|
if (row >= 0 && row < rows && column >= 0 && column < cols) grid[row]![column] = character;
|
|
15
|
+
|
|
15
16
|
column += 1;
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -31,6 +32,7 @@ export function ansiScreenToLines(ansi: string, cols: number, rows: number): str
|
|
|
31
32
|
});
|
|
32
33
|
continue;
|
|
33
34
|
}
|
|
35
|
+
|
|
34
36
|
if (character === "\n") {
|
|
35
37
|
row += 1;
|
|
36
38
|
column = 0;
|
|
@@ -39,6 +41,7 @@ export function ansiScreenToLines(ansi: string, cols: number, rows: number): str
|
|
|
39
41
|
} else {
|
|
40
42
|
put(character);
|
|
41
43
|
}
|
|
44
|
+
|
|
42
45
|
index += 1;
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -53,7 +56,9 @@ function consumeEscape(ansi: string, start: number, apply: EscapeEffect): number
|
|
|
53
56
|
|
|
54
57
|
return bell === -1 ? ansi.length : bell + 1;
|
|
55
58
|
}
|
|
59
|
+
|
|
56
60
|
if (ansi.slice(start, start + CSI.length) !== CSI) return start + 1;
|
|
61
|
+
|
|
57
62
|
let index = start + CSI.length;
|
|
58
63
|
|
|
59
64
|
while (index < ansi.length && !/[A-Za-z]/.test(ansi[index]!)) index += 1;
|
|
@@ -78,9 +78,11 @@ export class TerminalPaneRenderable extends Renderable {
|
|
|
78
78
|
const terminals = factory();
|
|
79
79
|
|
|
80
80
|
if (!terminals) return;
|
|
81
|
+
|
|
81
82
|
const vt = terminals.create(cols, rows);
|
|
82
83
|
|
|
83
84
|
if (!vt) return; // no emulator, no visible screen - do not spawn a blind child
|
|
85
|
+
|
|
84
86
|
this.vt = vt;
|
|
85
87
|
this.cols = cols;
|
|
86
88
|
this.rows = rows;
|
|
@@ -96,10 +98,12 @@ export class TerminalPaneRenderable extends Renderable {
|
|
|
96
98
|
// the shim streams a UTF-8-decoded string (split multibyte is handled); a
|
|
97
99
|
// rare non-UTF-8 byte arrives as U+FFFD - acceptable for agent TUIs.
|
|
98
100
|
this.vt?.write(encoder.encode(data));
|
|
101
|
+
|
|
99
102
|
if (this.pendingSeed !== undefined) {
|
|
100
103
|
this.pty?.write(this.pendingSeed);
|
|
101
104
|
this.pendingSeed = undefined;
|
|
102
105
|
}
|
|
106
|
+
|
|
103
107
|
this.requestRender();
|
|
104
108
|
});
|
|
105
109
|
this.pty.onExit(({ exitCode }) => this.opts.onExit?.(exitCode));
|
|
@@ -112,12 +116,15 @@ export class TerminalPaneRenderable extends Renderable {
|
|
|
112
116
|
|
|
113
117
|
protected onResize(width: number, height: number): void {
|
|
114
118
|
if (width <= 0 || height <= 0) return;
|
|
119
|
+
|
|
115
120
|
if (!this.pty) {
|
|
116
121
|
this.start(width, height);
|
|
117
122
|
|
|
118
123
|
return;
|
|
119
124
|
}
|
|
125
|
+
|
|
120
126
|
if (width === this.cols && height === this.rows) return;
|
|
127
|
+
|
|
121
128
|
this.cols = width;
|
|
122
129
|
this.rows = height;
|
|
123
130
|
this.vt?.resize(width, height);
|
|
@@ -126,17 +133,20 @@ export class TerminalPaneRenderable extends Renderable {
|
|
|
126
133
|
|
|
127
134
|
protected renderSelf(buffer: OptimizedBuffer): void {
|
|
128
135
|
if (!this.vt) return;
|
|
136
|
+
|
|
129
137
|
for (let y = 0; y < this.height; y++) {
|
|
130
138
|
for (let x = 0; x < this.width; x++) {
|
|
131
139
|
const cell = this.vt.readCell(x, y);
|
|
132
140
|
|
|
133
141
|
// Skip an empty normal-width cell; the buffer already starts blank.
|
|
134
142
|
if (cell && cell.width === 0 && cell.codepoint === 0) continue;
|
|
143
|
+
|
|
135
144
|
const char = cell && cell.codepoint ? String.fromCodePoint(cell.codepoint) : " ";
|
|
136
145
|
let fg = cell ? resolveColor(cell.fg, DEFAULT_FG) : DEFAULT_FG;
|
|
137
146
|
let bg = cell ? resolveColor(cell.bg, TRANSPARENT) : TRANSPARENT;
|
|
138
147
|
|
|
139
148
|
if (cell?.inverse) [fg, bg] = [bg, fg];
|
|
149
|
+
|
|
140
150
|
const attributes = cell
|
|
141
151
|
? createTextAttributes({
|
|
142
152
|
bold: cell.bold,
|
|
@@ -181,6 +191,7 @@ export class TerminalPaneRenderable extends Renderable {
|
|
|
181
191
|
/** Map a Ghostty cell color to an OpenTUI RGBA, using `fallback` for the terminal default. */
|
|
182
192
|
function resolveColor(color: GhosttyColor, fallback: RGBA): RGBA {
|
|
183
193
|
if (color.kind === "rgb") return RGBA.fromInts(color.r, color.g, color.b, 255);
|
|
194
|
+
|
|
184
195
|
if (color.kind === "palette") return RGBA.fromIndex(color.index);
|
|
185
196
|
|
|
186
197
|
return fallback;
|
|
@@ -191,6 +202,7 @@ let registered = false;
|
|
|
191
202
|
/** Register `<terminalPane>` with the OpenTUI React reconciler (idempotent). */
|
|
192
203
|
export function registerTerminalPane(): void {
|
|
193
204
|
if (registered) return;
|
|
205
|
+
|
|
194
206
|
registered = true;
|
|
195
207
|
extend({ terminalPane: TerminalPaneRenderable });
|
|
196
208
|
}
|
|
@@ -68,9 +68,11 @@ let ghosttyFactory: GhosttyTerminalFactory | null | undefined;
|
|
|
68
68
|
*/
|
|
69
69
|
export function loadGhosttyTerminals(): GhosttyTerminalFactory | null {
|
|
70
70
|
if (ghosttyFactory !== undefined) return ghosttyFactory;
|
|
71
|
+
|
|
71
72
|
const path = nativeLibraryPath();
|
|
72
73
|
|
|
73
74
|
if (!path) return (ghosttyFactory = null);
|
|
75
|
+
|
|
74
76
|
try {
|
|
75
77
|
const lib = dlopen(path, CVT_SYMBOLS);
|
|
76
78
|
|
|
@@ -121,19 +123,23 @@ export class GhosttyTerminal {
|
|
|
121
123
|
/** Process a chunk of the child's PTY output into the screen state. */
|
|
122
124
|
write(bytes: Uint8Array): void {
|
|
123
125
|
if (this.freed || bytes.length === 0) return;
|
|
126
|
+
|
|
124
127
|
this.lib.cvt_write(this.handle, ptr(bytes), BigInt(bytes.length));
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
/** Resize the screen (and the child's tty, via the caller) to cols x rows. */
|
|
128
131
|
resize(cols: number, rows: number): void {
|
|
129
132
|
if (this.freed) return;
|
|
133
|
+
|
|
130
134
|
this.lib.cvt_resize(this.handle, cols, rows);
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
/** The rendered cell at viewport (x, y), or null when out of range. */
|
|
134
138
|
readCell(x: number, y: number): GhosttyCell | null {
|
|
135
139
|
if (this.freed) return null;
|
|
140
|
+
|
|
136
141
|
if (this.lib.cvt_cell(this.handle, x, y, ptr(this.out)) !== 0) return null;
|
|
142
|
+
|
|
137
143
|
const flags = this.out[13]!;
|
|
138
144
|
|
|
139
145
|
return {
|
|
@@ -161,6 +167,7 @@ export class GhosttyTerminal {
|
|
|
161
167
|
const cell = this.readCell(x, y);
|
|
162
168
|
|
|
163
169
|
if (cell === null || cell.width === 2) continue;
|
|
170
|
+
|
|
164
171
|
line += cell.codepoint === 0 ? " " : String.fromCodePoint(cell.codepoint);
|
|
165
172
|
}
|
|
166
173
|
|
|
@@ -170,6 +177,7 @@ export class GhosttyTerminal {
|
|
|
170
177
|
/** The cursor's current viewport position and visibility. */
|
|
171
178
|
readCursor(): GhosttyCursor {
|
|
172
179
|
if (this.freed) return { x: 0, y: 0, visible: false };
|
|
180
|
+
|
|
173
181
|
this.lib.cvt_cursor(this.handle, ptr(this.cursorOut));
|
|
174
182
|
const view = new DataView(this.cursorOut.buffer);
|
|
175
183
|
|
|
@@ -183,6 +191,7 @@ export class GhosttyTerminal {
|
|
|
183
191
|
/** Release the terminal; further calls are no-ops. Call once on teardown. */
|
|
184
192
|
free(): void {
|
|
185
193
|
if (this.freed) return;
|
|
194
|
+
|
|
186
195
|
this.freed = true;
|
|
187
196
|
this.lib.cvt_free(this.handle);
|
|
188
197
|
}
|
|
@@ -191,6 +200,7 @@ export class GhosttyTerminal {
|
|
|
191
200
|
/** Decode the shim's (kind, r, g, b) color triple; palette carries its index in r. */
|
|
192
201
|
function decodeColor(kind: number, r: number, g: number, b: number): GhosttyColor {
|
|
193
202
|
if (kind === 2) return { kind: "rgb", r, g, b };
|
|
203
|
+
|
|
194
204
|
if (kind === 1) return { kind: "palette", index: r };
|
|
195
205
|
|
|
196
206
|
return { kind: "default" };
|
|
@@ -71,6 +71,7 @@ function placementKeys(imageId: number, region: CellRegion): string {
|
|
|
71
71
|
// cannot pre-place a symlink at the screenshot path and redirect the write.
|
|
72
72
|
function writeTempPng(png: Uint8Array): string {
|
|
73
73
|
if (!privateTempDir) privateTempDir = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
|
|
74
|
+
|
|
74
75
|
const path = join(privateTempDir, `${tempFileCounter++}.png`);
|
|
75
76
|
|
|
76
77
|
writeFileSync(path, png, { flag: "wx" });
|
|
@@ -119,6 +120,7 @@ export function transmitKittyImage(
|
|
|
119
120
|
medium: TransmitMedium = "base64",
|
|
120
121
|
): void {
|
|
121
122
|
if (region.columns < 1 || region.rows < 1) return;
|
|
123
|
+
|
|
122
124
|
const body =
|
|
123
125
|
medium === "file" ? fileTransmit(png, region, imageId) : base64Transmit(png, region, imageId);
|
|
124
126
|
|
|
@@ -128,6 +130,7 @@ export function transmitKittyImage(
|
|
|
128
130
|
/** Re-place the already-transmitted image over its region, cheaply (a=p, no data). */
|
|
129
131
|
export function placeKittyImage(write: OrderedWrite, region: CellRegion, imageId: number): void {
|
|
130
132
|
if (region.columns < 1 || region.rows < 1) return;
|
|
133
|
+
|
|
131
134
|
write(atRegion(region, graphicsCommand(`a=p,${placementKeys(imageId, region)}`, "")));
|
|
132
135
|
}
|
|
133
136
|
|
package/src/terminal/pty.ts
CHANGED
|
@@ -79,9 +79,11 @@ let ptyLib: PtyLib | null | undefined;
|
|
|
79
79
|
|
|
80
80
|
function library(): PtyLib | null {
|
|
81
81
|
if (ptyLib !== undefined) return ptyLib;
|
|
82
|
+
|
|
82
83
|
const path = nativeLibraryPath();
|
|
83
84
|
|
|
84
85
|
if (!path) return (ptyLib = null);
|
|
86
|
+
|
|
85
87
|
try {
|
|
86
88
|
ptyLib = dlopen(path, PTY_SYMBOLS).symbols;
|
|
87
89
|
} catch (error) {
|
|
@@ -154,7 +156,9 @@ class Pty implements IPty {
|
|
|
154
156
|
this.columns,
|
|
155
157
|
this.rowCount,
|
|
156
158
|
);
|
|
159
|
+
|
|
157
160
|
if (this.handle < 0) throw new Error("PTY spawn failed");
|
|
161
|
+
|
|
158
162
|
this.processId = lib.cueloop_pty_get_pid(this.handle);
|
|
159
163
|
// Let the caller attach onData/onExit before the first bytes arrive.
|
|
160
164
|
queueMicrotask(() => void this.readLoop());
|
|
@@ -178,6 +182,7 @@ class Pty implements IPty {
|
|
|
178
182
|
|
|
179
183
|
write(data: string): void {
|
|
180
184
|
if (this.closing) return;
|
|
185
|
+
|
|
181
186
|
const buffer = Buffer.from(data, "utf8");
|
|
182
187
|
|
|
183
188
|
this.lib.cueloop_pty_write(this.handle, ptr(buffer), buffer.length);
|
|
@@ -185,6 +190,7 @@ class Pty implements IPty {
|
|
|
185
190
|
|
|
186
191
|
resize(cols: number, rows: number): void {
|
|
187
192
|
if (this.closing) return;
|
|
193
|
+
|
|
188
194
|
this.columns = cols;
|
|
189
195
|
this.rowCount = rows;
|
|
190
196
|
this.lib.cueloop_pty_resize(this.handle, cols, rows);
|
|
@@ -192,6 +198,7 @@ class Pty implements IPty {
|
|
|
192
198
|
|
|
193
199
|
kill(): void {
|
|
194
200
|
if (this.closing) return;
|
|
201
|
+
|
|
195
202
|
this.closing = true;
|
|
196
203
|
this.lib.cueloop_pty_kill(this.handle);
|
|
197
204
|
this.lib.cueloop_pty_close(this.handle);
|
|
@@ -200,6 +207,7 @@ class Pty implements IPty {
|
|
|
200
207
|
|
|
201
208
|
private async readLoop(): Promise<void> {
|
|
202
209
|
if (this.reading) return;
|
|
210
|
+
|
|
203
211
|
this.reading = true;
|
|
204
212
|
const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES);
|
|
205
213
|
// consecutive empty reads; the idle sleep backs off exponentially so a quiet agent prompt
|
|
@@ -221,6 +229,7 @@ class Pty implements IPty {
|
|
|
221
229
|
const tail = this.decoder.decode();
|
|
222
230
|
|
|
223
231
|
if (tail) this.dataEvent.fire(tail);
|
|
232
|
+
|
|
224
233
|
const exitCode = this.lib.cueloop_pty_get_exit_code(this.handle);
|
|
225
234
|
|
|
226
235
|
this.lib.cueloop_pty_close(this.handle);
|
|
@@ -46,6 +46,7 @@ describe("isolateUserConfig", () => {
|
|
|
46
46
|
} finally {
|
|
47
47
|
if (priorEnv === undefined) delete process.env.CUELOOP_CONFIG;
|
|
48
48
|
else process.env.CUELOOP_CONFIG = priorEnv;
|
|
49
|
+
|
|
49
50
|
rmSync(home, { recursive: true, force: true });
|
|
50
51
|
}
|
|
51
52
|
});
|
|
@@ -69,6 +70,7 @@ describe("isolateUserConfig", () => {
|
|
|
69
70
|
expect(process.env.CUELOOP_CONFIG).toBeUndefined();
|
|
70
71
|
} finally {
|
|
71
72
|
if (priorEnv !== undefined) process.env.CUELOOP_CONFIG = priorEnv;
|
|
73
|
+
|
|
72
74
|
rmSync(home, { recursive: true, force: true });
|
|
73
75
|
}
|
|
74
76
|
});
|
|
@@ -41,6 +41,7 @@ export async function renderReadyApp(
|
|
|
41
41
|
options: TestRendererOptions,
|
|
42
42
|
): Promise<TestRendererSetup> {
|
|
43
43
|
if (!isValidElement<AppProps>(element)) throw new Error("renderReadyApp needs an App element");
|
|
44
|
+
|
|
44
45
|
const probe = appReadyProbe();
|
|
45
46
|
const setup = await testRender(cloneElement(element, { onReady: probe.onReady }), options);
|
|
46
47
|
|
|
@@ -122,7 +123,9 @@ export async function settle(setup: TestRendererSetup): Promise<void> {
|
|
|
122
123
|
const frame = paintedFrame(setup);
|
|
123
124
|
|
|
124
125
|
quietTurns = frame === previous ? quietTurns + 1 : 0;
|
|
126
|
+
|
|
125
127
|
if (quietTurns >= SETTLE_QUIET_TURNS) return;
|
|
128
|
+
|
|
126
129
|
previous = frame;
|
|
127
130
|
}
|
|
128
131
|
}
|
|
@@ -137,6 +140,7 @@ export async function press(setup: TestRendererSetup, key: string): Promise<void
|
|
|
137
140
|
else if (key === "up") setup.mockInput.pressKey("ARROW_UP");
|
|
138
141
|
else if (key === "down") setup.mockInput.pressKey("ARROW_DOWN");
|
|
139
142
|
else return typeText(setup, key);
|
|
143
|
+
|
|
140
144
|
await settle(setup);
|
|
141
145
|
}
|
|
142
146
|
|
|
@@ -164,6 +168,7 @@ export async function navCommand(setup: TestRendererSetup, key: string): Promise
|
|
|
164
168
|
await press(setup, "escape");
|
|
165
169
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
166
170
|
await settle(setup);
|
|
171
|
+
|
|
167
172
|
// a single letter is a discrete keypress (nav command); named keys route through press
|
|
168
173
|
if (key.length === 1) await pressKey(setup, key);
|
|
169
174
|
else await press(setup, key);
|
|
@@ -280,11 +285,14 @@ async function waitForFramePredicate(
|
|
|
280
285
|
const frame = setup.captureCharFrame();
|
|
281
286
|
|
|
282
287
|
polls++;
|
|
288
|
+
|
|
283
289
|
if (frame !== lastFrame) {
|
|
284
290
|
frameChanges++;
|
|
285
291
|
lastFrame = frame;
|
|
286
292
|
}
|
|
293
|
+
|
|
287
294
|
if (predicate(frame)) return frame;
|
|
295
|
+
|
|
288
296
|
const now = Date.now();
|
|
289
297
|
|
|
290
298
|
if (now > deadline) {
|
|
@@ -294,12 +302,14 @@ async function waitForFramePredicate(
|
|
|
294
302
|
`waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
295
303
|
);
|
|
296
304
|
}
|
|
305
|
+
|
|
297
306
|
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
298
307
|
lastLog = now;
|
|
299
308
|
console.error(
|
|
300
309
|
`[waitFor] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
|
|
301
310
|
);
|
|
302
311
|
}
|
|
312
|
+
|
|
303
313
|
await yieldEventLoop();
|
|
304
314
|
}
|
|
305
315
|
}
|
|
@@ -320,15 +330,19 @@ export async function waitForState(
|
|
|
320
330
|
|
|
321
331
|
for (;;) {
|
|
322
332
|
if (predicate()) return;
|
|
333
|
+
|
|
323
334
|
await setup.renderOnce();
|
|
324
335
|
const frame = setup.captureCharFrame();
|
|
325
336
|
|
|
326
337
|
polls++;
|
|
338
|
+
|
|
327
339
|
if (frame !== lastFrame) {
|
|
328
340
|
frameChanges++;
|
|
329
341
|
lastFrame = frame;
|
|
330
342
|
}
|
|
343
|
+
|
|
331
344
|
if (predicate()) return;
|
|
345
|
+
|
|
332
346
|
const now = Date.now();
|
|
333
347
|
|
|
334
348
|
if (now > deadline) {
|
|
@@ -338,12 +352,14 @@ export async function waitForState(
|
|
|
338
352
|
`waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
339
353
|
);
|
|
340
354
|
}
|
|
355
|
+
|
|
341
356
|
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
342
357
|
lastLog = now;
|
|
343
358
|
console.error(
|
|
344
359
|
`[waitForState] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`,
|
|
345
360
|
);
|
|
346
361
|
}
|
|
362
|
+
|
|
347
363
|
await yieldEventLoop();
|
|
348
364
|
}
|
|
349
365
|
}
|
|
@@ -22,7 +22,9 @@ test("only a completed reply in an idle conversation offers continuation", () =>
|
|
|
22
22
|
const completed: AgentContinuation = agentContinuation({ ...state, messages: [message] });
|
|
23
23
|
|
|
24
24
|
expect(completed.kind).toBe("ready");
|
|
25
|
+
|
|
25
26
|
if (completed.kind === "ready") expect(completed.replyId).toBe("answer");
|
|
27
|
+
|
|
26
28
|
expect(agentContinuation({ ...state, phase: { kind: "running" }, messages: [message] })).toEqual({
|
|
27
29
|
kind: "waiting",
|
|
28
30
|
});
|