mixdog 0.9.22 → 0.9.23

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 (91) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +165 -0
  5. package/scripts/channel-daemon-stub.mjs +69 -0
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  9. package/scripts/tool-efficiency-diag.mjs +1 -1
  10. package/scripts/tool-smoke.mjs +30 -17
  11. package/src/rules/lead/02-channels.md +3 -3
  12. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  20. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  21. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  28. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  29. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  30. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  31. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  32. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  33. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  34. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  35. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  36. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  37. package/src/runtime/memory/index.mjs +54 -7
  38. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  39. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  40. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  41. package/src/runtime/shared/atomic-file.mjs +20 -4
  42. package/src/runtime/shared/tool-primitives.mjs +31 -1
  43. package/src/runtime/shared/tool-surface.mjs +23 -13
  44. package/src/session-runtime/config-lifecycle.mjs +48 -7
  45. package/src/session-runtime/lifecycle-api.mjs +9 -0
  46. package/src/session-runtime/mcp-glue.mjs +63 -1
  47. package/src/session-runtime/resource-api.mjs +62 -8
  48. package/src/session-runtime/runtime-core.mjs +32 -2
  49. package/src/session-runtime/session-text.mjs +41 -0
  50. package/src/session-runtime/session-turn-api.mjs +24 -0
  51. package/src/session-runtime/settings-api.mjs +8 -1
  52. package/src/session-runtime/tool-catalog.mjs +306 -38
  53. package/src/session-runtime/tool-defs.mjs +7 -7
  54. package/src/session-runtime/workflow.mjs +2 -1
  55. package/src/standalone/channel-daemon-client.mjs +224 -0
  56. package/src/standalone/channel-daemon-transport.mjs +351 -0
  57. package/src/standalone/channel-daemon.mjs +139 -0
  58. package/src/standalone/channel-worker.mjs +213 -4
  59. package/src/standalone/hook-bus.mjs +71 -3
  60. package/src/tui/App.jsx +105 -17
  61. package/src/tui/app/clipboard.mjs +39 -19
  62. package/src/tui/app/doctor.mjs +57 -0
  63. package/src/tui/app/extension-pickers.mjs +53 -9
  64. package/src/tui/app/maintenance-pickers.mjs +0 -5
  65. package/src/tui/app/slash-dispatch.mjs +4 -4
  66. package/src/tui/app/text-layout.mjs +11 -0
  67. package/src/tui/app/use-mouse-input.mjs +235 -51
  68. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  69. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  70. package/src/tui/app/use-transcript-window.mjs +55 -1
  71. package/src/tui/components/Message.jsx +1 -1
  72. package/src/tui/components/PromptInput.jsx +3 -1
  73. package/src/tui/components/QueuedCommands.jsx +21 -10
  74. package/src/tui/components/ToolExecution.jsx +2 -2
  75. package/src/tui/dist/index.mjs +635 -306
  76. package/src/tui/engine/session-api.mjs +17 -7
  77. package/src/tui/engine/session-flow.mjs +6 -0
  78. package/src/tui/engine.mjs +8 -0
  79. package/src/tui/index.jsx +62 -18
  80. package/src/tui/paste-attachments.mjs +26 -0
  81. package/src/ui/statusline.mjs +45 -5
  82. package/src/ui/tool-card.mjs +8 -1
  83. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  84. package/src/workflows/bench/WORKFLOW.md +46 -0
  85. package/src/workflows/default/WORKFLOW.md +6 -0
  86. package/src/workflows/solo/WORKFLOW.md +5 -0
  87. package/vendor/ink/build/ink.js +23 -1
  88. package/vendor/ink/build/output.js +154 -71
  89. package/vendor/ink/build/render-node-to-output.js +44 -2
  90. package/vendor/ink/build/render.js +4 -0
  91. package/vendor/ink/build/renderer.js +4 -1
