Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7ca720b
refactor(agent): share one ACP session between ACP-speaking clients
Tryanks Oct 3, 2026
08d15ab
fix(agent): report an ACP startup failure only after the agent is gone
Tryanks Oct 3, 2026
9281f91
refactor: list the native providers once, at ProviderKind
Tryanks Oct 3, 2026
3d1ac5e
feat: add Cursor and Grok as native providers over the shared ACP ses…
Tryanks Oct 3, 2026
634994c
feat(agent): let an ACP dialect keep its own tools, options and appro…
Tryanks Oct 3, 2026
683af9e
feat(grok): complete the Grok session client
Tryanks Oct 3, 2026
7d8d530
feat(services): probe Grok authentication from `grok models`
Tryanks Oct 3, 2026
1f937a9
fix(agent): read ACP updates sent right after session/load as live
Tryanks Oct 3, 2026
a1fcd17
fix(agent): drop ACP config options the agent no longer offers
Tryanks Oct 3, 2026
8fd3746
feat(agent): hand an ACP dialect the session updates the schema canno…
Tryanks Oct 3, 2026
162e59e
feat(cursor): run Cursor sessions over its ACP dialect
Tryanks Oct 3, 2026
def92aa
feat(services): probe Cursor's sign-in from `cursor-agent status`
Tryanks Oct 3, 2026
8cc54f6
feat(cursor): approve by kind, own the model and restore parameters
Tryanks Oct 3, 2026
d1d7b97
feat(probe): stay open for turns the agent starts itself
Tryanks Oct 3, 2026
db8faf2
feat(grok): run the turns Grok starts itself as turns
Tryanks Oct 3, 2026
be35d58
docs(agent): keep the ACP comments to what the code cannot express
Tryanks Oct 3, 2026
e4a1390
docs: name Cursor where the README and CONTRIBUTING list the agents
Tryanks Oct 3, 2026
9709530
docs(agent): describe ACP option lists for every agent that speaks ACP
Tryanks Oct 3, 2026
f6767a5
fix(ui): show the native-approvals switch only for providers it governs
Tryanks Oct 3, 2026
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
16 changes: 9 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,11 @@ is derived from it. **Process** is how to build, check and submit work.
### Reporting bugs and asking for features

Open an [issue](https://github.com/Tryanks/tcode/issues). For a bug, include
your OS, which agent (Claude Code / Codex / which ACP agent), what you did, what
happened and what you expected. A screenshot beats a paragraph for anything
visual. If you are unsure whether something is a bug or intended, open an issue
anyway — the answer is worth writing down either way.
your OS, which agent (Claude Code, Codex, pi, OpenCode, Cursor, Grok, or which
ACP agent), what you did, what happened and what you expected. A screenshot
beats a paragraph for anything visual. If you are unsure whether something is a
bug or intended, open an issue anyway — the answer is worth writing down either
way.

### Building

Expand All @@ -121,8 +122,8 @@ Platform prerequisites:
`libwayland-dev`, `libxcb*`, `libssl-dev`, `libasound2-dev`, `libfontconfig-dev`).
The embedded preview browser is compiled out on Linux.

Provider CLIs are resolved from `PATH` (`claude`, `codex`, `pi`, `opencode`) and
can be overridden in **Settings → Providers**.
Provider CLIs are resolved from `PATH` (`claude`, `codex`, `pi`, `opencode`,
`cursor-agent`, `grok`) and can be overridden in **Settings → Providers**.

### Before you open a pull request

