callwalkietalkie 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "callwalkietalkie",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Chat into cmux from your phone. Run npx callwalkietalkie.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -81,6 +81,17 @@
81
81
  -webkit-overflow-scrolling: touch;
82
82
  border-top: 1px solid var(--line);
83
83
  }
84
+ .sess-section-title {
85
+ margin: 0;
86
+ padding: 12px 16px;
87
+ background: var(--ground);
88
+ border-bottom: 1px solid var(--line);
89
+ color: var(--muted);
90
+ font-size: 12px;
91
+ font-weight: 600;
92
+ letter-spacing: 0.06em;
93
+ text-transform: uppercase;
94
+ }
84
95
  .sess-row {
85
96
  position: relative;
86
97
  overflow: hidden;
@@ -135,6 +146,112 @@
135
146
  background: transparent;
136
147
  }
137
148
  .sess.active .dot { background: var(--live); }
149
+ .sess.browser .dot { background: #94a3b8; }
150
+ .sess.browser.active .dot { background: var(--sent); }
151
+ .sess .kind {
152
+ flex: none;
153
+ font-size: 10px;
154
+ font-weight: 700;
155
+ letter-spacing: 0.06em;
156
+ text-transform: uppercase;
157
+ color: var(--muted);
158
+ border: 1px solid var(--line);
159
+ border-radius: 6px;
160
+ padding: 2px 6px;
161
+ align-self: flex-start;
162
+ margin-top: 2px;
163
+ }
164
+ .sess.browser .kind {
165
+ color: var(--sent);
166
+ border-color: color-mix(in srgb, var(--sent) 35%, var(--line));
167
+ background: color-mix(in srgb, var(--sent) 8%, transparent);
168
+ }
169
+ #browser {
170
+ display: flex;
171
+ flex-direction: column;
172
+ height: 100%;
173
+ background: #0b0d10;
174
+ }
175
+ #browser[hidden] { display: none !important; }
176
+ #btop {
177
+ flex: none;
178
+ display: flex;
179
+ align-items: center;
180
+ gap: 10px;
181
+ padding: 8px 14px 10px;
182
+ border-bottom: 1px solid rgba(255,255,255,.08);
183
+ background: rgba(12,14,18,.96);
184
+ color: #f4f4f5;
185
+ }
186
+ #bback {
187
+ flex: none;
188
+ width: 36px;
189
+ height: 36px;
190
+ border: none;
191
+ background: transparent;
192
+ color: #7dd3fc;
193
+ font-size: 28px;
194
+ line-height: 1;
195
+ padding: 0;
196
+ cursor: pointer;
197
+ }
198
+ #btitlewrap { flex: 1; min-width: 0; }
199
+ #btitle {
200
+ font-size: 16px;
201
+ font-weight: 600;
202
+ display: flex;
203
+ align-items: center;
204
+ gap: 7px;
205
+ white-space: nowrap;
206
+ overflow: hidden;
207
+ text-overflow: ellipsis;
208
+ }
209
+ #btitle .live {
210
+ width: 7px;
211
+ height: 7px;
212
+ border-radius: 4px;
213
+ background: var(--live);
214
+ flex: none;
215
+ }
216
+ #bstatus {
217
+ font-size: 12px;
218
+ color: #a1a1aa;
219
+ font-family: var(--mono);
220
+ white-space: nowrap;
221
+ overflow: hidden;
222
+ text-overflow: ellipsis;
223
+ }
224
+ #bstage {
225
+ flex: 1;
226
+ min-height: 0;
227
+ position: relative;
228
+ display: flex;
229
+ align-items: center;
230
+ justify-content: center;
231
+ background: #0b0d10;
232
+ overflow: hidden;
233
+ }
234
+ #bframe {
235
+ max-width: 100%;
236
+ max-height: 100%;
237
+ object-fit: contain;
238
+ display: block;
239
+ background: #111;
240
+ }
241
+ #bframe[hidden] { display: none; }
242
+ #bempty {
243
+ position: absolute;
244
+ inset: 0;
245
+ display: flex;
246
+ align-items: center;
247
+ justify-content: center;
248
+ color: #a1a1aa;
249
+ font-size: 14px;
250
+ padding: 24px;
251
+ text-align: center;
252
+ }
253
+ #bempty[hidden] { display: none; }
254
+ #bempty.err { color: #fda4af; }
138
255
  .sess .body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
