@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70
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 +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1039 -799
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +461 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -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/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +286 -0
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +171 -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 +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -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-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/pages/Workspace.stories.tsx +181 -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/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -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/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -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 +461 -31
- package/src/config.ts +389 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -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 -312
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { editInEditor, resolveEditor, resolveEditorCommand } from "./editor";
|
|
6
|
+
|
|
7
|
+
describe("resolveEditor", () => {
|
|
8
|
+
test("a clean environment falls back to nano so editing always works", () => {
|
|
9
|
+
// Assert
|
|
10
|
+
expect(resolveEditor(undefined, {})).toBe("nano");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("the environment chain wins in order: CUELOOP_EDITOR, VISUAL, EDITOR", () => {
|
|
14
|
+
// Assert
|
|
15
|
+
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code", CUELOOP_EDITOR: "hx" })).toBe(
|
|
16
|
+
"hx",
|
|
17
|
+
);
|
|
18
|
+
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code" })).toBe("code");
|
|
19
|
+
expect(resolveEditor(undefined, { EDITOR: "vim" })).toBe("vim");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the [ui] editor config overrides the environment", () => {
|
|
23
|
+
// Assert
|
|
24
|
+
expect(resolveEditor("code --wait", { EDITOR: "vim" })).toBe("code --wait");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("resolveEditorCommand", () => {
|
|
29
|
+
test("a known GUI editor gets its wait flag appended and is trusted to wait", () => {
|
|
30
|
+
// Assert
|
|
31
|
+
expect(resolveEditorCommand("code")).toEqual({ argv: ["code", "--wait"], waits: true });
|
|
32
|
+
expect(resolveEditorCommand("subl")).toEqual({
|
|
33
|
+
argv: ["subl", "--new-window", "--wait"],
|
|
34
|
+
waits: true,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an existing wait flag is not duplicated", () => {
|
|
39
|
+
// Assert
|
|
40
|
+
expect(resolveEditorCommand("code --wait").argv).toEqual(["code", "--wait"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a terminal editor is left alone and trusted to hold the tty", () => {
|
|
44
|
+
// Assert
|
|
45
|
+
expect(resolveEditorCommand("vim")).toEqual({ argv: ["vim"], waits: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an unknown editor is neither flagged nor trusted to wait", () => {
|
|
49
|
+
// Assert
|
|
50
|
+
expect(resolveEditorCommand("my-editor")).toEqual({ argv: ["my-editor"], waits: false });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("editInEditor", () => {
|
|
55
|
+
let dir: string;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-edit-test-"));
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** A runnable editor stub with a chosen base name and body. */
|
|
65
|
+
function stubEditor(name: string, body: string): string {
|
|
66
|
+
const script = join(dir, name);
|
|
67
|
+
|
|
68
|
+
writeFileSync(script, `#!/bin/sh\n${body}\n`);
|
|
69
|
+
chmodSync(script, 0o755);
|
|
70
|
+
|
|
71
|
+
return script;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const instant = (): (() => number) => {
|
|
75
|
+
let value = 0;
|
|
76
|
+
|
|
77
|
+
return () => (value += 5);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
test("an editor that changes the file reports the new content", () => {
|
|
81
|
+
// Act
|
|
82
|
+
const result = editInEditor("old", "plan.md", {
|
|
83
|
+
editor: stubEditor("writer.sh", `printf 'new' > "$1"`),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
expect(result).toEqual({ content: "new", changed: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a fast, unchanged return opens the gate; confirming re-reads the saved file", () => {
|
|
91
|
+
// Arrange
|
|
92
|
+
let askedLabel = "";
|
|
93
|
+
|
|
94
|
+
// Act
|
|
95
|
+
const result = editInEditor("keep", "plan.md", {
|
|
96
|
+
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
97
|
+
now: instant(),
|
|
98
|
+
confirmSaved: (label, path) => {
|
|
99
|
+
askedLabel = label;
|
|
100
|
+
writeFileSync(path, "saved in the GUI");
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(askedLabel).toContain("gui-like.sh");
|
|
108
|
+
expect(result).toEqual({ content: "saved in the GUI", changed: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("declining the gate keeps the original content", () => {
|
|
112
|
+
// Act
|
|
113
|
+
const result = editInEditor("keep", "plan.md", {
|
|
114
|
+
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
115
|
+
now: instant(),
|
|
116
|
+
confirmSaved: () => false,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
expect(result).toEqual({ content: "keep", changed: false });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a terminal editor that makes no change never opens the gate", () => {
|
|
124
|
+
// Arrange
|
|
125
|
+
let asked = false;
|
|
126
|
+
|
|
127
|
+
// Act
|
|
128
|
+
const result = editInEditor("keep", "plan.md", {
|
|
129
|
+
editor: stubEditor("vi", "exit 0"),
|
|
130
|
+
now: instant(),
|
|
131
|
+
confirmSaved: () => {
|
|
132
|
+
asked = true;
|
|
133
|
+
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Assert
|
|
139
|
+
expect(asked).toBeFalse();
|
|
140
|
+
expect(result).toEqual({ content: "keep", changed: false });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a nonzero editor exit throws", () => {
|
|
144
|
+
// Assert
|
|
145
|
+
expect(() => editInEditor("x", "plan.md", { editor: stubEditor("fail.sh", "exit 3") })).toThrow(
|
|
146
|
+
"editor exited 3",
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/editor.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* File-level edit mode = the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* File-level edit mode = the editor hand-off: write the working copy to a temp
|
|
3
|
+
* file, run the reviewer's editor on it, read the result back. Editing must
|
|
4
|
+
* work with zero configuration, so the resolution chain ends in a terminal
|
|
5
|
+
* fallback that is always present. A GUI editor launched without a wait flag
|
|
6
|
+
* forks and returns instantly, losing the edit silently; known GUI editors get
|
|
7
|
+
* their wait flag applied, and any editor that still returns right away with an
|
|
8
|
+
* untouched file drops to a confirm gate rather than discarding the work.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -13,20 +17,146 @@ export interface EditResult {
|
|
|
13
17
|
changed: boolean;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
/** Present on every POSIX system, so a clean shell can still edit. */
|
|
21
|
+
const DEFAULT_EDITOR = "nano";
|
|
22
|
+
|
|
23
|
+
/** GUI editors fork and return immediately unless given their wait flag. */
|
|
24
|
+
interface GuiWaitFlags {
|
|
25
|
+
[editor: string]: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GUI_WAIT_FLAGS: GuiWaitFlags = {
|
|
29
|
+
code: ["--wait"],
|
|
30
|
+
"code-insiders": ["--wait"],
|
|
31
|
+
codium: ["--wait"],
|
|
32
|
+
cursor: ["--wait"],
|
|
33
|
+
windsurf: ["--wait"],
|
|
34
|
+
subl: ["--new-window", "--wait"],
|
|
35
|
+
sublime_text: ["--new-window", "--wait"],
|
|
36
|
+
zed: ["--wait"],
|
|
37
|
+
bbedit: ["--wait"],
|
|
38
|
+
mate: ["--wait"],
|
|
39
|
+
gedit: ["--wait"],
|
|
40
|
+
kate: ["--block"],
|
|
41
|
+
idea: ["--wait"],
|
|
42
|
+
webstorm: ["--wait"],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Editors that hold the terminal themselves - never GUI, never a wait gate. */
|
|
46
|
+
const TERMINAL_EDITORS = new Set([
|
|
47
|
+
"vi",
|
|
48
|
+
"vim",
|
|
49
|
+
"nvim",
|
|
50
|
+
"nano",
|
|
51
|
+
"pico",
|
|
52
|
+
"emacs",
|
|
53
|
+
"emacsclient",
|
|
54
|
+
"micro",
|
|
55
|
+
"hx",
|
|
56
|
+
"helix",
|
|
57
|
+
"kak",
|
|
58
|
+
"ed",
|
|
59
|
+
"joe",
|
|
60
|
+
"mcedit",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
export interface ResolvedEditor {
|
|
64
|
+
argv: string[];
|
|
65
|
+
/** True when this editor blocks the hand-off until the file is closed - a
|
|
66
|
+
* terminal editor, or a known GUI editor with its wait flag applied. */
|
|
67
|
+
waits: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The editor to run: [ui] editor, then the environment chain, then nano. */
|
|
71
|
+
export function resolveEditor(
|
|
72
|
+
configuredEditor: string | undefined,
|
|
73
|
+
env: Record<string, string | undefined> = process.env,
|
|
74
|
+
): string {
|
|
75
|
+
const candidates = [configuredEditor, env.CUELOOP_EDITOR, env.VISUAL, env.EDITOR];
|
|
76
|
+
|
|
77
|
+
return candidates.find((candidate) => candidate && candidate.trim())?.trim() ?? DEFAULT_EDITOR;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function editorBaseName(command: string): string {
|
|
81
|
+
return (command.split(/[\\/]/).pop() ?? command).toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Split the editor string and apply a GUI wait flag when we know one. */
|
|
85
|
+
export function resolveEditorCommand(rawEditor: string): ResolvedEditor {
|
|
86
|
+
const parts = rawEditor.trim().split(/\s+/);
|
|
87
|
+
const base = editorBaseName(parts[0] ?? "");
|
|
88
|
+
|
|
89
|
+
if (TERMINAL_EDITORS.has(base)) return { argv: parts, waits: true };
|
|
90
|
+
const waitFlags = GUI_WAIT_FLAGS[base];
|
|
91
|
+
|
|
92
|
+
if (waitFlags) {
|
|
93
|
+
const alreadyWaits = parts.some(
|
|
94
|
+
(part) => part === "--wait" || part === "-w" || part === "--block",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return { argv: alreadyWaits ? parts : [...parts, ...waitFlags], waits: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { argv: parts, waits: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A fast return with an untouched file means the editor probably did not wait. */
|
|
104
|
+
const NO_WAIT_THRESHOLD_MS = 1000;
|
|
105
|
+
|
|
106
|
+
function suspectsNoWait(resolved: ResolvedEditor, elapsedMs: number, unchanged: boolean): boolean {
|
|
107
|
+
if (resolved.waits) return false; // holds the terminal or has a wait flag - trust the exit
|
|
108
|
+
if (!unchanged) return false; // edits landed, so it waited after all
|
|
109
|
+
|
|
110
|
+
return elapsedMs < NO_WAIT_THRESHOLD_MS;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface EditHandOff {
|
|
114
|
+
/** Editor from [ui] editor config; overrides the environment. */
|
|
115
|
+
editor?: string;
|
|
116
|
+
env?: Record<string, string | undefined>;
|
|
117
|
+
/** Confirm gate for a suspected no-wait return; true re-reads the file. */
|
|
118
|
+
confirmSaved?: (editorLabel: string, path: string) => boolean;
|
|
119
|
+
now?: () => number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Prompt on the released terminal during the suspend window. */
|
|
123
|
+
function promptSaved(editorLabel: string, path: string): boolean {
|
|
124
|
+
const answer = prompt(
|
|
125
|
+
`${editorLabel} returned immediately and the plan is unchanged - a GUI editor needs its wait flag. Save and close ${path}, then press Enter to load your edits (or type n to skip):`,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return answer !== null && answer.trim().toLowerCase() !== "n";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function editInEditor(
|
|
132
|
+
content: string,
|
|
133
|
+
filename = "plan.md",
|
|
134
|
+
handOff: EditHandOff = {},
|
|
135
|
+
): EditResult {
|
|
136
|
+
const env = handOff.env ?? process.env;
|
|
137
|
+
const now = handOff.now ?? Date.now;
|
|
138
|
+
const resolved = resolveEditorCommand(resolveEditor(handOff.editor, env));
|
|
19
139
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-edit-"));
|
|
20
140
|
const path = join(dir, filename);
|
|
141
|
+
|
|
21
142
|
writeFileSync(path, content);
|
|
22
143
|
try {
|
|
23
|
-
const
|
|
144
|
+
const startedAt = now();
|
|
145
|
+
const editorProcess = Bun.spawnSync([...resolved.argv, path], {
|
|
24
146
|
stdin: "inherit",
|
|
25
147
|
stdout: "inherit",
|
|
26
148
|
stderr: "inherit",
|
|
27
149
|
});
|
|
28
|
-
|
|
29
|
-
|
|
150
|
+
|
|
151
|
+
if (editorProcess.exitCode !== 0) throw new Error(`editor exited ${editorProcess.exitCode}`);
|
|
152
|
+
let next = readFileSync(path, "utf8");
|
|
153
|
+
|
|
154
|
+
if (suspectsNoWait(resolved, now() - startedAt, next === content)) {
|
|
155
|
+
const confirm = handOff.confirmSaved ?? promptSaved;
|
|
156
|
+
|
|
157
|
+
if (confirm(resolved.argv[0] ?? "the editor", path)) next = readFileSync(path, "utf8");
|
|
158
|
+
}
|
|
159
|
+
|
|
30
160
|
return { content: next, changed: next !== content };
|
|
31
161
|
} finally {
|
|
32
162
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** The libghostty-vt FFI wrapper round-trips VT: fed bytes come back as decoded cells with colors and attributes. Skipped where no prebuilt dylib ships for the platform. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import { loadGhosttyTerminals } from "./ghostty-terminal";
|
|
5
|
+
|
|
6
|
+
const factory = loadGhosttyTerminals();
|
|
7
|
+
|
|
8
|
+
describe("ghostty-terminal FFI", () => {
|
|
9
|
+
test("reports platform support without throwing", () => {
|
|
10
|
+
// Assert - null on unsupported platforms, a factory where a dylib ships
|
|
11
|
+
expect(factory === null || factory.create !== undefined).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test.skipIf(!factory)("writes VT bytes and reads back decoded cells", () => {
|
|
15
|
+
// Arrange
|
|
16
|
+
const term = factory!.create(80, 24)!;
|
|
17
|
+
|
|
18
|
+
expect(term).not.toBeNull();
|
|
19
|
+
|
|
20
|
+
// Act - plain text then bold + palette-green "GO"
|
|
21
|
+
term.write(new TextEncoder().encode("hi\x1b[1;32mGO\x1b[0m"));
|
|
22
|
+
const cells = [0, 1, 2, 3].map((x) => term.readCell(x, 0));
|
|
23
|
+
|
|
24
|
+
// Assert - glyphs decode, the row reads as text, and the green run is bold on ANSI palette index 2
|
|
25
|
+
expect(
|
|
26
|
+
cells.map((c) => (c?.codepoint ? String.fromCodePoint(c.codepoint) : " ")).join(""),
|
|
27
|
+
).toBe("hiGO");
|
|
28
|
+
expect(term.rowText(0, 80)).toBe("hiGO");
|
|
29
|
+
expect(cells[2]!.bold).toBe(true);
|
|
30
|
+
expect(cells[2]!.fg).toEqual({ kind: "palette", index: 2 });
|
|
31
|
+
term.free();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test.skipIf(!factory)("tracks the cursor position as text is written", () => {
|
|
35
|
+
// Arrange
|
|
36
|
+
const term = factory!.create(80, 24)!;
|
|
37
|
+
|
|
38
|
+
// Act
|
|
39
|
+
term.write(new TextEncoder().encode("abc"));
|
|
40
|
+
|
|
41
|
+
// Assert - cursor advanced past the three glyphs on row 0
|
|
42
|
+
const cursor = term.readCursor();
|
|
43
|
+
|
|
44
|
+
expect(cursor.y).toBe(0);
|
|
45
|
+
expect(cursor.x).toBe(3);
|
|
46
|
+
term.free();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test.skipIf(!factory)(
|
|
50
|
+
"preserves the native screen contract across resize and styled text",
|
|
51
|
+
() => {
|
|
52
|
+
// Arrange
|
|
53
|
+
const term = factory!.create(8, 2)!;
|
|
54
|
+
|
|
55
|
+
// Act - resize first, then write RGB colors, every supported style, and a wide glyph
|
|
56
|
+
term.resize(6, 3);
|
|
57
|
+
term.write(
|
|
58
|
+
new TextEncoder().encode(
|
|
59
|
+
"\x1b[38;2;12;34;56;48;2;78;90;123;1;3;4;7;9mA\x1b[0m\x1b[2mF\x1b[0m\u754c",
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Assert - the flat native ABI retains cell data and rejects coordinates outside the viewport
|
|
64
|
+
expect(term.readCell(0, 0)).toEqual({
|
|
65
|
+
codepoint: 0x41,
|
|
66
|
+
fg: { kind: "rgb", r: 12, g: 34, b: 56 },
|
|
67
|
+
bg: { kind: "rgb", r: 78, g: 90, b: 123 },
|
|
68
|
+
width: 0,
|
|
69
|
+
bold: true,
|
|
70
|
+
italic: true,
|
|
71
|
+
underline: true,
|
|
72
|
+
inverse: true,
|
|
73
|
+
faint: false,
|
|
74
|
+
strikethrough: true,
|
|
75
|
+
});
|
|
76
|
+
expect(term.readCell(1, 0)?.faint).toBe(true);
|
|
77
|
+
expect(term.readCell(2, 0)?.codepoint).toBe("\u754c".codePointAt(0));
|
|
78
|
+
expect(term.readCell(2, 0)?.width).toBe(1);
|
|
79
|
+
expect(term.readCell(3, 0)?.width).toBe(2);
|
|
80
|
+
expect(term.readCell(6, 0)).toBeNull();
|
|
81
|
+
expect(term.readCell(0, 3)).toBeNull();
|
|
82
|
+
term.free();
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A live in-process terminal emulator backed by Ghostty's VT core (libghostty-vt)
|
|
3
|
+
* through a flat FFI shim (native/src/shim.zig). Feed it a child process's PTY
|
|
4
|
+
* output with `write`, then read the rendered screen cell-by-cell with `readCell`
|
|
5
|
+
* to paint it anywhere - this is what lets a real `cc`/shell render inside an
|
|
6
|
+
* OpenTUI box. Load with `loadGhosttyTerminals`, which returns null when no
|
|
7
|
+
* prebuilt dylib exists for this platform (callers fall back to a herdr split).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** How a cell's color arrived from Ghostty: the terminal default, an ANSI palette index, or true RGB. */
|
|
15
|
+
export type GhosttyColor =
|
|
16
|
+
| { kind: "default" }
|
|
17
|
+
| { kind: "palette"; index: number }
|
|
18
|
+
| { kind: "rgb"; r: number; g: number; b: number };
|
|
19
|
+
|
|
20
|
+
/** The cursor's viewport position and whether it should be drawn. */
|
|
21
|
+
export interface GhosttyCursor {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
visible: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One decoded screen cell: its glyph, colors, cell width, and text decorations. */
|
|
28
|
+
export interface GhosttyCell {
|
|
29
|
+
/** Unicode code point; 0 = blank. */
|
|
30
|
+
codepoint: number;
|
|
31
|
+
fg: GhosttyColor;
|
|
32
|
+
bg: GhosttyColor;
|
|
33
|
+
/** Ghostty width tag: 0 = normal, 1 = double-width lead, 2 = trailing cell. */
|
|
34
|
+
width: number;
|
|
35
|
+
bold: boolean;
|
|
36
|
+
italic: boolean;
|
|
37
|
+
underline: boolean;
|
|
38
|
+
inverse: boolean;
|
|
39
|
+
faint: boolean;
|
|
40
|
+
strikethrough: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CVT_SYMBOLS = {
|
|
44
|
+
cvt_new: { args: [FFIType.u16, FFIType.u16], returns: FFIType.ptr },
|
|
45
|
+
cvt_free: { args: [FFIType.ptr], returns: FFIType.void },
|
|
46
|
+
cvt_write: { args: [FFIType.ptr, FFIType.ptr, FFIType.u64], returns: FFIType.void },
|
|
47
|
+
cvt_resize: { args: [FFIType.ptr, FFIType.u16, FFIType.u16], returns: FFIType.void },
|
|
48
|
+
cvt_cell: { args: [FFIType.ptr, FFIType.u16, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
|
|
49
|
+
cvt_cursor: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
type CvtLib = ReturnType<typeof dlopen<typeof CVT_SYMBOLS>>["symbols"];
|
|
53
|
+
|
|
54
|
+
/** The prebuilt shim dylib for this platform, or null when none ships for it. */
|
|
55
|
+
function nativeLibraryPath(): string | null {
|
|
56
|
+
const suffix = process.platform === "darwin" ? "dylib" : "so";
|
|
57
|
+
const dir = `${process.platform}-${process.arch}`;
|
|
58
|
+
const path = join(import.meta.dir, "..", "native", dir, `libcueloopvt.${suffix}`);
|
|
59
|
+
|
|
60
|
+
return existsSync(path) ? path : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One shared load of the VT library; null when no dylib ships or it failed to load. */
|
|
64
|
+
let ghosttyFactory: GhosttyTerminalFactory | null | undefined;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open the Ghostty VT library for this platform, or return null when the prebuilt
|
|
68
|
+
* dylib is missing (the caller degrades to a herdr split instead of embedding).
|
|
69
|
+
* The library is opened once per process; later calls return the same factory.
|
|
70
|
+
*/
|
|
71
|
+
export function loadGhosttyTerminals(): GhosttyTerminalFactory | null {
|
|
72
|
+
if (ghosttyFactory !== undefined) return ghosttyFactory;
|
|
73
|
+
const path = nativeLibraryPath();
|
|
74
|
+
|
|
75
|
+
if (!path) return (ghosttyFactory = null);
|
|
76
|
+
try {
|
|
77
|
+
const lib = dlopen(path, CVT_SYMBOLS);
|
|
78
|
+
|
|
79
|
+
ghosttyFactory = new GhosttyTerminalFactory(lib.symbols);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// The file exists but failed to load (bad arch, missing symbol) - a real
|
|
82
|
+
// fault, not the expected no-prebuilt case; surface it, then degrade.
|
|
83
|
+
console.error(`cueloop: failed to load ${path}:`, error);
|
|
84
|
+
ghosttyFactory = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return ghosttyFactory;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Creates `GhosttyTerminal`s that share one loaded copy of the VT library. */
|
|
91
|
+
export class GhosttyTerminalFactory {
|
|
92
|
+
constructor(private readonly lib: CvtLib) {}
|
|
93
|
+
/** A fresh terminal sized to cols x rows, or null if allocation failed. */
|
|
94
|
+
create(cols: number, rows: number): GhosttyTerminal | null {
|
|
95
|
+
const handle = this.lib.cvt_new(cols, rows);
|
|
96
|
+
|
|
97
|
+
return handle ? new GhosttyTerminal(this.lib, handle) : null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The flat CvtCell struct the shim writes: u32 codepoint, fg rgba-ish (4 bytes),
|
|
102
|
+
// bg (4 bytes), u8 width, u8 flags, u16 pad = 16 bytes.
|
|
103
|
+
const CELL_STRUCT_BYTES = 16;
|
|
104
|
+
const FLAG_BOLD = 1;
|
|
105
|
+
const FLAG_ITALIC = 2;
|
|
106
|
+
const FLAG_UNDERLINE = 4;
|
|
107
|
+
const FLAG_INVERSE = 8;
|
|
108
|
+
const FLAG_FAINT = 16;
|
|
109
|
+
const FLAG_STRIKETHROUGH = 32;
|
|
110
|
+
|
|
111
|
+
/** A live Ghostty terminal: feed PTY bytes, read the screen, resize, free. */
|
|
112
|
+
export class GhosttyTerminal {
|
|
113
|
+
private readonly out = new Uint8Array(CELL_STRUCT_BYTES);
|
|
114
|
+
private readonly outView = new DataView(this.out.buffer);
|
|
115
|
+
private readonly cursorOut = new Uint8Array(8);
|
|
116
|
+
private freed = false;
|
|
117
|
+
|
|
118
|
+
constructor(
|
|
119
|
+
private readonly lib: CvtLib,
|
|
120
|
+
private readonly handle: NonNullable<ReturnType<CvtLib["cvt_new"]>>,
|
|
121
|
+
) {}
|
|
122
|
+
|
|
123
|
+
/** Process a chunk of the child's PTY output into the screen state. */
|
|
124
|
+
write(bytes: Uint8Array): void {
|
|
125
|
+
if (this.freed || bytes.length === 0) return;
|
|
126
|
+
this.lib.cvt_write(this.handle, ptr(bytes), BigInt(bytes.length));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Resize the screen (and the child's tty, via the caller) to cols x rows. */
|
|
130
|
+
resize(cols: number, rows: number): void {
|
|
131
|
+
if (this.freed) return;
|
|
132
|
+
this.lib.cvt_resize(this.handle, cols, rows);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The rendered cell at viewport (x, y), or null when out of range. */
|
|
136
|
+
readCell(x: number, y: number): GhosttyCell | null {
|
|
137
|
+
if (this.freed) return null;
|
|
138
|
+
if (this.lib.cvt_cell(this.handle, x, y, ptr(this.out)) !== 0) return null;
|
|
139
|
+
const flags = this.out[13]!;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
codepoint: this.outView.getUint32(0, true),
|
|
143
|
+
fg: decodeColor(this.out[7]!, this.out[4]!, this.out[5]!, this.out[6]!),
|
|
144
|
+
bg: decodeColor(this.out[11]!, this.out[8]!, this.out[9]!, this.out[10]!),
|
|
145
|
+
width: this.out[12]!,
|
|
146
|
+
bold: (flags & FLAG_BOLD) !== 0,
|
|
147
|
+
italic: (flags & FLAG_ITALIC) !== 0,
|
|
148
|
+
underline: (flags & FLAG_UNDERLINE) !== 0,
|
|
149
|
+
inverse: (flags & FLAG_INVERSE) !== 0,
|
|
150
|
+
faint: (flags & FLAG_FAINT) !== 0,
|
|
151
|
+
strikethrough: (flags & FLAG_STRIKETHROUGH) !== 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Row `y` as text: blank cells read as spaces, the trailing half of a wide
|
|
157
|
+
* glyph is skipped, and trailing spaces are trimmed.
|
|
158
|
+
*/
|
|
159
|
+
rowText(y: number, cols: number): string {
|
|
160
|
+
let line = "";
|
|
161
|
+
|
|
162
|
+
for (let x = 0; x < cols; x++) {
|
|
163
|
+
const cell = this.readCell(x, y);
|
|
164
|
+
|
|
165
|
+
if (cell === null || cell.width === 2) continue;
|
|
166
|
+
line += cell.codepoint === 0 ? " " : String.fromCodePoint(cell.codepoint);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return line.trimEnd();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The cursor's current viewport position and visibility. */
|
|
173
|
+
readCursor(): GhosttyCursor {
|
|
174
|
+
if (this.freed) return { x: 0, y: 0, visible: false };
|
|
175
|
+
this.lib.cvt_cursor(this.handle, ptr(this.cursorOut));
|
|
176
|
+
const view = new DataView(this.cursorOut.buffer);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
x: view.getUint16(0, true),
|
|
180
|
+
y: view.getUint16(2, true),
|
|
181
|
+
visible: this.cursorOut[4] === 1,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Release the terminal; further calls are no-ops. Call once on teardown. */
|
|
186
|
+
free(): void {
|
|
187
|
+
if (this.freed) return;
|
|
188
|
+
this.freed = true;
|
|
189
|
+
this.lib.cvt_free(this.handle);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Decode the shim's (kind, r, g, b) color triple; palette carries its index in r. */
|
|
194
|
+
function decodeColor(kind: number, r: number, g: number, b: number): GhosttyColor {
|
|
195
|
+
if (kind === 2) return { kind: "rgb", r, g, b };
|
|
196
|
+
if (kind === 1) return { kind: "palette", index: r };
|
|
197
|
+
|
|
198
|
+
return { kind: "default" };
|
|
199
|
+
}
|
package/src/herdr.test.ts
CHANGED
|
@@ -2,50 +2,23 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { focusHerdrPane
|
|
5
|
+
import { focusHerdrPane } from "./herdr";
|
|
6
6
|
|
|
7
7
|
let dir: string;
|
|
8
|
-
const savedEnv: Record<string, string | undefined> = {};
|
|
9
8
|
|
|
10
9
|
beforeEach(() => {
|
|
11
10
|
dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-"));
|
|
12
|
-
for (const k of ["HERDR_ENV", "HERDR_PANE_ID", "HERDR_BIN_PATH", "CUELOOP_RETURN_PANE"]) {
|
|
13
|
-
savedEnv[k] = process.env[k];
|
|
14
|
-
delete process.env[k];
|
|
15
|
-
}
|
|
16
11
|
});
|
|
17
12
|
afterEach(() => {
|
|
18
|
-
for (const [k, v] of Object.entries(savedEnv)) {
|
|
19
|
-
if (v === undefined) delete process.env[k];
|
|
20
|
-
else process.env[k] = v;
|
|
21
|
-
}
|
|
22
13
|
rmSync(dir, { recursive: true, force: true });
|
|
23
14
|
});
|
|
24
15
|
|
|
25
|
-
describe("returnPaneFor", () => {
|
|
26
|
-
test("outside herdr there is never a return target", () => {
|
|
27
|
-
expect(returnPaneFor("w1:p1")).toBeUndefined();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("env override wins, session meta is the fallback", () => {
|
|
31
|
-
process.env.HERDR_ENV = "1";
|
|
32
|
-
process.env.HERDR_PANE_ID = "w1:p9";
|
|
33
|
-
expect(returnPaneFor("w1:p1")).toBe("w1:p1");
|
|
34
|
-
process.env.CUELOOP_RETURN_PANE = "w1:p2";
|
|
35
|
-
expect(returnPaneFor("w1:p1")).toBe("w1:p2");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("returning to our own pane is a no-op", () => {
|
|
39
|
-
process.env.HERDR_ENV = "1";
|
|
40
|
-
process.env.HERDR_PANE_ID = "w1:p1";
|
|
41
|
-
expect(returnPaneFor("w1:p1")).toBeUndefined();
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
16
|
describe("focusHerdrPane", () => {
|
|
46
17
|
test("resolves the pane's tab and focuses it", () => {
|
|
18
|
+
// Arrange
|
|
47
19
|
const log = join(dir, "calls.log");
|
|
48
20
|
const stub = join(dir, "herdr");
|
|
21
|
+
|
|
49
22
|
writeFileSync(
|
|
50
23
|
stub,
|
|
51
24
|
`#!/bin/sh
|
|
@@ -54,15 +27,17 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
|
54
27
|
`,
|
|
55
28
|
);
|
|
56
29
|
Bun.spawnSync(["chmod", "+x", stub]);
|
|
57
|
-
|
|
58
|
-
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
|
|
59
33
|
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
34
|
+
|
|
60
35
|
expect(calls[0]).toBe("pane get w1:p1");
|
|
61
36
|
expect(calls[1]).toBe("tab focus w1:t3");
|
|
62
37
|
});
|
|
63
38
|
|
|
64
39
|
test("a missing binary never throws", () => {
|
|
65
|
-
|
|
66
|
-
expect(focusHerdrPane("w1:p1")).toBe(false);
|
|
40
|
+
// Assert
|
|
41
|
+
expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
|
|
67
42
|
});
|
|
68
43
|
});
|