Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/computer-use-mcp/src/backend.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,6 +70,16 @@ pub enum CapturePolicy {
IfSparse,
}

impl CapturePolicy {
pub fn should_capture(self, text_sparse: bool) -> bool {
match self {
Self::Never => false,
Self::Always => true,
Self::IfSparse => text_sparse,
}
}
}

#[derive(Debug, Clone, Copy)]
pub struct ObserveRequest {
pub semantic: bool,
Expand All@@ -80,6 +90,7 @@ pub struct ObserveRequest {
pub struct RootObservation {
pub root: RootInfo,
pub tree: UiNode,
pub text_sparse: bool,
pub screenshot_png: Option<Vec<u8>>,
}

Expand Down
14 changes: 6 additions & 8 deletions crates/computer-use-mcp/src/backend/macos/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,10 @@ use core_graphics::window::{
};

use super::{
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, CapturePolicy,
ObserveRequest, RootFilters, RootInfo, RootObservation,
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, ObserveRequest,
RootFilters, RootInfo, RootObservation,
};
use crate::outline::{UiNode, interactive_count};
use crate::outline::{UiNode, is_text_sparse};

pub(super) struct MacosBackend;

Expand DownExpand Up@@ -108,17 +108,15 @@ impl MacosBackend {
..UiNode::default()
}
};
let should_capture = match request.capture {
CapturePolicy::Never => false,
CapturePolicy::Always => true,
CapturePolicy::IfSparse => interactive_count(&tree) <= 3,
};
let text_sparse = is_text_sparse(&tree);
let should_capture = request.capture.should_capture(text_sparse);
let screenshot_png = should_capture
.then(|| capture::capture_window(root))
.transpose()?;
Ok(RootObservation {
root: root.clone(),
tree,
text_sparse,
screenshot_png,
})
}
Expand Down
69 changes: 69 additions & 0 deletions crates/computer-use-mcp/src/outline.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ pub const MAX_MODEL_LINES: usize = 2_000;
pub const PREVIEW_BYTES: usize = 16 * 1024;
pub const PAGE_BYTES: usize = 16 * 1024;
pub const SEARCH_LIMIT: usize = 20;
pub const TEXT_SPARSE_DESCENDANT_THRESHOLD: usize = 3;
pub const TEXT_SPARSE_MIN_FRAME_AREA: f64 = 20_000.0;

const FOLDED_DEPTH: usize = 7;
const FOLDED_LINES: usize = 500;
Expand DownExpand Up@@ -125,6 +127,30 @@ pub fn interactive_count(root: &UiNode) -> usize {
count
}

/// A large accessibility tree is text-sparse when fewer than three of its
/// descendants expose a title, value, or description. The root window's own
/// title is deliberately excluded because it does not describe the contents.
pub fn is_text_sparse(root: &UiNode) -> bool {
let area = root.frame.w * root.frame.h;
root.frame.has_area()
&& area.is_finite()
&& area >= TEXT_SPARSE_MIN_FRAME_AREA
&& text_bearing_descendant_count(root) < TEXT_SPARSE_DESCENDANT_THRESHOLD
}

fn text_bearing_descendant_count(root: &UiNode) -> usize {
root.children
.iter()
.map(|child| usize::from(node_has_text(child)) + text_bearing_descendant_count(child))
.sum()
}

fn node_has_text(node: &UiNode) -> bool {
[&node.title, &node.value, &node.description]
.into_iter()
.any(|text| !text.trim().is_empty())
}

pub fn assign_refs(root: &mut UiNode) {
let mut next = 1_u64;
walk_mut(root, &mut |node, _| {
Expand DownExpand Up@@ -721,6 +747,49 @@ mod tests {
assert!(!diff.use_full_view);
}

fn framed_tree(children: Vec<UiNode>) -> UiNode {
UiNode {
frame: Frame {
x: 20.0,
y: 30.0,
w: 800.0,
h: 600.0,
},
children,
..node("window", "Root window title", Vec::new())
}
}

#[test]
fn large_tree_with_fewer_than_three_text_descendants_is_sparse() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
]);

assert!(is_text_sparse(&tree));
}

#[test]
fn large_tree_with_three_text_descendants_is_adequate() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
node("static_text", "Ready", Vec::new()),
]);

assert!(!is_text_sparse(&tree));
}

#[test]
fn small_tree_is_not_sparse_even_without_text_descendants() {
let mut tree = framed_tree(Vec::new());
tree.frame.w = 100.0;
tree.frame.h = 100.0;

assert!(!is_text_sparse(&tree));
}

#[test]
fn safe_prefix_ends_on_utf8_and_line_boundaries() {
let text = "a\nβ\ncharlie";
Expand Down
92 changes: 89 additions & 3 deletions crates/computer-use-mcp/src/tools.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -405,15 +405,15 @@ mod dispatch {

pub(super) async fn observe_ui(params: ObserveUiParams) -> CallToolResult {
let permissions = permissions();
let config = crate::config::get();
let needs_accessibility = !matches!(params.mode, Some(ObserveMode::Visual));
let needs_screen_recording =
matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
let needs_screen_recording = config.image_mode != crate::config::ImageMode::Never
&& matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
if let Some(result) =
permission_gate(permissions, needs_accessibility, needs_screen_recording)
{
return result;
}
let config = crate::config::get();
if config.image_mode == crate::config::ImageMode::Always
&& let Some(result) = permission_gate(permissions, false, true)
{
Expand DownExpand Up@@ -858,6 +858,9 @@ mod dispatch {
count_nodes(&observation.tree),
outline::interactive_count(&observation.tree)
);
if observed.text_sparse {
text.push_str("\ntext_sparse: true");
}
if let Some(warning) = warning {
text.push_str("\nwarning: ");
text.push_str(warning);
Expand DownExpand Up@@ -1066,6 +1069,89 @@ mod dispatch {
fn tool_error(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message)])
}

#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use crate::outline::Frame;

fn sparse_observation(screenshot_png: Option<Vec<u8>>) -> RootObservation {
RootObservation {
root: RootInfo {
ref_id: "@r1".into(),
app_name: "Canvas App".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..RootInfo::default()
},
tree: UiNode {
role: "window".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..UiNode::default()
},
text_sparse: true,
screenshot_png,
}
}

#[cfg(target_os = "macos")]
#[test]
fn auto_with_permission_returns_sparse_flag_and_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Auto,
Some(ObserveMode::Semantic),
&permissions,
);
assert_eq!(policy, CapturePolicy::IfSparse);
assert!(policy.should_capture(true));

let result =
save_observation(sparse_observation(Some(vec![0x89, b'P', b'N', b'G'])), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text), ContentBlock::Image(image)]
if text.text.contains("text_sparse: true")
&& image.mime_type == "image/png"
));
}

#[cfg(target_os = "macos")]
#[test]
fn never_returns_sparse_flag_without_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Never,
Some(ObserveMode::Visual),
&permissions,
);
assert_eq!(policy, CapturePolicy::Never);
assert!(!policy.should_capture(true));

let result = save_observation(sparse_observation(None), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text)] if text.text.contains("text_sparse: true")
));
}
}
}

#[cfg(test)]
Expand Down
32 changes: 27 additions & 5 deletions docs/computer-use.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,10 +34,12 @@ Core contract, inherited from pi-computer-use:
- **Bounded output.** Model-visible text is capped; oversized results return a preview plus a
continuation ref for `read_text`.

