pi-chrome 0.12.1 → 0.14.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
@@ -11,7 +11,7 @@ Multiple Pi sessions can use Chrome at the same time. The first Pi session start
11
11
  ## Why try it?
12
12
 
13
13
  - **Uses your existing Chrome profile** — works with the Chrome windows/tabs you are already using, including logged-in GitHub, admin dashboards, local apps, and internal tools.
14
- - **Watch your authenticated Chrome work** — by default, `chrome_*` tool calls focus Chrome and activate the target tab so you can see the agent inspect, navigate, click, and type in real time. Switch to silent/background mode for the whole session with `/chrome settings background`, or pass `background: true` on a single tool call when you want quiet.
14
+ - **Watch your authenticated Chrome work** — by default, `chrome_*` tool calls focus Chrome and activate the target tab so you can see the agent inspect, navigate, click, and type in real time. Switch to silent/background mode for the whole session with `/chrome quiet`, or pass `background: true` on a single tool call when you want quiet.
15
15
  - **Full browser automation toolkit for Pi** — list/create/activate/close tabs, snapshot pages with usable CSS selectors, navigate, evaluate JavaScript, click, type, press keys, wait for page state, and capture screenshots.
16
16
  - **Built-in setup and agent guidance** — `/chrome onboard` walks users through installing the companion extension, `/chrome doctor` checks connectivity and version drift, screenshots save to disk, and the prompt primer tells agents to inspect with `chrome_snapshot` before acting and avoid destructive actions unless explicitly requested.
17
17
 
@@ -72,13 +72,13 @@ pi-chrome can drive Chrome two ways:
72
72
  - **Quiet clicks** — fast and unobtrusive. They work on most sites, but some pages (sign-in flows, copy-to-clipboard buttons, file pickers, autoplay videos, fullscreen, paywalls) ignore them because they don't look like a real human action.
73
73
  - **Real-looking clicks** — indistinguishable from a person clicking. They unlock the cases above, but Chrome shows a *"Pi Chrome Connector started debugging this browser"* banner at the top of every tab pi-chrome touches while it's working.
74
74
 
75
- Pick a mode with `/chrome settings trusted`:
75
+ Pick a mode with `/chrome clicks`:
76
76
 
77
77
  ```text
78
- /chrome settings trusted auto # default; quiet by default, real-looking only when needed
79
- /chrome settings trusted off # always quiet, no banner ever
80
- /chrome settings trusted on # always real-looking, banner stays up the whole session
81
- /chrome settings trusted status # show the current mode
78
+ /chrome clicks auto # default; quiet by default, real-looking only when needed
79
+ /chrome clicks off # always quiet, no banner ever
80
+ /chrome clicks on # always real-looking, banner stays up the whole session
81
+ /chrome clicks status # show the current mode
82
82
  ```
83
83
 
84
84
  For a one-off call, pass `trusted: true` (or `false`) on `chrome_click`, `chrome_type`, `chrome_fill`, `chrome_key`, `chrome_hover`, `chrome_drag`, or `chrome_scroll`. The per-call value wins over the global mode.
@@ -92,9 +92,9 @@ By default, `chrome_*` tools focus Chrome and activate the target tab so you can
92
92
  When you want quiet (planner / audit / worker sessions running alongside your editor), turn background mode on for the whole Pi session:
93
93
 
94
94
  ```text
95
- /chrome settings background # toggle
96
- /chrome settings background on # explicit
97
- /chrome settings background off # explicit
95
+ /chrome quiet # toggle
96
+ /chrome quiet on # explicit
97
+ /chrome quiet off # explicit
98
98
  ```
99
99
 
100
100
  For a single tool call, the agent can pass `background: true` directly. The per-call value always wins over the session toggle.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.12.1",
4
+ "version": "0.14.0",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation", "debugger"],
7
7
  "host_permissions": ["<all_urls>", "http://127.0.0.1:17318/*"],
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
2
3
  import { StringEnum } from "@earendil-works/pi-ai";
