tabby-tmux 1.0.1 → 1.1.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/dist/index.js CHANGED
@@ -77,35 +77,42 @@ var ___CSS_LOADER_EXPORT___ = _node_modules_pnpm_css_loader_7_1_2_webpack_5_104_
77
77
  // Module
78
78
  ___CSS_LOADER_EXPORT___.push([module.id, `@charset "UTF-8";
79
79
  /*
80
- * TmuxPaneTab — full CSS control over the terminal content area.
80
+ * TmuxPaneTab — pure container for the xterm content area.
81
81
  *
82
- * BaseTerminalTabComponent applies a "spaciness" margin that shrinks the
83
- * content box, which clips edge characters and makes size calculations
84
- * harder. tmux owns the cell grid, so we zero out every spacing property
85
- * so the pane occupies 100 % of its allocated container — pixel-accurate
86
- * and no rounding surprises.
82
+ * tmux owns the cell grid; the pane container is sized exactly to
83
+ * cols × cell pixels via pixel-absolute positioning. No padding,
84
+ * no border, no margin — the xterm canvas fills 100 % of the box.
87
85
  *
88
- * Uniform 4px padding on all sides gives consistent visual breathing room.
89
- * The scrollbar is hidden to remove the asymmetric right-side gap and
90
- * simplify size math; scrolling is handled by Tabby's local history buffer.
91
- * Padding is subtracted in measureClientSize() so tmux grid calculations
92
- * remain accurate.
86
+ * The scrollbar uses overlay mode so it floats above the content
87
+ * without consuming layout space, keeping the container width
88
+ * exactly cols × cellWidth.
93
89
  */
94
90
  :host > .content {
95
91
  margin: 0;
96
- padding: 4px;
92
+ padding: 0;
97
93
  border: 0;
98
94
  box-sizing: border-box;
99
95
  }
100
96
 
101
- /* Hide xterm scrollbar tmux handles scrolling via Control Mode protocol */
97
+ /* xterm fills the pane completely; no overflow clipping needed */
102
98
  :host ::ng-deep .xterm {
103
99
  overflow: hidden !important;
104
100
  }
105
101
 
102
+ /* Overlay scrollbar — floats above content, no layout space consumed */
106
103
  :host ::ng-deep .xterm-viewport {
107
- overflow-y: hidden !important;
108
- }`, "",{"version":3,"sources":["webpack://./src/components/tmuxPaneTab.component.scss"],"names":[],"mappings":"AAAA,gBAAgB;AAAhB;;;;;;;;;;;;;;EAAA;AAeA;EACI,SAAA;EACA,YAAA;EACA,SAAA;EACA,sBAAA;AAEJ;;AACA,4EAAA;AACA;EACI,2BAAA;AAEJ;;AAAA;EACI,6BAAA;AAGJ","sourcesContent":["/*\n * TmuxPaneTab — full CSS control over the terminal content area.\n *\n * BaseTerminalTabComponent applies a \"spaciness\" margin that shrinks the\n * content box, which clips edge characters and makes size calculations\n * harder. tmux owns the cell grid, so we zero out every spacing property\n * so the pane occupies 100 % of its allocated container — pixel-accurate\n * and no rounding surprises.\n *\n * Uniform 4px padding on all sides gives consistent visual breathing room.\n * The scrollbar is hidden to remove the asymmetric right-side gap and\n * simplify size math; scrolling is handled by Tabby's local history buffer.\n * Padding is subtracted in measureClientSize() so tmux grid calculations\n * remain accurate.\n */\n:host > .content {\n margin: 0;\n padding: 4px;\n border: 0;\n box-sizing: border-box;\n}\n\n/* Hide xterm scrollbar — tmux handles scrolling via Control Mode protocol */\n:host ::ng-deep .xterm {\n overflow: hidden !important;\n}\n:host ::ng-deep .xterm-viewport {\n overflow-y: hidden !important;\n}\n"],"sourceRoot":""}]);
104
+ overflow-y: overlay !important;
105
+ scrollbar-width: thin;
106
+ }
107
+
108
+ :host ::ng-deep .xterm-viewport::-webkit-scrollbar {
109
+ width: 6px;
110
+ }
111
+
112
+ :host ::ng-deep .xterm-viewport::-webkit-scrollbar-thumb {
113
+ background: rgba(255, 255, 255, 0.2);
114
+ border-radius: 3px;
115
+ }`, "",{"version":3,"sources":["webpack://./src/components/tmuxPaneTab.component.scss"],"names":[],"mappings":"AAAA,gBAAgB;AAAhB;;;;;;;;;;EAAA;AAWA;EACI,SAAA;EACA,UAAA;EACA,SAAA;EACA,sBAAA;AAEJ;;AACA,iEAAA;AACA;EACI,2BAAA;AAEJ;;AAAA,uEAAA;AACA;EACI,8BAAA;EACA,qBAAA;AAGJ;;AADA;EACI,UAAA;AAIJ;;AAFA;EACI,oCAAA;EACA,kBAAA;AAKJ","sourcesContent":["/*\n * TmuxPaneTab — pure container for the xterm content area.\n *\n * tmux owns the cell grid; the pane container is sized exactly to\n * cols × cell pixels via pixel-absolute positioning. No padding,\n * no border, no margin — the xterm canvas fills 100 % of the box.\n *\n * The scrollbar uses overlay mode so it floats above the content\n * without consuming layout space, keeping the container width\n * exactly cols × cellWidth.\n */\n:host > .content {\n margin: 0;\n padding: 0;\n border: 0;\n box-sizing: border-box;\n}\n\n/* xterm fills the pane completely; no overflow clipping needed */\n:host ::ng-deep .xterm {\n overflow: hidden !important;\n}\n/* Overlay scrollbar — floats above content, no layout space consumed */\n:host ::ng-deep .xterm-viewport {\n overflow-y: overlay !important;\n scrollbar-width: thin;\n}\n:host ::ng-deep .xterm-viewport::-webkit-scrollbar {\n width: 6px;\n}\n:host ::ng-deep .xterm-viewport::-webkit-scrollbar-thumb {\n background: rgba(255,255,255,0.2);\n border-radius: 3px;\n}\n"],"sourceRoot":""}]);
109
116
  // Exports
110
117
  /* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (___CSS_LOADER_EXPORT___);
111
118
 
@@ -439,7 +446,7 @@ let TmuxPaneTabComponent = class TmuxPaneTabComponent extends tabby_terminal_1.B
439
446
  // xterm's automatic fit-to-container so the pane never overrides the
440
447
  // tmux-dictated grid with its own (pixel-rounded) size — that mismatch
441
448
  // is what causes off-by-one wrapping / cursor errors. The grid is set
442
- // explicitly via setTmuxGrid() from the layout sync instead.
449
+ // explicitly via setTmuxGrid() from applyPixelLayout() instead.
443
450
  this.frontendReady$.pipe((0, rxjs_1.first)()).subscribe(() => {
444
451
  this._frontendReady = true;
445
452
  const frontend = this.frontend;
@@ -454,8 +461,16 @@ let TmuxPaneTabComponent = class TmuxPaneTabComponent extends tabby_terminal_1.B
454
461
  }
455
462
  }
456
463
  // Apply any grid size that arrived before the frontend was ready.
464
+ //
465
+ // IMPORTANT: Defer with setTimeout(0) to avoid re-entrant xterm.resize().
466
+ // frontendReady$ fires inside the onResize callback of fitAddon.fit()'s
467
+ // xterm.resize(N, M). Calling xterm.resize(tmuxCols, tmuxRows) from
468
+ // within that callback is re-entrant — the outer resize continues its
469
+ // internal bookkeeping after onResize returns and overwrites our changes.
470
+ // Deferring ensures applyTmuxGrid() runs after fitAddon.fit() and the
471
+ // outer resize have fully completed.
457
472
  if (this._tmuxCols > 0 && this._tmuxRows > 0) {
458
- this.applyTmuxGrid();
473
+ setTimeout(() => this.applyTmuxGrid(), 0);
459
474
  }
460
475
  });
461
476
  }
@@ -578,6 +593,12 @@ let TmuxPaneTabComponent = class TmuxPaneTabComponent extends tabby_terminal_1.B
578
593
  { label: this.translate.instant('Up'), click: () => this.splitPane('up') },
579
594
  ],
580
595
  },
596
+ {
597
+ label: this.translate.instant('Zoom pane'),
598
+ type: 'checkbox',
599
+ checked: this._isZoomed,
600
+ click: () => this.toggleZoom(),
601
+ },
581
602
  {
582
603
  label: this.translate.instant('Focus all tmux panes'),
583
604
  type: 'checkbox',
@@ -597,6 +618,24 @@ let TmuxPaneTabComponent = class TmuxPaneTabComponent extends tabby_terminal_1.B
597
618
  event.stopPropagation();
598
619
  this.platform.popupContextMenu(await this.buildContextMenu(), event);
599
620
  }
621
+ /** Whether this pane is currently zoomed (fills the entire window). */
622
+ get _isZoomed() {
623
+ if (!this.controller || !this.paneId)
624
+ return false;
625
+ // Find which window owns this pane
626
+ for (const ws of this.controller.getAllWindowStates()) {
627
+ if (ws.panes.has(this.paneId)) {
628
+ return ws.zoomedPaneId === this.paneId;
629
+ }
630
+ }
631
+ return false;
632
+ }
633
+ /** Toggle zoom via tmux resize-pane -Z (same as prefix+z). */
634
+ async toggleZoom() {
635
+ if (!this.controller)
636
+ return;
637
+ await this.controller.zoomPane(this.paneId);
638
+ }
600
639
  async splitPane(direction) {
601
640
  if (!this.controller)
602
641
  return;
@@ -707,7 +746,6 @@ var __importStar = (this && this.__importStar) || function (mod) {
707
746
  var __metadata = (this && this.__metadata) || function (k, v) {
708
747
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
709
748
  };
710
- var TmuxSessionTabComponent_1;
711
749
  Object.defineProperty(exports, "__esModule", ({ value: true }));
712
750
  exports.TmuxSessionTabComponent = void 0;
713
751
  const core_1 = __webpack_require__(/*! @angular/core */ "@angular/core");
@@ -715,6 +753,7 @@ const tabby_core_1 = __webpack_require__(/*! tabby-core */ "tabby-core");
715
753
  const tabby_core_2 = __webpack_require__(/*! tabby-core */ "tabby-core");
716
754
  const session_1 = __webpack_require__(/*! ../session */ "./src/session.ts");
717
755
  const tmux_service_1 = __webpack_require__(/*! ../services/tmux.service */ "./src/services/tmux.service.ts");
756
+ const gateway_1 = __webpack_require__(/*! ../gateway */ "./src/gateway.ts");
718
757
  const tmuxPaneTab_component_1 = __webpack_require__(/*! ./tmuxPaneTab.component */ "./src/components/tmuxPaneTab.component.ts");
719
758
  const layoutParser_1 = __webpack_require__(/*! ../layoutParser */ "./src/layoutParser.ts");
720
759
  /**
@@ -724,9 +763,14 @@ const layoutParser_1 = __webpack_require__(/*! ../layoutParser */ "./src/layoutP
724
763
  * removeTab()/addTab() when switching windows. The bottom window bar provides
725
764
  * window switching UI.
726
765
  *
766
+ * Layout is pixel-absolute: pane positions are computed from tmux's character
767
+ * coordinates × cell pixel size, NOT from SplitTab's ratio-based percentage
768
+ * layout. The SplitContainer tree is only used by addTab()/removeTab() for
769
+ * ViewContainerRef management.
770
+ *
727
771
  * Always created by TmuxService.attachToTerminal() with existingController set.
728
772
  */
729
- let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabComponent extends tabby_core_1.SplitTabComponent {
773
+ let TmuxSessionTabComponent = class TmuxSessionTabComponent extends tabby_core_1.SplitTabComponent {
730
774
  constructor(injector, tmuxService, configService, tabsService, cdr, hostElement, log) {
731
775
  super(injector.get(tabby_core_1.HotkeysService), tabsService, injector.get(tabby_core_2.TabRecoveryService), injector);
732
776
  this.tmuxService = tmuxService;
@@ -750,12 +794,8 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
750
794
  /** Last dimensions sent to tmux, for dedup */
751
795
  this._lastSentCols = 0;
752
796
  this._lastSentRows = 0;
753
- /** Custom tmux pane dividers — kept for interface compatibility but not rendered */
754
- this._tmuxDividers = [];
755
- /** mousedown handler attached to .pane-area for border drag detection */
756
- this._paneAreaMouseDownHandler = null;
757
- /** mousemove handler for border hover highlight */
758
- this._paneAreaMouseMoveHandler = null;
797
+ /** Active divider DOM elements for the current window layout */
798
+ this._dividerElements = [];
759
799
  this._tabsService = tabsService;
760
800
  this.logger = log.create('tmux-session');
761
801
  }
@@ -792,13 +832,19 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
792
832
  // has finished inserting us into its ViewContainerRef
793
833
  requestAnimationFrame(async () => {
794
834
  this._initialized = true;
835
+ // ── Step A: Disable tmux pane borders + push client size FIRST ──
836
+ // tmux draws box-drawing characters in pane borders by default.
837
+ // We draw our own CSS dividers instead, so disable tmux's borders
838
+ // to avoid double-rendering (the border chars would show through
839
+ // our transparent divider elements).
840
+ // Then push client size so tmux relays out without border space.
841
+ this.controller.gateway.sendCommand('set-option -gw pane-border-lines off', gateway_1.TMUX_COMMAND_TOLERATE_ERRORS).catch(() => { });
842
+ this.refreshClientSize();
843
+ await this.eventQueue;
844
+ // ── Step B: Pane discovery (now based on correct size) ──
795
845
  await this.controller.refreshPanes();
796
846
  this.bootstrapFromControllerState();
797
- // Wait for all queued events (window-add, pane-add) from refreshPanes
798
- // to be processed before switching to the first window.
799
847
  await this.eventQueue;
800
- // Prefer the tmux-side active window (from list-windows #{window_active}
801
- // or %session-window-changed). Fall back to the first window in the map.
802
848
  const activeWindowId = this.controller.getActiveWindowId();
803
849
  const targetWindowId = (activeWindowId !== null && this.windowPaneTabs.has(activeWindowId))
804
850
  ? activeWindowId
@@ -806,26 +852,15 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
806
852
  if (targetWindowId !== undefined) {
807
853
  await this.switchToWindow(targetWindowId);
808
854
  }
809
- // Listen for window resize events (like iTerm2's windowDidResize).
810
- // Only fires when the browser window changes size, not during
811
- // internal SplitTab layout operations. Debounced to avoid flooding.
855
+ // ── Step C: ResizeObserver + window resize ──
812
856
  this._resizeHandler = () => this.scheduleRefreshClientSize();
813
857
  window.addEventListener('resize', this._resizeHandler);
814
- // Observe the .pane-area container directly. This is the single
815
- // source of truth for the client size: any time the container's
816
- // pixel size changes (window resize, spanner drag, sidebar toggle,
817
- // first mount), we recompute and push the whole-window grid to tmux.
818
- // Per-pane xterm fit is disabled, so this never feeds back.
819
858
  const host = this.hostElement.nativeElement;
820
859
  const paneArea = host.querySelector('.pane-area');
821
860
  if (paneArea && typeof ResizeObserver !== 'undefined') {
822
861
  this._paneAreaObserver = new ResizeObserver(() => this.scheduleRefreshClientSize());
823
862
  this._paneAreaObserver.observe(paneArea);
824
863
  }
825
- // Attach border hover + drag handlers to the pane-area
826
- this.attachPaneAreaBorderHandlers();
827
- // Initial size sync after pane mount
828
- this.scheduleRefreshClientSize();
829
864
  });
830
865
  }
831
866
  bootstrapFromControllerState() {
@@ -926,6 +961,11 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
926
961
  this.handlePaneClose(event.paneId, event.windowId);
927
962
  }
928
963
  break;
964
+ case 'active-pane-changed':
965
+ if (event.paneId !== undefined && event.windowId !== undefined) {
966
+ this.handleActivePaneChanged(event.paneId, event.windowId);
967
+ }
968
+ break;
929
969
  case 'layout-change':
930
970
  // NOTE: We always call syncLayout for the active window.
931
971
  // For non-active windows, we save the layout but don't rebuild
@@ -933,7 +973,7 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
933
973
  if (event.windowId !== undefined && ((_b = event.data) === null || _b === void 0 ? void 0 : _b.layout)) {
934
974
  if (event.windowId === this.activeWindowId) {
935
975
  this.logger.info(`Syncing layout for active window @${event.windowId}`);
936
- await this.syncLayout(event.data.layout);
976
+ await this.syncLayout(event.data.layout, event.data.zoomed, event.data.visibleLayout);
937
977
  }
938
978
  else {
939
979
  this.logger.info(`Layout changed for inactive window @${event.windowId}, saved for next switch`);
@@ -951,15 +991,13 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
951
991
  * Hides current window's panes and shows target window's panes.
952
992
  */
953
993
  async switchToWindow(windowId) {
954
- var _a, _b;
994
+ var _a, _b, _c;
955
995
  if (windowId === this.activeWindowId)
956
996
  return;
957
997
  this.logger.info(`Switching to window @${windowId}`);
958
998
  // Clear dividers while switching windows
959
- this._tmuxDividers = [];
960
- // 1. Detach current active window's pane views (don't use removeTab —
961
- // SplitTabComponent.removeTab destroys the tab when root.children
962
- // becomes empty). Instead, clear the root directly.
999
+ this.clearDividers();
1000
+ // 1. Detach current active window's pane views
963
1001
  if (this.activeWindowId !== null) {
964
1002
  const paneMap = this.windowPaneTabs.get(this.activeWindowId);
965
1003
  if (paneMap) {
@@ -978,20 +1016,9 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
978
1016
  }
979
1017
  const paneMap = this.windowPaneTabs.get(windowId);
980
1018
  if (paneMap.size === 0) {
981
- // First time visiting this window. Pane tabs will be created by
982
- // handlePaneAdd (triggered by discoverPanesFromLayout on
983
- // %layout-change). We don't call addPanesForWindow — panes are
984
- // discovered asynchronously (iTerm2-style).
985
- //
986
- // HOWEVER: if the controller already knows the layout for this
987
- // window (from batch discovery during attach), we can proactively
988
- // discover panes from it instead of waiting for a layout-change
989
- // event that may never come (tmux doesn't re-send layout-change
990
- // for windows that haven't changed).
991
1019
  const windowState = (_a = this.controller) === null || _a === void 0 ? void 0 : _a.getWindowState(windowId);
992
1020
  if (windowState === null || windowState === void 0 ? void 0 : windowState.layout) {
993
1021
  this.logger.info(`No pane tabs yet for window @${windowId}, but layout is known — discovering panes proactively`);
994
- // Extract pane IDs from the known layout and create pane tabs
995
1022
  const { parseTmuxLayout, flattenLayout } = await Promise.resolve().then(() => __importStar(__webpack_require__(/*! ../layoutParser */ "./src/layoutParser.ts")));
996
1023
  const layoutTree = parseTmuxLayout(windowState.layout);
997
1024
  if (layoutTree) {
@@ -1013,45 +1040,81 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1013
1040
  else {
1014
1041
  this.logger.info(`Mounting existing ${paneMap.size} pane(s) for window @${windowId}`);
1015
1042
  }
1016
- // 4. Rebuild SplitContainer tree with this window's panes
1043
+ // 4. Determine zoom state and discover all pane tabs needed.
1044
+ // layout = real multi-pane layout (always has all pane IDs)
1045
+ // visibleLayout = zoomed display layout (single pane filling window)
1046
+ const windowState = (_b = this.controller) === null || _b === void 0 ? void 0 : _b.getWindowState(windowId);
1047
+ const isZoomed = !!(windowState === null || windowState === void 0 ? void 0 : windowState.zoomedPaneId);
1048
+ // Ensure pane tabs for ALL panes exist (discovered from layout, which is always real)
1049
+ if (windowState === null || windowState === void 0 ? void 0 : windowState.layout) {
1050
+ const fullTree = (0, layoutParser_1.parseTmuxLayout)(windowState.layout);
1051
+ if (fullTree) {
1052
+ for (const pane of (0, layoutParser_1.flattenLayout)(fullTree)) {
1053
+ if (!paneMap.has(pane.paneId)) {
1054
+ this.logger.info(`Creating pane tab for %${pane.paneId}` + (isZoomed ? ' (zoomed window)' : ''));
1055
+ const paneTab = this.createPaneTab(pane.paneId);
1056
+ paneTab.controller = this.controller;
1057
+ paneTab.paneId = pane.paneId;
1058
+ paneMap.set(pane.paneId, paneTab);
1059
+ }
1060
+ }
1061
+ }
1062
+ }
1063
+ // 5. Attach views for display pane tabs only
1064
+ // Reset root tree so addTab() registers panes into a clean structure.
1017
1065
  this.root = new tabby_core_1.SplitContainer();
1018
1066
  this.root.orientation = 'h';
1067
+ // Display layout: visibleLayout when zoomed (what's on screen), layout otherwise
1068
+ const displayLayoutStr = isZoomed && (windowState === null || windowState === void 0 ? void 0 : windowState.visibleLayout)
1069
+ ? windowState.visibleLayout
1070
+ : windowState === null || windowState === void 0 ? void 0 : windowState.layout;
1071
+ const displayTree = displayLayoutStr ? (0, layoutParser_1.parseTmuxLayout)(displayLayoutStr) : null;
1072
+ const displayPaneIds = displayTree
1073
+ ? new Set((0, layoutParser_1.flattenLayout)(displayTree).map(p => p.paneId))
1074
+ : new Set(paneMap.keys()); // no layout → show all
1019
1075
  const paneTabs = Array.from(paneMap.values());
1020
1076
  if (paneTabs.length > 0) {
1021
- this.logger.info(`Adding ${paneTabs.length} pane tab(s) to SplitTab`);
1022
- for (let i = 0; i < paneTabs.length; i++) {
1023
- const paneTab = paneTabs[i];
1024
- if (i === 0) {
1025
- await this.addTab(paneTab, null, 'r');
1077
+ for (const paneTab of paneTabs) {
1078
+ const isDisplay = displayPaneIds.has(paneTab.paneId);
1079
+ if (!((_c = this.viewRefs) === null || _c === void 0 ? void 0 : _c.has(paneTab))) {
1080
+ if (isDisplay) {
1081
+ await this.addTab(paneTab, null, 'r');
1082
+ }
1083
+ }
1084
+ if (isDisplay) {
1085
+ ;
1086
+ paneTab.emitVisibility(true);
1087
+ paneTab.emitFocused();
1026
1088
  }
1027
1089
  else {
1028
- await this.addTab(paneTab, paneTabs[i - 1], 'r');
1090
+ ;
1091
+ paneTab.emitVisibility(false);
1029
1092
  }
1030
- paneTab.emitVisibility(true);
1031
1093
  }
1032
- // 5. Sync layout from tmux
1033
- const windowState = (_b = this.controller) === null || _b === void 0 ? void 0 : _b.getWindowState(windowId);
1034
- if (windowState === null || windowState === void 0 ? void 0 : windowState.layout) {
1035
- this.logger.info('Syncing layout after mounting panes');
1036
- await this.syncLayout(windowState.layout);
1094
+ // 6. Apply pixel layout from tmux
1095
+ if (displayTree) {
1096
+ this.applyPixelLayout(displayTree);
1097
+ this.updateDividers(displayTree);
1037
1098
  }
1038
1099
  }
1039
- // 6. Detect changes; precise client size refresh happens via the
1040
- // .pane-area ResizeObserver once xterm renders its cell grid.
1100
+ // 7. Detect changes and push size
1041
1101
  this.cdr.detectChanges();
1042
- // 7. Push the correct client size to tmux once panes are mounted.
1043
- // We use requestAnimationFrame to wait for xterm to render its
1044
- // character grid (getCellSize needs real cell dimensions), then
1045
- // force a non-deduplicated refresh-client -C.
1046
1102
  if (paneTabs.length > 0) {
1047
1103
  requestAnimationFrame(() => {
1048
- // Reset dedup so the next call always goes through
1049
1104
  this._lastSentCols = 0;
1050
1105
  this._lastSentRows = 0;
1051
1106
  this.refreshClientSize();
1052
1107
  });
1053
1108
  }
1054
1109
  }
1110
+ /**
1111
+ * Override layout() to no-op. SplitTab's layoutInternal() uses percentage
1112
+ * positioning which conflicts with our pixel-absolute layout. Pane
1113
+ * positioning is handled exclusively by applyPixelLayout().
1114
+ */
1115
+ layout() {
1116
+ // Intentionally empty — pixel-absolute layout replaces SplitTab layout.
1117
+ }
1055
1118
  /**
1056
1119
  * Override focus to manage which pane is the active (hotkey-target) pane.
1057
1120
  *
@@ -1115,9 +1178,6 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1115
1178
  // Do NOT destroy self when root is empty — this is normal during
1116
1179
  // tmux window switches.
1117
1180
  }
1118
- /**
1119
- }
1120
-
1121
1181
  /**
1122
1182
  * Create a TmuxPaneTabComponent using TabsService (proper Angular DI).
1123
1183
  * This ensures the component has a hostView and ViewContainerRef.
@@ -1213,8 +1273,15 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1213
1273
  }
1214
1274
  /**
1215
1275
  * Handle a tmux window being closed.
1276
+ *
1277
+ * tmux automatically activates an adjacent window (next by index, or
1278
+ * previous if it was the last) and sends %session-window-changed.
1279
+ * The controller updates activeWindowId from that event, so we check
1280
+ * it to decide which window to switch to — matching tmux default
1281
+ * behavior (and browser tab close behavior).
1216
1282
  */
1217
1283
  async handleWindowClose(windowId) {
1284
+ var _a;
1218
1285
  const paneMap = this.windowPaneTabs.get(windowId);
1219
1286
  if (paneMap) {
1220
1287
  // Destroy all pane tabs for this window
@@ -1232,43 +1299,60 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1232
1299
  this.activeWindowId = null;
1233
1300
  const remainingWindows = Array.from(this.windowPaneTabs.keys());
1234
1301
  if (remainingWindows.length > 0) {
1235
- await this.switchToWindow(remainingWindows[0]);
1302
+ // tmux sends %session-window-changed which updates
1303
+ // controller.activeWindowId — prefer that over arbitrary choice
1304
+ const tmuxActiveId = (_a = this.controller) === null || _a === void 0 ? void 0 : _a.getActiveWindowId();
1305
+ const target = (tmuxActiveId !== null && this.windowPaneTabs.has(tmuxActiveId))
1306
+ ? tmuxActiveId
1307
+ : remainingWindows[0];
1308
+ await this.switchToWindow(target);
1236
1309
  }
1237
1310
  else {
1238
- // No windows left — reset
1239
- this.root = new tabby_core_1.SplitContainer();
1240
- this.root.orientation = 'h';
1241
- this.layout();
1311
+ // No windows left — clear dividers
1312
+ this.clearDividers();
1242
1313
  this.cdr.detectChanges();
1243
1314
  }
1244
1315
  }
1245
1316
  }
1246
1317
  /**
1247
- * Synchronize SplitTab layout with tmux's layout string.
1318
+ * Synchronize layout with tmux's layout string.
1248
1319
  *
1249
- * This is the SINGLE place where the SplitTab tree is built.
1250
- * It creates missing pane tabs, attaches their views, cleans up stale
1251
- * panes, and rebuilds the entire tree from the tmux layout string.
1320
+ * Creates missing pane tabs, attaches their views, cleans up stale
1321
+ * panes, and positions everything via pixel-absolute layout.
1252
1322
  */
1253
- async syncLayout(layoutStr) {
1254
- var _a;
1255
- const layoutTree = (0, layoutParser_1.parseTmuxLayout)(layoutStr);
1256
- if (!layoutTree) {
1257
- this.logger.warn('Failed to parse layout:', layoutStr);
1323
+ async syncLayout(layoutStr, zoomed, visibleLayout) {
1324
+ // tmux %layout-change semantics:
1325
+ // layout = real multi-pane layout (all panes, their actual sizes)
1326
+ // visibleLayout = layout that tmux actually displays on screen
1327
+ // When zoomed: visibleLayout is the single zoomed pane filling the window.
1328
+ //
1329
+ // For display, use visibleLayout when zoomed (what's on screen), layout otherwise.
1330
+ // For pane discovery, always use layout (has all pane IDs).
1331
+ var _a, _b;
1332
+ // Display layout: what's actually shown on screen
1333
+ const displayLayoutStr = zoomed && visibleLayout ? visibleLayout : layoutStr;
1334
+ const displayTree = (0, layoutParser_1.parseTmuxLayout)(displayLayoutStr);
1335
+ if (!displayTree) {
1336
+ this.logger.warn('Failed to parse display layout:', displayLayoutStr);
1258
1337
  return;
1259
1338
  }
1260
- const panes = (0, layoutParser_1.flattenLayout)(layoutTree);
1261
- this.logger.info(`Syncing layout for window @${this.activeWindowId}: ${panes.length} panes`);
1262
- // Ensure pane tabs exist and have attached views for every pane in
1263
- // the layout. New panes (from split-window) are registered by
1264
- // handlePaneAdd but have no view yet we call addTab to create one.
1339
+ const displayPanes = (0, layoutParser_1.flattenLayout)(displayTree);
1340
+ const displayPaneIds = new Set(displayPanes.map(p => p.paneId));
1341
+ // Full pane list from layout (always the real multi-pane layout)
1342
+ const fullTree = (0, layoutParser_1.parseTmuxLayout)(layoutStr);
1343
+ const allPanes = fullTree ? (0, layoutParser_1.flattenLayout)(fullTree) : displayPanes;
1344
+ this.logger.info(`Syncing layout for window @${this.activeWindowId}: ` +
1345
+ `${displayPanes.length} display pane(s), ${allPanes.length} total` +
1346
+ (zoomed ? ' (zoomed)' : ''));
1347
+ // Ensure pane tabs exist and have attached views
1265
1348
  if (this.activeWindowId !== null) {
1266
1349
  let paneMap = this.windowPaneTabs.get(this.activeWindowId);
1267
1350
  if (!paneMap) {
1268
1351
  paneMap = new Map();
1269
1352
  this.windowPaneTabs.set(this.activeWindowId, paneMap);
1270
1353
  }
1271
- for (const pane of panes) {
1354
+ // Create pane tabs for ALL panes (including hidden ones when zoomed)
1355
+ for (const pane of allPanes) {
1272
1356
  if (!paneMap.has(pane.paneId)) {
1273
1357
  this.logger.info(`Creating pane tab for %${pane.paneId} during layout sync`);
1274
1358
  const paneTab = this.createPaneTab(pane.paneId);
@@ -1277,35 +1361,39 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1277
1361
  paneMap.set(pane.paneId, paneTab);
1278
1362
  }
1279
1363
  }
1280
- // Attach views for panes that don't have one yet.
1281
- // The tree structure will be rebuilt below, so the addTab direction
1282
- // doesn't matter — we just need the view to exist.
1283
- for (const pane of panes) {
1364
+ // Ensure root exists for addTab to register ViewContainerRefs.
1365
+ if (!(this.root instanceof tabby_core_1.SplitContainer)) {
1366
+ this.root = new tabby_core_1.SplitContainer();
1367
+ this.root.orientation = 'h';
1368
+ }
1369
+ // Attach views for panes that should be displayed
1370
+ for (const pane of displayPanes) {
1284
1371
  const paneTab = paneMap.get(pane.paneId);
1285
1372
  if (!((_a = this.viewRefs) === null || _a === void 0 ? void 0 : _a.has(paneTab))) {
1286
1373
  this.logger.info(`Attaching view for pane %${pane.paneId}`);
1287
- const existingTabs = this.getAllTabs();
1288
- if (existingTabs.length === 0) {
1289
- await this.addTab(paneTab, null, 'r');
1290
- }
1291
- else {
1292
- await this.addTab(paneTab, existingTabs[existingTabs.length - 1], 'r');
1293
- }
1374
+ await this.addTab(paneTab, null, 'r');
1375
+ }
1376
+ ;
1377
+ paneTab.emitVisibility(true);
1378
+ paneTab.emitFocused();
1379
+ }
1380
+ // Hide panes not in the display set (e.g. non-zoomed panes)
1381
+ for (const [paneId, paneTab] of paneMap) {
1382
+ if (!displayPaneIds.has(paneId)) {
1294
1383
  ;
1295
- paneTab.emitVisibility(true);
1296
- paneTab.emitFocused();
1384
+ paneTab.emitVisibility(false);
1385
+ if ((_b = this.viewRefs) === null || _b === void 0 ? void 0 : _b.has(paneTab)) {
1386
+ this.detachPaneView(paneTab);
1387
+ }
1297
1388
  }
1298
1389
  }
1299
- }
1300
- // Detect and clean up stale pane tabs that are no longer in the layout.
1301
- // When tmux closes a pane, the %layout-change notification omits it.
1302
- // We remove the corresponding tab so the UI stays consistent.
1303
- if (this.activeWindowId !== null) {
1304
- const paneMap = this.windowPaneTabs.get(this.activeWindowId);
1305
- if (paneMap) {
1306
- const layoutPaneIds = new Set(panes.map(p => p.paneId));
1390
+ // Clean up stale pane tabs no longer in the full layout.
1391
+ // When zoomed, only clean up panes absent from visibleLayout;
1392
+ // panes hidden by zoom are still alive in tmux.
1393
+ if (!zoomed) {
1394
+ const fullPaneIds = new Set(allPanes.map(p => p.paneId));
1307
1395
  for (const [paneId, paneTab] of paneMap) {
1308
- if (!layoutPaneIds.has(paneId)) {
1396
+ if (!fullPaneIds.has(paneId)) {
1309
1397
  this.logger.info(`Pane %${paneId} no longer in layout, cleaning up`);
1310
1398
  paneMap.delete(paneId);
1311
1399
  paneTab.emitVisibility(false);
@@ -1315,30 +1403,25 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1315
1403
  }
1316
1404
  }
1317
1405
  }
1318
- // Build the SplitContainer tree from the parsed layout
1319
- const newRoot = this.buildSplitContainerFromLayout(layoutTree);
1320
- if (newRoot instanceof tabby_core_1.SplitContainer) {
1321
- this.root = newRoot;
1322
- this.layout();
1323
- }
1324
- else if (newRoot) {
1325
- // Single pane — wrap in a container
1326
- this.root = new tabby_core_1.SplitContainer();
1327
- this.root.orientation = 'h';
1328
- this.root.children.push(newRoot);
1329
- this.root.ratios.push(1);
1330
- this.layout();
1331
- }
1406
+ // Position panes using pixel-absolute layout + set character grids
1407
+ this.applyPixelLayout(displayTree);
1408
+ // Update divider elements
1409
+ this.updateDividers(displayTree);
1332
1410
  this.cdr.detectChanges();
1333
- // tmux is authoritative over each pane's character grid: push the exact
1334
- // cell dimensions from the layout string into each xterm. This keeps
1335
- // wrapping aligned with tmux and avoids any pixel-derived resize.
1336
- this.applyLayoutGrids(layoutTree);
1337
1411
  }
1338
1412
  /**
1339
1413
  * Handle a pane being closed (from %pane-close event or manual cleanup).
1414
+ *
1415
+ * Note: we do NOT activate a neighboring pane here. tmux sends
1416
+ * %window-pane-changed after closing a pane, which triggers
1417
+ * handleActivePaneChanged() to focus the correct pane.
1418
+ *
1419
+ * When zoomed, closing a hidden pane just removes it from the map.
1420
+ * Closing the zoomed pane triggers tmux to auto-unzoom + kill,
1421
+ * which sends %layout-change to restore the real layout.
1340
1422
  */
1341
1423
  handlePaneClose(paneId, windowId) {
1424
+ var _a;
1342
1425
  const paneMap = this.windowPaneTabs.get(windowId);
1343
1426
  if (!paneMap)
1344
1427
  return;
@@ -1347,43 +1430,70 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1347
1430
  return;
1348
1431
  this.logger.info(`Cleaning up closed pane %${paneId} in window @${windowId}`);
1349
1432
  paneMap.delete(paneId);
1350
- if (windowId === this.activeWindowId) {
1433
+ // Only detach view if it's actually attached (visible panes).
1434
+ // Hidden panes (e.g. non-zoomed panes when zoomed) are already detached.
1435
+ if (windowId === this.activeWindowId && ((_a = this.viewRefs) === null || _a === void 0 ? void 0 : _a.has(paneTab))) {
1351
1436
  ;
1352
1437
  paneTab.emitVisibility(false);
1353
1438
  this.detachPaneView(paneTab);
1354
- this.layout();
1355
1439
  this.cdr.detectChanges();
1356
1440
  }
1357
1441
  ;
1358
1442
  paneTab.destroy();
1359
1443
  }
1360
1444
  /**
1361
- * Build a SplitContainer tree from a tmux layout node.
1445
+ * Handle tmux telling us the active pane changed (e.g. after pane close).
1446
+ * Focuses the pane in the UI, matching tmux default behavior.
1362
1447
  */
1363
- buildSplitContainerFromLayout(node) {
1364
- if (node.type === 'pane' && node.paneId !== undefined && this.activeWindowId !== null) {
1365
- const paneMap = this.windowPaneTabs.get(this.activeWindowId);
1366
- return (paneMap === null || paneMap === void 0 ? void 0 : paneMap.get(node.paneId)) || null;
1367
- }
1368
- if (!node.children || node.children.length === 0) {
1369
- return null;
1370
- }
1371
- const container = new tabby_core_1.SplitContainer();
1372
- container.orientation = node.type === 'horizontal' ? 'h' : 'v';
1373
- const totalSize = node.type === 'horizontal'
1374
- ? node.children.reduce((sum, c) => sum + c.width, 0)
1375
- : node.children.reduce((sum, c) => sum + c.height, 0);
1376
- for (const child of node.children) {
1377
- const childComponent = this.buildSplitContainerFromLayout(child);
1378
- if (childComponent) {
1379
- container.children.push(childComponent);
1380
- const ratio = totalSize > 0
1381
- ? (node.type === 'horizontal' ? child.width / totalSize : child.height / totalSize)
1382
- : 1 / node.children.length;
1383
- container.ratios.push(ratio);
1448
+ handleActivePaneChanged(paneId, windowId) {
1449
+ if (windowId !== this.activeWindowId)
1450
+ return;
1451
+ const paneMap = this.windowPaneTabs.get(windowId);
1452
+ if (!paneMap)
1453
+ return;
1454
+ const paneTab = paneMap.get(paneId);
1455
+ if (!paneTab)
1456
+ return;
1457
+ this.logger.info(`Activating pane %${paneId} in window @${windowId}`);
1458
+ this.focus(paneTab);
1459
+ }
1460
+ /**
1461
+ * Position each pane using tmux's absolute character coordinates × cell pixel size.
1462
+ * Also sets the xterm character grid for each pane. One pass, zero rounding.
1463
+ */
1464
+ applyPixelLayout(layoutTree) {
1465
+ var _a;
1466
+ const cell = this.getCellSize();
1467
+ if (!cell)
1468
+ return;
1469
+ const paneMap = this.windowPaneTabs.get(this.activeWindowId);
1470
+ if (!paneMap)
1471
+ return;
1472
+ // Read pane-area padding so absolute-positioned panes respect it.
1473
+ // CSS absolute positioning ignores parent padding, so we offset manually.
1474
+ const host = this.hostElement.nativeElement;
1475
+ const paneArea = host.querySelector('.pane-area');
1476
+ const padL = paneArea ? parseFloat(getComputedStyle(paneArea).paddingLeft) || 0 : 0;
1477
+ const padT = paneArea ? parseFloat(getComputedStyle(paneArea).paddingTop) || 0 : 0;
1478
+ for (const pane of (0, layoutParser_1.flattenLayout)(layoutTree)) {
1479
+ const paneTab = paneMap.get(pane.paneId);
1480
+ if (!paneTab)
1481
+ continue;
1482
+ // Set pixel position from tmux char coords
1483
+ const viewRef = (_a = this.viewRefs) === null || _a === void 0 ? void 0 : _a.get(paneTab);
1484
+ if (viewRef) {
1485
+ const el = viewRef.rootNodes[0];
1486
+ el.classList.add('child');
1487
+ el.style.left = `${padL + pane.x * cell.width}px`;
1488
+ el.style.top = `${padT + pane.y * cell.height}px`;
1489
+ el.style.width = `${pane.width * cell.width}px`;
1490
+ el.style.height = `${pane.height * cell.height}px`;
1491
+ }
1492
+ // Set xterm character grid
1493
+ if (paneTab.setTmuxGrid) {
1494
+ paneTab.setTmuxGrid(pane.width, pane.height);
1384
1495
  }
1385
1496
  }
1386
- return container.children.length > 0 ? container : null;
1387
1497
  }
1388
1498
  /**
1389
1499
  * Refresh tmux client size based purely on the container (.pane-area) size.
@@ -1429,75 +1539,27 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1429
1539
  this.controller.resizePane(0, cols, rows);
1430
1540
  }
1431
1541
  }
1432
- /**
1433
- * Apply the tmux-authoritative character grid to each mounted pane.
1434
- *
1435
- * tmux's layout string gives the exact width/height (in cells) of every
1436
- * pane. We push those directly into the corresponding xterm so display
1437
- * wrapping matches tmux exactly. xterm auto-fit is disabled for tmux panes,
1438
- * so this is the only thing that sizes them.
1439
- */
1440
- applyLayoutGrids(layoutTree) {
1441
- if (this.activeWindowId === null)
1442
- return;
1443
- const paneMap = this.windowPaneTabs.get(this.activeWindowId);
1444
- if (!paneMap)
1445
- return;
1446
- for (const pane of (0, layoutParser_1.flattenLayout)(layoutTree)) {
1447
- const paneTab = paneMap.get(pane.paneId);
1448
- if (paneTab === null || paneTab === void 0 ? void 0 : paneTab.setTmuxGrid) {
1449
- paneTab.setTmuxGrid(pane.width, pane.height);
1450
- }
1451
- }
1452
- }
1453
1542
  /**
1454
1543
  * Measure the whole-window character grid from the .pane-area container.
1455
1544
  *
1456
- * The container width includes per-pane decorations (xterm scrollbar +
1457
- * padding) and UI spanner dividers, none of which belong to the tmux
1458
- * character grid. We subtract them, divide by the real xterm cell size,
1459
- * then add tmux's 1-char dividers between panes so tmux's own grid lines up.
1545
+ * Pure pixel-to-cell conversion. Uses clientWidth/clientHeight to
1546
+ * exclude padding from the measurement pane-area padding is purely
1547
+ * cosmetic and must not affect the tmux grid calculation.
1460
1548
  */
1461
1549
  measureClientSize() {
1462
- var _a, _b;
1550
+ var _a;
1463
1551
  const host = this.hostElement.nativeElement;
1464
1552
  const paneArea = (_a = host.querySelector('.pane-area')) !== null && _a !== void 0 ? _a : host;
1465
- const rect = paneArea.getBoundingClientRect();
1466
- if (rect.width < 10 || rect.height < 10)
1553
+ const pw = paneArea.clientWidth;
1554
+ const ph = paneArea.clientHeight;
1555
+ if (pw < 10 || ph < 10)
1467
1556
  return null;
1468
1557
  const cell = this.getCellSize();
1469
1558
  if (!cell)
1470
1559
  return null;
1471
- // Determine pane/split counts for the active window.
1472
- const paneMap = this.activeWindowId !== null
1473
- ? this.windowPaneTabs.get(this.activeWindowId)
1474
- : null;
1475
- const paneCount = (_b = paneMap === null || paneMap === void 0 ? void 0 : paneMap.size) !== null && _b !== void 0 ? _b : 1;
1476
- // UI spanner pixel widths: tabby's split-tab-spanner is 10px each.
1477
- // Our custom tmux dividers are purely visual overlays with no pixel cost.
1478
- // _spanners is still populated by layoutInternal() for tabby's internal
1479
- // bookkeeping, but we no longer render split-tab-spanner elements, so
1480
- // their pixel width should not be subtracted here.
1481
- const spannerPx = 0;
1482
- // Per-pane visual padding defined in tmuxPaneTab.component.scss.
1483
- // Each pane has 4px on all sides (8px total per axis).
1484
- // For N panes in a row: total horizontal padding = N * 8px.
1485
- // For N panes in a column: total vertical padding = N * 8px.
1486
- // Approximate as all panes contributing to both axes (safe overestimate).
1487
- const panePadPerAxis = 8;
1488
- const totalPadPx = paneCount * panePadPerAxis;
1489
- const availableWidth = rect.width - spannerPx - totalPadPx;
1490
- const availableHeight = rect.height - totalPadPx;
1491
- const contentCols = Math.floor(availableWidth / cell.width);
1492
- const contentRows = Math.floor(availableHeight / cell.height);
1493
- // tmux inserts a 1-char divider between adjacent panes; add them back so
1494
- // the size we report covers content + dividers (tmux subtracts them again
1495
- // when splitting). Approximate as a horizontal split (most common case).
1496
- const numDividers = Math.max(0, paneCount - 1);
1497
- const cols = contentCols + numDividers;
1498
1560
  return {
1499
- cols: Math.max(2, cols),
1500
- rows: Math.max(1, contentRows),
1561
+ cols: Math.max(2, Math.floor(pw / cell.width)),
1562
+ rows: Math.max(1, Math.floor(ph / cell.height)),
1501
1563
  };
1502
1564
  }
1503
1565
  /**
@@ -1531,158 +1593,173 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1531
1593
  this.refreshClientSize();
1532
1594
  }, debounceMs);
1533
1595
  }
1596
+ // ─── Divider management ──────────────────────────────────────────────────
1534
1597
  /**
1535
- * Attach mousemove + mousedown handlers to .pane-area so that hovering
1536
- * near a .child's right/bottom border highlights it, and dragging starts
1537
- * a tmux resize-pane operation.
1598
+ * Generate independent divider <div> elements for adjacent pane boundaries.
1599
+ * Walks the layout tree to find sibling edges and creates draggable lines.
1538
1600
  */
1539
- attachPaneAreaBorderHandlers() {
1601
+ updateDividers(layoutTree) {
1540
1602
  const host = this.hostElement.nativeElement;
1541
1603
  const paneArea = host.querySelector('.pane-area');
1542
1604
  if (!paneArea)
1543
1605
  return;
1544
- const HIT = TmuxSessionTabComponent_1.BORDER_HIT;
1545
- // Track which child + edge is currently hovered
1546
- let hoveredChild = null;
1547
- let hoveredEdge = null;
1548
- const clearHover = () => {
1549
- if (hoveredChild) {
1550
- hoveredChild.classList.remove('border-hover-right', 'border-hover-bottom');
1606
+ this.clearDividers();
1607
+ const cell = this.getCellSize();
1608
+ if (!cell)
1609
+ return;
1610
+ this.collectDividers(layoutTree, cell, paneArea);
1611
+ }
1612
+ /**
1613
+ * Recursively collect divider lines from the layout tree.
1614
+ * For each container node, consecutive children share a boundary → divider.
1615
+ *
1616
+ * tmux layout semantics:
1617
+ * - 'vertical' ([...]): children stacked top-to-bottom → horizontal divider line
1618
+ * - 'horizontal' ({...}): children side-by-side → vertical divider line
1619
+ *
1620
+ * Same-level siblings always share the same cross-axis extent (tmux guarantees
1621
+ * this for its binary splits), so divider size is simply derived from the parent.
1622
+ *
1623
+ * For container children (non-leaf), we find the actual pane IDs at the
1624
+ * boundary using helper methods so drag-resize works at every level.
1625
+ */
1626
+ collectDividers(node, cell, paneArea) {
1627
+ if (!node.children || node.children.length < 2) {
1628
+ return;
1629
+ }
1630
+ // Read pane-area padding to offset divider positions (same as applyPixelLayout)
1631
+ const cs = getComputedStyle(paneArea);
1632
+ const padL = parseFloat(cs.paddingLeft) || 0;
1633
+ const padT = parseFloat(cs.paddingTop) || 0;
1634
+ for (let i = 0; i < node.children.length - 1; i++) {
1635
+ const left = node.children[i];
1636
+ const right = node.children[i + 1];
1637
+ if (node.type === 'horizontal') {
1638
+ // Children are side-by-side → vertical divider between left and right
1639
+ // Divider is 1 cell wide, centered at the shared boundary
1640
+ const x = padL + (left.x + left.width) * cell.width;
1641
+ const top = padT + node.y * cell.height;
1642
+ const height = node.height * cell.height;
1643
+ // Find the rightmost pane(s) in `left` and leftmost pane(s) in `right`
1644
+ const paneIdA = this.getRightmostLeafPaneId(left);
1645
+ const paneIdB = this.getLeftmostLeafPaneId(right);
1646
+ this.createDividerElement(paneArea, 'v', x, top, cell.width, height, paneIdA, paneIdB, cell);
1551
1647
  }
1552
- hoveredChild = null;
1553
- hoveredEdge = null;
1554
- paneArea.style.cursor = '';
1555
- };
1556
- const onMove = (e) => {
1557
- const areaRect = paneArea.getBoundingClientRect();
1558
- const mx = e.clientX - areaRect.left;
1559
- const my = e.clientY - areaRect.top;
1560
- // Find a .child whose right or bottom border is within HIT pixels
1561
- clearHover();
1562
- const children = paneArea.querySelectorAll('.child');
1563
- for (const child of Array.from(children)) {
1564
- const el = child;
1565
- const r = el.getBoundingClientRect();
1566
- const right = r.right - areaRect.left;
1567
- const bottom = r.bottom - areaRect.top;
1568
- const left = r.left - areaRect.left;
1569
- const top = r.top - areaRect.top;
1570
- // Right border hit: within HIT of right edge, vertically inside
1571
- if (Math.abs(mx - right) <= HIT && my >= top && my <= bottom) {
1572
- el.classList.add('border-hover-right');
1573
- paneArea.style.cursor = 'col-resize';
1574
- hoveredChild = el;
1575
- hoveredEdge = 'right';
1576
- break;
1577
- }
1578
- // Bottom border hit: within HIT of bottom edge, horizontally inside
1579
- if (Math.abs(my - bottom) <= HIT && mx >= left && mx <= right) {
1580
- el.classList.add('border-hover-bottom');
1581
- paneArea.style.cursor = 'row-resize';
1582
- hoveredChild = el;
1583
- hoveredEdge = 'bottom';
1584
- break;
1585
- }
1648
+ else {
1649
+ // Children are stacked top-to-bottom → horizontal divider between top and bottom
1650
+ // Divider is 1 cell tall, centered at the shared boundary
1651
+ const y = padT + (left.y + left.height) * cell.height;
1652
+ const leftPx = padL + node.x * cell.width;
1653
+ const width = node.width * cell.width;
1654
+ // Find the bottommost pane(s) in `left` and topmost pane(s) in `right`
1655
+ const paneIdA = this.getBottommostLeafPaneId(left);
1656
+ const paneIdB = this.getTopmostLeafPaneId(right);
1657
+ this.createDividerElement(paneArea, 'h', leftPx, y, width, cell.height, paneIdA, paneIdB, cell);
1586
1658
  }
1587
- };
1588
- const onDown = (e) => {
1589
- if (!hoveredChild || !hoveredEdge || !this.controller)
1590
- return;
1591
- e.preventDefault();
1592
- e.stopPropagation();
1593
- const edge = hoveredEdge;
1594
- const cell = this.getCellSize();
1595
- if (!cell)
1596
- return;
1597
- const startX = e.clientX;
1598
- const startY = e.clientY;
1599
- // Find the pane ID for this .child element
1600
- const resizeTarget = hoveredChild;
1601
- const paneId = this.findPaneIdForElement(resizeTarget);
1602
- if (paneId === null)
1603
- return;
1604
- // Track last sent delta to send incremental resize commands
1605
- let lastSentCols = 0;
1606
- let lastSentRows = 0;
1607
- const onDragMove = (de) => {
1608
- document.body.style.cursor = edge === 'right' ? 'col-resize' : 'row-resize';
1609
- if (edge === 'right') {
1610
- const deltaCols = Math.round((de.clientX - startX) / cell.width);
1611
- if (deltaCols !== lastSentCols) {
1612
- const diff = deltaCols - lastSentCols;
1613
- const flag = diff > 0 ? '-R' : '-L';
1614
- this.controller.gateway.sendCommand(`resize-pane ${flag} -t %${paneId} ${Math.abs(diff)}`);
1615
- lastSentCols = deltaCols;
1616
- }
1617
- }
1618
- else {
1619
- const deltaRows = Math.round((de.clientY - startY) / cell.height);
1620
- if (deltaRows !== lastSentRows) {
1621
- const diff = deltaRows - lastSentRows;
1622
- const flag = diff > 0 ? '-D' : '-U';
1623
- this.controller.gateway.sendCommand(`resize-pane ${flag} -t %${paneId} ${Math.abs(diff)}`);
1624
- lastSentRows = deltaRows;
1625
- }
1626
- }
1627
- };
1628
- const onDragUp = () => {
1629
- document.removeEventListener('mousemove', onDragMove);
1630
- document.removeEventListener('mouseup', onDragUp);
1631
- document.body.style.cursor = '';
1632
- clearHover();
1633
- };
1634
- document.addEventListener('mousemove', onDragMove);
1635
- document.addEventListener('mouseup', onDragUp);
1636
- };
1637
- const onLeave = () => clearHover();
1638
- this._paneAreaMouseMoveHandler = onMove;
1639
- this._paneAreaMouseDownHandler = onDown;
1640
- paneArea.addEventListener('mousemove', onMove);
1641
- paneArea.addEventListener('mousedown', onDown);
1642
- paneArea.addEventListener('mouseleave', onLeave);
1643
- }
1644
- detachPaneAreaBorderHandlers() {
1645
- const host = this.hostElement.nativeElement;
1646
- const paneArea = host.querySelector('.pane-area');
1647
- if (!paneArea)
1648
- return;
1649
- if (this._paneAreaMouseMoveHandler) {
1650
- paneArea.removeEventListener('mousemove', this._paneAreaMouseMoveHandler);
1651
- this._paneAreaMouseMoveHandler = null;
1652
1659
  }
1653
- if (this._paneAreaMouseDownHandler) {
1654
- paneArea.removeEventListener('mousedown', this._paneAreaMouseDownHandler);
1655
- this._paneAreaMouseDownHandler = null;
1660
+ // Recurse into children
1661
+ for (const child of node.children) {
1662
+ this.collectDividers(child, cell, paneArea);
1656
1663
  }
1657
1664
  }
1665
+ /** Find the rightmost leaf pane in a layout subtree (for vertical divider) */
1666
+ getRightmostLeafPaneId(node) {
1667
+ var _a;
1668
+ if (node.type === 'pane')
1669
+ return node.paneId;
1670
+ if (!((_a = node.children) === null || _a === void 0 ? void 0 : _a.length))
1671
+ return undefined;
1672
+ return this.getRightmostLeafPaneId(node.children[node.children.length - 1]);
1673
+ }
1674
+ /** Find the leftmost leaf pane in a layout subtree (for vertical divider) */
1675
+ getLeftmostLeafPaneId(node) {
1676
+ var _a;
1677
+ if (node.type === 'pane')
1678
+ return node.paneId;
1679
+ if (!((_a = node.children) === null || _a === void 0 ? void 0 : _a.length))
1680
+ return undefined;
1681
+ return this.getLeftmostLeafPaneId(node.children[0]);
1682
+ }
1683
+ /** Find the bottommost leaf pane in a layout subtree (for horizontal divider) */
1684
+ getBottommostLeafPaneId(node) {
1685
+ var _a;
1686
+ if (node.type === 'pane')
1687
+ return node.paneId;
1688
+ if (!((_a = node.children) === null || _a === void 0 ? void 0 : _a.length))
1689
+ return undefined;
1690
+ return this.getBottommostLeafPaneId(node.children[node.children.length - 1]);
1691
+ }
1692
+ /** Find the topmost leaf pane in a layout subtree (for horizontal divider) */
1693
+ getTopmostLeafPaneId(node) {
1694
+ var _a;
1695
+ if (node.type === 'pane')
1696
+ return node.paneId;
1697
+ if (!((_a = node.children) === null || _a === void 0 ? void 0 : _a.length))
1698
+ return undefined;
1699
+ return this.getTopmostLeafPaneId(node.children[0]);
1700
+ }
1658
1701
  /**
1659
- * Map a .child DOM element back to the tmux pane ID it represents.
1702
+ * Create a single divider DOM element with drag-to-resize behavior.
1660
1703
  */
1661
- findPaneIdForElement(el) {
1662
- var _a, _b, _c;
1663
- if (this.activeWindowId === null)
1664
- return null;
1665
- const paneMap = this.windowPaneTabs.get(this.activeWindowId);
1666
- if (!paneMap)
1667
- return null;
1668
- for (const [paneId, paneTab] of paneMap) {
1669
- const tabEl = (_b = (_a = paneTab.hostElement) === null || _a === void 0 ? void 0 : _a.nativeElement) !== null && _b !== void 0 ? _b : (_c = paneTab.element) === null || _c === void 0 ? void 0 : _c.nativeElement;
1670
- if (tabEl && (tabEl === el || tabEl.contains(el) || el.contains(tabEl))) {
1671
- return paneId;
1672
- }
1704
+ createDividerElement(paneArea, orientation, x, y, w, h, paneIdA, paneIdB, cell) {
1705
+ const div = document.createElement('div');
1706
+ div.className = `tmux-divider ${orientation}`;
1707
+ div.style.left = `${x}px`;
1708
+ div.style.top = `${y}px`;
1709
+ div.style.width = `${w}px`;
1710
+ div.style.height = `${h}px`;
1711
+ // Divider is already 1 cell wide/tall — natural hit target matches tmux
1712
+ if (paneIdA !== undefined && paneIdB !== undefined) {
1713
+ const onDown = (e) => {
1714
+ e.preventDefault();
1715
+ e.stopPropagation();
1716
+ const startX = e.clientX;
1717
+ const startY = e.clientY;
1718
+ let lastSentCols = 0;
1719
+ let lastSentRows = 0;
1720
+ const onMove = (de) => {
1721
+ var _a, _b;
1722
+ document.body.style.cursor = orientation === 'v' ? 'col-resize' : 'row-resize';
1723
+ if (orientation === 'v') {
1724
+ const deltaCols = Math.round((de.clientX - startX) / cell.width);
1725
+ if (deltaCols !== lastSentCols) {
1726
+ const diff = deltaCols - lastSentCols;
1727
+ const flag = diff > 0 ? '-R' : '-L';
1728
+ (_a = this.controller) === null || _a === void 0 ? void 0 : _a.gateway.sendCommand(`resize-pane ${flag} -t %${paneIdA} ${Math.abs(diff)}`);
1729
+ lastSentCols = deltaCols;
1730
+ }
1731
+ }
1732
+ else {
1733
+ const deltaRows = Math.round((de.clientY - startY) / cell.height);
1734
+ if (deltaRows !== lastSentRows) {
1735
+ const diff = deltaRows - lastSentRows;
1736
+ const flag = diff > 0 ? '-D' : '-U';
1737
+ (_b = this.controller) === null || _b === void 0 ? void 0 : _b.gateway.sendCommand(`resize-pane ${flag} -t %${paneIdA} ${Math.abs(diff)}`);
1738
+ lastSentRows = deltaRows;
1739
+ }
1740
+ }
1741
+ };
1742
+ const onUp = () => {
1743
+ document.removeEventListener('mousemove', onMove);
1744
+ document.removeEventListener('mouseup', onUp);
1745
+ document.body.style.cursor = '';
1746
+ };
1747
+ document.addEventListener('mousemove', onMove);
1748
+ document.addEventListener('mouseup', onUp);
1749
+ };
1750
+ div.addEventListener('mousedown', onDown);
1673
1751
  }
1674
- return null;
1752
+ paneArea.appendChild(div);
1753
+ this._dividerElements.push(div);
1675
1754
  }
1676
- // ─── (legacy divider stubs removed) ─────────────────────────────────────
1677
1755
  /**
1678
- * Override onSpannerAdjusted to notify tmux of layout change.
1679
- * When the user drags a spanner (split divider), the pane containers
1680
- * resize and xterm.js auto-fits. We need to tell tmux the new client size
1681
- * so it can recalculate its layout accordingly.
1756
+ * Remove all divider elements from the DOM.
1682
1757
  */
1683
- onSpannerAdjusted(spanner) {
1684
- super.onSpannerAdjusted(spanner);
1685
- this.scheduleRefreshClientSize();
1758
+ clearDividers() {
1759
+ for (const el of this._dividerElements) {
1760
+ el.remove();
1761
+ }
1762
+ this._dividerElements = [];
1686
1763
  }
1687
1764
  // --- UI Event Handlers ---
1688
1765
  onDisconnect() {
@@ -1720,7 +1797,7 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1720
1797
  clearTimeout(this._resizeTimer);
1721
1798
  this._resizeTimer = null;
1722
1799
  }
1723
- this.detachPaneAreaBorderHandlers();
1800
+ this.clearDividers();
1724
1801
  super.ngOnDestroy();
1725
1802
  }
1726
1803
  async canClose() {
@@ -1743,9 +1820,6 @@ let TmuxSessionTabComponent = TmuxSessionTabComponent_1 = class TmuxSessionTabCo
1743
1820
  return null;
1744
1821
  }
1745
1822
  };
1746
- // ─── Border-based pane separator ────────────────────────────────────────
1747
- /** Pixels from the right/bottom edge of a .child that counts as "on border" */
1748
- TmuxSessionTabComponent.BORDER_HIT = 10;
1749
1823
  TmuxSessionTabComponent.ctorParameters = () => [
1750
1824
  { type: core_1.Injector },
1751
1825
  { type: tmux_service_1.TmuxService },
@@ -1759,7 +1833,7 @@ TmuxSessionTabComponent.propDecorators = {
1759
1833
  profile: [{ type: core_1.Input }],
1760
1834
  existingController: [{ type: core_1.Input }]
1761
1835
  };
1762
- TmuxSessionTabComponent = TmuxSessionTabComponent_1 = __decorate([
1836
+ TmuxSessionTabComponent = __decorate([
1763
1837
  (0, core_1.Component)({
1764
1838
  selector: 'tmux-session-tab',
1765
1839
  host: {
@@ -1778,7 +1852,7 @@ TmuxSessionTabComponent = TmuxSessionTabComponent_1 = __decorate([
1778
1852
  (createWindow)="onCreateWindow()"
1779
1853
  ></tmux-window-bar>
1780
1854
  `,