@@ -28,6 +28,14 @@ const MOUSE_CTRL_MASK = 16;
28
28
  // every modifier bit) so a shift-held left-click can extend an existing
29
29
  // selection instead of starting a fresh one.
30
30
  const MOUSE_SHIFT_MASK = 4;
31
+ // Windows Terminal (1.25+) forwards shift+click/drag to the app during VT
32
+ // mouse mode while ALSO painting its own native selection — honoring the
33
+ // events would draw two overlapping highlights (app blue + WT white). In WT,
34
+ // drop every shift-modified button event so shift stays purely native
35
+ // (native highlight + native Ctrl+C copy); plain drag / ctrl+click /
36
+ // right-click remain the app-owned selection paths. Other terminals keep
37
+ // the shift-extend behavior (with XTSHIFTESCAPE opted in from index.jsx).
38
+ const IS_WINDOWS_TERMINAL = Boolean(process.env.WT_SESSION);
31
39
 
32
40
  export function useMouseInput({
33
41
  inkInput,
@@ -56,7 +64,10 @@ export function useMouseInput({
56
64
  setMeasuredRowsVersion,
57
65
  clearStitchBuffer,
58
66
  }) {
59
- const mouseZoomPassthroughTimerRef = useRef(null);
67
+ const zoomTimerRef = useRef(null);
68
+ // Edge auto-scroll timer state (see the effect below). Owned at hook scope so
69
+ // it survives re-subscribes; the drag itself lives on the App-owned dragRef.
70
+ const edgeAutoscrollRef = useRef({ dir: 0, timer: null, noMove: 0 });
60
71
 
61
72
  const passthroughCtrlWheelZoom = useCallback(() => {
62
73
  if (!stdout?.write) return;
@@ -65,21 +76,21 @@ export function useMouseInput({
65
76
  } catch {
66
77
  return;
67
78
  }
68
- if (mouseZoomPassthroughTimerRef.current) clearTimeout(mouseZoomPassthroughTimerRef.current);
69
- mouseZoomPassthroughTimerRef.current = setTimeout(() => {
70
- mouseZoomPassthroughTimerRef.current = null;
79
+ if (zoomTimerRef.current) clearTimeout(zoomTimerRef.current);
80
+ zoomTimerRef.current = setTimeout(() => {
81
+ zoomTimerRef.current = null;
71
82
  try {
72
83
  stdout.write(ALT_SCROLL_ON + MOUSE_TRACKING_ON);
73
84
  } catch {
74
85
  // The terminal may already be closing.
75
86
  }
76
87
  }, 700);
77
- mouseZoomPassthroughTimerRef.current.unref?.();
78
- }, [stdout]);
88
+ zoomTimerRef.current.unref?.();
89
+ }, [stdout, zoomTimerRef]);
79
90
 
80
91
  useEffect(() => () => {
81
- if (mouseZoomPassthroughTimerRef.current) clearTimeout(mouseZoomPassthroughTimerRef.current);
82
- }, []);
92
+ if (zoomTimerRef.current) clearTimeout(zoomTimerRef.current);
93
+ }, [zoomTimerRef]);
83
94
 
84
95
  // Optional mouse handling. When index.jsx enables SGR mouse tracking
85
96
  // (?1000h button + ?1002h drag-motion + ?1006h SGR coords).
@@ -175,6 +186,83 @@ export function useMouseInput({
175
186
  promptMouseSelectionRef.current?.clear?.();
176
187
  applySelectionRect(null);
177
188
  };
189
+ // Edge auto-scroll TIMER (ref: ScrollKeybindingHandler useDragToScroll).
190
+ // SGR mode 1002 reports drag-motion only when the pointer changes CELL, so
191
+ // a pointer held stationary at the top/bottom edge stops emitting events
192
+ // and the motion-driven scroll below stalls. This interval keeps scrolling
193
+ // — scrollTranscriptRows' active-drag branch re-extends the selection to
194
+ // the still-held `last` cell each step — until the pointer leaves the edge,
195
+ // the drag ends, or a scroll boundary is reached (delta clamps to 0).
196
+ const EDGE_AUTOSCROLL_INTERVAL_MS = 50;
197
+ const stopEdgeAutoscroll = () => {
198
+ const st = edgeAutoscrollRef.current;
199
+ if (st.timer) { clearInterval(st.timer); st.timer = null; }
200
+ st.dir = 0;
201
+ st.noMove = 0;
202
+ };
203
+ const startEdgeAutoscroll = (dir) => {
204
+ const st = edgeAutoscrollRef.current;
205
+ if (st.dir === dir && st.timer) return; // already scrolling this way
206
+ stopEdgeAutoscroll();
207
+ st.dir = dir;
208
+ st.noMove = 0;
209
+ st.timer = setInterval(() => {
210
+ const drag = dragRef.current;
211
+ const st2 = edgeAutoscrollRef.current;
212
+ if (!drag.active || drag.region !== 'transcript' || st2.dir === 0) {
213
+ stopEdgeAutoscroll();
214
+ return;
215
+ }
216
+ const before = Number(scrollTargetRef.current) || 0;
217
+ queueScrollCoalesced(st2.dir * 3);
218
+ // A SINGLE unchanged tick is ambiguous: the coalescer may not have
219
+ // flushed this delta yet (queued behind its 16ms leading-edge timer),
220
+ // so scrollTarget legitimately reads the same value mid-flight. Only a
221
+ // real top/bottom clamp keeps it pinned across several ticks. Require
222
+ // CONSECUTIVE no-move ticks (>=3 ⇒ >150ms ≫ the 16ms coalescer window,
223
+ // so any pending scroll has certainly flushed) before stopping — any
224
+ // movement resets the counter (ref useDragToScroll's getScrollTop<=0 /
225
+ // >=max boundary stop).
226
+ if ((Number(scrollTargetRef.current) || 0) !== before) {
227
+ st2.noMove = 0;
228
+ } else if (++st2.noMove >= 3) {
229
+ stopEdgeAutoscroll();
230
+ }
231
+ }, EDGE_AUTOSCROLL_INTERVAL_MS);
232
+ st.timer.unref?.();
233
+ };
234
+ // Finalize an in-flight drag exactly like the button-release path: push the
235
+ // final rect from the given point, and reconcile measured row heights. Used
236
+ // by the real release below AND by the ctrl+wheel zoom passthrough, whose
237
+ // mouse-tracking disable can swallow the release event entirely.
238
+ const finalizeActiveDrag = (fx, fy) => {
239
+ const drag = dragRef.current;
240
+ if (!drag.active) return;
241
+ stopEdgeAutoscroll();
242
+ const region = drag.region;
243
+ if (region === 'prompt') {
244
+ const offset = promptOffsetAt(fx, fy);
245
+ drag.active = false;
246
+ promptMouseSelectionRef.current?.extendTo?.(offset, true);
247
+ return;
248
+ }
249
+ const span = drag.anchorSpan;
250
+ const finalY = region === 'status' ? clampToStatusBand(fy) : clampToTranscriptViewport(fy);
251
+ drag.active = false;
252
+ if (span) {
253
+ const rect = buildSpanRect(span, fx, finalY, region, drag.anchorScroll);
254
+ applySelectionRect(rect);
255
+ } else {
256
+ const anchor = region === 'status'
257
+ ? drag.anchor
258
+ : selectionPointAtCurrentScroll(drag.anchor, drag.anchorScroll);
259
+ const rect = linearSelection(anchor, { x: fx, y: finalY });
260
+ const empty = rect.x1 === rect.x2 && rect.y1 === rect.y2;
261
+ if (empty) applySelectionRect(null);
262
+ else applySelectionRect(rect);
263
+ }
264
+ if (TRANSCRIPT_MEASURED_ROWS) setMeasuredRowsVersion((v) => (v + 1) % 1000000);
265
+ };
178
266
  // Typed 'mouse' channel handler. Receives one event per emit:
179
267
  // • ParsedMouse {kind:'mouse',button,action,col,row,sequence} — click/drag
180
268
  // • ParsedKey {kind:'key',name:'wheelup'|'wheeldown',sequence} — wheel
@@ -193,6 +281,19 @@ export function useMouseInput({
193
281
  const wm = WHEEL_SGR.exec(seq);
194
282
  const ctrl = wm ? ((Number(wm[1]) & MOUSE_CTRL_MASK) !== 0) : false;
195
283
  if (ctrl) {
284
+ // Zoom passthrough disables mouse tracking for ~700ms, during which
285
+ // the button-release event may never arrive — leaving the drag stuck
286
+ // active and the edge-autoscroll interval firing forever. Finalize
287
+ // the in-flight drag from its last known point (same path as a real
288
+ // release: final applySelectionRect + measured-rows reconcile) so
289
+ // Ctrl+C still copies the full text, then hand the wheel to the
290
+ // terminal's font zoom. stopEdgeAutoscroll is folded into finalize.
291
+ if (dragRef.current.active) {
292
+ const last = dragRef.current.last || {};
293
+ finalizeActiveDrag(Number(last.x) || 0, Number(last.y) || 0);
294
+ } else {
295
+ stopEdgeAutoscroll();
296
+ }
196
297
  passthroughCtrlWheelZoom();
197
298
  return;
198
299
  }
@@ -201,9 +302,8 @@ export function useMouseInput({
201
302
  // Fall through to the wheel-scroll dispatch below (shared with the
202
303
  // previous SGR path) so slash-palette/overlay/scroll routing is identical.
203
304
  if (up !== 0 || down !== 0) {
204
- if (dragRef.current.active) return;
205
305
  const palette = slashPaletteRef.current;
206
- if (palette.open && palette.count > 0) {
306
+ if (!dragRef.current.active && palette.open && palette.count > 0) {
207
307
  const step = down - up;
208
308
  if (step !== 0) {
209
309
  setSlashIndex((index) => Math.max(0, Math.min(palette.count - 1, index + step)));
@@ -212,6 +312,11 @@ export function useMouseInput({
212
312
  }
213
313
  if (overlayBlocksGlobalTranscriptScroll(scrollFocusRef.current)) return;
214
314
  const STEP = 3; // rows per wheel notch; immediate updates feel steadier in Windows Terminal
315
+ // Wheel while a selection is live (mid-drag OR after release) scrolls
316
+ // the transcript instead of being dropped: scrollTranscriptRows'
317
+ // active-drag branch rebuilds the rect (anchor→last), the released
318
+ // branch shifts it — both keep the highlight and stitch-harvest the
319
+ // rows that scroll off (ref ScrollKeybindingHandler wheel path).
215
320
  queueScrollCoalesced((up - down) * STEP);
216
321
  }
217
322
  return;
@@ -229,7 +334,86 @@ export function useMouseInput({
229
334
  // (button & 3); MOUSE_MODIFIER_MASK above is scroll-routing-only and
230
335
  // deliberately treats shift as noise there.
231
336
  const shiftHeld = (button & MOUSE_SHIFT_MASK) !== 0;
337
+ const ctrlHeld = (button & MOUSE_CTRL_MASK) !== 0;
338
+ // WT reserves shift+mouse for its NATIVE selection and (per its docs)
339
+ // never forwards those events; keep this guard as a belt-and-braces so
340
+ // a future WT that starts forwarding them can never double-paint.
341
+ if (IS_WINDOWS_TERMINAL && shiftHeld) return;
342
+ // WT's native (shift+drag) selection overlay is NOT cleared by our
343
+ // incremental repaints — it would sit on top of the app selection as a
344
+ // second highlight. Any app-owned button press means the user is done
345
+ // with that native selection: force one full clear+rewrite frame
346
+ // (BSU/ESU-wrapped, no flash) which dismisses it before we paint ours.
347
+ if (IS_WINDOWS_TERMINAL && press && !isMotion) {
348
+ store.forceRenderRepaint?.();
349
+ }
350
+ // Ctrl+left-click is the app-side extend trigger (baseButton =
351
+ // button & 3 already maps ctrl+left press to button 0); right-button
352
+ // press is the second trigger (its own block below). This is
353
+ // unrelated to the ctrl+WHEEL zoom passthrough, which is handled in
354
+ // the wheel path and never reaches this button-press code.
355
+ const extendHeld = shiftHeld || ctrlHeld;
356
+ const isRightPress = baseButton === 2 && press && !isMotion;
357
+ if (isRightPress) {
358
+ // Right-button press = extend-click. Extends the existing selection in
359
+ // the pressed region using the SAME logic as the shift/ctrl paths, but
360
+ // ONLY when there is something extendable there; with nothing to extend
361
+ // it is ignored entirely (no selection clear, no drag start).
362
+ if (isInPromptBox(x, y)) {
363
+ const ctl = promptMouseSelectionRef.current;
364
+ if (ctl?.hasSelection?.()) {
365
+ applySelectionRect(null);
366
+ const offset = promptOffsetAt(x, y);
367
+ stopSmoothScroll();
368
+ // Right-click extend is a one-shot: apply immediately and leave the
369
+ // drag INACTIVE so the right-button release can't finalize (the
370
+ // generic !press branch is left-button-only anyway).
371
+ dragRef.current = { anchor: { x, y }, anchorScroll: 0, last: { x, y }, active: false, rect: null, region: 'prompt', anchorSpan: null };
372
+ if (offset != null) ctl.extendTo?.(offset, true);
373
+ lastClickRef.current = { x: -1, y: -1, t: 0 };
374
+ }
375
+ return;
376
+ }
377
+ const inTranscriptR = isInTranscriptViewport(y);
378
+ const inStatusR = !inTranscriptR && isInStatusBand(y);
379
+ if (!inTranscriptR && !inStatusR) return;
380
+ const regionR = inTranscriptR ? 'transcript' : 'status';
381
+ const nowR = Date.now();
382
+ if (dragRef.current.region === regionR && dragRef.current.anchorSpan) {
383
+ promptMouseSelectionRef.current?.clear?.();
384
+ const selectionY = regionR === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
385
+ const span = dragRef.current.anchorSpan;
386
+ const rect = buildSpanRect(span, x, selectionY, regionR, dragRef.current.anchorScroll);
387
+ stopSmoothScroll();
388
+ dragRef.current = { ...dragRef.current, last: { x, y: selectionY }, active: false, region: regionR };
389
+ applySelectionRect(rect);
390
+ lastClickRef.current = { x, y, t: nowR, count: 1 };
391
+ return;
392
+ }
393
+ if (
394
+ dragRef.current.region === regionR
395
+ && !dragRef.current.anchorSpan
396
+ && dragRef.current.anchor
397
+ && dragRef.current.rect
398
+ && !(dragRef.current.rect.x1 === dragRef.current.rect.x2 && dragRef.current.rect.y1 === dragRef.current.rect.y2)
399
+ ) {
400
+ promptMouseSelectionRef.current?.clear?.();
401
+ const selectionY = regionR === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
402
+ const anchor = regionR === 'status'
403
+ ? dragRef.current.anchor
404
+ : selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
405
+ const rect = linearSelection(anchor, { x, y: selectionY });
406
+ stopSmoothScroll();
407
+ dragRef.current = { ...dragRef.current, last: { x, y: selectionY }, active: false, region: regionR };
408
+ applySelectionRect(rect);
409
+ lastClickRef.current = { x, y, t: nowR, count: 1 };
410
+ return;
411
+ }
412
+ return;
413
+ }
232
414
  if (baseButton === 0 && press && !isMotion) {
415
+ // A fresh press ends any prior drag's held-at-edge auto-scroll.
416
+ stopEdgeAutoscroll();
233
417
  // Region router: a press decides which surface owns this selection.
234
418
  // Prompt box takes priority (it overlaps no transcript rows), then the
235
419
  // transcript viewport, then the bottom statusline band. A press
@@ -244,7 +428,7 @@ export function useMouseInput({
244
428
  // Shift+click extends the EXISTING prompt selection (anchor stays
245
429
  // put, cursor jumps to the click) instead of starting a fresh
246
430
  // zero-width anchor at the click point.
247
- if (shiftHeld && ctl?.hasSelection?.()) {
431
+ if (extendHeld && ctl?.hasSelection?.()) {
248
432
  if (offset != null) ctl.extendTo?.(offset, true);
249
433
  lastClickRef.current = { x: -1, y: -1, t: 0 };
250
434
  return;
@@ -295,6 +479,28 @@ export function useMouseInput({
295
479
  // A press always clears the prompt-box selection (single active region).
296
480
  promptMouseSelectionRef.current?.clear?.();
297
481
  const now = Date.now();
482
+ // Shift+click on an existing word/line (anchorSpan) selection extends
483
+ // that selection by whole words/lines to the click point, preserving
484
+ // the original anchor span (mirrors selection.ts extendSelection).
485
+ if (
486
+ extendHeld
487
+ && dragRef.current.region === region
488
+ && dragRef.current.anchorSpan
489
+ ) {
490
+ const selectionY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
491
+ const span = dragRef.current.anchorSpan;
492
+ const rect = buildSpanRect(span, x, selectionY, region, dragRef.current.anchorScroll);
493
+ stopSmoothScroll();
494
+ dragRef.current = {
495
+ ...dragRef.current,
496
+ last: { x, y: selectionY },
497
+ active: true,
498
+ region,
499
+ };
500
+ applySelectionRect(rect);
501
+ lastClickRef.current = { x, y, t: now, count: 1 };
502
+ return;
503
+ }
298
504
  // Shift+click extends the existing ink-grid selection in this SAME
299
505
  // region from its original anchor to the new click point, instead of
300
506
  // starting a fresh anchor here. Only applies to a plain char-drag
@@ -302,7 +508,7 @@ export function useMouseInput({
302
508
  // anchorSpan or an empty/absent selection falls through to a normal
303
509
  // fresh press below.
304
510
  if (
305
- shiftHeld
511
+ extendHeld
306
512
  && dragRef.current.region === region
307
513
  && !dragRef.current.anchorSpan
308
514
  && dragRef.current.anchor
@@ -432,55 +638,33 @@ export function useMouseInput({
432
638
  // scroll away the very rows being selected.
433
639
  if (y <= 1 && (y < prevDragY || y < top)) {
434
640
  queueScrollCoalesced(3);
641
+ startEdgeAutoscroll(1);
435
642
  } else if (y >= frameRows - 5 && (y > prevDragY || y > bottom)) {
436
643
  queueScrollCoalesced(-3);
644
+ startEdgeAutoscroll(-1);
645
+ } else {
646
+ // Pointer moved back inside the viewport (or along an edge row
647
+ // without pushing toward it): halt any held-at-edge auto-scroll.
648
+ stopEdgeAutoscroll();
437
649
  }
438
650
  }
439
- } else if (!press && dragRef.current.active) {
440
- const region = dragRef.current.region;
441
- if (region === 'prompt') {
442
- // Finalize the prompt selection; highlight persists (copy on Ctrl+C).
443
- const offset = promptOffsetAt(x, y);
444
- dragRef.current.active = false;
445
- promptMouseSelectionRef.current?.extendTo?.(offset, true);
446
- return;
447
- }
651
+ } else if (!press && baseButton === 0 && dragRef.current.active) {
448
652
  // Button release while dragging: finalize with the release coordinate
449
653
  // (the SGR release event carries col/row) and keep the selection
450
654
  // visible. Copy is NOT automatic — the user presses Ctrl+C to copy.
451
- // The highlight stays until ESC or a plain click.
452
- const span = dragRef.current.anchorSpan;
453
- const releaseY = region === 'status' ? clampToStatusBand(y) : clampToTranscriptViewport(y);
454
- dragRef.current.active = false;
455
- if (span) {
456
- // Word/line multi-click release: finalize from the span to the
457
- // word/line at the release cell. A release-without-motion resolves
458
- // to the span itself (in-span target), so the original word/line
459
- // highlight stays — never cleared as "empty" like a bare click.
460
- const rect = buildSpanRect(span, x, releaseY, region, dragRef.current.anchorScroll);
461
- applySelectionRect(rect);
462
- } else {
463
- const anchor = region === 'status'
464
- ? dragRef.current.anchor
465
- : selectionPointAtCurrentScroll(dragRef.current.anchor, dragRef.current.anchorScroll);
466
- const rect = linearSelection(anchor, { x, y: releaseY });
467
- const empty = rect.x1 === rect.x2 && rect.y1 === rect.y2;
468
- if (empty) {
469
- applySelectionRect(null); // a plain click clears any prior highlight
470
- } else {
471
- // Push the final rect so ink re-renders the visible selection.
472
- applySelectionRect(rect);
473
- }
474
- }
475
- // Drag is over: the measured-height harvest was skipped for every
476
- // motion commit (see the harvest effect's dragRef guard), so force one
477
- // re-measure now to reconcile any row whose true height drifted from
478
- // its estimate while the drag was in flight.
479
- if (TRANSCRIPT_MEASURED_ROWS) setMeasuredRowsVersion((v) => (v + 1) % 1000000);
655
+ // The highlight stays until ESC or a plain click. finalizeActiveDrag
656
+ // handles region routing (prompt/word-line/char), the empty→clear
657
+ // case, edge-autoscroll stop, and the measured-rows reconcile same
658
+ // path the ctrl+wheel zoom passthrough uses. Guarded to baseButton 0
659
+ // so a stray right-button release never finalizes.
660
+ finalizeActiveDrag(x, y);
480
661
  }
481
662
  }
482
663
  };
483
664
  inkInput.on('mouse', onMouse);
484
- return () => { inkInput.off('mouse', onMouse); };
665
+ return () => {
666
+ inkInput.off('mouse', onMouse);
667
+ stopEdgeAutoscroll();
668
+ };
485
669
  }, [inkInput, isRawModeSupported, store, passthroughCtrlWheelZoom, rows, scrollTranscriptRows, queueScrollCoalesced, applySelectionRect, applySelectionRectThrottled, selectionPointAtCurrentScroll, buildSpanRect]);
486
670
  }
@@ -12,6 +12,7 @@
12
12
  import { useCallback } from 'react';
13
13
  import {
14
14
  readClipboardImageAttachment,
15
+ readClipboardText,
15
16
  readImageAttachmentFromPath,
16
17
  splitPastedImagePathCandidates,
17
18
  } from '../paste-attachments.mjs';
@@ -57,36 +58,24 @@ export function usePromptHandlers({
57
58
  const handlePromptPaste = useCallback((text, meta = {}) => {
58
59
  const source = String(meta?.source || 'paste');
59
60
  const value = String(text ?? '');
60
- if (source === 'clipboard-image-shortcut' && !value) {
61
- return readClipboardImageAttachment()
62
- .then((image) => {
63
- if (!image) {
64
- showPromptHint('no image found on clipboard', 'plain');
65
- return false;
66
- }
67
- const ref = registerPastedImage(image);
68
- showPromptHint(`attached ${image.filename || 'clipboard image'}`, 'plain');
69
- return ref;
70
- })
71
- .catch((e) => {
72
- showPromptHint(`image paste failed: ${e?.message || e}`, 'warn');
73
- return false;
74
- });
75
- }
76
-
77
- const chunks = splitPastedImagePathCandidates(value);
78
- const hasImagePath = chunks.some((chunk) => chunk.imagePath);
79
- // No image paths in the paste: fold the whole text into a token when it is
80
- // large, otherwise let PromptInput insert it raw (return undefined).
81
- if (!hasImagePath) {
82
- if (shouldFoldPastedText(value)) return registerPastedText(value);
83
- return undefined;
84
- }
85
- // Mixed paste: resolve each image chunk to an image ref, then fold each
86
- // CONTIGUOUS run of non-image text into its own token (only if over
87
- // threshold) so content order around image refs is preserved. '\n'
88
- // separator chunks are plain text and stay inside their surrounding run.
89
- return Promise.all(chunks.map(async (chunk) => {
61
+ // Fold-or-insert text pipeline shared by bracketed paste and the Ctrl+V text
62
+ // path. returnRaw=true makes the "insert raw" case return the string itself
63
+ // (needed by the clipboard-text path, whose outer `text` is empty so the
64
+ // handleExternalPaste fallback would insert nothing).
65
+ const processText = (raw, returnRaw = false) => {
66
+ const chunks = splitPastedImagePathCandidates(raw);
67
+ const hasImagePath = chunks.some((chunk) => chunk.imagePath);
68
+ // No image paths: fold the whole text into a token when large, otherwise
69
+ // insert it raw (return undefined PromptInput inserts, or the string).
70
+ if (!hasImagePath) {
71
+ if (shouldFoldPastedText(raw)) return registerPastedText(raw);
72
+ return returnRaw ? raw : undefined;
73
+ }
74
+ // Mixed paste: resolve each image chunk to an image ref, then fold each
75
+ // CONTIGUOUS run of non-image text into its own token (only if over
76
+ // threshold) so content order around image refs is preserved. '\n'
77
+ // separator chunks are plain text and stay inside their surrounding run.
78
+ return Promise.all(chunks.map(async (chunk) => {
90
79
  if (!chunk.imagePath) return chunk.text;
91
80
  try {
92
81
  const image = await readImageAttachmentFromPath(chunk.text, state.cwd || process.cwd());
@@ -117,6 +106,36 @@ export function usePromptHandlers({
117
106
  flushRun();
118
107
  return out;
119
108
  });
109
+ };
110
+
111
+ // Ctrl+V / Meta+V: opencode clipboard.read() model. Prefer OS-clipboard TEXT
112
+ // (routed through the SAME fold pipeline as bracketed paste); when the
113
+ // clipboard holds no text, fall back to the image-attachment path. The async
114
+ // read result is applied by handleExternalPaste under its pasteGeneration
115
+ // staleness guard, so a stale resolve is dropped.
116
+ if (source === 'clipboard-shortcut' && !value) {
117
+ return readClipboardText()
118
+ .then((clip) => {
119
+ const normalized = String(clip ?? '').replace(/\r\n?/g, '\n');
120
+ if (normalized) return processText(normalized, true);
121
+ return readClipboardImageAttachment()
122
+ .then((image) => {
123
+ if (!image) {
124
+ showPromptHint('no text or image found on clipboard', 'plain');
125
+ return false;
126
+ }
127
+ const ref = registerPastedImage(image);
128
+ showPromptHint(`attached ${image.filename || 'clipboard image'}`, 'plain');
129
+ return ref;
130
+ });
131
+ })
132
+ .catch((e) => {
133
+ showPromptHint(`paste failed: ${e?.message || e}`, 'warn');
134
+ return false;
135
+ });
136
+ }
137
+
138
+ return processText(value);
120
139
  }, [registerPastedImage, registerPastedText, showPromptHint, state.cwd]);
121
140
 
122
141
  const handlePromptHistoryNavigate = useCallback((direction, currentText = '', meta = {}) => {