Deliberate v1 deviations from pi-computer-use (documented so later work can close them):
no OCR/`pictureOnly` nodes, no CDP browser roots (browser automation stays on the
Deliberate deviations from pi-computer-use (documented so later work can close them): no
Windows/UIAutomation backend, no CDP browser roots (browser automation stays on the
`tcode_preview` server and the embedded WebView), no separate helper app (see below), and a
simplified successor-diff heuristic.
simplified successor-diff heuristic. The earlier no-OCR/`pictureOnly`-node deviation is resolved
by raw-image pass-through for text-sparse accessibility trees. By maintainer decision this
fallback does not run OCR or synthesize text nodes; the model reads the attached pixels directly.

## Architecture

Expand All@@ -61,6 +63,25 @@ Unlike pi-computer-use, tcode needs **no helper app**: tcode is itself a signed
Accessibility and Screen Recording grants attach directly to tcode. That removes helper
install/signing/attribution handling entirely.

## Text-sparse image fallback

An observed window of at least 20,000 square screen points is considered text-sparse when fewer
than three accessibility descendants expose a title, value, or description. The root window
title is excluded. A sparse observation includes `text_sparse: true` so the agent knows the AX
outline does not adequately describe the window.

Image mode controls the raw screenshot attachment through the same capture path as other
observations:

- `auto` attaches one window screenshot only when the sparse rule triggers and Screen Recording
permission is available. Without permission, it returns the plain sparse tree and a warning.
- `always` attaches one screenshot to every observation; sparse observations still include the
marker.
- `never` never captures or attaches an image; sparse observations still include the marker.

The window is captured at most once per observation. The fallback is intentionally OCR-free and
does not add `pictureOnly` or other synthesized nodes.

## macOS permissions

| Permission | Needed for | Check | Request |
Expand DownExpand Up@@ -102,5 +123,6 @@ own "Quit & Reopen" dialog. tcode therefore treats any permission flow as a pote
grant permissions inside the VM, then inspect permission status via SSH.
- CI (macOS/Linux/Windows) builds the platform fallback paths and runs the platform-neutral unit
tests:
outline folding and search ranking, state-store eviction and staleness, tool schemas,
settings serde round-trips, and MCP registration wiring for all three provider paths.
outline folding, search ranking, text-sparse fallback decisions and observation shape,
state-store eviction and staleness, tool schemas, settings serde round-trips, and MCP
registration wiring for all three provider paths.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/computer-use-mcp/src/backend.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,6 +70,16 @@ pub enum CapturePolicy {
IfSparse,
}

impl CapturePolicy {
pub fn should_capture(self, text_sparse: bool) -> bool {
match self {
Self::Never => false,
Self::Always => true,
Self::IfSparse => text_sparse,
}
}
}

#[derive(Debug, Clone, Copy)]
pub struct ObserveRequest {
pub semantic: bool,
Expand All@@ -80,6 +90,7 @@ pub struct ObserveRequest {
pub struct RootObservation {
pub root: RootInfo,
pub tree: UiNode,
pub text_sparse: bool,
pub screenshot_png: Option<Vec<u8>>,
}

Expand Down
14 changes: 6 additions & 8 deletions crates/computer-use-mcp/src/backend/macos/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,10 @@ use core_graphics::window::{
};

use super::{
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, CapturePolicy,
ObserveRequest, RootFilters, RootInfo, RootObservation,
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, ObserveRequest,
RootFilters, RootInfo, RootObservation,
};
use crate::outline::{UiNode, interactive_count};
use crate::outline::{UiNode, is_text_sparse};

pub(super) struct MacosBackend;

Expand DownExpand Up@@ -108,17 +108,15 @@ impl MacosBackend {
..UiNode::default()
}
};
let should_capture = match request.capture {
CapturePolicy::Never => false,
CapturePolicy::Always => true,
CapturePolicy::IfSparse => interactive_count(&tree) <= 3,
};
let text_sparse = is_text_sparse(&tree);
let should_capture = request.capture.should_capture(text_sparse);
let screenshot_png = should_capture
.then(|| capture::capture_window(root))
.transpose()?;
Ok(RootObservation {
root: root.clone(),
tree,
text_sparse,
screenshot_png,
})
}
Expand Down
69 changes: 69 additions & 0 deletions crates/computer-use-mcp/src/outline.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ pub const MAX_MODEL_LINES: usize = 2_000;
pub const PREVIEW_BYTES: usize = 16 * 1024;
pub const PAGE_BYTES: usize = 16 * 1024;
pub const SEARCH_LIMIT: usize = 20;
pub const TEXT_SPARSE_DESCENDANT_THRESHOLD: usize = 3;
pub const TEXT_SPARSE_MIN_FRAME_AREA: f64 = 20_000.0;

const FOLDED_DEPTH: usize = 7;
const FOLDED_LINES: usize = 500;
Expand DownExpand Up@@ -125,6 +127,30 @@ pub fn interactive_count(root: &UiNode) -> usize {
count
}

/// A large accessibility tree is text-sparse when fewer than three of its
/// descendants expose a title, value, or description. The root window's own
/// title is deliberately excluded because it does not describe the contents.
pub fn is_text_sparse(root: &UiNode) -> bool {
let area = root.frame.w * root.frame.h;
root.frame.has_area()
&& area.is_finite()
&& area >= TEXT_SPARSE_MIN_FRAME_AREA
&& text_bearing_descendant_count(root) < TEXT_SPARSE_DESCENDANT_THRESHOLD
}

fn text_bearing_descendant_count(root: &UiNode) -> usize {
root.children
.iter()
.map(|child| usize::from(node_has_text(child)) + text_bearing_descendant_count(child))
.sum()
}

fn node_has_text(node: &UiNode) -> bool {
[&node.title, &node.value, &node.description]
.into_iter()
.any(|text| !text.trim().is_empty())
}

pub fn assign_refs(root: &mut UiNode) {
let mut next = 1_u64;
walk_mut(root, &mut |node, _| {
Expand DownExpand Up@@ -721,6 +747,49 @@ mod tests {
assert!(!diff.use_full_view);
}

fn framed_tree(children: Vec<UiNode>) -> UiNode {
UiNode {
frame: Frame {
x: 20.0,
y: 30.0,
w: 800.0,
h: 600.0,
},
children,
..node("window", "Root window title", Vec::new())
}
}

#[test]
fn large_tree_with_fewer_than_three_text_descendants_is_sparse() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
]);

assert!(is_text_sparse(&tree));
}

#[test]
fn large_tree_with_three_text_descendants_is_adequate() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
node("static_text", "Ready", Vec::new()),
]);

assert!(!is_text_sparse(&tree));
}

#[test]
fn small_tree_is_not_sparse_even_without_text_descendants() {
let mut tree = framed_tree(Vec::new());
tree.frame.w = 100.0;
tree.frame.h = 100.0;

assert!(!is_text_sparse(&tree));
}

#[test]
fn safe_prefix_ends_on_utf8_and_line_boundaries() {
let text = "a\nβ\ncharlie";
Expand Down
92 changes: 89 additions & 3 deletions crates/computer-use-mcp/src/tools.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -405,15 +405,15 @@ mod dispatch {

pub(super) async fn observe_ui(params: ObserveUiParams) -> CallToolResult {
let permissions = permissions();
let config = crate::config::get();
let needs_accessibility = !matches!(params.mode, Some(ObserveMode::Visual));
let needs_screen_recording =
matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
let needs_screen_recording = config.image_mode != crate::config::ImageMode::Never
&& matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
if let Some(result) =
permission_gate(permissions, needs_accessibility, needs_screen_recording)
{
return result;
}
let config = crate::config::get();
if config.image_mode == crate::config::ImageMode::Always
&& let Some(result) = permission_gate(permissions, false, true)
{
Expand DownExpand Up@@ -858,6 +858,9 @@ mod dispatch {
count_nodes(&observation.tree),
outline::interactive_count(&observation.tree)
);
if observed.text_sparse {
text.push_str("\ntext_sparse: true");
}
if let Some(warning) = warning {
text.push_str("\nwarning: ");
text.push_str(warning);
Expand DownExpand Up@@ -1066,6 +1069,89 @@ mod dispatch {
fn tool_error(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message)])
}

#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use crate::outline::Frame;

fn sparse_observation(screenshot_png: Option<Vec<u8>>) -> RootObservation {
RootObservation {
root: RootInfo {
ref_id: "@r1".into(),
app_name: "Canvas App".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..RootInfo::default()
},
tree: UiNode {
role: "window".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..UiNode::default()
},
text_sparse: true,
screenshot_png,
}
}

#[cfg(target_os = "macos")]
#[test]
fn auto_with_permission_returns_sparse_flag_and_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Auto,
Some(ObserveMode::Semantic),
&permissions,
);
assert_eq!(policy, CapturePolicy::IfSparse);
assert!(policy.should_capture(true));

let result =
save_observation(sparse_observation(Some(vec![0x89, b'P', b'N', b'G'])), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text), ContentBlock::Image(image)]
if text.text.contains("text_sparse: true")
&& image.mime_type == "image/png"
));
}

#[cfg(target_os = "macos")]
#[test]
fn never_returns_sparse_flag_without_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Never,
Some(ObserveMode::Visual),
&permissions,
);
assert_eq!(policy, CapturePolicy::Never);
assert!(!policy.should_capture(true));

let result = save_observation(sparse_observation(None), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text)] if text.text.contains("text_sparse: true")
));
}
}
}

#[cfg(test)]
Expand Down
32 changes: 27 additions & 5 deletions docs/computer-use.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,10 +34,12 @@ Core contract, inherited from pi-computer-use:
- **Bounded output.** Model-visible text is capped; oversized results return a preview plus a
continuation ref for `read_text`.