4
+ import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
3
5
  import { Type } from "typebox";
4
6
  import { existsSync, readFileSync, statSync } from "node:fs";
5
7
  import { mkdir, writeFile } from "node:fs/promises";
@@ -440,7 +442,7 @@ Usage rules:
440
442
  2. \`includeSnapshot=true\` on click/type/fill to verify in one round trip.
441
443
  3. If \`chrome_evaluate\` returns null when you expected a value, the expression evaluated to null/undefined in the page; surface the value via \`JSON.stringify\` to confirm.
442
444
  4. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
443
- 5. By default chrome_* tools focus Chrome so the user can watch; pass \`background=true\` or run /chrome settings background to silence the whole session.
445
+ 5. By default chrome_* tools focus Chrome so the user can watch; pass \`background=true\` or run /chrome quiet to silence the whole session.
444
446
  6. If you hit an autoplay/clipboard/file-picker gate, tell the user; this bridge cannot satisfy it.
445
447
  7. Run /chrome doctor when in doubt about connectivity or capabilities.
446
448
  </chrome-profile-bridge>`;
@@ -523,7 +525,7 @@ Usage rules:
523
525
  ? " Clicks/keys are quiet by default; if a site rejects a quiet click, pi-chrome retries it once with a real-looking click. The Chrome banner shows only when that retry happens."
524
526
  : status.mode === "on"
525
527
  ? " Every click and keystroke uses a real-looking event. The Chrome banner stays up on every tab pi-chrome touches."
526
- : " All clicks are quiet, no banner. Some sites (sign-ins, copy buttons, file pickers, paywalls) may silently ignore them. Switch to /chrome settings trusted auto if a site isn’t responding.";
528
+ : " All clicks are quiet, no banner. Some sites (sign-ins, copy buttons, file pickers, paywalls) may silently ignore them. Run /chrome clicks if a site isn’t responding.";
527
529
  const label = status.mode === "auto" ? "auto (smart upgrade)" : status.mode === "on" ? "on (always real-looking)" : "off (always quiet)";
528
530
  lines.push(`✓ Click mode: ${label}${banner}.${note}`);
529
531
  } else {
@@ -537,113 +539,98 @@ Usage rules:
537
539
  ctx.ui.notify(lines.join("\n"), "info");
538
540
  };
539
541
 
