Compare commits

...
Author SHA1 Message Date
Miguel Palhas 25344abe4f fix(web): close stale structured sockets 2026-09-05 08:29:48 +01:00
Miguel Palhas b52a9085e9 fix(web): require post-acceptance state 2026-09-05 08:22:26 +01:00
Miguel Palhas 8bd943a8ca fix(web): release accepted reconnect submit 2026-09-05 08:16:14 +01:00
Miguel Palhas b86335ddf1 fix(maestro): guard structured web submits 2026-09-05 08:09:51 +01:00
Miguel Palhas c348381c5f Merge issue #187: fix TUI structured spawn attachment race 2026-09-05 07:59:55 +01:00
Miguel Palhas dabdf9fcba fix(tui): use spawn view for attach 2026-09-05 07:57:03 +01:00
Miguel Palhas b76dc06a09 Merge structured-view/141-tui-structured 2026-09-05 07:44:56 +01:00
Miguel Palhas 23fbfdc5b8 fix(maestro): guard reconnect generations 2026-09-05 07:43:08 +01:00
Miguel Palhas ff34727e3c fix(maestro): correct structured TUI state 2026-09-05 07:36:34 +01:00
Miguel Palhas e9ede49256 feat(maestro): render structured TUI sessions 2026-09-05 07:19:10 +01:00
Miguel Palhas 8c97ef0616 Merge structured-view/140-web-structured 2026-09-05 06:53:26 +01:00
Miguel Palhas 9533672e09 fix(maestro): require real DOM focus before reconciliation re-focuses (#140)
ConnectionPool.reconcileViews re-focused a repaired connection whenever
focusedId named it, but a same-tab sidebar preview moves the keyboard
into the sidebar without ever calling setFocus (multiview.ts's
syncFocus skips it while previewing) — so focusedId keeps naming the
pane the operator was in before the preview, even though the DOM
doesn't agree. Replacing that pane's connection during exactly that
window called onMount({focus:true}) and yanked the keyboard back out
of the sidebar mid-navigation.

Capture existing.el.contains(document.activeElement) before dispose()/
el.remove() detach it (afterward the browser has already moved
activeElement to <body>, too late to tell), and require both that and
focusedId===id to re-focus. document.body and a null activeElement
both naturally fail .contains(), so "nothing focused" and "focus
elsewhere" both correctly skip.

Regression tests: 3 new (real-focus requirement, same-tab preview
race, descendant focus, body-as-unfocused) — 498 tests total.
2026-09-05 06:49:48 +01:00
Miguel Palhas b11784e8e3 fix(maestro): mobile drawer access, focus preservation, stale rAF focus (#140)
Three more fixes from acceptance review:

- structured.ts's header omitted the .sessions-btn attach.ts's terminal
  header carries. The sidebar is off-canvas on mobile, so a structured
  session had no way to reach it — add the same button and
  requestToggleDrawer wiring.

- ConnectionPool.reconcileViews always remounted a repaired connection
  with focus:false. If the mistyped pane was the one actually focused,
  disposing it dropped DOM focus while `focusedId` still named it. Pass
  `focus: this.focusedId === id` instead — `focusedId` only ever names a
  real focus (multiview.ts skips setFocus while previewing), so a
  preview or a merely-visible split neighbour still comes back
  unfocused.

- onMount's deferred requestAnimationFrame focus had no way to tell it
  had been superseded by an unmount (or a remount elsewhere) before it
  fired, so a parked pane could steal focus a frame later. A token
  bumped on every mount/unmount lets the callback recognize it's stale
  and no-op.

Regression tests: 7 new (structured.test.ts, connections.test.ts). 495
tests total.
2026-09-05 06:41:40 +01:00
Miguel Palhas 42ffc1a352 fix(maestro): correct connection view race and approval decisions (#140)
Three fixes from acceptance review:

- ConnectionPool.mount cached a connection's kind (terminal/structured)
  permanently from whatever `info` it saw first. A deep link or restored
  layout mounts before the catalog snapshot names a session's view, so
  `info` is undefined and a structured session got an xterm connection
  forever. The pool now tracks the view each cached connection was built
  for, subscribes to the catalog itself, and replaces a mistyped
  connection in place the moment the real view resolves — without
  downgrading a resolved connection just because a later caller couldn't
  resolve the session, and without treating a session merely absent from
  a snapshot (mid-removal) as "Terminal".

- The approval card offered all three decisions unconditionally. Per
  the daemon's decision mapping (session.rs's permission_outcome), a
  decision with no matching PermissionOptionKind in the request's own
  options resolves Cancelled silently — so "Allow for session" could
  look like it worked while the daemon withdrew it. Buttons are now
  gated on the options actually offered.

- Clicking a decision disabled the buttons and never re-enabled them if
  the POST failed. The decision path is async now: a rejection restores
  the buttons and surfaces the error only if the approval is still the
  pending one — a superseded/resolved approval's stale rejection is a
  no-op, not a wrongly revived control.

Regression tests: 12 new (connections.test.ts, structured.test.ts,
structuredRender.test.ts). 488 tests total.
2026-09-05 06:32:08 +01:00
Miguel Palhas a50f098c8a feat(maestro): render the structured view in the web client (#140)
Adds a transcript surface for a Structured-view session alongside the
existing xterm attach view: a WS /api/structured/:id connection
(structured.ts) mounted by ConnectionPool whenever a session's `view`
is `"Structured"`, and pure per-row/rail renderers (structuredRender.ts,
structuredDiff.ts) covering all eight transcript row kinds.

Hierarchy follows the design reference (artifact 88ecd690): the
approval is the only lifted, card-like element; a user turn is a
tinted block with an accent rail; agent prose is plain text; tool
calls are compact bordered rows expanding to output or a coloured,
horizontally-scrolling diff computed from ACP's full old/new text.
The client renders `reduced_state` and rows exactly as received, with
no raw-event folding or turn tracking of its own.

Extracted attach.ts's pane-header title building into paneTitle.ts and
its connection interface into pane.ts (PaneConnection) so both
connection kinds share one contract and one header.
2026-09-05 06:10:40 +01:00
Miguel Palhas 32d4058c96 Merge structured-view/186-unknown-update-doc 2026-09-05 05:38:04 +01:00
Miguel Palhas 74a423567f docs(maestro): fix disabled-unstable-variant claim in STRUCTURED-VIEW.md
A SessionUpdate variant gated behind an unstable ACP feature this build
doesn't enable is compiled out of the enum, not decoded into a Notice.
It fails deserialization the same as a genuinely unknown future tag.
2026-09-05 05:35:19 +01:00
Miguel Palhas b225fbdd69 docs(maestro): narrow the Notice forward-compat claim in STRUCTURED-VIEW.md
Notice only covers SessionUpdate variants the agent-client-protocol
dependency already decodes but the projection doesn't special-case.
SessionUpdate is an internally-tagged enum with no unknown-variant
fallback, so a genuinely new wire variant fails deserialization before
the projection ever sees it, rather than becoming a Notice. A new
variant needs the dependency upgraded first.

Adds a projection test asserting that an unrecognised sessionUpdate
tag is a deserialization error.

Refs #186
2026-09-05 05:28:11 +01:00
Miguel Palhas 244e8d2927 Merge structured-view/185-lock-order into blitz/structured-view 2026-09-05 05:20:20 +01:00
32 changed files with 5760 additions and 99 deletions
Generated
+1
View File
@@ -1644,6 +1644,7 @@ dependencies = [
"tokio",
"tokio-tungstenite 0.21.0",
"tracing",
"unicode-width",
"uuid",
"vt100",
]
+1
View File
@@ -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"
+10
View File
@@ -438,6 +438,16 @@ mod tests {
.unwrap()
}
#[test]
fn a_session_update_tag_the_acp_dependency_does_not_know_fails_to_deserialize() {
// `SessionUpdate` has no unknown-variant fallback, so this never reaches `Projection::apply`.
let result: Result<SessionNotification, _> = serde_json::from_value(serde_json::json!({
"sessionId": "acp-1",
"update": {"sessionUpdate": "some_future_update_variant"},
}));
assert!(result.is_err());
}
#[test]
fn folds_content_tool_diff_plan_and_notice_rows() {
let mut projection = Projection::default();
+57 -1
View File
@@ -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
}
+9
View File
@@ -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]
+1
View File
@@ -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
View File
@@ -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(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
);
}
}
+2
View File
@@ -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;
+30 -6
View File
@@ -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,
+1 -1
View File
@@ -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)
+815
View File
@@ -0,0 +1,815 @@
//! Native ratatui renderer for daemon-projected structured session rows.
use maestro_protocol::{SessionStatus, TranscriptRow, TranscriptRowKind};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use unicode_width::UnicodeWidthChar;
use crate::keymap::Mode;
use crate::palette;
use crate::session_task::SessionState;
use crate::structured_task::{offered_decisions, StructuredHandle, StructuredState};
const DIFF_PREVIEW_LINES: usize = 3;
#[derive(Debug, Clone)]
pub struct StructuredPane {
pub handle: StructuredHandle,
pub focused: bool,
pub mode: Mode,
pub session_status: SessionStatus,
pub activity_detail: Option<String>,
}
#[derive(Debug)]
struct VisualLine {
text: String,
row_id: Option<String>,
rail: bool,
tone: Tone,
}
#[derive(Debug, Clone, Copy)]
enum Tone {
Normal,
Muted,
Active,
Warn,
Error,
}
impl StructuredPane {
pub fn render(&self, f: &mut Frame, area: Rect) {
if area.width == 0 || area.height == 0 {
return;
}
let footer_height = area.height.min(3);
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(footer_height.saturating_sub(1).min(2)),
Constraint::Length(footer_height.min(1)),
])
.split(area);
let mut state = self.handle.state.lock().expect("structured state mutex");
self.render_transcript(f, parts[0], &mut state);
self.render_projection_status(f, parts[1], &state);
self.render_composer(f, parts[2], &state);
}
fn render_transcript(&self, f: &mut Frame, area: Rect, state: &mut StructuredState) {
let mut lines = build_lines(state, area.width.max(1) as usize);
if self.session_status == SessionStatus::Dead {
let detail = self
.activity_detail
.as_deref()
.unwrap_or("structured session ended");
lines.push(VisualLine {
text: format!("error: {detail}"),
row_id: None,
rail: true,
tone: Tone::Error,
});
} else {
match self.handle.connection() {
SessionState::Connecting => lines.push(system_line("connecting…")),
SessionState::Reconnecting => lines.push(system_line("reconnecting…")),
SessionState::Ended => lines.push(error_line("structured connection ended")),
SessionState::Live => {}
}
}
if let Some(error) = &state.error {
lines.push(error_line(error));
}
let viewport = usize::from(area.height);
let max_scroll = lines.len().saturating_sub(viewport);
if state.follow_selection {
if let Some(selected) = state.selected.as_deref() {
let first = lines
.iter()
.position(|line| line.row_id.as_deref() == Some(selected));
let last = lines
.iter()
.rposition(|line| line.row_id.as_deref() == Some(selected));
if let (Some(first), Some(last)) = (first, last) {
if first < state.scroll {
state.scroll = first;
} else if last >= state.scroll.saturating_add(viewport) {
state.scroll = last.saturating_add(1).saturating_sub(viewport);
}
}
} else {
state.scroll = max_scroll;
}
}
state.scroll = state.scroll.min(max_scroll);
let palette = palette::active();
for (offset, visual) in lines.iter().skip(state.scroll).take(viewport).enumerate() {
let selected = visual.row_id.as_ref() == state.selected.as_ref();
let surface = if selected {
palette.selection
} else {
palette.card
};
let foreground = match visual.tone {
Tone::Normal => surface.fg,
Tone::Muted => surface.muted,
Tone::Active => surface.active,
Tone::Warn => surface.awaiting,
Tone::Error => surface.error,
};
let base = Style::default().bg(surface.bg).fg(foreground);
let prefix = if visual.rail {
Span::styled("▌", base.fg(surface.awaiting))
} else {
Span::styled(" ", base)
};
let line = Line::from(vec![prefix, Span::styled(&visual.text, base)]);
f.render_widget(
Paragraph::new(line).style(base),
Rect::new(area.x, area.y + offset as u16, area.width, 1),
);
}
}
fn render_projection_status(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
if area.height == 0 {
return;
}
let palette = palette::active();
let plan = plan_summary(state);
let usage = usage_summary(state);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
f.render_widget(
Paragraph::new(plan).style(palette.card.muted_style()),
rows[0],
);
if area.height > 1 {
f.render_widget(
Paragraph::new(usage).style(palette.card.dim_style()),
rows[1],
);
}
}
fn render_composer(&self, f: &mut Frame, area: Rect, state: &StructuredState) {
if area.height == 0 {
return;
}
let palette = palette::active();
let active = state.turn_active();
let label = if active {
" working esc: cancel"
} else {
" › "
};
let label_width = label.chars().count().min(usize::from(area.width)) as u16;
let parts = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(label_width), Constraint::Min(0)])
.split(area);
let label_style = if active {
Style::default().bg(palette.card.bg).fg(palette.card.active)
} else {
palette.card.text_style().add_modifier(Modifier::BOLD)
};
f.render_widget(Paragraph::new(label).style(label_style), parts[0]);
if !active {
let cursor = state
.composer
.render(parts[1], f.buffer_mut(), palette.card.text_style());
if self.focused && self.mode == Mode::Insert && parts[1].width > 0 {
f.set_cursor_position((parts[1].x + cursor, parts[1].y));
}
}
}
}
fn system_line(text: &str) -> VisualLine {
VisualLine {
text: text.into(),
row_id: None,
rail: false,
tone: Tone::Muted,
}
}
fn error_line(text: &str) -> VisualLine {
VisualLine {
text: text.into(),
row_id: None,
rail: true,
tone: Tone::Error,
}
}
fn build_lines(state: &StructuredState, width: usize) -> Vec<VisualLine> {
let mut out = Vec::new();
for row in &state.rows {
if row.kind == TranscriptRowKind::Plan {
continue;
}
let row_width = width.saturating_sub(1).max(1);
match row.kind {
TranscriptRowKind::User => push_wrapped(
&mut out,
row,
"You: ",
content_text(row),
row_width,
Tone::Normal,
false,
),
TranscriptRowKind::AgentText => push_wrapped(
&mut out,
row,
"",
content_text(row),
row_width,
Tone::Normal,
false,
),
TranscriptRowKind::AgentThought => push_wrapped(
&mut out,
row,
"thought: ",
content_text(row),
row_width,
Tone::Muted,
false,
),
TranscriptRowKind::ToolCall | TranscriptRowKind::ToolDiff => {
tool_lines(&mut out, state, row, row_width)
}
TranscriptRowKind::Approval => approval_lines(&mut out, state, row, row_width),
TranscriptRowKind::Notice => push_wrapped(
&mut out,
row,
"",
"[notice]".into(),
row_width,
Tone::Muted,
false,
),
TranscriptRowKind::Plan => {}
}
}
out
}
fn push_wrapped(
out: &mut Vec<VisualLine>,
row: &TranscriptRow,
prefix: &str,
text: String,
width: usize,
tone: Tone,
rail: bool,
) {
let joined = format!("{prefix}{text}");
for line in wrap_text(&joined, width) {
out.push(VisualLine {
text: line,
row_id: Some(row.id.clone()),
rail,
tone,
});
}
}
fn tool_lines(
out: &mut Vec<VisualLine>,
state: &StructuredState,
row: &TranscriptRow,
width: usize,
) {
let expanded = state.expanded.contains(&row.id);
let status = row
.data
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("pending");
let status_mark = match status {
"pending" => "·",
"in_progress" => "●",
"failed" => "×",
_ => "✓",
};
let (name, argument) = tool_title(row);
let affordance = if expanded { "▾" } else { "▸" };
let mut head = format!("{affordance} {status_mark} {name}");
if !argument.is_empty() {
head.push_str(" ");
head.push_str(&argument);
}
out.push(VisualLine {
text: truncate_cells(&head, width),
row_id: Some(row.id.clone()),
rail: false,
tone: if status == "failed" {
Tone::Error
} else {
Tone::Active
},
});
let content = tool_content_lines(row);
if row.kind == TranscriptRowKind::ToolDiff && !expanded {
let shown = content.len().min(DIFF_PREVIEW_LINES);
for text in content.iter().take(shown) {
push_wrapped(out, row, " ", text.clone(), width, diff_tone(text), false);
}
if content.len() > shown {
push_wrapped(
out,
row,
" ",
"… Enter to expand".into(),
width,
Tone::Muted,
false,
);
}
} else if expanded {
for text in content {
push_wrapped(out, row, " ", text.clone(), width, diff_tone(&text), false);
}
}
}
fn approval_lines(
out: &mut Vec<VisualLine>,
state: &StructuredState,
row: &TranscriptRow,
width: usize,
) {
let pending = state.pending_nonce().is_some()
&& state
.rows
.iter()
.rev()
.find(|candidate| candidate.kind == TranscriptRowKind::Approval)
.is_some_and(|candidate| candidate.id == row.id);
let tool = row.data.get("toolCall").unwrap_or(&serde_json::Value::Null);
let title = tool
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or("permission requested");
push_wrapped(
out,
row,
"permission: ",
title.into(),
width,
Tone::Warn,
true,
);
if let Some(path) = tool
.pointer("/locations/0/path")
.and_then(serde_json::Value::as_str)
{
push_wrapped(out, row, " ", path.into(), width, Tone::Muted, true);
}
let actions = if pending {
let offered = offered_decisions(&row.data);
let mut keys = Vec::new();
if offered.contains(&maestro_protocol::StructuredApprovalDecision::AllowOnce) {
keys.push("[a] allow once");
}
if offered.contains(&maestro_protocol::StructuredApprovalDecision::AllowForSession) {
keys.push("[A] allow session");
}
if offered.contains(&maestro_protocol::StructuredApprovalDecision::Deny) {
keys.push("[d] deny");
}
if keys.is_empty() {
"no decision offered".into()
} else {
keys.join(" ")
}
} else {
"answered".into()
};
push_wrapped(out, row, " ", actions, width, Tone::Warn, true);
}
fn content_text(row: &TranscriptRow) -> String {
let Some(content) = row.data.get("content") else {
return String::new();
};
match content.get("type").and_then(serde_json::Value::as_str) {
Some("text") => content
.get("text")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.into(),
Some("image") => "[image]".into(),
Some("audio") => "[audio]".into(),
Some("resource_link") => format!(
"[resource: {}]",
content
.get("name")
.or_else(|| content.get("uri"))
.and_then(serde_json::Value::as_str)
.unwrap_or("linked")
),
Some("resource") => "[resource]".into(),
_ => String::new(),
}
}
fn tool_title(row: &TranscriptRow) -> (String, String) {
let fallback = row
.data
.get("kind")
.and_then(serde_json::Value::as_str)
.unwrap_or("tool");
let explicit = row.data.get("name").and_then(serde_json::Value::as_str);
let title = row
.data
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
if let Some((name, rest)) = title
.split_once('(')
.and_then(|(name, rest)| rest.strip_suffix(')').map(|rest| (name, rest)))
{
return (explicit.unwrap_or(name).to_string(), rest.to_string());
}
(explicit.unwrap_or(fallback).to_string(), title.to_string())
}
fn tool_content_lines(row: &TranscriptRow) -> Vec<String> {
let mut out = Vec::new();
for block in row
.data
.get("content")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
{
match block.get("type").and_then(serde_json::Value::as_str) {
Some("content") => {
let nested = block.get("content").cloned().unwrap_or_default();
let fake = TranscriptRow {
id: String::new(),
kind: TranscriptRowKind::AgentText,
data: serde_json::json!({"content": nested}),
};
let text = content_text(&fake);
out.extend(text.lines().map(str::to_string));
}
Some("diff") => {
let path = block
.get("path")
.and_then(serde_json::Value::as_str)
.unwrap_or("file");
out.push(format!("{path}:"));
if let Some(old) = block.get("oldText").and_then(serde_json::Value::as_str) {
out.extend(old.lines().map(|line| format!("- {line}")));
}
if let Some(new) = block.get("newText").and_then(serde_json::Value::as_str) {
out.extend(new.lines().map(|line| format!("+ {line}")));
}
}
Some("terminal") => out.push("[embedded terminal]".into()),
_ => {}
}
}
out
}
fn diff_tone(text: &str) -> Tone {
if text.starts_with("+ ") {
Tone::Active
} else if text.starts_with("- ") {
Tone::Error
} else {
Tone::Muted
}
}
fn plan_summary(state: &StructuredState) -> String {
let entries = state
.reduced
.plan
.as_ref()
.and_then(|plan| plan.get("entries"))
.and_then(serde_json::Value::as_array);
let Some(entries) = entries else {
return " plan —".into();
};
let current = entries.iter().find(|entry| {
entry.get("status").and_then(serde_json::Value::as_str) == Some("in_progress")
});
let done = entries
.iter()
.filter(|entry| {
entry.get("status").and_then(serde_json::Value::as_str) == Some("completed")
})
.count();
let text = current
.and_then(|entry| entry.get("content"))
.and_then(serde_json::Value::as_str)
.unwrap_or("waiting");
format!(" plan {done}/{} {text}", entries.len())
}
fn usage_summary(state: &StructuredState) -> String {
let activity = if state.reduced.turn.thinking {
"thinking"
} else if state.reduced.turn.responding {
"responding"
} else {
"idle"
};
let usage = state
.reduced
.usage
.as_ref()
.or(state.reduced.context.as_ref());
let used = usage
.and_then(|value| value.get("used"))
.and_then(serde_json::Value::as_u64);
let size = usage
.and_then(|value| value.get("size"))
.and_then(serde_json::Value::as_u64);
let context = match (used, size) {
(Some(used), Some(size)) => format!(" context {used}/{size}"),
_ => String::new(),
};
let model = state
.reduced
.model
.as_deref()
.map(|model| format!(" {model}"))
.unwrap_or_default();
let mode = state
.reduced
.mode
.as_deref()
.map(|mode| format!(" · {mode}"))
.unwrap_or_default();
format!(" {activity}{context}{model}{mode}")
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut out = Vec::new();
for source in text.split('\n') {
let mut line = String::new();
let mut cells = 0;
for ch in source.chars() {
let char_width = ch.width().unwrap_or(0);
if cells > 0 && cells + char_width > width {
out.push(std::mem::take(&mut line));
cells = 0;
}
line.push(ch);
cells += char_width;
}
out.push(line);
}
if out.is_empty() {
out.push(String::new());
}
out
}
fn truncate_cells(text: &str, width: usize) -> String {
let width = width.max(1);
let total: usize = text.chars().map(|ch| ch.width().unwrap_or(0)).sum();
if total <= width {
return text.to_string();
}
if width == 1 {
return "…".into();
}
let mut out = String::new();
let mut cells = 0;
for ch in text.chars() {
let char_width = ch.width().unwrap_or(0);
if cells + char_width > width - 1 {
break;
}
out.push(ch);
cells += char_width;
}
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use maestro_protocol::{ReducedState, TranscriptRow};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use serde_json::json;
fn row(id: &str, kind: TranscriptRowKind, data: serde_json::Value) -> TranscriptRow {
TranscriptRow {
id: id.into(),
kind,
data,
}
}
fn render_state(
state: StructuredState,
width: u16,
height: u16,
status: SessionStatus,
detail: Option<&str>,
) -> (String, ratatui::buffer::Buffer) {
let pane = StructuredPane {
handle: StructuredHandle::test_handle(state),
focused: true,
mode: Mode::Normal,
session_status: status,
activity_detail: detail.map(str::to_string),
};
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| pane.render(frame, frame.area()))
.unwrap();
let buffer = terminal.backend().buffer().clone();
let text = (0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
(text, buffer)
}
#[test]
fn prose_wraps_to_the_pane_width() {
let mut state = StructuredState::default();
state.rows.push(row(
"a",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"abcdefghij"}}),
));
let lines = build_lines(&state, 5);
assert_eq!(
lines
.iter()
.map(|line| line.text.as_str())
.collect::<Vec<_>>(),
["abcd", "efgh", "ij"]
);
}
#[test]
fn tool_is_compact_until_selected_row_expands() {
let mut state = StructuredState::default();
state.rows.push(row("t", TranscriptRowKind::ToolCall, json!({"title":"Bash(cargo test)","status":"completed","content":[{"type":"content","content":{"type":"text","text":"output"}}]})));
let collapsed = build_lines(&state, 80);
assert_eq!(collapsed.len(), 1);
assert!(collapsed[0].text.contains("Bash cargo test"));
assert_eq!(build_lines(&state, 8).len(), 1);
state.selected = Some("t".into());
state.toggle_selected();
assert!(build_lines(&state, 80)
.iter()
.any(|line| line.text.contains("output")));
}
#[test]
fn compact_tool_header_omits_client_invented_duration() {
let mut state = StructuredState::default();
state.rows.push(row(
"t",
TranscriptRowKind::ToolCall,
json!({"title":"Bash(cargo test)","status":"completed"}),
));
assert_eq!(build_lines(&state, 80)[0].text, "▸ ✓ Bash cargo test");
}
#[test]
fn serialized_tool_without_status_defaults_to_pending() {
let tool: TranscriptRow = serde_json::from_value(json!({
"id": "tool:1",
"kind": "tool_call",
"data": {"title": "Read(src/lib.rs)"}
}))
.unwrap();
let mut state = StructuredState::default();
state.rows.push(tool);
assert_eq!(build_lines(&state, 80)[0].text, "▸ · Read src/lib.rs");
}
#[test]
fn collapsed_diff_previews_only_a_few_lines_with_affordance() {
let mut state = StructuredState::default();
state.rows.push(row("d", TranscriptRowKind::ToolDiff, json!({"title":"Edit(x)","content":[{"type":"diff","path":"x","oldText":"a\nb","newText":"c\nd"}]})));
let lines = build_lines(&state, 80);
assert!(lines.len() <= 5);
assert!(lines.last().unwrap().text.contains("Enter to expand"));
}
#[test]
fn plan_is_not_a_transcript_row_and_status_is_two_lines() {
let mut state = StructuredState::default();
state
.rows
.push(row("plan", TranscriptRowKind::Plan, json!({})));
state.reduced = ReducedState {
plan: Some(json!({"entries":[{"status":"in_progress","content":"ship"}]})),
usage: Some(json!({"used":12,"size":100})),
..ReducedState::default()
};
assert!(build_lines(&state, 80).is_empty());
assert!(plan_summary(&state).contains("ship"));
assert!(usage_summary(&state).contains("12/100"));
}
#[test]
fn approval_is_inline_railed_and_lists_only_offered_keys() {
let mut state = StructuredState::default();
state.rows.push(row("p", TranscriptRowKind::Approval, json!({"toolCall":{"title":"Bash(rm x)"},"options":[{"kind":"allow_once"},{"kind":"reject_once"}]})));
state.reduced.pending_approval = Some(json!({"nonce":"n"}));
let lines = build_lines(&state, 80);
assert!(lines.iter().all(|line| line.rail));
assert!(lines
.iter()
.any(|line| line.text.contains("[a] allow once")));
assert!(!lines.iter().any(|line| line.text.contains("[A]")));
}
#[test]
fn one_column_panes_do_not_panic() {
let mut state = StructuredState::default();
state.rows.push(row(
"a",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"hi"}}),
));
assert!(!build_lines(&state, 1).is_empty());
}
#[test]
fn native_pane_renders_projection_footer_and_composer_together() {
let mut state = StructuredState::default();
state.rows.push(row(
"a",
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":"projected transcript"}}),
));
state.reduced.plan =
Some(json!({"entries":[{"status":"in_progress","content":"render native pane"}]}));
state.reduced.usage = Some(json!({"used":42,"size":1000}));
let (text, _) = render_state(state, 50, 8, SessionStatus::Running, None);
assert!(text.contains("projected transcript"), "{text}");
assert!(text.contains("render native pane"), "{text}");
assert!(text.contains("context 42/1000"), "{text}");
assert!(text.contains('›'), "{text}");
}
#[test]
fn selected_row_scrolls_into_view_and_uses_the_selection_surface() {
let mut state = StructuredState::default();
for n in 0..12 {
state.rows.push(row(
&format!("r{n}"),
TranscriptRowKind::AgentText,
json!({"content":{"type":"text","text":format!("line {n}")}}),
));
}
state.selected = Some("r11".into());
let (text, buffer) = render_state(state, 30, 7, SessionStatus::Running, None);
assert!(text.contains("line 11"), "{text}");
let selected_y = (0..4)
.find(|y| {
(0..30)
.map(|x| buffer[(x, *y)].symbol())
.collect::<String>()
.contains("line 11")
})
.unwrap();
assert_eq!(buffer[(2, selected_y)].bg, palette::active().selection.bg);
}
#[test]
fn dead_session_reason_and_connection_error_are_rendered_inline() {
let mut state = StructuredState::default();
state.error = Some("request failed".into());
let (text, _) = render_state(state, 50, 7, SessionStatus::Dead, Some("adapter exited"));
assert!(text.contains("adapter exited"), "{text}");
assert!(text.contains("request failed"), "{text}");
}
}
+657
View File
@@ -0,0 +1,657 @@
//! 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, error: String) {
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 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()
);
}
+23 -8
View File
@@ -113,13 +113,25 @@ crate.
| `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:<tool_call_id>` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. |
| `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:<uuid>` — minted fresh per request, unrelated to the ACP request's own id. |
| `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. |
| `Notice` | The raw JSON of any `session/update` variant the projection does not special-case. | `notice:<seq>`. |
| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. | `notice:<seq>`. |
`Notice` is the projection's forward-compatibility seam: ACP adds
notification variants over time, and an unrecognised one becomes a generic
row instead of being silently dropped or making the daemon reject the whole
stream. A client that does not know what to do with a `Notice` can render it
as an opaque, collapsed line — that is a reasonable floor, not a client bug.
`Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the
`agent-client-protocol` dependency's schema already deserializes (it
currently folds `available_commands_update` and `session_info_update`
there) but the projection's `match` doesn't fold into one of the other
seven kinds. It is not a wire-level forward-compatibility seam:
`SessionUpdate` is an internally-tagged enum with no unknown-variant
fallback, so a `session/update` whose `sessionUpdate` tag this build's
dependency has never heard of — including a variant gated behind an
unstable ACP feature this build doesn't enable, which is compiled out of
the enum entirely — fails to deserialize before the projection ever runs,
rather than becoming a `Notice`. A genuinely new ACP variant needs the
`agent-client-protocol` dependency upgraded (and, typically, the projection's
`match` taught about it) before it can appear on this transcript at all — see
`projection.rs`'s test asserting that an unrecognised `sessionUpdate` tag is a
deserialization error, not a `Notice`. A client that does not know what to do
with a `Notice` can still render it as an opaque, collapsed line — that
remains a reasonable floor, not a client bug.
Rendering is entirely a client concern. The projection's job stops at
folding ACP into these eight shapes; markdown formatting, diff rendering,
@@ -139,8 +151,11 @@ choices, not daemon ones.
- Approval nonce minting, secrecy, and the single-active-approval rule (see
below) — a client never sees an ACP permission request before the daemon
has already turned it into an `Approval` row and a nonce.
- What happens to an ACP update variant nobody has taught the daemon about
yet (falls back to `Notice` rather than erroring).
- What happens to an ACP update variant the dependency decodes but nobody has
taught the projection to special-case (falls back to `Notice` rather than
erroring). A variant the dependency itself can't decode is a deserialization
error, not a `Notice` — see [The eight transcript row
kinds](#the-eight-transcript-row-kinds).
- Persistence and replay: what is written to the event log, in what order,
and how a restart resumes it.
+43 -1
View File
@@ -11,6 +11,8 @@ import type {
Projects,
RecentDirs,
Session,
StructuredApprovalDecision,
StructuredPromptRequest,
Uuid,
} from "./types.js";
@@ -152,4 +154,44 @@ export async function fetchBranches(path: string): Promise<Branches> {
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
}
export { eventsWsUrl } from "./routes.js";
/** Send a prompt to a structured session's ACP turn (#138). `409
* turn_in_flight` when one is already active — the caller reflects that from
* `ReducedState.turn` instead of racing this call. */
export async function sendStructuredPrompt(id: Uuid, prompt: string): Promise<void> {
await jsonOrError(
await fetch(routes.structuredPrompt(id), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt } satisfies StructuredPromptRequest),
}),
);
}
/** Cancel a structured session's active ACP turn (#138). */
export async function cancelStructuredTurn(id: Uuid): Promise<void> {
await jsonOrError(
await fetch(routes.structuredCancel(id), { method: "POST", credentials: "same-origin" }),
);
}
/** Answer a pending ACP permission request (#139). Always resolves once the
* session itself is a live structured session — a nonce that is unknown or
* already consumed still answers `204` (docs/STRUCTURED-VIEW.md), so two
* clients racing the same approval never see this reject. */
export async function resolveStructuredApproval(
id: Uuid,
nonce: string,
decision: StructuredApprovalDecision,
): Promise<void> {
await jsonOrError(
await fetch(routes.structuredApproval(id, nonce), {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify(decision),
}),
);
}
export { eventsWsUrl, structuredWsUrl } from "./routes.js";
+8 -44
View File
@@ -4,14 +4,16 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
import { requestToggleDrawer } from "./drawer.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
import type { PaneConnection } from "./pane.js";
import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js";
export type { AttachInfo } from "./paneTitle.js";
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy();
@@ -27,25 +29,7 @@ const webglPolicy = new WebglPolicy();
* session ending) tears the socket down — so switching away and back is
* instant, with scrollback already in place.
*/
export interface AttachConnection {
readonly sessionId: Uuid;
/** The `.attach` element (header + terminal body) to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit to the pane, focus, sync size.
* `focus: false` mounts without taking the keyboard — what a sidebar
* preview needs, so j/k keeps working while the pane is on screen. */
onMount(opts?: { focus?: boolean }): void;
/** Parked into the cache: keep streaming, just stop driving focus/resize. */
onUnmount(): void;
/** Put the keyboard back into this terminal — what leaving the sidebar does. */
focusTerminal(): void;
/** Type raw bytes straight into the PTY, bypassing the terminal's own key
* handling — the leader's literal Ctrl-K escape hatch
* (docs/COMMAND-BAR.md). */
sendInput(data: string): void;
/** Fully disconnect: close the WS, dispose the terminal, remove the element. */
dispose(): void;
}
export interface AttachConnection extends PaneConnection {}
export interface AttachOptions {
/** Invoked when the user clicks the view's explicit "disconnect" control. */
@@ -59,23 +43,6 @@ export interface AttachOptions {
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
}
/** What the header shows about the session (name / command). Optional — falls
* back to a short session id when the session isn't known yet. */
export interface AttachInfo {
name: string;
command: string;
}
/** Header echoes the sidebar: the program becomes its tool icon when we have a
* mark for it and stays as its bare name when we do not. Arguments are
* dropped, as on the card. */
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
export function createAttachConnection(
sessionId: Uuid,
opts: AttachOptions = {},
@@ -85,15 +52,12 @@ export function createAttachConnection(
el.className = "attach";
// Useful identity over a bare UUID: name · command. The full id stays in the
// tooltip for when you really need it.
const titleHtml = info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId;
const titleHtml = paneTitleHtml(sessionId, info);
const titleAttr = paneTitleAttr(sessionId, info);
el.innerHTML = `
<header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${escapeHtml(titleAttr)}">${titleHtml}</span>
<span class="attach-title" title="${titleAttr}">${titleHtml}</span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header>
+261 -6
View File
@@ -2,28 +2,83 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConnectionPool } from "./connections.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
// the pool reaches the focused connection with them.
const sent: { id: string; data: string }[] = [];
vi.mock("./attach.js", () => ({
createAttachConnection: (id: string) => ({
const disposed: string[] = [];
const mounts: { id: string; focus: boolean | undefined }[] = [];
function fakeConn(kind: string, id: string) {
const el = document.createElement("div");
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
el.dataset.kind = kind;
el.dataset.session = id;
return {
sessionId: id,
el: document.createElement("div"),
onMount() {},
kind,
el,
onMount(opts?: { focus?: boolean }) {
mounts.push({ id, focus: opts?.focus });
},
onUnmount() {},
focusTerminal() {},
sendInput(data: string) {
sent.push({ id, data });
},
dispose() {},
}),
dispose() {
disposed.push(`${kind}:${id}`);
},
};
}
vi.mock("./attach.js", () => ({
createAttachConnection: (id: string) => fakeConn("attach", id),
}));
vi.mock("./structured.js", () => ({
createStructuredConnection: (id: string) => fakeConn("structured", id),
}));
afterEach(() => {
vi.unstubAllGlobals();
sent.length = 0;
disposed.length = 0;
mounts.length = 0;
});
/** Minimal fixture for the `CatalogState` a `CatalogStore` broadcasts. */
function session(id: string, view?: "Terminal" | "Structured"): Session {
return {
id,
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
view,
};
}
/** A `CatalogStore` an id can be pushed into on demand, mirroring
* events.test.ts's own injectable-socket pattern. */
class FakeCatalogSocket {
onmessage: ((ev: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: (() => void) | null = null;
close(): void {}
push(sessions: Session[]): void {
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
}
}
function fakeStore(): { store: CatalogStore; socket: FakeCatalogSocket } {
const socket = new FakeCatalogSocket();
const store = new CatalogStore({ socket: () => socket as unknown as WebSocket });
store.start();
return { store, socket };
}
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
@@ -125,3 +180,203 @@ describe("ConnectionPool.sendToFocused", () => {
expect(sent).toEqual([]);
});
});
// A deep link or restored layout can `mount()` a session before the catalog
// snapshot names its view — the daemon defaults an absent `view` to
// `"Terminal"`, but so does an *unknown* session, and only one of those is
// actually resolved (#140 review).
describe("ConnectionPool: view resolution races", () => {
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane); // no info: deep-link-before-snapshot race
expect(disposed).toEqual([]);
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// The fresh connection landed in the same pane nothing re-mounted it into.
expect(pane.children).toHaveLength(1);
});
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
pool.mount(A, document.createElement("div"));
pool.mount(B, document.createElement("div"));
pool.unmount(A);
pool.unmount(B);
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
// B's own element is still parked — A's repair didn't wipe its siblings
// out of the shared hidden cache.
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
});
it("does not replace a connection whose resolved view already matches", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual([]);
});
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
mockFetch();
const pool = new ConnectionPool();
const pane = document.createElement("div");
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
pool.mount(A, pane); // e.g. a re-render before the catalog answers again
expect(disposed).toEqual([]);
});
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" });
disposed.length = 0;
// `A` is absent from this snapshot — main.ts's own subscription handles
// closing the pane; the pool must not race it by "fixing" the view first.
socket.push([session(B, "Terminal")]);
expect(disposed).toEqual([]);
});
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
expect(document.activeElement).toBe(pane.firstElementChild);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: true }]);
pane.remove();
});
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
// docs/PREVIEW.md: previewing a session already open in the current tab
// moves the keyboard into the sidebar without ever calling setFocus, so
// `focusedId` keeps naming the pane the operator was in before — but the
// DOM disagrees, and the DOM is what must decide.
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(pane.firstElementChild as HTMLElement).focus();
const sidebarInput = document.createElement("input");
document.body.appendChild(sidebarInput);
sidebarInput.focus();
expect(document.activeElement).toBe(sidebarInput);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: false }]);
pane.remove();
sidebarInput.remove();
});
it("counts focus on a descendant of the pane's element, not just the element itself", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
const textarea = document.createElement("textarea");
(pane.firstElementChild as HTMLElement).appendChild(textarea);
textarea.focus();
expect(document.activeElement).toBe(textarea);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(mounts).toEqual([{ id: A, focus: true }]);
pane.remove();
});
it("treats document.body (nothing meaningfully focused) as no active descendant", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
document.body.appendChild(pane);
pool.mount(A, pane);
pool.setFocus(A);
(document.activeElement as HTMLElement | null)?.blur();
expect(document.activeElement).toBe(document.body);
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(mounts).toEqual([{ id: A, focus: false }]);
pane.remove();
});
it("does not steal focus for a merely-visible pane that was never actually focused", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const paneA = document.createElement("div");
const paneB = document.createElement("div");
pool.mount(A, paneA);
pool.mount(B, paneB);
pool.setFocus(B); // B is the real keyboard focus; A is only visible
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([{ id: A, focus: false }]);
});
it("does not focus a parked (unmounted) connection it repairs", () => {
mockFetch();
const { store, socket } = fakeStore();
const pool = new ConnectionPool({ store });
const pane = document.createElement("div");
pool.mount(A, pane);
pool.setFocus(A);
pool.unmount(A); // parked — no longer visible, `focusedId` still names it
mounts.length = 0;
socket.push([session(A, "Structured")]);
expect(disposed).toEqual(["attach:" + A]);
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
});
});
+110 -18
View File
@@ -1,10 +1,10 @@
import {
createAttachConnection,
type AttachConnection,
type AttachInfo,
} from "./attach.js";
import { createAttachConnection } from "./attach.js";
import type { AttachInfo } from "./paneTitle.js";
import type { PaneConnection } from "./pane.js";
import { createStructuredConnection } from "./structured.js";
import { setSessionFocus } from "./api.js";
import type { Uuid } from "./types.js";
import type { CatalogState, CatalogStore } from "./events.js";
import { sessionName, type SessionView, type Uuid } from "./types.js";
/**
* How many *parked* (off-screen but still-connected) sessions to keep warm
@@ -16,6 +16,15 @@ import type { Uuid } from "./types.js";
*/
const MAX_PARKED = 6;
/** A session's view defaults to `"Terminal"` wherever it isn't carried —
* matching the wire default (`SessionView`'s `#[default]`, types.ts). Only
* meaningful once the session itself is known; an altogether-unknown
* session (no `AttachInfo`/`Session` at all) is a distinct, un-resolved
* state callers must check for separately, not "Terminal". */
function resolveView(view: SessionView | undefined): SessionView {
return view ?? "Terminal";
}
export interface PoolOptions {
/** Fired when the focused session changes (drives sidebar highlight + HUD). */
onFocusChange?: (id: Uuid | null) => void;
@@ -27,6 +36,9 @@ export interface PoolOptions {
onClosed?: (id: Uuid) => void;
/** Forwarded to each connection: should a dropped socket be retried? */
shouldReconnect?: (id: Uuid, code: number) => boolean;
/** Threaded through to a structured connection so its rail can read live
* worktree/git-status data (#140). Terminal connections don't need it. */
store?: CatalogStore;
}
/**
@@ -40,12 +52,19 @@ export interface PoolOptions {
*/
export class ConnectionPool {
/** All live connections. Insertion order doubles as LRU: oldest first. */
private readonly cache = new Map<Uuid, AttachConnection>();
private readonly cache = new Map<Uuid, PaneConnection>();
/** The view each cached connection was actually created for — a deep link
* or a restored layout can `mount()` a session before the catalog
* snapshot names its view, defaulting it to `"Terminal"`; this is what
* lets a later reconciliation notice the resolved view disagrees (#140
* review). */
private readonly connViews = new Map<Uuid, SessionView>();
/** Sessions currently mounted into a visible pane (never evicted). */
private readonly mounted = new Set<Uuid>();
private readonly hidden: HTMLElement;
private focusedId: Uuid | null = null;
private readonly opts: PoolOptions;
private readonly unsubscribeStore?: () => void;
constructor(opts: PoolOptions = {}) {
this.opts = opts;
@@ -54,6 +73,11 @@ export class ConnectionPool {
this.hidden.className = "conn-cache";
this.hidden.setAttribute("aria-hidden", "true");
document.body.appendChild(this.hidden);
// A session `mount()`ed before its catalog entry exists gets a
// `"Terminal"` connection by default; once the snapshot actually names
// the view, fix any connection that guessed wrong — nothing else
// re-`mount()`s an already-open pane just because the catalog updated.
this.unsubscribeStore = opts.store?.subscribe((state) => this.reconcileViews(state));
}
/** The focused session, or null when nothing is focused (welcome pane). */
@@ -77,18 +101,18 @@ export class ConnectionPool {
mount(id: Uuid, pane: HTMLElement, info?: AttachInfo, opts?: { focus?: boolean }): void {
let conn = this.cache.get(id);
if (conn) {
this.cache.delete(id);
this.cache.set(id, conn);
// `info === undefined` means "session not known yet", not "Terminal" —
// never downgrade a resolved connection just because a later caller
// couldn't resolve the session. Only a *resolved* view that disagrees
// with what this connection was built for forces a replace.
if (info !== undefined && resolveView(info.view) !== this.connViews.get(id)) {
conn = this.replace(id, info);
} else {
this.cache.delete(id);
this.cache.set(id, conn); // LRU refresh
}
} else {
conn = createAttachConnection(
id,
{
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
onClosed: (sid) => this.opts.onClosed?.(sid),
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
},
info,
);
conn = this.create(id, info);
this.cache.set(id, conn);
}
if (conn.el.parentElement !== pane) {
@@ -101,6 +125,71 @@ export class ConnectionPool {
this.evict();
}
private create(id: Uuid, info?: AttachInfo): PaneConnection {
const view = resolveView(info?.view);
this.connViews.set(id, view);
const connOpts = {
onRequestClose: (sid: Uuid) => this.opts.onRequestClose?.(sid),
onClosed: (sid: Uuid) => this.opts.onClosed?.(sid),
shouldReconnect: (sid: Uuid, code: number) => this.opts.shouldReconnect?.(sid, code) ?? false,
};
return view === "Structured"
? createStructuredConnection(id, connOpts, info, this.opts.store)
: createAttachConnection(id, connOpts, info);
}
/** Dispose whatever is cached for `id` and cache a freshly created
* connection in its place. Does not touch the DOM or `mounted` — callers
* place the new element themselves. */
private replace(id: Uuid, info?: AttachInfo): PaneConnection {
this.cache.get(id)?.dispose();
const fresh = this.create(id, info);
this.cache.set(id, fresh);
return fresh;
}
/** Proactively fix any cached connection whose view no longer matches the
* catalog — the only path that repairs a pane nothing will `mount()`
* again on its own (parked, or mounted but not currently being
* re-rendered by anything else). A session absent from `state` (not yet
* known, or removed) is left alone — that's `main.ts`'s
* `closePane`/eviction concern, not a view mismatch. */
private reconcileViews(state: CatalogState): void {
for (const id of [...this.cache.keys()]) {
const session = state.sessions.get(id);
if (session === undefined || resolveView(session.view) === this.connViews.get(id)) continue;
const existing = this.cache.get(id)!;
const parent = existing.el.parentElement;
const wasMounted = this.mounted.has(id);
// Capture before anything below touches the DOM: dispose()/el.remove()
// detach `existing.el`, and once a focused descendant is detached the
// browser moves `document.activeElement` to `<body>` — too late to
// tell whether the keyboard was actually in this pane.
const hadDomFocus = existing.el.contains(document.activeElement);
const info: AttachInfo = { name: sessionName(session), command: session.command, view: session.view };
const fresh = this.replace(id, info);
existing.el.remove(); // don't rely on dispose() to clear the DOM
if (parent && parent !== this.hidden) {
parent.replaceChildren(fresh.el);
} else {
// Parked: `this.hidden` holds every parked connection's element as
// siblings — append, never replaceChildren, or they'd all vanish.
this.hidden.appendChild(fresh.el);
}
if (wasMounted) {
// `focusedId` names the session the pool was last told to focus —
// but a same-tab sidebar preview moves the keyboard into the
// sidebar without clearing it (multiview.ts skips `setFocus` while
// previewing), so `focusedId` alone can point at a pane the
// keyboard already left. Only re-focus when the DOM agrees it
// never did.
fresh.onMount({ focus: this.focusedId === id && hadDomFocus });
} else {
fresh.onUnmount();
}
}
}
/** Park `id` back into the hidden cache (still connected) and stop showing it. */
unmount(id: Uuid): void {
if (!this.mounted.delete(id)) return;
@@ -177,13 +266,16 @@ export class ConnectionPool {
if (!conn) return;
conn.dispose();
this.cache.delete(id);
this.connViews.delete(id);
this.mounted.delete(id);
if (this.focusedId === id) this.setFocus(null);
}
dispose(): void {
this.unsubscribeStore?.();
for (const conn of this.cache.values()) conn.dispose();
this.cache.clear();
this.connViews.clear();
this.mounted.clear();
this.hidden.remove();
}
+2 -1
View File
@@ -172,6 +172,7 @@ const pool = new ConnectionPool({
onRequestClose: (id) => closePane(id),
onClosed: (id) => closePane(id),
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
store,
});
// Session death used to reach the UI on two uncoordinated channels — the row
@@ -209,7 +210,7 @@ function scheduleSave(): void {
function infoFor(sessionId: string) {
const s = store.snapshot().sessions.get(sessionId);
return s ? { name: sessionName(s), command: s.command } : undefined;
return s ? { name: sessionName(s), command: s.command, view: s.view } : undefined;
}
/** Drive the shell's responsive layout off whether any pane is open. */
+26
View File
@@ -0,0 +1,26 @@
// Shared shape a mounted pane connection satisfies, whatever session view is
// behind it — what ConnectionPool needs to mount/park/dispose a pane without
// caring whether it holds an xterm attach (attach.ts) or a structured
// transcript (structured.ts).
import type { Uuid } from "./types.js";
export interface PaneConnection {
readonly sessionId: Uuid;
/** The element to mount into a pane. */
readonly el: HTMLElement;
/** Becomes the visible view: fit/focus/sync as the connection needs.
* `focus: false` mounts without taking the keyboard (a sidebar preview). */
onMount(opts?: { focus?: boolean }): void;
/** Parked into the hidden cache: keep streaming, stop driving focus/sizing. */
onUnmount(): void;
/** Put the keyboard into this pane's primary input — the terminal for an
* attach connection, the composer for a structured one. */
focusTerminal(): void;
/** Raw bytes into a PTY, bypassing normal key handling (the leader's
* literal-byte escape hatch, docs/COMMAND-BAR.md). A structured connection
* has no PTY, so this is a no-op there. */
sendInput(data: string): void;
/** Fully disconnect and remove the element. */
dispose(): void;
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { paneTitleAttr, paneTitleHtml } from "./paneTitle.js";
describe("paneTitleHtml", () => {
it("falls back to a short session id when there is no info yet", () => {
expect(paneTitleHtml("abcdef12-3456-7890-abcd-ef1234567890")).toContain("abcdef12");
});
it("shows the name alone when command matches it", () => {
const html = paneTitleHtml("s1", { name: "claude", command: "claude" });
expect(html).toContain("claude");
expect(html).not.toContain("at-cmd");
});
it("escapes a name that looks like markup", () => {
const html = paneTitleHtml("s1", { name: "<img onerror=x>", command: "claude" });
expect(html).not.toContain("<img");
});
});
describe("paneTitleAttr", () => {
it("escapes the attribute form too", () => {
const attr = paneTitleAttr("s1", { name: '"quoted"', command: "claude" });
expect(attr).not.toContain('"quoted"');
expect(attr).toContain("&quot;quoted&quot;");
});
});
+36
View File
@@ -0,0 +1,36 @@
// Pane header title: name/command echoing the sidebar card, shared by the
// xterm attach view (attach.ts) and the structured transcript view
// (structured.ts) so both panes read identically.
import { escapeHtml } from "./html.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { SessionView, Uuid } from "./types.js";
/** What a pane header shows about its session. Falls back to a short session
* id when the session isn't known yet. */
export interface AttachInfo {
name: string;
command: string;
/** Which connection kind to mount (#140). Absent/`"Terminal"` mounts the
* xterm attach; `"Structured"` mounts the transcript view. */
view?: SessionView;
}
function commandFragment(command: string): string {
const { tool, program } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? "" : program;
return `<span class="at-cmd">${icon ?? ""}${text ? escapeHtml(text) : ""}</span>`;
}
export function paneTitleHtml(sessionId: Uuid, info?: AttachInfo): string {
return info
? `<span class="at-name">${escapeHtml(info.name)}</span>` +
(info.command && info.command !== info.name ? commandFragment(info.command) : "")
: `session <span class="sid">${escapeHtml(sessionId.slice(0, 8))}</span>`;
}
/** Attribute-safe (`title="..."`) form of the same identity. */
export function paneTitleAttr(sessionId: Uuid, info?: AttachInfo): string {
return escapeHtml(info ? `${info.name} · ${info.command} (${sessionId})` : sessionId);
}
+25
View File
@@ -37,6 +37,27 @@ export function sessionFocus(id: Uuid): string {
return `${session(id)}/focus`;
}
/** `WS /api/structured/:id` — transcript/state snapshots and live deltas. */
export function structured(id: Uuid): string {
return `${API_PREFIX}/structured/${encodeURIComponent(id)}`;
}
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
export function structuredPrompt(id: Uuid): string {
return `${session(id)}/structured/prompt`;
}
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
export function structuredCancel(id: Uuid): string {
return `${session(id)}/structured/cancel`;
}
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a permission
* request. */
export function structuredApproval(id: Uuid, nonce: string): string {
return `${session(id)}/structured/approvals/${encodeURIComponent(nonce)}`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
@@ -55,3 +76,7 @@ export function eventsWsUrl(): string {
export function attachWsUrl(id: Uuid): string {
return `${wsBase()}${attach(id)}`;
}
export function structuredWsUrl(id: Uuid): string {
return `${wsBase()}${structured(id)}`;
}
+737
View File
@@ -0,0 +1,737 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./api.js", async () => {
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
return {
...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
};
});
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js";
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
import { CatalogStore } from "./events.js";
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
import type { ReducedState, Session, StructuredEvent, TranscriptRow } from "./types.js";
/** Just enough of a WebSocket for the connection to drive (mirrors
* events.test.ts's FakeSocket, adapted for JSON structured events). */
class FakeSocket {
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
closed = false;
open(): void {
this.onopen?.();
}
emit(event: StructuredEvent): void {
this.onmessage?.({ data: JSON.stringify(event) });
}
drop(code = 1006, reason = ""): void {
this.onclose?.({ code, reason });
}
close(): void {
this.closed = true;
}
}
function row(id: string, kind: TranscriptRow["kind"], data: unknown): TranscriptRow {
return { id, kind, data };
}
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
const allApprovalOptions = [
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
{ optionId: "r1", name: "Reject once", kind: "reject_once" as const },
];
function mount(opts: {
sockets: FakeSocket[];
shouldReconnect?: (id: string, code: number) => boolean;
store?: CatalogStore;
}) {
let i = 0;
const factory: StructuredSocketFactory = () => opts.sockets[i++] as unknown as WebSocket;
const conn = createStructuredConnection(
"s1",
{ shouldReconnect: opts.shouldReconnect },
{ name: "s1", command: "claude", view: "Structured" },
opts.store,
factory,
);
document.body.appendChild(conn.el);
return conn;
}
describe("structured connection: data flow", () => {
beforeEach(() => vi.clearAllMocks());
it("renders every row from the initial snapshot", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: {
cursor: 2,
rows: [
row("user:1", "user", { content: { type: "text", text: "hello" } }),
row("agent:1", "agent_text", { content: { type: "text", text: "hi there" } }),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.textContent).toContain("hello");
expect(conn.el.textContent).toContain("hi there");
conn.dispose();
});
it("appends and patches rows via projection_update without duplicating them", () => {
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 });
ws.emit({
type: "projection_update",
transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) },
state: idleState,
});
ws.emit({
type: "projection_update",
transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) },
state: idleState,
});
const rows = conn.el.querySelectorAll(".st-row-agent");
expect(rows).toHaveLength(1);
expect(conn.el.textContent).toContain("partial done");
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
conn.dispose();
});
});
describe("structured connection: turn/composer gating", () => {
beforeEach(() => vi.clearAllMocks());
it("disables the composer while a turn is active and re-enables when idle", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
expect(input.disabled).toBe(true);
expect(cancel.hidden).toBe(false);
ws.emit({ type: "reduced_state", state: idleState });
expect(input.disabled).toBe(false);
expect(cancel.hidden).toBe(true);
conn.dispose();
});
it("sends a prompt on Enter, clears the draft, and restores it on failure", async () => {
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 = "do the thing";
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";
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
await Promise.resolve();
await Promise.resolve();
expect(input.value).toBe("retry me");
expect(conn.el.textContent).toContain("boom");
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] });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: true, active_tool_calls: [] } } });
(conn.el.querySelector(".structured-cancel") as HTMLButtonElement).click();
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
conn.dispose();
});
});
describe("structured connection: approvals", () => {
beforeEach(() => vi.clearAllMocks());
it("shows decision buttons only for the currently pending approval, and answers with its nonce", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({
type: "projection_update",
transcript: {
type: "append",
row: row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions }),
},
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
expect(buttons).toHaveLength(3);
buttons[1].click(); // "Allow for session"
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_for_session");
conn.dispose();
});
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions })] },
});
ws.emit({
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
expect(conn.el.querySelectorAll(".st-approval-acts button")).toHaveLength(3);
// Resolved on another client: state clears with no transcript delta at all.
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
conn.dispose();
});
it("does not act on a stale click after the approval already moved on", () => {
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
});
ws.emit({
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
// Approval is superseded before the click is handled (buttons already
// disabled by the click handler is not the path under test here — this
// simulates the row having already flipped to "answered" underneath it).
ws.emit({ type: "reduced_state", state: idleState });
buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(resolveStructuredApproval).not.toHaveBeenCalled();
conn.dispose();
});
it("restores the buttons and surfaces an error when the POST rejects while still pending", async () => {
vi.mocked(resolveStructuredApproval).mockRejectedValueOnce(new Error("network down"));
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
});
ws.emit({
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
expect(buttons[0].disabled).toBe(true);
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].disabled).toBe(false);
expect(conn.el.textContent).toContain("network down");
conn.dispose();
});
it("does not resurrect buttons or show an error for a decision the approval already moved past", async () => {
let reject!: (e: unknown) => void;
vi.mocked(resolveStructuredApproval).mockImplementationOnce(
() => new Promise((_, r) => { reject = r; }),
);
const ws = new FakeSocket();
const conn = mount({ sockets: [ws] });
ws.open();
ws.emit({
type: "transcript_snapshot",
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
});
ws.emit({
type: "reduced_state",
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_once");
// Superseded by a new approval before the in-flight POST settles.
ws.emit({
type: "projection_update",
transcript: { type: "append", row: row("approval:2", "approval", { toolCall: {}, options: allApprovalOptions }) },
state: { ...idleState, pending_approval: { nonce: "n2", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
});
reject(new Error("too late"));
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
expect(conn.el.textContent).not.toContain("too late");
const newButtons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
expect(newButtons).toHaveLength(3);
expect(newButtons[0].disabled).toBe(false);
conn.dispose();
});
});
describe("structured connection: reconnection/error/loading", () => {
beforeEach(() => {
vi.clearAllMocks();
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();
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
const state = () => (conn.el.querySelector(".attach-state") as HTMLElement).dataset.state;
expect(state()).toBe("connecting");
first.open();
expect(state()).toBe("open");
first.emit({
type: "transcript_snapshot",
snapshot: { cursor: 0, rows: [row("user:1", "user", { content: { type: "text", text: "before" } })] },
});
expect(conn.el.textContent).toContain("before");
first.drop(1006, "");
expect(state()).toBe("connecting"); // "reconnecting…"
vi.advanceTimersByTime(1000);
second.open();
expect(state()).toBe("open");
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
expect(conn.el.textContent).not.toContain("before"); // rebuilt wholesale, not merged
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 });
only.open();
only.drop(4004, "session is not a live structured session");
const stateEl = conn.el.querySelector(".attach-state") as HTMLElement;
expect(stateEl.dataset.state).toBe("error");
expect(stateEl.textContent).toBe("session is not a live structured session");
vi.advanceTimersByTime(60_000);
// No second socket was ever asked for — only one factory call happened.
conn.dispose();
});
});
describe("structured connection: pane lifecycle", () => {
beforeEach(() => vi.clearAllMocks());
it("sendInput is a no-op — there is no PTY behind a structured session", () => {
const conn = mount({ sockets: [new FakeSocket()] });
expect(() => conn.sendInput("\x0b")).not.toThrow();
conn.dispose();
});
it("carries the same off-canvas sessions button as the terminal attach header", () => {
const conn = mount({ sockets: [new FakeSocket()] });
const btn = conn.el.querySelector(".sessions-btn") as HTMLButtonElement;
expect(btn).not.toBeNull();
expect(btn.getAttribute("aria-label")).toBe("Show sessions");
expect(btn.type).toBe("button");
let toggled = 0;
const onToggle = () => toggled++;
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
btn.click();
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
expect(toggled).toBe(1);
conn.dispose();
});
it("focusTerminal focuses the composer only when it is enabled", () => {
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: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
conn.focusTerminal();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
ws.emit({ type: "reduced_state", state: idleState });
conn.focusTerminal();
expect(document.activeElement).toBe(input);
conn.dispose();
});
it("dispose closes the socket and unsubscribes from the store", () => {
const store = new CatalogStore({ socket: () => new FakeSocket() as unknown as WebSocket });
const unsubscribe = vi.spyOn(store, "subscribe");
const ws = new FakeSocket();
const conn = mount({ sockets: [ws], store });
expect(unsubscribe).toHaveBeenCalled();
conn.dispose();
expect(ws.closed).toBe(true);
});
it("reflects live worktree/blocked-work data from the store in the rail", () => {
const catalogWs = new FakeSocket();
const store = new CatalogStore({ socket: () => catalogWs as unknown as WebSocket });
store.start();
const session: Session = {
id: "s1",
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
blocked_work: { uncommitted_files: 1, untracked_files: 0, unpushed_commits: 2 },
};
catalogWs.emit({ type: "Snapshot", sessions: [session] } as never);
const ws = new FakeSocket();
const conn = mount({ sockets: [ws], store });
ws.open();
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
ws.emit({ type: "reduced_state", state: idleState });
expect(conn.el.textContent).toContain("feat/x");
conn.dispose();
});
});
describe("structured connection: deferred mount focus", () => {
let frames: FrameRequestCallback[];
beforeEach(() => {
vi.clearAllMocks();
frames = [];
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
frames.push(cb);
return frames.length;
});
});
afterEach(() => vi.unstubAllGlobals());
function runFrames(): void {
const pending = frames;
frames = [];
for (const cb of pending) cb(0);
}
it("does not focus the composer if the pane was parked before its deferred frame ran", () => {
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 });
conn.onMount({ focus: true });
conn.onUnmount(); // parked again before the frame below runs
runFrames();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
conn.dispose();
});
it("does not focus the composer if it was re-mounted into a different pane before the stale frame ran", () => {
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 });
conn.onMount({ focus: true }); // stale frame #1
conn.onUnmount();
conn.onMount({ focus: false }); // the pane that actually won focus, without it
runFrames(); // both frames fire; only the latest should have any say
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).not.toBe(input);
conn.dispose();
});
it("still focuses normally when nothing raced the frame", () => {
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 });
conn.onMount({ focus: true });
runFrames();
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
expect(document.activeElement).toBe(input);
conn.dispose();
});
});
+483
View File
@@ -0,0 +1,483 @@
// A single live structured session: a transcript + rail bound to the
// daemon's `WS /api/structured/:id` (#140). Mirrors attach.ts's contract —
// decoupled from the UI, streaming whether or not mounted, torn down only by
// `dispose()` — but the wire is JSON rows, not PTY bytes, and every connect
// (fresh or reconnect) resends the *whole* transcript rather than resuming a
// cursor (docs/STRUCTURED-VIEW.md), so a snapshot always replaces state
// wholesale instead of being merged into what came before.
import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
structuredWsUrl,
} from "./api.js";
import { requestToggleDrawer } from "./drawer.js";
import type { CatalogState, CatalogStore } from "./events.js";
import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js";
import type { PaneConnection } from "./pane.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { buildTranscriptRow, renderRail } from "./structuredRender.js";
import type {
AcpPlan,
AcpUsage,
ReducedState,
Session,
StructuredApprovalDecision,
StructuredEvent,
TranscriptDelta,
TranscriptRow,
TranscriptRowKind,
Uuid,
} from "./types.js";
export interface StructuredOptions {
onRequestClose?: (sessionId: Uuid) => void;
onClosed?: (sessionId: Uuid) => void;
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
}
/** Injectable so tests can drive the socket without a daemon — the same seam
* `CatalogStore` uses (events.ts). */
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
const idleTurn = (): ReducedState["turn"] => ({
responding: false,
thinking: false,
active_tool_calls: [],
});
export function createStructuredConnection(
sessionId: Uuid,
opts: StructuredOptions = {},
info?: AttachInfo,
store?: CatalogStore,
socketFactory: StructuredSocketFactory = (id) => new WebSocket(structuredWsUrl(id)),
): PaneConnection {
const el = document.createElement("div");
el.className = "attach structured";
el.innerHTML = `
<header class="attach-header">
<button type="button" class="btn btn-ghost btn-icon sessions-btn" aria-label="Show sessions" title="Show sessions">☰</button>
<span class="attach-title" title="${paneTitleAttr(sessionId, info)}">${paneTitleHtml(sessionId, info)}</span>
<span class="st-pills"></span>
<span class="attach-state" data-state="connecting">connecting…</span>
<button class="btn btn-ghost btn-icon disconnect" type="button" aria-label="Disconnect" title="Disconnect this session">⏻</button>
</header>
<div class="structured-body">
<div class="structured-scroll">
<div class="structured-rows"></div>
<form class="structured-composer">
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"></textarea>
<button type="submit" class="btn btn-primary structured-send">Send</button>
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
</form>
</div>
<aside class="structured-rail"></aside>
</div>
`;
const stateEl = el.querySelector(".attach-state") as HTMLSpanElement;
const pillsEl = el.querySelector(".st-pills") as HTMLSpanElement;
const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement;
const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement;
const railEl = el.querySelector(".structured-rail") as HTMLElement;
const bodyEl = el.querySelector(".structured-body") as HTMLElement;
const composerForm = el.querySelector(".structured-composer") as HTMLFormElement;
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
// view): the session list is an off-canvas drawer and this opens it —
// same control attach.ts's terminal header carries.
(el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () =>
requestToggleDrawer(),
);
const setConnState = (text: string, kind: "connecting" | "open" | "closed" | "error") => {
stateEl.textContent = text;
stateEl.dataset.state = kind;
};
// ---- transcript state: mirrors the daemon's own Projection shape (id ->
// index, patched in place) so a Patch never needs to search the array. ----
let rows: TranscriptRow[] = [];
const rowIndex = new Map<string, number>();
const rowEls = new Map<string, HTMLElement>();
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;
return null;
}
function approvalStateFor(row: TranscriptRow) {
if (row.kind !== "approval") return undefined;
return {
pending: row.id === pendingApprovalRowId,
onDecide: (decision: StructuredApprovalDecision) => decide(row.id, decision),
};
}
async function decide(rowId: string, decision: StructuredApprovalDecision): Promise<void> {
const nonce = state.pending_approval?.nonce;
// Already resolved elsewhere between the click and here — the row's
// buttons should already be gone, but a stale nonce is a no-op, not an
// error (docs/STRUCTURED-VIEW.md: any nonce answers 204). Resolve
// quietly: there is nothing for the row to restore.
if (!nonce || rowId !== pendingApprovalRowId) return;
try {
await resolveStructuredApproval(sessionId, nonce, decision);
} catch (e: unknown) {
// Only propagate the failure — so the row re-enables its buttons — if
// this is still the pending approval. If it moved on while the
// 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 (!disposed && rowId === pendingApprovalRowId) {
showError(String(e));
throw e;
}
}
}
function mountRow(row: TranscriptRow): void {
const el = buildTranscriptRow(row, approvalStateFor(row));
rowEls.set(row.id, el);
rowsEl.appendChild(el);
}
function remountRow(row: TranscriptRow): void {
const old = rowEls.get(row.id);
const el = buildTranscriptRow(row, approvalStateFor(row));
rowEls.set(row.id, el);
if (old) old.replaceWith(el);
else rowsEl.appendChild(el);
}
function applySnapshot(snapshot: { rows: TranscriptRow[] }): void {
rows = [...snapshot.rows];
rowIndex.clear();
rows.forEach((row, i) => rowIndex.set(row.id, i));
rowEls.clear();
rowsEl.replaceChildren();
for (const row of rows) mountRow(row);
}
function applyDelta(delta: TranscriptDelta | null | undefined): void {
if (!delta) return;
if (delta.type === "append") {
rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row);
mountRow(delta.row);
} else if (delta.type === "patch") {
const i = rowIndex.get(delta.row.id);
if (i === undefined) {
rowIndex.set(delta.row.id, rows.length);
rows.push(delta.row);
mountRow(delta.row);
} else {
rows[i] = delta.row;
remountRow(delta.row);
}
} else {
const i = rowIndex.get(delta.id);
if (i === undefined) return;
rows.splice(i, 1);
rowIndex.clear();
rows.forEach((row, idx) => rowIndex.set(row.id, idx));
rowEls.get(delta.id)?.remove();
rowEls.delete(delta.id);
}
}
function toolRowCount(): number {
return rows.reduce((n, r) => n + (r.kind === "tool_call" || r.kind === "tool_diff" ? 1 : 0), 0);
}
function renderPills(): void {
const parts: string[] = [];
if (state.model) parts.push(state.model);
if (state.mode) parts.push(state.mode);
pillsEl.textContent = parts.join(" · ");
}
function renderRailNow(): void {
renderRail(railEl, {
plan: (state.plan ?? null) as AcpPlan | null,
turn: state.turn,
toolRowCount: toolRowCount(),
usage: (state.usage ?? null) as AcpUsage | null,
session: latestSession,
});
}
function renderComposerState(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
input.disabled = active;
sendBtn.disabled = active;
cancelBtn.hidden = !state.turn.responding && !state.turn.thinking;
}
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;
pendingApprovalRowId = newPendingId;
if (prev !== null && rowIndex.has(prev)) remountRow(rows[rowIndex.get(prev)!]);
if (newPendingId !== null) remountRow(rows[rowIndex.get(newPendingId)!]);
}
renderPills();
renderRailNow();
renderComposerState();
}
// ---- error banner ----
let errorEl: HTMLElement | null = null;
function showError(message: string): void {
if (!errorEl) {
errorEl = document.createElement("div");
errorEl.className = "structured-error";
composerForm.insertAdjacentElement("beforebegin", errorEl);
}
errorEl.textContent = message;
}
function clearError(): void {
errorEl?.remove();
errorEl = null;
}
// ---- composer ----
function autoResize(): void {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
}
input.addEventListener("input", autoResize);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
composerForm.requestSubmit();
}
});
composerForm.addEventListener("submit", (e) => {
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();
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) => {
if (!disposed) showError(String(e));
});
});
// ---- narrow-pane rail collapse (a split pane, not just the viewport) ----
const NARROW_BREAKPOINT = 560;
const resizeObserver = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? bodyEl.clientWidth;
bodyEl.classList.toggle("st-narrow", width < NARROW_BREAKPOINT);
});
resizeObserver.observe(bodyEl);
// ---- 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 | null = null;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
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 {
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");
};
socket.onmessage = (ev) => {
if (disposed || ws !== socket) return;
if (typeof ev.data !== "string") return;
let msg: StructuredEvent;
try {
msg = JSON.parse(ev.data) as StructuredEvent;
} catch {
return; // not a structured event we understand
}
switch (msg.type) {
case "transcript_snapshot":
applySnapshot(msg.snapshot);
break;
case "reduced_state":
applyState(msg.state, connectionGeneration);
break;
case "projection_update":
applyDelta(msg.transcript);
applyState(msg.state, connectionGeneration);
break;
}
};
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) {
setConnState(reason, "error");
showError(`cannot attach: ${reason}`);
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);
return;
}
setConnState(`closed (${ev.code})`, "closed");
opts.onClosed?.(sessionId);
};
}
connect();
// A pane can be parked again before its deferred focus frame fires (a
// preview stepped past it, a split closed it) — the frame must not focus a
// hidden textarea out from under whatever the operator moved to since.
// `mountToken` is bumped on every mount/unmount so a stale frame can tell
// it is no longer the most recent one.
let mountToken = 0;
return {
sessionId,
el,
onMount(mountOpts?: { focus?: boolean }) {
const focusOnMount = mountOpts?.focus !== false;
const token = ++mountToken;
requestAnimationFrame(() => {
if (disposed || token !== mountToken) return; // parked/remounted since this was scheduled
if (focusOnMount && !input.disabled) input.focus();
});
},
onUnmount() {
mountToken++;
},
focusTerminal() {
if (!input.disabled) input.focus();
},
sendInput() {
// No PTY behind a structured session — the leader's literal-byte
// escape hatch has nothing to do here.
},
dispose() {
if (disposed) return;
disposed = true;
mountToken++;
cancelReconnect();
resizeObserver.disconnect();
unsubscribeStore?.();
closeCurrentSocket();
el.remove();
},
};
}
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { computeDiffLines, diffStats, type DiffLine } from "./structuredDiff.js";
function texts(lines: DiffLine[]): Array<[string, string]> {
return lines.map((l) => [l.kind, l.text]);
}
describe("computeDiffLines", () => {
it("renders a brand new file (no oldText) as all additions", () => {
const lines = computeDiffLines(null, "a\nb\n");
expect(texts(lines)).toEqual([
["add", "a"],
["add", "b"],
]);
});
it("renders a full deletion as all removals", () => {
const lines = computeDiffLines("a\nb\n", "");
expect(texts(lines)).toEqual([
["del", "a"],
["del", "b"],
]);
});
it("shows a small replace as context around del/add", () => {
const lines = computeDiffLines("one\ntwo\nthree\n", "one\ntwx\nthree\n");
expect(texts(lines)).toEqual([
["ctx", "one"],
["del", "two"],
["add", "twx"],
["ctx", "three"],
]);
});
it("leaves identical text as pure context, uncollapsed when short", () => {
const lines = computeDiffLines("a\nb\nc\n", "a\nb\nc\n");
expect(texts(lines)).toEqual([
["ctx", "a"],
["ctx", "b"],
["ctx", "c"],
]);
});
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");
const newText = [...common.slice(0, 10), "NEW", ...common.slice(10)].join("\n");
const lines = computeDiffLines(oldText, newText);
// The leading run keeps only its last 3 lines (adjacent to the change),
// behind a marker for the rest; the trailing run mirrors that.
expect(lines[0].kind).toBe("hunk");
expect(lines[1]).toEqual({ kind: "ctx", text: "line7" });
expect(lines[2]).toEqual({ kind: "ctx", text: "line8" });
expect(lines[3]).toEqual({ kind: "ctx", text: "line9" });
expect(lines[4].kind).toBe("del");
expect(lines[5].kind).toBe("add");
expect(lines[6]).toEqual({ kind: "ctx", text: "line10" });
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(2);
});
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.
const middle = Array.from({ length: 10 }, (_, i) => `c${i}`);
const oldText = ["lead0", "lead1", "OLD1", ...middle, "OLD2", "trail0", "trail1"].join("\n");
const newText = ["lead0", "lead1", "NEW1", ...middle, "NEW2", "trail0", "trail1"].join("\n");
const lines = computeDiffLines(oldText, newText);
const dels = lines.filter((l) => l.kind === "del").map((l) => l.text);
const adds = lines.filter((l) => l.kind === "add").map((l) => l.text);
expect(dels).toEqual(["OLD1", "OLD2"]);
expect(adds).toEqual(["NEW1", "NEW2"]);
expect(lines.filter((l) => l.kind === "hunk")).toHaveLength(1);
expect(texts(lines).slice(0, 2)).toEqual([
["ctx", "lead0"],
["ctx", "lead1"],
]);
expect(texts(lines).slice(-2)).toEqual([
["ctx", "trail0"],
["ctx", "trail1"],
]);
});
it("handles an old file with no trailing newline", () => {
const lines = computeDiffLines("a\nb", "a\nb\nc");
expect(texts(lines)).toEqual([
["ctx", "a"],
["ctx", "b"],
["add", "c"],
]);
});
});
describe("diffStats", () => {
it("counts add/del lines, ignoring context and hunk markers", () => {
const lines = computeDiffLines("a\nb\nc\n", "a\nx\ny\nc\n");
expect(diffStats(lines)).toEqual({ added: 2, removed: 1 });
});
it("is zero for identical text", () => {
expect(diffStats(computeDiffLines("same\n", "same\n"))).toEqual({ added: 0, removed: 0 });
});
});
+141
View File
@@ -0,0 +1,141 @@
// Line-level diff for a `ToolDiff` row's `Diff` content block (#140). ACP
// 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 interface DiffLine {
kind: DiffLineKind;
/** The line's text (a `hunk` line is a human summary, not source text). */
text: string;
}
/** Interior unchanged runs longer than this collapse to a summary line
* flanked by this many lines of real context on each side. */
const CONTEXT = 3;
/** Bounds the O(n·m) LCS table: above this cell count, the changed middle is
* shown as one coarse del-then-add block instead of a fine-grained diff —
* 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");
}
type Op = { kind: "add" | "del" | "ctx"; text: string };
/** 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[] {
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 * m > LCS_CELL_LIMIT) {
return [...a.map((text) => ({ kind: "del" as const, text })), ...b.map((text) => ({ kind: "add" as const, text }))];
}
// 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));
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]);
}
}
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] });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
ops.push({ kind: "del", text: a[i] });
i++;
} else {
ops.push({ kind: "add", text: b[j] });
j++;
}
}
while (i < n) ops.push({ kind: "del", text: a[i++] });
while (j < m) ops.push({ kind: "add", text: b[j++] });
return ops;
}
function diffLines(a: string[], b: string[]): Op[] {
let pre = 0;
while (pre < a.length && pre < b.length && 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]
) {
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 middle = lcsDiff(a.slice(pre, a.length - suf), b.slice(pre, b.length - suf));
return [...prefixCtx, ...middle, ...suffixCtx];
}
/** Collapse a long run of unchanged lines to a summary, keeping up to
* {@link CONTEXT} real lines adjacent to each neighbouring change — same
* shape a unified diff gives a reader, without claiming line numbers this
* module never tracked. A run touching the start/end of the whole diff has
* no change to be adjacent to on that side, so it keeps no context there
* (unless it is the *only* run, i.e. nothing changed at all). */
function hunk(ops: Op[]): DiffLine[] {
const out: DiffLine[] = [];
let i = 0;
while (i < ops.length) {
if (ops[i].kind !== "ctx") {
out.push(ops[i]);
i++;
continue;
}
let j = i;
while (j < ops.length && ops[j].kind === "ctx") j++;
const run = ops.slice(i, j);
const wholeDiff = i === 0 && j === ops.length;
const keepHead = wholeDiff || i > 0 ? CONTEXT : 0;
const keepTail = wholeDiff || j < ops.length ? CONTEXT : 0;
if (run.length <= keepHead + keepTail) {
out.push(...run);
} else {
if (keepHead) out.push(...run.slice(0, keepHead));
out.push({
kind: "hunk",
text: `⋯ ${run.length - keepHead - keepTail} unchanged lines ⋯`,
});
if (keepTail) out.push(...run.slice(run.length - keepTail));
}
i = j;
}
return out;
}
/** Coloured, hunked diff lines for a `Diff` content block. `oldText` absent
* (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 }));
const ops = diffLines(splitLines(oldText), newLines);
return hunk(ops);
}
/** `+added -removed` counts straight off the diff — real derived data, not a
* fabricated duration or line count. */
export function diffStats(lines: DiffLine[]): { added: number; removed: number } {
let added = 0;
let removed = 0;
for (const line of lines) {
if (line.kind === "add") added++;
else if (line.kind === "del") removed++;
}
return { added, removed };
}
+368
View File
@@ -0,0 +1,368 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import {
branchFromWorktreePath,
buildTranscriptRow,
renderInlineMarkdown,
renderRail,
} from "./structuredRender.js";
import type { Session, TranscriptRow } from "./types.js";
function row(kind: TranscriptRow["kind"], data: unknown, id = "r1"): TranscriptRow {
return { id, kind, data };
}
describe("buildTranscriptRow: user/agent/thought", () => {
it("renders a user row with the text content and no markdown", () => {
const el = buildTranscriptRow(
row("user", { content: { type: "text", text: "hi <b>there</b>" } }),
);
expect(el.className).toContain("st-row-user");
expect(el.textContent).toContain("hi <b>there</b>");
// never actual markup from user input
expect(el.querySelector("b")).toBeNull();
});
it("renders agent prose as paragraphs with inline markdown", () => {
const el = buildTranscriptRow(
row("agent_text", { content: { type: "text", text: "one **two** `three`\n\nfour" } }),
);
expect(el.className).toContain("st-row-agent");
const ps = el.querySelectorAll("p");
expect(ps).toHaveLength(2);
expect(ps[0].innerHTML).toContain("<strong>two</strong>");
expect(ps[0].innerHTML).toContain("<code>three</code>");
});
it("renders a thought row as plain text", () => {
const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } }));
expect(el.className).toContain("st-row-thought");
expect(el.textContent).toBe("hmm");
});
it("falls back to a labelled placeholder for non-text content, never crashing", () => {
const el = buildTranscriptRow(row("user", { content: { type: "image" } }));
expect(el.textContent).toContain("[image]");
});
it("never clips text: a very long user message is not truncated", () => {
const long = "x".repeat(5000);
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
expect(el.textContent).toContain(long);
});
});
describe("renderInlineMarkdown", () => {
it("escapes raw HTML before applying markdown", () => {
expect(renderInlineMarkdown("<script>alert(1)</script>")).not.toContain("<script>");
});
it("does not treat a single asterisk pair meant as bold as nested italics", () => {
expect(renderInlineMarkdown("**bold**")).toBe("<strong>bold</strong>");
});
});
describe("buildTranscriptRow: tool calls", () => {
it("shows glyph, name, argument and no meta for a completed non-diff call", () => {
const el = buildTranscriptRow(
row("tool_call", {
toolCallId: "t1",
title: "Read(src/lib.rs)",
kind: "read",
status: "completed",
}),
);
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Read");
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("src/lib.rs");
expect(head.querySelector(".st-tool-meta")).toBeNull();
});
it("falls back to the kind label and full title when the title has no Name(arg) shape", () => {
const el = buildTranscriptRow(row("tool_call", { title: "does something odd", kind: "execute" }));
const head = el.querySelector(".st-tool-head")!;
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Execute");
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("does something odd");
});
it("shows a running badge for an in-progress call", () => {
const el = buildTranscriptRow(row("tool_call", { title: "Bash(sleep 1)", status: "in_progress" }));
const meta = el.querySelector(".st-tool-meta")!;
expect(meta.textContent).toBe("running");
expect(meta.className).toContain("st-tool-meta-running");
});
it("shows a failed badge for a failed call", () => {
const el = buildTranscriptRow(row("tool_call", { title: "Bash(false)", status: "failed" }));
expect(el.querySelector(".st-tool-meta")!.textContent).toBe("failed");
});
it("starts collapsed and expands its text output on click", () => {
const el = buildTranscriptRow(
row("tool_call", {
title: "Grep(x)",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "match found" } }],
}),
);
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
const out = el.querySelector(".st-tool-out") as HTMLElement;
expect(out.hidden).toBe(true);
expect(head.getAttribute("aria-expanded")).toBe("false");
head.click();
expect(out.hidden).toBe(false);
expect(head.getAttribute("aria-expanded")).toBe("true");
expect(out.textContent).toContain("match found");
head.click();
expect(out.hidden).toBe(true);
});
it("does not expand when there is nothing to show", () => {
const el = buildTranscriptRow(row("tool_call", { title: "Read(x)", status: "completed" }));
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
head.click();
expect(head.getAttribute("aria-expanded")).toBe("false");
});
});
describe("buildTranscriptRow: tool diffs", () => {
it("shows +added -removed derived from the diff, and renders coloured hunks on expand", () => {
const el = buildTranscriptRow(
row("tool_diff", {
title: "Edit(x.rs)",
status: "completed",
content: [{ type: "diff", path: "/repo/x.rs", oldText: "a\nb\n", newText: "a\nc\n" }],
}),
);
const meta = el.querySelector(".st-tool-meta")!;
expect(meta.textContent).toContain("+1");
expect(meta.textContent).toContain("−1");
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
const diff = el.querySelector(".st-diff") as HTMLElement;
expect(diff).not.toBeNull();
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
});
it("gives the diff its own horizontally-scrolling container", () => {
const el = buildTranscriptRow(
row("tool_diff", {
title: "Edit(x.rs)",
content: [{ type: "diff", path: "/repo/x.rs", newText: "new file\n" }],
}),
);
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the
// stylesheet keys `overflow-x: auto` off, not a computed style.
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
});
});
describe("buildTranscriptRow: approval", () => {
const approvalRow = (options: Array<{ optionId: string; name: string; kind: string }>) =>
row("approval", {
sessionId: "s1",
toolCall: { toolCallId: "t1", title: "Write outside worktree", locations: [{ path: "/etc/x" }] },
options,
});
const allThreeOptions = [
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
];
it("shows the three decision buttons only when the request offers all three option kinds", () => {
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
pending: true,
onDecide: () => Promise.resolve(),
});
const buttons = el.querySelectorAll(".st-approval-acts button");
expect(buttons).toHaveLength(3);
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
});
it("hides the buttons and shows an answered note once resolved", () => {
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
pending: false,
onDecide: () => Promise.resolve(),
});
expect(el.querySelector(".st-approval-acts")).toBeNull();
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
});
it("invokes onDecide with the clicked decision and disables the buttons", () => {
const onDecide = vi.fn().mockResolvedValue(undefined);
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[2].click();
expect(onDecide).toHaveBeenCalledWith("deny");
expect(buttons[0].disabled).toBe(true);
});
it("treats a row with no approval state as not pending, defensively", () => {
const el = buildTranscriptRow(approvalRow(allThreeOptions));
expect(el.querySelector(".st-approval-acts")).toBeNull();
});
it("omits Allow for session when the request offers no allow_always option", () => {
const el = buildTranscriptRow(
approvalRow([
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
]),
{ pending: true, onDecide: () => Promise.resolve() },
);
const labels = [...el.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent);
expect(labels).toEqual(["Allow once", "Deny"]);
});
it("offers Deny for either reject_once or reject_always, never for neither", () => {
const rejectAlwaysOnly = buildTranscriptRow(
approvalRow([{ optionId: "r2", name: "Reject always", kind: "reject_always" }]),
{ pending: true, onDecide: () => Promise.resolve() },
);
expect([...rejectAlwaysOnly.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent)).toEqual([
"Deny",
]);
});
it("shows a 'no decision offered' note instead of an empty action row when nothing matches", () => {
const el = buildTranscriptRow(approvalRow([{ optionId: "x", name: "Something else", kind: "allow_once" }]), {
pending: true,
onDecide: () => Promise.resolve(),
});
// allow_once is offered, so this specific case still shows a button —
// use a kind the client doesn't map to any decision at all.
expect(el.querySelectorAll(".st-approval-acts button")).toHaveLength(1);
const none = buildTranscriptRow(approvalRow([]), { pending: true, onDecide: () => Promise.resolve() });
expect(none.querySelector(".st-approval-acts")).toBeNull();
expect(none.querySelector(".st-approval-resolved")!.textContent).toBe("no decision offered");
});
it("re-enables the buttons when onDecide rejects", async () => {
const onDecide = vi.fn().mockRejectedValue(new Error("network down"));
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
expect(buttons[0].disabled).toBe(true);
await Promise.resolve().then(() => Promise.resolve());
expect(buttons[0].disabled).toBe(false);
expect(buttons[1].disabled).toBe(false);
});
it("leaves the buttons disabled when onDecide resolves", async () => {
const onDecide = vi.fn().mockResolvedValue(undefined);
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
buttons[0].click();
await Promise.resolve().then(() => Promise.resolve());
expect(buttons[0].disabled).toBe(true);
});
});
describe("buildTranscriptRow: notice / unknown kinds", () => {
it("renders a plan or notice row without crashing", () => {
expect(() => buildTranscriptRow(row("notice", { sessionUpdate: "future" }))).not.toThrow();
expect(() => buildTranscriptRow(row("plan", { entries: [] }))).not.toThrow();
});
});
describe("branchFromWorktreePath", () => {
it("takes everything after the last /worktrees/ segment", () => {
expect(branchFromWorktreePath("/home/n/tea/maestro/worktrees/acp-bridge")).toBe("acp-bridge");
});
it("keeps slashes in a branch name that contains them", () => {
expect(branchFromWorktreePath("/repo/worktrees/feature/login")).toBe("feature/login");
});
it("falls back to the basename when there is no /worktrees/ marker", () => {
expect(branchFromWorktreePath("/some/other/path")).toBe("path");
});
});
function session(overrides: Partial<Session> = {}): Session {
return {
id: "s1",
cwd: "/repo",
command: "claude",
env: {},
metadata: {},
status: "Running",
created_at: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("renderRail", () => {
it("omits every section that has nothing to show", () => {
const el = document.createElement("div");
renderRail(el, {
plan: null,
turn: { responding: false, thinking: false, active_tool_calls: [] },
toolRowCount: 0,
usage: null,
session: session(),
});
// Turn always renders (it has a state and a count); the rest are absent.
expect(el.querySelectorAll("h3")).toHaveLength(1);
expect(el.querySelector("h3")!.textContent).toBe("Turn");
});
it("renders the plan with status marks", () => {
const el = document.createElement("div");
renderRail(el, {
plan: {
entries: [
{ content: "done thing", priority: "high", status: "completed" },
{ content: "now thing", priority: "high", status: "in_progress" },
{ content: "later thing", priority: "low", status: "pending" },
],
},
turn: { responding: false, thinking: false, active_tool_calls: [] },
toolRowCount: 0,
usage: null,
session: session(),
});
const items = el.querySelectorAll(".st-plan li");
expect(items).toHaveLength(3);
expect(items[0].className).toBe("st-plan-done");
expect(items[1].className).toBe("st-plan-now");
expect(items[2].className).toBe("st-plan-todo");
});
it("renders context usage and a meter proportional to used/size", () => {
const el = document.createElement("div");
renderRail(el, {
plan: null,
turn: { responding: true, thinking: false, active_tool_calls: ["a"] },
toolRowCount: 3,
usage: { used: 50_000, size: 200_000 },
session: session(),
});
expect(el.querySelector(".st-meter i")!.getAttribute("style")).toContain("25%");
});
it("renders the worktree section only when the session has one", () => {
const el = document.createElement("div");
renderRail(el, {
plan: null,
turn: { responding: false, thinking: false, active_tool_calls: [] },
toolRowCount: 0,
usage: null,
session: session({
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
blocked_work: { uncommitted_files: 2, untracked_files: 1, unpushed_commits: 3 },
}),
});
const headers = [...el.querySelectorAll("h3")].map((h) => h.textContent);
expect(headers).toContain("Worktree");
const rows = [...el.querySelectorAll(".st-kv-row")].map((r) => r.textContent);
expect(rows.some((t) => t?.includes("feat/x"))).toBe(true);
expect(rows.some((t) => t?.includes("3"))).toBe(true); // ahead
expect(rows.some((t) => t?.includes("dirty") && t.includes("3"))).toBe(true); // 2+1
});
});
+519
View File
@@ -0,0 +1,519 @@
// Pure per-row and rail DOM builders for the structured transcript (#140).
// No socket, no daemon calls — structured.ts owns the connection and calls
// into here with data already in hand, so every rendering decision here is
// unit-testable without a WebSocket.
import { escapeHtml } from "./html.js";
import { computeDiffLines, diffStats } from "./structuredDiff.js";
import type {
AcpContentBlock,
AcpPermissionOption,
AcpPlan,
AcpToolCallContent,
AcpToolCallLike,
AcpUsage,
InTheWay,
Session,
StructuredApprovalDecision,
TranscriptRow,
} from "./types.js";
// ---------------------------------------------------------------------------
// row taxonomy
// ---------------------------------------------------------------------------
/** Glyph shown in a tool row's head, by `ToolKind`. Unrecognised/absent kinds
* fall back to the generic bullet — never a blank glyph column. */
const KIND_GLYPH: Record<string, string> = {
read: "≡",
edit: "±",
delete: "✕",
move: "→",
search: "⌕",
execute: "$",
think: "…",
fetch: "⇣",
switch_mode: "⇄",
};
const KIND_LABEL: Record<string, string> = {
read: "Read",
edit: "Edit",
delete: "Delete",
move: "Move",
search: "Search",
execute: "Execute",
think: "Think",
fetch: "Fetch",
switch_mode: "Switch mode",
};
function toolGlyph(kind: string | undefined): string {
return (kind && KIND_GLYPH[kind]) || "•";
}
function toolKindLabel(kind: string | undefined): string {
return (kind && KIND_LABEL[kind]) || "Tool";
}
/** Split a title into `{label, arg}` when it follows the adapter's own
* `Name(argument)` convention; otherwise the whole title is the arg and the
* tool's `kind` supplies a generic label. Never guesses at a shape the data
* doesn't actually show. */
function splitToolTitle(
title: string | undefined,
name: string | null | undefined,
kind: string | undefined,
): { label: string; arg: string } {
const label = name ?? toolKindLabel(kind);
if (!title) return { label, arg: "" };
const m = /^(\S+)\(([\s\S]*)\)$/.exec(title);
if (m) return { label: name ?? m[1], arg: m[2] };
return { label, arg: title };
}
function el(tag: string, className?: string): HTMLElement {
const node = document.createElement(tag);
if (className) node.className = className;
return node;
}
/** Content-block text, or a labelled placeholder for anything that isn't
* text — never dropped silently, never fabricated. */
function contentBlockText(block: AcpContentBlock | undefined): string {
if (!block) return "";
switch (block.type) {
case "text":
return block.text;
case "image":
return "[image]";
case "audio":
return "[audio]";
case "resource_link":
return `[resource: ${block.name ?? block.uri ?? "linked"}]`;
case "resource":
return "[resource]";
default:
return "";
}
}
/** A tiny, dependency-free inline renderer: `**bold**`, `*italic*`, and
* `` `code` `` become real markup; everything else stays escaped plain
* text. No block-level markdown (lists, headings) — agent prose is
* paragraphs, not documents. */
export function renderInlineMarkdown(text: string): string {
const escaped = escapeHtml(text);
return escaped
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
}
function renderProse(container: HTMLElement, text: string): void {
const paragraphs = text.split(/\n{2,}/);
for (const para of paragraphs) {
if (!para) continue;
const p = document.createElement("p");
p.innerHTML = renderInlineMarkdown(para);
container.appendChild(p);
}
if (container.children.length === 0) container.textContent = "";
}
// ---------------------------------------------------------------------------
// row builders
// ---------------------------------------------------------------------------
function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement {
const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined;
const text = contentBlockText(data?.content);
const wrap = el("div", `st-row st-row-${variant}`);
const body = el("div", "st-body");
if (variant === "user") {
const who = el("div", "st-who");
who.textContent = "You";
body.appendChild(who);
const line = el("div", "st-user-text");
line.textContent = text;
body.appendChild(line);
} else if (variant === "agent") {
renderProse(body, text);
} else {
body.textContent = text;
}
wrap.appendChild(body);
return wrap;
}
function buildDiffBlock(diff: { path: string; oldText?: string | null; newText: string }): HTMLElement {
const wrap = el("div", "st-diff-block");
const label = el("div", "st-diff-path");
label.textContent = diff.path;
label.title = diff.path;
wrap.appendChild(label);
const scroller = el("div", "st-diff");
const pre = document.createElement("pre");
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
const span = document.createElement("span");
span.className = `st-ln st-ln-${line.kind}`;
span.textContent = line.text;
pre.appendChild(span);
pre.appendChild(document.createTextNode("\n"));
}
scroller.appendChild(pre);
wrap.appendChild(scroller);
return wrap;
}
/** `+added -removed` across every diff block in a tool call's content —
* real derived data, never a fabricated duration. */
function toolDiffMeta(content: AcpToolCallContent[]): string {
let added = 0;
let removed = 0;
for (const block of content) {
if (block.type !== "diff") continue;
const stats = diffStats(computeDiffLines(block.oldText, block.newText));
added += stats.added;
removed += stats.removed;
}
return `+${added} −${removed}`;
}
function statusMeta(status: string | undefined, hasDiff: boolean): { text: string; kind: string } | null {
if (status === "in_progress") return { text: "running", kind: "running" };
if (status === "failed") return { text: "failed", kind: "failed" };
if (status === "pending" && !hasDiff) return { text: "pending", kind: "pending" };
return null;
}
function buildToolRow(row: TranscriptRow): HTMLElement {
const tool = (row.data ?? {}) as AcpToolCallLike;
const content = tool.content ?? [];
const hasDiff = content.some((c) => c.type === "diff");
const { label, arg } = splitToolTitle(tool.title, tool.name, tool.kind);
const wrap = el("div", "st-row st-row-tool");
const card = el("div", "st-tool");
wrap.appendChild(card);
const head = document.createElement("button");
head.type = "button";
head.className = "st-tool-head";
head.setAttribute("aria-expanded", "false");
const glyph = el("span", "st-tool-glyph");
glyph.textContent = toolGlyph(tool.kind);
glyph.setAttribute("aria-hidden", "true");
const name = el("span", "st-tool-name");
name.textContent = label;
const argEl = el("span", "st-tool-arg");
argEl.textContent = arg;
argEl.title = arg;
head.append(glyph, name, argEl);
const metaParts: string[] = [];
if (hasDiff) metaParts.push(toolDiffMeta(content));
const meta = statusMeta(tool.status, hasDiff);
if (meta) metaParts.push(meta.text);
if (metaParts.length > 0) {
const metaEl = el("span", "st-tool-meta");
if (meta) metaEl.classList.add(`st-tool-meta-${meta.kind}`);
metaEl.textContent = metaParts.join(" · ");
head.appendChild(metaEl);
}
card.appendChild(head);
const out = el("div", "st-tool-out");
out.hidden = true;
let outBuilt = false;
const buildOut = () => {
if (outBuilt) return;
outBuilt = true;
const texts = content
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
.map((c) => contentBlockText(c.content))
.filter(Boolean);
if (texts.length > 0) {
const textPanel = el("div", "st-tool-text");
textPanel.textContent = texts.join("\n");
out.appendChild(textPanel);
}
for (const block of content) {
if (block.type === "diff") out.appendChild(buildDiffBlock(block));
if (block.type === "terminal") {
const note = el("div", "st-tool-text");
note.textContent = "[embedded terminal]";
out.appendChild(note);
}
}
if (out.children.length === 0) out.hidden = true;
};
card.appendChild(out);
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;
});
return wrap;
}
function buildPlainRow(row: TranscriptRow): HTMLElement {
const wrap = el("div", "st-row st-row-notice");
const body = el("div", "st-body");
body.textContent = `[${row.kind}]`;
wrap.appendChild(body);
return wrap;
}
export interface ApprovalRowState {
pending: boolean;
/** Resolves once the decision has been submitted; rejects when the POST
* failed and this approval is still the one awaiting a decision, so the
* caller knows to restore its controls. Never rejects for a stale click
* (the approval already moved on) — there is nothing to restore then. */
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
}
/** Which decisions a permission request actually backs, by the
* `PermissionOptionKind` values it offers (docs/STRUCTURED-VIEW.md's
* "Decision mapping"). A decision with no matching option resolves
* `Cancelled` server-side regardless of what the client sent — offering its
* button would let the operator "allow" something that silently no-ops. */
function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDecision[] {
const kinds = new Set(options.map((o) => o.kind));
const decisions: StructuredApprovalDecision[] = [];
if (kinds.has("allow_once")) decisions.push("allow_once");
if (kinds.has("allow_always")) decisions.push("allow_for_session");
if (kinds.has("reject_once") || kinds.has("reject_always")) decisions.push("deny");
return decisions;
}
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
const toolCall = request.toolCall ?? {};
const wrap = el("div", "st-row st-row-approval");
const card = el("div", "st-approval");
wrap.appendChild(card);
const title = el("div", "st-approval-title");
title.innerHTML = `<span aria-hidden="true">⚠</span> permission requested`;
card.appendChild(title);
const what = el("div", "st-approval-what");
what.textContent = toolCall.title ?? toolKindLabel(toolCall.kind);
card.appendChild(what);
const path = toolCall.locations?.[0]?.path;
if (path) {
const pathEl = el("div", "st-approval-path");
pathEl.textContent = path;
card.appendChild(pathEl);
}
if (approval.pending) {
const acts = el("div", "st-approval-acts");
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = cls ? `act ${cls}` : "act";
btn.textContent = label;
btn.addEventListener("click", () => {
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
buttons.forEach((b) => (b.disabled = true));
approval.onDecide(decision).catch(() => {
// Rejects only when this approval is still pending (see
// ApprovalRowState) — restore the controls so the operator can
// retry; the error itself is surfaced by the connection.
buttons.forEach((b) => (b.disabled = false));
});
});
return btn;
};
const offered = offeredDecisions(request.options ?? []);
const specs: [StructuredApprovalDecision, string, string][] = [
["allow_once", "Allow once", "primary"],
["allow_for_session", "Allow for session", ""],
["deny", "Deny", "deny"],
];
for (const [decision, label, cls] of specs) {
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
}
if (acts.children.length > 0) {
card.appendChild(acts);
} else {
const none = el("div", "st-approval-resolved");
none.textContent = "no decision offered";
card.appendChild(none);
}
} else {
const resolved = el("div", "st-approval-resolved");
resolved.textContent = "answered";
card.appendChild(resolved);
}
return wrap;
}
/** 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 {
let built: HTMLElement;
switch (row.kind) {
case "user":
built = buildContentRow(row, "user");
break;
case "agent_text":
built = buildContentRow(row, "agent");
break;
case "agent_thought":
built = buildContentRow(row, "thought");
break;
case "tool_call":
case "tool_diff":
built = buildToolRow(row);
break;
case "approval":
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
break;
default:
built = buildPlainRow(row);
break;
}
built.dataset.rowId = row.id;
return built;
}
// ---------------------------------------------------------------------------
// rail
// ---------------------------------------------------------------------------
const PLAN_MARK: Record<string, string> = { completed: "✓", in_progress: "▸", pending: "·" };
const PLAN_CLASS: Record<string, string> = { completed: "done", in_progress: "now", pending: "todo" };
function renderPlanSection(plan: AcpPlan | null | undefined): HTMLElement | null {
if (!plan || plan.entries.length === 0) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Plan";
section.appendChild(h);
const list = document.createElement("ul");
list.className = "st-plan";
for (const entry of plan.entries) {
const li = document.createElement("li");
li.className = `st-plan-${PLAN_CLASS[entry.status] ?? "todo"}`;
li.setAttribute("aria-label", `${entry.status.replace("_", " ")}: ${entry.content}`);
const mark = el("span", "st-plan-mark");
mark.textContent = PLAN_MARK[entry.status] ?? "·";
mark.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.textContent = entry.content;
li.append(mark, text);
list.appendChild(li);
}
section.appendChild(list);
return section;
}
function kv(label: string, value: string): HTMLElement {
const row = el("div", "st-kv-row");
const k = el("span", "st-kv-k");
k.textContent = label;
const v = el("span", "st-kv-v");
v.textContent = value;
row.append(k, v);
return row;
}
function renderTurnSection(
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] },
toolRowCount: number,
usage: AcpUsage | null | undefined,
): HTMLElement {
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Turn";
section.appendChild(h);
const box = el("div", "st-kv");
const activity = turn.thinking ? "thinking" : turn.responding ? "responding" : "idle";
box.appendChild(kv("state", activity));
box.appendChild(kv("active tools", String(turn.active_tool_calls.length)));
box.appendChild(kv("tool calls", String(toolRowCount)));
if (usage?.cost) box.appendChild(kv("cost", `$${usage.cost.amount.toFixed(2)}`));
section.appendChild(box);
return section;
}
function renderContextSection(usage: AcpUsage | null | undefined): HTMLElement | null {
if (!usage) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Context";
section.appendChild(h);
const box = el("div", "st-kv");
box.appendChild(kv("used", formatTokenCount(usage.used)));
box.appendChild(kv("window", formatTokenCount(usage.size)));
section.appendChild(box);
const meter = el("div", "st-meter");
const fill = el("i");
const pct = usage.size > 0 ? Math.min(100, (usage.used / usage.size) * 100) : 0;
fill.style.width = `${pct}%`;
meter.appendChild(fill);
section.appendChild(meter);
return section;
}
function formatTokenCount(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
/** `SessionWorktree.path` is `<repo>/worktrees/<branch>` (docs/WORKTREES.md);
* the branch is not carried anywhere else on the wire, so derive it here. */
export function branchFromWorktreePath(path: string): string {
const marker = "/worktrees/";
const i = path.indexOf(marker);
if (i >= 0) return path.slice(i + marker.length);
return path.split("/").filter(Boolean).pop() ?? path;
}
function renderWorktreeSection(session: Session | undefined): HTMLElement | null {
const worktree = session?.worktree;
if (!worktree) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Worktree";
section.appendChild(h);
const box = el("div", "st-kv");
box.appendChild(kv("branch", branchFromWorktreePath(worktree.path)));
const blocked: InTheWay | null | undefined = session?.blocked_work;
box.appendChild(kv("ahead", String(blocked?.unpushed_commits ?? 0)));
const dirty = (blocked?.uncommitted_files ?? 0) + (blocked?.untracked_files ?? 0);
box.appendChild(kv("dirty", String(dirty)));
section.appendChild(box);
return section;
}
export interface RailContext {
plan: AcpPlan | null | undefined;
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] };
toolRowCount: number;
usage: AcpUsage | null | undefined;
session: Session | undefined;
}
/** Rebuild the rail wholesale from the latest state — rails are small, so a
* full rebuild per update is simpler and cheap enough that no incremental
* patching is worth the complexity. */
export function renderRail(container: HTMLElement, ctx: RailContext): void {
const sections = [
renderPlanSection(ctx.plan),
renderTurnSection(ctx.turn, ctx.toolRowCount, ctx.usage),
renderContextSection(ctx.usage),
renderWorktreeSection(ctx.session),
].filter((s): s is HTMLElement => s !== null);
container.replaceChildren(...sections);
}
+499
View File
@@ -2165,6 +2165,505 @@ input.leaf-rename:focus {
border-color: var(--accent-strong);
}
/* ---------- structured view (#140) ----------
`.attach.structured` reuses `.attach`'s flex column, `.attach-header` and
`.attach-state` verbatim (attach.ts and structured.ts share paneTitle.ts's
header markup) — only the body differs: a transcript column + rail grid
instead of an xterm surface. Hierarchy is deliberate: the approval is the
only lifted, card-like element on the page; everything else is either a
tinted block (user), plain text (agent), or a thin bordered row (tool). */
.st-pills {
font-size: var(--fs-11);
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.structured-body {
flex: 1 1 auto;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 232px;
}
.structured-body.st-narrow {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
}
.structured-body.st-narrow .structured-rail {
border-left: 0;
border-top: 1px solid var(--border);
max-height: 40%;
}
.structured-scroll {
min-width: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.structured-rows {
padding: var(--sp-3) var(--sp-3) 0;
display: flex;
flex-direction: column;
gap: var(--sp-3);
min-width: 0;
}
.structured-rail {
border-left: 1px solid var(--border);
background: var(--surface-2);
padding: var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-5);
min-width: 0;
overflow-y: auto;
}
.structured-rail h3 {
margin: 0 0 var(--sp-2);
font-size: var(--fs-11);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--faint);
font-weight: 500;
}
.st-plan {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.st-plan li {
display: flex;
gap: var(--sp-2);
font-size: var(--fs-12);
line-height: 1.4;
}
.st-plan-mark {
flex: 0 0 auto;
width: 12px;
text-align: center;
}
.st-plan-done {
color: var(--faint);
}
.st-plan-now {
color: var(--ink);
}
.st-plan-now .st-plan-mark,
.st-plan-done .st-plan-mark {
color: var(--live);
}
.st-plan-todo {
color: var(--muted);
}
.st-plan-todo .st-plan-mark {
color: var(--border-strong);
}
.st-kv {
display: flex;
flex-direction: column;
gap: var(--sp-2);
font-size: var(--fs-12);
}
.st-kv-row {
display: flex;
justify-content: space-between;
gap: var(--sp-2);
}
.st-kv-k {
color: var(--faint);
}
.st-kv-v {
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.st-meter {
height: 4px;
background: var(--surface-3);
border-radius: var(--r-1);
overflow: hidden;
margin-top: var(--sp-2);
}
.st-meter i {
display: block;
height: 100%;
background: var(--live);
}
/* every transcript row carries its own layout; none of them are cards */
.st-row {
display: flex;
gap: var(--sp-3);
min-width: 0;
}
.st-body {
min-width: 0;
}
/* user turn: the only tinted block besides the approval — an accent rail,
never a full border, so it still reads lighter than the approval card. */
.st-row-user {
border-left: 2px solid var(--accent);
background: var(--surface-2);
padding: var(--sp-3);
}
.st-who {
font-size: var(--fs-11);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--faint);
margin-bottom: var(--sp-1);
}
.st-user-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* agent prose: no card, no background — plain text is the point */
.st-row-agent .st-body p {
margin: 0 0 var(--sp-2);
overflow-wrap: anywhere;
}
.st-row-agent .st-body p:last-child {
margin-bottom: 0;
}
.st-row-agent code {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r-1);
padding: 0 3px;
font-size: 0.92em;
}
.st-row-thought {
color: var(--faint);
font-style: italic;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.st-row-notice {
color: var(--faint);
font-size: var(--fs-12);
}
/* tool rows: bordered, compact, deliberately not lifted */
.st-tool {
border: 1px solid var(--border);
border-radius: var(--r-2);
min-width: 0;
}
.st-tool-head {
appearance: none;
width: 100%;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
min-width: 0;
}
.st-tool-head:hover {
background: var(--surface-2);
}
.st-tool-glyph {
width: 16px;
flex: 0 0 auto;
text-align: center;
color: var(--faint);
}
.st-tool-name {
font-weight: 500;
flex: 0 0 auto;
}
.st-tool-arg {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
text-align: left;
}
.st-tool-meta {
margin-left: auto;
flex: 0 0 auto;
font-size: var(--fs-11);
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.st-tool-meta-running {
color: var(--live);
}
.st-tool-meta-failed {
color: var(--danger);
}
.st-tool-out {
border-top: 1px solid var(--border);
padding: var(--sp-2) var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
/* `display: flex` above outranks the UA sheet's `[hidden]` (same pitfall as
`.wt-step`/`.raw-wrap`) — restate it explicitly. */
.st-tool-out[hidden] {
display: none;
}
.st-tool-text {
font-size: var(--fs-12);
color: var(--muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
overflow-x: auto;
}
.st-diff-block {
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.st-diff-path {
font-size: var(--fs-11);
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* the diff gets its own horizontal scroller, independent of the page */
.st-diff {
font-size: var(--fs-12);
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--r-1);
}
.st-diff pre {
margin: 0;
}
.st-ln {
display: block;
white-space: pre;
padding: 0 var(--sp-3);
}
.st-ln-add {
background: color-mix(in oklab, var(--live) 14%, transparent);
}
.st-ln-del {
background: var(--danger-tint);
}
.st-ln-ctx {
color: var(--muted);
}
.st-ln-hunk {
color: var(--faint);
background: var(--surface-2);
}
/* the one lifted, card-like element on the surface: the only thing the
operator owes a decision on */
.st-approval {
border: 1px solid var(--warn);
border-left-width: 3px;
border-radius: var(--r-2);
background: color-mix(in oklab, var(--warn) 12%, var(--surface));
box-shadow: var(--shadow-1);
padding: var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
}
.st-approval-title {
color: var(--warn);
font-size: var(--fs-11);
letter-spacing: 0.1em;
text-transform: uppercase;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.st-approval-what {
color: var(--ink);
}
.st-approval-path {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-1);
padding: var(--sp-2);
font-size: var(--fs-12);
overflow-x: auto;
white-space: pre;
}
.st-approval-acts {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.st-approval-resolved {
font-size: var(--fs-11);
color: var(--faint);
}
.act {
appearance: none;
font: inherit;
font-size: var(--fs-12);
height: 26px;
padding: 0 var(--sp-3);
border-radius: var(--r-2);
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--ink);
cursor: pointer;
transition:
transform var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out);
}
.act:hover {
background: var(--surface-3);
}
.act:active {
transform: translateY(1px);
}
.act:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.act.primary {
background: var(--accent);
color: var(--accent-ink);
border-color: var(--accent);
font-weight: 500;
}
.act.deny {
color: var(--danger);
border-color: color-mix(in oklab, var(--danger) 40%, transparent);
}
.act.deny:hover {
background: var(--danger-tint);
border-color: var(--danger);
}
/* composer: sticky at the bottom of the scrolling transcript column */
.structured-composer {
position: sticky;
bottom: 0;
margin-top: var(--sp-3);
padding: var(--sp-3);
border-top: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: flex-end;
gap: var(--sp-2);
}
.structured-input {
flex: 1 1 auto;
min-width: 0;
max-height: 160px;
resize: none;
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--border-strong);
border-radius: var(--r-2);
background: var(--bg);
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--fs-13);
}
.structured-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-tint);
}
.structured-input:disabled {
color: var(--faint);
background: var(--surface-2);
}
/* `.btn` sets `display: inline-flex`, which outranks the UA sheet's
`[hidden]` — restate it explicitly (same pitfall as `.st-tool-out`). */
.structured-cancel[hidden] {
display: none;
}
.structured-error {
margin: 0 var(--sp-3);
padding: var(--sp-2) var(--sp-3);
background: var(--danger-tint);
color: var(--danger);
font-size: var(--fs-12);
border-radius: var(--r-2);
}
@media (prefers-reduced-motion: reduce) {
.act {
transition-duration: 0.01ms;
}
}
/* ---------- modal ---------- */
.modal-backdrop {
+190
View File
@@ -60,8 +60,15 @@ export interface Session {
* `session/new` (#134). Only a `Structured` session whose handshake
* succeeded has one; a respawn passes it to `session/load` (#135). */
acp_session_id?: string | null;
/** Terminal (PTY bytes) or structured (ACP rows) (#133). Fixed at spawn and
* immutable for the session's life. Defaults to `"Terminal"` on the wire, so
* absent means terminal. */
view?: SessionView;
}
/** A session's process shape (#133): mirrors `maestro_protocol::SessionView`. */
export type SessionView = "Terminal" | "Structured";
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
export interface SessionWorktree {
path: string;
@@ -367,6 +374,189 @@ export function abbreviateCwd(cwd: string, home?: string): string {
return cwd;
}
// ---------------------------------------------------------------------------
// Structured-session projections (#133/#140). Mirror of
// `maestro_protocol`'s "Structured-session projections" section. `data`,
// `ReducedState.plan/usage/context/pending_approval` and every `Acp*` type
// below are the daemon's opaque ACP payloads (`serde_json::Value` on the
// wire) — shaped to `claude-code-acp`'s ACP v1 schema, not covered by
// maestro-protocol's own compatibility guarantee. Treat every field as
// optional and degrade gracefully rather than trust this shape exactly.
// ---------------------------------------------------------------------------
/** `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
export type TranscriptRowKind =
| "user"
| "agent_text"
| "agent_thought"
| "tool_call"
| "tool_diff"
| "approval"
| "plan"
| "notice";
/** One row of a structured session's transcript. */
export interface TranscriptRow {
id: string;
kind: TranscriptRowKind;
data: unknown;
}
/** The complete transcript at a durable event-log position (`WS
* /api/structured/:id`'s first message, and every replay page). */
export interface TranscriptSnapshot {
cursor: number;
rows: TranscriptRow[];
}
/** A change to one transcript row, addressed by `id`. */
export type TranscriptDelta =
| { type: "append"; row: TranscriptRow }
| { type: "patch"; row: TranscriptRow }
| { type: "remove"; id: string };
/** Per-turn indicators derived from the ACP stream — read verbatim, never
* re-derived client-side (docs/STRUCTURED-VIEW.md). */
export interface TurnState {
responding: boolean;
thinking: boolean;
active_tool_calls: string[];
}
/** A whole-session view derived from ACP notifications. `plan`/`usage`/
* `context`/`pending_approval` are opaque ACP JSON — see the module note
* above. `pending_approval` is `{nonce, request: AcpRequestPermissionRequest}`
* while a permission request is live, absent otherwise. */
export interface ReducedState {
turn: TurnState;
pending_approval?: { nonce: string; request: AcpRequestPermissionRequest } | null;
plan?: AcpPlan | null;
usage?: AcpUsage | null;
context?: AcpUsage | null;
model?: string | null;
mode?: string | null;
}
/** Server messages on `WS /api/structured/:id`. */
export type StructuredEvent =
| { type: "transcript_snapshot"; snapshot: TranscriptSnapshot }
| { type: "reduced_state"; state: ReducedState }
| { type: "projection_update"; transcript?: TranscriptDelta | null; state: ReducedState };
/** `POST /api/sessions/:id/structured/prompt` body. */
export interface StructuredPromptRequest {
prompt: string;
}
/** `POST /api/sessions/:id/structured/approvals/:nonce` body — a bare JSON
* string (a bracketless Rust enum), not `{decision: ...}`. */
export type StructuredApprovalDecision = "allow_once" | "allow_for_session" | "deny";
// ---- ACP v1 payload shapes (agent-client-protocol schema, camelCase) ----
/** `ContentBlock`. Only `text` is common in practice; the rest degrade to a
* generic placeholder rather than being dropped silently. */
export type AcpContentBlock =
| { type: "text"; text: string }
| { type: "image"; mimeType?: string }
| { type: "audio"; mimeType?: string }
| { type: "resource_link"; uri?: string; name?: string }
| { type: "resource"; resource?: unknown };
/** `ContentChunk` — the payload of a `User`/`AgentText`/`AgentThought` row. */
export interface AcpContentChunk {
content: AcpContentBlock;
messageId?: string | null;
}
/** `ToolKind`, snake_case on the wire. */
export type AcpToolKind =
| "read"
| "edit"
| "delete"
| "move"
| "search"
| "execute"
| "think"
| "fetch"
| "switch_mode"
| "other";
/** `ToolCallStatus`, snake_case on the wire. */
export type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
/** `Diff` — a file modification. `oldText` absent means a new file. */
export interface AcpDiff {
path: string;
oldText?: string | null;
newText: string;
}
/** `ToolCallContent`. */
export type AcpToolCallContent =
| { type: "content"; content: AcpContentBlock }
| ({ type: "diff" } & AcpDiff)
| { type: "terminal"; terminalId?: string };
export interface AcpToolCallLocation {
path: string;
line?: number | null;
}
/** `ToolCall` (a `ToolCall`/`ToolDiff` row's `data`) and, loosely, the
* flattened `ToolCallUpdate` an approval's `toolCall` field carries — the
* two share every field below, just with different subsets required. */
export interface AcpToolCallLike {
toolCallId?: string;
title?: string;
/** Unstable ACP capability; absent when the adapter doesn't send one. */
name?: string | null;
kind?: AcpToolKind;
status?: AcpToolCallStatus;
content?: AcpToolCallContent[];
locations?: AcpToolCallLocation[];
rawInput?: unknown;
rawOutput?: unknown;
}
/** `PlanEntryPriority` / `PlanEntryStatus`, snake_case on the wire. */
export type AcpPlanPriority = "high" | "medium" | "low";
export type AcpPlanEntryStatus = "pending" | "in_progress" | "completed";
export interface AcpPlanEntry {
content: string;
priority: AcpPlanPriority;
status: AcpPlanEntryStatus;
}
/** `Plan` — `ReducedState.plan`. */
export interface AcpPlan {
entries: AcpPlanEntry[];
}
/** `UsageUpdate` — `ReducedState.usage`/`.context` (the daemon folds one
* update into both fields). */
export interface AcpUsage {
used: number;
size: number;
cost?: { amount: number; currency: string } | null;
}
export interface AcpPermissionOption {
optionId: string;
name: string;
kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
}
/** `RequestPermissionRequest`, serialized without its nonce (the nonce lives
* only in `ReducedState.pending_approval.nonce`) — an `Approval` row's `data`,
* and `ReducedState.pending_approval.request`. */
export interface AcpRequestPermissionRequest {
sessionId: string;
toolCall: AcpToolCallLike;
options: AcpPermissionOption[];
}
/** `~/git.naps.pt/naps62/maestro` → `~/g/n/maestro`: keep the last segment
* verbatim, collapse every earlier one to its first character. Two-or-fewer
* segments are left alone (nothing to gain). Returns escaped HTML with the