Deliberate v1 deviations from pi-computer-use (documented so later work can close them):
no OCR/`pictureOnly` nodes, no CDP browser roots (browser automation stays on the
Deliberate deviations from pi-computer-use (documented so later work can close them): no
Windows/UIAutomation backend, no CDP browser roots (browser automation stays on the
`tcode_preview` server and the embedded WebView), no separate helper app (see below), and a
simplified successor-diff heuristic.
simplified successor-diff heuristic. The earlier no-OCR/`pictureOnly`-node deviation is resolved
by raw-image pass-through for text-sparse accessibility trees. By maintainer decision this
fallback does not run OCR or synthesize text nodes; the model reads the attached pixels directly.

## Architecture

Expand All@@ -61,6 +63,25 @@ Unlike pi-computer-use, tcode needs **no helper app**: tcode is itself a signed
Accessibility and Screen Recording grants attach directly to tcode. That removes helper
install/signing/attribution handling entirely.

## Text-sparse image fallback

An observed window of at least 20,000 square screen points is considered text-sparse when fewer
than three accessibility descendants expose a title, value, or description. The root window
title is excluded. A sparse observation includes `text_sparse: true` so the agent knows the AX
outline does not adequately describe the window.

Image mode controls the raw screenshot attachment through the same capture path as other
observations:

- `auto` attaches one window screenshot only when the sparse rule triggers and Screen Recording
permission is available. Without permission, it returns the plain sparse tree and a warning.
- `always` attaches one screenshot to every observation; sparse observations still include the
marker.
- `never` never captures or attaches an image; sparse observations still include the marker.

The window is captured at most once per observation. The fallback is intentionally OCR-free and
does not add `pictureOnly` or other synthesized nodes.

## macOS permissions

| Permission | Needed for | Check | Request |
Expand DownExpand Up@@ -102,5 +123,6 @@ own "Quit & Reopen" dialog. tcode therefore treats any permission flow as a pote
grant permissions inside the VM, then inspect permission status via SSH.
- CI (macOS/Linux/Windows) builds the platform fallback paths and runs the platform-neutral unit
tests:
outline folding and search ranking, state-store eviction and staleness, tool schemas,
settings serde round-trips, and MCP registration wiring for all three provider paths.
outline folding, search ranking, text-sparse fallback decisions and observation shape,
state-store eviction and staleness, tool schemas, settings serde round-trips, and MCP
registration wiring for all three provider paths.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/computer-use-mcp/src/backend.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,6 +70,16 @@ pub enum CapturePolicy {
IfSparse,
}

impl CapturePolicy {
pub fn should_capture(self, text_sparse: bool) -> bool {
match self {
Self::Never => false,
Self::Always => true,
Self::IfSparse => text_sparse,
}
}
}

#[derive(Debug, Clone, Copy)]
pub struct ObserveRequest {
pub semantic: bool,
Expand All@@ -80,6 +90,7 @@ pub struct ObserveRequest {
pub struct RootObservation {
pub root: RootInfo,
pub tree: UiNode,
pub text_sparse: bool,
pub screenshot_png: Option<Vec<u8>>,
}

Expand Down
14 changes: 6 additions & 8 deletions crates/computer-use-mcp/src/backend/macos/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,10 @@ use core_graphics::window::{
};

use super::{
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, CapturePolicy,
ObserveRequest, RootFilters, RootInfo, RootObservation,
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, ObserveRequest,
RootFilters, RootInfo, RootObservation,
};
use crate::outline::{UiNode, interactive_count};
use crate::outline::{UiNode, is_text_sparse};

pub(super) struct MacosBackend;

Expand DownExpand Up@@ -108,17 +108,15 @@ impl MacosBackend {
..UiNode::default()
}
};
let should_capture = match request.capture {
CapturePolicy::Never => false,
CapturePolicy::Always => true,
CapturePolicy::IfSparse => interactive_count(&tree) <= 3,
};
let text_sparse = is_text_sparse(&tree);
let should_capture = request.capture.should_capture(text_sparse);
let screenshot_png = should_capture
.then(|| capture::capture_window(root))
.transpose()?;
Ok(RootObservation {
root: root.clone(),
tree,
text_sparse,
screenshot_png,
})
}
Expand Down
69 changes: 69 additions & 0 deletions crates/computer-use-mcp/src/outline.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ pub const MAX_MODEL_LINES: usize = 2_000;
pub const PREVIEW_BYTES: usize = 16 * 1024;
pub const PAGE_BYTES: usize = 16 * 1024;
pub const SEARCH_LIMIT: usize = 20;
pub const TEXT_SPARSE_DESCENDANT_THRESHOLD: usize = 3;
pub const TEXT_SPARSE_MIN_FRAME_AREA: f64 = 20_000.0;

const FOLDED_DEPTH: usize = 7;
const FOLDED_LINES: usize = 500;
Expand DownExpand Up@@ -125,6 +127,30 @@ pub fn interactive_count(root: &UiNode) -> usize {
count
}

/// A large accessibility tree is text-sparse when fewer than three of its
/// descendants expose a title, value, or description. The root window's own
/// title is deliberately excluded because it does not describe the contents.
pub fn is_text_sparse(root: &UiNode) -> bool {
let area = root.frame.w * root.frame.h;
root.frame.has_area()
&& area.is_finite()
&& area >= TEXT_SPARSE_MIN_FRAME_AREA
&& text_bearing_descendant_count(root) < TEXT_SPARSE_DESCENDANT_THRESHOLD
}

fn text_bearing_descendant_count(root: &UiNode) -> usize {
root.children
.iter()
.map(|child| usize::from(node_has_text(child)) + text_bearing_descendant_count(child))
.sum()
}

fn node_has_text(node: &UiNode) -> bool {
[&node.title, &node.value, &node.description]
.into_iter()
.any(|text| !text.trim().is_empty())
}

pub fn assign_refs(root: &mut UiNode) {
let mut next = 1_u64;
walk_mut(root, &mut |node, _| {
Expand DownExpand Up@@ -721,6 +747,49 @@ mod tests {
assert!(!diff.use_full_view);
}

fn framed_tree(children: Vec<UiNode>) -> UiNode {
UiNode {
frame: Frame {
x: 20.0,
y: 30.0,
w: 800.0,
h: 600.0,
},
children,
..node("window", "Root window title", Vec::new())
}
}

#[test]
fn large_tree_with_fewer_than_three_text_descendants_is_sparse() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
]);

assert!(is_text_sparse(&tree));
}

#[test]
fn large_tree_with_three_text_descendants_is_adequate() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
node("static_text", "Ready", Vec::new()),
]);

assert!(!is_text_sparse(&tree));
}

#[test]
fn small_tree_is_not_sparse_even_without_text_descendants() {
let mut tree = framed_tree(Vec::new());
tree.frame.w = 100.0;
tree.frame.h = 100.0;

assert!(!is_text_sparse(&tree));
}

#[test]
fn safe_prefix_ends_on_utf8_and_line_boundaries() {
let text = "a\nβ\ncharlie";
Expand Down
92 changes: 89 additions & 3 deletions crates/computer-use-mcp/src/tools.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -405,15 +405,15 @@ mod dispatch {

pub(super) async fn observe_ui(params: ObserveUiParams) -> CallToolResult {
let permissions = permissions();
let config = crate::config::get();
let needs_accessibility = !matches!(params.mode, Some(ObserveMode::Visual));
let needs_screen_recording =
matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
let needs_screen_recording = config.image_mode != crate::config::ImageMode::Never
&& matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
if let Some(result) =
permission_gate(permissions, needs_accessibility, needs_screen_recording)
{
return result;
}
let config = crate::config::get();
if config.image_mode == crate::config::ImageMode::Always
&& let Some(result) = permission_gate(permissions, false, true)
{
Expand DownExpand Up@@ -858,6 +858,9 @@ mod dispatch {
count_nodes(&observation.tree),
outline::interactive_count(&observation.tree)
);
if observed.text_sparse {
text.push_str("\ntext_sparse: true");
}
if let Some(warning) = warning {
text.push_str("\nwarning: ");
text.push_str(warning);
Expand DownExpand Up@@ -1066,6 +1069,89 @@ mod dispatch {
fn tool_error(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message)])
}

#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use crate::outline::Frame;

fn sparse_observation(screenshot_png: Option<Vec<u8>>) -> RootObservation {
RootObservation {
root: RootInfo {
ref_id: "@r1".into(),
app_name: "Canvas App".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..RootInfo::default()
},
tree: UiNode {
role: "window".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..UiNode::default()
},
text_sparse: true,
screenshot_png,
}
}

#[cfg(target_os = "macos")]
#[test]
fn auto_with_permission_returns_sparse_flag_and_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Auto,
Some(ObserveMode::Semantic),
&permissions,
);
assert_eq!(policy, CapturePolicy::IfSparse);
assert!(policy.should_capture(true));

let result =
save_observation(sparse_observation(Some(vec![0x89, b'P', b'N', b'G'])), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text), ContentBlock::Image(image)]
if text.text.contains("text_sparse: true")
&& image.mime_type == "image/png"
));
}

#[cfg(target_os = "macos")]
#[test]
fn never_returns_sparse_flag_without_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Never,
Some(ObserveMode::Visual),
&permissions,
);
assert_eq!(policy, CapturePolicy::Never);
assert!(!policy.should_capture(true));

let result = save_observation(sparse_observation(None), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text)] if text.text.contains("text_sparse: true")
));
}
}
}

#[cfg(test)]
Expand Down
32 changes: 27 additions & 5 deletions docs/computer-use.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,10 +34,12 @@ Core contract, inherited from pi-computer-use:
- **Bounded output.** Model-visible text is capped; oversized results return a preview plus a
continuation ref for `read_text`.