542
+ // Click realism handler. With no args, cycles auto → on → off → auto. Explicit args jump
543
+ // directly. 'status' prints the current mode without changing it.
544
+ const CLICKS_CYCLE = ["auto", "on", "off"] as const;
545
+ const CLICKS_DESC: Record<string, string> = {
546
+ auto: "Quiet by default; pi-chrome retries once with a real-looking click if a site rejects the quiet one. The Chrome banner appears only when that retry happens.",
547
+ off: "All clicks are quiet, no banner. Some sites (sign-ins, copy buttons, file pickers, paywalls) may silently ignore these clicks.",
548
+ on: "Every click and keystroke looks real to websites. Chrome shows a 'Pi Chrome Connector started debugging this browser' banner on every tab pi-chrome touches.",
549
+ };
550
+ const CLICKS_LABEL: Record<string, string> = {
551
+ auto: "auto (smart upgrade)",
552
+ off: "off (always quiet)",
553
+ on: "on (always real-looking)",
554
+ };
555
+
540
556
  const trustedHandler = async (ctx: ExtensionContext, args: string) => {
541
- const rawArg = (args || "").trim().toLowerCase();
557
+ const rawArg = (args || "").trim().toLowerCase();
542
558
 
543
- // Resolve current status once for both branches (interactive picker + direct args).
544
- let status: { mode: string; attachedTabs: number[]; permissionGranted: boolean } | undefined;
545
- try {
546
- status = (await bridge.send("trusted.status", {}, 5_000)) as typeof status;
547
- } catch (error) {
548
- ctx.ui.notify(`Couldn't check current mode: ${(error as Error).message}`, "warning");
549
- return;
550
- }
551
- if (!status) return;
559
+ let status: { mode: string; attachedTabs: number[]; permissionGranted: boolean } | undefined;
560
+ try {
561
+ status = (await bridge.send("trusted.status", {}, 5_000)) as typeof status;
562
+ } catch (error) {
563
+ ctx.ui.notify(`Couldn't check current click mode: ${(error as Error).message}`, "warning");
564
+ return;
565
+ }
566
+ if (!status) return;
552
567
 
553
- if (!status.permissionGranted) {
554
- ctx.ui.notify(
555
- "pi-chrome can't drive real-looking clicks yet — the companion extension is missing a permission. Open chrome://extensions, click reload on 'Pi Chrome Connector', and accept the new permission prompt that appears.",
556
- "warning",
557
- );
558
- return;
559
- }
568
+ if (!status.permissionGranted) {
569
+ ctx.ui.notify(
570
+ "pi-chrome can't drive real-looking clicks yet — the companion extension is missing a permission. Open chrome://extensions, click reload on 'Pi Chrome Connector', and accept the new permission prompt that appears.",
571
+ "warning",
572
+ );
573
+ return;
574
+ }
560
575
 
561
- const MODE_NAMES: Record<string, string> = {
562
- auto: "auto (smart upgrade)",
563
- off: "off (always quiet)",
564
- on: "on (always real-looking)",
565
- };
566
- const friendly = (m: string) => MODE_NAMES[m] ?? m;
567
- const attached = status.attachedTabs?.length ? ` — banner currently up on ${status.attachedTabs.length} tab(s)` : "";
568
- const current = status.mode;
576
+ const current = status.mode;
577
+ const attached = status.attachedTabs?.length ? ` (banner up on ${status.attachedTabs.length} tab(s))` : "";
569
578
 
570
- let target = rawArg;
571
- if (target === "status") {
572
- ctx.ui.notify(`Current mode: ${friendly(current)}${attached}`, "info");
573
- return;
574
- }
575
- if (!target) {
576
- // Interactive picker. Plain-English descriptions; no jargon.
577
- const options = [
578
- `auto${current === "auto" ? " (current)" : ""} — quiet by default; if a site rejects a quiet click, retry it once with a real-looking click. Yellow banner appears only when needed. Recommended for everyday use.`,
579
- `off${current === "off" ? " (current)" : ""} — always quiet. No banner, ever. Fast and unobtrusive, but some sites (sign-in pages, copy-to-clipboard buttons, file pickers, paywalls) will silently ignore the click.`,
580
- `on${current === "on" ? " (current)" : ""} — every click and keystroke uses a real-looking event. A banner stays at the top of every Chrome window for the whole session, saying ‘Pi Chrome Connector started debugging this browser’. Best when working a stubborn site for a long stretch.`,
581
- `status — just show me which mode is on right now.`,
582
- ];
583
- const picked = await ctx.ui.select(
584
- `How realistic should pi-chrome's clicks be? (current: ${friendly(current)}${attached})`,
585
- options,
586
- );
587
- if (!picked) return; // cancelled
588
- if (picked.startsWith("on")) target = "on";
589
- else if (picked.startsWith("off")) target = "off";
590
- else if (picked.startsWith("auto")) target = "auto";
591
- else if (picked.startsWith("status")) {
592
- ctx.ui.notify(`Current mode: ${friendly(current)}${attached}`, "info");
593
- return;
594
- }
595
- }
579
+ if (rawArg === "status") {
580
+ ctx.ui.notify(`Click mode is ${CLICKS_LABEL[current] ?? current}${attached}. ${CLICKS_DESC[current] ?? ""}`, "info");
581
+ return;
582
+ }
596
583
 
597
- if (!["on", "off", "auto"].includes(target)) {
598
- ctx.ui.notify(`Unknown choice '${rawArg}'. Pick one of: auto | off | on | status, or run /chrome settings trusted with no argument.`, "warning");
599
- return;
600
- }
584
+ // No argument = cycle to the next mode.
585
+ let target = rawArg;
586
+ if (!target) {
587
+ const idx = CLICKS_CYCLE.indexOf(current as typeof CLICKS_CYCLE[number]);
588
+ target = CLICKS_CYCLE[(idx + 1 + CLICKS_CYCLE.length) % CLICKS_CYCLE.length];
589
+ }
601
590
 
602
- if (target === current) {
603
- ctx.ui.notify(`Already in ${friendly(current)} mode.`, "info");
604
- return;
605
- }
591
+ if (!["on", "off", "auto"].includes(target)) {
592
+ ctx.ui.notify(`Unknown click mode '${rawArg}'. Pick one of: auto | off | on | status.`, "warning");
593
+ return;
594
+ }
606
595
 
607
- // Extra confirmation only on first-time "on" (warn about persistent banner).
608
- if (target === "on" && current !== "on") {
609
- const ok = await ctx.ui.confirm(
610
- "Always use real-looking clicks?",
611
- "Every click and keystroke pi-chrome sends will now look like a real human action to websites. This unlocks copy-to-clipboard buttons, sign-in pages, file pickers, fullscreen, autoplay, and most bot-protected sites.\n\nThe tradeoff: Chrome will pin a banner at the top of every Chrome window saying ‘Pi Chrome Connector started debugging this browser’. The banner stays visible for the rest of your pi session (or until you switch back to auto/off). Clicking ‘Cancel’ on the banner interrupts pi-chrome.",
612
- );
613
- if (!ok) {
614
- ctx.ui.notify("Mode unchanged.", "info");
615
- return;
616
- }
617
- }
596
+ if (target === current) {
597
+ ctx.ui.notify(`Click mode is already ${CLICKS_LABEL[current] ?? current}.`, "info");
598
+ return;
599
+ }
618
600
 
619
- try {
620
- const result = (await bridge.send("trusted.mode", { mode: target }, 5_000)) as { mode: string };
621
- if (result.mode === "on") {
622
- ctx.ui.notify(
623
- "On. Every click and keystroke now looks real to websites. A banner saying ‘Pi Chrome Connector started debugging this browser’ will appear on every tab pi-chrome touches.",
624
- "info",
625
- );
626
- } else if (result.mode === "off") {
627
- ctx.ui.notify("Off. All clicks are quiet now, no banner. Note: some sites (sign-ins, copy buttons, file pickers, paywalls) may silently ignore these clicks.", "info");
628
- } else {
629
- ctx.ui.notify("Auto. Clicks stay quiet by default; pi-chrome will only switch to real-looking clicks when a site rejects a quiet one. The Chrome banner will appear only when that retry happens.", "info");
630
- }
631
- } catch (error) {
632
- ctx.ui.notify(`Couldn't switch mode: ${(error as Error).message}`, "warning");
601
+ try {
602
+ await bridge.send("trusted.mode", { mode: target }, 5_000);
603
+ ctx.ui.notify(`Click mode ${CLICKS_LABEL[target] ?? target}. ${CLICKS_DESC[target] ?? ""}`, "info");
604
+ } catch (error) {
605
+ ctx.ui.notify(`Couldn't switch click mode: ${(error as Error).message}`, "warning");
633
606
  }
634
607
  };
635
608
 
609
+ // Quiet (Chrome focus) handler. No args = toggle. Explicit on/off/status.
610
+ const QUIET_DESC: Record<string, string> = {
611
+ on: "pi-chrome works in the background; Chrome won't pop up or steal focus.",
612
+ off: "Chrome pops to the front and switches tabs so you can watch what pi-chrome is doing.",
613
+ };
614
+
636
615
  const backgroundHandler = async (ctx: ExtensionContext, args: string) => {
637
616
  const arg = (args || "").trim().toLowerCase();
617
+ const currentLabel = backgroundDefault ? "on" : "off";
618
+
619
+ if (arg === "status") {
620
+ ctx.ui.notify(`Quiet mode is ${currentLabel}. ${QUIET_DESC[currentLabel]}`, "info");
621
+ return;
622
+ }
623
+
638
624
  if (arg === "on" || arg === "true" || arg === "1") backgroundDefault = true;
639
625
  else if (arg === "off" || arg === "false" || arg === "0") backgroundDefault = false;
640
- else backgroundDefault = !backgroundDefault;
641
- ctx.ui.notify(
642
- backgroundDefault
643
- ? "Quiet mode on. pi-chrome will work in the background; Chrome won't steal focus."
644
- : "Watch mode on. Chrome will pop to the front and switch tabs so you can see what pi-chrome is doing.",
645
- "info",
646
- );
626
+ else if (arg === "toggle" || arg === "") backgroundDefault = !backgroundDefault;
627
+ else {
628
+ ctx.ui.notify(`Unknown quiet mode '${arg}'. Pick one of: on | off | toggle | status.`, "warning");
629
+ return;
630
+ }
631
+
632
+ const nextLabel = backgroundDefault ? "on" : "off";
633
+ ctx.ui.notify(`Quiet mode → ${nextLabel}. ${QUIET_DESC[nextLabel]}`, "info");
647
634
  };
648
635
 
649
636
  const onboardHandler = async (ctx: ExtensionContext) => {
@@ -667,90 +654,181 @@ Usage rules:
667
654
  );
668
655
  };
