@cueloop/client 0.1.0-alpha.82 → 0.1.0-alpha.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.82",
3
+ "version": "0.1.0-alpha.84",
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.82",
31
- "@cueloop/extension-api": "0.1.0-alpha.82",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.82",
33
- "@cueloop/schema": "0.1.0-alpha.82",
30
+ "@cueloop/daemon": "0.1.0-alpha.84",
31
+ "@cueloop/extension-api": "0.1.0-alpha.84",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.84",
33
+ "@cueloop/schema": "0.1.0-alpha.84",
34
34
  "@opentui/core": "^0.5.11",
35
35
  "@opentui/keymap": "0.5.11",
36
36
  "@opentui/react": "^0.5.11",
@@ -96,9 +96,9 @@ describe("diff review", () => {
96
96
 
97
97
  // Act - submit with the session chord (cmd+enter, no composer open), cycle to request changes
98
98
  await pressKey(setup, "RETURN", { meta: true });
99
- await waitForText(setup, "[Approve]");
99
+ await waitForText(setup, "[approve]");
100
100
  await press(setup, "right");
101
- await waitForText(setup, "[Changes]");
101
+ await waitForText(setup, "[changes]");
102
102
  await pressKey(setup, "RETURN", { meta: true });
103
103
 
104
104
  // Assert
@@ -195,10 +195,11 @@ describe("diff review", () => {
195
195
  await navCommand(setup, "x");
196
196
 
197
197
  // Assert - the single change is gone, so the curated working copy is empty
198
- await waitForText(setup, "change rejected");
198
+ await waitForState(setup, () => server.core.sessionGet(withFiles.id).workingCopy === "");
199
199
  const stored = server.core.sessionGet(withFiles.id);
200
200
 
201
201
  expect(stored.workingCopy).toBe("");
202
+ expect(setup.captureCharFrame()).not.toContain("change rejected");
202
203
  });
203
204
 
204
205
  test("a diff opens with the Changes column listing every changed file", async () => {
@@ -77,7 +77,7 @@ describe("obsidian export on resolve", () => {
77
77
  await pressKey(setup, "RETURN", { meta: true }); // open the submit overlay, keep the default message (approve)
78
78
 
79
79
  // Assert
80
- await waitForText(setup, "[Approve]");
80
+ await waitForText(setup, "[approve]");
81
81
 
82
82
  // Act
83
83
  await pressKey(setup, "RETURN", { meta: true }); // submit
@@ -114,7 +114,7 @@ describe("observer primitives are blocked", () => {
114
114
 
115
115
  // no draft card and no submit card ever appear
116
116
  expect(frame).not.toContain("● c");
117
- expect(frame).not.toContain("[Approve]");
117
+ expect(frame).not.toContain("[approve]");
118
118
  expect(snapshot()).toEqual(before);
119
119
  });
120
120
  }
@@ -84,7 +84,7 @@ function seedAnnotations(count: number): void {
84
84
  }
85
85
 
86
86
  describe("send message confirm", () => {
87
- test("cmd+enter opens the send-message confirm overlay", async () => {
87
+ test("cmd+enter opens the send-message confirm overlay with stable message choices", async () => {
88
88
  // Arrange
89
89
  const setup = await renderApp();
90
90
 
@@ -95,12 +95,31 @@ describe("send message confirm", () => {
95
95
  await pressKey(setup, "RETURN", { meta: true });
96
96
 
97
97
  // Assert - the overlay: message selector and word-buttons
98
- await waitForText(setup, "[Approve]"); // nothing pending: approve default
99
- const frame = setup.captureCharFrame();
98
+ await waitForText(setup, "[approve]"); // nothing pending: approve default
99
+ const initialFrame = setup.captureCharFrame();
100
+ const initialSelector = initialFrame
101
+ .split("\n")
102
+ .find((row) => row.includes("[comment] [approve] [changes]"));
103
+
104
+ expect(initialSelector).toBeDefined();
105
+ expect(initialFrame).toContain(" send ");
106
+ expect(initialFrame).toContain(" cancel ");
100
107
 
101
- expect(frame).toContain("[Approve]");
102
- expect(frame).toContain(" send ");
103
- expect(frame).toContain(" cancel ");
108
+ // Act - move the active choice across the selector
109
+ await press(setup, "right");
110
+ const changesSelector = setup
111
+ .captureCharFrame()
112
+ .split("\n")
113
+ .find((row) => row.includes("[comment] [approve] [changes]"));
114
+ await press(setup, "right");
115
+ const commentSelector = setup
116
+ .captureCharFrame()
117
+ .split("\n")
118
+ .find((row) => row.includes("[comment] [approve] [changes]"));
119
+
120
+ // Assert - active styling does not move or resize any choice
121
+ expect(changesSelector).toBe(initialSelector);
122
+ expect(commentSelector).toBe(initialSelector);
104
123
  });
105
124
 
106
125
  test("left/right cycles the message selector in the overlay", async () => {
@@ -110,25 +129,25 @@ describe("send message confirm", () => {
110
129
  await pressKey(setup, "RETURN", { meta: true });
111
130
 
112
131
  // Assert
113
- await waitForText(setup, "[Approve]");
132
+ await waitForText(setup, "[approve]");
114
133
 
115
134
  // Act
116
135
  await press(setup, "right");
117
136
 
118
137
  // Assert
119
- await waitForText(setup, "[Changes]");
138
+ await waitForText(setup, "[changes]");
120
139
 
121
140
  // Act
122
141
  await press(setup, "right");
123
142
 
124
143
  // Assert
125
- await waitForText(setup, "[Approve]");
144
+ await waitForText(setup, "[comment]");
126
145
 
127
146
  // Act
128
147
  await press(setup, "left");
129
148
 
130
149
  // Assert
131
- await waitForText(setup, "[Changes]");
150
+ await waitForText(setup, "[changes]");
132
151
  });
133
152
 
134
153
  test("esc closes the overlay", async () => {
@@ -138,13 +157,13 @@ describe("send message confirm", () => {
138
157
  await pressKey(setup, "RETURN", { meta: true });
139
158
 
140
159
  // Assert
141
- await waitForText(setup, "[Approve]");
160
+ await waitForText(setup, "[approve]");
142
161
 
143
162
  // Act
144
163
  await press(setup, "escape");
145
164
 
146
165
  // Assert - a bare ESC settles after the parser's escape-sequence window
147
- const frame = await waitForTextGone(setup, "[Approve]");
166
+ const frame = await waitForTextGone(setup, "[approve]");
148
167
 
149
168
  expect(frame).not.toContain(" cancel ");
150
169
  });
@@ -158,9 +177,9 @@ describe("send message confirm", () => {
158
177
  await pressKey(setup, "RETURN", { meta: true });
159
178
 
160
179
  // Assert - opens on the default message, then cycle to request changes
161
- await waitForText(setup, "[Approve]");
180
+ await waitForText(setup, "[approve]");
162
181
  await press(setup, "right");
163
- await waitForText(setup, "[Changes]");
182
+ await waitForText(setup, "[changes]");
164
183
 
165
184
  // Act
166
185
  await setup.mockInput.typeText("Tighten the steps.");
@@ -174,12 +193,35 @@ describe("send message confirm", () => {
174
193
  expect(stored.message!.outcome).toBe("changes_requested");
175
194
  });
176
195
 
196
+ test("Comment sends feedback while leaving the Thread open", async () => {
197
+ seedAnnotations(1);
198
+ const setup = await renderApp();
199
+
200
+ await pressKey(setup, "RETURN", { meta: true });
201
+ await waitForText(setup, "[approve]");
202
+ await press(setup, "left");
203
+ await waitForText(setup, "[comment]");
204
+
205
+ await setup.mockInput.typeText("For now.");
206
+ await pressKey(setup, "RETURN", { meta: true });
207
+
208
+ await waitForText(setup, "comment sent - thread stays open");
209
+ const stored = server.core.sessionGet(session.id);
210
+
211
+ expect(stored.status).toBe("pending");
212
+ expect(stored.message!.outcome).toBe("comment");
213
+ expect(stored.message!.annotations?.map((annotation) => annotation.id)).toEqual([
214
+ "a_confirm_1",
215
+ ]);
216
+ expect(setup.captureCharFrame()).not.toContain("feedback sent");
217
+ });
218
+
177
219
  test("typing / in the summary opens the skills/actions palette", async () => {
178
220
  // Arrange
179
221
  seedAnnotations(1);
180
222
  const setup = await renderApp();
181
223
  await pressKey(setup, "RETURN", { meta: true });
182
- await waitForText(setup, "[Approve]");
224
+ await waitForText(setup, "[approve]");
183
225
 
184
226
  // Act
185
227
  await setup.mockInput.typeText("/restate");
@@ -192,7 +234,7 @@ describe("send message confirm", () => {
192
234
  // Arrange
193
235
  const setup = await renderApp();
194
236
  await pressKey(setup, "RETURN", { meta: true });
195
- await waitForText(setup, "[Approve]");
237
+ await waitForText(setup, "[approve]");
196
238
 
197
239
  // Act - a burst of control bytes stands in for the binary an image paste delivers
198
240
  await setup.mockInput.typeText("look at this ");
@@ -215,7 +257,7 @@ describe("send message confirm", () => {
215
257
  // Assert - no overlay, and the session stays pending
216
258
  const frame = setup.captureCharFrame();
217
259
 
218
- expect(frame).not.toContain("[Approve]");
260
+ expect(frame).not.toContain("[approve]");
219
261
  expect(frame).not.toContain(" cancel ");
220
262
  expect(server.core.sessionGet(session.id).status).toBe("pending");
221
263
  });
package/src/App.test.tsx CHANGED
@@ -8,6 +8,7 @@ import React from "react";
8
8
  import { DaemonServer } from "@cueloop/daemon";
9
9
  import type { Thread } from "@cueloop/schema";
10
10
  import { App } from "./App";
11
+ import { DARK } from "./theme";
11
12
  import {
12
13
  clickText,
13
14
  dragText,
@@ -33,6 +34,8 @@ The daemon persists sessions to disk atomically.
33
34
 
34
35
  - move the store
35
36
  - add recovery
37
+
38
+ Use the **safe** mode.
36
39
  `;
37
40
 
38
41
  let home: string;
@@ -67,6 +70,25 @@ async function renderApp(sessionId?: string) {
67
70
  });
68
71
  }
69
72
 
73
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
74
+
75
+ function foregroundsOf(setup: Setup, needle: string): string[] {
76
+ const foregrounds: string[] = [];
77
+
78
+ for (const line of setup.captureSpans().lines) {
79
+ for (const span of line.spans) {
80
+ if (!span.text.includes(needle)) continue;
81
+ const [red, green, blue] = span.fg.toInts();
82
+
83
+ foregrounds.push(
84
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
85
+ );
86
+ }
87
+ }
88
+
89
+ return foregrounds;
90
+ }
91
+
70
92
  describe("plan rendering", () => {
71
93
  test("renders the plan with headings, list markers, and the footer", async () => {
72
94
  // Arrange
@@ -168,6 +190,58 @@ describe("thread view grammar", () => {
168
190
  await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
169
191
  });
170
192
 
193
+ test("Cut removes only the marked characters instead of their whole block", async () => {
194
+ const setup = await renderApp();
195
+
196
+ await dragText(setup, "move the store", "move the store", "move".length);
197
+ await navCommand(setup, "x");
198
+ await waitForState(setup, () =>
199
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes("- the store"),
200
+ );
201
+ const working = server.core.sessionGet(session.id).workingCopy ?? "";
202
+ const textCuts = server.core.sessionGet(session.id).textCuts;
203
+
204
+ expect(working).not.toContain("move the store");
205
+ expect(working).toContain("- the store");
206
+ expect(working).toContain("- add recovery");
207
+ expect(textCuts).toHaveLength(1);
208
+ expect(textCuts?.[0]?.quote).toBe("move");
209
+ expect(foregroundsOf(setup, "the store")).not.toContain(DARK.green);
210
+ expect(setup.captureCharFrame()).not.toContain("selection cut");
211
+
212
+ await pressKey(setup, "x");
213
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
214
+
215
+ expect(server.core.sessionGet(session.id).textCuts).toBeUndefined();
216
+ });
217
+
218
+ test("a large partial Cut keeps its surviving prefix plain in one modified block", async () => {
219
+ const original = "A seeded plan so cueloop dev always has a thread to open.";
220
+ const shortened = server.core.sessionCreate({
221
+ workspace: { repoRoot: "/repo", branch: "main" },
222
+ artifact: { type: "plan", content: original, meta: { title: "Partial Cut" } },
223
+ });
224
+
225
+ server.core.sessionSetWorkingCopy(shortened.id, "A seed");
226
+ const setup = await renderApp(shortened.id);
227
+
228
+ await waitForText(setup, "A seed");
229
+ expect(foregroundsOf(setup, "seed")).toContain(DARK.text);
230
+ expect(foregroundsOf(setup, "seed")).not.toContain(DARK.green);
231
+ });
232
+
233
+ test("Cut maps rendered inline Markdown back to its exact source characters", async () => {
234
+ const setup = await renderApp();
235
+
236
+ await dragText(setup, "safe", "safe", "safe".length);
237
+ await navCommand(setup, "x");
238
+ await waitForState(setup, () =>
239
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes("Use the **** mode."),
240
+ );
241
+
242
+ expect(server.core.sessionGet(session.id).workingCopy).toContain("Use the **** mode.");
243
+ });
244
+
171
245
  test("ctrl+e opens the inline editor; the header toggles to normal and leaving tracks the edit", async () => {
172
246
  // Arrange
173
247
  const setup = await renderApp();
@@ -204,11 +278,11 @@ describe("submit", () => {
204
278
  await pressKey(setup, "RETURN", { meta: true });
205
279
 
206
280
  // Assert - the card opens on the default message, approve
207
- await waitForText(setup, "[Approve]");
281
+ await waitForText(setup, "[approve]");
208
282
 
209
283
  // Act - cycle to request changes, then send with a summary
210
284
  await press(setup, "right");
211
- await waitForText(setup, "[Changes]");
285
+ await waitForText(setup, "[changes]");
212
286
  await type(setup, "Expand the steps.");
213
287
  await pressKey(setup, "RETURN", { meta: true });
214
288
 
@@ -231,7 +305,7 @@ describe("submit", () => {
231
305
  await pressKey(setup, "RETURN", { meta: true });
232
306
 
233
307
  // Assert
234
- await waitForText(setup, "[Approve]"); // no pending items → approve default
308
+ await waitForText(setup, "[approve]"); // no pending items → approve default
235
309
 
236
310
  // Act
237
311
  await pressKey(setup, "RETURN", { meta: true });
package/src/App.tsx CHANGED
@@ -674,7 +674,6 @@ export function App({
674
674
  const name = github.name?.trim() || github.login;
675
675
 
676
676
  applyIdentity({ name, provider: "github" });
677
- controller.setStatus(`identity synced from GitHub - ${name}`);
678
677
  });
679
678
  };
680
679
 
@@ -859,10 +858,6 @@ export function App({
859
858
 
860
859
  setDiffView(next);
861
860
  persistDiffView(next);
862
- // every toggle names the new mode; picking split on a narrow pane also says it needs the wide layout
863
- if (next === "stacked") controller.setStatus("stacked diff");
864
- else if (workbench.zoomed) controller.setStatus("split diff");
865
- else controller.setStatus("split diff shows when zoomed");
866
861
  },
867
862
  openShareDialog: () => {
868
863
  if (!isOwner) return controller.setStatus("only the plan owner can share");
@@ -896,7 +891,13 @@ export function App({
896
891
  }, [session, navigablePanes, focusedPane]);
897
892
  const cyclePanes = (backward: boolean): void =>
898
893
  setFocusedPane((current) => nextFocusPane(current, navigablePanes, backward));
899
- const runNavCommand = (key: { name: string; shift?: boolean }): boolean => {
894
+ const runNavCommand = (
895
+ key: { name: string; shift?: boolean },
896
+ selection: {
897
+ start: { blockIndex: number; char: number };
898
+ end: { blockIndex: number; char: number };
899
+ } | null = null,
900
+ ): boolean => {
900
901
  const intent = resolveNavKey(key, {
901
902
  isOwner,
902
903
  resolved,
@@ -905,7 +906,14 @@ export function App({
905
906
  });
906
907
 
907
908
  if (!intent) return false;
908
- dispatch(intent);
909
+ if (intent.type === "cut" && selection) {
910
+ controller.cut(
911
+ selection.start.blockIndex,
912
+ selection.start.char,
913
+ selection.end.char,
914
+ selection.end.blockIndex,
915
+ );
916
+ } else dispatch(intent);
909
917
 
910
918
  return true;
911
919
  };
@@ -253,7 +253,7 @@ describe("the guided walk", () => {
253
253
  await waitForTextGone(setup, "agent note");
254
254
  await pressKey(setup, "RETURN", { meta: true });
255
255
  // nothing pending despite the note, so the confirm card defaults to approve
256
- await waitForText(setup, "[Approve]");
256
+ await waitForText(setup, "[approve]");
257
257
  await pressKey(setup, "RETURN", { meta: true });
258
258
 
259
259
  // Assert
@@ -7,7 +7,7 @@
7
7
 
8
8
  import React, { useContext } from "react";
9
9
  import { useTerminalDimensions } from "@opentui/react";
10
- import type { MessageOutcome } from "@cueloop/schema";
10
+ import { MESSAGE_OUTCOMES, type MessageOutcome } from "@cueloop/schema";
11
11
  import type { Theme } from "../theme";
12
12
  import type { QuickAction } from "../config";
13
13
  import { SlashSkillsContext } from "../skills";
@@ -18,15 +18,14 @@ import { composeRowCount } from "./AnnotationCards";
18
18
  import { SlashComposer } from "./SlashComposer";
19
19
  import { DialogActions } from "./primitives/DialogActions";
20
20
 
21
- const SUBMIT_CARD_MAX_WIDTH = 48;
21
+ const SUBMIT_CARD_MAX_WIDTH = 52;
22
22
  const PALETTE_WINDOW = 5;
23
23
 
24
- export const MESSAGE_OUTCOMES: MessageOutcome[] = ["approved", "changes_requested"];
25
-
26
24
  /** Selector words in the confirm card - one word per message. */
27
25
  export const MESSAGE_OUTCOME_LABEL: Record<MessageOutcome, string> = {
28
- approved: "Approve",
29
- changes_requested: "Changes",
26
+ comment: "comment",
27
+ approved: "approve",
28
+ changes_requested: "changes",
30
29
  };
31
30
 
32
31
  export interface ConfirmCardProps {
@@ -57,10 +56,10 @@ export function outcomeColor(message: MessageOutcome, tokens: Theme): string {
57
56
  }
58
57
 
59
58
  /**
60
- * The message selector: one row of pressable words, matching the reading
61
- * direction of a choice between two outcomes. Selection stays controlled by
62
- * the grammar (←/→ cycle the message); a click selects directly. The
63
- * selected message wears brackets and its color.
59
+ * The message selector: one row of pressable words in reading order.
60
+ * Selection stays controlled by the grammar (←/→ cycle the message); a click
61
+ * selects directly. Every message keeps its brackets so active color never changes the row's
62
+ * width or moves a neighboring choice.
64
63
  */
65
64
  function MessageSelector({
66
65
  message,
@@ -82,9 +81,7 @@ function MessageSelector({
82
81
  onMouseUp={() => onSelectMessage(candidate)}
83
82
  >
84
83
  <text fg={candidate === message ? outcomeColor(candidate, tokens) : tokens.textDim}>
85
- {candidate === message
86
- ? `[${MESSAGE_OUTCOME_LABEL[candidate]}]`
87
- : ` ${MESSAGE_OUTCOME_LABEL[candidate]} `}
84
+ {`[${MESSAGE_OUTCOME_LABEL[candidate]}]`}
88
85
  </text>
89
86
  </box>
90
87
  ))}
@@ -106,7 +103,7 @@ export function ConfirmCard({
106
103
  const tokens = useComponentTheme(theme);
107
104
  const skills = useContext(SlashSkillsContext);
108
105
  const { width: terminalWidth } = useTerminalDimensions();
109
- const cardWidth = Math.max(24, Math.min(terminalWidth - 6, SUBMIT_CARD_MAX_WIDTH));
106
+ const cardWidth = Math.max(24, Math.min(terminalWidth - 2, SUBMIT_CARD_MAX_WIDTH));
110
107
 
111
108
  const composerRows = composeRowCount(summary, cardWidth - 4);
112
109
  const token = activeSlashToken(summary, summary.length);
@@ -24,7 +24,7 @@ import { splitDiffRows, type SplitLine, type SplitRow } from "../split-diff";
24
24
  import { UNDERLINE, type AnnotationPalette } from "../annotation-palette";
25
25
  import { lineMarkRanges, runsFor, wrapLines, type MarkRange } from "../mark-runs";
26
26
  import { useFrameMeasure } from "../use-frame-measure";
27
- import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
27
+ import { scrollBoxDragViewport, useTerminalVirtualizer } from "../use-terminal-virtualizer";
28
28
  import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
29
29
  import { NavModeHint } from "./NavModeHint";
30
30
  import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
@@ -98,7 +98,7 @@ export interface DiffContentViewProps {
98
98
  onUpdateAnnotation: (id: string, body: string) => void;
99
99
  /** The author's display name for a comment's hover tooltip. */
100
100
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
101
- onNavCommand?: (key: KeyEvent) => boolean;
101
+ onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
102
102
  onExit: () => void;
103
103
  /** Row indices the owner rejected during curation; drawn struck through. */
104
104
  rejectedRows?: Set<number>;
@@ -527,6 +527,7 @@ export function DiffContentView({
527
527
  onAnnotate,
528
528
  onReply,
529
529
  onUpdateAnnotation,
530
+ dragViewport: () => scrollBoxDragViewport(scrollRef.current),
530
531
  resolveAuthorLabel,
531
532
  onNavCommand,
532
533
  onExit,
@@ -5,7 +5,7 @@ import { test, expect } from "bun:test";
5
5
  import React from "react";
6
6
  import { testRender } from "@opentui/react/test-utils";
7
7
  import { ProjectTreeView } from "./ProjectTreeView";
8
- import { settle, waitForText } from "../test-support";
8
+ import { locateText, settle, waitForText } from "../test-support";
9
9
  import { DARK } from "../theme";
10
10
 
11
11
  test("tab expands a folder, then j moves down and tab opens the file", async () => {
@@ -57,3 +57,19 @@ test("without focus the keyboard does nothing", async () => {
57
57
 
58
58
  setup.renderer.destroy();
59
59
  });
60
+
61
+ test("the empty project hint stays centered in the panel", async () => {
62
+ const setup = await testRender(
63
+ <ProjectTreeView loadFiles={async () => []} onSelectFile={() => {}} theme={DARK} />,
64
+ { width: 40, height: 12 },
65
+ );
66
+
67
+ await waitForText(setup, "empty");
68
+ const position = locateText(setup, "empty");
69
+
70
+ expect(position.column).toBeGreaterThanOrEqual(17);
71
+ expect(position.column).toBeLessThanOrEqual(18);
72
+ expect(position.row).toBeGreaterThanOrEqual(5);
73
+ expect(position.row).toBeLessThanOrEqual(6);
74
+ setup.renderer.destroy();
75
+ });
@@ -78,14 +78,28 @@ export function ProjectTreeView({
78
78
 
79
79
  if (paths === null) {
80
80
  return (
81
- <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
81
+ <box
82
+ style={{
83
+ flexGrow: 1,
84
+ flexDirection: "column",
85
+ alignItems: "center",
86
+ justifyContent: "center",
87
+ }}
88
+ >
82
89
  <text fg={tokens.textDim}>loading...</text>
83
90
  </box>
84
91
  );
85
92
  }
86
93
  if (paths.length === 0) {
87
94
  return (
88
- <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
95
+ <box
96
+ style={{
97
+ flexGrow: 1,
98
+ flexDirection: "column",
99
+ alignItems: "center",
100
+ justifyContent: "center",
101
+ }}
102
+ >
89
103
  <text fg={tokens.textDim}>empty</text>
90
104
  </box>
91
105
  );
@@ -58,7 +58,7 @@ function session(annotations: Annotation[] = []) {
58
58
  });
59
59
  }
60
60
 
61
- async function mount(annotations: Annotation[] = []): Promise<void> {
61
+ async function mount(annotations: Annotation[] = [], height = 24): Promise<void> {
62
62
  annotate = mock<AnnotateHandler>(() => {});
63
63
  reply = mock<ReplyHandler>(() => {});
64
64
  const display = buildDisplay(PLAN, undefined);
@@ -75,7 +75,7 @@ async function mount(annotations: Annotation[] = []): Promise<void> {
75
75
  onUpdateAnnotation={() => {}}
76
76
  onExit={() => {}}
77
77
  />,
78
- { width: 64, height: 24 },
78
+ { width: 64, height },
79
79
  );
80
80
  await settle(setup);
81
81
  await settle(setup);
@@ -391,6 +391,20 @@ describe("marking across rows", () => {
391
391
  expect(rows.get(origin.row + 1)).toBe("session, t");
392
392
  });
393
393
 
394
+ test("shift+down extends a mark to the same column on the next visual row", async () => {
395
+ const origin = stanzaOrigin();
396
+ const column = STANZA_ROWS[0]!.indexOf("written");
397
+
398
+ await setup.mockMouse.click(origin.column + column, origin.row);
399
+ await settle(setup);
400
+ setup.mockInput.pressKey("ARROW_DOWN", { shift: true });
401
+ await settle(setup);
402
+
403
+ expect(highlightedAcrossRows()).toBe(
404
+ STANZA.slice(stanzaOffset(0, column), stanzaOffset(1, column)).replace("\n", " "),
405
+ );
406
+ });
407
+
394
408
  test("a backward drag up two rows normalizes to the same characters", async () => {
395
409
  // Arrange: press inside "crash" on row three
396
410
  const origin = stanzaOrigin();
@@ -463,6 +477,21 @@ describe("marking across rows", () => {
463
477
  // Assert
464
478
  expect(highlightedByRow().size).toBe(0);
465
479
  });
480
+
481
+ test("holding a mark at the viewport edge scrolls and extends it", async () => {
482
+ setup.renderer.destroy();
483
+ await mount([], 10);
484
+ const origin = locate("daemon");
485
+
486
+ await setup.mockMouse.pressDown(origin.column, origin.row);
487
+ await setup.mockMouse.moveTo(63, 9);
488
+ await new Promise((resolve) => setTimeout(resolve, 350));
489
+ await settle(setup);
490
+ await setup.mockMouse.release(63, 9);
491
+
492
+ expect(setup.captureCharFrame()).not.toContain("# Plan");
493
+ expect(highlightedText().length).toBeGreaterThan("daemon".length);
494
+ });
466
495
  });
467
496
 
468
497
  describe("marking across blocks", () => {
@@ -35,7 +35,7 @@ import { BOLD, CUT, ITALIC, UNDERLINE } from "../annotation-palette";
35
35
  import { wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
36
36
  import { MarkdownGridBlock } from "./MarkdownGridBlock";
37
37
  import { useFrameMeasure } from "../use-frame-measure";
38
- import { useTerminalVirtualizer } from "../use-terminal-virtualizer";
38
+ import { scrollBoxDragViewport, useTerminalVirtualizer } from "../use-terminal-virtualizer";
39
39
  import { useAnnotationSurface, type LineSource } from "../use-annotation-surface";
40
40
  import { NavModeHint } from "./NavModeHint";
41
41
  import { DiscussionMarkerRail } from "./DiscussionMarkerRail";
@@ -158,7 +158,7 @@ export interface ThreadViewProps {
158
158
  onUpdateAnnotation: (id: string, body: string) => void;
159
159
  /** The author's display name for a comment's hover tooltip. */
160
160
  resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
161
- onNavCommand?: (key: KeyEvent) => boolean;
161
+ onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
162
162
  onExit: () => void;
163
163
  theme?: Theme;
164
164
  }
@@ -192,6 +192,7 @@ export function ThreadView({
192
192
  textAt: (blockIndex) => renderedText(display[blockIndex]!),
193
193
  annotatable: () => true,
194
194
  };
195
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
195
196
  const surface = useAnnotationSurface({
196
197
  source,
197
198
  session,
@@ -209,12 +210,12 @@ export function ThreadView({
209
210
  onAnnotate,
210
211
  onReply,
211
212
  onUpdateAnnotation,
213
+ dragViewport: () => scrollBoxDragViewport(scrollRef.current),
212
214
  resolveAuthorLabel,
213
215
  onNavCommand,
214
216
  onExit,
215
217
  });
216
218
  const { palette, discussions } = surface;
217
- const scrollRef = useRef<ScrollBoxRenderable | null>(null);
218
219
  const { height: terminalHeight } = useTerminalDimensions();
219
220
  const viewWidth = useFrameMeasure(
220
221
  () => scrollRef.current?.content?.width ?? 0,