Deliberate v1 deviations from pi-computer-use (documented so later work can close them):
no OCR/`pictureOnly` nodes, no CDP browser roots (browser automation stays on the
Deliberate deviations from pi-computer-use (documented so later work can close them): no
Windows/UIAutomation backend, no CDP browser roots (browser automation stays on the
`tcode_preview` server and the embedded WebView), no separate helper app (see below), and a
simplified successor-diff heuristic.
simplified successor-diff heuristic. The earlier no-OCR/`pictureOnly`-node deviation is resolved
by raw-image pass-through for text-sparse accessibility trees. By maintainer decision this
fallback does not run OCR or synthesize text nodes; the model reads the attached pixels directly.

## Architecture

Expand All@@ -61,6 +63,25 @@ Unlike pi-computer-use, tcode needs **no helper app**: tcode is itself a signed
Accessibility and Screen Recording grants attach directly to tcode. That removes helper
install/signing/attribution handling entirely.

## Text-sparse image fallback

An observed window of at least 20,000 square screen points is considered text-sparse when fewer
than three accessibility descendants expose a title, value, or description. The root window
title is excluded. A sparse observation includes `text_sparse: true` so the agent knows the AX
outline does not adequately describe the window.

Image mode controls the raw screenshot attachment through the same capture path as other
observations:

- `auto` attaches one window screenshot only when the sparse rule triggers and Screen Recording
permission is available. Without permission, it returns the plain sparse tree and a warning.
- `always` attaches one screenshot to every observation; sparse observations still include the
marker.
- `never` never captures or attaches an image; sparse observations still include the marker.

The window is captured at most once per observation. The fallback is intentionally OCR-free and
does not add `pictureOnly` or other synthesized nodes.

## macOS permissions

| Permission | Needed for | Check | Request |
Expand DownExpand Up@@ -102,5 +123,6 @@ own "Quit & Reopen" dialog. tcode therefore treats any permission flow as a pote
grant permissions inside the VM, then inspect permission status via SSH.
- CI (macOS/Linux/Windows) builds the platform fallback paths and runs the platform-neutral unit
tests:
outline folding and search ranking, state-store eviction and staleness, tool schemas,
settings serde round-trips, and MCP registration wiring for all three provider paths.
outline folding, search ranking, text-sparse fallback decisions and observation shape,
state-store eviction and staleness, tool schemas, settings serde round-trips, and MCP
registration wiring for all three provider paths.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/computer-use-mcp/src/backend.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,6 +70,16 @@ pub enum CapturePolicy {
IfSparse,
}

impl CapturePolicy {
pub fn should_capture(self, text_sparse: bool) -> bool {
match self {
Self::Never => false,
Self::Always => true,
Self::IfSparse => text_sparse,
}
}
}

#[derive(Debug, Clone, Copy)]
pub struct ObserveRequest {
pub semantic: bool,
Expand All@@ -80,6 +90,7 @@ pub struct ObserveRequest {
pub struct RootObservation {
pub root: RootInfo,
pub tree: UiNode,
pub text_sparse: bool,
pub screenshot_png: Option<Vec<u8>>,
}

Expand Down
14 changes: 6 additions & 8 deletions crates/computer-use-mcp/src/backend/macos/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,10 @@ use core_graphics::window::{
};

use super::{
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, CapturePolicy,
ObserveRequest, RootFilters, RootInfo, RootObservation,
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, ObserveRequest,
RootFilters, RootInfo, RootObservation,
};
use crate::outline::{UiNode, interactive_count};
use crate::outline::{UiNode, is_text_sparse};

pub(super) struct MacosBackend;

Expand DownExpand Up@@ -108,17 +108,15 @@ impl MacosBackend {
..UiNode::default()
}
};
let should_capture = match request.capture {
CapturePolicy::Never => false,
CapturePolicy::Always => true,
CapturePolicy::IfSparse => interactive_count(&tree) <= 3,
};
let text_sparse = is_text_sparse(&tree);
let should_capture = request.capture.should_capture(text_sparse);
let screenshot_png = should_capture
.then(|| capture::capture_window(root))
.transpose()?;
Ok(RootObservation {
root: root.clone(),
tree,
text_sparse,
screenshot_png,
})
}
Expand Down
69 changes: 69 additions & 0 deletions crates/computer-use-mcp/src/outline.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ pub const MAX_MODEL_LINES: usize = 2_000;
pub const PREVIEW_BYTES: usize = 16 * 1024;
pub const PAGE_BYTES: usize = 16 * 1024;
pub const SEARCH_LIMIT: usize = 20;
pub const TEXT_SPARSE_DESCENDANT_THRESHOLD: usize = 3;
pub const TEXT_SPARSE_MIN_FRAME_AREA: f64 = 20_000.0;

const FOLDED_DEPTH: usize = 7;
const FOLDED_LINES: usize = 500;
Expand DownExpand Up@@ -125,6 +127,30 @@ pub fn interactive_count(root: &UiNode) -> usize {
count
}

/// A large accessibility tree is text-sparse when fewer than three of its
/// descendants expose a title, value, or description. The root window's own
/// title is deliberately excluded because it does not describe the contents.
pub fn is_text_sparse(root: &UiNode) -> bool {
let area = root.frame.w * root.frame.h;
root.frame.has_area()
&& area.is_finite()
&& area >= TEXT_SPARSE_MIN_FRAME_AREA
&& text_bearing_descendant_count(root) < TEXT_SPARSE_DESCENDANT_THRESHOLD
}

fn text_bearing_descendant_count(root: &UiNode) -> usize {
root.children
.iter()
.map(|child| usize::from(node_has_text(child)) + text_bearing_descendant_count(child))
.sum()
}

fn node_has_text(node: &UiNode) -> bool {
[&node.title, &node.value, &node.description]
.into_iter()
.any(|text| !text.trim().is_empty())
}

pub fn assign_refs(root: &mut UiNode) {
let mut next = 1_u64;
walk_mut(root, &mut |node, _| {
Expand DownExpand Up@@ -721,6 +747,49 @@ mod tests {
assert!(!diff.use_full_view);
}

fn framed_tree(children: Vec<UiNode>) -> UiNode {
UiNode {
frame: Frame {
x: 20.0,
y: 30.0,
w: 800.0,
h: 600.0,
},
children,
..node("window", "Root window title", Vec::new())
}
}

#[test]
fn large_tree_with_fewer_than_three_text_descendants_is_sparse() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
]);

assert!(is_text_sparse(&tree));
}

#[test]
fn large_tree_with_three_text_descendants_is_adequate() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
node("static_text", "Ready", Vec::new()),
]);

assert!(!is_text_sparse(&tree));
}

#[test]
fn small_tree_is_not_sparse_even_without_text_descendants() {
let mut tree = framed_tree(Vec::new());
tree.frame.w = 100.0;
tree.frame.h = 100.0;

assert!(!is_text_sparse(&tree));
}

#[test]
fn safe_prefix_ends_on_utf8_and_line_boundaries() {
let text = "a\nβ\ncharlie";
Expand Down
92 changes: 89 additions & 3 deletions crates/computer-use-mcp/src/tools.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -405,15 +405,15 @@ mod dispatch {

pub(super) async fn observe_ui(params: ObserveUiParams) -> CallToolResult {
let permissions = permissions();
let config = crate::config::get();
let needs_accessibility = !matches!(params.mode, Some(ObserveMode::Visual));
let needs_screen_recording =
matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
let needs_screen_recording = config.image_mode != crate::config::ImageMode::Never
&& matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
if let Some(result) =
permission_gate(permissions, needs_accessibility, needs_screen_recording)
{
return result;
}
let config = crate::config::get();
if config.image_mode == crate::config::ImageMode::Always
&& let Some(result) = permission_gate(permissions, false, true)
{
Expand DownExpand Up@@ -858,6 +858,9 @@ mod dispatch {
count_nodes(&observation.tree),
outline::interactive_count(&observation.tree)
);
if observed.text_sparse {
text.push_str("\ntext_sparse: true");
}
if let Some(warning) = warning {
text.push_str("\nwarning: ");
text.push_str(warning);
Expand DownExpand Up@@ -1066,6 +1069,89 @@ mod dispatch {
fn tool_error(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message)])
}

#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use crate::outline::Frame;

fn sparse_observation(screenshot_png: Option<Vec<u8>>) -> RootObservation {
RootObservation {
root: RootInfo {
ref_id: "@r1".into(),
app_name: "Canvas App".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..RootInfo::default()
},
tree: UiNode {
role: "window".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..UiNode::default()
},
text_sparse: true,
screenshot_png,
}
}

#[cfg(target_os = "macos")]
#[test]
fn auto_with_permission_returns_sparse_flag_and_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Auto,
Some(ObserveMode::Semantic),
&permissions,
);
assert_eq!(policy, CapturePolicy::IfSparse);
assert!(policy.should_capture(true));

let result =
save_observation(sparse_observation(Some(vec![0x89, b'P', b'N', b'G'])), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text), ContentBlock::Image(image)]
if text.text.contains("text_sparse: true")
&& image.mime_type == "image/png"
));
}

#[cfg(target_os = "macos")]
#[test]
fn never_returns_sparse_flag_without_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Never,
Some(ObserveMode::Visual),
&permissions,
);
assert_eq!(policy, CapturePolicy::Never);
assert!(!policy.should_capture(true));

let result = save_observation(sparse_observation(None), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text)] if text.text.contains("text_sparse: true")
));
}
}
}

#[cfg(test)]
Expand Down
32 changes: 27 additions & 5 deletions docs/computer-use.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,10 +34,12 @@ Core contract, inherited from pi-computer-use:
- **Bounded output.** Model-visible text is capped; oversized results return a preview plus a
continuation ref for `read_text`.

Deliberate v1 deviations from pi-computer-use (documented so later work can close them):
no OCR/`pictureOnly` nodes, no CDP browser roots (browser automation stays on the
Deliberate deviations from pi-computer-use (documented so later work can close them): no
Windows/UIAutomation backend, no CDP browser roots (browser automation stays on the
`tcode_preview` server and the embedded WebView), no separate helper app (see below), and a
simplified successor-diff heuristic.
simplified successor-diff heuristic. The earlier no-OCR/`pictureOnly`-node deviation is resolved
by raw-image pass-through for text-sparse accessibility trees. By maintainer decision this
fallback does not run OCR or synthesize text nodes; the model reads the attached pixels directly.

## Architecture

Expand All@@ -61,6 +63,25 @@ Unlike pi-computer-use, tcode needs **no helper app**: tcode is itself a signed
Accessibility and Screen Recording grants attach directly to tcode. That removes helper
install/signing/attribution handling entirely.

## Text-sparse image fallback

An observed window of at least 20,000 square screen points is considered text-sparse when fewer
than three accessibility descendants expose a title, value, or description. The root window
title is excluded. A sparse observation includes `text_sparse: true` so the agent knows the AX
outline does not adequately describe the window.

Image mode controls the raw screenshot attachment through the same capture path as other
observations:

- `auto` attaches one window screenshot only when the sparse rule triggers and Screen Recording
permission is available. Without permission, it returns the plain sparse tree and a warning.
- `always` attaches one screenshot to every observation; sparse observations still include the
marker.
- `never` never captures or attaches an image; sparse observations still include the marker.

The window is captured at most once per observation. The fallback is intentionally OCR-free and
does not add `pictureOnly` or other synthesized nodes.

## macOS permissions

| Permission | Needed for | Check | Request |
Expand DownExpand Up@@ -102,5 +123,6 @@ own "Quit & Reopen" dialog. tcode therefore treats any permission flow as a pote
grant permissions inside the VM, then inspect permission status via SSH.
- CI (macOS/Linux/Windows) builds the platform fallback paths and runs the platform-neutral unit
tests:
outline folding and search ranking, state-store eviction and staleness, tool schemas,
settings serde round-trips, and MCP registration wiring for all three provider paths.
outline folding, search ranking, text-sparse fallback decisions and observation shape,
state-store eviction and staleness, tool schemas, settings serde round-trips, and MCP
registration wiring for all three provider paths.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/computer-use-mcp/src/backend.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,6 +70,16 @@ pub enum CapturePolicy {
IfSparse,
}

impl CapturePolicy {
pub fn should_capture(self, text_sparse: bool) -> bool {
match self {
Self::Never => false,
Self::Always => true,
Self::IfSparse => text_sparse,
}
}
}

#[derive(Debug, Clone, Copy)]
pub struct ObserveRequest {
pub semantic: bool,
Expand All@@ -80,6 +90,7 @@ pub struct ObserveRequest {
pub struct RootObservation {
pub root: RootInfo,
pub tree: UiNode,
pub text_sparse: bool,
pub screenshot_png: Option<Vec<u8>>,
}

Expand Down
14 changes: 6 additions & 8 deletions crates/computer-use-mcp/src/backend/macos/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,10 @@ use core_graphics::window::{
};

use super::{
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, CapturePolicy,
ObserveRequest, RootFilters, RootInfo, RootObservation,
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, ObserveRequest,
RootFilters, RootInfo, RootObservation,
};
use crate::outline::{UiNode, interactive_count};
use crate::outline::{UiNode, is_text_sparse};

pub(super) struct MacosBackend;

Expand DownExpand Up@@ -108,17 +108,15 @@ impl MacosBackend {
..UiNode::default()
}
};
let should_capture = match request.capture {
CapturePolicy::Never => false,
CapturePolicy::Always => true,
CapturePolicy::IfSparse => interactive_count(&tree) <= 3,
};
let text_sparse = is_text_sparse(&tree);
let should_capture = request.capture.should_capture(text_sparse);
let screenshot_png = should_capture
.then(|| capture::capture_window(root))
.transpose()?;
Ok(RootObservation {
root: root.clone(),
tree,
text_sparse,
screenshot_png,
})
}
Expand Down
69 changes: 69 additions & 0 deletions crates/computer-use-mcp/src/outline.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ pub const MAX_MODEL_LINES: usize = 2_000;
pub const PREVIEW_BYTES: usize = 16 * 1024;
pub const PAGE_BYTES: usize = 16 * 1024;
pub const SEARCH_LIMIT: usize = 20;
pub const TEXT_SPARSE_DESCENDANT_THRESHOLD: usize = 3;
pub const TEXT_SPARSE_MIN_FRAME_AREA: f64 = 20_000.0;

const FOLDED_DEPTH: usize = 7;
const FOLDED_LINES: usize = 500;
Expand DownExpand Up@@ -125,6 +127,30 @@ pub fn interactive_count(root: &UiNode) -> usize {
count
}

/// A large accessibility tree is text-sparse when fewer than three of its
/// descendants expose a title, value, or description. The root window's own
/// title is deliberately excluded because it does not describe the contents.
pub fn is_text_sparse(root: &UiNode) -> bool {
let area = root.frame.w * root.frame.h;
root.frame.has_area()
&& area.is_finite()
&& area >= TEXT_SPARSE_MIN_FRAME_AREA
&& text_bearing_descendant_count(root) < TEXT_SPARSE_DESCENDANT_THRESHOLD
}

fn text_bearing_descendant_count(root: &UiNode) -> usize {
root.children
.iter()
.map(|child| usize::from(node_has_text(child)) + text_bearing_descendant_count(child))
.sum()
}

fn node_has_text(node: &UiNode) -> bool {
[&node.title, &node.value, &node.description]
.into_iter()
.any(|text| !text.trim().is_empty())
}

pub fn assign_refs(root: &mut UiNode) {
let mut next = 1_u64;
walk_mut(root, &mut |node, _| {
Expand DownExpand Up@@ -721,6 +747,49 @@ mod tests {
assert!(!diff.use_full_view);
}

fn framed_tree(children: Vec<UiNode>) -> UiNode {
UiNode {
frame: Frame {
x: 20.0,
y: 30.0,
w: 800.0,
h: 600.0,
},
children,
..node("window", "Root window title", Vec::new())
}
}

#[test]
fn large_tree_with_fewer_than_three_text_descendants_is_sparse() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
]);

assert!(is_text_sparse(&tree));
}

#[test]
fn large_tree_with_three_text_descendants_is_adequate() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
node("static_text", "Ready", Vec::new()),
]);

assert!(!is_text_sparse(&tree));
}

#[test]
fn small_tree_is_not_sparse_even_without_text_descendants() {
let mut tree = framed_tree(Vec::new());
tree.frame.w = 100.0;
tree.frame.h = 100.0;

assert!(!is_text_sparse(&tree));
}

#[test]
fn safe_prefix_ends_on_utf8_and_line_boundaries() {
let text = "a\nβ\ncharlie";
Expand Down
92 changes: 89 additions & 3 deletions crates/computer-use-mcp/src/tools.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -405,15 +405,15 @@ mod dispatch {

pub(super) async fn observe_ui(params: ObserveUiParams) -> CallToolResult {
let permissions = permissions();
let config = crate::config::get();
let needs_accessibility = !matches!(params.mode, Some(ObserveMode::Visual));
let needs_screen_recording =
matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
let needs_screen_recording = config.image_mode != crate::config::ImageMode::Never
&& matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
if let Some(result) =
permission_gate(permissions, needs_accessibility, needs_screen_recording)
{
return result;
}
let config = crate::config::get();
if config.image_mode == crate::config::ImageMode::Always
&& let Some(result) = permission_gate(permissions, false, true)
{
Expand DownExpand Up@@ -858,6 +858,9 @@ mod dispatch {
count_nodes(&observation.tree),
outline::interactive_count(&observation.tree)
);
if observed.text_sparse {
text.push_str("\ntext_sparse: true");
}
if let Some(warning) = warning {
text.push_str("\nwarning: ");
text.push_str(warning);
Expand DownExpand Up@@ -1066,6 +1069,89 @@ mod dispatch {
fn tool_error(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message)])
}

#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use crate::outline::Frame;

fn sparse_observation(screenshot_png: Option<Vec<u8>>) -> RootObservation {
RootObservation {
root: RootInfo {
ref_id: "@r1".into(),
app_name: "Canvas App".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..RootInfo::default()
},
tree: UiNode {
role: "window".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..UiNode::default()
},
text_sparse: true,
screenshot_png,
}
}

#[cfg(target_os = "macos")]
#[test]
fn auto_with_permission_returns_sparse_flag_and_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Auto,
Some(ObserveMode::Semantic),
&permissions,
);
assert_eq!(policy, CapturePolicy::IfSparse);
assert!(policy.should_capture(true));

let result =
save_observation(sparse_observation(Some(vec![0x89, b'P', b'N', b'G'])), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text), ContentBlock::Image(image)]
if text.text.contains("text_sparse: true")
&& image.mime_type == "image/png"
));
}

#[cfg(target_os = "macos")]
#[test]
fn never_returns_sparse_flag_without_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Never,
Some(ObserveMode::Visual),
&permissions,
);
assert_eq!(policy, CapturePolicy::Never);
assert!(!policy.should_capture(true));

let result = save_observation(sparse_observation(None), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text)] if text.text.contains("text_sparse: true")
));
}
}
}

#[cfg(test)]
Expand Down
32 changes: 27 additions & 5 deletions docs/computer-use.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,10 +34,12 @@ Core contract, inherited from pi-computer-use:
- **Bounded output.** Model-visible text is capped; oversized results return a preview plus a
continuation ref for `read_text`.

