tmux-ide 2.9.0-beta.5 → 2.9.0-beta.6

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/bin/cli.js CHANGED
@@ -47379,8 +47379,13 @@ var init_terminal_replica_interpreter = __esm({
47379
47379
  function boundedPositive(value) {
47380
47380
  return Number.isSafeInteger(value) && value > 0 && value <= 65535;
47381
47381
  }
47382
+ function nativeRowsMatchLease(lease, nativeRows) {
47383
+ if (lease.paneBorderStatus === "off") return lease.pane.height === nativeRows;
47384
+ const touchesStatusEdge = lease.paneBorderStatus === "top" ? lease.pane.top === 0 : lease.pane.top + lease.pane.height === lease.windowRows;
47385
+ return lease.pane.height === nativeRows + (touchesStatusEdge ? 1 : 0);
47386
+ }
47382
47387
  function layoutLeaseEqual(left, right) {
47383
- return left.semanticWindowId === right.semanticWindowId && left.currentWindow === right.currentWindow && left.zoomed === right.zoomed && left.windowCols === right.windowCols && left.windowRows === right.windowRows && left.paneBorderStatus === right.paneBorderStatus && left.pane.semanticPaneId === right.pane.semanticPaneId && left.pane.left === right.pane.left && left.pane.top === right.pane.top && left.pane.width === right.pane.width && left.pane.height === right.pane.height && left.pane.active === right.pane.active;
47388
+ return left.semanticWindowId === right.semanticWindowId && left.zoomed === right.zoomed && left.windowCols === right.windowCols && left.windowRows === right.windowRows && left.paneBorderStatus === right.paneBorderStatus && left.pane.semanticPaneId === right.pane.semanticPaneId && left.pane.left === right.pane.left && left.pane.top === right.pane.top && left.pane.width === right.pane.width && left.pane.height === right.pane.height;
47384
47389
  }
47385
47390
  var SessionRuntimeTerminalReplicaOwner;
47386
47391
  var init_terminal_replica_owner = __esm({
@@ -47586,7 +47591,10 @@ var init_terminal_replica_owner = __esm({
47586
47591
  rows: lease.pane.height,
47587
47592
  chunks: reseed.chunks,
47588
47593
  trace: reseed.trace,
47589
- cursor: { x: event.x, y: event.y },
47594
+ // tmux can report x === cols while its terminal is in the
47595
+ // right-margin wrap-pending state. The canonical replica stores
47596
+ // a cell position, so project that state onto the final column.
47597
+ cursor: { x: Math.min(event.x, reseed.nativeCols - 1), y: event.y },
47590
47598
  bootstrap: "painted-capture",
47591
47599
  validateBeforeCommit: () => this.#leaseIsCurrent(lease, reseed.subscriptionEpoch),
47592
47600
  onInvalidated: () => this.#retryReseedOrFault("terminal reseed layout lease crossed")
@@ -47666,9 +47674,8 @@ var init_terminal_replica_owner = __esm({
47666
47674
  const lease = reseed.layoutLease;
47667
47675
  if (!lease || !this.#leaseIsCurrent(lease, reseed.subscriptionEpoch)) return null;
47668
47676
  if (lease.pane.width !== reseed.nativeCols) return null;
47669
- const rowsExact = lease.paneBorderStatus === "off" ? lease.pane.height === reseed.nativeRows : lease.pane.height === reseed.nativeRows + 1;
47670
- if (!rowsExact) return null;
47671
- if (!Number.isSafeInteger(cursorX) || !Number.isSafeInteger(cursorY) || cursorX < 0 || cursorY < 0 || cursorX >= reseed.nativeCols || cursorY >= reseed.nativeRows)
47677
+ if (!nativeRowsMatchLease(lease, reseed.nativeRows)) return null;
47678
+ if (!Number.isSafeInteger(cursorX) || !Number.isSafeInteger(cursorY) || cursorX < 0 || cursorY < 0 || cursorX > reseed.nativeCols || cursorY >= reseed.nativeRows)
47672
47679
  return null;
47673
47680
  return lease;
47674
47681
  }
@@ -61987,7 +61994,7 @@ var require_package = __commonJS({
61987
61994
  "package.json"(exports, module) {
61988
61995
  module.exports = {
61989
61996
  name: "tmux-ide",
61990
- version: "2.9.0-beta.5",
61997
+ version: "2.9.0-beta.6",
61991
61998
  description: "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
61992
61999
  type: "module",
61993
62000
  bin: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-ide",
3
- "version": "2.9.0-beta.5",
3
+ "version": "2.9.0-beta.6",
4
4
  "description": "A visual, agent-aware IDE for any tmux session, with optional workspace presets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -220,7 +220,10 @@ export class SessionRuntimeTerminalReplicaOwner {
220
220
  rows: lease.pane.height,
221
221
  chunks: reseed.chunks,
222
222
  trace: reseed.trace,
223
- cursor: { x: event.x, y: event.y },
223
+ // tmux can report x === cols while its terminal is in the
224
+ // right-margin wrap-pending state. The canonical replica stores
225
+ // a cell position, so project that state onto the final column.
226
+ cursor: { x: Math.min(event.x, reseed.nativeCols - 1), y: event.y },
224
227
  bootstrap: "painted-capture",
225
228
  validateBeforeCommit: () => this.#leaseIsCurrent(lease, reseed.subscriptionEpoch),
226
229
  onInvalidated: () => this.#retryReseedOrFault("terminal reseed layout lease crossed"),
@@ -317,16 +320,13 @@ export class SessionRuntimeTerminalReplicaOwner {
317
320
  return null;
318
321
  if (lease.pane.width !== reseed.nativeCols)
319
322
  return null;
320
- const rowsExact = lease.paneBorderStatus === "off"
321
- ? lease.pane.height === reseed.nativeRows
322
- : lease.pane.height === reseed.nativeRows + 1;
323
- if (!rowsExact)
323
+ if (!nativeRowsMatchLease(lease, reseed.nativeRows))
324
324
  return null;
325
325
  if (!Number.isSafeInteger(cursorX) ||
326
326
  !Number.isSafeInteger(cursorY) ||
327
327
  cursorX < 0 ||
328
328
  cursorY < 0 ||
329
- cursorX >= reseed.nativeCols ||
329
+ cursorX > reseed.nativeCols ||
330
330
  cursorY >= reseed.nativeRows)
331
331
  return null;
332
332
  return lease;
@@ -371,9 +371,25 @@ export class SessionRuntimeTerminalReplicaOwner {
371
371
  function boundedPositive(value) {
372
372
  return Number.isSafeInteger(value) && value > 0 && value <= 65_535;
373
373
  }
374
+ function nativeRowsMatchLease(lease, nativeRows) {
375
+ if (lease.paneBorderStatus === "off")
376
+ return lease.pane.height === nativeRows;
377
+ // pane-border-status consumes a terminal row only on the pane touching the
378
+ // configured outer window edge. Interior separators are drawn inside the
379
+ // layout and tmux reports their capture at the full visible pane height.
380
+ const touchesStatusEdge = lease.paneBorderStatus === "top"
381
+ ? lease.pane.top === 0
382
+ : lease.pane.top + lease.pane.height === lease.windowRows;
383
+ return lease.pane.height === nativeRows + (touchesStatusEdge ? 1 : 0);
384
+ }
374
385
  function layoutLeaseEqual(left, right) {
386
+ // Focus is ordinary mutable tmux state, not terminal geometry. A second
387
+ // attached client can switch the current window or active pane while an
388
+ // atomic capture is in flight without changing the bytes' dimensions or
389
+ // identity. Treating those flags as part of the capture lease made that
390
+ // harmless navigation invalidate bootstrap, eventually faulting the whole
391
+ // pane stream after the single reseed retry.
375
392
  return (left.semanticWindowId === right.semanticWindowId &&
376
- left.currentWindow === right.currentWindow &&
377
393
  left.zoomed === right.zoomed &&
378
394
  left.windowCols === right.windowCols &&
379
395
  left.windowRows === right.windowRows &&
@@ -382,6 +398,5 @@ function layoutLeaseEqual(left, right) {
382
398
  left.pane.left === right.pane.left &&
383
399
  left.pane.top === right.pane.top &&
384
400
  left.pane.width === right.pane.width &&
385
- left.pane.height === right.pane.height &&
386
- left.pane.active === right.pane.active);
401
+ left.pane.height === right.pane.height);
387
402
  }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { TerminalDeliveryAssembler, decodeVerifiedCompactSemanticTerminalUpdateCooperatively, decodeVerifiedLegacySemanticTerminalUpdate, terminalDeliveryEncodingAccepted, } from "@tmux-ide/core";
2
+ import { TerminalDeliveryAssembler, decodeVerifiedCompactSemanticTerminalUpdate, decodeVerifiedCompactSemanticTerminalUpdateCooperatively, decodeVerifiedLegacySemanticTerminalUpdate, terminalDeliveryEncodingAccepted, } from "@tmux-ide/core";
3
3
  import { PaneStreamOperationError, } from "@tmux-ide/daemon-client/pane-stream-client";
4
4
  import { createOpenTuiPaneStreamSocket } from "./open-tui-pane-stream-socket.js";
5
5
  import { currentTuiPerformanceEventSink } from "./performance-events.js";
@@ -294,6 +294,21 @@ class WireTerminalEndpoint {
294
294
  return consumed;
295
295
  const bytes = assembler.complete();
296
296
  if (envelope.encoding === "semantic-compact-v1") {
297
+ // Bootstrap every endpoint synchronously. Opening a workspace starts
298
+ // several cooperative decoders at once while live panes are already
299
+ // producing deltas; under sustained output one initial seed could be
300
+ // perpetually retired before it established a canonical baseline.
301
+ // Compact seeds are already size-bounded and decoding one atomically
302
+ // gives the endpoint the baseline needed for subsequent cooperative
303
+ // patches and their normal backpressure.
304
+ if (envelope.frame === "seed" && !this.#hasCanonicalSeed) {
305
+ const verified = decodeVerifiedCompactSemanticTerminalUpdate(bytes, this.#canonicalSnapshot, envelope.canonicalStateHash, {
306
+ grantReducerAdoption: true,
307
+ ...(this.#compactDecodeProfile ? { onComplete: this.#compactDecodeProfile } : {}),
308
+ });
309
+ this.#acceptVerified(envelope, verified, performanceSink, parseStartedAt);
310
+ return consumed;
311
+ }
297
312
  const token = Object.freeze({});
298
313
  this.#decodeToken = token;
299
314
  void this.#completeCooperativeCompactDecode(token, envelope, bytes, performanceSink, parseStartedAt);
@@ -298,7 +298,10 @@ export class SessionRuntimeTerminalReplicaOwner {
298
298
  rows: lease.pane.height,
299
299
  chunks: reseed.chunks,
300
300
  trace: reseed.trace,
301
- cursor: { x: event.x, y: event.y },
301
+ // tmux can report x === cols while its terminal is in the
302
+ // right-margin wrap-pending state. The canonical replica stores
303
+ // a cell position, so project that state onto the final column.
304
+ cursor: { x: Math.min(event.x, reseed.nativeCols - 1), y: event.y },
302
305
  bootstrap: "painted-capture",
303
306
  validateBeforeCommit: () => this.#leaseIsCurrent(lease, reseed.subscriptionEpoch),
304
307
  onInvalidated: () => this.#retryReseedOrFault("terminal reseed layout lease crossed"),
@@ -399,17 +402,13 @@ export class SessionRuntimeTerminalReplicaOwner {
399
402
  const lease = reseed.layoutLease;
400
403
  if (!lease || !this.#leaseIsCurrent(lease, reseed.subscriptionEpoch)) return null;
401
404
  if (lease.pane.width !== reseed.nativeCols) return null;
402
- const rowsExact =
403
- lease.paneBorderStatus === "off"
404
- ? lease.pane.height === reseed.nativeRows
405
- : lease.pane.height === reseed.nativeRows + 1;
406
- if (!rowsExact) return null;
405
+ if (!nativeRowsMatchLease(lease, reseed.nativeRows)) return null;
407
406
  if (
408
407
  !Number.isSafeInteger(cursorX) ||
409
408
  !Number.isSafeInteger(cursorY) ||
410
409
  cursorX < 0 ||
411
410
  cursorY < 0 ||
412
- cursorX >= reseed.nativeCols ||
411
+ cursorX > reseed.nativeCols ||
413
412
  cursorY >= reseed.nativeRows
414
413
  )
415
414
  return null;
@@ -477,13 +476,31 @@ function boundedPositive(value: number): boolean {
477
476
  return Number.isSafeInteger(value) && value > 0 && value <= 65_535;
478
477
  }
479
478
 
479
+ function nativeRowsMatchLease(lease: LayoutLease, nativeRows: number): boolean {
480
+ if (lease.paneBorderStatus === "off") return lease.pane.height === nativeRows;
481
+
482
+ // pane-border-status consumes a terminal row only on the pane touching the
483
+ // configured outer window edge. Interior separators are drawn inside the
484
+ // layout and tmux reports their capture at the full visible pane height.
485
+ const touchesStatusEdge =
486
+ lease.paneBorderStatus === "top"
487
+ ? lease.pane.top === 0
488
+ : lease.pane.top + lease.pane.height === lease.windowRows;
489
+ return lease.pane.height === nativeRows + (touchesStatusEdge ? 1 : 0);
490
+ }
491
+
480
492
  function layoutLeaseEqual(
481
493
  left: LayoutLease | Omit<LayoutLease, "epoch">,
482
494
  right: LayoutLease | Omit<LayoutLease, "epoch">,
483
495
  ): boolean {
496
+ // Focus is ordinary mutable tmux state, not terminal geometry. A second
497
+ // attached client can switch the current window or active pane while an
498
+ // atomic capture is in flight without changing the bytes' dimensions or
499
+ // identity. Treating those flags as part of the capture lease made that
500
+ // harmless navigation invalidate bootstrap, eventually faulting the whole
501
+ // pane stream after the single reseed retry.
484
502
  return (
485
503
  left.semanticWindowId === right.semanticWindowId &&
486
- left.currentWindow === right.currentWindow &&
487
504
  left.zoomed === right.zoomed &&
488
505
  left.windowCols === right.windowCols &&
489
506
  left.windowRows === right.windowRows &&
@@ -492,7 +509,6 @@ function layoutLeaseEqual(
492
509
  left.pane.left === right.pane.left &&
493
510
  left.pane.top === right.pane.top &&
494
511
  left.pane.width === right.pane.width &&
495
- left.pane.height === right.pane.height &&
496
- left.pane.active === right.pane.active
512
+ left.pane.height === right.pane.height
497
513
  );
498
514
  }
@@ -19,6 +19,7 @@ import type {
19
19
  } from "@tmux-ide/contracts";
20
20
  import {
21
21
  TerminalDeliveryAssembler,
22
+ decodeVerifiedCompactSemanticTerminalUpdate,
22
23
  decodeVerifiedCompactSemanticTerminalUpdateCooperatively,
23
24
  decodeVerifiedLegacySemanticTerminalUpdate,
24
25
  terminalDeliveryEncodingAccepted,
@@ -474,6 +475,26 @@ class WireTerminalEndpoint {
474
475
  if (chunk.index + 1 < envelope.chunkCount) return consumed;
475
476
  const bytes = assembler.complete();
476
477
  if (envelope.encoding === "semantic-compact-v1") {
478
+ // Bootstrap every endpoint synchronously. Opening a workspace starts
479
+ // several cooperative decoders at once while live panes are already
480
+ // producing deltas; under sustained output one initial seed could be
481
+ // perpetually retired before it established a canonical baseline.
482
+ // Compact seeds are already size-bounded and decoding one atomically
483
+ // gives the endpoint the baseline needed for subsequent cooperative
484
+ // patches and their normal backpressure.
485
+ if (envelope.frame === "seed" && !this.#hasCanonicalSeed) {
486
+ const verified = decodeVerifiedCompactSemanticTerminalUpdate(
487
+ bytes,
488
+ this.#canonicalSnapshot,
489
+ envelope.canonicalStateHash,
490
+ {
491
+ grantReducerAdoption: true,
492
+ ...(this.#compactDecodeProfile ? { onComplete: this.#compactDecodeProfile } : {}),
493
+ },
494
+ );
495
+ this.#acceptVerified(envelope, verified, performanceSink, parseStartedAt);
496
+ return consumed;
497
+ }
477
498
  const token = Object.freeze({});
478
499
  this.#decodeToken = token;
479
500
  void this.#completeCooperativeCompactDecode(
@@ -298,6 +298,32 @@ class FakeRuntime implements WorkspaceClientRuntimePort<string, string> {
298
298
  }
299
299
  }
300
300
 
301
+ class EagerSeedRuntime extends FakeRuntime {
302
+ constructor(
303
+ generation: string,
304
+ private readonly snapshot: string,
305
+ ) {
306
+ super(generation);
307
+ }
308
+
309
+ override async subscribeTerminal(
310
+ address: TerminalReplicaAddress,
311
+ ): Promise<FakeTerminalSubscription> {
312
+ const subscription = new FakeTerminalSubscription(this.generation);
313
+ const onUpdate = subscription.onUpdate.bind(subscription);
314
+ subscription.onUpdate = (listener) => {
315
+ const unsubscribe = onUpdate(listener);
316
+ listener({
317
+ ...terminalSeedFor(address.semanticPaneId, this.generation),
318
+ snapshot: this.snapshot,
319
+ });
320
+ return unsubscribe;
321
+ };
322
+ this.subscriptions.set(address.semanticPaneId, subscription);
323
+ return subscription;
324
+ }
325
+ }
326
+
301
327
  class ClosableFakeRuntime extends FakeRuntime {
302
328
  private readonly closedState = deferred<void>();
303
329
  override readonly closed = this.closedState.promise;
@@ -880,6 +906,36 @@ describe("WorkspaceClient", () => {
880
906
  ).toBe(true);
881
907
  });
882
908
 
909
+ it("does not charge an eager canonical seed against the candidate update buffer", async () => {
910
+ const shell = shellBroker({ alpha: shellResource("alpha", ["pane.alpha"]) });
911
+ const runtime = new EagerSeedRuntime(ALPHA_DAEMON.instanceId, "x".repeat(8 * 1024 * 1024 + 1));
912
+ const publications: TerminalReplicaUpdate<string, string>[] = [];
913
+ const activations: FakeRuntime[] = [];
914
+ const client = createWorkspaceClient<string, string>({
915
+ target: target("alpha"),
916
+ ports: {
917
+ shell: shell.transport,
918
+ connectRuntime: async (_current, _inventory, _signal, prepare) => {
919
+ await prepare(runtime);
920
+ return runtime;
921
+ },
922
+ didActivateRuntime: (nextRuntime) => activations.push(nextRuntime as FakeRuntime),
923
+ actions,
924
+ },
925
+ });
926
+ client.subscribeTerminal({ workspaceName: "alpha", semanticPaneId: "pane.alpha" }, (update) =>
927
+ publications.push(update),
928
+ );
929
+
930
+ shell.connections[0]!.handlers.onVerifiedOpen();
931
+ await settle();
932
+
933
+ expect(activations).toEqual([runtime]);
934
+ expect(publications).toHaveLength(1);
935
+ expect(publications[0]?.type).toBe("terminal.seed");
936
+ await client.dispose();
937
+ });
938
+
883
939
  it("does not reconnect when a shell refresh leaves runtime inventory unchanged", async () => {
884
940
  const shell = shellBroker({ alpha: shellResource("alpha") });
885
941
  const runtime = new FakeRuntime(ALPHA_DAEMON.instanceId);
@@ -777,11 +777,22 @@ export function createWorkspaceClient<
777
777
  publishTerminalUpdate(interest, nextRuntime, update, metadata);
778
778
  return;
779
779
  }
780
- let encodedBytes = MAX_PREPARED_TERMINAL_BYTES + 1;
781
- try {
782
- encodedBytes = UTF8_ENCODER.encode(JSON.stringify({ update, metadata })).byteLength;
783
- } catch {
784
- // An unencodable canonical update cannot be admitted safely.
780
+ // A seed is already retained by the candidate runtime as its
781
+ // canonical baseline. Buffering it here adds only one reference,
782
+ // so charging its fully expanded JSON representation can reject
783
+ // an otherwise bounded multi-pane bootstrap before activation.
784
+ // Patches and tombstones are additional retained payloads and
785
+ // continue to count against the byte budget.
786
+ let encodedBytes = 0;
787
+ if (update.type !== "terminal.seed") {
788
+ encodedBytes = MAX_PREPARED_TERMINAL_BYTES + 1;
789
+ try {
790
+ encodedBytes = UTF8_ENCODER.encode(
791
+ JSON.stringify({ update, metadata }),
792
+ ).byteLength;
793
+ } catch {
794
+ // An unencodable canonical update cannot be admitted safely.
795
+ }
785
796
  }
786
797
  if (
787
798
  prepared.bufferedUpdateCount + 1 > MAX_PREPARED_TERMINAL_UPDATES ||