Skip to content

Commit bd93cbc

Browse files
authored
Merge pull request #174 from httpsphl/feat/desktop-act
feat(desktop): bots click, type, choose and scroll in the owner's apps, through accessibility
2 parents e81e8ae + 28bf1c2 commit bd93cbc

25 files changed

Lines changed: 1505 additions & 479 deletions

File tree

‎app/src/features/desktop/DesktopCard.tsx‎

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
// A bot asking to see an app on the owner's desktop (spec 24.2): the app,
2-
// why the bot wants it, what seeing it means, and Allow or Deny. Once
3-
// answered it shrinks to one line.
1+
// A bot asking to see, or to use, an app on the owner's desktop (spec
2+
// 24.2): the app, why the bot wants it, what that means, and Allow or
3+
// Deny. Once answered it shrinks to one line.
44

55
import { Ban, Check, Monitor, TimerOff } from "lucide-react";
66
import { useT } from "../../i18n";
@@ -15,29 +15,47 @@ export const DESKTOP_TOOL = "mcp__botloft__desktop";
1515
interface Asked {
1616
path: string | null;
1717
why: string | null;
18+
/** To use the app, not only see it. */
19+
use: boolean;
1820
}
1921

2022
function askedOf(input: string): Asked {
2123
try {
22-
const parsed = JSON.parse(input) as { path?: unknown; why?: unknown };
24+
const parsed = JSON.parse(input) as { path?: unknown; why?: unknown; level?: unknown };
2325
const text = (value: unknown) => (typeof value === "string" && value.trim() ? value : null);
24-
return { path: text(parsed.path), why: text(parsed.why) };
26+
return { path: text(parsed.path), why: text(parsed.why), use: parsed.level === "act" };
2527
} catch {
26-
return { path: null, why: null };
28+
return { path: null, why: null, use: false };
2729
}
2830
}
2931