669
656
 
670
- const settingsHandler = async (ctx: ExtensionContext, rest: string[]) => {
671
- if (rest.length === 0) {
672
- const picked = await ctx.ui.select(
673
- "pi-chrome settings what would you like to change?",
674
- [
675
- "background should Chrome pop to the front when pi-chrome acts, or work silently?",
676
- "trusted how realistic should pi-chrome's clicks and keystrokes be?",
677
- ],
678
- );
679
- if (!picked) return;
680
- if (picked.startsWith("background")) return backgroundHandler(ctx, "");
681
- if (picked.startsWith("trusted")) return trustedHandler(ctx, "");
682
- return;
683
- }
684
- const [head, ...sub] = rest;
685
- const subArgs = sub.join(" ");
686
- switch (head) {
687
- case "background": return backgroundHandler(ctx, subArgs);
688
- case "trusted": return trustedHandler(ctx, subArgs);
689
- default:
690
- ctx.ui.notify(`Unknown setting '${head}'. Try: /chrome settings background | trusted.`, "warning");
657
+ // One-line snapshot of pi-chrome's current state. Used as a header in the bare-/chrome
658
+ // picker and as the body of /chrome status.
659
+ const statusSummary = async (): Promise<string> => {
660
+ const parts: string[] = [];
661
+ try {
662
+ const version = (await bridge.send("tab.version", {}, 5_000)) as { extensionVersion?: string };
663
+ if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) {
664
+ parts.push(`⚠ Chrome extension v${version.extensionVersion} (pi-chrome v${PI_CHROME_VERSION}, reload extension)`);
665
+ } else {
666
+ parts.push(`✓ Chrome connected`);
667
+ }
668
+ } catch {
669
+ parts.push(`✗ Chrome not responding`);
691
670
  }
671
+ try {
672
+ const t = (await bridge.send("trusted.status", {}, 3_000)) as { mode?: string; attachedTabs?: number[] };
673
+ const banner = t.attachedTabs?.length ? `, banner on ${t.attachedTabs.length} tab(s)` : "";
674
+ parts.push(`clicks: ${t.mode ?? "?"}${banner}`);
675
+ } catch {}
676
+ parts.push(`quiet: ${backgroundDefault ? "on" : "off"}`);
677
+ return parts.join(" · ");
678
+ };
679
+
680
+ const statusHandler = async (ctx: ExtensionContext) => {
681
+ ctx.ui.notify(await statusSummary(), "info");
682
+ };
683
+
684
+ // Interactive dialog: each row is a setting whose value cycles with Space/Enter. Enter on
685
+ // the last value also saves; Esc / 'q' closes. The description below changes with the
686
+ // current value so users always see what the active setting means.
687
+ const openSettingsDialog = async (ctx: ExtensionContext): Promise<void> => {
688
+ // Read current click mode (might fail if extension permission missing).
689
+ let clicksMode: string = "auto";
690
+ let permissionGranted = false;
691
+ try {
692
+ const t = (await bridge.send("trusted.status", {}, 5_000)) as { mode?: string; permissionGranted?: boolean };
693
+ clicksMode = t.mode ?? "auto";
694
+ permissionGranted = !!t.permissionGranted;
695
+ } catch {}
696
+
697
+ const clicksItem: SettingItem = {
698
+ id: "clicks",
699
+ label: "Click realism",
700
+ currentValue: clicksMode,
701
+ values: ["auto", "on", "off"],
702
+ description: permissionGranted
703
+ ? (CLICKS_DESC[clicksMode] ?? "")
704
+ : "Real-looking clicks unavailable: reload the Chrome extension in chrome://extensions and accept the new permission prompt.",
705
+ };
706
+ const quietItem: SettingItem = {
707
+ id: "quiet",
708
+ label: "Quiet mode",
709
+ currentValue: backgroundDefault ? "on" : "off",
710
+ values: ["on", "off"],
711
+ description: QUIET_DESC[backgroundDefault ? "on" : "off"] ?? "",
712
+ };
713
+ const items: SettingItem[] = [clicksItem, quietItem];
714
+
715
+ await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
716
+ const container = new Container();
717
+ container.addChild(new Text(theme.fg("accent", theme.bold("pi-chrome settings")), 1, 1));
718
+ container.addChild(new Text(theme.fg("muted", "\u2191\u2193 navigate · space/enter cycle · esc close"), 1, 0));
719
+
720
+ let list: SettingsList;
721
+ list = new SettingsList(
722
+ items,
723
+ Math.min(items.length + 2, 8),
724
+ getSettingsListTheme(),
725
+ (id, newValue) => {
726
+ if (id === "clicks") {
727
+ if (!permissionGranted) {
728
+ ctx.ui.notify("Click mode locked: reload the Chrome extension first.", "warning");
729
+ // Revert by snapping back to the previous value.
730
+ list.updateValue("clicks", clicksItem.currentValue);
731
+ return;
732
+ }
733
+ // Mutate description so the help text matches the new value.
734
+ clicksItem.currentValue = newValue;
735
+ clicksItem.description = CLICKS_DESC[newValue] ?? "";
736
+ list.invalidate();
737
+ void bridge.send("trusted.mode", { mode: newValue }, 5_000).catch((err) => {
738
+ ctx.ui.notify(`Couldn't switch click mode: ${(err as Error).message}`, "warning");
739
+ });
740
+ } else if (id === "quiet") {
741
+ backgroundDefault = newValue === "on";
742
+ quietItem.currentValue = newValue;
743
+ quietItem.description = QUIET_DESC[newValue] ?? "";
744
+ list.invalidate();
745
+ }
746
+ },
747
+ () => done(undefined),
748
+ );
749
+ container.addChild(list);
750
+
751
+ return {
752
+ render: (w) => container.render(w),
753
+ invalidate: () => container.invalidate(),
754
+ handleInput: (data: string) => list.handleInput(data),
755
+ };
756
+ });
692
757
  };