Deliberate v1 deviations from pi-computer-use (documented so later work can close them):
no OCR/`pictureOnly` nodes, no CDP browser roots (browser automation stays on the
Deliberate deviations from pi-computer-use (documented so later work can close them): no
Windows/UIAutomation backend, no CDP browser roots (browser automation stays on the
`tcode_preview` server and the embedded WebView), no separate helper app (see below), and a
simplified successor-diff heuristic.
simplified successor-diff heuristic. The earlier no-OCR/`pictureOnly`-node deviation is resolved
by raw-image pass-through for text-sparse accessibility trees. By maintainer decision this
fallback does not run OCR or synthesize text nodes; the model reads the attached pixels directly.

## Architecture

Expand All@@ -61,6 +63,25 @@ Unlike pi-computer-use, tcode needs **no helper app**: tcode is itself a signed
Accessibility and Screen Recording grants attach directly to tcode. That removes helper
install/signing/attribution handling entirely.

## Text-sparse image fallback

An observed window of at least 20,000 square screen points is considered text-sparse when fewer
than three accessibility descendants expose a title, value, or description. The root window
title is excluded. A sparse observation includes `text_sparse: true` so the agent knows the AX
outline does not adequately describe the window.

Image mode controls the raw screenshot attachment through the same capture path as other
observations:

- `auto` attaches one window screenshot only when the sparse rule triggers and Screen Recording
permission is available. Without permission, it returns the plain sparse tree and a warning.
- `always` attaches one screenshot to every observation; sparse observations still include the
marker.
- `never` never captures or attaches an image; sparse observations still include the marker.

The window is captured at most once per observation. The fallback is intentionally OCR-free and
does not add `pictureOnly` or other synthesized nodes.

## macOS permissions

| Permission | Needed for | Check | Request |
Expand DownExpand Up@@ -102,5 +123,6 @@ own "Quit & Reopen" dialog. tcode therefore treats any permission flow as a pote
grant permissions inside the VM, then inspect permission status via SSH.
- CI (macOS/Linux/Windows) builds the platform fallback paths and runs the platform-neutral unit
tests:
outline folding and search ranking, state-store eviction and staleness, tool schemas,
settings serde round-trips, and MCP registration wiring for all three provider paths.
outline folding, search ranking, text-sparse fallback decisions and observation shape,
state-store eviction and staleness, tool schemas, settings serde round-trips, and MCP
registration wiring for all three provider paths.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/computer-use-mcp/src/backend.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,6 +70,16 @@ pub enum CapturePolicy {
IfSparse,
}

impl CapturePolicy {
pub fn should_capture(self, text_sparse: bool) -> bool {
match self {
Self::Never => false,
Self::Always => true,
Self::IfSparse => text_sparse,
}
}
}

#[derive(Debug, Clone, Copy)]
pub struct ObserveRequest {
pub semantic: bool,
Expand All@@ -80,6 +90,7 @@ pub struct ObserveRequest {
pub struct RootObservation {
pub root: RootInfo,
pub tree: UiNode,
pub text_sparse: bool,
pub screenshot_png: Option<Vec<u8>>,
}

Expand Down
14 changes: 6 additions & 8 deletions crates/computer-use-mcp/src/backend/macos/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,10 @@ use core_graphics::window::{
};

use super::{
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, CapturePolicy,
ObserveRequest, RootFilters, RootInfo, RootObservation,
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, ObserveRequest,
RootFilters, RootInfo, RootObservation,
};
use crate::outline::{UiNode, interactive_count};
use crate::outline::{UiNode, is_text_sparse};

pub(super) struct MacosBackend;

Expand DownExpand Up@@ -108,17 +108,15 @@ impl MacosBackend {
..UiNode::default()
}
};
let should_capture = match request.capture {
CapturePolicy::Never => false,
CapturePolicy::Always => true,
CapturePolicy::IfSparse => interactive_count(&tree) <= 3,
};
let text_sparse = is_text_sparse(&tree);
let should_capture = request.capture.should_capture(text_sparse);
let screenshot_png = should_capture
.then(|| capture::capture_window(root))
.transpose()?;
Ok(RootObservation {
root: root.clone(),
tree,
text_sparse,
screenshot_png,
})
}
Expand Down
69 changes: 69 additions & 0 deletions crates/computer-use-mcp/src/outline.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ pub const MAX_MODEL_LINES: usize = 2_000;
pub const PREVIEW_BYTES: usize = 16 * 1024;
pub const PAGE_BYTES: usize = 16 * 1024;
pub const SEARCH_LIMIT: usize = 20;
pub const TEXT_SPARSE_DESCENDANT_THRESHOLD: usize = 3;
pub const TEXT_SPARSE_MIN_FRAME_AREA: f64 = 20_000.0;

const FOLDED_DEPTH: usize = 7;
const FOLDED_LINES: usize = 500;
Expand DownExpand Up@@ -125,6 +127,30 @@ pub fn interactive_count(root: &UiNode) -> usize {
count
}

/// A large accessibility tree is text-sparse when fewer than three of its
/// descendants expose a title, value, or description. The root window's own
/// title is deliberately excluded because it does not describe the contents.
pub fn is_text_sparse(root: &UiNode) -> bool {
let area = root.frame.w * root.frame.h;
root.frame.has_area()
&& area.is_finite()
&& area >= TEXT_SPARSE_MIN_FRAME_AREA
&& text_bearing_descendant_count(root) < TEXT_SPARSE_DESCENDANT_THRESHOLD
}

fn text_bearing_descendant_count(root: &UiNode) -> usize {
root.children
.iter()
.map(|child| usize::from(node_has_text(child)) + text_bearing_descendant_count(child))
.sum()
}

fn node_has_text(node: &UiNode) -> bool {
[&node.title, &node.value, &node.description]
.into_iter()
.any(|text| !text.trim().is_empty())
}

pub fn assign_refs(root: &mut UiNode) {
let mut next = 1_u64;
walk_mut(root, &mut |node, _| {
Expand DownExpand Up@@ -721,6 +747,49 @@ mod tests {
assert!(!diff.use_full_view);
}

fn framed_tree(children: Vec<UiNode>) -> UiNode {
UiNode {
frame: Frame {
x: 20.0,
y: 30.0,
w: 800.0,
h: 600.0,
},
children,
..node("window", "Root window title", Vec::new())
}
}

#[test]
fn large_tree_with_fewer_than_three_text_descendants_is_sparse() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
]);

assert!(is_text_sparse(&tree));
}

#[test]
fn large_tree_with_three_text_descendants_is_adequate() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
node("static_text", "Ready", Vec::new()),
]);

assert!(!is_text_sparse(&tree));
}

#[test]
fn small_tree_is_not_sparse_even_without_text_descendants() {
let mut tree = framed_tree(Vec::new());
tree.frame.w = 100.0;
tree.frame.h = 100.0;

assert!(!is_text_sparse(&tree));
}

#[test]
fn safe_prefix_ends_on_utf8_and_line_boundaries() {
let text = "a\nβ\ncharlie";
Expand Down
92 changes: 89 additions & 3 deletions crates/computer-use-mcp/src/tools.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -405,15 +405,15 @@ mod dispatch {

pub(super) async fn observe_ui(params: ObserveUiParams) -> CallToolResult {
let permissions = permissions();
let config = crate::config::get();
let needs_accessibility = !matches!(params.mode, Some(ObserveMode::Visual));
let needs_screen_recording =
matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
let needs_screen_recording = config.image_mode != crate::config::ImageMode::Never
&& matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
if let Some(result) =
permission_gate(permissions, needs_accessibility, needs_screen_recording)
{
return result;
}
let config = crate::config::get();
if config.image_mode == crate::config::ImageMode::Always
&& let Some(result) = permission_gate(permissions, false, true)
{
Expand DownExpand Up@@ -858,6 +858,9 @@ mod dispatch {
count_nodes(&observation.tree),
outline::interactive_count(&observation.tree)
);
if observed.text_sparse {
text.push_str("\ntext_sparse: true");
}
if let Some(warning) = warning {
text.push_str("\nwarning: ");
text.push_str(warning);
Expand DownExpand Up@@ -1066,6 +1069,89 @@ mod dispatch {
fn tool_error(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message)])
}

#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use crate::outline::Frame;

fn sparse_observation(screenshot_png: Option<Vec<u8>>) -> RootObservation {
RootObservation {
root: RootInfo {
ref_id: "@r1".into(),
app_name: "Canvas App".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..RootInfo::default()
},
tree: UiNode {
role: "window".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..UiNode::default()
},
text_sparse: true,
screenshot_png,
}
}

#[cfg(target_os = "macos")]
#[test]
fn auto_with_permission_returns_sparse_flag_and_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Auto,
Some(ObserveMode::Semantic),
&permissions,
);
assert_eq!(policy, CapturePolicy::IfSparse);
assert!(policy.should_capture(true));

let result =
save_observation(sparse_observation(Some(vec![0x89, b'P', b'N', b'G'])), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text), ContentBlock::Image(image)]
if text.text.contains("text_sparse: true")
&& image.mime_type == "image/png"
));
}

#[cfg(target_os = "macos")]
#[test]
fn never_returns_sparse_flag_without_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Never,
Some(ObserveMode::Visual),
&permissions,
);
assert_eq!(policy, CapturePolicy::Never);
assert!(!policy.should_capture(true));

let result = save_observation(sparse_observation(None), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text)] if text.text.contains("text_sparse: true")
));
}
}
}

#[cfg(test)]
Expand Down
32 changes: 27 additions & 5 deletions docs/computer-use.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,10 +34,12 @@ Core contract, inherited from pi-computer-use:
- **Bounded output.** Model-visible text is capped; oversized results return a preview plus a
continuation ref for `read_text`.

