@cueloop/client 0.1.0-alpha.77 → 0.1.0-alpha.78

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 (63) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +14 -2
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.plan-review.test.tsx +16 -0
  6. package/src/App.submit-confirm.test.tsx +34 -5
  7. package/src/App.test.tsx +2 -2
  8. package/src/App.tsx +4 -0
  9. package/src/App.walk.test.tsx +2 -2
  10. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  11. package/src/ansi-screen.test.ts +28 -0
  12. package/src/ansi-screen.ts +72 -0
  13. package/src/app-screens.tsx +7 -3
  14. package/src/app-view-model.ts +3 -0
  15. package/src/components/AnnotatableFileView.tsx +1 -1
  16. package/src/components/AnnotationCards.tsx +18 -2
  17. package/src/components/AnsiScreen.stories.tsx +12 -0
  18. package/src/components/AnsiScreen.tsx +28 -0
  19. package/src/components/CompletionOverlay.stories.tsx +15 -1
  20. package/src/components/CompletionOverlay.tsx +22 -5
  21. package/src/components/ConfirmCard.stories.tsx +2 -0
  22. package/src/components/ConfirmCard.tsx +45 -18
  23. package/src/components/ConfirmDialog.tsx +11 -12
  24. package/src/components/DiffContentView.tsx +4 -4
  25. package/src/components/FileContentsView.tsx +1 -1
  26. package/src/components/ManageAccessDialog.tsx +13 -11
  27. package/src/components/PromptDialog.test.tsx +2 -2
  28. package/src/components/PromptDialog.tsx +9 -10
  29. package/src/components/ShareChoiceDialog.tsx +0 -2
  30. package/src/components/SlashComposer.stories.tsx +37 -0
  31. package/src/components/SlashComposer.tsx +87 -0
  32. package/src/components/ThreadTree.tsx +11 -6
  33. package/src/components/ThreadView.selection.test.tsx +88 -1
  34. package/src/components/ThreadView.slash.test.tsx +0 -1
  35. package/src/components/ThreadView.tsx +66 -7
  36. package/src/components/Toast.tsx +0 -5
  37. package/src/components/WalkWizard.stories.tsx +2 -2
  38. package/src/components/WalkWizard.tsx +10 -12
  39. package/src/components/__snapshots__/stories.test.tsx.snap +219 -89
  40. package/src/components/primitives/Card.tsx +3 -0
  41. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  42. package/src/components/primitives/DialogActions.tsx +39 -0
  43. package/src/components/session-tree.ts +7 -6
  44. package/src/components/stories-app.tsx +13 -4
  45. package/src/components/stories.test.tsx +8 -1
  46. package/src/components/story-tree.test.ts +32 -24
  47. package/src/components/story.ts +32 -15
  48. package/src/diff-intraline.test.ts +75 -2
  49. package/src/diff-intraline.ts +95 -26
  50. package/src/keymap.test.ts +2 -2
  51. package/src/keymap.ts +0 -1
  52. package/src/pasted-image.test.ts +67 -0
  53. package/src/pasted-image.ts +60 -0
  54. package/src/run.ts +31 -19
  55. package/src/serve.ts +1 -1
  56. package/src/share.ts +1 -1
  57. package/src/slash-palette.test.ts +17 -0
  58. package/src/slash-palette.ts +11 -0
  59. package/src/thread-controller.derivation.test.ts +62 -0
  60. package/src/thread-controller.optimistic.test.ts +69 -0
  61. package/src/thread-controller.switch-cache.test.ts +146 -0
  62. package/src/thread-controller.ts +157 -24
  63. package/src/use-annotation-surface.tsx +43 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.77",
3
+ "version": "0.1.0-alpha.78",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.77",
31
- "@cueloop/extension-api": "0.1.0-alpha.77",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.77",
33
- "@cueloop/schema": "0.1.0-alpha.77",
30
+ "@cueloop/daemon": "0.1.0-alpha.78",
31
+ "@cueloop/extension-api": "0.1.0-alpha.78",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.78",
33
+ "@cueloop/schema": "0.1.0-alpha.78",
34
34
  "@opentui/core": "^0.5.8",