693
758
 
694
759
  pi.registerCommand("chrome", {
695
760
  description:
696
- "All pi-chrome controls in one place. Subcommands:\n /chrome doctor quick health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome settingschange how pi-chrome behaves (background mode, click realism).\nRun with no arguments for an interactive picker.",
761
+ "All pi-chrome controls in one place.\n /chrome status — one-line snapshot of connection + current modes.\n /chrome doctor full health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome clicks [auto|off|on|status] — how realistic should pi-chrome's clicks be.\n /chrome quiet [on|off|status|toggle] whether Chrome pops to the front when pi-chrome acts.\nRun with no arguments for an interactive picker that shows current state.",
697
762
  getArgumentCompletions: (prefix) => {
698
- const rawTokens = prefix.split(/\s+/);
699
- const last = (rawTokens[rawTokens.length - 1] ?? "").toLowerCase();
700
- const path = rawTokens.slice(0, -1).filter(Boolean).map((t) => t.toLowerCase());
701
-
702
- const TOP = [
703
- { value: "doctor", description: "Quick health check. Tells you if Chrome is connected and what's wrong if it isn't." },
704
- { value: "onboard", description: "Install the Chrome companion extension (first-time setup)." },
705
- { value: "settings", description: "Change pi-chrome behaviour: background mode, click realism." },
706
- ];
707
- const SETTINGS = [
708
- { value: "background", description: "Should Chrome pop to the front when pi-chrome acts, or work silently?" },
709
- { value: "trusted", description: "How realistic should pi-chrome's clicks and keystrokes be?" },
710
- ];
711
- const BG = [
712
- { value: "on", description: "Work silently. Chrome stays in the background. Your editor keeps focus." },
713
- { value: "off", description: "Bring Chrome to the front so you can watch (default)." },
714
- ];
715
- const TRUSTED = [
716
- { value: "auto", description: "Default. Quiet clicks; upgrade to real ones only when a site rejects them." },
717
- { value: "off", description: "Always quiet. No banner. Some sites won't accept the clicks." },
718
- { value: "on", description: "Always real-looking clicks. Banner stays up. Best for stubborn sites." },
719
- { value: "status", description: "Show the current click mode." },
720
- ];
721
-
722
- let pool: Array<{ value: string; description: string }> | null = null;
723
- if (path.length === 0) pool = TOP;
724
- else if (path[0] === "settings" && path.length === 1) pool = SETTINGS;
725
- else if (path[0] === "settings" && path[1] === "background" && path.length === 2) pool = BG;
726
- else if (path[0] === "settings" && path[1] === "trusted" && path.length === 2) pool = TRUSTED;
727
- if (!pool) return null;
728
-
729
- const items = pool.map((p) => ({ value: p.value, label: p.value, description: p.description }));
730
- const filtered = items.filter((i) => i.value.startsWith(last));
731
- return filtered.length > 0 ? filtered : null;
763
+ const raw = prefix;
764
+ const trimmedRight = raw.replace(/\s+$/, "");
765
+ const tokens = trimmedRight ? trimmedRight.split(/\s+/) : [];
766
+ const endsWithSpace = raw.length > 0 && raw !== trimmedRight;
767
+ // Path = completed tokens; partial = the token currently being typed (or "" if cursor sits right after a space).
768
+ const partial = endsWithSpace ? "" : (tokens.pop() ?? "");
769
+ const path = tokens.map((t) => t.toLowerCase());
770
+ const partialLower = partial.toLowerCase();
771
+
772
+ // Build candidate set with FULL argument-text values so pi-tui's apply-completion
773
+ // (which replaces the entire argument) lands correctly even for nested paths.
774
+ type Item = { fullValue: string; label: string; description: string };
775
+ let candidates: Item[] = [];
776
+ if (path.length === 0) {
777
+ candidates = [
778
+ { fullValue: "status", label: "status", description: "One-line summary: connection + click mode + quiet mode." },
779
+ { fullValue: "doctor", label: "doctor", description: "Full health check. Tells you if Chrome is connected and what's wrong if it isn't." },
780
+ { fullValue: "onboard", label: "onboard", description: "Install the Chrome companion extension (first-time setup)." },
781
+ { fullValue: "clicks", label: "clicks", description: "How realistic should pi-chrome's clicks be? auto / off / on." },
782
+ { fullValue: "quiet", label: "quiet", description: "Should Chrome pop to the front when pi-chrome acts, or work silently?" },
783
+ ];
784
+ } else if (path[0] === "clicks" && path.length === 1) {
785
+ candidates = [
786
+ { fullValue: "clicks auto", label: "auto", description: "Default. Quiet clicks; upgrade to real-looking ones only when a site rejects them." },
787
+ { fullValue: "clicks off", label: "off", description: "Always quiet. No banner. Some sites won't accept the clicks." },
788
+ { fullValue: "clicks on", label: "on", description: "Always real-looking. Chrome shows a banner. Best for stubborn sites." },
789
+ { fullValue: "clicks status", label: "status", description: "Show the current click mode." },
790
+ ];
791
+ } else if (path[0] === "quiet" && path.length === 1) {
792
+ candidates = [
793
+ { fullValue: "quiet on", label: "on", description: "Work silently. Chrome stays in the background. Your editor keeps focus." },
794
+ { fullValue: "quiet off", label: "off", description: "Bring Chrome to the front so you can watch (default)." },
795
+ { fullValue: "quiet toggle", label: "toggle", description: "Flip whichever way it's currently set." },
796
+ { fullValue: "quiet status", label: "status", description: "Show the current setting." },
797
+ ];
798
+ }
799
+ if (candidates.length === 0) return null;
800
+ const filtered = candidates.filter((c) => c.label.toLowerCase().startsWith(partialLower));
801
+ if (filtered.length === 0) return null;
802
+ return filtered.map((c) => ({ value: c.fullValue, label: c.label, description: c.description }));
732
803
  },
733
804
  handler: async (args, ctx) => {
734
805
  const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
735
806
  if (tokens.length === 0) {
736
- const picked = await ctx.ui.select("pi-chrome — what would you like to do?", [
737
- "doctor — quick health check; tells you what's wrong if Chrome isn't responding",
738
- "onboard — install the Chrome companion extension (first-time setup)",
739
- "settings — change pi-chrome behaviour (background mode, click realism)",
740
- ]);
741
- if (!picked) return;
742
- if (picked.startsWith("doctor")) return doctorHandler(ctx);
743
- if (picked.startsWith("onboard")) return onboardHandler(ctx);
744
- if (picked.startsWith("settings")) return settingsHandler(ctx, []);
807
+ await openSettingsDialog(ctx);
745
808
  return;
746
809
  }
747
810
  const [head, ...rest] = tokens;
811
+ const subArgs = rest.join(" ");
748
812
  switch (head) {
813
+ case "status": return statusHandler(ctx);
749
814
  case "doctor": return doctorHandler(ctx);
750
815
  case "onboard": return onboardHandler(ctx);
751
- case "settings": return settingsHandler(ctx, rest);
816
+ case "clicks":
817
+ case "trusted": // legacy alias
818
+ return trustedHandler(ctx, subArgs);
819
+ case "quiet":
820
+ case "background": // legacy alias
821
+ return backgroundHandler(ctx, subArgs);
822
+ case "settings": {
823
+ // Legacy nested form: /chrome settings background ... or /chrome settings trusted ...
824
+ const [setting, ...settingArgs] = rest;
825
+ if (setting === "background") return backgroundHandler(ctx, settingArgs.join(" "));
826
+ if (setting === "trusted") return trustedHandler(ctx, settingArgs.join(" "));
827
+ ctx.ui.notify(`'/chrome settings' was removed. Use /chrome clicks or /chrome quiet directly.`, "warning");
828
+ return;
829
+ }
752
830
  default:
753
- ctx.ui.notify(`Unknown subcommand '${head}'. Try: /chrome doctor | onboard | settings.`, "warning");
831
+ ctx.ui.notify(`Unknown subcommand '${head}'. Try: /chrome status | doctor | onboard | clicks | quiet.`, "warning");
754
832
  }
755
833
  },
756
834
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "Drive your existing logged-in Chrome from Pi — no re-login, no throwaway profile, watch the agent work in real time (or toggle quiet background mode).",
5
5
  "keywords": [
6
6
  "pi-package",