1781
- styles: ["\n :host {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n }\n .pane-area {\n flex: 1 1 0;\n position: relative;\n min-height: 0;\n }\n /* SplitTab.layoutInternal() positions .child with inline left/top/width/height %.\n border-right + border-bottom render the tmux pane separator line.\n box-sizing: border-box keeps the border inside the layout box so\n xterm content is not displaced.\n The border area also serves as the resize drag handle (see onPaneAreaMouseDown). */\n ::ng-deep .pane-area > .child {\n position: absolute;\n transition: 0.125s all;\n opacity: .75;\n box-sizing: border-box;\n border-right: 1px solid rgba(128,128,128,0.3);\n border-bottom: 1px solid rgba(128,128,128,0.3);\n }\n ::ng-deep .pane-area > .child.focused {\n opacity: 1;\n }\n /*\n * Transparent hit-target overlays at the right/bottom edges.\n * xterm renders a scrollbar (~14px wide) that intercepts mouse events,\n * preventing the pane-area mousemove handler from detecting border\n * proximity. These ::after pseudo-elements sit above the scrollbar\n * (z-index: 10) and capture events for resize dragging.\n */\n ::ng-deep .pane-area > .child::after {\n content: '';\n position: absolute;\n top: 0;\n right: 0;\n width: 10px;\n height: 100%;\n z-index: 10;\n pointer-events: auto;\n cursor: col-resize;\n }\n ::ng-deep .pane-area > .child::before {\n content: '';\n position: absolute;\n bottom: 0;\n left: 0;\n width: 100%;\n height: 10px;\n z-index: 10;\n pointer-events: auto;\n cursor: row-resize;\n }\n /* Highlight the border when hovering near the right/bottom edge */\n ::ng-deep .pane-area > .child.border-hover-right {\n border-right-color: rgba(128,128,128,0.75);\n }\n ::ng-deep .pane-area > .child.border-hover-bottom {\n border-bottom-color: rgba(128,128,128,0.75);\n }\n tmux-window-bar {\n flex: 0 0 auto;\n position: relative;\n z-index: 10;\n }\n "]
1855
+ styles: ["\n :host {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n }\n .pane-area {\n flex: 1 1 0;\n position: relative;\n min-height: 0;\n padding: 4px;\n box-sizing: border-box;\n }\n /* Pane containers: pixel-absolute positioned by applyPixelLayout().\n No border, no padding \u2014 the xterm canvas fills the entire box. */\n ::ng-deep .pane-area > .child {\n position: absolute;\n box-sizing: border-box;\n opacity: .75;\n transition: opacity 0.125s;\n }\n ::ng-deep .pane-area > .child.focused {\n opacity: 1;\n }\n /* Independent divider elements for pane boundaries + resize dragging.\n Width/height is set inline to 1 cell to match tmux's 1-char separator.\n The visible line is a 1px ::after pseudo-element centered in the hit area. */\n ::ng-deep .tmux-divider {\n position: absolute;\n z-index: 5;\n display: flex;\n align-items: center;\n justify-content: center;\n }\n ::ng-deep .tmux-divider::after {\n content: '';\n background: rgba(128,128,128,0.3);\n transition: background 0.15s;\n }\n ::ng-deep .tmux-divider:hover::after {\n background: rgba(128,128,128,0.75);\n }\n ::ng-deep .tmux-divider.v { /* vertical divider: left-right split */\n cursor: col-resize;\n }\n ::ng-deep .tmux-divider.v::after {\n width: 1px;\n height: 100%;\n }\n ::ng-deep .tmux-divider.h { /* horizontal divider: top-bottom split */\n cursor: row-resize;\n }\n ::ng-deep .tmux-divider.h::after {\n height: 1px;\n width: 100%;\n }\n tmux-window-bar {\n flex: 0 0 auto;\n position: relative;\n z-index: 10;\n }\n "]
1782
1856
  }),
