@zgeoff/atc 1.0.0 → 1.0.2

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": "@zgeoff/atc",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Terminal control tower for Claude Code sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -0,0 +1,26 @@
1
+ export interface BoxExtent {
2
+ readonly top: number;
3
+ readonly height: number;
4
+ }
5
+
6
+ /**
7
+ * The screen rows the previous box covered that the next one does not. A
8
+ * centered box shifts and shrinks as its row count changes, so a redraw
9
+ * erases these rows first — otherwise the old box's edges linger as ghost
10
+ * lines above and below the new one.
11
+ */
12
+ export function planVacatedRows(prev: BoxExtent | null, next: BoxExtent): number[] {
13
+ if (prev === null) {
14
+ return [];
15
+ }
16
+
17
+ const vacated: number[] = [];
18
+
19
+ for (let row = prev.top; row < prev.top + prev.height; row++) {
20
+ if (row < next.top || row >= next.top + next.height) {
21
+ vacated.push(row);
22
+ }
23
+ }
24
+
25
+ return vacated;
26
+ }
package/src/client/ui.ts CHANGED
@@ -4,6 +4,8 @@ import type { SessionState } from '../daemon/sessions';
4
4
  import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
5
5
  import { formatDir } from './dirs';
6
6
  import { formatOverlayAgentMark } from './format-overlay-agent-mark';
7
+ import { planVacatedRows } from './plan-vacated-rows';
8
+ import type { BoxExtent } from './plan-vacated-rows';
7
9
 
8
10
  // The slice of a session the drawing layer needs; satisfied by both the
9
11
  // daemon's sessions and the wire descriptors a client mirrors.
@@ -119,12 +121,24 @@ interface Row {
119
121
  readonly width: number;
120
122
  }
121
123
 
124
+ // The extent of the last box drawn, so the next draw can erase the rows a
125
+ // shrinking or shifting box leaves behind. Erasing an already-blank row is a
126
+ // no-op, so a stale extent after a full clear is harmless.
127
+ let lastBoxExtent: BoxExtent | null = null;
128
+
122
129
  function drawBox(rowsList: readonly Row[]) {
123
130
  const boxWidth = Math.max(...rowsList.map((r) => r.width));
124
131
  const top = Math.max(1, Math.floor((rows() - 1 - rowsList.length) / 2));
125
132
  const left = Math.max(1, Math.floor((cols() - boxWidth) / 2));
133
+ const extent: BoxExtent = { top, height: rowsList.length };
126
134
  let buf = ansi.hideCursor;
127
135
 
136
+ for (const row of planVacatedRows(lastBoxExtent, extent)) {
137
+ buf += `${ansi.moveTo(row, 1)}${ESC}[2K`;
138
+ }
139
+
140
+ lastBoxExtent = extent;
141
+
128
142
  for (const [i, r] of rowsList.entries()) {
129
143
  buf += ansi.moveTo(top + i, left) + r.styled;
130
144
  }
@@ -481,7 +481,12 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
481
481
  attachments.attach(sessionID, client, dims);
482
482
  mgr.attach(sessionID);
483
483
 
484
- scheduleResize(sessionID);
484
+ // The PTY and screen model take the new effective size before the
485
+ // replay renders, so the repaint arrives at the attaching terminal's
486
+ // own dims instead of the previous attachment's — a replay at stale
487
+ // dims paints vertically misaligned and the agent's resize repaint
488
+ // only redraws its live region, never the rows above it.
489
+ applyEffectiveDims(sessionID);
485
490
  void sendReplay(sessionID, client);
486
491
 
487
492
  return 'ok';
@@ -84,10 +84,19 @@ export class ScreenModel {
84
84
  });
85
85
  }
86
86
 
87
- // The terminal parses asynchronously; the replay waits for every recorded
88
- // byte to land in the buffer before serializing.
87
+ // The terminal parses asynchronously, and bytes recorded while a flush is
88
+ // awaited re-arm it — so the replay drains until no newer write is
89
+ // pending. Serializing earlier would omit bytes already streamed live to
90
+ // clients, and the replay's leading clear would erase them from the
91
+ // client's screen for good.
89
92
  async renderReplay(): Promise<string> {
90
- await this.flushed;
93
+ let pending: Promise<void>;
94
+
95
+ do {
96
+ pending = this.flushed;
97
+
98
+ await pending;
99
+ } while (pending !== this.flushed);
91
100
 
92
101
  return RESET_INPUT_MODES + this.serializer.serialize() + this.renderInputModes();
93
102
  }