@brimveyn/aimux 1.15.1 → 1.16.0

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": "@brimveyn/aimux",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,21 @@
1
+ // opentui re-emits 'finishSelection' on re-renders, focus changes, and
2
+ // viewport refreshes. Each of those would call copyToSystemClipboard with the
3
+ // *same* text, blindly clobbering whatever the user just copied in another
4
+ // app. We dedupe by content: aimux never writes the same selection twice in
5
+ // a row to the system clipboard, so an external copy survives any phantom
6
+ // re-emit of the previous in-aimux selection.
7
+ //
8
+ // The dedup state is reset when the renderer reports an empty selection
9
+ // (deselect), so a user who deliberately reselects the same text after
10
+ // clicking elsewhere still gets it copied.
11
+ let lastWrittenText: string | null = null
12
+
13
+ export function shouldWriteSelectionToClipboard(text: string): boolean {
14
+ if (text === lastWrittenText) return false
15
+ lastWrittenText = text
16
+ return true
17
+ }
18
+
19
+ export function resetSelectionClipboardDedup(): void {
20
+ lastWrittenText = null
21
+ }
@@ -126,11 +126,15 @@ export function applyViewportObservation(
126
126
  next: ViewportObservation | null
127
127
  ): ViewportObservation | null {
128
128
  if (!next) {
129
- if (prior !== null) resetSelectionShiftState(renderer)
129
+ if (prior !== null) {
130
+ renderer.clearSelection()
131
+ resetSelectionShiftState(renderer)
132
+ }
130
133
  return null
131
134
  }
132
135
 
133
136
  if (!prior || prior.tabId !== next.tabId) {
137
+ if (prior && prior.tabId !== next.tabId) renderer.clearSelection()
134
138
  resetSelectionShiftState(renderer)
135
139
  return next
136
140
  }
@@ -21,6 +21,7 @@ import {
21
21
  } from './click-selection-resolver'
22
22
  import { recordMultiClickClipboardWrite } from './multi-click-clipboard-guard'
23
23
  import { requestRenderUpTree } from './render-invalidation'
24
+ import { shouldWriteSelectionToClipboard } from './selection-clipboard-dedup'
24
25
  import {
25
26
  type AnchoredRatioDragState,
26
27
  type AxisDragState,
@@ -146,7 +147,9 @@ function applyMultiClickInitialSelection(
146
147
  finishDragging: false,
147
148
  })
148
149
  requestRenderUpTree(selection.target)
149
- copyToSystemClipboard(selection.selectedText)
150
+ if (shouldWriteSelectionToClipboard(selection.selectedText)) {
151
+ copyToSystemClipboard(selection.selectedText)
152
+ }
150
153
  }
151
154
 