139
256
  .sess .top { display: flex; justify-content: space-between; gap: 8px; }
140
257
  .sess .name {
@@ -459,7 +576,7 @@
459
576
  </div>
460
577
  <div id="sess-list"></div>
461
578
  <div id="sessions-foot">
462
- <span class="hint">Swipe left to hide · <button id="restore-sessions" type="button">Restore hidden</button></span>
579
+ <span class="hint">Terminals chat · browsers live-stream · <button id="restore-sessions" type="button">Restore</button></span>
463
580
  <button id="leave" type="button">Disconnect</button>
464
581
  </div>
465
582
  </div>
@@ -482,6 +599,20 @@
482
599
  </div>
483
600
  </div>
484
601
 
602
+ <div id="browser" hidden>
603
+ <div id="btop">
604
+ <button id="bback" type="button" aria-label="Back to sessions">‹</button>
605
+ <div id="btitlewrap">
606
+ <div id="btitle"><span class="live" id="blivedot" hidden></span><span id="btitletext">Browser</span></div>
607
+ <div id="bstatus">…</div>
608
+ </div>
609
+ </div>
610
+ <div id="bstage">
611
+ <img id="bframe" alt="Live browser view" hidden />
612
+ <div id="bempty">Starting live view…</div>
613
+ </div>
614
+ </div>
615
+
485
616
  <script>
486
617
  (function() {
487
618
  var TOKEN = __TOKEN_JSON__;
@@ -489,6 +620,7 @@
489
620
  var API = __API_BASE_JSON__;
490
621
  var sessionsView = document.getElementById("sessions");
491
622
  var threadView = document.getElementById("thread");
623
+ var browserView = document.getElementById("browser");
492
624
  var sessList = document.getElementById("sess-list");
493
625
  var sessionsMeta = document.getElementById("sessions-meta");
494
626
  var log = document.getElementById("log");
@@ -501,6 +633,12 @@
501
633
  var backBtn = document.getElementById("back");
502
634
  var leaveBtn = document.getElementById("leave");
503
635
  var restoreBtn = document.getElementById("restore-sessions");
636
+ var bbackBtn = document.getElementById("bback");
637
+ var btitleText = document.getElementById("btitletext");
638
+ var bstatusEl = document.getElementById("bstatus");
639
+ var bliveDot = document.getElementById("blivedot");
640
+ var bframe = document.getElementById("bframe");
641
+ var bempty = document.getElementById("bempty");
504
642
 
505
643
  var misses = 0;
506
644
  var lastSig = "";
@@ -520,6 +658,13 @@
520
658
  var lastSessions = [];
521
659
  var hiddenSessions = loadHidden();
522
660
  var openSwipeRow = null;
661
+ var activeType = "terminal";
662
+ var activeUrl = "";
663
+ var activeSurface = null;
664
+ var activeWorkspace = null;
665
+ var inBrowser = false;
666
+ var previewTimer = null;
667
+ var previewBusy = false;
523
668
 
524
669
  function loadHidden() {
525
670
  try {
@@ -575,16 +720,92 @@
575
720
  return String(p).replace(/^\/Users\/[^\/]+/, "~");
576
721
  }
577
722
 
723
+ function shortUrl(u) {
724
+ if (!u) return "";
725
+ try {
726
+ var x = new URL(u);
727
+ var path = x.pathname === "/" ? "" : x.pathname;
728
+ return x.host + path + (x.search || "");
729
+ } catch (e) {
730
+ return String(u);
731
+ }
732
+ }
733
+
734
+ function stopBrowserStream() {
735
+ if (previewTimer) {
736
+ clearTimeout(previewTimer);
737
+ previewTimer = null;
738
+ }
739
+ previewBusy = false;
740
+ if (bframe) {
741
+ bframe.removeAttribute("src");
742
+ bframe.hidden = true;
743
+ }
744
+ }
745
+
578
746
  function showSessions() {
579
747
  inThread = false;
748
+ inBrowser = false;
749
+ stopBrowserStream();
580
750
  sessionsView.hidden = false;
581
751
  threadView.hidden = true;
752
+ if (browserView) browserView.hidden = true;
582
753
  }
583
754
 
584
755
  function showThread() {
585
756
  inThread = true;
757
+ inBrowser = false;
758
+ stopBrowserStream();
586
759
  sessionsView.hidden = true;
587
760
  threadView.hidden = false;
761
+ if (browserView) browserView.hidden = true;
762
+ }
763
+
764
+ function showBrowserLive() {
765
+ inThread = false;
766
+ inBrowser = true;
767
+ sessionsView.hidden = true;
768
+ threadView.hidden = true;
769
+ if (browserView) browserView.hidden = false;
770
+ if (btitleText) btitleText.textContent = titleText.textContent || "Browser";
771
+ if (bstatusEl) bstatusEl.textContent = activeUrl ? shortUrl(activeUrl) : "live view";
772
+ if (bempty) {
773
+ bempty.hidden = false;
774
+ bempty.classList.remove("err");
775
+ bempty.textContent = "Starting live view…";
776
+ }
777
+ if (bliveDot) bliveDot.hidden = true;
778
+ stopBrowserStream();
779
+ pullBrowserFrame();
780
+ }
781
+
782
+ function pullBrowserFrame() {
783
+ if (!inBrowser || previewBusy) return;
784
+ previewBusy = true;
785
+ var url = API + "/preview?t=" + encodeURIComponent(TOKEN) + "&_=" + Date.now();
786
+ if (activeSurface) url += "&surface=" + encodeURIComponent(activeSurface);
787
+ if (activeWorkspace) url += "&workspace=" + encodeURIComponent(activeWorkspace);
788
+ var img = new Image();
789
+ img.onload = function() {
790
+ if (!inBrowser) { previewBusy = false; return; }
791
+ bframe.src = img.src;
792
+ bframe.hidden = false;
793
+ if (bempty) bempty.hidden = true;
794
+ if (bliveDot) bliveDot.hidden = false;
795
+ previewBusy = false;
796
+ previewTimer = setTimeout(pullBrowserFrame, 800);
797
+ };
798
+ img.onerror = function() {
799
+ previewBusy = false;
800
+ if (!inBrowser) return;
801
+ if (bframe.hidden && bempty) {
802
+ bempty.hidden = false;
803
+ bempty.classList.add("err");
804
+ bempty.textContent = "Can't stream this browser tab yet";
805
+ }
806
+ previewTimer = setTimeout(pullBrowserFrame, 1600);
807
+ };
808
+ img.src = url;
588
809
  }
589
810
 
590
811
  function inlineMd(s) {
@@ -809,38 +1030,58 @@
809
1030
  return hiddenSessions.indexOf(sessionKey(s)) === -1;
810
1031
  });
811
1032
  if (!visible.length) {
812
- var msg = lastSessions.length
1033
+ var emptyMsg = lastSessions.length
813
1034
  ? "All sessions hidden. Swipe deleted rows are stored on this phone."
814
- : "No cmux terminals open.";
815
- sessList.innerHTML = '<div class="turn system" style="padding:24px 16px"><div class="bubble">' + msg + "</div></div>";
1035
+ : "No cmux terminals or browsers open.";
1036
+ sessList.innerHTML = '<div class="turn system" style="padding:24px 16px"><div class="bubble">' + emptyMsg + "</div></div>";
816
1037
  return;
817
1038
  }
818
- sessList.innerHTML = visible.map(function(s) {
1039
+ function renderSession(s) {
819
1040
  var surface = s.surface || s.surface_id || "";
820
1041
  var workspace = s.workspace || s.workspace_id || "";
821
- var title = esc(s.title || surface || "session");
822
- var cwd = esc(shortPath(s.cwd || s.current_directory || ""));
1042
+ var kind = s.type || "terminal";
1043
+ var isBrowser = kind === "browser";
1044
+ var title = esc(s.title || surface || (isBrowser ? "browser" : "session"));
1045
+ var path = isBrowser
1046
+ ? esc(shortUrl(s.url || ""))
1047
+ : esc(shortPath(s.cwd || s.current_directory || ""));
823
1048
  var key = esc(sessionKey(s));
824
1049
  var active = surface === currentSurface;
825
- var snip = active ? "Open in this chat" : "Tap to open";
1050
+ var snip = isBrowser
1051
+ ? (active ? "Live stream" : "Browser · tap for live view")
1052
+ : (active ? "Open in this chat" : "Terminal · tap to chat");
826
1053
  return '<div class="sess-row" data-key="' + key + '">' +
827
1054
  '<button type="button" class="sess-delete">Delete</button>' +
828
- '<button type="button" class="sess' + (active ? " active" : "") + '"' +
1055
+ '<button type="button" class="sess' + (active ? " active" : "") + (isBrowser ? " browser" : "") + '"' +
829
1056
  ' data-surface="' + esc(surface) + '"' +
830
1057
  ' data-workspace="' + esc(workspace) + '"' +
831
1058
  ' data-window="' + esc(s.window || "") + '"' +
832
- ' data-title="' + title + '">' +
1059
+ ' data-title="' + title + '"' +
1060
+ ' data-type="' + esc(kind) + '"' +
1061
+ ' data-url="' + esc(s.url || "") + '">' +
833
1062
  '<span class="dot"></span>' +
834
1063
  '<span class="body">' +
835
- '<span class="top"><span class="name">' + title + '</span></span>' +
1064
+ '<span class="top"><span class="name">' + title + '</span><span class="kind">' + (isBrowser ? "Browser" : "Terminal") + "</span></span>" +
836
1065
  '<span class="snip">' + snip + "</span>" +
837
- (cwd ? '<span class="path">' + cwd + "</span>" : "") +
1066
+ (path ? '<span class="path">' + path + "</span>" : "") +
838
1067
  "</span></button></div>";
1068
+ }
1069
+ sessList.innerHTML = [
1070
+ { type: "browser", title: "Browsers" },
1071
+ { type: "terminal", title: "Terminals" }
1072
+ ].map(function(group) {
1073
+ var items = visible.filter(function(s) {
1074
+ return (s.type || "terminal") === group.type;
1075
+ });
1076
+ if (!items.length) return "";
1077
+ return '<section aria-labelledby="sess-' + group.type + '-heading">' +
1078
+ '<h2 class="sess-section-title" id="sess-' + group.type + '-heading">' + group.title + '</h2>' +
1079
+ items.map(renderSession).join("") + '</section>';
839
1080
  }).join("");
840
1081
  bindSwipeRows();
841
1082
  }
842
1083
 
843
- async function selectSession(surface, workspace, windowRef, title) {
1084
+ async function selectSession(surface, workspace, windowRef, title, typeHint, urlHint) {
844
1085
  try {
845
1086
  var r = await fetch(API + "/select?t=" + encodeURIComponent(TOKEN), {
846
1087
  method: "POST",
@@ -853,9 +1094,12 @@
853
1094
  return;
854
1095
  }
855
1096
  activeKey = j.surface || surface;
1097
+ activeSurface = j.surface || surface || null;
1098
+ activeWorkspace = j.workspace || workspace || null;
1099
+ activeType = j.type || typeHint || "terminal";
1100
+ activeUrl = j.url || urlHint || "";
856
1101
  titleText.textContent = title || j.title || "cmux";
857
1102
  liveDot.hidden = false;
858
- // Hard-reset thread so the previous surface's messages can't linger.
859
1103
  known = [];
860
1104
  pendingUser = null;
861
1105
  lastSig = "";
@@ -864,9 +1108,13 @@
864
1108
  agentBusy = false;
865
1109
  log.innerHTML = "";
866
1110
  updateComposerMode();
867
- showThread();
868
- await refreshStatus();
869
- await refreshTranscript();
1111
+ if (activeType === "browser") {
1112
+ showBrowserLive();
1113
+ } else {
1114
+ showThread();
1115
+ await refreshStatus();
1116
+ await refreshTranscript();
1117
+ }
870
1118
  } catch (err) {
871
1119
  setSessionsMeta("switch failed", "err");
872
1120
  }
@@ -884,7 +1132,9 @@
884
1132
  btn.getAttribute("data-surface"),
885
1133
  btn.getAttribute("data-workspace"),
886
1134
  btn.getAttribute("data-window"),
887
- btn.getAttribute("data-title")
1135
+ btn.getAttribute("data-title"),
1136
+ btn.getAttribute("data-type"),
1137
+ btn.getAttribute("data-url")
888
1138
  );
889
1139
  });
890
1140
 
@@ -892,11 +1142,18 @@
892
1142
  showSessions();
893
1143
  refreshStatus();
894
1144
  });
1145
+ if (bbackBtn) {
1146
+ bbackBtn.addEventListener("click", function() {
1147
+ showSessions();
1148
+ refreshStatus();
1149
+ });
1150
+ }
895
1151
 
896
1152
  function disconnect() {
897
1153
  if (leaving) return;
898
1154
  leaving = true;
899
1155
  clearInterval(poller);
1156
+ stopBrowserStream();
900
1157
  fetch(API + "/shutdown?t=" + encodeURIComponent(TOKEN), {
901
1158
  method: "POST",
902
1159
  keepalive: true,
@@ -924,10 +1181,19 @@
924
1181
  setSessionsMeta("localhost · live", "ok");
925
1182
  titleText.textContent = j.title || "cmux";
926
1183
  liveDot.hidden = false;
1184
+ activeType = j.type || activeType || "terminal";
1185
+ activeUrl = j.url || activeUrl || "";
1186
+ if (j.surface) activeSurface = j.surface;
1187
+ if (j.workspace) activeWorkspace = j.workspace;
927
1188
  var bits = [];
928
- if (j.cwd) bits.push(shortPath(j.cwd));
1189
+ if (j.type === "browser" && j.url) bits.push(shortUrl(j.url));
1190
+ else if (j.cwd) bits.push(shortPath(j.cwd));
929
1191
  if (j.surface) bits.push(j.surface);
930
1192
  setStatus(bits.join(" · ") || "live", "ok");
1193
+ if (inBrowser) {
1194
+ if (btitleText) btitleText.textContent = j.title || "Browser";
1195
+ if (bstatusEl) bstatusEl.textContent = j.url ? shortUrl(j.url) : "live view";
1196
+ }
931
1197
  renderPicker(j.sessions || j.workspaces || [], j.surface);
932
1198
  return true;
933
1199
  } catch (e) {
@@ -940,7 +1206,7 @@
940
1206
  }
941
1207
 
942
1208
  async function refreshTranscript() {
943
- if (!inThread) return;
1209
+ if (!inThread || inBrowser) return;
944
1210
  try {
945
1211
  var r = await fetch(API + "/transcript?t=" + encodeURIComponent(TOKEN));
946
1212
  var j = await r.json();
@@ -207,6 +207,12 @@ class Target:
207
207
  window_ref: Optional[str] = None
208
208
  title: str = "cmux"
209
209
  cwd: Optional[str] = None
210
+ surface_type: str = "terminal"
211
+ url: Optional[str] = None
212
+
213
+ @property
214
+ def is_browser(self) -> bool:
215
+ return (self.surface_type or "terminal") == "browser"
210
216
 
211
217
 
212
218
  @dataclass
@@ -243,6 +249,10 @@ class Session:
243
249
  self.target.cwd = item["cwd"]
244
250
  if item.get("window"):
245
251
  self.target.window_ref = item.get("window")
252
+ if item.get("type"):
253
+ self.target.surface_type = item["type"]
254
+ if "url" in item:
255
+ self.target.url = item.get("url")
246
256
  return
247
257
  except CmuxError:
248
258
  pass
@@ -259,6 +269,8 @@ class Session:
259
269
  break
260
270
 
261
271
  def read_text(self, *, lines: int = 800, scrollback: bool = True) -> str:
272
+ if self.target.is_browser:
273
+ return ""
262
274
  result = rpc(
263
275
  "surface.read_text",
264
276
  {
@@ -280,6 +292,8 @@ class Session:
280
292
  Agents disagree on the cancel key (Ctrl+C vs Escape), and some cmux
281
293
  builds only honor one of send_key / send_text — so we fire all of them.
282
294
  """
295
+ if self.target.is_browser:
296
+ raise CmuxError("Can't interrupt a browser tab")
283
297
  self.focus()
284
298
  base = {
285
299
  "workspace_id": self.target.workspace_ref,
@@ -316,6 +330,8 @@ class Session:
316
330
  pass
317
331
 
318
332
  def send(self, text: str) -> None:
333
+ if self.target.is_browser:
334
+ raise CmuxError("This is a browser live view — open a terminal to chat")
319
335
  text = text.rstrip("\n")
320
336
  if not text:
321
337
  raise CmuxError("empty message")
@@ -334,6 +350,8 @@ class Session:
334
350
  self._sent.append(text)
335
351
 
336
352
  def transcript(self) -> list[ChatMessage]:
353
+ if self.target.is_browser:
354
+ return []
337
355
  screen = self.read_text()
338
356
  self._last_text = screen
339
357
  parsed = parse_screen(screen)
@@ -341,6 +359,8 @@ class Session:
341
359
  return merge_sent(parsed, self._sent)
342
360
 
343
361
  def busy(self) -> bool:
362
+ if self.target.is_browser:
363
+ return False
344
364
  return is_busy(self._last_text or self.read_text())
345
365
 
346
366
  def notify_linked(self) -> None:
@@ -371,6 +391,29 @@ class Session:
371
391
  pass
372
392
 
373
393
 
394
+ def browser_screenshot(
395
+ *,
396
+ surface: Optional[str] = None,
397
+ workspace: Optional[str] = None,
398
+ socket_path: Optional[str] = None,
399
+ ) -> bytes:
400
+ """Capture a cmux browser surface as PNG bytes. Does not focus/steal foreground."""
401
+ path = socket_path or find_socket()
402
+ params: dict[str, Any] = {}
403
+ if surface:
404
+ params["surface_id"] = surface
405
+ if workspace:
406
+ params["workspace_id"] = workspace
407
+ result = rpc("browser.screenshot", params, socket_path=path) or {}
408
+ b64 = result.get("png_base64") or result.get("base64")
409
+ if b64:
410
+ return base64.b64decode(b64)
411
+ file_path = result.get("path")
412
+ if file_path and Path(file_path).is_file():
413
+ return Path(file_path).read_bytes()
414
+ raise CmuxError("browser screenshot returned no image")
415
+
416
+
374
417
  # ---------------------------------------------------------------- discovery
375
418
 
376
419
 
@@ -399,7 +442,7 @@ def list_surfaces(workspace_ref: str, socket_path: Optional[str] = None) -> list
399
442
 
400
443
 
401
444
  def inventory(socket_path: Optional[str] = None) -> list[dict]:
402
- """Flat list of pickable terminal sessions across windows/workspaces."""
445
+ """Flat list of pickable terminal + browser sessions across windows/workspaces."""
403
446
  path = socket_path or find_socket()
404
447
  items: list[dict] = []
405
448
  tree = None
@@ -411,6 +454,25 @@ def inventory(socket_path: Optional[str] = None) -> list[dict]:
411
454
  except CmuxError:
412
455
  tree = None
413
456
 
457
+ def _append(win_ref, ws_ref, ws_id, ws_title, ws_cwd, surf):
458
+ kind = surf.get("type") or "terminal"
459
+ if kind not in ("terminal", "browser"):
460
+ return
461
+ items.append(
462
+ {
463
+ "window": win_ref,
464
+ "workspace": ws_ref or ws_id,
465
+ "workspace_id": ws_id,
466
+ "surface": surf.get("ref") or surf.get("id"),
467
+ "surface_id": surf.get("id"),
468
+ "title": surf.get("title") or ws_title or kind,
469
+ "cwd": ws_cwd if kind == "terminal" else None,
470
+ "url": surf.get("url") if kind == "browser" else None,
471
+ "type": kind,
472
+ "active": bool(surf.get("active") or surf.get("focused")),
473
+ }
474
+ )
475
+
414
476
  if isinstance(tree, dict) and tree.get("windows"):
415
477
  for win in tree.get("windows") or []:
416
478
  win_ref = win.get("ref") or win.get("window_ref")
@@ -421,20 +483,7 @@ def inventory(socket_path: Optional[str] = None) -> list[dict]:
421
483
  ws_cwd = ws.get("current_directory")
422
484
  for pane in ws.get("panes") or []:
423
485
  for surf in pane.get("surfaces") or []:
424
- if (surf.get("type") or "terminal") != "terminal":
425
- continue
426
- items.append(
427
- {
428
- "window": win_ref,
429
- "workspace": ws_ref or ws_id,
430
- "workspace_id": ws_id,
431
- "surface": surf.get("ref") or surf.get("id"),
432
- "surface_id": surf.get("id"),
433
- "title": surf.get("title") or ws_title or "terminal",
434
- "cwd": ws_cwd,
435
- "active": bool(surf.get("active") or surf.get("focused")),
436
- }
437
- )
486
+ _append(win_ref, ws_ref, ws_id, ws_title, ws_cwd, surf)
438
487
  if items:
439
488
  return items
440
489
 
@@ -442,22 +491,13 @@ def inventory(socket_path: Optional[str] = None) -> list[dict]:
442
491
  for ws in list_workspaces(socket_path=path):
443
492
  ws_ref = ws.get("ref") or ws.get("id")
444
493
  for surf in list_surfaces(ws_ref, socket_path=path):
445
- if (surf.get("type") or "terminal") != "terminal":
446
- continue
447
- items.append(
448
- {
449
- "window": None,
450
- "workspace": ws_ref,
451
- "workspace_id": ws.get("id"),
452
- "surface": surf.get("ref") or surf.get("id"),
453
- "surface_id": surf.get("id"),
454
- "title": surf.get("title")
455
- or ws.get("custom_title")
456
- or ws.get("title")
457
- or "terminal",
458
- "cwd": ws.get("current_directory"),
459
- "active": bool(surf.get("focused") or surf.get("selected_in_pane")),
460
- }
494
+ _append(
495
+ None,
496
+ ws_ref,
497
+ ws.get("id"),
498
+ ws.get("custom_title") or ws.get("title") or ws_ref,
499
+ ws.get("current_directory"),
500
+ surf,
461
501
  )
462
502
  return items
463
503
 
@@ -491,11 +531,21 @@ def resolve_target(
491
531
  continue
492
532
  pick = item
493
533
  break
534
+ if pick is None:
535
+ for item in inv:
536
+ if item.get("active") and (item.get("type") or "terminal") == "terminal":
537
+ pick = item
538
+ break
494
539
  if pick is None:
495
540
  for item in inv:
496
541
  if item.get("active"):
497
542
  pick = item
498
543
  break
544
+ if pick is None:
545
+ for item in inv:
546
+ if (item.get("type") or "terminal") == "terminal":
547
+ pick = item
548
+ break
499
549
  if pick is None and inv:
500
550
  pick = inv[0]
501
551
  if pick is None:
@@ -510,10 +560,14 @@ def resolve_target(
510
560
  "window": focused.get("window_ref"),
511
561
  "title": "cmux",
512
562
  "cwd": None,
563
+ "type": focused.get("surface_type") or "terminal",
564
+ "url": None,
513
565
  }
514
566
 
515
567
  # Enrich title/cwd from workspace.list when tree omitted them
516
- if not pick.get("cwd") or pick.get("title") in (None, "terminal", "cmux"):
568
+ if (pick.get("type") or "terminal") == "terminal" and (
569
+ not pick.get("cwd") or pick.get("title") in (None, "terminal", "cmux")
570
+ ):
517
571
  for ws in list_workspaces(socket_path=path):
518
572
  if pick.get("workspace") in (ws.get("ref"), ws.get("id")):
519
573
  pick["cwd"] = pick.get("cwd") or ws.get("current_directory")
@@ -530,6 +584,8 @@ def resolve_target(
530
584
  window_ref=pick.get("window") or focused.get("window_ref"),
531
585
  title=pick.get("title") or "cmux",
532
586
  cwd=pick.get("cwd"),
587
+ surface_type=pick.get("type") or "terminal",
588
+ url=pick.get("url"),
533
589
  )
534
590
 
535
591
 
@@ -595,6 +651,14 @@ def _is_chrome(line: str) -> bool:
595
651
  s = line.strip()
596
652
  if not s:
597
653
  return True
654
+ label = re.sub(r"^[→›>►▶•·▸▹➤➜\-\s]+", "", s).strip().lower()
655
+ if label in {
656
+ "add a follow-up",
657
+ "add follow-up",
658
+ "follow-up",
659
+ "follow up",
660
+ } or label.startswith("add a follow-up"):
661
+ return True
598
662
  if _BOXY.search(s) and len(_BOXY.findall(s)) >= 3:
599
663
  return True
600
664
  if s.startswith(("→ ", "→")) and ("ctrl+" in s.lower() or "stop" in s.lower()):
@@ -35,6 +35,7 @@ if _ADAPTER_DIR not in sys.path:
35
35
  sys.path.insert(0, _ADAPTER_DIR)
36
36
  from cmux_adapter import ( # noqa: E402
37
37
  CmuxError,
38
+ browser_screenshot,
38
39
  inventory,
39
40
  open_session,
40
41
  ping as cmux_ping,
@@ -703,6 +704,8 @@ def agent_status():
703
704
  "app": "cmux",
704
705
  "title": t.title,
705
706
  "cwd": t.cwd,
707
+ "url": t.url,
708
+ "type": t.surface_type,
706
709
  "workspace": t.workspace_ref,
707
710
  "surface": t.surface_ref,
708
711
  "window": t.window_ref,
@@ -715,9 +718,11 @@ def agent_status():
715
718
  "ref": s.get("workspace"),
716
719
  "title": s.get("title"),
717
720
  "custom_title": None,
718
- "current_directory": s.get("cwd"),
721
+ "current_directory": s.get("cwd") or s.get("url"),
719
722
  "selected": s.get("active"),
720
723
  "surface": s.get("surface"),
724
+ "type": s.get("type"),
725
+ "url": s.get("url"),
721
726
  }
722
727
  for s in sessions
723
728
  ],
@@ -741,6 +746,16 @@ def agent_select():
741
746
  workspace=workspace, surface=surface, window=window, force=True
742
747
  )
743
748
  session.focus()
749
+ # Bring cmux to the foreground so the selected tab is actually visible.
750
+ try:
751
+ subprocess.run(
752
+ ["/usr/bin/open", "-a", "cmux"],
753
+ check=False,
754
+ capture_output=True,
755
+ timeout=5,
756
+ )
757
+ except Exception:
758
+ pass
744
759
  t = session.target
745
760
  return jsonify(
746
761
  {
@@ -750,6 +765,8 @@ def agent_select():
750
765
  "window": t.window_ref,
751
766
  "title": t.title,
752
767
  "cwd": t.cwd,
768
+ "url": t.url,
769
+ "type": t.surface_type,
753
770
  }
754
771
  )
755
772
  except KeyboardInterrupt:
@@ -758,6 +775,43 @@ def agent_select():
758
775
  return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
759
776
 
760
777
 
778
+ @app.route("/agent/preview")
779
+ def agent_preview():
780
+ """Live PNG frame of a cmux browser tab. Runs outside the agent lock."""
781
+ surface = (request.args.get("surface") or "").strip() or None
782
+ workspace = (request.args.get("workspace") or "").strip() or None
783
+ try:
784
+ with _agent_lock:
785
+ session = _get_session()
786
+ path = session.socket_path
787
+ t = session.target
788
+ if not surface:
789
+ if not t.is_browser:
790
+ return jsonify(
791
+ {"ok": False, "error": "select a browser tab first"}
792
+ ), 400
793
+ surface = t.surface_ref
794
+ workspace = t.workspace_ref
795
+ elif not workspace:
796
+ workspace = t.workspace_ref
797
+ # Screenshot outside the lock so chat/select stay responsive.
798
+ png = browser_screenshot(
799
+ surface=surface, workspace=workspace, socket_path=path
800
+ )
801
+ return Response(
802
+ png,
803
+ mimetype="image/png",
804
+ headers={
805
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
806
+ "Pragma": "no-cache",
807
+ },
808
+ )
809
+ except KeyboardInterrupt:
810
+ raise
811
+ except BaseException as e:
812
+ return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
813
+
814
+
761
815
  @app.route("/agent/transcript")
762
816
  def agent_transcript():
763
817
  try: