dsh-mobilecode 0.6.1 → 0.7.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/README.md CHANGED
@@ -93,8 +93,10 @@ drawer:
93
93
  tap can address. Values are single-quoted for the device shell (apostrophes
94
94
  escaped, control characters refused), so metacharacters stay inert.
95
95
  - `device_stream` — drive the live screen stream the panel shows: `start` an
96
- online device (returns a signed `streamUrl`), `status`, or `stop`. Agents that
97
- just need to see the screen should prefer `device_screen` / `device_ui_tree`.
96
+ online device (returns a signed `streamUrl` **and** an `android-stream`
97
+ `presentationMeta` that renders a compact conversation card and auto-opens the
98
+ panel), `status`, or `stop`. Agents that just need to see the screen should
99
+ prefer `device_screen` / `device_ui_tree`.
98
100
 
99
101
  **Live device stream (the panel)**
100
102
 
@@ -110,9 +112,20 @@ The Devices pane shows a real-time mirror of the attached device. It is produced
110
112
  frames instead of building an unbounded queue or watching a growing delay.
111
113
  - A consumer refcount + idle timeout stops the loop when nobody is watching; a
112
114
  keep-alive restarts a crashed loop; switching devices retires the old child.
113
- - **Tap or drag directly on the screen** to drive the device; a Back / Home /
114
- Recents / Rotate / Power bar sits below it, and a device picker switches which
115
- online device streams (it never boots one use `device_boot` for that).
115
+ - **Tap or drag directly on the screen** to drive the device: a short press is a
116
+ tap, a long drag becomes a swipe carrying the real press duration (clamped
117
+ 0–5000 ms), coalesced into one control call. A floating **pill toolbar** of SVG
118
+ icons (Back / Home / Recents / Screenshot / Rotate / Refresh) sits over the
119
+ stage, and a ☰ **device menu** adds notifications / quick settings / collapse /
120
+ lock / wake / assistant.
121
+ - The header **device picker** groups online devices (🖥 emulator / 📱 physical,
122
+ streaming badge) and lists configured AVDs as boot hints (`device_boot` starts
123
+ one — the picker never does); a **Live** badge shows the streamed serial. Quick
124
+ sizes (Fit / 100% / S·240 / M·320 presets + a select) and frame styles
125
+ (none / bezel / device) reshape the stage.
126
+ - **Screenshot** captures a still via `POST /stream/still` (a real `screencap`,
127
+ embedded as a data URL up to 4 MB) and flips the stage to a still view with a
128
+ "back to Live" link.
116
129
  - **Security**: every stream route sits behind a loopback + trusted-browser
117
130
  transport fence (peer address, loopback `Host`, `Sec-Fetch-Site` / `Origin` —
118
131
  so a LAN client cannot spoof localhost and a DNS-rebinding `Host` is rejected),
@@ -166,6 +179,44 @@ classified adb boundary (argv-based, quoted, replay-safe):
166
179
  - `device_reboot` — reboot into `normal` / `recovery` / `bootloader`
167
180
  (the device drops offline and comes back in a minute or two).
168
181
 
182
+ **Row & memory tools (v0.7.0)** — ported from
183
+ [ZSeven-W/dsh-android](https://github.com/ZSeven-W/dsh-android):
184
+
185
+ - `device_ui_rows` — read a list as **rows, not a tree**: clusters repeated
186
+ sibling nodes of similar height (≥3 rows; page-sized containers and off-screen
187
+ nodes excluded), aggregates each row's label, and parses counters (`3万`,
188
+ `1.2k`, `42 items`) into `{key, value, raw}`. Returns
189
+ `{rows: [{index, group, frame, label, counters}], omittedOffscreen}` — the
190
+ compact handle for "tap row 7 of this list" without reading a 40 KB UI tree.
191
+ Optional `filter` keeps rows whose label contains a substring.
192
+ - `device_tap_row` — tap row `index` (from `device_ui_rows`) at row-relative
193
+ fractions `x`/`y` (default 0.5,0.5 = center). `expect_count {key, delta}` turns
194
+ the tap into **one verified round trip**: the counter must already be visible
195
+ in the row before the tap (refused otherwise — never probe an unknown control),
196
+ and after an 800 ms settle the same row must show the count moved by exactly
197
+ `delta` (default +1) with the row not having scrolled.
198
+ - `device_meminfo` — a running app's memory profile from `dumpsys meminfo`:
199
+ TOTAL PSS/RSS/Swap-PSS, the App Summary heap breakdown
200
+ (Java/Native/Code/Stack/Graphics) and the top PSS categories. Throws when the
201
+ package has no running process.
202
+ - `device_backtrace` — a thread/crash dump without a debugger: sends SIGQUIT
203
+ (`kill -3`), waits for ART to write the trace, reads the newest `/data/anr`
204
+ entry, and falls back to the logcat crash buffer when `/data/anr` is unreadable
205
+ (`engine: "anr-trace" | "logcat-crash"`). Pass `package_name` or `pid`.
206
+
207
+ **Conversation surface (v0.7.0)** — the transcript integration ported from
208
+ dsh-android's UI/UX:
209
+
210
+ - A settled `device_stream` / `device_boot` call renders a **compact card** in
211
+ the conversation (kind, serial, streaming/failed badge, "⤢ open in panel")
212
+ instead of a raw JSON blob, and **auto-opens the Devices panel once** when a
213
+ stream first settles. The card hydrates from the host-projected
214
+ `presentationMeta` on top-level calls and falls back to parsing the serial out
215
+ of the durable result text for nested calls; it never crashes on a shape it
216
+ does not recognize.
217
+ - A **composer capsule** — a green `● <serial>` pill in the input dock — shows
218
+ while a device streams and the panel is closed; clicking it opens the panel.
219
+
169
220
  **First-run experience & settings**
170
221
 
171
222
  - On first start after installation, a **welcome window** explains how to use the
@@ -200,7 +251,18 @@ classified adb boundary (argv-based, quoted, replay-safe):
200
251
  group — plus setup endpoints: `GET /welcome`, `POST /welcome/dismiss`,
201
252
  `GET /doctor`, `POST /doctor/fix {id}`, `GET /ocr`, `POST /ocr/install`,
202
253
  `GET/POST /settings`, `GET /connection` — plus the Wi-Fi actions
203
- `POST /connect` (host/port/pairing code) and `POST /pair-qr` (mDNS QR flow).
254
+ `POST /connect` (host/port/pairing code) and `POST /pair-qr` (mDNS QR flow)
255
+ plus the live-stream routes `GET /stream/status`, `POST /stream/grant`,
256
+ `POST /stream/control` (tap / swipe / key / long-press, coalesced),
257
+ `POST /stream/devices` (online devices **and** configured AVDs),
258
+ `POST /stream/still` (one `screencap` as a data URL up to 4 MB),
259
+ `POST /stream/device-action` (the `device_action` verbs over the panel fence)
260
+ and `GET /stream/{token}` (the multipart frame body). Every stream error carries
261
+ a machine-readable `code` next to the HTTP status — `token_invalid`,
262
+ `stream_not_running`, `stream_start_failed`, `devices_unavailable`,
263
+ `device_not_found`, `device_offline`, `unknown_action`, `bad_request`,
264
+ `control_failed`, `capture_failed`, `device_action_failed` — so the panel can
265
+ say *why* instead of "something failed".
204
266
 
205
267
  **One classified adb boundary** — every serial-targeted adb command runs through
206
268
  `adbRun()` in `lib/device-build.js`:
package/lib/client.js CHANGED
@@ -134,6 +134,41 @@ window.__ModuleLoader__.load({
134
134
  .mc-qr { margin: 8px 0; padding: 8px; border: 1px dashed light-dark(rgba(0,0,0,.25), rgba(255,255,255,.3)); border-radius: 8px; }
135
135
  .mc-qr-text { user-select: all; font-family: ui-monospace, Consolas, monospace; font-size: 12px; word-break: break-all; margin: 4px 0 0; }
136
136
  .mc-warn { color: #b3261e; font-size: 12px; margin: 6px 0; }
137
+ /* ── 0.7.0: toolbar pill, picker popover, device menu, sizes, frames ── */
138
+ .mc-toolbar { display: flex; align-items: center; justify-content: center; gap: 2px; padding: 3px; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.18)); border-radius: 999px; background: light-dark(rgba(0,0,0,.03), rgba(255,255,255,.05)); align-self: center; }
139
+ .mc-toolbar button { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 28px; border: 0; background: transparent; color: inherit; border-radius: 999px; cursor: pointer; opacity: .8; }
140
+ .mc-toolbar button:hover:not(:disabled) { background: light-dark(rgba(0,0,0,.08), rgba(255,255,255,.12)); opacity: 1; }
141
+ .mc-toolbar button:disabled { opacity: .3; cursor: default; }
142
+ .mc-toolbar svg { display: block; }
143
+ .mc-live-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; color: #4caf50; }
144
+ .mc-picker { position: relative; }
145
+ .mc-picker .mc-btn { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
146
+ .mc-picker-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 50; min-width: 240px; max-width: 320px; max-height: 320px; overflow: auto; background: light-dark(#fff, #20242b); color: inherit; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.35); padding: 6px; display: flex; flex-direction: column; gap: 6px; }
147
+ .mc-picker-group { display: flex; flex-direction: column; gap: 2px; }
148
+ .mc-picker-label { font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: light-dark(#6b7078, #9aa0a8); padding: 2px 6px; }
149
+ .mc-picker-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; text-align: left; }
150
+ .mc-picker-row:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
151
+ .mc-picker-row.on { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); }
152
+ .mc-picker-hint { font-size: 11px; color: light-dark(#6b7078, #9aa0a8); padding: 4px 8px; }
153
+ .mc-menu-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 50; min-width: 180px; background: light-dark(#fff, #20242b); color: inherit; border: 1px solid light-dark(rgba(0,0,0,.16), rgba(255,255,255,.2)); border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.35); padding: 4px; display: flex; flex-direction: column; }
154
+ .mc-menu-pop button { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border: 0; border-radius: 7px; background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; text-align: left; }
155
+ .mc-menu-pop button:hover { background: light-dark(rgba(0,0,0,.06), rgba(255,255,255,.1)); }
156
+ .mc-menu { position: relative; }
157
+ .mc-live-sizesel { flex: 0 1 auto; width: 92px; }
158
+ .mc-live-size { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
159
+ .mc-seg { display: inline-flex; border: 1px solid light-dark(rgba(0,0,0,.18), rgba(255,255,255,.2)); border-radius: 8px; overflow: hidden; }
160
+ .mc-seg button { border: 0; background: transparent; color: inherit; font: inherit; font-size: 11px; padding: 4px 9px; cursor: pointer; opacity: .6; }
161
+ .mc-seg button[data-on] { background: light-dark(rgba(66,133,244,.12), rgba(66,133,244,.22)); color: #4285f4; opacity: 1; }
162
+ .mc-live-stage.bezel { padding: 8px; background: #111; border-radius: 16px; }
163
+ .mc-live-stage.device { padding: 12px; background: linear-gradient(145deg, #2c2f36, #17191d); border-radius: 26px; box-shadow: 0 10px 30px rgba(0,0,0,.45); }
164
+ /* ── 0.7.0: compact conversation cards ── */
165
+ .mc-card-compact { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid light-dark(rgba(0,0,0,.12), rgba(255,255,255,.16)); border-radius: 10px; font-size: 12px; background: light-dark(rgba(0,0,0,.02), rgba(255,255,255,.03)); }
166
+ .mc-card-compact .mc-card-kind { font-weight: 700; }
167
+ .mc-badge.ok { background: rgba(76,175,80,.16); color: #4caf50; }
168
+ .mc-card-open { font-size: 11px; color: #4285f4; cursor: pointer; border: 0; background: transparent; padding: 2px 4px; font: inherit; }
169
+ /* ── 0.7.0: composer status capsule ── */
170
+ .mc-capsule { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1px solid rgba(76,175,80,.4); border-radius: 999px; background: rgba(76,175,80,.1); color: #4caf50; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
171
+ .mc-capsule:hover { background: rgba(76,175,80,.18); }
137
172
  `;
138
173
  //#endregion
139
174
 
@@ -372,13 +407,99 @@ window.__ModuleLoader__.load({
372
407
  );
373
408
  }
374
409
 
375
- /** Live device stream: an <img> fed by the in-process multipart route, with
376
- * tap/drag control and the Back/Home/Recents/rotate/power nav bar. */
410
+ // 0.7.0: the conversation cards (tool.call.toolview) and the composer
411
+ // capsule (conversation.input.dock) live outside our panel tree, so the
412
+ // controller they need to open the panel is a module-level reference set
413
+ // by apply() before the slots register. Auto-open fires exactly once per
414
+ // page load (a settled boot/stream card opens the panel).
415
+ let activeController = null;
416
+ let autoOpenedOnce = false;
417
+ /** Stable stand-in store so the capsule hooks are unconditional even before apply() runs. */
418
+ const DUMMY_STORE = { subscribe: () => () => {}, getSnapshot: () => ({ panelOpen: true }) };
419
+
420
+ /** Extract text from a tool-result block (string content or pages of {type,text}). */
421
+ function blockText(block) {
422
+ if (typeof block?.content === "string") return block.content;
423
+ if (Array.isArray(block?.content)) return block.content.map((c) => (typeof c?.text === "string" ? c.text : "")).join("\n");
424
+ return "";
425
+ }
426
+
427
+ /** Compact conversation card for tools that emit android-stream meta.
428
+ * ToolCallBlock model (matches the rc.6 runtime): a settled result has
429
+ * `kind: "tool-result"` + `isError`; a running block has neither, so it
430
+ * resolves here to null and the runtime's own running UI shows. */
431
+ function MobileCodeToolCard(props) {
432
+ const block = props?.block ?? null;
433
+ const toolName = props?.toolName ?? block?.toolName ?? "";
434
+ const settled = block !== null && typeof block === "object" && "kind" in block;
435
+ const error = settled && block.isError === true;
436
+ // block.meta IS the host-projected presentationMeta envelope (see
437
+ // dsh-android parseAndroidMeta). Nested calls that carry no meta fall
438
+ // back to hydrating the serial from the durable text.
439
+ const meta = settled && !error ? block?.meta ?? null : null;
440
+ const kind = meta?.kind ?? null;
441
+ const serial = meta?.device?.serial
442
+ ?? (/(emulator-\d+|(?:RFC[0-9A-F]{7,}|[A-Za-z0-9]{6,}))/i.exec(blockText(block))?.[1] ?? "");
443
+ // Auto-open the panel once when a stream settles (boot/stream start).
444
+ useEffect(() => {
445
+ if (settled && kind === "android-stream" && !autoOpenedOnce && activeController) {
446
+ autoOpenedOnce = true;
447
+ activeController.setOpen(true);
448
+ }
449
+ }, [settled, kind]);
450
+ if (!settled || kind !== "android-stream") return null;
451
+ const badge = error ? "failed" : "streaming";
452
+ return h("div", { className: "mc-card-compact" },
453
+ h("span", { className: "mc-card-kind" }, "Android stream"),
454
+ serial !== "" && h("span", { className: "mc-serial" }, serial),
455
+ h("span", { className: "mc-badge" + (error ? " err" : " ok") }, badge),
456
+ h("span", { className: "sp" }),
457
+ activeController && h("button", { className: "mc-card-open", onClick: () => activeController.setOpen(true) }, "⤢ open in panel"),
458
+ );
459
+ }
460
+
461
+ /** Composer capsule: green "● <serial>" pill while a device streams and the panel is closed. */
462
+ function MobileCodeStatusCapsule() {
463
+ const [running, setRunning] = useState(false);
464
+ const [status, setStatus] = useState(null);
465
+ const store = activeController ?? DUMMY_STORE;
466
+ const { panelOpen } = useSyncExternalStore(store.subscribe, store.getSnapshot);
467
+ const poll = async () => {
468
+ try {
469
+ const r = await streamPost("/stream/status", {});
470
+ setRunning(r.running === true);
471
+ setStatus(r);
472
+ } catch {
473
+ setRunning(false);
474
+ setStatus(null);
475
+ }
476
+ };
477
+ useEffect(() => {
478
+ poll();
479
+ const timer = setInterval(() => { if (!panelOpen) poll(); }, 5000);
480
+ return () => clearInterval(timer);
481
+ }, [panelOpen]);
482
+ if (!running || panelOpen) return null;
483
+ return h("button", { className: "mc-capsule", onClick: () => activeController?.setOpen(true) },
484
+ h("span", { className: "mc-dot on" }),
485
+ h("span", null, status?.serial ?? "device stream"),
486
+ );
487
+ }
488
+
489
+ /** Live device stream: picker-popover header, SVG toolbar pill, device
490
+ * menu, quick sizes + frame styles, and tap/drag with duration. */
377
491
  function LiveDeviceCard() {
378
492
  const [devices, setDevices] = useState([]);
493
+ const [avds, setAvds] = useState([]);
379
494
  const [serial, setSerial] = useState("");
380
495
  const [streamUrl, setStreamUrl] = useState("");
381
496
  const [error, setError] = useState("");
497
+ const [pickerOpen, setPickerOpen] = useState(false);
498
+ const [menuOpen, setMenuOpen] = useState(false);
499
+ const [still, setStill] = useState(null);
500
+ const [view, setView] = useState("live"); // 'live' | 'still'
501
+ const [sizeMode, setSizeMode] = useState({ mode: "fit" }); // {mode:'fit'|'pct'|'px', value?}
502
+ const [frame, setFrame] = useState("none"); // 'none' | 'bezel' | 'device'
382
503
  const imgRef = useRef(null);
383
504
  const dragRef = useRef(null);
384
505
  const grantTimer = useRef(null);
@@ -406,6 +527,7 @@ window.__ModuleLoader__.load({
406
527
  try {
407
528
  const r = await streamPost("/stream/devices", {});
408
529
  setDevices(r.devices);
530
+ setAvds(r.avds ?? []);
409
531
  setSerial((cur) => cur || (r.devices.find((d) => d.streaming)?.serial ?? r.devices[0]?.serial ?? ""));
410
532
  } catch (err) {
411
533
  setError(err instanceof Error ? err.message : String(err));
@@ -418,8 +540,8 @@ window.__ModuleLoader__.load({
418
540
  return () => { clearInterval(timer); clearTimeout(grantTimer.current); };
419
541
  }, []);
420
542
 
421
- // Auto-grant once a serial is known but no stream is up yet.
422
- useEffect(() => { if (serial !== "" && streamUrl === "") grant(serial).catch(() => {}); }, [serial, streamUrl]);
543
+ // Auto-grant once a serial is known, no stream, and we're in live view.
544
+ useEffect(() => { if (serial !== "" && streamUrl === "" && view === "live") grant(serial).catch(() => {}); }, [serial, streamUrl, view]);
423
545
 
424
546
  const control = async (action) => {
425
547
  if (serial === "") return;
@@ -437,50 +559,158 @@ window.__ModuleLoader__.load({
437
559
  y: Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)),
438
560
  };
439
561
  };
440
- const onDown = (e) => { const p = norm(e.clientX, e.clientY); if (p) dragRef.current = p; };
562
+ // Gesture coalescing: remember the down point + timestamp; on up,
563
+ // taps (<2% travel) vs drags keep the real duration (clamped 0..5s).
564
+ const onDown = (e) => { const p = norm(e.clientX, e.clientY); if (p) dragRef.current = { ...p, t: Date.now() }; };
441
565
  const onUp = (e) => {
442
566
  const start = dragRef.current;
443
567
  dragRef.current = null;
444
568
  const end = norm(e.clientX, e.clientY);
445
569
  if (!start || !end) return;
570
+ const durationMs = Math.max(0, Math.min(5000, Date.now() - start.t));
446
571
  if (Math.hypot(end.x - start.x, end.y - start.y) > 0.02) {
447
- control({ kind: "drag", fromX: start.x, fromY: start.y, toX: end.x, toY: end.y });
572
+ control({ kind: "drag", fromX: start.x, fromY: start.y, toX: end.x, toY: end.y, durationMs });
448
573
  } else {
449
574
  control({ kind: "tap", x: end.x, y: end.y });
450
575
  }
451
576
  };
452
577
 
578
+ const capture = async () => {
579
+ if (serial === "") return;
580
+ try {
581
+ const r = await streamPost("/stream/still", { device: serial });
582
+ if (r.dataUrl) {
583
+ setStill(r);
584
+ setView("still");
585
+ setError("");
586
+ } else {
587
+ setStill(null);
588
+ setView("live");
589
+ setError("still saved to " + (r.path ?? "a temp file") + " but too large to embed here");
590
+ }
591
+ } catch (err) {
592
+ setError(err instanceof Error ? err.message : String(err));
593
+ }
594
+ };
595
+
596
+ const menuAction = async (action) => {
597
+ if (serial === "") return;
598
+ try { await streamPost("/stream/device-action", { device: serial, action }); setMenuOpen(false); }
599
+ catch (err) { setError(err instanceof Error ? err.message : String(err)); }
600
+ };
601
+
602
+ const pick = (device) => { setSerial(device); setStreamUrl(""); setStill(null); setView("live"); setError(""); setPickerOpen(false); };
603
+ const showLive = () => { setView("live"); if (serial !== "" && streamUrl === "") grant(serial).catch(() => {}); };
604
+
605
+ const sizeStyle = sizeMode.mode === "pct"
606
+ ? { width: sizeMode.value + "%", maxWidth: "100%", maxHeight: "60vh" }
607
+ : sizeMode.mode === "px"
608
+ ? { width: sizeMode.value + "px", maxWidth: "100%", maxHeight: "60vh" }
609
+ : {};
610
+ const stageClass = "mc-live-stage" + (frame !== "none" ? " " + frame : "");
611
+ const toolbar = [
612
+ { title: "Back", icon: "M15 18l-6-6 6-6", run: () => control({ kind: "button", name: "back" }) },
613
+ { title: "Home", icon: "M3 10.5L12 3l9 7.5M5 9.5V21h14V9.5", run: () => control({ kind: "button", name: "home" }) },
614
+ { title: "Recents", icon: "M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5", run: () => control({ kind: "button", name: "recents" }) },
615
+ { title: "Screenshot", icon: "M4 7h4l2-3h4l2 3h4v13H4zM12 17a3 3 0 1 0 0-6 3 3 0 0 0 0 6z", run: () => capture() },
616
+ { title: "Rotate", icon: "M21 12a9 9 0 1 1-2.6-6.3M21 3v6h-6", run: () => control({ kind: "rotate" }) },
617
+ { title: "Refresh", icon: "M21 12a9 9 0 1 1-2.6-6.3M21 3v6h-6", run: () => { if (serial !== "") grant(serial).catch(() => {}); } },
618
+ ];
619
+ const menuItems = [
620
+ { action: "notifications", label: "Notifications" },
621
+ { action: "quick_settings", label: "Quick settings" },
622
+ { action: "collapse", label: "Collapse" },
623
+ { action: "lock", label: "Lock" },
624
+ { action: "wake", label: "Wake" },
625
+ { action: "assistant", label: "Assistant" },
626
+ ];
627
+ const SIZE_PRESETS = [
628
+ { label: "Fit", mode: { mode: "fit" } },
629
+ { label: "100%", mode: { mode: "pct", value: 100 } },
630
+ { label: "S · 240", mode: { mode: "px", value: 240 } },
631
+ { label: "M · 320", mode: { mode: "px", value: 320 } },
632
+ ];
633
+ const isCurrent = (m) => m.mode === sizeMode.mode && (m.value === undefined || m.value === sizeMode.value);
634
+
453
635
  return h("div", { className: "mc-card mc-live" },
454
636
  h("div", { className: "mc-card-head" },
455
637
  h("span", null, "Live device"),
638
+ streamUrl !== "" && h("span", { className: "mc-live-badge" }, h("span", { className: "mc-dot on" }), "Live"),
456
639
  h("span", { className: "sp" }),
457
- devices.length > 0
458
- ? h("select", {
459
- value: serial,
460
- onChange: (e) => { setSerial(e.target.value); setStreamUrl(""); setError(""); },
461
- }, devices.map((d) => h("option", { key: d.serial, value: d.serial }, d.serial + (d.kind === "emulator" ? " · emu" : " · phone"))))
462
- : h("span", { className: "mc-hint" }, "no device"),
463
- h("button", { className: "mc-btn", disabled: serial === "", onClick: () => grant(serial) }, "Reconnect"),
640
+ h("div", { className: "mc-picker" },
641
+ h("button", { className: "mc-btn", onClick: () => setPickerOpen((v) => !v) }, serial || "no device", " ▾"),
642
+ pickerOpen && h("div", { className: "mc-picker-pop" },
643
+ devices.length > 0
644
+ ? h("div", { className: "mc-picker-group" },
645
+ h("div", { className: "mc-picker-label" }, "Online devices"),
646
+ devices.map((d) => h("button", { key: d.serial, className: "mc-picker-row" + (d.serial === serial ? " on" : ""), onClick: () => pick(d.serial) },
647
+ h("span", null, d.kind === "emulator" ? "🖥" : "📱"),
648
+ h("span", { className: "mc-serial" }, d.serial),
649
+ d.streaming && h("span", { className: "mc-badge" }, "streaming"),
650
+ )),
651
+ )
652
+ : h("div", { className: "mc-picker-hint" }, "No device attached"),
653
+ avds.length > 0 && h("div", { className: "mc-picker-group" },
654
+ h("div", { className: "mc-picker-label" }, "AVDs — start with device_boot"),
655
+ avds.map((avd) => h("div", { key: avd, className: "mc-picker-hint" }, "▫ " + avd)),
656
+ ),
657
+ ),
658
+ ),
659
+ h("div", { className: "mc-menu" },
660
+ h("button", { className: "mc-btn", disabled: serial === "", title: "Device actions", onClick: () => setMenuOpen((v) => !v) }, "☰"),
661
+ menuOpen && h("div", { className: "mc-menu-pop" },
662
+ menuItems.map((item) => h("button", { key: item.action, onClick: () => menuAction(item.action) }, item.label)),
663
+ ),
664
+ ),
464
665
  ),
465
666
  error !== "" && h("div", { className: "mc-error" }, error),
466
- h("div", { className: "mc-live-stage" },
467
- streamUrl !== ""
468
- ? h("img", { ref: imgRef, src: streamUrl, alt: "device screen", draggable: false, onPointerDown: onDown, onPointerUp: onUp, onError: () => grant(serial) })
469
- : h("div", { className: "mc-live-off" }, devices.length === 0
470
- ? "No device attached. Boot one with device_boot, or press Run app."
471
- : "Connecting to " + serial + "…"),
667
+ h("div", { className: "mc-toolbar" },
668
+ toolbar.map((item) => h("button", { key: item.title, title: item.title, disabled: serial === "", onClick: item.run },
669
+ h("svg", { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" },
670
+ h("path", { d: item.icon }),
671
+ ),
672
+ )),
673
+ ),
674
+ h("div", { className: stageClass },
675
+ view === "still" && still?.dataUrl
676
+ ? h("img", { src: still.dataUrl, alt: "still capture", draggable: false })
677
+ : streamUrl !== ""
678
+ ? h("img", { ref: imgRef, src: streamUrl, alt: "device screen", style: sizeStyle, draggable: false, onPointerDown: onDown, onPointerUp: onUp, onError: () => grant(serial) })
679
+ : h("div", { className: "mc-live-off" }, devices.length === 0
680
+ ? "No device attached. Boot one with device_boot, or press Run app."
681
+ : "Connecting to " + serial + "..."),
472
682
  ),
473
- h("div", { className: "mc-live-nav" },
474
- h("button", { title: "Back", disabled: serial === "", onClick: () => control({ kind: "button", name: "back" }) }, ""),
475
- h("button", { title: "Home", disabled: serial === "", onClick: () => control({ kind: "button", name: "home" }) }, "○"),
476
- h("button", { title: "Recents", disabled: serial === "", onClick: () => control({ kind: "button", name: "recents" }) }, "▢"),
477
- h("button", { title: "Rotate", disabled: serial === "", onClick: () => control({ kind: "rotate" }) }, "⟳"),
478
- h("button", { title: "Power", disabled: serial === "", onClick: () => control({ kind: "button", name: "power" }) }, ""),
683
+ view === "still"
684
+ ? h("div", { className: "mc-live-cap" }, "Still captured" + (still?.width ? " (" + still.width + "x" + still.height + ")" : "") + " · ",
685
+ h("a", { className: "mc-log-toggle", onClick: showLive }, "back to Live"))
686
+ : h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device"),
687
+ h("div", { className: "mc-live-size" },
688
+ h("span", { className: "mc-hint" }, "Size"),
689
+ h("div", { className: "mc-seg" }, SIZE_PRESETS.map((p) => h("button", { key: p.label, "data-on": isCurrent(p.mode) || undefined, onClick: () => setSizeMode(p.mode) }, p.label))),
690
+ h("select", { className: "mc-input mc-live-sizesel", value: sizeMode.mode === "pct" ? sizeMode.value + "%" : sizeMode.mode === "px" ? String(sizeMode.value) : "fit", onChange: (e) => {
691
+ const v = e.target.value;
692
+ if (v === "fit") setSizeMode({ mode: "fit" });
693
+ else if (v.endsWith("%")) setSizeMode({ mode: "pct", value: Number(v.slice(0, -1)) });
694
+ else setSizeMode({ mode: "px", value: Number(v) });
695
+ } },
696
+ h("option", { value: "fit" }, "fit"),
697
+ h("option", { value: "50%" }, "50%"),
698
+ h("option", { value: "75%" }, "75%"),
699
+ h("option", { value: "100%" }, "100%"),
700
+ h("option", { value: "125%" }, "125%"),
701
+ h("option", { value: "240" }, "S · 240px"),
702
+ h("option", { value: "320" }, "M · 320px"),
703
+ h("option", { value: "420" }, "L · 420px"),
704
+ ),
705
+ h("span", { className: "mc-hint" }, "Frame"),
706
+ h("div", { className: "mc-seg" },
707
+ h("button", { "data-on": frame === "none" || undefined, onClick: () => setFrame("none") }, "none"),
708
+ h("button", { "data-on": frame === "bezel" || undefined, onClick: () => setFrame("bezel") }, "bezel"),
709
+ h("button", { "data-on": frame === "device" || undefined, onClick: () => setFrame("device") }, "device"),
710
+ ),
479
711
  ),
480
- h("div", { className: "mc-live-cap" }, "tap or drag on the screen to drive the device"),
481
712
  );
482
713
  }
483
-
484
714
  //#region setup UI (welcome + settings)
485
715
 
486
716
  /** PaddleOCR status card: fetch /ocr, Install button, log tail. */
@@ -1018,6 +1248,7 @@ window.__ModuleLoader__.load({
1018
1248
  styleGuard.observe(document.head, { childList: true });
1019
1249
 
1020
1250
  const controller = new PanelController();
1251
+ activeController = controller;
1021
1252
  const disposers = [];
1022
1253
  try {
1023
1254
  disposers.push(mountSidebarEntry(controller));
@@ -1035,6 +1266,32 @@ window.__ModuleLoader__.load({
1035
1266
  }, SettingsSection),
1036
1267
  );
1037
1268
  disposers.push(disposeSlot);
1269
+ // 0.7.0: compact conversation card under a settled device_stream /
1270
+ // device_boot result (opens the panel on "⤢ open in panel", and
1271
+ // auto-opens it once when a stream first settles). One slot per
1272
+ // tool name keyed like the reference port (openpencil shape):
1273
+ // `key: toolName` scopes the card to that tool's calls, so other
1274
+ // tools keep their default card.
1275
+ for (const toolName of ["device_stream", "device_boot"]) {
1276
+ const disposeCard = ctx.slots.inject("tool.call.toolview", () =>
1277
+ ctx.slots.register({
1278
+ name: "tool.call.toolview",
1279
+ key: toolName,
1280
+ }, MobileCodeToolCard),
1281
+ );
1282
+ disposers.push(disposeCard);
1283
+ }
1284
+ // 0.7.0: green "● <serial>" composer capsule while a device
1285
+ // streams; clicking it opens the panel. Registered unconditionally
1286
+ // so the capsule can appear even before any card auto-opens.
1287
+ const disposeCapsule = ctx.slots.inject("conversation.input.dock", () =>
1288
+ ctx.slots.register({
1289
+ name: "conversation.input.dock",
1290
+ id: "dsh-mobilecode-status",
1291
+ order: 40,
1292
+ }, MobileCodeStatusCapsule),
1293
+ );
1294
+ disposers.push(disposeCapsule);
1038
1295
  }
1039
1296
  } catch (error) {
1040
1297
  console.warn("[dsh-mobilecode] mount failed:", error);
@@ -1042,6 +1299,8 @@ window.__ModuleLoader__.load({
1042
1299
  ctx.effect(() => () => {
1043
1300
  styleGuard.disconnect();
1044
1301
  for (const dispose of disposers.splice(0)) dispose();
1302
+ activeController = null;
1303
+ autoOpenedOnce = false;
1045
1304
  style.remove();
1046
1305
  }, "dsh-mobilecode: ui mounts");
1047
1306
  }
@@ -937,6 +937,58 @@ export function jsonSafe(value) {
937
937
  return value
938
938
  }
939
939
 
940
+ /**
941
+ * Parse `dumpsys meminfo <pkg>` into {totalPssKb, totalRssKb, totalSwapPssKb,
942
+ * appSummary, topCategories}. The dumpsys layout (Android 10+):
943
+ * - "TOTAL PSS:" / "TOTAL RSS:" / "TOTAL SWAP PSS:" summary line;
944
+ * - "App Summary" section with one line per heap bucket (Java/Native/Code/
945
+ * Stack/Graphics), first column = PSS total;
946
+ * - a category breakdown whose rows are ` Name pss privDirty privClean swapDirty`
947
+ * (heap rows add Heap Size/Alloc/Free columns). Rows are only EVIDENCE:
948
+ * the parser keeps the top PSS rows and drops header/dashed/TOTAL lines.
949
+ * Returns undefined when the requested process is absent ("No process found").
950
+ */
951
+ export function parsePackageMeminfo(output) {
952
+ const text = String(output)
953
+ const blockIndex = text.indexOf("** MEMINFO in pid")
954
+ if (blockIndex < 0) return undefined
955
+ const grab = (name) => {
956
+ const match = new RegExp(`${name}:\\s*([\\d,]+)`, "m").exec(text)
957
+ return match ? Number(match[1].replace(/,/g, "")) : undefined
958
+ }
959
+ const summary = {
960
+ totalPssKb: grab("TOTAL PSS"),
961
+ totalRssKb: grab("TOTAL RSS"),
962
+ totalSwapPssKb: grab("TOTAL SWAP PSS"),
963
+ }
964
+ // App Summary: capture the PSS column of the five heap buckets.
965
+ const appSummary = {}
966
+ for (const [key, rowName] of [["javaHeapKb", "Java Heap"], ["nativeHeapKb", "Native Heap"], ["codeKb", "Code"], ["stackKb", "Stack"], ["graphicsKb", "Graphics"]]) {
967
+ const line = text.split(/\r?\n/).find((item) => new RegExp(`^\\s+${rowName}:`).test(item) && /\d/.test(item))
968
+ const match = line ? /:\s+([\d,]+)/.exec(line) : undefined
969
+ if (match) appSummary[key] = Number(match[1].replace(/,/g, ""))
970
+ }
971
+ // Category breakdown: ` Name pss privDirty privClean swapDirty [+3 heap cols]`.
972
+ const categories = []
973
+ const seen = new Set()
974
+ for (const line of text.split(/\r?\n/)) {
975
+ const match = /^\s{2,}([A-Za-z.][A-Za-z0-9. _()\/-]*?)\s+([\d,]+)\s+([\d,]+)\s+([\d,]+)\s+([\d,]+)/.exec(line)
976
+ if (!match) continue
977
+ const name = match[1].trim()
978
+ if (!name || name === "TOTAL" || /^[-\s]+$/.test(name) || /^(Pss|Private|Swap|Size|Alloc|Free)$/.test(name)) continue
979
+ const pss = Number(match[2].replace(/,/g, ""))
980
+ if (seen.has(name)) continue
981
+ seen.add(name)
982
+ categories.push({ name, pssKb: pss })
983
+ }
984
+ categories.sort((l, r) => r.pssKb - l.pssKb)
985
+ return {
986
+ ...summary,
987
+ appSummary: Object.keys(appSummary).length > 0 ? appSummary : undefined,
988
+ topCategories: categories.slice(0, 10),
989
+ }
990
+ }
991
+
940
992
  /** Parse `dumpsys package <pkg>` into {versionName, versionCode, minSdk, targetSdk, permissions, activities}. */
941
993
  export function parseAppInfo(output, packageName) {
942
994
  const text = String(output)