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

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 (130) hide show
  1. package/package.json +14 -13
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +22 -5
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.focus.test.ts +1 -59
  6. package/src/App.identity.test.tsx +3 -3
  7. package/src/App.plan-render.test.tsx +209 -0
  8. package/src/App.plan-review.test.tsx +91 -26
  9. package/src/App.readonly.test.tsx +6 -5
  10. package/src/App.share-dialog.test.tsx +172 -0
  11. package/src/App.submit-confirm.test.tsx +39 -8
  12. package/src/App.test.tsx +33 -30
  13. package/src/App.toast.test.tsx +11 -4
  14. package/src/App.tsx +263 -274
  15. package/src/App.walk.test.tsx +11 -10
  16. package/src/App.workbench.test.tsx +86 -0
  17. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  18. package/src/annotation-palette.ts +1 -0
  19. package/src/ansi-screen.test.ts +28 -0
  20. package/src/ansi-screen.ts +72 -0
  21. package/src/app-screens.tsx +19 -9
  22. package/src/app-view-model.ts +3 -1
  23. package/src/components/AnnotatableFileView.tsx +1 -1
  24. package/src/components/AnnotationCards.tsx +18 -2
  25. package/src/components/AnsiScreen.stories.tsx +12 -0
  26. package/src/components/AnsiScreen.tsx +28 -0
  27. package/src/components/CompletionOverlay.stories.tsx +15 -1
  28. package/src/components/CompletionOverlay.tsx +22 -5
  29. package/src/components/ConfirmCard.stories.tsx +2 -0
  30. package/src/components/ConfirmCard.tsx +45 -18
  31. package/src/components/ConfirmDialog.stories.tsx +3 -3
  32. package/src/components/ConfirmDialog.tsx +11 -12
  33. package/src/components/DiffContentView.tsx +18 -11
  34. package/src/components/DiscussionMarkerRail.tsx +9 -4
  35. package/src/components/FileContentsView.tsx +1 -1
  36. package/src/components/GridTabContent.tsx +8 -2
  37. package/src/components/HandleChipsInput.stories.tsx +24 -0
  38. package/src/components/HandleChipsInput.test.tsx +35 -0
  39. package/src/components/HandleChipsInput.tsx +145 -0
  40. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  41. package/src/components/MarkdownGridBlock.tsx +72 -0
  42. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  43. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  44. package/src/components/MarkdownThreadEditor.tsx +162 -0
  45. package/src/components/NavModeHint.stories.tsx +40 -0
  46. package/src/components/NavModeHint.tsx +31 -0
  47. package/src/components/PromptDialog.test.tsx +2 -2
  48. package/src/components/PromptDialog.tsx +9 -10
  49. package/src/components/SettingsDialog.tsx +1 -0
  50. package/src/components/ShareDialog.stories.tsx +121 -0
  51. package/src/components/ShareDialog.tsx +552 -0
  52. package/src/components/SlashComposer.stories.tsx +37 -0
  53. package/src/components/SlashComposer.tsx +87 -0
  54. package/src/components/ThreadFooter.tsx +4 -1
  55. package/src/components/ThreadTree.tsx +12 -7
  56. package/src/components/ThreadView.selection.test.tsx +88 -1
  57. package/src/components/ThreadView.slash.test.tsx +0 -36
  58. package/src/components/ThreadView.tsx +200 -67
  59. package/src/components/Toast.stories.tsx +7 -0
  60. package/src/components/Toast.tsx +15 -8
  61. package/src/components/WalkWizard.stories.tsx +2 -2
  62. package/src/components/WalkWizard.tsx +10 -12
  63. package/src/components/__snapshots__/stories.test.tsx.snap +436 -182
  64. package/src/components/primitives/Button.tsx +6 -2
  65. package/src/components/primitives/Card.tsx +5 -2
  66. package/src/components/primitives/Dialog.tsx +2 -2
  67. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  68. package/src/components/primitives/DialogActions.tsx +39 -0
  69. package/src/components/primitives/Switch.stories.tsx +17 -0
  70. package/src/components/primitives/Switch.tsx +32 -0
  71. package/src/components/primitives/Tree.tsx +5 -1
  72. package/src/components/primitives/frame.ts +5 -0
  73. package/src/components/session-tree.ts +7 -6
  74. package/src/components/share-dialog-store.ts +58 -0
  75. package/src/components/stories-app.tsx +13 -4
  76. package/src/components/stories.test.tsx +12 -4
  77. package/src/components/story-tree.test.ts +32 -24
  78. package/src/components/story.ts +32 -15
  79. package/src/config.test.ts +18 -0
  80. package/src/config.ts +8 -1
  81. package/src/diff-fold.ts +1 -1
  82. package/src/diff-intraline.test.ts +75 -2
  83. package/src/diff-intraline.ts +95 -26
  84. package/src/intent-dispatch.test.ts +12 -3
  85. package/src/intent-dispatch.ts +10 -25
  86. package/src/key-bindings.ts +0 -6
  87. package/src/keymap.test.ts +2 -2
  88. package/src/keymap.ts +0 -2
  89. package/src/markdown-editor-syntax-style.ts +50 -0
  90. package/src/markdown-grid.test.ts +101 -0
  91. package/src/markdown-grid.ts +203 -0
  92. package/src/markdown-highlight.test.ts +61 -0
  93. package/src/markdown-highlight.ts +122 -0
  94. package/src/pasted-image.test.ts +67 -0
  95. package/src/pasted-image.ts +60 -0
  96. package/src/prototype-browser.test.ts +9 -1
  97. package/src/prototype-browser.ts +22 -0
  98. package/src/run.ts +39 -25
  99. package/src/serve.ts +1 -1
  100. package/src/share.ts +16 -2
  101. package/src/slash-palette.test.ts +17 -0
  102. package/src/slash-palette.ts +11 -0
  103. package/src/test-support.ts +14 -0
  104. package/src/thread-chords.test.ts +55 -178
  105. package/src/thread-chords.ts +78 -169
  106. package/src/thread-controller.curate.test.ts +1 -0
  107. package/src/thread-controller.derivation.test.ts +62 -0
  108. package/src/thread-controller.live-diff.test.ts +3 -0
  109. package/src/thread-controller.optimistic.test.ts +69 -0
  110. package/src/thread-controller.share.test.ts +60 -5
  111. package/src/thread-controller.switch-cache.test.ts +176 -0
  112. package/src/thread-controller.tree.test.ts +6 -1
  113. package/src/thread-controller.ts +386 -78
  114. package/src/use-annotation-surface.tsx +88 -63
  115. package/src/use-settings-dialog.tsx +14 -3
  116. package/src/use-thread-body-editing.tsx +58 -0
  117. package/src/view-plan.test.ts +56 -6
  118. package/src/view-plan.ts +154 -5
  119. package/src/App.share-choice.test.tsx +0 -105
  120. package/src/components/ManageAccessDialog.stories.tsx +0 -36
  121. package/src/components/ManageAccessDialog.test.tsx +0 -94
  122. package/src/components/ManageAccessDialog.tsx +0 -120
  123. package/src/components/ShareChoiceDialog.stories.tsx +0 -34
  124. package/src/components/ShareChoiceDialog.test.tsx +0 -103
  125. package/src/components/ShareChoiceDialog.tsx +0 -82
  126. package/src/components/ThreadsSidebar.stories.tsx +0 -36
  127. package/src/components/ThreadsSidebar.tsx +0 -71
  128. package/src/components/agent-launcher.stories.tsx +0 -26
  129. package/src/components/agent-launcher.test.tsx +0 -50
  130. package/src/components/agent-launcher.tsx +0 -149
@@ -12,6 +12,7 @@ import { DARK } from "./theme";
12
12
  import {
13
13
  isolateUserConfig,
14
14
  press,
15
+ navCommand,
15
16
  pressKey,
16
17
  renderReadyApp,
17
18
  waitForState,
@@ -104,7 +105,7 @@ describe("the guided walk", () => {
104
105
  const setup = await renderApp();
105
106
 
106
107
  // Act
107
- await pressKey(setup, "k", { meta: true });
108
+ await navCommand(setup, "k");
108
109
 
109
110
  // Assert
110
111
  await waitForText(setup, "file 1 of 3 · 0 viewed");
@@ -162,7 +163,7 @@ describe("the guided walk", () => {
162
163
  expect(setup.captureCharFrame()).toContain("3/3 files viewed");
163
164
 
164
165
  // Act
165
- await press(setup, "enter");
166
+ await pressKey(setup, "RETURN", { meta: true });
166
167
 
167
168
  // Assert
168
169
  await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
@@ -172,7 +173,7 @@ describe("the guided walk", () => {
172
173
  // Arrange
173
174
  const setup = await renderApp();
174
175
 
175
- await pressKey(setup, "k", { meta: true });
176
+ await navCommand(setup, "k");
176
177
  await waitForText(setup, "file 1 of 3 · 0 viewed");
177
178
  await press(setup, "]");
178
179
  await waitForText(setup, "file 2 of 3 · 1 viewed");
@@ -186,7 +187,7 @@ describe("the guided walk", () => {
186
187
  expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
187
188
 
188
189
  // Act
189
- await pressKey(setup, "k", { meta: true });
190
+ await navCommand(setup, "k");
190
191
 
191
192
  // Assert
192
193
  await waitForText(setup, "file 2 of 3 · 1 viewed");
@@ -196,7 +197,7 @@ describe("the guided walk", () => {
196
197
  // Arrange
197
198
  const first = await renderApp();
198
199
 
199
- await pressKey(first, "k", { meta: true });
200
+ await navCommand(first, "k");
200
201
  await waitForText(first, "file 1 of 3 · 0 viewed");
201
202
  await press(first, "]");
202
203
  await press(first, "]");
@@ -211,7 +212,7 @@ describe("the guided walk", () => {
211
212
  // a fresh client reads the viewed set back from the session record
212
213
  const second = await renderApp();
213
214
 
214
- await pressKey(second, "k", { meta: true });
215
+ await navCommand(second, "k");
215
216
 
216
217
  // Assert
217
218
  await waitForText(second, "file 3 of 3 · 2 viewed");
@@ -232,7 +233,7 @@ describe("the guided walk", () => {
232
233
  const setup = await renderApp();
233
234
 
234
235
  // Act
235
- await pressKey(setup, "k", { meta: true });
236
+ await navCommand(setup, "k");
236
237
 
237
238
  // Assert
238
239
  await waitForText(setup, "file 1 of 3");
@@ -253,7 +254,7 @@ describe("the guided walk", () => {
253
254
  await pressKey(setup, "RETURN", { meta: true });
254
255
  // nothing pending despite the note, so the confirm card defaults to approve
255
256
  await waitForText(setup, "[Approve]");
256
- await press(setup, "enter");
257
+ await pressKey(setup, "RETURN", { meta: true });
257
258
 
258
259
  // Assert
259
260
  await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
@@ -273,7 +274,7 @@ describe("the guided walk", () => {
273
274
  expect(foregroundsOf(setup, "new")).toContain(DARK.insertedForeground);
274
275
 
275
276
  // Act
276
- await pressKey(setup, "k", { meta: true });
277
+ await navCommand(setup, "k");
277
278
 
278
279
  // Assert
279
280
  await waitForText(setup, "file 1 of 3 · 0 viewed");
@@ -289,7 +290,7 @@ describe("the guided walk", () => {
289
290
  const setup = await renderApp();
290
291
 
291
292
  // Act - renderApp already waited for the diff to load; the session is resolved
292
- await pressKey(setup, "k", { meta: true });
293
+ await navCommand(setup, "k");
293
294
 
294
295
  // Assert
295
296
  await waitForText(setup, "review submitted - read-only");
@@ -21,10 +21,12 @@ import {
21
21
  dragText,
22
22
  isolateUserConfig,
23
23
  locateText,
24
+ press,
24
25
  pressKey,
25
26
  typeText,
26
27
  waitForState,
27
28
  waitForText,
29
+ waitForTextGone,
28
30
  renderReadyApp,
29
31
  } from "./test-support";
30
32
  import { NERD } from "./components/primitives/icons";
@@ -98,6 +100,7 @@ type Setup = Awaited<ReturnType<typeof renderApp>>;
98
100
 
99
101
  /** The header controls sit on the top content row of the compact header. */
100
102
  const HEADER_ROW = 0;
103
+ const BARE_NAV_HINT = "c comment · z fold · type to leave";
101
104
 
102
105
  /** Column of a Project-header toggle, located by its own glyph so spacing changes never break the click. */
103
106
  function toggleColumn(setup: Setup, glyph: string): number {
@@ -496,6 +499,89 @@ describe("the bare-launch welcome shell", () => {
496
499
  expect(frame).toContain("edited in the working tree");
497
500
  });
498
501
 
502
+ test("nav mode on the Welcome tab offers only comment and fold; a diff letter types", async () => {
503
+ const setup = await renderReadyApp(
504
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
505
+ { width: 160, height: 28 },
506
+ );
507
+ await waitForText(setup, "quick brown fox");
508
+ const line = locateText(setup, "quick brown fox");
509
+ await setup.mockMouse.click(line.column, line.row);
510
+
511
+ // esc enters nav; the footer names what this surface can do, not the diff commands
512
+ await press(setup, "escape");
513
+ await waitForText(setup, BARE_NAV_HINT);
514
+ expect(setup.captureCharFrame()).not.toContain("x reject");
515
+
516
+ // a diff letter has no command here, so it leaves nav and types, as the footer says
517
+ await pressKey(setup, "d");
518
+ await waitForText(setup, "● d");
519
+ await press(setup, "escape");
520
+ await waitForTextGone(setup, "● d");
521
+
522
+ // c opens a comment on the caret word; z folds the saved card
523
+ await press(setup, "escape");
524
+ await waitForText(setup, BARE_NAV_HINT);
525
+ await pressKey(setup, "c");
526
+ await typeText(setup, "note");
527
+ await waitForText(setup, "● note");
528
+ await pressKey(setup, "RETURN", { meta: true });
529
+ await waitForText(setup, "note");
530
+ await press(setup, "escape");
531
+ await waitForText(setup, BARE_NAV_HINT);
532
+ await pressKey(setup, "z");
533
+ await waitForText(setup, "○ 1 comment ›");
534
+
535
+ setup.renderer.destroy();
536
+ });
537
+
538
+ test("nav mode on a bare changed-file diff has no reject or walk; x types instead", async () => {
539
+ const setup = await renderReadyApp(
540
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
541
+ { width: 160, height: 28 },
542
+ );
543
+ await waitForText(setup, "README.md");
544
+ const readme = locateText(setup, "README.md");
545
+ await setup.mockMouse.click(readme.column, readme.row);
546
+ await waitForText(setup, "edited in the working tree");
547
+ const line = locateText(setup, "edited in the working tree");
548
+ await setup.mockMouse.click(line.column, line.row);
549
+
550
+ await press(setup, "escape");
551
+ await waitForText(setup, BARE_NAV_HINT);
552
+ expect(setup.captureCharFrame()).not.toContain("x reject");
553
+ await pressKey(setup, "x");
554
+ await waitForText(setup, "● x");
555
+ const frame = setup.captureCharFrame();
556
+ expect(frame).toContain("edited in the working tree");
557
+ expect(frame).not.toContain("change rejected");
558
+
559
+ setup.renderer.destroy();
560
+ });
561
+
562
+ test("nav mode on a project file offers only comment and fold", async () => {
563
+ const setup = await renderReadyApp(
564
+ <App home={welcomeHome} sessionId={undefined} cwd={welcomeRepo} />,
565
+ { width: 160, height: 28 },
566
+ );
567
+ await waitForText(setup, "README.md");
568
+ await setup.mockMouse.click(treeToggleColumn(setup), HEADER_ROW);
569
+ await waitForText(setup, "README.md");
570
+ const readme = locateText(setup, "README.md");
571
+ await setup.mockMouse.click(readme.column, readme.row);
572
+ await waitForText(setup, "edited in the working tree");
573
+ const line = locateText(setup, "edited in the working tree");
574
+ await setup.mockMouse.click(line.column, line.row);
575
+
576
+ await press(setup, "escape");
577
+ await waitForText(setup, BARE_NAV_HINT);
578
+ expect(setup.captureCharFrame()).not.toContain("x reject");
579
+ await pressKey(setup, "d");
580
+ await waitForText(setup, "● d");
581
+
582
+ setup.renderer.destroy();
583
+ });
584
+
499
585
  test("a bare launch rides a Welcome tab in the editor while the Thread pane waits empty", async () => {
500
586
  const setup = await renderWelcome();
501
587
 
@@ -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
  [
@@ -11,6 +11,7 @@ import type { Theme } from "./theme";
11
11
  export const UNDERLINE = createTextAttributes({ underline: true });
12
12
  export const CUT = createTextAttributes({ strikethrough: true, dim: true });
13
13
  export const BOLD = createTextAttributes({ bold: true });
14
+ export const ITALIC = createTextAttributes({ italic: true });
14
15
 
15
16
  /** The marked-words treatment: a violet backdrop under an underline. */
16
17
  const MARK_BACKDROP_DARK = "#463852";
@@ -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
+ }
@@ -371,12 +371,8 @@ export function NoThreadShell(props: {
371
371
  {menuChrome}
372
372
  <ConfirmDialog
373
373
  isOpen={confirming !== null}
374
- title=" Delete plan "
375
- message={
376
- confirming
377
- ? `Delete "${confirming.title}"? This removes the plan and its review.`
378
- : ""
379
- }
374
+ title=" delete thread "
375
+ message={confirming ? `Delete "${confirming.title}"? This removes the thread.` : ""}
380
376
  onConfirm={() => {
381
377
  if (confirming) controller.deleteSession(confirming.sessionId);
382
378
  setMode({ type: "normal" });
@@ -388,7 +384,6 @@ export function NoThreadShell(props: {
388
384
  <PromptDialog
389
385
  isOpen
390
386
  title=" rename thread "
391
- label="new title for this thread:"
392
387
  value={mode.text}
393
388
  placeholder="a short title"
394
389
  onInput={(text) => setMode({ ...mode, text })}
@@ -412,8 +407,11 @@ export function CompletionScreen(props: {
412
407
  verdict: VerdictKind;
413
408
  completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
414
409
  status: string;
410
+ onClose: () => void;
411
+ onBackToPlan: () => void;
412
+ onAlways: () => void;
415
413
  }): React.ReactNode {
416
- const { theme, session, verdict, completion, status } = props;
414
+ const { theme, session, verdict, completion, status, onClose, onBackToPlan, onAlways } = props;
417
415
 
418
416
  return (
419
417
  <ThemeProvider theme={theme}>
@@ -421,6 +419,9 @@ export function CompletionScreen(props: {
421
419
  verdict={verdict}
422
420
  completion={completion}
423
421
  status={status}
422
+ onClose={onClose}
423
+ onBackToPlan={onBackToPlan}
424
+ onAlways={onAlways}
424
425
  returnsTo={
425
426
  returnPaneFor(session.artifact.meta.herdrPane)
426
427
  ? (session.artifact.meta.agent ?? "the agent")
@@ -510,7 +511,6 @@ export function TrailingOverlays(props: {
510
511
  <PromptDialog
511
512
  isOpen
512
513
  title=" rename thread "
513
- label="new title for this thread:"
514
514
  value={mode.text}
515
515
  placeholder="a short title"
516
516
  onInput={(text) => setMode({ ...mode, text })}
@@ -519,6 +519,16 @@ export function TrailingOverlays(props: {
519
519
  theme={theme}
520
520
  />
521
521
  ) : null}
522
+ {mode.type === "confirmDelete" ? (
523
+ <ConfirmDialog
524
+ isOpen
525
+ title=" delete thread "
526
+ message={`Delete "${mode.title}"? This removes the thread.`}
527
+ onConfirm={() => dispatch({ type: "confirmDialog" })}
528
+ onCancel={() => setMode({ type: "normal" })}
529
+ theme={theme}
530
+ />
531
+ ) : null}
522
532
  {mode.type === "nameSelf" ? (
523
533
  <PromptDialog
524
534
  isOpen
@@ -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";
@@ -57,7 +58,6 @@ export function resolveOverlay(
57
58
  mode.type === "treePrompt"
58
59
  )
59
60
  return "prompt";
60
- if (mode.type === "shareChoice") return "shareChoice";
61
61
  if (mode.type === "spanActions") return "spanActions";
62
62
  if (completionPhase === "prompt") return "completion-prompt";
63
63
  if (completionPhase === "counting") return "completion-counting";
@@ -136,6 +136,7 @@ export function buildSubmitConfirmState(
136
136
  session: Thread;
137
137
  walkFileList: WalkFile[];
138
138
  viewedPaths: Set<string>;
139
+ quickActions: QuickAction[];
139
140
  },
140
141
  ): Omit<ConfirmCardProps, "theme"> | null {
141
142
  const { mode, isDiff, session, walkFileList, viewedPaths, liveInput, setMode, dispatch } = deps;
@@ -145,6 +146,7 @@ export function buildSubmitConfirmState(
145
146
  return {
146
147
  verdict: mode.verdict,
147
148
  summary: mode.summary,
149
+ quickActions: deps.quickActions,
148
150
  viewedSummary:
149
151
  isDiff && session.viewedPaths !== undefined
150
152
  ? `${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 = {