iterm-dash 0.1.1__py3-none-any.whl

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.
iterm_dash/__init__.py ADDED
File without changes
iterm_dash/backend.py ADDED
@@ -0,0 +1,395 @@
1
+ """Live iTerm2 backend: discovery, throttled previews, native status and focus.
2
+
3
+ Read-only toward the user's sessions. The only calls that change anything are
4
+ the two focus calls (``session.async_activate`` and ``app.async_activate``).
5
+ Logs carry ids, counts and timings only; exceptions are logged by type name,
6
+ because their messages can carry screen text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import time
14
+ from collections.abc import Awaitable, Callable
15
+ from dataclasses import dataclass
16
+
17
+ import iterm2
18
+ from iterm2 import api_pb2
19
+ from iterm2 import rpc as iterm2_rpc
20
+ from iterm2.screen import LineContents
21
+
22
+ from iterm_dash.hub import ScreenFrame
23
+ from iterm_dash.palette import DEFAULT_PALETTE, Palette
24
+ from iterm_dash.pump import LatestOnlyPump
25
+ from iterm_dash.registry import SessionInfo, list_sessions
26
+ from iterm_dash.render import Row, line_row
27
+ from iterm_dash.status import StatusTracker, StatusView, classify
28
+
29
+ _log = logging.getLogger(__name__)
30
+ _RETRY_SECONDS = 1.0
31
+ _DEBOUNCE_SECONDS = 0.05
32
+ _DISCOVERY_INTERVAL = 10.0 # a periodic pass catches title changes and missed layout events
33
+ _BUFFER_OK = api_pb2.GetBufferResponse.Status.Value("OK")
34
+ # Passes that may follow a change iTerm2 didn't announce to the App, so they reload its hierarchy first.
35
+ # Discovery triggers: start, exclude, layout, new, terminated, status-flip, size-change, periodic.
36
+ _REFRESH_TRIGGERS = frozenset({"size-change", "periodic"})
37
+
38
+
39
+ @dataclass
40
+ class _LineCache:
41
+ """Per-session render cache: the last frame's rows keyed by each line's raw proto bytes.
42
+
43
+ Keyed by content, not position, so a scrolled screen reuses the rows that moved.
44
+ Rows may be shared between lines and frames; they are never mutated downstream.
45
+ """
46
+
47
+ palette: Palette
48
+ rows: dict[bytes, Row]
49
+
50
+
51
+ async def _get_buffer(session) -> api_pb2.GetBufferResponse:
52
+ """The raw styled screen proto, fetched the way Session.async_get_screen_contents does."""
53
+ result = await iterm2_rpc.async_get_screen_contents(session.connection, session.session_id, None, True)
54
+ response = result.get_buffer_response
55
+ if response.status != _BUFFER_OK:
56
+ raise iterm2.RPCException(api_pb2.GetBufferResponse.Status.Name(response.status))
57
+ return response
58
+
59
+
60
+ async def read_status(connection, session_id: str) -> dict | None:
61
+ """iTerm2's native status dict for a session, or None when the read itself failed.
62
+
63
+ A failed read (e.g. a timeout) carries no information. A session that isn't running
64
+ Claude still reads successfully: its dict has ``status`` None, or it is ``{}`` when
65
+ iTerm2 answered with something other than a dict.
66
+ """
67
+ try:
68
+ result = await iterm2.async_invoke_function(
69
+ connection, f'iterm2.get_session_status(session_id: "{session_id}")', timeout=2
70
+ )
71
+ except Exception as exc:
72
+ _log.debug("status read failed for %s: %s", session_id, type(exc).__name__)
73
+ return None
74
+ return result if isinstance(result, dict) else {}
75
+
76
+
77
+ def has_claude_status(raw: dict | None) -> bool:
78
+ return raw is not None and raw.get("status") is not None
79
+
80
+
81
+ async def _keep_running(name: str, body: Callable[[], Awaitable[None]]) -> None:
82
+ """Run ``body`` and run it again shortly after it fails, until cancelled."""
83
+ while True:
84
+ try:
85
+ await body()
86
+ except asyncio.CancelledError:
87
+ raise
88
+ except Exception as exc:
89
+ task = asyncio.current_task()
90
+ if task is not None and task.cancelling():
91
+ # A cleanup step (e.g. unsubscribing on a dead socket) replaced
92
+ # our cancellation with its own error; stay cancelled.
93
+ raise asyncio.CancelledError from None
94
+ _log.warning("%s failed: %s", name, type(exc).__name__)
95
+ await asyncio.sleep(_RETRY_SECONDS)
96
+
97
+
98
+ class ItermBackend:
99
+ def __init__(
100
+ self,
101
+ connection,
102
+ hub,
103
+ *,
104
+ preview_interval: float = 0.25,
105
+ status_interval: float = 1.0,
106
+ discovery_interval: float = _DISCOVERY_INTERVAL,
107
+ app=None,
108
+ ) -> None:
109
+ self._conn = connection
110
+ self._hub = hub
111
+ self._preview_interval = preview_interval
112
+ self._status_interval = status_interval
113
+ self._discovery_interval = discovery_interval
114
+ self._app = app
115
+ self._excluded: set[str] = set()
116
+ self._infos: dict[str, SessionInfo] = {} # in cell order: first seen first
117
+ self._watchers: dict[str, asyncio.Task] = {}
118
+ self._pumps: dict[str, LatestOnlyPump] = {}
119
+ self._palettes: dict[str, Palette] = {}
120
+ self._palette_gen = 0
121
+ self._line_cache: dict[str, _LineCache] = {}
122
+ self._status_locks: dict[str, asyncio.Lock] = {}
123
+ self._claude_seen: dict[str, bool] = {}
124
+ self._last_view: dict[str, StatusView] = {} # the view last pushed to the hub
125
+ self._size_flagged: dict[str, tuple[int, int]] = {} # (buffer rows, grid rows) already rediscovered
126
+ self._tracker = StatusTracker()
127
+ self._rediscover = asyncio.Event()
128
+ self._triggers: set[str] = set() # why the next discovery pass runs
129
+ self._children: set[asyncio.Task] = set()
130
+
131
+ # -- public API -----------------------------------------------------------
132
+ def exclude(self, session_id: str) -> None:
133
+ """Never watch this session (the dashboard's own tab)."""
134
+ self._excluded.add(session_id)
135
+ self._request_discovery("exclude")
136
+
137
+ async def run(self) -> None:
138
+ """Watch iTerm2 until the connection closes, then stop every child task."""
139
+ closed = asyncio.create_task(self._conn.websocket.wait_closed())
140
+ main = asyncio.create_task(self._main())
141
+ try:
142
+ done, _ = await asyncio.wait({main, closed}, return_when=asyncio.FIRST_COMPLETED)
143
+ if closed in done:
144
+ _log.info("iTerm2 connection closed; backend stopping")
145
+ elif not main.cancelled() and main.exception() is not None:
146
+ _log.error("backend failed: %s", type(main.exception()).__name__)
147
+ finally:
148
+ tasks = [main, closed, *self._children, *self._watchers.values()]
149
+ for t in tasks:
150
+ t.cancel()
151
+ await asyncio.gather(*tasks, return_exceptions=True)
152
+ self._children.clear()
153
+ self._watchers.clear()
154
+ self._pumps.clear()
155
+ _log.info("backend stopped")
156
+
157
+ async def focus(self, session_id: str) -> None:
158
+ app = self._app
159
+ session = app.get_session_by_id(session_id) if app is not None else None
160
+ if session is None:
161
+ _log.info("focus: session %s not found", session_id)
162
+ return
163
+ start = time.perf_counter()
164
+ await session.async_activate(select_tab=True, order_window_front=True)
165
+ await app.async_activate(raise_all_windows=False, ignoring_other_apps=True)
166
+ _log.info("focus %s took %.0f ms", session_id, (time.perf_counter() - start) * 1000)
167
+
168
+ async def fetch_frame(self, session) -> ScreenFrame | None:
169
+ """A styled snapshot of the session, or None when it can't be read (e.g. gone)."""
170
+ sid = session.session_id
171
+ try:
172
+ buffer = await _get_buffer(session)
173
+ except Exception as exc:
174
+ _log.debug("screen fetch failed for %s: %s", sid, type(exc).__name__)
175
+ return None
176
+ ts = time.time()
177
+ size = session.grid_size
178
+ if size is None:
179
+ return None
180
+ # grid_size is only refreshed by layout events; the buffer always has the screen's real rows.
181
+ rows = len(buffer.contents)
182
+ self._check_size(sid, rows, size.height)
183
+ palette = await self._palette_for(session)
184
+ lines = self._render(sid, buffer, palette)
185
+ return ScreenFrame(size.width, rows, palette.fg, palette.bg, lines, ts)
186
+
187
+ def _check_size(self, sid: str, rows: int, grid_rows: int) -> None:
188
+ """Rediscover (with a refreshed App) once per new mismatch between the buffer and grid_size."""
189
+ if rows == grid_rows:
190
+ self._size_flagged.pop(sid, None)
191
+ return
192
+ if self._size_flagged.get(sid) != (rows, grid_rows):
193
+ self._size_flagged[sid] = (rows, grid_rows)
194
+ self._request_discovery("size-change")
195
+
196
+ def _render(self, sid: str, buffer: api_pb2.GetBufferResponse, palette: Palette) -> list[Row]:
197
+ """Rows for the buffer, rendering only lines whose content wasn't on the last frame."""
198
+ cache = self._line_cache.get(sid)
199
+ known = cache.rows if cache is not None and cache.palette == palette else {}
200
+ current: dict[bytes, Row] = {}
201
+ lines: list[Row] = []
202
+ for line in buffer.contents:
203
+ raw = line.SerializeToString()
204
+ row = current.get(raw)
205
+ if row is None:
206
+ row = known.get(raw)
207
+ if row is None:
208
+ row = line_row(LineContents(line), palette)
209
+ current[raw] = row
210
+ lines.append(row)
211
+ # Only this frame's lines are kept, so the cache stays bounded to one screen.
212
+ self._line_cache[sid] = _LineCache(palette, current)
213
+ return lines
214
+
215
+ # -- tasks ------------------------------------------------------------------
216
+ async def _main(self) -> None:
217
+ if self._app is None:
218
+ self._app = await iterm2.async_get_app(self._conn)
219
+ for name, body in (
220
+ ("layout monitor", lambda: self._monitor(iterm2.LayoutChangeMonitor, "layout")),
221
+ ("new-session monitor", lambda: self._monitor(iterm2.NewSessionMonitor, "new")),
222
+ ("termination monitor", lambda: self._monitor(iterm2.SessionTerminationMonitor, "terminated")),
223
+ ("theme monitor", self._theme_monitor),
224
+ ("status loop", self._status_loop),
225
+ ("periodic discovery", self._periodic_discovery),
226
+ ):
227
+ self._children.add(asyncio.create_task(_keep_running(name, body)))
228
+ self._request_discovery("start")
229
+ await _keep_running("discovery", self._discovery_loop)
230
+
231
+ async def _monitor(self, factory, trigger: str) -> None:
232
+ async with factory(self._conn) as mon:
233
+ while True:
234
+ await mon.async_get()
235
+ if trigger == "layout": # profile or size may have changed: refresh palettes and frames
236
+ self._recolour()
237
+ self._request_discovery(trigger)
238
+
239
+ async def _periodic_discovery(self) -> None:
240
+ """A safety net next to the events: same debounced pass, so it never reorders cells."""
241
+ while True:
242
+ await asyncio.sleep(self._discovery_interval)
243
+ self._request_discovery("periodic")
244
+
245
+ async def _theme_monitor(self) -> None:
246
+ """A light/dark switch is not a layout change; watch the app's effectiveTheme."""
247
+ async with iterm2.VariableMonitor(self._conn, iterm2.VariableScopes.APP, "effectiveTheme", None) as mon:
248
+ while True:
249
+ await mon.async_get()
250
+ _log.info("theme changed")
251
+ self._line_cache.clear()
252
+ self._recolour()
253
+
254
+ def _request_discovery(self, trigger: str) -> None:
255
+ self._triggers.add(trigger)
256
+ self._rediscover.set()
257
+
258
+ def _recolour(self) -> None:
259
+ """Re-resolve every palette on the next fetch and fetch every session again."""
260
+ self._palette_gen += 1
261
+ self._palettes.clear()
262
+ for pump in self._pumps.values():
263
+ pump.mark_dirty()
264
+
265
+ async def _discovery_loop(self) -> None:
266
+ while True:
267
+ await self._rediscover.wait()
268
+ await asyncio.sleep(_DEBOUNCE_SECONDS) # one pass for a burst of events
269
+ self._rediscover.clear()
270
+ triggers, self._triggers = self._triggers, set()
271
+ try:
272
+ await self._discover(triggers)
273
+ except Exception:
274
+ self._triggers |= triggers
275
+ self._rediscover.set() # retry the pass, not just wait for the next event
276
+ raise
277
+
278
+ async def _discover(self, triggers: set[str]) -> None:
279
+ start = time.perf_counter()
280
+ if triggers & _REFRESH_TRIGGERS:
281
+ await self._app.async_refresh() # a read: reloads windows, tabs and grid sizes
282
+ found = {i.id: i for i in await list_sessions(self._app, self._excluded, self._has_status)}
283
+ # Cells keep their positions: known sessions stay in first-seen order and new ones
284
+ # (in iTerm2's order among themselves) go at the end. Moving tabs reorders nothing.
285
+ order = [sid for sid in self._infos if sid in found] + [sid for sid in found if sid not in self._infos]
286
+ self._infos = {sid: found[sid] for sid in order}
287
+ infos = list(self._infos.values())
288
+ gone = [sid for sid in self._watchers if sid not in self._infos]
289
+ stopping = [self._watchers.pop(sid) for sid in gone]
290
+ for task in stopping:
291
+ task.cancel()
292
+ for sid in gone:
293
+ for cache in (
294
+ self._pumps, self._palettes, self._line_cache, self._status_locks, self._claude_seen,
295
+ self._last_view, self._size_flagged,
296
+ ):
297
+ cache.pop(sid, None)
298
+ self._tracker.forget(sid)
299
+ # Stop vanished sessions' tasks before the hub drops their cached state.
300
+ await asyncio.gather(*stopping, return_exceptions=True)
301
+ await self._hub.set_sessions(infos)
302
+ added = 0
303
+ for info in infos:
304
+ if info.id in self._watchers:
305
+ continue
306
+ session = self._app.get_session_by_id(info.id)
307
+ if session is not None:
308
+ self._watchers[info.id] = asyncio.create_task(self._watch(session))
309
+ added += 1
310
+ _log.info(
311
+ "discovery %s: sessions=%d added=%d removed=%d in %.0f ms",
312
+ ",".join(sorted(triggers)), len(infos), added, len(gone), (time.perf_counter() - start) * 1000,
313
+ )
314
+
315
+ async def _watch(self, session) -> None:
316
+ sid = session.session_id
317
+ pump = LatestOnlyPump(lambda: self._publish(session), self._preview_interval)
318
+ self._pumps[sid] = pump
319
+ pump_task = asyncio.create_task(pump.run())
320
+ pump.mark_dirty()
321
+
322
+ async def stream() -> None:
323
+ async with session.get_screen_streamer(want_contents=False) as streamer:
324
+ pump.mark_dirty() # anything that changed while subscribing
325
+ while True:
326
+ await streamer.async_get()
327
+ pump.mark_dirty()
328
+
329
+ try:
330
+ await _keep_running(f"streamer {sid}", stream)
331
+ finally:
332
+ pump_task.cancel()
333
+ await asyncio.gather(pump_task, return_exceptions=True)
334
+
335
+ async def _publish(self, session) -> None:
336
+ sid = session.session_id
337
+ frame = await self.fetch_frame(session)
338
+ if frame is not None and sid in self._infos:
339
+ await self._hub.set_screen(sid, frame)
340
+ await self._refresh_status(sid)
341
+
342
+ async def _palette_for(self, session) -> Palette:
343
+ sid = session.session_id
344
+ cached = self._palettes.get(sid)
345
+ if cached is not None:
346
+ return cached
347
+ gen = self._palette_gen
348
+ try:
349
+ profile = await session.async_get_profile()
350
+ theme = await self._app.async_get_variable("effectiveTheme")
351
+ palette = Palette.from_profile(profile, dark="dark" in (theme or ""))
352
+ except Exception as exc:
353
+ _log.info("default palette for %s: %s", sid, type(exc).__name__)
354
+ return DEFAULT_PALETTE # for this frame only: the next fetch tries the profile again
355
+ if gen == self._palette_gen: # not invalidated while we were fetching
356
+ self._palettes[sid] = palette
357
+ return palette
358
+
359
+ # -- status -----------------------------------------------------------------
360
+ async def _has_status(self, sid: str) -> bool:
361
+ raw = await read_status(self._conn, sid)
362
+ if raw is None: # failed read: no new information, so keep what was last known
363
+ return self._claude_seen.setdefault(sid, False)
364
+ present = has_claude_status(raw)
365
+ self._claude_seen[sid] = present
366
+ return present
367
+
368
+ async def _status_loop(self) -> None:
369
+ while True:
370
+ results = await asyncio.gather(
371
+ *(self._refresh_status(sid) for sid in list(self._infos)), return_exceptions=True
372
+ )
373
+ for r in results:
374
+ if isinstance(r, Exception):
375
+ _log.warning("status refresh failed: %s", type(r).__name__)
376
+ await asyncio.sleep(self._status_interval)
377
+
378
+ async def _refresh_status(self, sid: str) -> None:
379
+ if sid not in self._infos:
380
+ return
381
+ async with self._status_locks.setdefault(sid, asyncio.Lock()):
382
+ raw = await read_status(self._conn, sid)
383
+ info = self._infos.get(sid)
384
+ if info is None or raw is None: # vanished while we were reading, or the read failed
385
+ return
386
+ present = has_claude_status(raw)
387
+ if self._claude_seen.get(sid, present) != present:
388
+ self._request_discovery("status-flip") # Claude started or exited: refresh is_claude
389
+ self._claude_seen[sid] = present
390
+ view = classify(raw, is_claude=info.is_claude, job=info.job)
391
+ # changed_at moves only with the tone or label; any other change (detail, colour) is still pushed.
392
+ _, changed_at = self._tracker.update(sid, view)
393
+ if view != self._last_view.get(sid):
394
+ self._last_view[sid] = view
395
+ await self._hub.set_status(sid, view, changed_at)