Deliberate v1 deviations from pi-computer-use (documented so later work can close them):
no OCR/`pictureOnly` nodes, no CDP browser roots (browser automation stays on the
Deliberate deviations from pi-computer-use (documented so later work can close them): no
Windows/UIAutomation backend, no CDP browser roots (browser automation stays on the
`tcode_preview` server and the embedded WebView), no separate helper app (see below), and a
simplified successor-diff heuristic.
simplified successor-diff heuristic. The earlier no-OCR/`pictureOnly`-node deviation is resolved
by raw-image pass-through for text-sparse accessibility trees. By maintainer decision this
fallback does not run OCR or synthesize text nodes; the model reads the attached pixels directly.

## Architecture

Expand All@@ -61,6 +63,25 @@ Unlike pi-computer-use, tcode needs **no helper app**: tcode is itself a signed
Accessibility and Screen Recording grants attach directly to tcode. That removes helper
install/signing/attribution handling entirely.

## Text-sparse image fallback

An observed window of at least 20,000 square screen points is considered text-sparse when fewer
than three accessibility descendants expose a title, value, or description. The root window
title is excluded. A sparse observation includes `text_sparse: true` so the agent knows the AX
outline does not adequately describe the window.

Image mode controls the raw screenshot attachment through the same capture path as other
observations:

- `auto` attaches one window screenshot only when the sparse rule triggers and Screen Recording
permission is available. Without permission, it returns the plain sparse tree and a warning.
- `always` attaches one screenshot to every observation; sparse observations still include the
marker.
- `never` never captures or attaches an image; sparse observations still include the marker.

The window is captured at most once per observation. The fallback is intentionally OCR-free and
does not add `pictureOnly` or other synthesized nodes.

## macOS permissions

| Permission | Needed for | Check | Request |
Expand DownExpand Up@@ -102,5 +123,6 @@ own "Quit & Reopen" dialog. tcode therefore treats any permission flow as a pote
grant permissions inside the VM, then inspect permission status via SSH.
- CI (macOS/Linux/Windows) builds the platform fallback paths and runs the platform-neutral unit
tests:
outline folding and search ranking, state-store eviction and staleness, tool schemas,
settings serde round-trips, and MCP registration wiring for all three provider paths.
outline folding, search ranking, text-sparse fallback decisions and observation shape,
state-store eviction and staleness, tool schemas, settings serde round-trips, and MCP
registration wiring for all three provider paths.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/computer-use-mcp/src/backend.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,6 +70,16 @@ pub enum CapturePolicy {
IfSparse,
}

impl CapturePolicy {
pub fn should_capture(self, text_sparse: bool) -> bool {
match self {
Self::Never => false,
Self::Always => true,
Self::IfSparse => text_sparse,
}
}
}

#[derive(Debug, Clone, Copy)]
pub struct ObserveRequest {
pub semantic: bool,
Expand All@@ -80,6 +90,7 @@ pub struct ObserveRequest {
pub struct RootObservation {
pub root: RootInfo,
pub tree: UiNode,
pub text_sparse: bool,
pub screenshot_png: Option<Vec<u8>>,
}

Expand Down
14 changes: 6 additions & 8 deletions crates/computer-use-mcp/src/backend/macos/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,10 @@ use core_graphics::window::{
};

use super::{
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, CapturePolicy,
ObserveRequest, RootFilters, RootInfo, RootObservation,
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, ObserveRequest,
RootFilters, RootInfo, RootObservation,
};
use crate::outline::{UiNode, interactive_count};
use crate::outline::{UiNode, is_text_sparse};

pub(super) struct MacosBackend;

Expand DownExpand Up@@ -108,17 +108,15 @@ impl MacosBackend {
..UiNode::default()
}
};
let should_capture = match request.capture {
CapturePolicy::Never => false,
CapturePolicy::Always => true,
CapturePolicy::IfSparse => interactive_count(&tree) <= 3,
};
let text_sparse = is_text_sparse(&tree);
let should_capture = request.capture.should_capture(text_sparse);
let screenshot_png = should_capture
.then(|| capture::capture_window(root))
.transpose()?;
Ok(RootObservation {
root: root.clone(),
tree,
text_sparse,
screenshot_png,
})
}
Expand Down
69 changes: 69 additions & 0 deletions crates/computer-use-mcp/src/outline.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ pub const MAX_MODEL_LINES: usize = 2_000;
pub const PREVIEW_BYTES: usize = 16 * 1024;
pub const PAGE_BYTES: usize = 16 * 1024;
pub const SEARCH_LIMIT: usize = 20;
pub const TEXT_SPARSE_DESCENDANT_THRESHOLD: usize = 3;
pub const TEXT_SPARSE_MIN_FRAME_AREA: f64 = 20_000.0;

const FOLDED_DEPTH: usize = 7;
const FOLDED_LINES: usize = 500;
Expand DownExpand Up@@ -125,6 +127,30 @@ pub fn interactive_count(root: &UiNode) -> usize {
count
}

/// A large accessibility tree is text-sparse when fewer than three of its
/// descendants expose a title, value, or description. The root window's own
/// title is deliberately excluded because it does not describe the contents.
pub fn is_text_sparse(root: &UiNode) -> bool {
let area = root.frame.w * root.frame.h;
root.frame.has_area()
&& area.is_finite()
&& area >= TEXT_SPARSE_MIN_FRAME_AREA
&& text_bearing_descendant_count(root) < TEXT_SPARSE_DESCENDANT_THRESHOLD
}

fn text_bearing_descendant_count(root: &UiNode) -> usize {
root.children
.iter()
.map(|child| usize::from(node_has_text(child)) + text_bearing_descendant_count(child))
.sum()
}

fn node_has_text(node: &UiNode) -> bool {
[&node.title, &node.value, &node.description]
.into_iter()
.any(|text| !text.trim().is_empty())
}

pub fn assign_refs(root: &mut UiNode) {
let mut next = 1_u64;
walk_mut(root, &mut |node, _| {
Expand DownExpand Up@@ -721,6 +747,49 @@ mod tests {
assert!(!diff.use_full_view);
}

fn framed_tree(children: Vec<UiNode>) -> UiNode {
UiNode {
frame: Frame {
x: 20.0,
y: 30.0,
w: 800.0,
h: 600.0,
},
children,
..node("window", "Root window title", Vec::new())
}
}

#[test]
fn large_tree_with_fewer_than_three_text_descendants_is_sparse() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
]);

assert!(is_text_sparse(&tree));
}

#[test]
fn large_tree_with_three_text_descendants_is_adequate() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
node("static_text", "Ready", Vec::new()),
]);

assert!(!is_text_sparse(&tree));
}

#[test]
fn small_tree_is_not_sparse_even_without_text_descendants() {
let mut tree = framed_tree(Vec::new());
tree.frame.w = 100.0;
tree.frame.h = 100.0;

assert!(!is_text_sparse(&tree));
}

#[test]
fn safe_prefix_ends_on_utf8_and_line_boundaries() {
let text = "a\nβ\ncharlie";
Expand Down
92 changes: 89 additions & 3 deletions crates/computer-use-mcp/src/tools.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -405,15 +405,15 @@ mod dispatch {

pub(super) async fn observe_ui(params: ObserveUiParams) -> CallToolResult {
let permissions = permissions();
let config = crate::config::get();
let needs_accessibility = !matches!(params.mode, Some(ObserveMode::Visual));
let needs_screen_recording =
matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
let needs_screen_recording = config.image_mode != crate::config::ImageMode::Never
&& matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
if let Some(result) =
permission_gate(permissions, needs_accessibility, needs_screen_recording)
{
return result;
}
let config = crate::config::get();
if config.image_mode == crate::config::ImageMode::Always
&& let Some(result) = permission_gate(permissions, false, true)
{
Expand DownExpand Up@@ -858,6 +858,9 @@ mod dispatch {
count_nodes(&observation.tree),
outline::interactive_count(&observation.tree)
);
if observed.text_sparse {
text.push_str("\ntext_sparse: true");
}
if let Some(warning) = warning {
text.push_str("\nwarning: ");
text.push_str(warning);
Expand DownExpand Up@@ -1066,6 +1069,89 @@ mod dispatch {
fn tool_error(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message)])
}

#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use crate::outline::Frame;

fn sparse_observation(screenshot_png: Option<Vec<u8>>) -> RootObservation {
RootObservation {
root: RootInfo {
ref_id: "@r1".into(),
app_name: "Canvas App".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..RootInfo::default()
},
tree: UiNode {
role: "window".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..UiNode::default()
},
text_sparse: true,
screenshot_png,
}
}

#[cfg(target_os = "macos")]
#[test]
fn auto_with_permission_returns_sparse_flag_and_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Auto,
Some(ObserveMode::Semantic),
&permissions,
);
assert_eq!(policy, CapturePolicy::IfSparse);
assert!(policy.should_capture(true));

let result =
save_observation(sparse_observation(Some(vec![0x89, b'P', b'N', b'G'])), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text), ContentBlock::Image(image)]
if text.text.contains("text_sparse: true")
&& image.mime_type == "image/png"
));
}

#[cfg(target_os = "macos")]
#[test]
fn never_returns_sparse_flag_without_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Never,
Some(ObserveMode::Visual),
&permissions,
);
assert_eq!(policy, CapturePolicy::Never);
assert!(!policy.should_capture(true));

let result = save_observation(sparse_observation(None), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text)] if text.text.contains("text_sparse: true")
));
}
}
}

#[cfg(test)]
Expand Down
32 changes: 27 additions & 5 deletions docs/computer-use.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,10 +34,12 @@ Core contract, inherited from pi-computer-use:
- **Bounded output.** Model-visible text is capped; oversized results return a preview plus a
continuation ref for `read_text`.

