feat: implement TUI control queue and response handling

This commit is contained in:
Nathan Flurry 2026-02-04 14:38:10 -08:00
parent 8c7cfd12b3
commit 2b1ddc2e02
7 changed files with 469 additions and 21 deletions

1
.turbo Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/.turbo

1
dist Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/dist

1
node_modules Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/node_modules

View file

@ -460,6 +460,51 @@ struct DirectoryQuery {
directory: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct TuiAppendPromptRequest {
text: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct TuiExecuteCommandRequest {
command: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct TuiShowToastRequest {
title: Option<String>,
message: String,
variant: String,
duration: Option<f64>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct TuiSelectSessionRequest {
#[serde(rename = "sessionID")]
session_id: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct TuiPublishRequest {
#[serde(rename = "type")]
event_type: String,
properties: Value,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct TuiControlResponseRequest {
#[serde(rename = "requestID")]
request_id: String,
body: Option<Value>,
error: Option<Value>,
}
#[derive(Debug, Deserialize, IntoParams)]
struct ToolQuery {
directory: Option<String>,
@ -4088,29 +4133,79 @@ async fn oc_skill_list() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_next() -> impl IntoResponse {
async fn oc_tui_next(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let next = state
.inner
.session_manager()
.next_tui_control(directory)
.await;
if let Some(request) = next {
return (
StatusCode::OK,
Json(json!({
"path": request.path,
"body": request.body,
"requestID": request.id,
})),
);
}
(StatusCode::OK, Json(json!({"path": "", "body": {}})))
}
#[utoipa::path(
post,
path = "/tui/control/response",
request_body = String,
request_body = TuiControlResponseRequest,
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_response() -> impl IntoResponse {
bool_ok(true)
async fn oc_tui_response(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
Json(body): Json<TuiControlResponseRequest>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let response = json!({
"body": body.body,
"error": body.error,
});
let accepted = state
.inner
.session_manager()
.respond_tui_control(directory, &body.request_id, response)
.await;
bool_ok(accepted)
}
#[utoipa::path(
post,
path = "/tui/append-prompt",
request_body = String,
request_body = TuiAppendPromptRequest,
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_append_prompt() -> impl IntoResponse {
async fn oc_tui_append_prompt(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
Json(body): Json<TuiAppendPromptRequest>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
state
.inner
.session_manager()
.enqueue_tui_control(
directory,
"/tui/append-prompt".to_string(),
json!({"text": body.text}),
)
.await;
bool_ok(true)
}
@ -4120,7 +4215,17 @@ async fn oc_tui_append_prompt() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_open_help() -> impl IntoResponse {
async fn oc_tui_open_help(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
state
.inner
.session_manager()
.enqueue_tui_control(directory, "/tui/open-help".to_string(), json!({}))
.await;
bool_ok(true)
}
@ -4130,7 +4235,17 @@ async fn oc_tui_open_help() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_open_sessions() -> impl IntoResponse {
async fn oc_tui_open_sessions(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
state
.inner
.session_manager()
.enqueue_tui_control(directory, "/tui/open-sessions".to_string(), json!({}))
.await;
bool_ok(true)
}
@ -4140,7 +4255,17 @@ async fn oc_tui_open_sessions() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_open_themes() -> impl IntoResponse {
async fn oc_tui_open_themes(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
state
.inner
.session_manager()
.enqueue_tui_control(directory, "/tui/open-themes".to_string(), json!({}))
.await;
bool_ok(true)
}
@ -4150,18 +4275,37 @@ async fn oc_tui_open_themes() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_open_models() -> impl IntoResponse {
async fn oc_tui_open_models(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
state
.inner
.session_manager()
.enqueue_tui_control(directory, "/tui/open-models".to_string(), json!({}))
.await;
bool_ok(true)
}
#[utoipa::path(
post,
path = "/tui/submit-prompt",
request_body = String,
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_submit_prompt() -> impl IntoResponse {
async fn oc_tui_submit_prompt(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
state
.inner
.session_manager()
.enqueue_tui_control(directory, "/tui/submit-prompt".to_string(), json!({}))
.await;
bool_ok(true)
}
@ -4171,51 +4315,179 @@ async fn oc_tui_submit_prompt() -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_clear_prompt() -> impl IntoResponse {
async fn oc_tui_clear_prompt(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
state
.inner
.session_manager()
.enqueue_tui_control(directory, "/tui/clear-prompt".to_string(), json!({}))
.await;
bool_ok(true)
}
#[utoipa::path(
post,
path = "/tui/execute-command",
request_body = String,
request_body = TuiExecuteCommandRequest,
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_execute_command() -> impl IntoResponse {
async fn oc_tui_execute_command(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
Json(body): Json<TuiExecuteCommandRequest>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
state
.inner
.session_manager()
.enqueue_tui_control(
directory,
"/tui/execute-command".to_string(),
json!({"command": body.command}),
)
.await;
bool_ok(true)
}
#[utoipa::path(
post,
path = "/tui/show-toast",
request_body = String,
request_body = TuiShowToastRequest,
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_show_toast() -> impl IntoResponse {
async fn oc_tui_show_toast(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
Json(body): Json<TuiShowToastRequest>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
state
.inner
.session_manager()
.enqueue_tui_control(
directory,
"/tui/show-toast".to_string(),
json!({
"title": body.title,
"message": body.message,
"variant": body.variant,
"duration": body.duration,
}),
)
.await;
bool_ok(true)
}
#[utoipa::path(
post,
path = "/tui/publish",
request_body = String,
request_body = TuiPublishRequest,
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_publish() -> impl IntoResponse {
async fn oc_tui_publish(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
Json(body): Json<TuiPublishRequest>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let (path, payload) = match body.event_type.as_str() {
"tui.prompt.append" => {
let Some(text) = body.properties.get("text").and_then(|v| v.as_str()) else {
return bad_request("text is required").into_response();
};
("/tui/append-prompt", json!({"text": text}))
}
"tui.command.execute" => {
let Some(command) = body
.properties
.get("command")
.and_then(|v| v.as_str())
else {
return bad_request("command is required").into_response();
};
("/tui/execute-command", json!({"command": command}))
}
"tui.toast.show" => {
let Some(message) = body.properties.get("message").and_then(|v| v.as_str()) else {
return bad_request("message is required").into_response();
};
let Some(variant) = body.properties.get("variant").and_then(|v| v.as_str()) else {
return bad_request("variant is required").into_response();
};
let title = body.properties.get("title").cloned();
let duration = body.properties.get("duration").cloned();
(
"/tui/show-toast",
json!({
"title": title,
"message": message,
"variant": variant,
"duration": duration,
}),
)
}
"tui.session.select" => {
let Some(session_id) = body
.properties
.get("sessionID")
.and_then(|v| v.as_str())
else {
return bad_request("sessionID is required").into_response();
};
let sessions = state.opencode.sessions.lock().await;
if !sessions.contains_key(session_id) {
return not_found("session not found").into_response();
}
("/tui/select-session", json!({"sessionID": session_id}))
}
_ => return bad_request("unsupported TUI event").into_response(),
};
state
.inner
.session_manager()
.enqueue_tui_control(directory, path.to_string(), payload)
.await;
bool_ok(true)
}
#[utoipa::path(
post,
path = "/tui/select-session",
request_body = String,
request_body = TuiSelectSessionRequest,
responses((status = 200)),
tag = "opencode"
)]
async fn oc_tui_select_session() -> impl IntoResponse {
async fn oc_tui_select_session(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<DirectoryQuery>,
headers: HeaderMap,
Json(body): Json<TuiSelectSessionRequest>,
) -> impl IntoResponse {
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
let sessions = state.opencode.sessions.lock().await;
if !sessions.contains_key(&body.session_id) {
return not_found("session not found").into_response();
}
state
.inner
.session_manager()
.enqueue_tui_control(
directory,
"/tui/select-session".to_string(),
json!({"sessionID": body.session_id}),
)
.await;
bool_ok(true)
}

View file

@ -50,6 +50,7 @@ use sandbox_agent_agent_management::credentials::{
const MOCK_EVENT_DELAY_MS: u64 = 200;
static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
static TUI_CONTROL_COUNTER: AtomicU64 = AtomicU64::new(1);
#[derive(Debug)]
pub struct AppState {
@ -360,6 +361,20 @@ struct PendingQuestion {
options: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct TuiControlRequest {
pub(crate) id: String,
pub(crate) path: String,
pub(crate) body: Value,
}
#[derive(Debug, Default)]
struct TuiControlQueue {
pending: VecDeque<TuiControlRequest>,
inflight: HashMap<String, TuiControlRequest>,
responses: HashMap<String, Value>,
}
impl SessionState {
fn new(
session_id: String,
@ -818,6 +833,7 @@ pub(crate) struct SessionManager {
sessions: Mutex<Vec<SessionState>>,
server_manager: Arc<AgentServerManager>,
http_client: Client,
tui_controls: Mutex<HashMap<String, TuiControlQueue>>,
}
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
@ -1538,6 +1554,7 @@ impl SessionManager {
sessions: Mutex::new(Vec::new()),
server_manager,
http_client: Client::new(),
tui_controls: Mutex::new(HashMap::new()),
}
}
@ -1960,6 +1977,59 @@ impl SessionManager {
items
}
pub(crate) async fn enqueue_tui_control(
&self,
directory: String,
path: String,
body: Value,
) -> String {
let id = format!(
"tui_{}",
TUI_CONTROL_COUNTER.fetch_add(1, Ordering::Relaxed)
);
let request = TuiControlRequest {
id: id.clone(),
path,
body,
};
let mut controls = self.tui_controls.lock().await;
let queue = controls.entry(directory).or_default();
queue.pending.push_back(request);
id
}
pub(crate) async fn next_tui_control(
&self,
directory: String,
) -> Option<TuiControlRequest> {
let mut controls = self.tui_controls.lock().await;
let queue = controls.entry(directory).or_default();
if let Some(request) = queue.pending.pop_front() {
queue
.inflight
.insert(request.id.clone(), request.clone());
return Some(request);
}
None
}
pub(crate) async fn respond_tui_control(
&self,
directory: String,
request_id: &str,
response: Value,
) -> bool {
let mut controls = self.tui_controls.lock().await;
let Some(queue) = controls.get_mut(&directory) else {
return false;
};
if queue.inflight.remove(request_id).is_some() {
queue.responses.insert(request_id.to_string(), response);
return true;
}
false
}
async fn subscribe_for_turn(
&self,
session_id: &str,

View file

@ -0,0 +1,102 @@
/**
* Tests for OpenCode-compatible TUI control endpoints.
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
type TuiClient = {
appendPrompt: (args: unknown) => Promise<{ data?: unknown }>;
executeCommand: (args: unknown) => Promise<{ data?: unknown }>;
showToast: (args: unknown) => Promise<{ data?: unknown }>;
control: {
next: (args?: unknown) => Promise<{ data?: unknown }>;
response: (args: unknown) => Promise<{ data?: unknown }>;
};
};
describe("OpenCode-compatible TUI Control API", () => {
let handle: SandboxAgentHandle;
let client: OpencodeClient;
let tui: TuiClient;
beforeAll(async () => {
await buildSandboxAgent();
});
beforeEach(async () => {
handle = await spawnSandboxAgent({ opencodeCompat: true });
client = createOpencodeClient({
baseUrl: `${handle.baseUrl}/opencode`,
headers: { Authorization: `Bearer ${handle.token}` },
});
tui = client.tui as unknown as TuiClient;
});
afterEach(async () => {
await handle?.dispose();
});
it("queues TUI control requests in order", async () => {
await tui.appendPrompt({ body: { text: "First" } });
await tui.executeCommand({ body: { command: "prompt.clear" } });
await tui.showToast({ body: { message: "Hello", variant: "info" } });
const first = (await tui.control.next({})).data as {
path: string;
body: { text?: string };
requestID?: string;
};
const second = (await tui.control.next({})).data as {
path: string;
body: { command?: string };
requestID?: string;
};
const third = (await tui.control.next({})).data as {
path: string;
body: { message?: string };
requestID?: string;
};
expect(first.path).toBe("/tui/append-prompt");
expect(first.body.text).toBe("First");
expect(first.requestID).toBeDefined();
expect(second.path).toBe("/tui/execute-command");
expect(second.body.command).toBe("prompt.clear");
expect(second.requestID).toBeDefined();
expect(third.path).toBe("/tui/show-toast");
expect(third.body.message).toBe("Hello");
expect(third.requestID).toBeDefined();
const empty = (await tui.control.next({})).data as {
path: string;
body: Record<string, unknown>;
};
expect(empty.path).toBe("");
expect(empty.body).toEqual({});
});
it("handles control responses with request IDs", async () => {
await tui.appendPrompt({ body: { text: "Ack me" } });
const next = (await tui.control.next({})).data as { requestID?: string };
expect(next.requestID).toBeDefined();
const accepted = await tui.control.response({
body: { requestID: next.requestID, body: { ok: true } },
});
expect(accepted.data).toBe(true);
const duplicate = await tui.control.response({
body: { requestID: next.requestID, body: { ok: false } },
});
expect(duplicate.data).toBe(false);
const missing = await tui.control.response({
body: { requestID: "tui_missing" },
});
expect(missing.data).toBe(false);
});
});

1
target Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/target