152
155
  export function useMouseHandlers({
@@ -573,7 +576,7 @@ export function useMouseHandlers({
573
576
  const anchorIdx = drag.anchorAbsRow - startAbs
574
577
  const focusIdx = drag.focusAbsRow - startAbs
575
578
  const text = extractStreamText(lines, anchorIdx, anchorCol, focusIdx, drag.focusCol)
576
- if (text.length > 0) {
579
+ if (text.length > 0 && shouldWriteSelectionToClipboard(text)) {
577
580
  copyToSystemClipboard(text)
578
581
  recordMultiClickClipboardWrite()
579
582
  }
@@ -23,6 +23,10 @@ import {
23
23
  import { shouldSuppressSelectionCopy } from './multi-click-clipboard-guard'
24
24
  import { writeMacroExpansionToTab, writePasteToTab, writeToTab } from './pty-write'
25
25
  import { type OtuiSelection, resolveSelectionClipboardText } from './selection-clipboard'
26
+ import {
27
+ resetSelectionClipboardDedup,
28
+ shouldWriteSelectionToClipboard,
29
+ } from './selection-clipboard-dedup'
26
30
  import { applyViewportObservation, type ViewportObservation } from './selection-scroll'
27
31
 
28
32
  const BRACKETED_PASTE_ENABLE_SEQUENCE = '\x1b[?2004h'
@@ -239,7 +243,13 @@ export function useRendererBindings({
239
243
  textLength: selectedText.length,
240
244
  })
241
245
 
242
- if (selection.isDragging === true || selectedText.length === 0) {
246
+ if (selection.isDragging === true) {
247
+ return
248
+ }
249
+ if (selectedText.length === 0) {
250
+ // Deselect → forget the last-written text so a deliberate reselection
251
+ // of the same text writes it again.
252
+ resetSelectionClipboardDedup()
243
253
  return
244
254
  }
245
255
 
@@ -254,7 +264,11 @@ export function useRendererBindings({
254
264
  }
255
265
 
256
266
  renderer.copyToClipboardOSC52(selectedText)
257
- copyToSystemClipboard(selectedText)
267
+ if (shouldWriteSelectionToClipboard(selectedText)) {
268
+ copyToSystemClipboard(selectedText)
269
+ } else {
270
+ logInputDebug('app.selection.dedup', { textLength: selectedText.length })
271
+ }
258
272
  }
259
273
 
260
274
  renderer.prependInputHandler(handler)
@@ -19,6 +19,7 @@ import {
19
19
  forEachSplitPaneRect,
20
20
  toTerminalContentSize,
21
21
  } from '../state/layout-resize'
22
+ import { getTreeForTab } from '../state/layout-tree'
22
23
 
23
24
  const MAIN_AREA_HORIZONTAL_CHROME = 2
24
25
  const MAIN_AREA_VERTICAL_PADDING = 0
@@ -161,6 +162,19 @@ export function useTerminalResize({
161
162
 
162
163
  const activeTabIdRef = useRef(state.activeTabId)
163
164
  activeTabIdRef.current = state.activeTabId
165
+ // Whether the active tab is part of a split. contentOriginRef must hold the
166
+ // whole terminal-area origin (root.tsx derives splitContentOrigin from it and
167
+ // SplitLayout re-adds each pane's bounds offset). When split, the active
168
+ // pane's measured box is pane-relative, so adopting it as the area origin
169
+ // would double-count the pane offset and push the hardware cursor off along
170
+ // the split axis. Track it so handleMeasure can skip the x/y overwrite.
171
+ const activeIsSplitRef = useRef(false)
172
+ {
173
+ const id = state.activeTabId
174
+ const tree =
175
+ id != null && id !== '' ? getTreeForTab(state.layoutTrees, state.tabGroupMap, id) : null
176
+ activeIsSplitRef.current = tree?.type === 'split'
177
+ }
164
178
  // Last size we pushed to the backend per tab, used to dedupe the measurement
165
179
  // loop so an unchanged box never re-triggers a resize.
166
180
  const measuredRef = useRef(new Map<string, { cols: number; rows: number }>())
@@ -187,7 +201,11 @@ export function useTerminalResize({
187
201
  const cols = Math.max(PTY_MIN_COLS, rect.cols)
188
202
  const rows = Math.max(PTY_MIN_ROWS, rect.rows)
189
203
  const isActive = tabId === activeTabIdRef.current
190
- if (isActive) {
204
+ // Only adopt the measured box as the terminal-area origin when the active
205
+ // pane spans the whole area (no split). In a split the box is pane-local;
206
+ // keep the model-derived area origin from the terminalSize memo below so
207
+ // the hardware cursor isn't offset by the pane's bounds (see ref comment).
208
+ if (isActive && !activeIsSplitRef.current) {
191
209
  contentOriginRef.current = { cols, rows, x: rect.x, y: rect.y }
192
210
  }
193
211
  const prev = measuredRef.current.get(tabId)
@@ -341,6 +341,25 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
341
341
  return false
342
342
  })
343
343
 
344
+ // Wire the emulator's reply channel back to the pty. When a program
345
+ // writes a query sequence — CPR (ESC[6n), Device Attributes (ESC[c),
346
+ // Device Status (ESC[5n), DECRQM, OSC color queries — xterm generates
347
+ // the answer and emits it via onData. Without forwarding it the program
348
+ // waits forever for a response it never gets (e.g. AWS CLI / Python
349
+ // prompt_toolkit warns "terminal doesn't support cursor position
350
+ // requests (CPR)"). These replies are produced synchronously inside the
351
+ // emulator.write() below, so write straight to the pty rather than
352
+ // queueing through the render path. The listener is disposed together
353
+ // with the emulator in finalize/dispose, mirroring registerCsiHandler.
354
+ //
355
+ // onBinary (legacy non-UTF-8 mouse reports) is intentionally not wired:
356
+ // bun-pty.write() only accepts a UTF-8 string so byte values >127 can't
357
+ // be transmitted faithfully, and the headless emulator is never fed raw
358
+ // mouse input, so it never generates those reports in the first place.
359
+ emulator.onData((reply) => {
360
+ session.pty.write(reply)
361
+ })
362
+
344
363
  pty.onData((data) => {
345
364
  logDebug('ptyManager.data', {
346
365
  byteLength: Buffer.byteLength(data, 'utf8'),