Deliberate v1 deviations from pi-computer-use (documented so later work can close them):
no OCR/`pictureOnly` nodes, no CDP browser roots (browser automation stays on the
Deliberate deviations from pi-computer-use (documented so later work can close them): no
Windows/UIAutomation backend, no CDP browser roots (browser automation stays on the
`tcode_preview` server and the embedded WebView), no separate helper app (see below), and a
simplified successor-diff heuristic.
simplified successor-diff heuristic. The earlier no-OCR/`pictureOnly`-node deviation is resolved
by raw-image pass-through for text-sparse accessibility trees. By maintainer decision this
fallback does not run OCR or synthesize text nodes; the model reads the attached pixels directly.

## Architecture

Expand All@@ -61,6 +63,25 @@ Unlike pi-computer-use, tcode needs **no helper app**: tcode is itself a signed
Accessibility and Screen Recording grants attach directly to tcode. That removes helper
install/signing/attribution handling entirely.

## Text-sparse image fallback

An observed window of at least 20,000 square screen points is considered text-sparse when fewer
than three accessibility descendants expose a title, value, or description. The root window
title is excluded. A sparse observation includes `text_sparse: true` so the agent knows the AX
outline does not adequately describe the window.

Image mode controls the raw screenshot attachment through the same capture path as other
observations:

- `auto` attaches one window screenshot only when the sparse rule triggers and Screen Recording
permission is available. Without permission, it returns the plain sparse tree and a warning.
- `always` attaches one screenshot to every observation; sparse observations still include the
marker.
- `never` never captures or attaches an image; sparse observations still include the marker.

The window is captured at most once per observation. The fallback is intentionally OCR-free and
does not add `pictureOnly` or other synthesized nodes.

## macOS permissions

| Permission | Needed for | Check | Request |
Expand DownExpand Up@@ -102,5 +123,6 @@ own "Quit & Reopen" dialog. tcode therefore treats any permission flow as a pote
grant permissions inside the VM, then inspect permission status via SSH.
- CI (macOS/Linux/Windows) builds the platform fallback paths and runs the platform-neutral unit
tests:
outline folding and search ranking, state-store eviction and staleness, tool schemas,
settings serde round-trips, and MCP registration wiring for all three provider paths.
outline folding, search ranking, text-sparse fallback decisions and observation shape,
state-store eviction and staleness, tool schemas, settings serde round-trips, and MCP
registration wiring for all three provider paths.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/computer-use-mcp/src/backend.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,6 +70,16 @@ pub enum CapturePolicy {
IfSparse,
}

impl CapturePolicy {
pub fn should_capture(self, text_sparse: bool) -> bool {
match self {
Self::Never => false,
Self::Always => true,
Self::IfSparse => text_sparse,
}
}
}

#[derive(Debug, Clone, Copy)]
pub struct ObserveRequest {
pub semantic: bool,
Expand All@@ -80,6 +90,7 @@ pub struct ObserveRequest {
pub struct RootObservation {
pub root: RootInfo,
pub tree: UiNode,
pub text_sparse: bool,
pub screenshot_png: Option<Vec<u8>>,
}

Expand Down
14 changes: 6 additions & 8 deletions crates/computer-use-mcp/src/backend/macos/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,10 +17,10 @@ use core_graphics::window::{
};

use super::{
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, CapturePolicy,
ObserveRequest, RootFilters, RootInfo, RootObservation,
ActionKind, ActionRequest, ActionResult, BackendError, BackendErrorCode, ObserveRequest,
RootFilters, RootInfo, RootObservation,
};
use crate::outline::{UiNode, interactive_count};
use crate::outline::{UiNode, is_text_sparse};

pub(super) struct MacosBackend;

Expand DownExpand Up@@ -108,17 +108,15 @@ impl MacosBackend {
..UiNode::default()
}
};
let should_capture = match request.capture {
CapturePolicy::Never => false,
CapturePolicy::Always => true,
CapturePolicy::IfSparse => interactive_count(&tree) <= 3,
};
let text_sparse = is_text_sparse(&tree);
let should_capture = request.capture.should_capture(text_sparse);
let screenshot_png = should_capture
.then(|| capture::capture_window(root))
.transpose()?;
Ok(RootObservation {
root: root.clone(),
tree,
text_sparse,
screenshot_png,
})
}
Expand Down
69 changes: 69 additions & 0 deletions crates/computer-use-mcp/src/outline.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,8 @@ pub const MAX_MODEL_LINES: usize = 2_000;
pub const PREVIEW_BYTES: usize = 16 * 1024;
pub const PAGE_BYTES: usize = 16 * 1024;
pub const SEARCH_LIMIT: usize = 20;
pub const TEXT_SPARSE_DESCENDANT_THRESHOLD: usize = 3;
pub const TEXT_SPARSE_MIN_FRAME_AREA: f64 = 20_000.0;

const FOLDED_DEPTH: usize = 7;
const FOLDED_LINES: usize = 500;
Expand DownExpand Up@@ -125,6 +127,30 @@ pub fn interactive_count(root: &UiNode) -> usize {
count
}

/// A large accessibility tree is text-sparse when fewer than three of its
/// descendants expose a title, value, or description. The root window's own
/// title is deliberately excluded because it does not describe the contents.
pub fn is_text_sparse(root: &UiNode) -> bool {
let area = root.frame.w * root.frame.h;
root.frame.has_area()
&& area.is_finite()
&& area >= TEXT_SPARSE_MIN_FRAME_AREA
&& text_bearing_descendant_count(root) < TEXT_SPARSE_DESCENDANT_THRESHOLD
}

fn text_bearing_descendant_count(root: &UiNode) -> usize {
root.children
.iter()
.map(|child| usize::from(node_has_text(child)) + text_bearing_descendant_count(child))
.sum()
}

fn node_has_text(node: &UiNode) -> bool {
[&node.title, &node.value, &node.description]
.into_iter()
.any(|text| !text.trim().is_empty())
}

pub fn assign_refs(root: &mut UiNode) {
let mut next = 1_u64;
walk_mut(root, &mut |node, _| {
Expand DownExpand Up@@ -721,6 +747,49 @@ mod tests {
assert!(!diff.use_full_view);
}

fn framed_tree(children: Vec<UiNode>) -> UiNode {
UiNode {
frame: Frame {
x: 20.0,
y: 30.0,
w: 800.0,
h: 600.0,
},
children,
..node("window", "Root window title", Vec::new())
}
}

#[test]
fn large_tree_with_fewer_than_three_text_descendants_is_sparse() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
]);

assert!(is_text_sparse(&tree));
}

#[test]
fn large_tree_with_three_text_descendants_is_adequate() {
let tree = framed_tree(vec![
node("button", "Menu", Vec::new()),
node("static_text", "Score", Vec::new()),
node("static_text", "Ready", Vec::new()),
]);

assert!(!is_text_sparse(&tree));
}

#[test]
fn small_tree_is_not_sparse_even_without_text_descendants() {
let mut tree = framed_tree(Vec::new());
tree.frame.w = 100.0;
tree.frame.h = 100.0;

assert!(!is_text_sparse(&tree));
}

#[test]
fn safe_prefix_ends_on_utf8_and_line_boundaries() {
let text = "a\nβ\ncharlie";
Expand Down
92 changes: 89 additions & 3 deletions crates/computer-use-mcp/src/tools.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -405,15 +405,15 @@ mod dispatch {

pub(super) async fn observe_ui(params: ObserveUiParams) -> CallToolResult {
let permissions = permissions();
let config = crate::config::get();
let needs_accessibility = !matches!(params.mode, Some(ObserveMode::Visual));
let needs_screen_recording =
matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
let needs_screen_recording = config.image_mode != crate::config::ImageMode::Never
&& matches!(params.mode, Some(ObserveMode::Visual | ObserveMode::Fused));
if let Some(result) =
permission_gate(permissions, needs_accessibility, needs_screen_recording)
{
return result;
}
let config = crate::config::get();
if config.image_mode == crate::config::ImageMode::Always
&& let Some(result) = permission_gate(permissions, false, true)
{
Expand DownExpand Up@@ -858,6 +858,9 @@ mod dispatch {
count_nodes(&observation.tree),
outline::interactive_count(&observation.tree)
);
if observed.text_sparse {
text.push_str("\ntext_sparse: true");
}
if let Some(warning) = warning {
text.push_str("\nwarning: ");
text.push_str(warning);
Expand DownExpand Up@@ -1066,6 +1069,89 @@ mod dispatch {
fn tool_error(message: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message)])
}

#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
use crate::outline::Frame;

fn sparse_observation(screenshot_png: Option<Vec<u8>>) -> RootObservation {
RootObservation {
root: RootInfo {
ref_id: "@r1".into(),
app_name: "Canvas App".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..RootInfo::default()
},
tree: UiNode {
role: "window".into(),
title: "Canvas".into(),
frame: Frame {
x: 0.0,
y: 0.0,
w: 800.0,
h: 600.0,
},
..UiNode::default()
},
text_sparse: true,
screenshot_png,
}
}

#[cfg(target_os = "macos")]
#[test]
fn auto_with_permission_returns_sparse_flag_and_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Auto,
Some(ObserveMode::Semantic),
&permissions,
);
assert_eq!(policy, CapturePolicy::IfSparse);
assert!(policy.should_capture(true));

let result =
save_observation(sparse_observation(Some(vec![0x89, b'P', b'N', b'G'])), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text), ContentBlock::Image(image)]
if text.text.contains("text_sparse: true")
&& image.mime_type == "image/png"
));
}

#[cfg(target_os = "macos")]
#[test]
fn never_returns_sparse_flag_without_image() {
let permissions = crate::permissions::PermissionStatus {
accessibility: true,
screen_recording: true,
};
let policy = capture_policy(
crate::config::ImageMode::Never,
Some(ObserveMode::Visual),
&permissions,
);
assert_eq!(policy, CapturePolicy::Never);
assert!(!policy.should_capture(true));

let result = save_observation(sparse_observation(None), None);
assert!(matches!(
result.content.as_slice(),
[ContentBlock::Text(text)] if text.text.contains("text_sparse: true")
));
}
}
}

#[cfg(test)]
Expand Down
32 changes: 27 additions & 5 deletions docs/computer-use.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,10 +34,12 @@ Core contract, inherited from pi-computer-use:
- **Bounded output.** Model-visible text is capped; oversized results return a preview plus a
continuation ref for `read_text`.

Deliberate v1 deviations from pi-computer-use (documented so later work can close them):
no OCR/`pictureOnly` nodes, no CDP browser roots (browser automation stays on the
Deliberate deviations from pi-computer-use (documented so later work can close them): no
Windows/UIAutomation backend, no CDP browser roots (browser automation stays on the
`tcode_preview` server and the embedded WebView), no separate helper app (see below), and a
simplified successor-diff heuristic.
simplified successor-diff heuristic. The earlier no-OCR/`pictureOnly`-node deviation is resolved
by raw-image pass-through for text-sparse accessibility trees. By maintainer decision this
fallback does not run OCR or synthesize text nodes; the model reads the attached pixels directly.

## Architecture

Expand All@@ -61,6 +63,25 @@ Unlike pi-computer-use, tcode needs **no helper app**: tcode is itself a signed
Accessibility and Screen Recording grants attach directly to tcode. That removes helper
install/signing/attribution handling entirely.

## Text-sparse image fallback

An observed window of at least 20,000 square screen points is considered text-sparse when fewer
than three accessibility descendants expose a title, value, or description. The root window
title is excluded. A sparse observation includes `text_sparse: true` so the agent knows the AX
outline does not adequately describe the window.

Image mode controls the raw screenshot attachment through the same capture path as other
observations:

- `auto` attaches one window screenshot only when the sparse rule triggers and Screen Recording
permission is available. Without permission, it returns the plain sparse tree and a warning.
- `always` attaches one screenshot to every observation; sparse observations still include the
marker.
- `never` never captures or attaches an image; sparse observations still include the marker.

The window is captured at most once per observation. The fallback is intentionally OCR-free and
does not add `pictureOnly` or other synthesized nodes.

## macOS permissions

| Permission | Needed for | Check | Request |
Expand DownExpand Up@@ -102,5 +123,6 @@ own "Quit & Reopen" dialog. tcode therefore treats any permission flow as a pote
grant permissions inside the VM, then inspect permission status via SSH.
- CI (macOS/Linux/Windows) builds the platform fallback paths and runs the platform-neutral unit
tests:
outline folding and search ranking, state-store eviction and staleness, tool schemas,
settings serde round-trips, and MCP registration wiring for all three provider paths.
outline folding, search ranking, text-sparse fallback decisions and observation shape,
state-store eviction and staleness, tool schemas, settings serde round-trips, and MCP
registration wiring for all three provider paths.