Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a0dbbf7f2 | ||
|
|
84fa4a3076 | ||
|
|
eafeac5c05 | ||
|
|
f96f94db8f | ||
|
|
efb54750ed | ||
|
|
9e59121eec | ||
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 | ||
|
|
25344abe4f | ||
|
|
b52a9085e9 | ||
|
|
8bd943a8ca | ||
|
|
b86335ddf1 | ||
|
|
c348381c5f | ||
|
|
dabdf9fcba | ||
|
|
b76dc06a09 | ||
|
|
23fbfdc5b8 | ||
|
|
ff34727e3c | ||
|
|
e9ede49256 | ||
|
|
8c97ef0616 |
Generated
+1
@@ -1644,6 +1644,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.21.0",
|
||||
"tracing",
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
"vt100",
|
||||
]
|
||||
|
||||
@@ -44,6 +44,7 @@ tokio-util = { version = "0.7", features = ["compat"] }
|
||||
toml = "0.8"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
unicode-width = "0.1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
ratatui = "0.28"
|
||||
vt100 = "0.15"
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::routes::{self, UrlError};
|
||||
use crate::{
|
||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||
Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session,
|
||||
SetFocusRequest,
|
||||
SetFocusRequest, StructuredApprovalDecision, StructuredPromptRequest,
|
||||
};
|
||||
|
||||
/// Time budget for establishing a TCP connection to the daemon.
|
||||
@@ -105,6 +105,11 @@ impl DaemonClient {
|
||||
Ok(routes::attach_url(&self.base, id)?)
|
||||
}
|
||||
|
||||
/// `WS /api/structured/:id` URL for this daemon.
|
||||
pub fn structured_url(&self, id: Uuid) -> Result<String, ClientError> {
|
||||
Ok(routes::structured_url(&self.base, id)?)
|
||||
}
|
||||
|
||||
// -- typed calls --------------------------------------------------------
|
||||
|
||||
pub async fn list_sessions(&self) -> Result<Vec<Session>, ClientError> {
|
||||
@@ -179,6 +184,57 @@ impl DaemonClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send one prompt to a structured session. The daemon rejects a second
|
||||
/// prompt while the preceding ACP turn is still active.
|
||||
pub async fn send_structured_prompt(&self, id: Uuid, prompt: &str) -> Result<(), ClientError> {
|
||||
let req = StructuredPromptRequest {
|
||||
prompt: prompt.to_string(),
|
||||
};
|
||||
let resp = self
|
||||
.send(
|
||||
Method::POST,
|
||||
&routes::structured_prompt(id),
|
||||
&[],
|
||||
Some(&req),
|
||||
)
|
||||
.await?;
|
||||
check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel the structured session's active ACP turn.
|
||||
pub async fn cancel_structured_turn(&self, id: Uuid) -> Result<(), ClientError> {
|
||||
let resp = self
|
||||
.send(
|
||||
Method::POST,
|
||||
&routes::structured_cancel(id),
|
||||
&[],
|
||||
None::<&()>,
|
||||
)
|
||||
.await?;
|
||||
check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the currently pending structured-session permission request.
|
||||
pub async fn resolve_structured_approval(
|
||||
&self,
|
||||
id: Uuid,
|
||||
nonce: &str,
|
||||
decision: StructuredApprovalDecision,
|
||||
) -> Result<(), ClientError> {
|
||||
let resp = self
|
||||
.send(
|
||||
Method::POST,
|
||||
&routes::structured_approval(id, nonce),
|
||||
&[],
|
||||
Some(&decision),
|
||||
)
|
||||
.await?;
|
||||
check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
|
||||
self.get(routes::INFO, &[]).await
|
||||
}
|
||||
|
||||
@@ -159,6 +159,11 @@ pub fn attach_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
||||
Ok(format!("{}{}", ws_base(http_base)?, attach(id)))
|
||||
}
|
||||
|
||||
/// Full `WS /api/structured/:id` URL for a daemon reachable at `http_base`.
|
||||
pub fn structured_url(http_base: &str, id: Uuid) -> Result<String, UrlError> {
|
||||
Ok(format!("{}{}", ws_base(http_base)?, structured(id)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -208,6 +213,10 @@ mod tests {
|
||||
events_url("http://host:7070/").unwrap(),
|
||||
"ws://host:7070/api/events"
|
||||
);
|
||||
assert_eq!(
|
||||
structured_url("http://host:7070/", id()).unwrap(),
|
||||
"ws://host:7070/api/structured/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -21,6 +21,7 @@ thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
tracing.workspace = true
|
||||
unicode-width.workspace = true
|
||||
uuid.workspace = true
|
||||
vt100.workspace = true
|
||||
|
||||
|
||||
+388
-13
@@ -17,7 +17,10 @@ use std::time::Duration;
|
||||
use chrono::Utc;
|
||||
use crossterm::event::{Event as CtEvent, EventStream};
|
||||
use futures_util::StreamExt;
|
||||
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
|
||||
use maestro_protocol::{
|
||||
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, SessionView,
|
||||
StructuredApprovalDecision,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -38,6 +41,8 @@ use crate::render::{draw, RegionMap, RenderInputs};
|
||||
use crate::session_task::{SessionInput, SessionTask};
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||
use crate::structured_pane::StructuredPane;
|
||||
use crate::structured_task::{StructuredHandle, StructuredTask};
|
||||
use crate::text_input::TextInput;
|
||||
use crate::TuiError;
|
||||
|
||||
@@ -54,6 +59,7 @@ pub struct App {
|
||||
home_dir: String,
|
||||
catalog: CatalogStore,
|
||||
emulators: SessionEmulators,
|
||||
structured: HashMap<Uuid, StructuredHandle>,
|
||||
state: AppState,
|
||||
help: HelpOverlay,
|
||||
chord: ChordState,
|
||||
@@ -163,6 +169,18 @@ pub enum Effect {
|
||||
id: Uuid,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
StructuredPrompt {
|
||||
id: Uuid,
|
||||
prompt: String,
|
||||
},
|
||||
StructuredCancel {
|
||||
id: Uuid,
|
||||
},
|
||||
StructuredApproval {
|
||||
id: Uuid,
|
||||
nonce: String,
|
||||
decision: StructuredApprovalDecision,
|
||||
},
|
||||
/// Fetch presets / projects / names for a freshly opened form (#64).
|
||||
LoadDraft,
|
||||
/// Fast-forward spawn (#67): resolve every default from the daemon and
|
||||
@@ -214,6 +232,7 @@ impl App {
|
||||
home_dir: std::env::var("HOME").unwrap_or_default(),
|
||||
catalog,
|
||||
emulators: SessionEmulators::default(),
|
||||
structured: HashMap::new(),
|
||||
state: AppState::new(),
|
||||
draft: None,
|
||||
help,
|
||||
@@ -321,6 +340,11 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(effects) = self.handle_structured_key(&key) {
|
||||
self.perform(effects).await;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(effect) = self.handle_pane_scroll(&key) {
|
||||
self.perform(effect.into_iter().collect()).await;
|
||||
return Ok(false);
|
||||
@@ -419,6 +443,44 @@ impl App {
|
||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||
}
|
||||
}
|
||||
Effect::StructuredPrompt { id, prompt } => {
|
||||
if let Err(error) = self.daemon.send_structured_prompt(id, &prompt).await {
|
||||
if let Some(handle) = self.structured.get(&id) {
|
||||
handle
|
||||
.state
|
||||
.lock()
|
||||
.expect("structured state mutex")
|
||||
.prompt_failed(prompt, error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Effect::StructuredCancel { id } => {
|
||||
if let Err(error) = self.daemon.cancel_structured_turn(id).await {
|
||||
if let Some(handle) = self.structured.get(&id) {
|
||||
handle.state.lock().expect("structured state mutex").error =
|
||||
Some(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Effect::StructuredApproval {
|
||||
id,
|
||||
nonce,
|
||||
decision,
|
||||
} => {
|
||||
if let Err(error) = self
|
||||
.daemon
|
||||
.resolve_structured_approval(id, &nonce, decision)
|
||||
.await
|
||||
{
|
||||
if let Some(handle) = self.structured.get(&id) {
|
||||
handle
|
||||
.state
|
||||
.lock()
|
||||
.expect("structured state mutex")
|
||||
.approval_failed(&nonce, error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -563,13 +625,13 @@ impl App {
|
||||
Action::OpenCommandBar => effects.extend(self.open_command_bar()),
|
||||
// Escape hatch (#156): the literal byte, not the bar.
|
||||
Action::SendLiteralCtrlK => {
|
||||
effects.extend(self.forward_to_session(&InputKey::ctrl_char('k')));
|
||||
effects.extend(self.forward_literal_to_terminal(&InputKey::ctrl_char('k')));
|
||||
}
|
||||
// Escape hatch (#158): the leader's own byte, built from the
|
||||
// configured leader rather than a hardcoded key.
|
||||
Action::SendLiteralLeader => {
|
||||
let leader = self.cfg.leader.clone();
|
||||
effects.extend(self.forward_to_session(&leader));
|
||||
effects.extend(self.forward_literal_to_terminal(&leader));
|
||||
}
|
||||
Action::OpenSpawnModal => {
|
||||
self.open_spawn_modal();
|
||||
@@ -587,7 +649,7 @@ impl App {
|
||||
}
|
||||
// A previewed session is already attached: committing
|
||||
// to it keeps that stream rather than reopening one.
|
||||
if self.emulators.get(sid).is_none() {
|
||||
if !self.is_attached(sid) {
|
||||
effects.push(Effect::Attach(sid));
|
||||
}
|
||||
}
|
||||
@@ -595,6 +657,9 @@ impl App {
|
||||
// preview budget.
|
||||
self.preview_lru.retain(|s| *s != sid);
|
||||
self.state.attach_to_focused(sid);
|
||||
if self.session_view(sid) == SessionView::Structured {
|
||||
self.state.mode = Mode::Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::KillHighlighted => {
|
||||
@@ -663,7 +728,7 @@ impl App {
|
||||
self.state.preview = Some(sid);
|
||||
// Set outside the input path, so nothing else marks the frame stale.
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
let fresh = self.emulators.get(sid).is_none();
|
||||
let fresh = !self.is_attached(sid);
|
||||
if fresh {
|
||||
self.preview_lru.retain(|s| *s != sid);
|
||||
self.preview_lru.push(sid);
|
||||
@@ -732,15 +797,27 @@ impl App {
|
||||
|
||||
/// Open a stream for `sid` if one isn't already running.
|
||||
fn attach(&mut self, sid: Uuid) {
|
||||
if self.emulators.get(sid).is_some() {
|
||||
self.attach_with_view(sid, self.session_view(sid));
|
||||
}
|
||||
|
||||
fn attach_with_view(&mut self, sid: Uuid, view: SessionView) {
|
||||
if self.is_attached(sid) {
|
||||
return;
|
||||
}
|
||||
match self.daemon.attach_url(sid) {
|
||||
Ok(ws_url) => {
|
||||
let handle = SessionTask::spawn(ws_url, 24, 80);
|
||||
self.emulators.insert(sid, handle);
|
||||
}
|
||||
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
||||
match view {
|
||||
SessionView::Terminal => match self.daemon.attach_url(sid) {
|
||||
Ok(ws_url) => {
|
||||
let handle = SessionTask::spawn(ws_url, 24, 80);
|
||||
self.emulators.insert(sid, handle);
|
||||
}
|
||||
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
||||
},
|
||||
SessionView::Structured => match self.daemon.structured_url(sid) {
|
||||
Ok(ws_url) => {
|
||||
self.structured.insert(sid, StructuredTask::spawn(ws_url));
|
||||
}
|
||||
Err(e) => self.state.toast(format!("attach failed: {e}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,9 +825,26 @@ impl App {
|
||||
/// dropping its vt100 parser.
|
||||
fn release_session(&mut self, sid: Uuid) {
|
||||
self.emulators.remove(sid);
|
||||
if let Some(handle) = self.structured.remove(&sid) {
|
||||
handle.end();
|
||||
}
|
||||
self.last_sent_size.remove(&sid);
|
||||
}
|
||||
|
||||
fn session_view(&self, sid: Uuid) -> SessionView {
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == sid)
|
||||
.map(|session| session.view)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_attached(&self, sid: Uuid) -> bool {
|
||||
self.emulators.get(sid).is_some() || self.structured.contains_key(&sid)
|
||||
}
|
||||
|
||||
/// Return the session under the sidebar highlight, falling back to the
|
||||
/// first listed session so the user can attach without arrowing first.
|
||||
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
||||
@@ -817,6 +911,16 @@ impl App {
|
||||
/// attached to send it to.
|
||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||
let sid = self.state.active_session()?;
|
||||
if let Some(handle) = self.structured.get(&sid) {
|
||||
let mut state = handle.state.lock().expect("structured state mutex");
|
||||
if matches!(key, InputKey::Enter) {
|
||||
return state
|
||||
.begin_prompt()
|
||||
.map(|prompt| Effect::StructuredPrompt { id: sid, prompt });
|
||||
}
|
||||
state.edit_composer(key);
|
||||
return None;
|
||||
}
|
||||
let handle = self.emulators.get(sid)?;
|
||||
let bytes = encode_key(key)?;
|
||||
handle
|
||||
@@ -827,6 +931,86 @@ impl App {
|
||||
Some(Effect::Forward { id: sid, bytes })
|
||||
}
|
||||
|
||||
fn forward_literal_to_terminal(&mut self, key: &InputKey) -> Option<Effect> {
|
||||
let sid = self.state.active_session()?;
|
||||
if self.session_view(sid) == SessionView::Structured {
|
||||
return None;
|
||||
}
|
||||
self.forward_to_session(key)
|
||||
}
|
||||
|
||||
fn handle_structured_key(&mut self, key: &InputKey) -> Option<Vec<Effect>> {
|
||||
if self.state.focus != Focus::Pane || self.state.preview.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sid = self.state.active_session()?;
|
||||
let handle = self.structured.get(&sid)?;
|
||||
let mut state = handle.state.lock().expect("structured state mutex");
|
||||
if self.state.mode == Mode::Insert {
|
||||
if matches!(key, InputKey::Esc) && state.turn_active() {
|
||||
return Some(vec![Effect::StructuredCancel { id: sid }]);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let effects = match key {
|
||||
InputKey::Char {
|
||||
c: 'j',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
..
|
||||
}
|
||||
| InputKey::Down => {
|
||||
state.select(1);
|
||||
Vec::new()
|
||||
}
|
||||
InputKey::Char {
|
||||
c: 'k',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
..
|
||||
}
|
||||
| InputKey::Up => {
|
||||
state.select(-1);
|
||||
Vec::new()
|
||||
}
|
||||
InputKey::Enter => {
|
||||
state.toggle_selected();
|
||||
Vec::new()
|
||||
}
|
||||
InputKey::Esc => {
|
||||
if state.turn_active() {
|
||||
vec![Effect::StructuredCancel { id: sid }]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
InputKey::Char {
|
||||
c: key @ ('a' | 'A' | 'd'),
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
..
|
||||
} => state
|
||||
.approval_decision(*key)
|
||||
.map(|(nonce, decision)| Effect::StructuredApproval {
|
||||
id: sid,
|
||||
nonce,
|
||||
decision,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
InputKey::PageUp => {
|
||||
state.scroll_by(self.pane_page_rows() as isize);
|
||||
Vec::new()
|
||||
}
|
||||
InputKey::PageDown => {
|
||||
state.scroll_by(-(self.pane_page_rows() as isize));
|
||||
Vec::new()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(effects)
|
||||
}
|
||||
|
||||
fn pane_page_rows(&self) -> usize {
|
||||
self.region
|
||||
.pane
|
||||
@@ -839,6 +1023,14 @@ impl App {
|
||||
let Some(sid) = self.state.active_session() else {
|
||||
return;
|
||||
};
|
||||
if let Some(handle) = self.structured.get(&sid) {
|
||||
handle
|
||||
.state
|
||||
.lock()
|
||||
.expect("structured state mutex")
|
||||
.scroll_by(rows);
|
||||
return;
|
||||
}
|
||||
let Some(handle) = self.emulators.get(sid) else {
|
||||
return;
|
||||
};
|
||||
@@ -872,6 +1064,14 @@ impl App {
|
||||
_ => return None,
|
||||
};
|
||||
let sid = self.state.active_session()?;
|
||||
if let Some(handle) = self.structured.get(&sid) {
|
||||
handle
|
||||
.state
|
||||
.lock()
|
||||
.expect("structured state mutex")
|
||||
.scroll_by(lines);
|
||||
return Some(None);
|
||||
}
|
||||
let handle = self.emulators.get(sid)?;
|
||||
let pane = self.region.pane?;
|
||||
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||
@@ -1294,7 +1494,10 @@ impl App {
|
||||
}
|
||||
self.draft = None;
|
||||
self.state.complete_spawn(sid);
|
||||
self.attach(sid);
|
||||
if session.view == SessionView::Structured {
|
||||
self.state.mode = Mode::Normal;
|
||||
}
|
||||
self.attach_with_view(sid, session.view);
|
||||
}
|
||||
Err(e) => {
|
||||
// A failed POST belongs in the form it came from (#64), not a
|
||||
@@ -1355,6 +1558,11 @@ impl App {
|
||||
self.emulators
|
||||
.get(sid)
|
||||
.map(|h| (sid, h.revision(), h.state()))
|
||||
.or_else(|| {
|
||||
self.structured
|
||||
.get(&sid)
|
||||
.map(|h| (sid, h.revision(), h.connection()))
|
||||
})
|
||||
});
|
||||
FrameKey {
|
||||
catalog: self.catalog.version(),
|
||||
@@ -1416,6 +1624,17 @@ impl App {
|
||||
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||
})
|
||||
});
|
||||
let structured_pane = shown_sid.and_then(|sid| {
|
||||
let handle = self.structured.get(&sid)?;
|
||||
let session = snap.sessions.iter().find(|session| session.id == sid)?;
|
||||
Some(StructuredPane {
|
||||
handle: handle.clone(),
|
||||
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||
mode: self.state.mode,
|
||||
session_status: session.status,
|
||||
activity_detail: session.activity_detail.clone(),
|
||||
})
|
||||
});
|
||||
let pane_title = match shown_sid {
|
||||
Some(sid) => {
|
||||
let name = snap
|
||||
@@ -1439,6 +1658,7 @@ impl App {
|
||||
home_dir: &self.home_dir,
|
||||
pane_title: &pane_title,
|
||||
pane: pane.as_ref(),
|
||||
structured_pane: structured_pane.as_ref(),
|
||||
state: &self.state,
|
||||
draft: self.draft.as_ref(),
|
||||
help: &self.help,
|
||||
@@ -2805,6 +3025,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_response_selects_transport_before_catalog_event() {
|
||||
use axum::routing::post;
|
||||
use axum::Json;
|
||||
|
||||
async fn spawn_session(
|
||||
Json(req): Json<CreateSessionRequest>,
|
||||
) -> Json<maestro_protocol::Session> {
|
||||
let id = match req.view {
|
||||
SessionView::Terminal => Uuid::from_u128(187),
|
||||
SessionView::Structured => Uuid::from_u128(188),
|
||||
};
|
||||
let mut session = test_session(id, req.cwd.to_str().unwrap());
|
||||
session.view = req.view;
|
||||
session.command = req.command;
|
||||
Json(session)
|
||||
}
|
||||
|
||||
let router =
|
||||
axum::Router::new().route(maestro_protocol::routes::SESSIONS, post(spawn_session));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
|
||||
|
||||
let mut app = make_app_at(&base);
|
||||
let request = |view| CreateSessionRequest {
|
||||
view,
|
||||
cwd: std::path::PathBuf::from("/srv/work"),
|
||||
command: "claude".into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
};
|
||||
|
||||
let structured_id = Uuid::from_u128(188);
|
||||
app.submit_spawn(request(SessionView::Structured)).await;
|
||||
assert!(app.catalog.snapshot().sessions.is_empty());
|
||||
assert!(app.structured.contains_key(&structured_id));
|
||||
assert!(app.emulators.get(structured_id).is_none());
|
||||
|
||||
let terminal_id = Uuid::from_u128(187);
|
||||
app.submit_spawn(request(SessionView::Terminal)).await;
|
||||
assert!(app.catalog.snapshot().sessions.is_empty());
|
||||
assert!(app.emulators.get(terminal_id).is_some());
|
||||
assert!(!app.structured.contains_key(&terminal_id));
|
||||
}
|
||||
|
||||
/// The full data load against a live daemon stub: presets replace the
|
||||
/// built-ins, projects land sorted, and the first generated name seeds
|
||||
/// the field.
|
||||
@@ -3697,4 +3964,112 @@ mod tests {
|
||||
assert!(app.command_bar.is_none());
|
||||
assert!(app.state.overlay().is_none());
|
||||
}
|
||||
|
||||
fn structured_app(sid: Uuid) -> App {
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
let mut session = app.catalog.snapshot().sessions[0].clone();
|
||||
session.view = SessionView::Structured;
|
||||
app.catalog
|
||||
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
||||
app.structured.insert(
|
||||
sid,
|
||||
StructuredHandle::test_handle(crate::structured_task::StructuredState::default()),
|
||||
);
|
||||
app.state.attach_to_focused(sid);
|
||||
app
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_view_selects_the_structured_connection_and_cleanup_stops_it() {
|
||||
let sid = Uuid::from_u128(141);
|
||||
let mut app = app_with_highlighted_session(sid);
|
||||
let mut session = app.catalog.snapshot().sessions[0].clone();
|
||||
session.view = SessionView::Structured;
|
||||
app.catalog
|
||||
.apply_event(maestro_protocol::CatalogEvent::SessionUpdated { session });
|
||||
|
||||
app.attach(sid);
|
||||
assert!(app.structured.contains_key(&sid));
|
||||
assert!(app.emulators.get(sid).is_none());
|
||||
let handle = app.structured.get(&sid).unwrap().clone();
|
||||
app.release_session(sid);
|
||||
assert_eq!(
|
||||
handle.connection(),
|
||||
crate::session_task::SessionState::Ended
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_normal_mode_routes_navigation_expansion_and_approval_by_row() {
|
||||
use maestro_protocol::{ReducedState, TranscriptRow, TranscriptRowKind};
|
||||
use serde_json::json;
|
||||
|
||||
let sid = Uuid::from_u128(141);
|
||||
let mut app = structured_app(sid);
|
||||
app.dispatch(Action::AttachHighlighted);
|
||||
assert_eq!(app.state.focus, Focus::Pane);
|
||||
assert_eq!(app.state.mode, Mode::Normal);
|
||||
let handle = app.structured.get(&sid).unwrap().clone();
|
||||
{
|
||||
let mut state = handle.state.lock().unwrap();
|
||||
state.rows = vec![
|
||||
TranscriptRow {
|
||||
id: "text".into(),
|
||||
kind: TranscriptRowKind::AgentText,
|
||||
data: json!({"content":{"type":"text","text":"hello"}}),
|
||||
},
|
||||
TranscriptRow {
|
||||
id: "approval".into(),
|
||||
kind: TranscriptRowKind::Approval,
|
||||
data: json!({"options":[{"kind":"allow_once"}]}),
|
||||
},
|
||||
];
|
||||
state.selected = Some("text".into());
|
||||
state.reduced = ReducedState {
|
||||
pending_approval: Some(json!({"nonce":"n"})),
|
||||
..ReducedState::default()
|
||||
};
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
app.handle_structured_key(&InputKey::char('j')),
|
||||
Some(vec![])
|
||||
);
|
||||
assert_eq!(
|
||||
app.handle_structured_key(&InputKey::char('a')),
|
||||
Some(vec![Effect::StructuredApproval {
|
||||
id: sid,
|
||||
nonce: "n".into(),
|
||||
decision: StructuredApprovalDecision::AllowOnce,
|
||||
}])
|
||||
);
|
||||
assert_eq!(
|
||||
handle.state.lock().unwrap().selected.as_deref(),
|
||||
Some("approval")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_insert_mode_edits_submits_and_cancels_without_pty_bytes() {
|
||||
let sid = Uuid::from_u128(141);
|
||||
let mut app = structured_app(sid);
|
||||
assert!(app.emulators.get(sid).is_none());
|
||||
app.forward_to_session(&InputKey::char('h'));
|
||||
app.forward_to_session(&InputKey::char('i'));
|
||||
assert_eq!(
|
||||
app.forward_to_session(&InputKey::Enter),
|
||||
Some(Effect::StructuredPrompt {
|
||||
id: sid,
|
||||
prompt: "hi".into(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
app.handle_structured_key(&InputKey::Esc),
|
||||
Some(vec![Effect::StructuredCancel { id: sid }])
|
||||
);
|
||||
assert_eq!(
|
||||
app.forward_literal_to_terminal(&app.cfg.leader.clone()),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+120
-11
@@ -41,8 +41,10 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_config::{builtin_presets, custom_preset};
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
use maestro_protocol::display::{abbreviate_dir, command_tool};
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||
};
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -86,9 +88,11 @@ pub enum DraftField {
|
||||
/// apply immediately, mirroring the web form's per-click choice.
|
||||
WorktreeChoice,
|
||||
Name,
|
||||
/// Disclosure collapsing the worktree/branch controls below (#93).
|
||||
/// Disclosure collapsing the optional view and worktree controls below.
|
||||
/// Closed by default; Enter / Space toggles.
|
||||
Advanced,
|
||||
/// ACP-backed transcript instead of a terminal. Claude Code only.
|
||||
StructuredToggle,
|
||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||
WorktreeToggle,
|
||||
/// New-vs-existing branch source; Left / Right switches.
|
||||
@@ -251,6 +255,9 @@ pub struct DraftPane {
|
||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||
/// take the default target.
|
||||
pub advanced_collapsed: bool,
|
||||
/// ACP-backed structured transcript. Off by default and supported only
|
||||
/// when the resolved command is Claude Code.
|
||||
pub structured_view: bool,
|
||||
/// Enclosing repository of the chosen target — from the project scan for
|
||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||
pub base_repo: Option<PathBuf>,
|
||||
@@ -311,6 +318,7 @@ impl Default for DraftPane {
|
||||
name_touched: false,
|
||||
worktree_enabled: true,
|
||||
advanced_collapsed: true,
|
||||
structured_view: false,
|
||||
base_repo: None,
|
||||
probed_cwd: None,
|
||||
probe_seq: 0,
|
||||
@@ -348,6 +356,7 @@ impl DraftPane {
|
||||
.position(|p| !p.command.is_empty())
|
||||
.unwrap_or(0);
|
||||
self.apply_seed_command();
|
||||
self.clear_unsupported_structured_view();
|
||||
}
|
||||
|
||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||
@@ -663,6 +672,22 @@ impl DraftPane {
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_structured_view(&self) -> bool {
|
||||
command_tool(self.command()) == Some("claude")
|
||||
}
|
||||
|
||||
fn clear_unsupported_structured_view(&mut self) {
|
||||
if !self.supports_structured_view() {
|
||||
self.structured_view = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_structured_view(&mut self) {
|
||||
if self.supports_structured_view() {
|
||||
self.structured_view = !self.structured_view;
|
||||
}
|
||||
}
|
||||
|
||||
/// The chosen working directory: a picked project row, else the raw path
|
||||
/// input when it holds something.
|
||||
pub fn cwd(&self) -> Option<PathBuf> {
|
||||
@@ -694,7 +719,11 @@ impl DraftPane {
|
||||
}
|
||||
let worktree = self.build_worktree()?;
|
||||
Ok(CreateSessionRequest {
|
||||
view: Default::default(),
|
||||
view: if self.structured_view && self.supports_structured_view() {
|
||||
SessionView::Structured
|
||||
} else {
|
||||
SessionView::Terminal
|
||||
},
|
||||
cwd,
|
||||
command: command.to_string(),
|
||||
env: Default::default(),
|
||||
@@ -754,6 +783,9 @@ impl DraftPane {
|
||||
order.push(DraftField::Name);
|
||||
order.push(DraftField::Advanced);
|
||||
if !self.advanced_collapsed {
|
||||
if self.supports_structured_view() {
|
||||
order.push(DraftField::StructuredToggle);
|
||||
}
|
||||
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
||||
order.push(DraftField::WorktreeChoice);
|
||||
}
|
||||
@@ -795,6 +827,7 @@ impl DraftPane {
|
||||
}
|
||||
self.selected_preset =
|
||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||
self.clear_unsupported_structured_view();
|
||||
}
|
||||
|
||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||
@@ -1001,9 +1034,9 @@ impl DraftPane {
|
||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||
// highlighted row isn't the picked one until a first Enter chooses
|
||||
// it (the next Enter, now that it's active, falls through to
|
||||
// submit); and the disclosure/toggle pair (`Advanced`,
|
||||
// `WorktreeToggle`), which activate on Enter like a button rather
|
||||
// than submitting, same as the web form's own `<button>` controls.
|
||||
// submit); and the disclosure/checkbox fields, which activate on
|
||||
// Enter like buttons rather than submitting, matching the web
|
||||
// form controls.
|
||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||
let choose_only =
|
||||
@@ -1023,6 +1056,10 @@ impl DraftPane {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
|
||||
self.toggle_structured_view();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Enter => DraftInputResult::Submit,
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
@@ -1040,6 +1077,14 @@ impl DraftPane {
|
||||
self.toggle_worktree();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Char {
|
||||
c: ' ',
|
||||
ctrl: false,
|
||||
..
|
||||
} if self.focused_field == DraftField::StructuredToggle => {
|
||||
self.toggle_structured_view();
|
||||
DraftInputResult::Consumed
|
||||
}
|
||||
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
||||
self.move_selection(-1);
|
||||
DraftInputResult::Consumed
|
||||
@@ -1083,7 +1128,10 @@ impl DraftPane {
|
||||
other => {
|
||||
match self.focused_field {
|
||||
DraftField::Preset | DraftField::Submit => {}
|
||||
DraftField::CustomCommand => self.custom_command.handle_key(other),
|
||||
DraftField::CustomCommand => {
|
||||
self.custom_command.handle_key(other);
|
||||
self.clear_unsupported_structured_view();
|
||||
}
|
||||
// Typing filters the list; the highlight resets to the top
|
||||
// so a narrowed search always starts at its best match.
|
||||
DraftField::DirPicker => {
|
||||
@@ -1121,9 +1169,9 @@ impl DraftPane {
|
||||
|
||||
let show_custom = self.is_custom();
|
||||
let show_raw = self.cwd_raw_visible;
|
||||
// The worktree choice step, the worktree toggle and its body all stay
|
||||
// behind the "Advanced options" disclosure (#93): collapsed hides
|
||||
// them, regardless of what they'd otherwise show.
|
||||
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
|
||||
// Optional view and worktree controls stay behind the "Advanced
|
||||
// options" disclosure (#93), regardless of what they'd otherwise show.
|
||||
let show_wt_toggle = !self.advanced_collapsed;
|
||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||
let show_wt_choice =
|
||||
@@ -1142,6 +1190,7 @@ impl DraftPane {
|
||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
||||
+ u16::from(show_custom)
|
||||
+ u16::from(show_raw)
|
||||
+ u16::from(show_structured)
|
||||
+ u16::from(show_wt_toggle)
|
||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||
@@ -1161,6 +1210,9 @@ impl DraftPane {
|
||||
}
|
||||
constraints.push(Constraint::Length(1)); // name
|
||||
constraints.push(Constraint::Length(1)); // advanced header
|
||||
if show_structured {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
if show_wt_choice {
|
||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||
}
|
||||
@@ -1359,6 +1411,24 @@ impl DraftPane {
|
||||
advanced_row,
|
||||
);
|
||||
|
||||
if show_structured {
|
||||
let row = next_row();
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(format!("{:<GUTTER$}", "view:")),
|
||||
Span::styled(
|
||||
if self.structured_view {
|
||||
"[x] structured view"
|
||||
} else {
|
||||
"[ ] structured view"
|
||||
},
|
||||
field_style(DraftField::StructuredToggle),
|
||||
),
|
||||
])),
|
||||
row,
|
||||
);
|
||||
}
|
||||
|
||||
if show_wt_choice {
|
||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||
// worktrees newest-first.
|
||||
@@ -1817,6 +1887,45 @@ mod tests {
|
||||
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
|
||||
let mut draft = DraftPane {
|
||||
advanced_collapsed: false,
|
||||
..DraftPane::default()
|
||||
};
|
||||
assert!(!draft.structured_view);
|
||||
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(rendered.contains("[ ] structured view"), "{rendered}");
|
||||
|
||||
draft.toggle_structured_view();
|
||||
assert!(draft.structured_view);
|
||||
draft.cycle_preset(1);
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
assert!(
|
||||
!draft.structured_view,
|
||||
"a hidden value must not stay checked"
|
||||
);
|
||||
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||
assert!(!rendered.contains("structured view"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_view_request_is_guarded_against_a_stale_command() {
|
||||
let mut draft = DraftPane {
|
||||
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||
worktree_enabled: false,
|
||||
structured_view: true,
|
||||
..DraftPane::default()
|
||||
};
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||
|
||||
draft.selected_preset = 1;
|
||||
assert_eq!(draft.command(), "opencode");
|
||||
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
||||
let mut draft = DraftPane {
|
||||
|
||||
@@ -23,6 +23,8 @@ pub mod render;
|
||||
pub mod session_task;
|
||||
pub mod sidebar;
|
||||
pub mod state;
|
||||
pub mod structured_pane;
|
||||
pub mod structured_task;
|
||||
pub mod terminal;
|
||||
pub mod text_input;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::palette;
|
||||
use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||
use crate::structured_pane::StructuredPane;
|
||||
|
||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||
/// coordinate to one of these via [`RegionMap::region_at`].
|
||||
@@ -80,6 +81,7 @@ pub struct RenderInputs<'a> {
|
||||
pub home_dir: &'a str,
|
||||
pub pane_title: &'a str,
|
||||
pub pane: Option<&'a Pane>,
|
||||
pub structured_pane: Option<&'a StructuredPane>,
|
||||
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||
pub state: &'a AppState,
|
||||
@@ -147,14 +149,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||
if let Some(p) = inputs.pane {
|
||||
p.render(f, pane_area);
|
||||
} else if let Some(p) = inputs.structured_pane {
|
||||
p.render(f, pane_area);
|
||||
}
|
||||
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
||||
// chrome; every remaining cell belongs to the emulator.
|
||||
// chrome; every remaining cell belongs to the active session pane.
|
||||
map.pane = Some(pane_area);
|
||||
|
||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||
// always see the current mode + key hints.
|
||||
render_status_bar(f, status_area, inputs.state, inputs.link);
|
||||
render_status_bar(
|
||||
f,
|
||||
status_area,
|
||||
inputs.state,
|
||||
inputs.link,
|
||||
inputs.structured_pane.is_some(),
|
||||
);
|
||||
|
||||
match inputs.state.overlay() {
|
||||
Some(OverlayKind::Spawn) => {
|
||||
@@ -253,7 +263,13 @@ fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||
/// mode + focused panel + the most useful key hints so the user is never
|
||||
/// guessing what state they're in.
|
||||
fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkStatus) {
|
||||
fn render_status_bar(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
state: &AppState,
|
||||
link: &LinkStatus,
|
||||
structured: bool,
|
||||
) {
|
||||
// The daemon indicator is pinned right and sized to its own text, so a
|
||||
// narrow terminal truncates the key hints rather than the release.
|
||||
let label = link_label(link);
|
||||
@@ -271,9 +287,13 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState, link: &LinkSta
|
||||
Focus::Sidebar => "sidebar",
|
||||
Focus::Pane => "pane",
|
||||
};
|
||||
let hint = match state.mode {
|
||||
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
|
||||
Mode::Insert => "^a: return to sidebar",
|
||||
let hint = match (structured && state.focus == Focus::Pane, state.mode) {
|
||||
(true, Mode::Normal) => "j/k: rows • enter: expand • i: compose • esc: cancel",
|
||||
(true, Mode::Insert) => "enter: send • esc: cancel • ^a: sidebar",
|
||||
(false, Mode::Normal) => {
|
||||
"j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit"
|
||||
}
|
||||
(false, Mode::Insert) => "^a: return to sidebar",
|
||||
};
|
||||
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||
let line = ratatui::text::Line::from(vec![
|
||||
@@ -435,6 +455,7 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
structured_pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -532,6 +553,7 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
structured_pane: None,
|
||||
draft: Some(&draft),
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -587,6 +609,7 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
structured_pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -629,6 +652,7 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
structured_pane: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
|
||||
@@ -221,7 +221,7 @@ impl SessionTask {
|
||||
}
|
||||
}
|
||||
|
||||
fn jitter(base: Duration) -> Duration {
|
||||
pub(crate) fn jitter(base: Duration) -> Duration {
|
||||
let ms = base.as_millis() as u64;
|
||||
let now_nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,735 @@
|
||||
//! Live projection state for one structured session.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use maestro_protocol::{
|
||||
ReducedState, StructuredApprovalDecision, StructuredEvent, TranscriptDelta, TranscriptRow,
|
||||
TranscriptRowKind,
|
||||
};
|
||||
use tokio::sync::watch;
|
||||
use tokio_tungstenite::tungstenite::{Error as WsError, Message};
|
||||
|
||||
use crate::input::InputKey;
|
||||
use crate::session_task::SessionState;
|
||||
use crate::text_input::TextInput;
|
||||
|
||||
/// Renderer and input state built directly from the daemon's two projections.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StructuredState {
|
||||
pub rows: Vec<TranscriptRow>,
|
||||
pub reduced: ReducedState,
|
||||
pub selected: Option<String>,
|
||||
pub expanded: HashSet<String>,
|
||||
pub scroll: usize,
|
||||
pub follow_selection: bool,
|
||||
pub composer: TextInput,
|
||||
pub error: Option<String>,
|
||||
submitting: bool,
|
||||
submission_generation: u64,
|
||||
reconcile_submission_on_snapshot: Option<u64>,
|
||||
approval_submitting: bool,
|
||||
}
|
||||
|
||||
impl Default for StructuredState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
rows: Vec::new(),
|
||||
reduced: ReducedState::default(),
|
||||
selected: None,
|
||||
expanded: HashSet::new(),
|
||||
scroll: 0,
|
||||
follow_selection: true,
|
||||
composer: TextInput::default(),
|
||||
error: None,
|
||||
submitting: false,
|
||||
submission_generation: 0,
|
||||
reconcile_submission_on_snapshot: None,
|
||||
approval_submitting: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StructuredState {
|
||||
pub fn apply(&mut self, event: StructuredEvent) {
|
||||
match event {
|
||||
StructuredEvent::TranscriptSnapshot { snapshot } => {
|
||||
let selected = self.selected.clone();
|
||||
self.rows = snapshot.rows;
|
||||
self.selected = selected
|
||||
.filter(|id| self.rows.iter().any(|row| &row.id == id))
|
||||
.or_else(|| {
|
||||
self.rows
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|row| row.kind != TranscriptRowKind::Plan)
|
||||
.map(|row| row.id.clone())
|
||||
});
|
||||
self.expanded
|
||||
.retain(|id| self.rows.iter().any(|row| &row.id == id));
|
||||
}
|
||||
StructuredEvent::ReducedState { state } => {
|
||||
let reconcile_submission = self
|
||||
.reconcile_submission_on_snapshot
|
||||
.take()
|
||||
.is_some_and(|generation| {
|
||||
self.submitting && generation == self.submission_generation
|
||||
});
|
||||
self.apply_reduced(state, reconcile_submission);
|
||||
}
|
||||
StructuredEvent::ProjectionUpdate { transcript, state } => {
|
||||
if let Some(delta) = transcript {
|
||||
self.apply_delta(delta);
|
||||
}
|
||||
self.apply_reduced(state, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_delta(&mut self, delta: TranscriptDelta) {
|
||||
match delta {
|
||||
TranscriptDelta::Append { row } => {
|
||||
let followed_tail = self.follow_selection
|
||||
&& self.selected.as_ref().is_some_and(|id| {
|
||||
self.rows
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|old| old.kind != TranscriptRowKind::Plan)
|
||||
.is_some_and(|old| &old.id == id)
|
||||
});
|
||||
if let Some(index) = self.rows.iter().position(|old| old.id == row.id) {
|
||||
self.rows[index] = row.clone();
|
||||
} else {
|
||||
self.rows.push(row.clone());
|
||||
}
|
||||
if row.kind != TranscriptRowKind::Plan && (self.selected.is_none() || followed_tail)
|
||||
{
|
||||
self.selected = Some(row.id);
|
||||
}
|
||||
}
|
||||
TranscriptDelta::Patch { row } => {
|
||||
if let Some(index) = self.rows.iter().position(|old| old.id == row.id) {
|
||||
self.rows[index] = row.clone();
|
||||
} else {
|
||||
self.rows.push(row.clone());
|
||||
}
|
||||
}
|
||||
TranscriptDelta::Remove { id } => {
|
||||
let removed = self.rows.iter().position(|row| row.id == id);
|
||||
self.rows.retain(|row| row.id != id);
|
||||
self.expanded.remove(&id);
|
||||
if self.selected.as_deref() == Some(&id) {
|
||||
self.selected = removed.and_then(|index| {
|
||||
let split = index.min(self.rows.len());
|
||||
self.rows[split..]
|
||||
.iter()
|
||||
.find(|row| row.kind != TranscriptRowKind::Plan)
|
||||
.or_else(|| {
|
||||
self.rows[..split]
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|row| row.kind != TranscriptRowKind::Plan)
|
||||
})
|
||||
.map(|row| row.id.clone())
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_reduced(&mut self, state: ReducedState, reconcile_submission: bool) {
|
||||
let previous_nonce = self.pending_nonce().map(str::to_owned);
|
||||
if reconcile_submission || state.turn.responding || state.turn.thinking {
|
||||
self.submitting = false;
|
||||
}
|
||||
self.reduced = state;
|
||||
let next_nonce = self.pending_nonce().map(str::to_owned);
|
||||
if next_nonce != previous_nonce {
|
||||
self.approval_submitting = false;
|
||||
if next_nonce.is_some() {
|
||||
self.selected = self
|
||||
.rows
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|row| row.kind == TranscriptRowKind::Approval)
|
||||
.map(|row| row.id.clone());
|
||||
self.follow_selection = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_snapshot_reconciliation(&mut self) {
|
||||
self.reconcile_submission_on_snapshot =
|
||||
self.submitting.then_some(self.submission_generation);
|
||||
}
|
||||
|
||||
pub fn select(&mut self, delta: isize) {
|
||||
let selectable: Vec<&TranscriptRow> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|row| row.kind != TranscriptRowKind::Plan)
|
||||
.collect();
|
||||
if selectable.is_empty() {
|
||||
self.selected = None;
|
||||
return;
|
||||
}
|
||||
let current = self
|
||||
.selected
|
||||
.as_ref()
|
||||
.and_then(|id| selectable.iter().position(|row| &row.id == id))
|
||||
.unwrap_or(selectable.len() - 1);
|
||||
let next = if delta >= 0 {
|
||||
current
|
||||
.saturating_add(delta as usize)
|
||||
.min(selectable.len() - 1)
|
||||
} else {
|
||||
current.saturating_sub(delta.unsigned_abs())
|
||||
};
|
||||
self.selected = Some(selectable[next].id.clone());
|
||||
self.follow_selection = true;
|
||||
}
|
||||
|
||||
pub fn toggle_selected(&mut self) {
|
||||
let Some(id) = self.selected.clone() else {
|
||||
return;
|
||||
};
|
||||
let expandable = self.rows.iter().any(|row| {
|
||||
row.id == id
|
||||
&& matches!(
|
||||
row.kind,
|
||||
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff
|
||||
)
|
||||
});
|
||||
if !expandable {
|
||||
return;
|
||||
}
|
||||
if !self.expanded.insert(id.clone()) {
|
||||
self.expanded.remove(&id);
|
||||
}
|
||||
self.follow_selection = true;
|
||||
}
|
||||
|
||||
pub fn scroll_by(&mut self, lines: isize) {
|
||||
self.scroll = if lines >= 0 {
|
||||
self.scroll.saturating_sub(lines as usize)
|
||||
} else {
|
||||
self.scroll.saturating_add(lines.unsigned_abs())
|
||||
};
|
||||
self.follow_selection = false;
|
||||
}
|
||||
|
||||
pub fn edit_composer(&mut self, key: &InputKey) {
|
||||
if !self.turn_active() {
|
||||
self.composer.handle_key(key);
|
||||
self.error = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_prompt(&mut self) -> Option<String> {
|
||||
if self.turn_active() || self.composer.value().trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let prompt = self.composer.value().to_string();
|
||||
self.composer = TextInput::default();
|
||||
self.submission_generation = self.submission_generation.wrapping_add(1);
|
||||
self.submitting = true;
|
||||
self.error = None;
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
pub fn prompt_failed(&mut self, prompt: String, error: String) {
|
||||
if self.composer.value().is_empty() {
|
||||
self.composer = TextInput::with_value(prompt);
|
||||
}
|
||||
self.submitting = false;
|
||||
self.error = Some(error);
|
||||
}
|
||||
|
||||
pub fn turn_active(&self) -> bool {
|
||||
self.submitting || self.reduced.turn.responding || self.reduced.turn.thinking
|
||||
}
|
||||
|
||||
pub fn pending_nonce(&self) -> Option<&str> {
|
||||
self.reduced
|
||||
.pending_approval
|
||||
.as_ref()?
|
||||
.get("nonce")?
|
||||
.as_str()
|
||||
}
|
||||
|
||||
pub fn approval_decision(&mut self, key: char) -> Option<(String, StructuredApprovalDecision)> {
|
||||
if self.approval_submitting {
|
||||
return None;
|
||||
}
|
||||
let selected = self.selected.as_deref()?;
|
||||
let pending_row = self
|
||||
.rows
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|row| row.kind == TranscriptRowKind::Approval)?;
|
||||
if pending_row.id != selected {
|
||||
return None;
|
||||
}
|
||||
let decision = match key {
|
||||
'a' => StructuredApprovalDecision::AllowOnce,
|
||||
'A' => StructuredApprovalDecision::AllowForSession,
|
||||
'd' => StructuredApprovalDecision::Deny,
|
||||
_ => return None,
|
||||
};
|
||||
let offered = offered_decisions(&pending_row.data);
|
||||
if !offered.contains(&decision) {
|
||||
return None;
|
||||
}
|
||||
let nonce = self.pending_nonce()?.to_string();
|
||||
self.approval_submitting = true;
|
||||
self.error = None;
|
||||
Some((nonce, decision))
|
||||
}
|
||||
|
||||
pub fn approval_failed(&mut self, nonce: &str, error: String) {
|
||||
if self.pending_nonce() != Some(nonce) {
|
||||
return;
|
||||
}
|
||||
self.approval_submitting = false;
|
||||
self.error = Some(error);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn offered_decisions(data: &serde_json::Value) -> Vec<StructuredApprovalDecision> {
|
||||
let mut out = Vec::new();
|
||||
for kind in data
|
||||
.get("options")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|option| option.get("kind").and_then(serde_json::Value::as_str))
|
||||
{
|
||||
match kind {
|
||||
"allow_once" if !out.contains(&StructuredApprovalDecision::AllowOnce) => {
|
||||
out.push(StructuredApprovalDecision::AllowOnce)
|
||||
}
|
||||
"allow_always" if !out.contains(&StructuredApprovalDecision::AllowForSession) => {
|
||||
out.push(StructuredApprovalDecision::AllowForSession)
|
||||
}
|
||||
"reject_once" | "reject_always" if !out.contains(&StructuredApprovalDecision::Deny) => {
|
||||
out.push(StructuredApprovalDecision::Deny)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Cheap shared handle for a structured projection connection.
|
||||
#[derive(Clone)]
|
||||
pub struct StructuredHandle {
|
||||
pub state: Arc<Mutex<StructuredState>>,
|
||||
connection: Arc<AtomicU8>,
|
||||
revision: Arc<AtomicU64>,
|
||||
stop: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for StructuredHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("StructuredHandle")
|
||||
.field("connection", &self.connection())
|
||||
.field("revision", &self.revision())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl StructuredHandle {
|
||||
pub fn connection(&self) -> SessionState {
|
||||
match self.connection.load(Ordering::Acquire) {
|
||||
0 => SessionState::Connecting,
|
||||
1 => SessionState::Live,
|
||||
2 => SessionState::Reconnecting,
|
||||
_ => SessionState::Ended,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn end(&self) {
|
||||
self.connection
|
||||
.store(SessionState::Ended as u8, Ordering::Release);
|
||||
let _ = self.stop.send(true);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_handle(state: StructuredState) -> Self {
|
||||
let (stop, _) = watch::channel(false);
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(state)),
|
||||
connection: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||
revision: Arc::new(AtomicU64::new(0)),
|
||||
stop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StructuredTask;
|
||||
|
||||
impl StructuredTask {
|
||||
pub fn spawn(ws_url: String) -> StructuredHandle {
|
||||
let state = Arc::new(Mutex::new(StructuredState::default()));
|
||||
let connection = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
||||
let revision = Arc::new(AtomicU64::new(0));
|
||||
let (stop, mut stopped) = watch::channel(false);
|
||||
let handle = StructuredHandle {
|
||||
state: state.clone(),
|
||||
connection: connection.clone(),
|
||||
revision: revision.clone(),
|
||||
stop,
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
loop {
|
||||
if *stopped.borrow() {
|
||||
return;
|
||||
}
|
||||
state
|
||||
.lock()
|
||||
.expect("structured state mutex")
|
||||
.prepare_snapshot_reconciliation();
|
||||
connection.store(SessionState::Connecting as u8, Ordering::Release);
|
||||
let connected = tokio::select! {
|
||||
result = tokio_tungstenite::connect_async(&ws_url) => result,
|
||||
_ = stopped.changed() => return,
|
||||
};
|
||||
let (mut ws, _) = match connected {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
if terminal_handshake_error(&error) {
|
||||
state.lock().expect("structured state mutex").error =
|
||||
Some(format!("cannot open structured session: {error}"));
|
||||
connection.store(SessionState::Ended as u8, Ordering::Release);
|
||||
revision.fetch_add(1, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
connection.store(SessionState::Reconnecting as u8, Ordering::Release);
|
||||
revision.fetch_add(1, Ordering::Release);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(super::session_task::jitter(backoff)) => {},
|
||||
_ = stopped.changed() => return,
|
||||
}
|
||||
backoff = backoff.saturating_mul(2).min(Duration::from_secs(30));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
backoff = Duration::from_secs(1);
|
||||
connection.store(SessionState::Live as u8, Ordering::Release);
|
||||
revision.fetch_add(1, Ordering::Release);
|
||||
|
||||
loop {
|
||||
let frame = tokio::select! {
|
||||
frame = ws.next() => frame,
|
||||
_ = stopped.changed() => return,
|
||||
};
|
||||
match frame {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Ok(event) = serde_json::from_str::<StructuredEvent>(&text) {
|
||||
state.lock().expect("structured state mutex").apply(event);
|
||||
revision.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
connection.store(SessionState::Reconnecting as u8, Ordering::Release);
|
||||
revision.fetch_add(1, Ordering::Release);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(super::session_task::jitter(backoff)) => {},
|
||||
_ = stopped.changed() => return,
|
||||
}
|
||||
backoff = backoff.saturating_mul(2).min(Duration::from_secs(30));
|
||||
}
|
||||
});
|
||||
handle
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_handshake_error(error: &WsError) -> bool {
|
||||
matches!(error, WsError::Http(response) if response.status().is_client_error())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use maestro_protocol::{TranscriptSnapshot, TurnState};
|
||||
use serde_json::json;
|
||||
|
||||
fn row(id: &str, kind: TranscriptRowKind, data: serde_json::Value) -> TranscriptRow {
|
||||
TranscriptRow {
|
||||
id: id.into(),
|
||||
kind,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_replace_and_deltas_address_rows_by_id() {
|
||||
let mut state = StructuredState::default();
|
||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||
snapshot: TranscriptSnapshot {
|
||||
cursor: 2,
|
||||
rows: vec![row("a", TranscriptRowKind::AgentText, json!({}))],
|
||||
},
|
||||
});
|
||||
state.apply(StructuredEvent::ProjectionUpdate {
|
||||
transcript: Some(TranscriptDelta::Patch {
|
||||
row: row("a", TranscriptRowKind::AgentText, json!({"n": 2})),
|
||||
}),
|
||||
state: ReducedState::default(),
|
||||
});
|
||||
state.apply(StructuredEvent::ProjectionUpdate {
|
||||
transcript: Some(TranscriptDelta::Append {
|
||||
row: row("b", TranscriptRowKind::Notice, json!({})),
|
||||
}),
|
||||
state: ReducedState::default(),
|
||||
});
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
assert_eq!(state.rows[0].data["n"], 2);
|
||||
|
||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||
snapshot: TranscriptSnapshot {
|
||||
cursor: 5,
|
||||
rows: vec![row("b", TranscriptRowKind::Notice, json!({"fresh": true}))],
|
||||
},
|
||||
});
|
||||
assert_eq!(state.rows.len(), 1);
|
||||
assert_eq!(state.rows[0].data["fresh"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_survives_patch_and_moves_after_remove() {
|
||||
let mut state = StructuredState {
|
||||
rows: vec![
|
||||
row("a", TranscriptRowKind::Notice, json!({})),
|
||||
row("b", TranscriptRowKind::ToolCall, json!({})),
|
||||
row("plan", TranscriptRowKind::Plan, json!({})),
|
||||
],
|
||||
selected: Some("a".into()),
|
||||
..StructuredState::default()
|
||||
};
|
||||
state.select(1);
|
||||
assert_eq!(state.selected.as_deref(), Some("b"));
|
||||
state.toggle_selected();
|
||||
assert!(state.expanded.contains("b"));
|
||||
state.apply_delta(TranscriptDelta::Remove { id: "b".into() });
|
||||
assert_eq!(state.selected.as_deref(), Some("a"));
|
||||
|
||||
state.apply_delta(TranscriptDelta::Remove { id: "a".into() });
|
||||
assert_eq!(
|
||||
state.selected, None,
|
||||
"collapsed plan rows are not selectable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_keys_only_offer_server_backed_decisions() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[
|
||||
{"kind":"allow_once"}, {"kind":"reject_always"}
|
||||
]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"secret"}));
|
||||
assert_eq!(
|
||||
state.approval_decision('A'),
|
||||
None,
|
||||
"allow-for-session was not offered"
|
||||
);
|
||||
assert_eq!(
|
||||
state.approval_decision('a'),
|
||||
Some(("secret".into(), StructuredApprovalDecision::AllowOnce))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_approval_ignores_its_late_failure() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||
assert!(state.approval_decision('a').is_some());
|
||||
|
||||
state.apply(StructuredEvent::ProjectionUpdate {
|
||||
transcript: None,
|
||||
state: ReducedState::default(),
|
||||
});
|
||||
state.approval_failed("old", "response lost".into());
|
||||
|
||||
assert_eq!(state.error, None);
|
||||
assert!(!state.approval_submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_approval_ignores_its_late_failure() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"old"}));
|
||||
assert!(state.approval_decision('a').is_some());
|
||||
|
||||
state.rows.push(row(
|
||||
"approval:2",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.apply(StructuredEvent::ProjectionUpdate {
|
||||
transcript: None,
|
||||
state: ReducedState {
|
||||
pending_approval: Some(json!({"nonce":"new"})),
|
||||
..ReducedState::default()
|
||||
},
|
||||
});
|
||||
assert_eq!(
|
||||
state.approval_decision('a'),
|
||||
Some(("new".into(), StructuredApprovalDecision::AllowOnce))
|
||||
);
|
||||
|
||||
state.approval_failed("old", "response lost".into());
|
||||
|
||||
assert_eq!(state.error, None);
|
||||
assert!(state.approval_submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_approval_surfaces_its_failure() {
|
||||
let mut state = StructuredState::default();
|
||||
state.rows.push(row(
|
||||
"approval:1",
|
||||
TranscriptRowKind::Approval,
|
||||
json!({"options":[{"kind":"allow_once"}]}),
|
||||
));
|
||||
state.selected = Some("approval:1".into());
|
||||
state.reduced.pending_approval = Some(json!({"nonce":"current"}));
|
||||
assert!(state.approval_decision('a').is_some());
|
||||
|
||||
state.approval_failed("current", "request failed".into());
|
||||
|
||||
assert_eq!(state.error.as_deref(), Some("request failed"));
|
||||
assert!(!state.approval_submitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_is_gated_by_the_projected_turn_lifecycle() {
|
||||
let mut state = StructuredState::default();
|
||||
state.edit_composer(&InputKey::char('h'));
|
||||
state.edit_composer(&InputKey::char('i'));
|
||||
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
|
||||
assert!(state.turn_active());
|
||||
state.prompt_failed("hi".into(), "nope".into());
|
||||
assert_eq!(state.composer.value(), "hi");
|
||||
|
||||
state.reduced.turn = TurnState {
|
||||
responding: true,
|
||||
..TurnState::default()
|
||||
};
|
||||
state.edit_composer(&InputKey::char('x'));
|
||||
assert_eq!(state.composer.value(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_idle_snapshot_reconciles_a_wholly_missed_turn() {
|
||||
let mut state = StructuredState::default();
|
||||
state.edit_composer(&InputKey::char('h'));
|
||||
state.edit_composer(&InputKey::char('i'));
|
||||
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
|
||||
|
||||
state.apply(StructuredEvent::ProjectionUpdate {
|
||||
transcript: None,
|
||||
state: ReducedState::default(),
|
||||
});
|
||||
assert!(
|
||||
state.turn_active(),
|
||||
"an ordinary idle update must not reopen the composer before turn begin"
|
||||
);
|
||||
|
||||
state.prepare_snapshot_reconciliation();
|
||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||
snapshot: TranscriptSnapshot {
|
||||
cursor: 0,
|
||||
rows: Vec::new(),
|
||||
},
|
||||
});
|
||||
state.apply(StructuredEvent::ReducedState {
|
||||
state: ReducedState::default(),
|
||||
});
|
||||
assert!(
|
||||
!state.turn_active(),
|
||||
"a reconnect snapshot must settle a submission whose whole turn was missed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_handshake_state_does_not_settle_a_new_submission() {
|
||||
let mut state = StructuredState::default();
|
||||
state.prepare_snapshot_reconciliation();
|
||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||
snapshot: TranscriptSnapshot {
|
||||
cursor: 0,
|
||||
rows: Vec::new(),
|
||||
},
|
||||
});
|
||||
|
||||
state.edit_composer(&InputKey::char('h'));
|
||||
state.edit_composer(&InputKey::char('i'));
|
||||
assert_eq!(state.begin_prompt().as_deref(), Some("hi"));
|
||||
state.apply(StructuredEvent::ReducedState {
|
||||
state: ReducedState::default(),
|
||||
});
|
||||
|
||||
assert!(
|
||||
state.turn_active(),
|
||||
"state captured before this submission must not reopen the composer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_submission_reconciliation_does_not_settle_its_successor() {
|
||||
let mut state = StructuredState::default();
|
||||
state.edit_composer(&InputKey::char('a'));
|
||||
assert_eq!(state.begin_prompt().as_deref(), Some("a"));
|
||||
state.prepare_snapshot_reconciliation();
|
||||
|
||||
state.prompt_failed("a".into(), "nope".into());
|
||||
state.composer = TextInput::with_value("b");
|
||||
assert_eq!(state.begin_prompt().as_deref(), Some("b"));
|
||||
|
||||
state.apply(StructuredEvent::TranscriptSnapshot {
|
||||
snapshot: TranscriptSnapshot {
|
||||
cursor: 0,
|
||||
rows: Vec::new(),
|
||||
},
|
||||
});
|
||||
state.apply(StructuredEvent::ReducedState {
|
||||
state: ReducedState::default(),
|
||||
});
|
||||
|
||||
assert!(
|
||||
state.turn_active(),
|
||||
"a reconnect for the failed submission must not settle its successor"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! The TUI's structured actions use the shared typed daemon client; assert the
|
||||
//! exact route and bare approval decision body at the HTTP boundary.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use maestro_protocol::{DaemonClient, StructuredApprovalDecision, StructuredPromptRequest};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Seen {
|
||||
prompt: Option<String>,
|
||||
cancelled: bool,
|
||||
approval: Option<(String, StructuredApprovalDecision)>,
|
||||
}
|
||||
|
||||
type Shared = Arc<Mutex<Seen>>;
|
||||
|
||||
async fn prompt(
|
||||
State(seen): State<Shared>,
|
||||
Path(_id): Path<Uuid>,
|
||||
Json(body): Json<StructuredPromptRequest>,
|
||||
) {
|
||||
seen.lock().unwrap().prompt = Some(body.prompt);
|
||||
}
|
||||
|
||||
async fn cancel(State(seen): State<Shared>, Path(_id): Path<Uuid>) {
|
||||
seen.lock().unwrap().cancelled = true;
|
||||
}
|
||||
|
||||
async fn approval(
|
||||
State(seen): State<Shared>,
|
||||
Path((_id, nonce)): Path<(Uuid, String)>,
|
||||
Json(decision): Json<StructuredApprovalDecision>,
|
||||
) {
|
||||
seen.lock().unwrap().approval = Some((nonce, decision));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn prompt_cancel_and_approval_use_structured_api_contract() {
|
||||
let seen = Shared::default();
|
||||
let app = Router::new()
|
||||
.route("/api/sessions/:id/structured/prompt", post(prompt))
|
||||
.route("/api/sessions/:id/structured/cancel", post(cancel))
|
||||
.route(
|
||||
"/api/sessions/:id/structured/approvals/:nonce",
|
||||
post(approval),
|
||||
)
|
||||
.with_state(seen.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
|
||||
let client = DaemonClient::new(format!("http://{addr}"));
|
||||
let id = Uuid::from_u128(141);
|
||||
client.send_structured_prompt(id, "ship it").await.unwrap();
|
||||
client.cancel_structured_turn(id).await.unwrap();
|
||||
client
|
||||
.resolve_structured_approval(
|
||||
id,
|
||||
"one-use-nonce",
|
||||
StructuredApprovalDecision::AllowForSession,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let seen = seen.lock().unwrap();
|
||||
assert_eq!(seen.prompt.as_deref(), Some("ship it"));
|
||||
assert!(seen.cancelled);
|
||||
assert_eq!(
|
||||
seen.approval,
|
||||
Some((
|
||||
"one-use-nonce".into(),
|
||||
StructuredApprovalDecision::AllowForSession
|
||||
))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Projection socket coverage: snapshots replace state on reconnect and live
|
||||
//! deltas patch rows without client-side event folding.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
use maestro_protocol::{
|
||||
ReducedState, StructuredEvent, TranscriptDelta, TranscriptRow, TranscriptRowKind,
|
||||
TranscriptSnapshot,
|
||||
};
|
||||
use maestro_tui::session_task::SessionState;
|
||||
use maestro_tui::structured_task::StructuredTask;
|
||||
use serde_json::json;
|
||||
|
||||
async fn structured_handler(
|
||||
State(connects): State<Arc<AtomicUsize>>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
ws.on_upgrade(move |mut socket| async move {
|
||||
let attempt = connects.fetch_add(1, Ordering::SeqCst);
|
||||
let snapshot = if attempt == 0 {
|
||||
TranscriptSnapshot {
|
||||
cursor: 1,
|
||||
rows: vec![row("old", json!({"content":{"type":"text","text":"old"}}))],
|
||||
}
|
||||
} else {
|
||||
TranscriptSnapshot {
|
||||
cursor: 3,
|
||||
rows: vec![row("live", json!({"content":{"type":"text","text":"one"}}))],
|
||||
}
|
||||
};
|
||||
send(
|
||||
&mut socket,
|
||||
StructuredEvent::TranscriptSnapshot { snapshot },
|
||||
)
|
||||
.await;
|
||||
if attempt == 0 {
|
||||
let _ = socket.close().await;
|
||||
return;
|
||||
}
|
||||
send(
|
||||
&mut socket,
|
||||
StructuredEvent::ProjectionUpdate {
|
||||
transcript: Some(TranscriptDelta::Patch {
|
||||
row: row("live", json!({"content":{"type":"text","text":"two"}})),
|
||||
}),
|
||||
state: ReducedState {
|
||||
model: Some("test-model".into()),
|
||||
..ReducedState::default()
|
||||
},
|
||||
},
|
||||
)
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
})
|
||||
}
|
||||
|
||||
fn row(id: &str, data: serde_json::Value) -> TranscriptRow {
|
||||
TranscriptRow {
|
||||
id: id.into(),
|
||||
kind: TranscriptRowKind::AgentText,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(socket: &mut axum::extract::ws::WebSocket, event: StructuredEvent) {
|
||||
socket
|
||||
.send(Message::Text(serde_json::to_string(&event).unwrap()))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn reconnect_snapshot_replaces_then_delta_patches_projection() {
|
||||
let connects = Arc::new(AtomicUsize::new(0));
|
||||
let app = Router::new()
|
||||
.route("/api/structured/x", any(structured_handler))
|
||||
.with_state(connects.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
|
||||
let handle = StructuredTask::spawn(format!("ws://{addr}/api/structured/x"));
|
||||
for _ in 0..250 {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
let state = handle.state.lock().unwrap().clone();
|
||||
if state.reduced.model.as_deref() == Some("test-model") {
|
||||
assert_eq!(state.rows.len(), 1);
|
||||
assert_eq!(state.rows[0].id, "live");
|
||||
assert_eq!(state.rows[0].data["content"]["text"], "two");
|
||||
assert!(connects.load(Ordering::SeqCst) >= 2);
|
||||
handle.end();
|
||||
assert_eq!(handle.connection(), SessionState::Ended);
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!(
|
||||
"structured projection never reached reconnect delta; connection={:?}",
|
||||
handle.connection()
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const base: SpawnFormValues = {
|
||||
command: "claude",
|
||||
cwd: "/home/user/project",
|
||||
name: "amber-fox",
|
||||
structuredView: false,
|
||||
worktreeEnabled: false,
|
||||
worktreeBranch: "",
|
||||
worktreeBranchMode: "new",
|
||||
@@ -25,6 +26,22 @@ describe("buildSpawnRequest", () => {
|
||||
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
||||
});
|
||||
|
||||
it("requests structured view only for Claude Code", () => {
|
||||
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({
|
||||
req: {
|
||||
cwd: base.cwd,
|
||||
command: "claude",
|
||||
metadata: { name: "amber-fox" },
|
||||
view: "Structured",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
|
||||
).toEqual({
|
||||
req: { cwd: base.cwd, command: "opencode", metadata: { name: "amber-fox" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
|
||||
const res = buildSpawnRequest(
|
||||
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
|
||||
|
||||
+28
-3
@@ -11,6 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { parentDir } from "./paths.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { ProjectGroup } from "./projectList.js";
|
||||
import { splitCommand } from "./toolIcon.js";
|
||||
import { abbreviateCwd } from "./types.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
|
||||
@@ -40,6 +41,7 @@ export interface SpawnFormValues {
|
||||
command: string;
|
||||
cwd: string;
|
||||
name: string;
|
||||
structuredView: boolean;
|
||||
worktreeEnabled: boolean;
|
||||
worktreeBranch: string;
|
||||
worktreeBranchEdited: boolean;
|
||||
@@ -66,6 +68,10 @@ export function buildSpawnRequest(
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (values.structuredView && splitCommand(values.command).tool === "claude") {
|
||||
req.view = "Structured";
|
||||
}
|
||||
|
||||
if (values.worktreeEnabled) {
|
||||
const mode = values.worktreeBranchMode ?? "new";
|
||||
const branch = values.worktreeBranch.trim();
|
||||
@@ -153,6 +159,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
<span class="wt-summary"></span>
|
||||
</button>
|
||||
<div class="wt-collapsible" hidden>
|
||||
<label class="row-inline structured-view" hidden>
|
||||
<input type="checkbox" name="structured_view" />
|
||||
Structured view
|
||||
</label>
|
||||
<div class="field wt-step" hidden>
|
||||
<span class="field-label">Worktree</span>
|
||||
<div class="picker wt-choices"></div>
|
||||
@@ -203,6 +213,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||
const structuredWrap = form.querySelector(".structured-view") as HTMLElement;
|
||||
const structuredView = form.querySelector("input[name=structured_view]") as HTMLInputElement;
|
||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
||||
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
||||
@@ -248,6 +260,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
command,
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
structuredView: structuredView.checked,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
@@ -317,6 +330,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
} else {
|
||||
command = preset!.command;
|
||||
}
|
||||
updateStructuredAvailability();
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
||||
}
|
||||
@@ -354,10 +368,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// operator has already touched (#76).
|
||||
let commandTouched = false;
|
||||
|
||||
function updateStructuredAvailability(): void {
|
||||
const supported = splitCommand(command).tool === "claude";
|
||||
structuredWrap.hidden = !supported;
|
||||
if (!supported) structuredView.checked = false;
|
||||
}
|
||||
|
||||
structuredView.addEventListener("change", emit);
|
||||
|
||||
cmdRaw.addEventListener("input", () => {
|
||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||
commandTouched = true;
|
||||
command = cmdRaw.value.trim();
|
||||
updateStructuredAvailability();
|
||||
emit();
|
||||
}
|
||||
});
|
||||
@@ -844,10 +867,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target === wtEnabled) {
|
||||
if (target === wtEnabled || target === structuredView) {
|
||||
e.preventDefault();
|
||||
wtEnabled.checked = !wtEnabled.checked;
|
||||
wtEnabled.dispatchEvent(new Event("change"));
|
||||
const checkbox = target as HTMLInputElement;
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.dispatchEvent(new Event("change"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -885,6 +909,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
command,
|
||||
cwd,
|
||||
name: nameInput.value,
|
||||
structuredView: structuredView.checked,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
function stubApi(): RequestInit[] {
|
||||
const sessionRequests: RequestInit[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/sessions") && init?.method === "POST") {
|
||||
sessionRequests.push(init);
|
||||
return new Response(JSON.stringify({ id: "s1", command: "claude", cwd: "/srv/work" }), {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/info")) {
|
||||
return new Response(
|
||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user" }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/names")) {
|
||||
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify([]), { status: 200 });
|
||||
}),
|
||||
);
|
||||
return sessionRequests;
|
||||
}
|
||||
|
||||
describe("spawn form structured view", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
await drain();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("is default-off and shown only for the resolved Claude Code command", async () => {
|
||||
stubApi();
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
|
||||
const wrap = handle.el.querySelector<HTMLElement>(".structured-view")!;
|
||||
const checkbox = wrap.querySelector<HTMLInputElement>("input")!;
|
||||
const disclosure = handle.el.querySelector<HTMLElement>(".wt-collapsible")!;
|
||||
expect(disclosure.hidden).toBe(true);
|
||||
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||
expect(disclosure.hidden).toBe(false);
|
||||
expect(wrap.hidden).toBe(false);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click();
|
||||
expect(wrap.hidden).toBe(true);
|
||||
|
||||
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click();
|
||||
expect(wrap.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a checked value when the command changes away from Claude Code", async () => {
|
||||
stubApi();
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
|
||||
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||
checkbox.click();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
||||
custom.click();
|
||||
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
command.value = "opencode";
|
||||
command.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(checkbox.closest<HTMLElement>(".structured-view")!.hidden).toBe(true);
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("emits Structured in the submitted request when selected", async () => {
|
||||
const requests = stubApi();
|
||||
handle = createSpawnForm({
|
||||
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
||||
onSuccess() {},
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
|
||||
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
|
||||
handle.el.requestSubmit();
|
||||
await drain();
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(JSON.parse(String(requests[0].body))).toMatchObject({
|
||||
command: "claude",
|
||||
view: "Structured",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,113 @@ describe("structured connection: data flow", () => {
|
||||
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps an expanded running tool open without emitting a click when patched", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [
|
||||
row("tool:1", "tool_call", {
|
||||
title: "Bash(pnpm test)",
|
||||
status: "in_progress",
|
||||
content: [{ type: "content", content: { type: "text", text: "starting" } }],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||
originalHead.click();
|
||||
expect(originalHead.getAttribute("aria-expanded")).toBe("true");
|
||||
const clickListener = vi.fn();
|
||||
window.addEventListener("click", clickListener, { capture: true, once: true });
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("tool:1", "tool_call", {
|
||||
title: "Bash(pnpm test)",
|
||||
status: "in_progress",
|
||||
content: [{ type: "content", content: { type: "text", text: "still running" } }],
|
||||
}),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
|
||||
expect(clickListener).not.toHaveBeenCalled();
|
||||
window.removeEventListener("click", clickListener, true);
|
||||
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||
expect(patchedHead).not.toBe(originalHead);
|
||||
expect(patchedHead.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(conn.el.querySelector<HTMLElement>(".st-tool-out")!.hidden).toBe(false);
|
||||
expect(conn.el.textContent).toContain("still running");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("restores focus to a patched tool header with the same stable row id", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const originalHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||
originalHead.focus();
|
||||
expect(document.activeElement).toBe(originalHead);
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
|
||||
const patchedHead = conn.el.querySelector<HTMLButtonElement>(".st-tool-head")!;
|
||||
expect(patchedHead).not.toBe(originalHead);
|
||||
expect(document.activeElement).toBe(patchedHead);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not move unrelated focus when a tool row is patched", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({
|
||||
type: "transcript_snapshot",
|
||||
snapshot: {
|
||||
cursor: 1,
|
||||
rows: [row("tool:1", "tool_call", { title: "Read(file.ts)", status: "in_progress" })],
|
||||
},
|
||||
});
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
|
||||
input.focus();
|
||||
|
||||
ws.emit({
|
||||
type: "projection_update",
|
||||
transcript: {
|
||||
type: "patch",
|
||||
row: row("tool:1", "tool_call", { title: "Read(file.ts)", status: "completed" }),
|
||||
},
|
||||
state: idleState,
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(input);
|
||||
conn.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured connection: turn/composer gating", () => {
|
||||
@@ -147,6 +254,8 @@ describe("structured connection: turn/composer gating", () => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing");
|
||||
expect(input.value).toBe("");
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
||||
input.value = "retry me";
|
||||
@@ -158,6 +267,103 @@ describe("structured connection: turn/composer gating", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("disables immediately and dispatches only once before begin-turn is projected", () => {
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
const send = conn.el.querySelector(".structured-send") as HTMLButtonElement;
|
||||
const form = conn.el.querySelector(".structured-composer") as HTMLFormElement;
|
||||
input.value = "only once";
|
||||
|
||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
||||
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(send.disabled).toBe(true);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not overwrite a newer operator draft when the matching request fails", async () => {
|
||||
let reject!: (error: unknown) => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((_, rejectPromise) => { reject = rejectPromise; }),
|
||||
);
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "first prompt";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
input.value = "newer draft";
|
||||
|
||||
reject(new Error("late failure"));
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(input.value).toBe("newer draft");
|
||||
expect(conn.el.textContent).toContain("late failure");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("ignores an older failure after a newer submission generation starts", async () => {
|
||||
let rejectFirst!: (error: unknown) => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((_, rejectPromise) => { rejectFirst = rejectPromise; }),
|
||||
);
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "first prompt";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
input.value = "second prompt";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
rejectFirst(new Error("stale failure"));
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
||||
expect(input.value).toBe("");
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(conn.el.textContent).not.toContain("stale failure");
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("ignores a submission failure after disposal", async () => {
|
||||
let reject!: (error: unknown) => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((_, rejectPromise) => { reject = rejectPromise; }),
|
||||
);
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
ws.open();
|
||||
ws.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "do not restore";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
conn.dispose();
|
||||
input.value = "detached draft";
|
||||
reject(new Error("late failure"));
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(input.value).toBe("detached draft");
|
||||
expect(conn.el.querySelector(".structured-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels the active turn via the cancel button", () => {
|
||||
const ws = new FakeSocket();
|
||||
const conn = mount({ sockets: [ws] });
|
||||
@@ -301,6 +507,8 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows connecting, then connected, then reconnecting with backoff, and rebuilds from the fresh snapshot", () => {
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
@@ -327,6 +535,154 @@ describe("structured connection: reconnection/error/loading", () => {
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("keeps a local submission gated across a reconnect until begin-turn arrives", () => {
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "survive reconnect";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(true);
|
||||
|
||||
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(false);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("releases an accepted submission from a post-acceptance idle snapshot", async () => {
|
||||
vi.mocked(sendStructuredPrompt).mockResolvedValueOnce(undefined);
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "finishes while disconnected";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
|
||||
expect(input.disabled).toBe(false);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("does not release from reconnect idle cached before submission acceptance", async () => {
|
||||
let accept!: () => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((resolve) => { accept = resolve; }),
|
||||
);
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const third = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second, third], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "accepted after reconnect";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(true);
|
||||
|
||||
accept();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(input.disabled).toBe(true);
|
||||
expect(second.closed).toBe(true);
|
||||
|
||||
third.open();
|
||||
third.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
||||
third.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(false);
|
||||
conn.dispose();
|
||||
});
|
||||
|
||||
it("cancels pending backoff when acceptance forces reconciliation", async () => {
|
||||
let accept!: () => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((resolve) => { accept = resolve; }),
|
||||
);
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const reconciliation = new FakeSocket();
|
||||
const duplicate = new FakeSocket();
|
||||
const conn = mount({
|
||||
sockets: [first, second, reconciliation, duplicate],
|
||||
shouldReconnect: () => true,
|
||||
});
|
||||
first.open();
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "accepted during backoff";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.drop();
|
||||
accept();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
|
||||
expect(reconciliation.onopen).not.toBeNull();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(duplicate.onopen).toBeNull();
|
||||
|
||||
reconciliation.open();
|
||||
reconciliation.emit({ type: "reduced_state", state: idleState });
|
||||
expect(input.disabled).toBe(false);
|
||||
conn.dispose();
|
||||
expect(reconciliation.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not reconcile when submission acceptance arrives after disposal", async () => {
|
||||
let accept!: () => void;
|
||||
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
||||
() => new Promise((resolve) => { accept = resolve; }),
|
||||
);
|
||||
const first = new FakeSocket();
|
||||
const second = new FakeSocket();
|
||||
const late = new FakeSocket();
|
||||
const conn = mount({ sockets: [first, second, late], shouldReconnect: () => true });
|
||||
first.open();
|
||||
first.emit({ type: "reduced_state", state: idleState });
|
||||
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
||||
input.value = "accepted after disposal";
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
|
||||
first.drop();
|
||||
vi.advanceTimersByTime(1000);
|
||||
second.open();
|
||||
second.emit({ type: "reduced_state", state: idleState });
|
||||
conn.dispose();
|
||||
accept();
|
||||
await Promise.resolve().then(() => Promise.resolve());
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(second.closed).toBe(true);
|
||||
expect(late.onopen).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a daemon rejection reason and does not schedule a retry", () => {
|
||||
const only = new FakeSocket();
|
||||
const conn = mount({ sockets: [only], shouldReconnect: () => true });
|
||||
|
||||
+118
-29
@@ -109,6 +109,12 @@ export function createStructuredConnection(
|
||||
let state: ReducedState = { turn: idleTurn() };
|
||||
let pendingApprovalRowId: string | null = null;
|
||||
let latestSession: Session | undefined = store?.snapshot().sessions.get(sessionId);
|
||||
let submissionGeneration = 0;
|
||||
let awaitingBeginGeneration: number | null = null;
|
||||
let acceptedSubmission: { generation: number; socketGeneration: number } | null = null;
|
||||
let socketGeneration = 0;
|
||||
let stateSocketGeneration = 0;
|
||||
let disposed = false;
|
||||
|
||||
function lastRowIdOfKind(kind: TranscriptRowKind): string | null {
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
|
||||
@@ -138,7 +144,7 @@ export function createStructuredConnection(
|
||||
// request was in flight (superseded, cancelled, answered elsewhere),
|
||||
// the state-driven re-render already replaced the row; reviving
|
||||
// buttons on it would be reviving a decision that no longer applies.
|
||||
if (rowId === pendingApprovalRowId) {
|
||||
if (!disposed && rowId === pendingApprovalRowId) {
|
||||
showError(String(e));
|
||||
throw e;
|
||||
}
|
||||
@@ -153,10 +159,15 @@ export function createStructuredConnection(
|
||||
|
||||
function remountRow(row: TranscriptRow): void {
|
||||
const old = rowEls.get(row.id);
|
||||
const el = buildTranscriptRow(row, approvalStateFor(row));
|
||||
const oldToolHead = old?.querySelector<HTMLButtonElement>(".st-tool-head") ?? null;
|
||||
const wasExpanded = oldToolHead?.getAttribute("aria-expanded") === "true";
|
||||
const restoreFocus = document.activeElement === oldToolHead;
|
||||
const el = buildTranscriptRow(row, approvalStateFor(row), wasExpanded);
|
||||
rowEls.set(row.id, el);
|
||||
if (old) old.replaceWith(el);
|
||||
else rowsEl.appendChild(el);
|
||||
const newToolHead = el.querySelector<HTMLButtonElement>(".st-tool-head");
|
||||
if (restoreFocus) newToolHead?.focus();
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
|
||||
@@ -217,14 +228,28 @@ export function createStructuredConnection(
|
||||
}
|
||||
|
||||
function renderComposerState(): void {
|
||||
const active = state.turn.responding || state.turn.thinking;
|
||||
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
|
||||
input.disabled = active;
|
||||
sendBtn.disabled = active;
|
||||
cancelBtn.hidden = !active;
|
||||
cancelBtn.hidden = !state.turn.responding && !state.turn.thinking;
|
||||
}
|
||||
|
||||
function applyState(next: ReducedState): void {
|
||||
function reconcileAwaitingBegin(): void {
|
||||
if (state.turn.responding || state.turn.thinking) {
|
||||
awaitingBeginGeneration = null;
|
||||
} else if (
|
||||
awaitingBeginGeneration !== null
|
||||
&& acceptedSubmission?.generation === awaitingBeginGeneration
|
||||
&& stateSocketGeneration > acceptedSubmission.socketGeneration
|
||||
) {
|
||||
awaitingBeginGeneration = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyState(next: ReducedState, connectionGeneration: number): void {
|
||||
state = next;
|
||||
stateSocketGeneration = connectionGeneration;
|
||||
reconcileAwaitingBegin();
|
||||
const newPendingId = state.pending_approval ? lastRowIdOfKind("approval") : null;
|
||||
if (newPendingId !== pendingApprovalRowId) {
|
||||
const prev = pendingApprovalRowId;
|
||||
@@ -268,18 +293,44 @@ export function createStructuredConnection(
|
||||
e.preventDefault();
|
||||
const text = input.value;
|
||||
if (!text.trim() || input.disabled) return;
|
||||
const generation = ++submissionGeneration;
|
||||
const submissionSocketGeneration = socketGeneration;
|
||||
awaitingBeginGeneration = generation;
|
||||
acceptedSubmission = null;
|
||||
input.value = "";
|
||||
autoResize();
|
||||
clearError();
|
||||
sendStructuredPrompt(sessionId, text).catch((e: unknown) => {
|
||||
input.value = text;
|
||||
autoResize();
|
||||
showError(String(e));
|
||||
});
|
||||
renderComposerState();
|
||||
sendStructuredPrompt(sessionId, text).then(
|
||||
() => {
|
||||
if (
|
||||
disposed
|
||||
|| generation !== submissionGeneration
|
||||
|| awaitingBeginGeneration !== generation
|
||||
) return;
|
||||
const acceptanceSocketGeneration = socketGeneration;
|
||||
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
|
||||
// A replacement socket may have captured idle before the POST claimed
|
||||
// the prompt. Reconnect again so its snapshot cannot predate the 204.
|
||||
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
|
||||
},
|
||||
(e: unknown) => {
|
||||
if (disposed || generation !== submissionGeneration) return;
|
||||
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
|
||||
if (input.value === "") {
|
||||
input.value = text;
|
||||
autoResize();
|
||||
}
|
||||
showError(String(e));
|
||||
renderComposerState();
|
||||
},
|
||||
);
|
||||
});
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
clearError();
|
||||
cancelStructuredTurn(sessionId).catch((e: unknown) => showError(String(e)));
|
||||
cancelStructuredTurn(sessionId).catch((e: unknown) => {
|
||||
if (!disposed) showError(String(e));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
|
||||
@@ -292,23 +343,59 @@ export function createStructuredConnection(
|
||||
|
||||
// ---- live worktree/git-status rail data ----
|
||||
const unsubscribeStore = store?.subscribe((catalog: CatalogState) => {
|
||||
if (disposed) return;
|
||||
latestSession = catalog.sessions.get(sessionId);
|
||||
renderRailNow();
|
||||
});
|
||||
|
||||
// ---- socket lifecycle ----
|
||||
let ws: WebSocket;
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
let disposed = false;
|
||||
|
||||
function cancelReconnect(): void {
|
||||
if (reconnectTimer === null) return;
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
function closeCurrentSocket(): void {
|
||||
const socket = ws;
|
||||
ws = null;
|
||||
if (!socket) return;
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.onclose = null;
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function reconnectForReconciliation(): void {
|
||||
if (disposed) return;
|
||||
cancelReconnect();
|
||||
closeCurrentSocket();
|
||||
setConnState("reconnecting…", "connecting");
|
||||
connect();
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
ws = socketFactory(sessionId);
|
||||
ws.onopen = () => {
|
||||
if (disposed) return;
|
||||
cancelReconnect();
|
||||
closeCurrentSocket();
|
||||
const connectionGeneration = ++socketGeneration;
|
||||
const socket = socketFactory(sessionId);
|
||||
ws = socket;
|
||||
socket.onopen = () => {
|
||||
if (disposed || ws !== socket) return;
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setConnState("connected", "open");
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
socket.onmessage = (ev) => {
|
||||
if (disposed || ws !== socket) return;
|
||||
if (typeof ev.data !== "string") return;
|
||||
let msg: StructuredEvent;
|
||||
try {
|
||||
@@ -321,28 +408,33 @@ export function createStructuredConnection(
|
||||
applySnapshot(msg.snapshot);
|
||||
break;
|
||||
case "reduced_state":
|
||||
applyState(msg.state);
|
||||
applyState(msg.state, connectionGeneration);
|
||||
break;
|
||||
case "projection_update":
|
||||
applyDelta(msg.transcript);
|
||||
applyState(msg.state);
|
||||
applyState(msg.state, connectionGeneration);
|
||||
break;
|
||||
}
|
||||
};
|
||||
ws.onerror = () => setConnState("error", "error");
|
||||
ws.onclose = (ev) => {
|
||||
socket.onerror = () => {
|
||||
if (disposed || ws !== socket) return;
|
||||
setConnState("error", "error");
|
||||
};
|
||||
socket.onclose = (ev) => {
|
||||
if (disposed || ws !== socket) return;
|
||||
ws = null;
|
||||
const reason = ev.reason?.trim();
|
||||
if (ev.code >= 4000 && reason && !disposed) {
|
||||
if (ev.code >= 4000 && reason) {
|
||||
setConnState(reason, "error");
|
||||
showError(`cannot attach: ${reason}`);
|
||||
return;
|
||||
}
|
||||
if (disposed) return;
|
||||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setConnState("reconnecting…", "connecting");
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (disposed) return;
|
||||
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
|
||||
connect();
|
||||
}, delay);
|
||||
@@ -368,7 +460,7 @@ export function createStructuredConnection(
|
||||
const focusOnMount = mountOpts?.focus !== false;
|
||||
const token = ++mountToken;
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== mountToken) return; // parked/remounted since this was scheduled
|
||||
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
|
||||
if (focusOnMount && !input.disabled) input.focus();
|
||||
});
|
||||
},
|
||||
@@ -385,14 +477,11 @@ export function createStructuredConnection(
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (reconnectTimer !== null) window.clearTimeout(reconnectTimer);
|
||||
mountToken++;
|
||||
cancelReconnect();
|
||||
resizeObserver.disconnect();
|
||||
unsubscribeStore?.();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
closeCurrentSocket();
|
||||
el.remove();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,6 +42,61 @@ describe("computeDiffLines", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an insertion without changing surrounding lines", () => {
|
||||
expect(texts(computeDiffLines("a\nc\n", "a\nb\nc\n"))).toEqual([
|
||||
["ctx", "a"],
|
||||
["add", "b"],
|
||||
["ctx", "c"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a deletion without changing surrounding lines", () => {
|
||||
expect(texts(computeDiffLines("a\nb\nc\n", "a\nc\n"))).toEqual([
|
||||
["ctx", "a"],
|
||||
["del", "b"],
|
||||
["ctx", "c"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders two empty files without invented lines", () => {
|
||||
expect(computeDiffLines("", "")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows when a final newline is added", () => {
|
||||
expect(texts(computeDiffLines("a", "a\n"))).toEqual([
|
||||
["del", "a"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
["add", "a"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows when a final newline is removed", () => {
|
||||
expect(texts(computeDiffLines("a\n", "a"))).toEqual([
|
||||
["del", "a"],
|
||||
["add", "a"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks an unchanged unterminated final context line", () => {
|
||||
expect(texts(computeDiffLines("a\nb\nc", "a\nX\nc"))).toEqual([
|
||||
["ctx", "a"],
|
||||
["del", "b"],
|
||||
["add", "X"],
|
||||
["ctx", "c"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks an unterminated new file only once", () => {
|
||||
const lines = computeDiffLines(null, "a");
|
||||
expect(texts(lines)).toEqual([
|
||||
["add", "a"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
expect(lines.filter((line) => line.kind === "eof")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("collapses a long unchanged interior run, keeping 3 lines of context each side", () => {
|
||||
const common = Array.from({ length: 20 }, (_, i) => `line${i}`);
|
||||
const oldText = [...common.slice(0, 10), "OLD", ...common.slice(10)].join("\n");
|
||||
@@ -59,6 +114,22 @@ describe("computeDiffLines", () => {
|
||||
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("preserves the EOF marker when a long unterminated trailing run collapses", () => {
|
||||
const trailing = Array.from({ length: 10 }, (_, i) => `t${i}`);
|
||||
const oldText = ["OLD", ...trailing].join("\n");
|
||||
const newText = ["NEW", ...trailing].join("\n");
|
||||
|
||||
expect(texts(computeDiffLines(oldText, newText))).toEqual([
|
||||
["del", "OLD"],
|
||||
["add", "NEW"],
|
||||
["ctx", "t0"],
|
||||
["ctx", "t1"],
|
||||
["ctx", "t2"],
|
||||
["hunk", "⋯ 7 unchanged lines ⋯"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("produces one hunk for the long middle run, none for the short edges", () => {
|
||||
// Edge runs (2 lines) fit within their one-sided 3-line context budget;
|
||||
// only the interior run (10 lines, budgeted 3+3=6) needs to collapse.
|
||||
@@ -75,9 +146,10 @@ describe("computeDiffLines", () => {
|
||||
["ctx", "lead0"],
|
||||
["ctx", "lead1"],
|
||||
]);
|
||||
expect(texts(lines).slice(-2)).toEqual([
|
||||
expect(texts(lines).slice(-3)).toEqual([
|
||||
["ctx", "trail0"],
|
||||
["ctx", "trail1"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -85,10 +157,22 @@ describe("computeDiffLines", () => {
|
||||
const lines = computeDiffLines("a\nb", "a\nb\nc");
|
||||
expect(texts(lines)).toEqual([
|
||||
["ctx", "a"],
|
||||
["ctx", "b"],
|
||||
["del", "b"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
["add", "b"],
|
||||
["add", "c"],
|
||||
["eof", "\\ No newline at end of file"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to a bounded coarse diff for a pathological changed middle", () => {
|
||||
const oldLines = Array.from({ length: 501 }, (_, index) => `line${index}`);
|
||||
const newLines = [...oldLines.slice(1), oldLines[0]];
|
||||
const lines = computeDiffLines(`${oldLines.join("\n")}\n`, `${newLines.join("\n")}\n`);
|
||||
expect(lines).toHaveLength(1_002);
|
||||
expect(lines.slice(0, 501).every((line) => line.kind === "del")).toBe(true);
|
||||
expect(lines.slice(501).every((line) => line.kind === "add")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffStats", () => {
|
||||
|
||||
+57
-31
@@ -2,7 +2,7 @@
|
||||
// hands over full `oldText`/`newText`, not a unified-diff patch, so the
|
||||
// coloured hunks the design calls for have to be computed here.
|
||||
|
||||
export type DiffLineKind = "add" | "del" | "ctx" | "hunk";
|
||||
export type DiffLineKind = "add" | "del" | "ctx" | "hunk" | "eof";
|
||||
|
||||
export interface DiffLine {
|
||||
kind: DiffLineKind;
|
||||
@@ -19,66 +19,82 @@ const CONTEXT = 3;
|
||||
* still correct, just not minimal. */
|
||||
const LCS_CELL_LIMIT = 250_000;
|
||||
|
||||
function splitLines(text: string): string[] {
|
||||
if (text === "") return [];
|
||||
const body = text.endsWith("\n") ? text.slice(0, -1) : text;
|
||||
return body.split("\n");
|
||||
interface SourceLine {
|
||||
text: string;
|
||||
terminated: boolean;
|
||||
}
|
||||
|
||||
type Op = { kind: "add" | "del" | "ctx"; text: string };
|
||||
function splitLines(text: string): SourceLine[] {
|
||||
if (text === "") return [];
|
||||
const parts = text.split("\n");
|
||||
const hasFinalNewline = text.endsWith("\n");
|
||||
if (hasFinalNewline) parts.pop();
|
||||
return parts.map((line, index) => ({
|
||||
text: line,
|
||||
terminated: index < parts.length - 1 || hasFinalNewline,
|
||||
}));
|
||||
}
|
||||
|
||||
type Op = { kind: "add" | "del" | "ctx"; line: SourceLine };
|
||||
|
||||
function sameLine(a: SourceLine, b: SourceLine): boolean {
|
||||
return a.text === b.text && a.terminated === b.terminated;
|
||||
}
|
||||
|
||||
/** Longest-common-subsequence diff of two line arrays, already trimmed of any
|
||||
* shared prefix/suffix by the caller. */
|
||||
function lcsDiff(a: string[], b: string[]): Op[] {
|
||||
function lcsDiff(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (n === 0) return b.map((text) => ({ kind: "add", text }));
|
||||
if (m === 0) return a.map((text) => ({ kind: "del", text }));
|
||||
if (n === 0) return b.map((line) => ({ kind: "add", line }));
|
||||
if (m === 0) return a.map((line) => ({ kind: "del", line }));
|
||||
if (n * m > LCS_CELL_LIMIT) {
|
||||
return [...a.map((text) => ({ kind: "del" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))];
|
||||
return [...a.map((line) => ({ kind: "del" as const, line })), ...b.map((line) => ({ kind: "add" as const, line }))];
|
||||
}
|
||||
// dp[i][j] = length of the LCS of a[i:] and b[j:].
|
||||
const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
||||
const columns = m + 1;
|
||||
const dp = new Uint32Array((n + 1) * columns);
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] =
|
||||
a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
dp[i * columns + j] = sameLine(a[i], b[j])
|
||||
? dp[(i + 1) * columns + j + 1] + 1
|
||||
: Math.max(dp[(i + 1) * columns + j], dp[i * columns + j + 1]);
|
||||
}
|
||||
}
|
||||
const ops: Op[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
ops.push({ kind: "ctx", text: a[i] });
|
||||
if (sameLine(a[i], b[j])) {
|
||||
ops.push({ kind: "ctx", line: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ kind: "del", text: a[i] });
|
||||
} else if (dp[(i + 1) * columns + j] >= dp[i * columns + j + 1]) {
|
||||
ops.push({ kind: "del", line: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
ops.push({ kind: "add", text: b[j] });
|
||||
ops.push({ kind: "add", line: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) ops.push({ kind: "del", text: a[i++] });
|
||||
while (j < m) ops.push({ kind: "add", text: b[j++] });
|
||||
while (i < n) ops.push({ kind: "del", line: a[i++] });
|
||||
while (j < m) ops.push({ kind: "add", line: b[j++] });
|
||||
return ops;
|
||||
}
|
||||
|
||||
function diffLines(a: string[], b: string[]): Op[] {
|
||||
function diffLines(a: SourceLine[], b: SourceLine[]): Op[] {
|
||||
let pre = 0;
|
||||
while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
|
||||
while (pre < a.length && pre < b.length && sameLine(a[pre], b[pre])) pre++;
|
||||
let suf = 0;
|
||||
while (
|
||||
suf < a.length - pre &&
|
||||
suf < b.length - pre &&
|
||||
a[a.length - 1 - suf] === b[b.length - 1 - suf]
|
||||
sameLine(a[a.length - 1 - suf], b[b.length - 1 - suf])
|
||||
) {
|
||||
suf++;
|
||||
}
|
||||
const prefixCtx: Op[] = a.slice(0, pre).map((text) => ({ kind: "ctx", text }));
|
||||
const suffixCtx: Op[] = a.slice(a.length - suf).map((text) => ({ kind: "ctx", text }));
|
||||
const prefixCtx: Op[] = a.slice(0, pre).map((line) => ({ kind: "ctx", line }));
|
||||
const suffixCtx: Op[] = a.slice(a.length - suf).map((line) => ({ kind: "ctx", line }));
|
||||
const middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
|
||||
return [...prefixCtx, ...middle, ...suffixCtx];
|
||||
}
|
||||
@@ -91,10 +107,16 @@ function diffLines(a: string[], b: string[]): Op[] {
|
||||
* (unless it is the *only* run, i.e. nothing changed at all). */
|
||||
function hunk(ops: Op[]): DiffLine[] {
|
||||
const out: DiffLine[] = [];
|
||||
const push = (op: Op) => {
|
||||
out.push({ kind: op.kind, text: op.line.text });
|
||||
if (!op.line.terminated) {
|
||||
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
||||
}
|
||||
};
|
||||
let i = 0;
|
||||
while (i < ops.length) {
|
||||
if (ops[i].kind !== "ctx") {
|
||||
out.push(ops[i]);
|
||||
push(ops[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -105,14 +127,18 @@ function hunk(ops: Op[]): DiffLine[] {
|
||||
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
|
||||
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
|
||||
if (run.length <= keepHead + keepTail) {
|
||||
out.push(...run);
|
||||
run.forEach(push);
|
||||
} else {
|
||||
if (keepHead) out.push(...run.slice(0, keepHead));
|
||||
if (keepHead) run.slice(0, keepHead).forEach(push);
|
||||
const collapsed = run.slice(keepHead, run.length - keepTail);
|
||||
out.push({
|
||||
kind: "hunk",
|
||||
text: `⋯ ${run.length - keepHead - keepTail} unchanged lines ⋯`,
|
||||
text: `⋯ ${collapsed.length} unchanged lines ⋯`,
|
||||
});
|
||||
if (keepTail) out.push(...run.slice(run.length - keepTail));
|
||||
if (collapsed.some((op) => !op.line.terminated)) {
|
||||
out.push({ kind: "eof", text: "\\ No newline at end of file" });
|
||||
}
|
||||
if (keepTail) run.slice(run.length - keepTail).forEach(push);
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
@@ -123,7 +149,7 @@ function hunk(ops: Op[]): DiffLine[] {
|
||||
* (a new file) renders every line of `newText` as added. */
|
||||
export function computeDiffLines(oldText: string | null | undefined, newText: string): DiffLine[] {
|
||||
const newLines = splitLines(newText);
|
||||
if (oldText == null) return newLines.map((text) => ({ kind: "add", text }));
|
||||
if (oldText == null) return hunk(newLines.map((line) => ({ kind: "add", line })));
|
||||
const ops = diffLines(splitLines(oldText), newLines);
|
||||
return hunk(ops);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,17 @@ describe("buildTranscriptRow: tool diffs", () => {
|
||||
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
||||
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
||||
});
|
||||
|
||||
it("renders a visible marker for a removed final newline", () => {
|
||||
const el = buildTranscriptRow(
|
||||
row("tool_diff", {
|
||||
title: "Edit(x.rs)",
|
||||
content: [{ type: "diff", path: "/repo/x.rs", oldText: "same\n", newText: "same" }],
|
||||
}),
|
||||
);
|
||||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||||
expect(el.querySelector(".st-ln-eof")!.textContent).toBe("\\ No newline at end of file");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscriptRow: approval", () => {
|
||||
|
||||
@@ -187,7 +187,7 @@ function statusMeta(status: string | undefined, hasDiff: boolean): { text: strin
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildToolRow(row: TranscriptRow): HTMLElement {
|
||||
function buildToolRow(row: TranscriptRow, expanded: boolean): HTMLElement {
|
||||
const tool = (row.data ?? {}) as AcpToolCallLike;
|
||||
const content = tool.content ?? [];
|
||||
const hasDiff = content.some((c) => c.type === "diff");
|
||||
@@ -251,13 +251,16 @@ function buildToolRow(row: TranscriptRow): HTMLElement {
|
||||
};
|
||||
card.appendChild(out);
|
||||
|
||||
const setExpanded = (nextExpanded: boolean) => {
|
||||
if (nextExpanded) buildOut();
|
||||
if (out.children.length === 0) nextExpanded = false;
|
||||
head.setAttribute("aria-expanded", String(nextExpanded));
|
||||
out.hidden = !nextExpanded;
|
||||
};
|
||||
head.addEventListener("click", () => {
|
||||
const expanded = head.getAttribute("aria-expanded") === "true";
|
||||
if (!expanded) buildOut();
|
||||
if (out.children.length === 0) return; // nothing to show — stay collapsed
|
||||
head.setAttribute("aria-expanded", String(!expanded));
|
||||
out.hidden = expanded;
|
||||
setExpanded(head.getAttribute("aria-expanded") !== "true");
|
||||
});
|
||||
setExpanded(expanded);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
@@ -361,7 +364,11 @@ function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLE
|
||||
|
||||
/** Build the DOM for one transcript row. `approval` is consulted only for an
|
||||
* `approval` row; pass it whenever the row might be that kind. */
|
||||
export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowState): HTMLElement {
|
||||
export function buildTranscriptRow(
|
||||
row: TranscriptRow,
|
||||
approval?: ApprovalRowState,
|
||||
toolExpanded = false,
|
||||
): HTMLElement {
|
||||
let built: HTMLElement;
|
||||
switch (row.kind) {
|
||||
case "user":
|
||||
@@ -375,7 +382,7 @@ export function buildTranscriptRow(row: TranscriptRow, approval?: ApprovalRowSta
|
||||
break;
|
||||
case "tool_call":
|
||||
case "tool_diff":
|
||||
built = buildToolRow(row);
|
||||
built = buildToolRow(row, toolExpanded);
|
||||
break;
|
||||
case "approval":
|
||||
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
|
||||
|
||||
@@ -2509,6 +2509,11 @@ input.leaf-rename:focus {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.st-ln-eof {
|
||||
color: var(--faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* the one lifted, card-like element on the surface: the only thing the
|
||||
operator owes a decision on */
|
||||
.st-approval {
|
||||
|
||||
@@ -276,6 +276,7 @@ export interface CreateSessionRequest {
|
||||
env?: Record<string, string>;
|
||||
metadata?: Record<string, unknown>;
|
||||
worktree?: WorktreeSpec;
|
||||
view?: SessionView;
|
||||
}
|
||||
|
||||
export type CatalogEvent =
|
||||
|
||||
Reference in New Issue
Block a user