@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.
Files changed (122) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/annotation-palette.ts +1 -0
  3. package/src/annotations/components/AnnotationCards.tsx +14 -1
  4. package/src/annotations/components/DiscussionMarkerRail.tsx +3 -0
  5. package/src/annotations/discussions.ts +1 -0
  6. package/src/annotations/thread-selection.ts +6 -0
  7. package/src/annotations/use-annotation-surface.tsx +154 -19
  8. package/src/app/App.deleted-scroll.test.tsx +3 -0
  9. package/src/app/App.harness-settings.test.tsx +77 -0
  10. package/src/app/App.plan-render.test.tsx +2 -0
  11. package/src/app/App.plan-review.test.tsx +1 -0
  12. package/src/app/App.test.tsx +1 -0
  13. package/src/app/App.tsx +72 -6
  14. package/src/app/App.visual.test.tsx +1 -0
  15. package/src/app/App.walk.test.tsx +1 -0
  16. package/src/app/App.workbench.test.tsx +1 -0
  17. package/src/app/app-screens.tsx +1 -0
  18. package/src/app/app-view-model.ts +7 -0
  19. package/src/app/components/WorkspacePanels.tsx +1 -0
  20. package/src/app/intent-dispatch.test.ts +1 -0
  21. package/src/app/intent-dispatch.ts +26 -0
  22. package/src/app/ready-signal.ts +2 -0
  23. package/src/app/run.ts +2 -0
  24. package/src/app/serve.test.ts +4 -0
  25. package/src/diff/components/AnnotatableFileView.tsx +3 -2
  26. package/src/diff/components/DiffContentView.scroll.test.tsx +9 -0
  27. package/src/diff/components/DiffContentView.syntax.test.tsx +1 -0
  28. package/src/diff/components/DiffContentView.tsx +28 -3
  29. package/src/diff/components/diff-content-view-layout.ts +3 -0
  30. package/src/diff/diff-fold.ts +4 -0
  31. package/src/diff/diff-hunk-curate.ts +3 -0
  32. package/src/diff/diff-intraline.ts +6 -0
  33. package/src/diff/diff-syntax.ts +8 -0
  34. package/src/diff/split-diff.ts +6 -0
  35. package/src/diff/view-diff.ts +18 -0
  36. package/src/diff/walk.ts +3 -0
  37. package/src/integrations/attribution.ts +2 -0
  38. package/src/integrations/client-extension-registry.ts +4 -0
  39. package/src/integrations/clipboard.ts +1 -0
  40. package/src/integrations/components/ShareDialog.agent.test.tsx +2 -0
  41. package/src/integrations/components/ShareDialog.tsx +28 -0
  42. package/src/integrations/editor.ts +3 -0
  43. package/src/integrations/github-identity.test.ts +1 -0
  44. package/src/integrations/github-identity.ts +1 -0
  45. package/src/integrations/herdr.ts +2 -0
  46. package/src/integrations/pasted-image.ts +2 -0
  47. package/src/integrations/share.ts +4 -0
  48. package/src/keyboard/components/HandleChipsInput.tsx +4 -0
  49. package/src/keyboard/components/SlashComposer.tsx +3 -0
  50. package/src/keyboard/key-bindings.ts +3 -0
  51. package/src/keyboard/keymap.ts +51 -0
  52. package/src/keyboard/skills.ts +2 -0
  53. package/src/keyboard/slash-palette.ts +8 -0
  54. package/src/keyboard/thread-chords.ts +5 -0
  55. package/src/keyboard/use-shared-keyboard.ts +1 -0
  56. package/src/markdown/components/MarkdownThreadEditor.tsx +3 -0
  57. package/src/markdown/components/ThreadView.selection.test.tsx +3 -0
  58. package/src/markdown/components/ThreadView.tsx +16 -3
  59. package/src/markdown/mark-runs.ts +4 -0
  60. package/src/markdown/markdown-editor-syntax-style.ts +1 -0
  61. package/src/markdown/markdown-grid.ts +6 -0
  62. package/src/markdown/markdown-highlight.ts +1 -0
  63. package/src/markdown/thread-code-syntax.ts +3 -0
  64. package/src/markdown/view-plan.ts +52 -0
  65. package/src/performance/perf-report.ts +1 -0
  66. package/src/performance/perf-timings.ts +1 -0
  67. package/src/prototype/components/PrototypeContentView.tsx +17 -0
  68. package/src/prototype/prototype-browser.ts +11 -0
  69. package/src/settings/components/SettingsDialog.tsx +3 -0
  70. package/src/settings/config.test.ts +41 -0
  71. package/src/settings/config.ts +44 -0
  72. package/src/settings/use-settings-dialog.test.tsx +98 -0
  73. package/src/settings/use-settings-dialog.tsx +38 -1
  74. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  75. package/src/settings/use-thread-harness-setting.ts +86 -0
  76. package/src/stories/stories.test.tsx +4 -0
  77. package/src/stories/story.ts +6 -0
  78. package/src/terminal/ansi-screen.ts +5 -0
  79. package/src/terminal/components/terminal-pane.ts +12 -0
  80. package/src/terminal/ghostty-terminal.ts +10 -0
  81. package/src/terminal/kitty-image.ts +3 -0
  82. package/src/terminal/pty.ts +9 -0
  83. package/src/testing/test-support.test.ts +2 -0
  84. package/src/testing/test-support.ts +16 -0
  85. package/src/thread/agent-continuation.test.ts +2 -0
  86. package/src/thread/agent-transcript.ts +18 -10
  87. package/src/thread/components/AgentConfigControls.tsx +1 -0
  88. package/src/thread/components/AgentThreadPane.test.tsx +257 -1
  89. package/src/thread/components/AgentThreadPane.tsx +48 -16
  90. package/src/thread/components/ThreadTree.tsx +1 -0
  91. package/src/thread/components/thread-tree.test.ts +1 -0
  92. package/src/thread/thread-controller.curate.test.ts +2 -0
  93. package/src/thread/thread-controller.ts +123 -0
  94. package/src/thread/tree-view.ts +4 -0
  95. package/src/thread/use-thread-agent.test.tsx +1 -0
  96. package/src/thread/use-thread-agent.ts +5 -0
  97. package/src/ui/components/OverlayScrollbar.test.tsx +3 -0
  98. package/src/ui/components/OverlayScrollbar.tsx +5 -0
  99. package/src/ui/components/PromptDialog.tsx +2 -0
  100. package/src/ui/components/RootOverlay.tsx +1 -0
  101. package/src/ui/components/ScrollArea.tsx +2 -0
  102. package/src/ui/components/primitives/Button.tsx +1 -0
  103. package/src/ui/components/primitives/Tabs.tsx +1 -0
  104. package/src/ui/components/primitives/Tree.tsx +4 -0
  105. package/src/ui/components/primitives/tree-model.ts +7 -0
  106. package/src/ui/components/syntax-highlight.ts +5 -0
  107. package/src/ui/components/truncate-title.ts +1 -0
  108. package/src/ui/use-frame-measure.ts +3 -0
  109. package/src/ui/use-terminal-virtualizer.ts +6 -0
  110. package/src/workbench/components/ChangesColumn.tsx +7 -0
  111. package/src/workbench/components/ChangesFileTree.test.tsx +3 -0
  112. package/src/workbench/components/EditorGrid.tsx +8 -0
  113. package/src/workbench/components/FileContentsView.tsx +2 -0
  114. package/src/workbench/components/GridTabContent.tsx +2 -0
  115. package/src/workbench/components/ProjectTreeView.test.tsx +3 -0
  116. package/src/workbench/components/ProjectTreeView.tsx +8 -0
  117. package/src/workbench/components/TreePane.tsx +2 -0
  118. package/src/workbench/components/editor-grid.ts +3 -0
  119. package/src/workbench/components/file-tree.ts +2 -0
  120. package/src/workbench/use-changes-workbench.ts +5 -0
  121. package/src/workbench/use-remember-layout.ts +2 -0
  122. 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
  }
@@ -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
 
@@ -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
  });