Expand Down Expand Up @@ -275,7 +276,8 @@ crates/ios, crates/android, crates/web
`tcode_ui::run_shell`
crates/platform/* GPUI platform backends for iOS and Android
crates/agent provider clients (no GPUI) — claude.rs, codex.rs, pi.rs,
opencode.rs, acp.rs
opencode.rs, cursor.rs, grok.rs, acp.rs; the ACP
session they share is acp_session.rs
crates/term terminal implementation (PTY, host-side emulator)
crates/mcp-host shared authenticated loopback host for in-process MCP servers
crates/preview-mcp MCP server exposing the preview browser to the agent
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

**A native GUI for the terminal coding agents you already use.**

Claude Code, Codex, pi, OpenCode, and any agent that speaks ACP — one window,
Claude Code, Codex, pi, OpenCode, Cursor, Grok, and any agent that speaks ACP — one window,
on your desktop, your phone, or in a browser.

[Download](https://github.com/Tryanks/tcode/releases) ·
Expand Down Expand Up @@ -78,6 +78,8 @@ and a live plan/task panel.
| [Codex](https://developers.openai.com/codex/cli) | `codex` on your `PATH` |
| [pi](https://github.com/earendil-works/pi) | `pi` on your `PATH` |
| [OpenCode](https://opencode.ai) | `opencode` on your `PATH` |
| [Cursor](https://cursor.com/cli) | `cursor-agent` on your `PATH` |
| [Grok](https://x.ai) | `grok` on your `PATH` |

**Everything else, over [ACP](https://agentclientprotocol.com).** Tcode ships a
marketplace backed by the official ACP registry.
Expand Down Expand Up @@ -179,7 +181,7 @@ also include a `SHA256SUMS.txt` file.
| Browser | Embedded in the headless release; open the HTTP link it prints (HTTPS only through your own tunnel). No separate signed app package |

**2. Have an agent installed.** Tcode drives the CLIs, it doesn't bundle them.
Make sure `claude`, `codex`, `pi` or `opencode` is on your `PATH` — or install an ACP agent from
Make sure `claude`, `codex`, `pi`, `opencode`, `cursor-agent` or `grok` is on your `PATH` — or install an ACP agent from
the marketplace once Tcode is running.

**3. Add a project and start a thread.** Point Tcode at a directory, type, send.
Expand Down
4 changes: 4 additions & 0 deletions assets/icons/cursor.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions assets/icons/grok.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
204 changes: 171 additions & 33 deletions crates/agent/examples/probe.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
//! Headless end-to-end probe for provider clients.
//!
//! Catalog mode: `probe --list-models <codex|claude|pi|opencode>`.
//! Catalog mode: `probe --list-models <codex|claude|pi|opencode|cursor|grok> [--binary <path>]`.
//! Turn mode: `probe <provider> <prompt> [cwd] [approval] [acp-command args…] [flags]`.
//! Flags are `--mode plan`, `--effort <value>`, `--interrupt-after <seconds>`,
//! `--steer <message>`, and `--image <path>`. Only one of the last three may be used.
//! Flags are `--binary <path>`, `--model <id>`, `--mode plan`, `--effort <value>`,
//! `--resume <cursor-json>`, `--fork`, `--leave-questions` (user-input requests
//! stay unanswered), `--mcp <name> <url> <token>` (an HTTP MCP server registered
//! as tcode registers its own), `--linger <seconds>` (stay open until no turn has
//! run for that long, to see turns the agent starts itself), `--follow-up
//! <seconds> <prompt>` (send another turn that long after the first completes,
//! running turn or not), `--interrupt-after <seconds>`, `--steer <message>`, and
//! `--image <path>`. Only one of the last three may be used.

use std::path::PathBuf;
use std::time::Duration;
use std::time::{Duration, Instant};

use agent::{
AcpAgent, AcpLaunch, AgentEvent, ApprovalDecision, ApprovalMode, Attachment, InteractionMode,
ItemContent, OptionSelection, ProviderKind, SessionCommand, SessionOptions, TurnOptions,
TurnStatus, list_models, start_session,
ItemContent, McpRegistration, OptionSelection, ProviderKind, ResumeCursor, SessionCommand,
SessionOptions, TurnOptions, TurnStatus, list_models, start_session,
};
use base64::Engine as _;

Expand All @@ -29,10 +35,12 @@ enum ProbeMode {

fn usage() -> ! {
eprintln!(
"usage: probe <codex|claude|pi|opencode|acp> <prompt> [cwd] \
[supervised|auto_edits|full_access] [acp-command args…] [flags]"
"usage: probe <codex|claude|pi|opencode|cursor|grok|acp> <prompt> [cwd] \
[supervised|read_only|auto_edits|full_access] [acp-command args…] [flags]"
);
eprintln!(
" probe --list-models <codex|claude|pi|opencode|cursor|grok> [--binary <path>]"
);
eprintln!(" probe --list-models <codex|claude|pi|opencode>");
std::process::exit(2);
}

Expand All @@ -42,6 +50,8 @@ fn parse_provider(arg: Option<&str>) -> ProviderKind {
Some("claude") => ProviderKind::ClaudeCode,
Some("pi") => ProviderKind::Pi,
Some("opencode") => ProviderKind::OpenCode,
Some("cursor") => ProviderKind::Cursor,
Some("grok") => ProviderKind::Grok,
Some("acp") => ProviderKind::Acp,
_ => usage(),
}
Expand Down Expand Up @@ -91,8 +101,13 @@ fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.first().map(String::as_str) == Some("--list-models") {
let provider = parse_provider(args.get(1).map(String::as_str));
let binary = match args.get(2).map(String::as_str) {
Some("--binary") => Some(args.get(3).map(PathBuf::from).unwrap_or_else(|| usage())),
Some(_) => usage(),
None => None,
};
let exit_code = smol::block_on(async move {
match list_models(provider, None, Default::default(), Default::default()).await {
match list_models(provider, binary, Default::default(), Default::default()).await {
Ok(models) => {
println!("{}", serde_json::to_string_pretty(&models).unwrap());
0
Expand All @@ -107,7 +122,14 @@ fn main() {
}

let mut interaction_mode = InteractionMode::Build;
let mut binary = None;
let mut model = None;
let mut effort = None;
let mut resume = None;
let mut fork = false;
let mut leave_questions = false;
let mut mcp_servers = Vec::new();
let mut continuation = Continuation::default();
let mut probe_mode = ProbeMode::Standard;
let mut positional = Vec::new();
let mut args = args.into_iter();
Expand All @@ -124,7 +146,38 @@ fn main() {
None => usage(),
};
}
"--binary" => binary = Some(args.next().map(PathBuf::from).unwrap_or_else(|| usage())),
"--model" => model = Some(args.next().unwrap_or_else(|| usage())),
"--effort" => effort = Some(args.next().unwrap_or_else(|| usage())),
"--resume" => {
let cursor = args.next().unwrap_or_else(|| usage());
resume = Some(ResumeCursor(serde_json::from_str(&cursor).unwrap_or_else(
|error| {
eprintln!("--resume takes the JSON of a resume cursor: {error}");
std::process::exit(2);
},
)));
}
"--fork" => fork = true,
"--leave-questions" => leave_questions = true,
"--linger" => {
let seconds = args.next().and_then(|value| value.parse().ok());
continuation.linger = Some(Duration::from_secs(seconds.unwrap_or_else(|| usage())));
}
"--follow-up" => {
let seconds = args.next().and_then(|value| value.parse().ok());
let seconds = seconds.unwrap_or_else(|| usage());
let prompt = args.next().unwrap_or_else(|| usage());
continuation.follow_up = Some((Duration::from_secs(seconds), prompt));
}
"--mcp" => {
let mut next = || args.next().unwrap_or_else(|| usage());
mcp_servers.push(McpRegistration {
name: next(),
url: next(),
bearer_token: next(),
});
}
"--interrupt-after" => {
let seconds = args
.next()
Expand Down Expand Up @@ -162,15 +215,20 @@ fn main() {
});
let mut remaining: Vec<String> = positional.collect();
let approval_mode = match remaining.first().map(String::as_str) {
Some("supervised" | "auto_edits" | "full_access") => match remaining.remove(0).as_str() {
"supervised" => ApprovalMode::Supervised,
"auto_edits" => ApprovalMode::AutoAcceptEdits,
_ => ApprovalMode::FullAccess,
},
Some("supervised" | "read_only" | "auto_edits" | "full_access") => {
match remaining.remove(0).as_str() {
"supervised" => ApprovalMode::Supervised,
"read_only" => ApprovalMode::ReadOnly,
"auto_edits" => ApprovalMode::AutoAcceptEdits,
_ => ApprovalMode::FullAccess,
}
}
None => ApprovalMode::Supervised,
Some(_) if provider == ProviderKind::Acp => ApprovalMode::Supervised,
Some(other) => {
eprintln!("unknown approval mode {other:?}; use supervised|auto_edits|full_access");
eprintln!(
"unknown approval mode {other:?}; use supervised|read_only|auto_edits|full_access"
);
std::process::exit(2);
}
};
Expand Down Expand Up @@ -210,10 +268,31 @@ fn main() {
effort,
probe_mode,
acp,
mcp_servers,
continuation,
Resumption {
resume,
fork,
leave_questions,
},
binary,
model,
));
std::process::exit(exit_code);
}

struct Resumption {
resume: Option<ResumeCursor>,
fork: bool,
leave_questions: bool,
}

#[derive(Default)]
struct Continuation {
linger: Option<Duration>,
follow_up: Option<(Duration, String)>,
}

#[allow(clippy::too_many_arguments)]
async fn run_probe(
provider: ProviderKind,
Expand All @@ -224,28 +303,38 @@ async fn run_probe(
effort: Option<String>,
probe_mode: ProbeMode,
acp: Option<AcpAgent>,
mcp_servers: Vec<McpRegistration>,
continuation: Continuation,
resumption: Resumption,
binary_path: Option<PathBuf>,
model: Option<String>,
) -> i32 {
// Grok's effort is its wire config option, persisted under its option id.
let effort_id = match provider {
ProviderKind::Grok => "acp:cfg:reasoning_effort",
_ => "reasoningEffort",
};
let option_selections = effort
.iter()
.map(|value| OptionSelection {
id: "reasoningEffort".into(),
id: effort_id.into(),
value: serde_json::Value::String(value.clone()),
})
.collect();
let model = match (provider, effort.is_some()) {
let model = model.or_else(|| match (provider, effort.is_some()) {
(ProviderKind::ClaudeCode, true) => Some("claude-opus-4-8".to_string()),
_ => None,
};
});
let opts = SessionOptions {
cwd,
model,
resume: None,
fork: false,
binary_path: None,
resume: resumption.resume,
fork: resumption.fork,
binary_path,
approval_mode,
option_selections,
interaction_mode,
mcp_servers: Vec::new(),
mcp_servers,
launch_env: Default::default(),
extra_args: Vec::new(),
acp,
Expand Down Expand Up @@ -318,18 +407,46 @@ async fn run_probe(
let mut turns_completed = 0;
let mut first_status = None;
let mut steer_accepted = false;
let lingering = continuation
.linger
.or(continuation.follow_up.as_ref().map(|_| PHANTOM_TURN_GRACE));
let mut follow_up = continuation.follow_up;
let mut open_until = None;
let mut running = false;
let mut closing = false;
loop {
let event = if matches!(probe_mode, ProbeMode::Steer(_)) && turns_completed > 0 {
smol::future::or(handle.events.recv(), async {
smol::Timer::after(PHANTOM_TURN_GRACE).await;
Err(smol::channel::RecvError)
})
.await
.ok()
} else {
handle.events.recv().await.ok()
let quiet = match lingering {
Some(linger) if turns_completed > 0 && !running && !closing => Some(linger),
_ if matches!(probe_mode, ProbeMode::Steer(_)) && turns_completed > 0 => {
Some(PHANTOM_TURN_GRACE)
}
_ => None,
};
let event = match quiet {
Some(quiet) => {
let event = smol::future::or(handle.events.recv(), async {
smol::Timer::after(quiet).await;
Err(smol::channel::RecvError)
})
.await
.ok();
if event.is_none() {
if open_until.is_none_or(|until| Instant::now() >= until) {
closing = true;
handle.commands.send(SessionCommand::Shutdown).await.ok();
}
continue;
}
event
}
None => handle.events.recv().await.ok(),
};
let Some(event) = event else { break };
match &event {
AgentEvent::TurnStarted { .. } => running = true,
AgentEvent::TurnCompleted { .. } => running = false,
_ => {}
}

if provider == ProviderKind::Acp {
match &event {
Expand Down Expand Up @@ -360,6 +477,9 @@ async fn run_probe(
.await
.ok();
}
AgentEvent::UserInputRequested { questions, .. } if resumption.leave_questions => {
eprintln!("probe: leaving {} question(s) unanswered", questions.len());
}
AgentEvent::UserInputRequested {
request_id,
questions,
Expand Down Expand Up @@ -440,7 +560,25 @@ async fn run_probe(
"probe: TurnCompleted {turn_id} status={status:?} (#{turns_completed})"
);
}
if !matches!(probe_mode, ProbeMode::Steer(_)) {
if let Some((delay, text)) = follow_up.take() {
open_until = Some(Instant::now() + delay + Duration::from_secs(1));
let commands = handle.commands.clone();
smol::spawn(async move {
smol::Timer::after(delay).await;
eprintln!("probe: FOLLOW-UP -> {text:?}");
commands
.send(SessionCommand::SendTurn {
delivery_id: 1,
text,
options: None,
attachments: Vec::new(),
})
.await
.ok();
})
.detach();
}
if !matches!(probe_mode, ProbeMode::Steer(_)) && lingering.is_none() {
handle.commands.send(SessionCommand::Shutdown).await.ok();
}
}
Expand Down
Loading
Loading