1783
1857
  __metadata("design:paramtypes", [core_1.Injector,
1784
1858
  tmux_service_1.TmuxService,
@@ -1839,6 +1913,11 @@ let TmuxWindowBarComponent = class TmuxWindowBarComponent {
1839
1913
  case 'initialized':
1840
1914
  this.refreshWindows();
1841
1915
  break;
1916
+ case 'layout-change':
1917
+ // Layout changes may affect pane count display
1918
+ // (e.g. zoom shows fewer panes in layout, but real count is unchanged)
1919
+ this.refreshWindows();
1920
+ break;
1842
1921
  }
1843
1922
  });
1844
1923
  }
@@ -3280,7 +3359,20 @@ class TmuxController {
3280
3359
  this.gateway.layoutChange$.subscribe(({ windowId, layout, visibleLayout, zoomed }) => {
3281
3360
  const state = this.windowStates.get(windowId);
3282
3361
  if (state) {
3362
+ // tmux %layout-change semantics:
3363
+ // layout = real multi-pane layout (all panes, actual sizes)
3364
+ // visibleLayout = what tmux displays (zoomed single pane when zoomed)
3283
3365
  state.layout = layout;
3366
+ if (zoomed && visibleLayout) {
3367
+ // Extract zoomed pane ID from visibleLayout (the single pane filling window)
3368
+ const m = /\d+x\d+,\d+,\d+,(\d+)/.exec(visibleLayout);
3369
+ state.zoomedPaneId = m ? parseInt(m[1]) : undefined;
3370
+ state.visibleLayout = visibleLayout;
3371
+ }
3372
+ else {
3373
+ state.zoomedPaneId = undefined;
3374
+ state.visibleLayout = undefined;
3375
+ }
3284
3376
  }
3285
3377
  // Discover new panes from the layout string, then emit layout-change
3286
3378
  this.discoverPanesFromLayout(windowId, layout, visibleLayout, zoomed);
@@ -3292,6 +3384,12 @@ class TmuxController {
3292
3384
  this.activeWindowId = windowId;
3293
3385
  this.events.next({ type: 'active-window-changed', windowId });
3294
3386
  });
3387
+ // Handle pane focus changes (e.g. after pane close, tmux auto-focuses
3388
+ // the next pane and sends %window-pane-changed).
3389
+ this.gateway.paneChanged$.subscribe(({ windowId, paneId }) => {
3390
+ this.log.info(`Active pane changed to %${paneId} in window @${windowId}`);
3391
+ this.events.next({ type: 'active-pane-changed', paneId, windowId });
3392
+ });
3295
3393
  this.gateway.exit$.subscribe(reason => {
3296
3394
  this.attached = false;
3297
3395
  this.events.next({ type: 'exit', data: { reason } });
@@ -3436,15 +3534,18 @@ class TmuxController {
3436
3534
  * layout-change so syncLayout() runs after pane tabs exist.
3437
3535
  */
3438
3536
  async discoverPanesFromLayout(windowId, layout, visibleLayout, zoomed) {
3439
- // Extract pane IDs from the layout string
3440
- // Pane IDs appear as trailing numbers in layout leaf nodes like "80x24,0,0,3"
3441
- // Match pattern: dimension,position,paneId (paneId is the last number after the last comma)
3537
+ // Extract pane IDs from layout strings.
3538
+ // When zoomed, the layout only contains the zoomed pane also scan
3539
+ // visibleLayout (the real multi-pane layout) so all panes are discovered.
3442
3540
  const paneIdSet = new Set();
3443
- // Match all occurrences of ,\d+ at the end of leaf node specs
3444
3541
  const leafPattern = /\d+x\d+,\d+,\d+,(\d+)/g;
3445
3542
  let m;
3446
- while ((m = leafPattern.exec(layout)) !== null) {
3447
- paneIdSet.add(parseInt(m[1]));
3543
+ const layoutsToScan = zoomed && visibleLayout ? [layout, visibleLayout] : [layout];
3544
+ for (const ls of layoutsToScan) {
3545
+ leafPattern.lastIndex = 0;
3546
+ while ((m = leafPattern.exec(ls)) !== null) {
3547
+ paneIdSet.add(parseInt(m[1]));
3548
+ }
3448
3549
  }
3449
3550
  if (paneIdSet.size === 0)
3450
3551
  return;
@@ -3459,6 +3560,30 @@ class TmuxController {
3459
3560
  newPaneIds.push({ paneId, windowId });
3460
3561
  }
3461
3562
  }
3563
+ // Remove panes no longer in the layout (only when not zoomed).
3564
+ // When zoomed, the real layout is in visibleLayout, and pane-close
3565
+ // events should handle cleanup of actually closed panes.
3566
+ if (!zoomed && windowState) {
3567
+ const closedPaneIds = [];
3568
+ for (const paneId of windowState.panes) {
3569
+ if (!paneIdSet.has(paneId)) {
3570
+ closedPaneIds.push(paneId);
3571
+ }
3572
+ }
3573
+ for (const paneId of closedPaneIds) {
3574
+ windowState.panes.delete(paneId);
3575
+ this.knownPanes.delete(paneId);
3576
+ // Clean up pane session
3577
+ const session = this.paneSessions.get(paneId);
3578
+ if (session) {
3579
+ session.destroy();
3580
+ this.paneSessions.delete(paneId);
3581
+ }
3582
+ this.pendingPaneOutput.delete(paneId);
3583
+ this.log.info(`Removed closed pane %${paneId} from window @${windowId} (not in layout)`);
3584
+ this.events.next({ type: 'pane-close', paneId, windowId });
3585
+ }
3586
+ }
3462
3587
  if (newPaneIds.length > 0) {
3463
3588
  this.log.info(`Discovered ${newPaneIds.length} new pane(s) from layout-change for window @${windowId}`);
3464
3589
  // Capture history + state for new panes (same as discoverWindowsAndPanes Step 3)
@@ -3519,7 +3644,15 @@ class TmuxController {
3519
3644
  registerPane(paneId, session) {
3520
3645
  this.paneSessions.set(paneId, session);
3521
3646
  this.knownPanes.add(paneId);
3522
- // Flush any buffered output that arrived before registration
3647
+ // If a snapshot exists, the pending output is redundant the snapshot
3648
+ // already contains the same content (and more). Discard it to avoid
3649
+ // writing the prompt/scrollback twice (once from buffered %output,
3650
+ // once from capture-pane history in restorePaneHistory).
3651
+ if (this.pendingSnapshots.has(paneId)) {
3652
+ this.pendingPaneOutput.delete(paneId);
3653
+ return;
3654
+ }
3655
+ // No snapshot (shouldn't happen normally) — flush buffered output
3523
3656
  const buffered = this.pendingPaneOutput.get(paneId);
3524
3657
  if (buffered) {
3525
3658
  for (const data of buffered) {
@@ -3809,6 +3942,13 @@ class TmuxController {
3809
3942
  async killPane(paneId) {
3810
3943
  await this.gateway.sendCommand(`kill-pane -t %${paneId}`, gateway_1.TMUX_COMMAND_TOLERATE_ERRORS);
3811
3944
  }
3945
+ /**
3946
+ * Toggle zoom on a pane (tmux prefix+z equivalent).
3947
+ * When zoomed, the pane fills the entire window; other panes are hidden.
3948
+ */
3949
+ async zoomPane(paneId) {
3950
+ await this.gateway.sendCommand(`resize-pane -Z -t %${paneId}`, gateway_1.TMUX_COMMAND_TOLERATE_ERRORS);
3951
+ }
3812
3952
  // --- Window Operations ---
3813
3953
  async createWindow() {
3814
3954
  try {