35
35
  "@opentui/keymap": "0.5.8",
36
36
  "@opentui/react": "^0.5.8",
@@ -57,7 +57,7 @@ type Setup = Awaited<ReturnType<typeof renderApp>>;
57
57
 
58
58
  async function submitApprove(setup: Setup): Promise<void> {
59
59
  await pressKey(setup, "RETURN", { meta: true }); // open submit (approve default: no pending items)
60
- await press(setup, "enter"); // confirm
60
+ await pressKey(setup, "RETURN", { meta: true }); // confirm
61
61
  await waitForText(setup, "review approved");
62
62
  }
63
63
 
@@ -74,8 +74,8 @@ describe("completion overlay", () => {
74
74
  const frame = await waitForText(setup, "closing in 5s");
75
75
 
76
76
  expect(frame).toContain("review approved");
77
- expect(frame).toContain("close [return]");
78
- expect(frame).toContain("return to plan [esc]");
77
+ expect(frame).toContain("close");
78
+ expect(frame).toContain("back to plan");
79
79
 
80
80
  // Act
81
81
  await press(setup, "enter");
@@ -13,7 +13,6 @@ import {
13
13
  clickText,
14
14
  dragText,
15
15
  isolateUserConfig,
16
- press,
17
16
  pressKey,
18
17
  renderReadyApp,
19
18
  typeText,
@@ -95,7 +94,7 @@ describe("diff review", () => {
95
94
 
96
95
  // Act - submit with the session chord (cmd+enter, no composer open), confirm request_changes
97
96
  await pressKey(setup, "RETURN", { meta: true });
98
- await press(setup, "enter");
97
+ await pressKey(setup, "RETURN", { meta: true });
99
98
 
100
99
  // Assert
101
100
  await waitForText(setup, "feedback sent");
@@ -105,6 +104,19 @@ describe("diff review", () => {
105
104
  expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
106
105
  });
107
106
 
107
+ test("pasting an image into a diff comment drops in an [Image #n] placeholder", async () => {
108
+ // Arrange - mark the added line and open the inline comment composer
109
+ const setup = await renderApp();
110
+ await dragText(setup, "new Map()", "new Map()", "new Map()".length);
111
+ await typeText(setup, "see ");
112
+
113
+ // Act - a burst of control bytes stands in for the binary an image paste delivers
114
+ await setup.mockInput.pasteBracketedText("");
115
+
116
+ // Assert - the same placeholder the summary composer shows
117
+ await waitForText(setup, "see [Image #1]");
118
+ });
119
+
108
120
  test("a comment reopens with its mark painted on the code", async () => {
109
121
  // Arrange - a stored comment anchored to the added line's words
110
122
  server.core.sessionAnnotate(session.id, {
@@ -18,7 +18,6 @@ import type { Thread } from "@cueloop/schema";
18
18
  import { App } from "./App";
19
19
  import {
20
20
  isolateUserConfig,
21
- press,
22
21
  waitForState,
23
22
  waitForText,
24
23
  pressKey,
@@ -81,7 +80,7 @@ describe("obsidian export on resolve", () => {
81
80
  await waitForText(setup, "[Approve]");
82
81
 
83
82
  // Act
84
- await press(setup, "enter"); // submit
83
+ await pressKey(setup, "RETURN", { meta: true }); // submit
85
84
 
86
85
  // Assert
87
86
  // the export is an async round-trip after resolve; the status line lands
@@ -228,6 +228,22 @@ describe("the mark stays painted while composing", () => {
228
228
  await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
229
229
  await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
230
230
  }, 60_000);
231
+
232
+ test("backspace on an empty draft dismisses the composer back to the mark", async () => {
233
+ // Arrange
234
+ const setup = await renderApp();
235
+
236
+ // Act: mark, type one character, then delete it and backspace again on the now-empty draft
237
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
238
+ await type(setup, "x");
239
+ await waitForText(setup, "● x");
240
+ await press(setup, "backspace");
241
+ await press(setup, "backspace");
242
+
243
+ // Assert: the composer is gone and the mark stays, ready to re-type
244
+ await waitForTextGone(setup, "● x");
245
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
246
+ }, 60_000);
231
247
  });
232
248
 
233
249
  describe("compose newline convention", () => {
@@ -99,8 +99,8 @@ describe("send message confirm", () => {
99
99
  const frame = setup.captureCharFrame();
100
100
 
101
101
  expect(frame).toContain("[Approve]");
102
- expect(frame).toContain(" send message ");
103
- expect(frame).toContain(" Cancel ");
102
+ expect(frame).toContain(" send ");
103
+ expect(frame).toContain(" cancel ");
104
104
  });
105
105
 
106
106
  test("left/right cycles the verdict selector in the overlay", async () => {
@@ -146,7 +146,7 @@ describe("send message confirm", () => {
146
146
  // Assert - a bare ESC settles after the parser's escape-sequence window
147
147
  const frame = await waitForTextGone(setup, "[Approve]");
148
148
 
149
- expect(frame).not.toContain(" Cancel ");
149
+ expect(frame).not.toContain(" cancel ");
150
150
  });
151
151
 
152
152
  test("enter in the overlay resolves the session through the controller", async () => {
@@ -162,7 +162,7 @@ describe("send message confirm", () => {
162
162
 
163
163
  // Act
164
164
  await setup.mockInput.typeText("Tighten the steps.");
165
- await press(setup, "enter");
165
+ await pressKey(setup, "RETURN", { meta: true });
166
166
 
167
167
  // Assert - the completion flow after submit is unchanged
168
168
  await waitForText(setup, "feedback sent");
@@ -172,6 +172,35 @@ describe("send message confirm", () => {
172
172
  expect(stored.verdict!.kind).toBe("request_changes");
173
173
  });
174
174
 
175
+ test("typing / in the summary opens the skills/actions palette", async () => {
176
+ // Arrange
177
+ seedAnnotations(1);
178
+ const setup = await renderApp();
179
+ await pressKey(setup, "RETURN", { meta: true });
180
+ await waitForText(setup, "[Changes]");
181
+
182
+ // Act
183
+ await setup.mockInput.typeText("/restate");
184
+
185
+ // Assert - the same slash palette the inline composer shows
186
+ await waitForText(setup, "restate-simplified");
187
+ });
188
+
189
+ test("pasting an image into the summary drops in an [Image #n] placeholder", async () => {
190
+ // Arrange
191
+ const setup = await renderApp();
192
+ await pressKey(setup, "RETURN", { meta: true });
193
+ await waitForText(setup, "[Approve]");
194
+
195
+ // Act - a burst of control bytes stands in for the binary an image paste delivers
196
+ await setup.mockInput.typeText("look at this ");
197
+ await setup.mockInput.pasteBracketedText("");
198
+
199
+ // Assert - the raw bytes never reach the draft; a numbered placeholder does
200
+ await waitForText(setup, "[Image #1]");
201
+ expect(setup.captureCharFrame()).toContain("look at this [Image #1]");
202
+ });
203
+
175
204
  test("read-only observers cannot open the confirm overlay", async () => {
176
205
  // Arrange
177
206
  seedAnnotations(1);
@@ -185,7 +214,7 @@ describe("send message confirm", () => {
185
214
  const frame = setup.captureCharFrame();
186
215
 
187
216
  expect(frame).not.toContain("[Changes]");
188
- expect(frame).not.toContain(" Cancel ");
217
+ expect(frame).not.toContain(" cancel ");
189
218
  expect(server.core.sessionGet(session.id).status).toBe("pending");
190
219
  });
191
220
  });
package/src/App.test.tsx CHANGED
@@ -207,7 +207,7 @@ describe("submit", () => {
207
207
 
208
208
  // Act
209
209
  await type(setup, "Expand the steps.");
210
- await press(setup, "enter");
210
+ await pressKey(setup, "RETURN", { meta: true });
211
211
 
212
212
  // Assert
213
213
  await waitForText(setup, "feedback sent");
@@ -231,7 +231,7 @@ describe("submit", () => {
231
231
  await waitForText(setup, "[Approve]"); // no pending items → approve default
232
232
 
233
233
  // Act
234
- await press(setup, "enter");
234
+ await pressKey(setup, "RETURN", { meta: true });
235
235
 
236
236
  // Assert
237
237
  await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
package/src/App.tsx CHANGED
@@ -1068,6 +1068,9 @@ export function App({
1068
1068
  verdict={activeSession.verdict.kind}
1069
1069
  completion={completion}
1070
1070
  status={status}
1071
+ onClose={() => dispatch({ type: "finishReview" })}
1072
+ onBackToPlan={() => dispatch({ type: "dismissCompletion" })}
1073
+ onAlways={() => dispatch({ type: "optInAutoClose" })}
1071
1074
  />
1072
1075
  );
1073
1076
 
@@ -1086,6 +1089,7 @@ export function App({
1086
1089
  session: activeSession,
1087
1090
  walkFileList,
1088
1091
  viewedPaths,
1092
+ quickActions,
1089
1093
  liveInput,
1090
1094
  setMode,
1091
1095
  dispatch,
@@ -162,7 +162,7 @@ describe("the guided walk", () => {
162
162
  expect(setup.captureCharFrame()).toContain("3/3 files viewed");
163
163
 
164
164
  // Act
165
- await press(setup, "enter");
165
+ await pressKey(setup, "RETURN", { meta: true });
166
166
 
167
167
  // Assert
168
168
  await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
@@ -253,7 +253,7 @@ describe("the guided walk", () => {
253
253
  await pressKey(setup, "RETURN", { meta: true });
254
254
  // nothing pending despite the note, so the confirm card defaults to approve
255
255
  await waitForText(setup, "[Approve]");
256
- await press(setup, "enter");
256
+ await pressKey(setup, "RETURN", { meta: true });
257
257
 
258
258
  // Assert
259
259
  await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
@@ -1,4 +1,4 @@
1
- // Bun Snapshot v1, https://goo.gl/fbAQLP
1
+ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
2
 
3
3
  exports[`view-plan regression harness (M0) blockRuns output is stable across the representative blocks 1`] = `
4
4
  [
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ansiScreenToLines } from "./ansi-screen";
3
+
4
+ describe("ansiScreenToLines", () => {
5
+ test("places text at the positioned cursor", () => {
6
+ const lines = ansiScreenToLines("\x1b[2;3Hhi", 10, 4);
7
+
8
+ expect(lines[0]).toBe("");
9
+ expect(lines[1]).toBe(" hi");
10
+ expect(lines[2]).toBe("");
11
+ });
12
+
13
+ test("clears the screen before repositioning", () => {
14
+ const lines = ansiScreenToLines("\x1b[1;1Hold\x1b[2J\x1b[1;1Hnew", 10, 2);
15
+
16
+ expect(lines[0]).toBe("new");
17
+ });
18
+
19
+ test("ignores colour, cursor-visibility, and clipboard sequences", () => {
20
+ const lines = ansiScreenToLines("\x1b[?25l\x1b[1;1H\x1b[32mgo\x1b]52;c;AA\x07", 10, 2);
21
+
22
+ expect(lines[0]).toBe("go");
23
+ });
24
+
25
+ test("clamps writes outside the grid instead of throwing", () => {
26
+ expect(() => ansiScreenToLines("\x1b[99;99Hx", 5, 3)).not.toThrow();
27
+ });
28
+ });
@@ -0,0 +1,72 @@
1
+ /** Interpret an absolute-positioned ANSI byte stream (cursor moves plus printed text) into a grid of rows. */
2
+
3
+ const CSI = "\x1b[";
4
+
5
+ export function ansiScreenToLines(ansi: string, cols: number, rows: number): string[] {
6
+ const grid: string[][] = Array.from({ length: rows }, () =>
7
+ Array.from({ length: cols }, () => " "),
8
+ );
9
+ let row = 0;
10
+ let column = 0;
11
+ let index = 0;
12
+
13
+ const put = (character: string): void => {
14
+ if (row >= 0 && row < rows && column >= 0 && column < cols) grid[row]![column] = character;
15
+ column += 1;
16
+ };
17
+
18
+ while (index < ansi.length) {
19
+ const character = ansi[index]!;
20
+
21
+ if (character === "\x1b") {
22
+ index = consumeEscape(ansi, index, (nextRow, nextColumn) => {
23
+ if (nextRow === "clear") {
24
+ for (const line of grid) line.fill(" ");
25
+ row = 0;
26
+ column = 0;
27
+ } else {
28
+ row = nextRow;
29
+ column = nextColumn;
30
+ }
31
+ });
32
+ continue;
33
+ }
34
+ if (character === "\n") {
35
+ row += 1;
36
+ column = 0;
37
+ } else if (character === "\r") {
38
+ column = 0;
39
+ } else {
40
+ put(character);
41
+ }
42
+ index += 1;
43
+ }
44
+
45
+ return grid.map((line) => line.join("").replace(/\s+$/, ""));
46
+ }
47
+
48
+ type EscapeEffect = (row: number | "clear", column: number) => void;
49
+
50
+ function consumeEscape(ansi: string, start: number, apply: EscapeEffect): number {
51
+ if (ansi[start + 1] === "]") {
52
+ const bell = ansi.indexOf("\x07", start);
53
+
54
+ return bell === -1 ? ansi.length : bell + 1;
55
+ }
56
+ if (ansi.slice(start, start + CSI.length) !== CSI) return start + 1;
57
+ let index = start + CSI.length;
58
+
59
+ while (index < ansi.length && !/[A-Za-z]/.test(ansi[index]!)) index += 1;
60
+ const final = ansi[index];
61
+ const parameters = ansi.slice(start + CSI.length, index);
62
+
63
+ if (final === "H" || final === "f") {
64
+ const [rowParameter = "1", columnParameter = "1"] = parameters.split(";");
65
+
66
+ apply(Math.max(0, Number(rowParameter) - 1), Math.max(0, Number(columnParameter) - 1));
67
+ } else if (final === "J" && parameters === "2") {
68
+ apply("clear", 0);
69
+ }
70
+
71
+ return index + 1;
72
+ }
@@ -388,7 +388,6 @@ export function NoThreadShell(props: {
388
388
  <PromptDialog
389
389
  isOpen
390
390
  title=" rename thread "
391
- label="new title for this thread:"
392
391
  value={mode.text}
393
392
  placeholder="a short title"
394
393
  onInput={(text) => setMode({ ...mode, text })}
@@ -412,8 +411,11 @@ export function CompletionScreen(props: {
412
411
  verdict: VerdictKind;
413
412
  completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
414
413
  status: string;
414
+ onClose: () => void;
415
+ onBackToPlan: () => void;
416
+ onAlways: () => void;
415
417
  }): React.ReactNode {
416
- const { theme, session, verdict, completion, status } = props;
418
+ const { theme, session, verdict, completion, status, onClose, onBackToPlan, onAlways } = props;
417
419
 
418
420
  return (
419
421
  <ThemeProvider theme={theme}>
@@ -421,6 +423,9 @@ export function CompletionScreen(props: {
421
423
  verdict={verdict}
422
424
  completion={completion}
423
425
  status={status}
426
+ onClose={onClose}
427
+ onBackToPlan={onBackToPlan}
428
+ onAlways={onAlways}
424
429
  returnsTo={
425
430
  returnPaneFor(session.artifact.meta.herdrPane)
426
431
  ? (session.artifact.meta.agent ?? "the agent")
@@ -510,7 +515,6 @@ export function TrailingOverlays(props: {
510
515
  <PromptDialog
511
516
  isOpen
512
517
  title=" rename thread "
513
- label="new title for this thread:"
514
518
  value={mode.text}
515
519
  placeholder="a short title"
516
520
  onInput={(text) => setMode({ ...mode, text })}
@@ -4,6 +4,7 @@ import type { Mode } from "./intent-dispatch";
4
4
  import type { Intent, KeyState } from "./keymap";
5
5
  import type { Completion } from "./thread-controller";
6
6
  import type { WalkFile } from "./walk";
7
+ import type { QuickAction } from "./config";
7
8
  import { viewedCount } from "./walk";
8
9
  import type { ConfirmCardProps } from "./components/ConfirmCard";
9
10
  import type { BreadcrumbItem } from "./components/Breadcrumb";
@@ -136,6 +137,7 @@ export function buildSubmitConfirmState(
136
137
  session: Thread;
137
138
  walkFileList: WalkFile[];
138
139
  viewedPaths: Set<string>;
140
+ quickActions: QuickAction[];
139
141
  },
140
142
  ): Omit<ConfirmCardProps, "theme"> | null {
141
143
  const { mode, isDiff, session, walkFileList, viewedPaths, liveInput, setMode, dispatch } = deps;
@@ -145,6 +147,7 @@ export function buildSubmitConfirmState(
145
147
  return {
146
148
  verdict: mode.verdict,
147
149
  summary: mode.summary,
150
+ quickActions: deps.quickActions,
148
151
  viewedSummary:
149
152
  isDiff && session.viewedPaths !== undefined
150
153
  ? `${viewedCount(walkFileList, viewedPaths)}/${walkFileList.length} files viewed`
@@ -1,6 +1,6 @@
1
1
  // A project file shown as its own annotatable surface: the file's lines render through the diff
2
2
  // sheet as context rows (one line-number gutter, no +/- sign), so selecting text leaves a comment
3
- // anchored to the file exactly as a plan or diff comment is. Used where a review session exists to
3
+ // anchored to the file exactly as a plan or diff comment is. Used where a thread exists to
4
4
  // hold the notes; the bare-launch welcome shell uses the read-only FileContentsView instead.
5
5
 
6
6
  import React, { useEffect, useRef, useState } from "react";
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import React, { useContext, useLayoutEffect, useRef, useState } from "react";
10
+ import { usePaste } from "@opentui/react";
10
11
  import type {
11
12
  BoxRenderable,
12
13
  KeyBinding,
@@ -18,6 +19,7 @@ import type { Theme } from "../theme";
18
19
  import { useTooltip } from "./Tooltip";
19
20
  import { lighten } from "../annotation-palette";
20
21
  import { useFrameMeasure } from "../use-frame-measure";
22
+ import { imagePlaceholder, looksLikeBinaryPaste } from "../pasted-image";
21
23
  import { skillReferenceRanges, type SlashItem } from "../slash-palette";
22
24
  import { PaletteNamesContext } from "../skills";
23
25
  import { referenceStyleFor } from "./syntax-highlight";
@@ -35,7 +37,7 @@ const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
35
37
  { name: "return", shift: true, action: "newline" },
36
38
  ];
37
39
 
38
- function composeRowCount(text: string, contentWidth: number): number {
40
+ export function composeRowCount(text: string, contentWidth: number): number {
39
41
  const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
40
42
  let visualRowCount = 0;
41
43
 
@@ -54,6 +56,7 @@ export function Composer({
54
56
  onSave,
55
57
  onReady,
56
58
  onInput,
59
+ placeholder,
57
60
  }: {
58
61
  seed: string;
59
62
  glyph: string;
@@ -61,12 +64,23 @@ export function Composer({
61
64
  onSave: (body: string) => void;
62
65
  onReady: () => void;
63
66
  onInput: (text: string, caret: number) => void;
67
+ placeholder?: string;
64
68
  }): React.ReactNode {
65
69
  const editorRef = useRef<TextareaRenderable | null>(null);
70
+ const pastedImageCount = useRef(0);
66
71
  const [rows, setRows] = useState(1);
67
72
  // action and skill names whose "/name" references paint in the accent color
68
73
  const referenceNames = useContext(PaletteNamesContext);
69
74
 
75
+ usePaste((event) => {
76
+ const editor = editorRef.current;
77
+
78
+ if (!editor || !editor.focused || !looksLikeBinaryPaste(event.bytes)) return;
79
+ event.preventDefault();
80
+ pastedImageCount.current += 1;
81
+ editor.editBuffer.insertText(imagePlaceholder(pastedImageCount.current));
82
+ });
83
+
70
84
  const paintReferences = (editor: TextareaRenderable): void => {
71
85
  const { styleId } = referenceStyleFor(tokens);
72
86
 
@@ -98,6 +112,8 @@ export function Composer({
98
112
  ref={editorRef}
99
113
  focused
100
114
  initialValue={seed}
115
+ placeholder={placeholder}
116
+ placeholderColor={tokens.textDim}
101
117
  cursorStyle={{ style: "block", blinking: true }}
102
118
  keyBindings={COMPOSE_KEY_BINDINGS}
103
119
  onSubmit={() => onSave(editorRef.current?.plainText ?? "")}
@@ -247,7 +263,7 @@ export function DiscussionCard({
247
263
  });
248
264
 
249
265
  return (
250
- <box style={{ flexDirection: "row", marginTop: 1, marginLeft: 2 }}>
266
+ <box style={{ flexDirection: "row", marginTop: 0, marginLeft: 2 }}>
251
267
  <text selectable={false} style={{ flexShrink: 0, width: 1 }}>
252
268
  {edgeRows.map((row, index) => (
253
269
  <span key={index} fg={row.color}>
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import type { Story, StoryMeta } from "./story";
3
+ import { AnsiScreen } from "./AnsiScreen";
4
+
5
+ export const meta: StoryMeta = { title: "Primitives/AnsiScreen" };
6
+
7
+ const POSITIONED = "\x1b[2;4HHello from\x1b[4;6Hraw, positioned ANSI";
8
+
9
+ export const Positioned: Story = {
10
+ render: () => <AnsiScreen ansi={POSITIONED} cols={40} rows={6} />,
11
+ size: { width: 44, height: 8 },
12
+ };
@@ -0,0 +1,28 @@
1
+ /** Paints a raw ANSI screen (cursor-positioned text) as a terminal would, for previewing SSH surfaces. */
2
+
3
+ import React from "react";
4
+ import type { Theme } from "../theme";
5
+ import { ansiScreenToLines } from "../ansi-screen";
6
+ import { useComponentTheme } from "./theme-context";
7
+
8
+ export interface AnsiScreenProps {
9
+ ansi: string;
10
+ cols: number;
11
+ rows: number;
12
+ theme?: Theme;
13
+ }
14
+
15
+ export function AnsiScreen({ ansi, cols, rows, theme }: AnsiScreenProps): React.ReactNode {
16
+ const tokens = useComponentTheme(theme);
17
+ const lines = ansiScreenToLines(ansi, cols, rows);
18
+
19
+ return (
20
+ <box style={{ flexDirection: "column" }}>
21
+ {lines.map((line, index) => (
22
+ <text key={index} fg={tokens.text} selectable={false} style={{ wrapMode: "none" }}>
23
+ {line === "" ? " " : line}
24
+ </text>
25
+ ))}
26
+ </box>
27
+ );
28
+ }
@@ -5,8 +5,19 @@ import { CompletionOverlay } from "./CompletionOverlay";
5
5
 
6
6
  export const meta: StoryMeta = { title: "Overlays/CompletionOverlay" };
7
7
 
8
+ const noop = () => {};
9
+
8
10
  export const ApprovedPrompt: Story = {
9
- render: () => <CompletionOverlay verdict="approve" completion={{ phase: "prompt" }} status="" />,
11
+ render: () => (
12
+ <CompletionOverlay
13
+ verdict="approve"
14
+ completion={{ phase: "prompt" }}
15
+ status=""
16
+ onClose={noop}
17
+ onBackToPlan={noop}
18
+ onAlways={noop}
19
+ />
20
+ ),
10
21
  expectedColors: [DARK.green],
11
22
  };
12
23
 
@@ -17,6 +28,9 @@ export const FeedbackCounting: Story = {
17
28
  completion={{ phase: "counting", remaining: 3 }}
18
29
  status="exported to vault/cueloop/plan.md"
19
30
  returnsTo="agent/worker-3"
31
+ onClose={noop}
32
+ onBackToPlan={noop}
33
+ onAlways={noop}
20
34
  />
21
35
  ),
22
36
  expectedColors: [DARK.accent],
@@ -8,6 +8,8 @@ import React from "react";
8
8
  import type { VerdictKind } from "@cueloop/schema";
9
9
  import type { Theme } from "../theme";
10
10
  import { useComponentTheme } from "./theme-context";
11
+ import { Toolbar } from "./primitives/Toolbar";
12
+ import { Button } from "./primitives/Button";
11
13
 
12
14
  export interface CompletionOverlayProps {
13
15
  verdict: VerdictKind;
@@ -16,6 +18,9 @@ export interface CompletionOverlayProps {
16
18
  status: string;
17
19
  /** Where focus goes on close (the agent's pane), when known. */
18
20
  returnsTo?: string;
21
+ onClose: () => void;
22
+ onBackToPlan: () => void;
23
+ onAlways: () => void;
19
24
  theme?: Theme;
20
25
  }
21
26
 
@@ -24,6 +29,9 @@ export function CompletionOverlay({
24
29
  completion,
25
30
  status,
26
31
  returnsTo,
32
+ onClose,
33
+ onBackToPlan,
34
+ onAlways,
27
35
  theme,
28
36
  }: CompletionOverlayProps): React.ReactNode {
29
37
  const tokens = useComponentTheme(theme);
@@ -49,12 +57,21 @@ export function CompletionOverlay({
49
57
  </text>
50
58
  {returnsTo ? <text fg={tokens.textDim}>returning to {returnsTo} on close</text> : null}
51
59
  {status ? <text fg={tokens.textDim}>{status}</text> : null}
60
+ {completion.phase === "counting" ? (
61
+ <text fg={tokens.textDim}>closing in {completion.remaining}s</text>
62
+ ) : null}
52
63
  <text> </text>
53
- <text fg={tokens.textDim}>
54
- close [return]
55
- {completion.phase === "counting" ? ` · closing in ${completion.remaining}s` : ""}
56
- {" · return to plan [esc] · always [a]"}
57
- </text>
64
+ <Toolbar>
65
+ <Button variant="solid" marginRight={2} onPress={onClose} theme={theme}>
66
+ {" close "}
67
+ </Button>
68
+ <Button marginRight={2} onPress={onBackToPlan} theme={theme}>
69
+ {" back to plan "}
70
+ </Button>
71
+ <Button onPress={onAlways} theme={theme}>
72
+ {" always "}
73
+ </Button>
74
+ </Toolbar>
58
75
  </box>
59
76
  );
60
77
  }
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { DARK } from "../theme";
3
+ import { DEFAULT_QUICK_ACTIONS } from "../config";
3
4
  import type { Story, StoryMeta } from "./story";
4
5
  import { ConfirmCard } from "./ConfirmCard";
5
6
 
@@ -10,6 +11,7 @@ const callbacks = {
10
11
  onSelectVerdict: () => {},
11
12
  onSubmit: () => {},
12
13
  onCancel: () => {},
14
+ quickActions: DEFAULT_QUICK_ACTIONS,
13
15
  };
14
16
 
15
17
  export const ApproveDefault: Story = {