30-
function Answered({ approval, bot, app }: { approval: ApprovalItem; bot: Bot; app: string }) {
32+
function Answered({
33+
approval,
34+
bot,
35+
app,
36+
use,
37+
}: {
38+
approval: ApprovalItem;
39+
bot: Bot;
40+
app: string;
41+
use: boolean;
42+
}) {
3143
const words = useT().desktop.card;
3244
switch (approval.status) {
3345
case "allowed":
34-
return <SettledLine icon={Check} tone="ok" text={words.allowed(bot.name, app)} />;
46+
return (
47+
<SettledLine
48+
icon={Check}
49+
tone="ok"
50+
text={(use ? words.allowedUse : words.allowed)(bot.name, app)}
51+
/>
52+
);
3553
case "denied":
3654
return (
3755
<SettledLine
3856
icon={Ban}
3957
tone="danger"
40-
text={words.denied(bot.name, app)}
58+
text={(use ? words.deniedUse : words.denied)(bot.name, app)}
4159
note={approval.note}
4260
/>
4361
);
@@ -54,19 +72,20 @@ export function DesktopCard({ approval, bot }: { approval: ApprovalItem; bot: Bo
5472
deny: t.chat.approval.denyFailed,
5573
});
5674
const app = approval.summary;
75+
const { path, why, use } = askedOf(approval.input);
5776
if (approval.status !== "pending") {
58-
return <Answered approval={approval} bot={bot} app={app} />;
77+
return <Answered approval={approval} bot={bot} app={app} use={use} />;
5978
}
60-
const { path, why } = askedOf(approval.input);
6179

6280
return (
6381
<RequestCard
64-
label={words.asks(bot.name, app)}
82+
label={(use ? words.asksUse : words.asks)(bot.name, app)}
6583
icon={Monitor}
6684
tone="warn"
6785
title={
6886
<span>
69-
{words.wants(bot.name)} <span className="font-semibold">{app}</span>
87+
{(use ? words.wantsUse : words.wants)(bot.name)}{" "}
88+
<span className="font-semibold">{app}</span>
7089
</span>
7190
}
7291
>
@@ -80,7 +99,7 @@ export function DesktopCard({ approval, bot }: { approval: ApprovalItem; bot: Bo
8099
{path}
81100
</p>
82101
)}
83-
<p className="mt-1.5 text-muted text-xs">{words.means(bot.name)}</p>
102+
<p className="mt-1.5 text-muted text-xs">{(use ? words.meansUse : words.means)(bot.name)}</p>
84103
<NoteInput
85104
label={t.chat.approval.noteLabel(bot.name)}
86105
value={note}

‎app/src/features/desktop/desktop.test.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ describe("the desktop", () => {
5555
expect(await screen.findByText("Scout can see Notepad")).toBeDefined();
5656
});
5757

58+
test("a request to use an app says what using it means", async () => {
59+
const { fake, scout } = await openScout();
60+
act(() => {
61+
fake.chat.ask(
62+
scout.id,
63+
"mcp__botloft__desktop",
64+
"Notepad",
65+
JSON.stringify({ app: "Notepad", path: NOTEPAD, level: "act", why: "To save the list" }),
66+
);
67+
});
68+
const card = screen.getByRole("region", { name: "Scout asks to use Notepad" });
69+
expect(within(card).getByText(/without moving your mouse/)).toBeDefined();
70+
expect(within(card).getByText(/Never in password fields/)).toBeDefined();
71+
fireEvent.click(within(card).getByRole("button", { name: "Deny" }));
72+
expect(await screen.findByText("Scout may not use Notepad")).toBeDefined();
73+
});
74+
5875
test("the bot's details list what it may see on the desktop, and it can be taken back", async () => {
5976
const { fake, scout } = await openScout();
6077
fireEvent.click(screen.getByRole("button", { name: /^Show details/ }));

‎app/src/i18n/en/desktop.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ export const desktop = {
1111
allowed: (bot: string, app: string) => `${bot} can see ${app}`,
1212
denied: (bot: string, app: string) => `${bot} may not see ${app}`,
1313
expired: (app: string) => `No answer about ${app}`,
14+
wantsUse: (bot: string) => `${bot} wants to use`,
15+
asksUse: (bot: string, app: string) => `${bot} asks to use ${app}`,
16+
meansUse: (bot: string) =>
17+
`${bot} will click, type and choose in this app's windows as you would, without moving your mouse, while you are at your computer. Every action shows in this chat. Never in password fields. You can take it back in ${bot}'s details.`,
18+
allowedUse: (bot: string, app: string) => `${bot} can use ${app}`,
19+
deniedUse: (bot: string, app: string) => `${bot} may not use ${app}`,
1420
},
1521
grants: {
1622
title: "Your desktop",

‎app/src/i18n/en/tools.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ export const tools = {
5555
desktop_windows: "See your windows",
5656
desktop_look: "Read a window",
5757
desktop_screenshot: "Look at a window",
58+
desktop_click: "Click in a window",
59+
desktop_type: "Type in a window",
60+
desktop_select: "Choose in a window",
61+
desktop_scroll: "Scroll a window",
5862
},
5963
/** Where `browser_scroll` went. */
6064
scroll: { down: "down", up: "up", top: "to the top", bottom: "to the end" },

‎app/src/i18n/es/desktop.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export const desktop: Messages["desktop"] = {
1010
allowed: (bot, app) => `${bot} puede ver ${app}`,
1111
denied: (bot, app) => `${bot} no puede ver ${app}`,
1212
expired: (app) => `Sin respuesta sobre ${app}`,
13+
wantsUse: (bot) => `${bot} quiere usar`,
14+
asksUse: (bot, app) => `${bot} pide usar ${app}`,
15+
meansUse: (bot) =>
16+
`${bot} va a hacer clic, escribir y elegir en las ventanas de esa app como lo harías tú, sin mover tu mouse, mientras estés en la computadora. Cada acción aparece en este chat. Nunca en campos de contraseña. Puedes quitárselo en los detalles de ${bot}.`,
17+
allowedUse: (bot, app) => `${bot} puede usar ${app}`,
18+
deniedUse: (bot, app) => `${bot} no puede usar ${app}`,
1319
},
1420
grants: {
1521
title: "Tu escritorio",

‎app/src/i18n/es/tools.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ export const tools: Messages["tools"] = {
5656
desktop_windows: "Ver tus ventanas",
5757
desktop_look: "Leer una ventana",
5858
desktop_screenshot: "Mirar una ventana",
59+
desktop_click: "Hacer clic en una ventana",
60+
desktop_type: "Escribir en una ventana",
61+
desktop_select: "Elegir en una ventana",
62+
desktop_scroll: "Desplazar una ventana",
5963
},
6064
scroll: {
6165
down: "hacia abajo",

‎app/src/i18n/pt-BR/desktop.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export const desktop: Messages["desktop"] = {
1010
allowed: (bot, app) => `${bot} pode ver ${app}`,
1111
denied: (bot, app) => `${bot} não pode ver ${app}`,
1212
expired: (app) => `Sem resposta sobre ${app}`,
13+
wantsUse: (bot) => `${bot} quer usar`,
14+
asksUse: (bot, app) => `${bot} pede para usar ${app}`,
15+
meansUse: (bot) =>
16+
`${bot} vai clicar, escrever e escolher nas janelas desse app como você faria, sem mexer no seu mouse, enquanto você estiver no computador. Cada ação aparece neste chat. Nunca em campos de senha. Você pode tirar isso nos detalhes de ${bot}.`,
17+
allowedUse: (bot, app) => `${bot} pode usar ${app}`,
18+
deniedUse: (bot, app) => `${bot} não pode usar ${app}`,
1319
},
1420
grants: {
1521
title: "Seu desktop",

‎app/src/i18n/pt-BR/tools.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ export const tools: Messages["tools"] = {
5555
desktop_windows: "Ver suas janelas",
5656
desktop_look: "Ler uma janela",
5757
desktop_screenshot: "Olhar uma janela",
58+
desktop_click: "Clicar numa janela",
59+
desktop_type: "Escrever numa janela",
60+
desktop_select: "Escolher numa janela",
61+
desktop_scroll: "Rolar uma janela",
5862
},
5963
scroll: { down: "para baixo", up: "para cima", top: "até o topo", bottom: "até o fim" },
6064
use: (tool) => `usar ${tool}`,

‎crates/botloftd/src/desktop.rs‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
//! The owner's desktop as the bots use it (spec 24): one action at a time
2-
//! across every bot, and whether the owner is there to see it.
2+
//! across every bot, whether the owner is there to see it, and each bot's
3+
//! last reading, whose refs its actions name.
34
5+
use std::collections::HashMap;
46
use std::sync::atomic::{AtomicUsize, Ordering};
57
use std::sync::{Arc, Mutex, PoisonError};
68
use std::time::Duration;
79

10+
use botloft_core::ids::BotId;
811
use tokio::sync::MutexGuard;
912

13+
use crate::platform::desktop::Control;
14+
1015
/// How long the owner may go without touching the computer and still count
1116
/// as there (spec 24.8).
1217
pub const AWAY_AFTER: Duration = Duration::from_secs(5 * 60);
@@ -15,9 +20,17 @@ pub const AWAY_AFTER: Duration = Duration::from_secs(5 * 60);
1520
/// that cannot be known.
1621
pub type OwnerIdle = Arc<dyn Fn() -> Option<Duration> + Send + Sync>;
1722

23+
/// A bot's last reading of a window: its refs are indexes in `controls`.
24+
#[derive(Debug, Clone)]
25+
pub struct Reading {
26+
pub window: u64,
27+
pub controls: Vec<Control>,
28+
}
29+
1830
pub struct Desktop {
1931
/// App connections that said hello, open now.
2032
apps: AtomicUsize,
33+
readings: Mutex<HashMap<BotId, Reading>>,
2134
/// Held for each desktop action: the cursor is one.
2235
turn: tokio::sync::Mutex<()>,
2336
idle: Mutex<OwnerIdle>,
@@ -52,6 +65,7 @@ impl Desktop {
5265
pub fn new(idle: OwnerIdle) -> Self {
5366
Self {
5467
apps: AtomicUsize::new(0),
68+
readings: Mutex::new(HashMap::new()),
5569
turn: tokio::sync::Mutex::new(()),
5670
idle: Mutex::new(idle),
5771
}
@@ -88,6 +102,23 @@ impl Desktop {
88102
}
89103
}
90104

105+
/// Keeps `controls` as the bot's last reading of `window`.
106+
pub fn keep(&self, bot: &BotId, window: u64, controls: Vec<Control>) {
107+
self.readings
108+
.lock()
109+
.unwrap_or_else(PoisonError::into_inner)
110+
.insert(bot.clone(), Reading { window, controls });
111+
}
112+
113+
/// The bot's last reading, if it read a window.
114+
pub fn reading(&self, bot: &BotId) -> Option<Reading> {
115+
self.readings
116+
.lock()
117+
.unwrap_or_else(PoisonError::into_inner)
118+
.get(bot)
119+
.cloned()
120+
}
121+
91122
/// Waits for the desktop to be free; it is the caller's until dropped.
92123
pub async fn turn(&self) -> MutexGuard<'_, ()> {
93124
self.turn.lock().await

‎crates/botloftd/src/platform/desktop/mod.rs‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,52 @@ pub enum DesktopError {
6464
Minimized,
6565
#[error("Windows could not do it: {0}")]
6666
System(String),
67+
/// The control a reading named is not in the window anymore.
68+
#[error("that control is not in the window anymore")]
69+
NotThere,
70+
/// The control does not take that action through accessibility.
71+
#[error("that control cannot be {0} through accessibility")]
72+
Cannot(&'static str),
73+
/// A password field: the owner types there, never a bot (spec 24.3).
74+
#[error("that is a password field")]
75+
Password,
76+
/// A field that cannot be changed.
77+
#[error("that field cannot be changed")]
78+
ReadOnly,
79+
/// No option with that text in the list.
80+
#[error("there is no option {0:?} there")]
81+
NoOption(String),
82+
}
83+
84+
/// What a bot does to a control through accessibility (spec 24.5).
85+
#[derive(Debug, Clone, PartialEq, Eq)]
86+
pub enum Action {
87+
/// Invokes a button or link, ticks a box, picks an item, opens or
88+
/// closes what expands.
89+
Click,
90+
/// Replaces a field's text.
91+
Type(String),
92+
/// Picks the option with this text in a list or combo box.
93+
Select(String),
94+
Scroll(Scroll),
95+
}
96+
97+
/// Where `Action::Scroll` goes.
98+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99+
pub enum Scroll {
100+
Down,
101+
Up,
102+
Top,
103+
Bottom,
104+
}
105+
106+
/// How an action went.
107+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108+
pub enum Acted {
109+
Done,
110+
/// The app did not answer in time: it may be showing a dialog, or still
111+
/// busy with it.
112+
Waiting,
67113
}
68114

69115
/// The windows open on the owner's desktop: shown ones, minimized too,
@@ -107,6 +153,21 @@ pub fn read(id: u64) -> Result<Vec<Control>, DesktopError> {
107153
}
108154
}
109155

156+
/// Does `action` to the control `target` (its `RuntimeId`, from a reading)
157+
/// in window `id`, through accessibility, without the owner's mouse or
158+
/// keyboard (spec 24.5).
159+
pub fn act(id: u64, target: &[i32], action: &Action) -> Result<Acted, DesktopError> {
160+
#[cfg(windows)]
161+
{
162+
windows::act(id, target, action)
163+
}
164+
#[cfg(not(windows))]
165+
{
166+
let _ = (id, target, action);
167+
Err(DesktopError::Unavailable)
168+
}
169+
}
170+
110171
/// A picture of the window `id`, only of it, even behind other windows, in
111172
/// logical pixels up to [`PICTURE_MAX`] (spec 24.4).
112173
pub fn picture(id: u64) -> Result<Picture, DesktopError> {

0 commit comments

Comments
 (0)