Skip to content

Commit b303f69

Browse files
committed
fix(terminal): Correct background output rendering
- Replay control sequences at source PTY dimensions to fix ConPTY wrapping, duplicated characters, and cursor overwrites - Report local and SSH terminal dimensions, support legacy metadata, and flag incomplete replay - Separate replay from display while preserving colors, log reflow, and raw output copying - Serialize polling, discard stale session responses, and drain remaining output after completion - Fix bottom whitespace, black strips, and lost rows under scaling - Remove the simplified display option and use colored output throughout - Blend command card footers into the Ink Charm theme - Fix @xterm/headless entry resolution and add regression coverage for replay, terminal sizing, and metadata compatibility Refs: #2208
1 parent 048b7c2 commit b303f69

28 files changed

Lines changed: 742 additions & 165 deletions

File tree

‎design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.module.css‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@
143143
padding-inline: var(--openbitfun-space-3);
144144
border-radius: var(--openbitfun-radius-sm);
145145
color: var(--openbitfun-color-content-muted);
146-
background: var(--openbitfun-color-surface-raised);
146+
background: transparent;
147147
font-size: var(--openbitfun-type-body-sm-font-size);
148148
}
149149

‎pnpm-lock.yaml‎

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,7 @@ impl ExecCommandTool {
558558
workdir: Some(workdir.clone()),
559559
remote: true,
560560
tty,
561+
terminal_size: remote_exec_port.exec_terminal_size(),
561562
})
562563
.await,
563564
)
@@ -833,6 +834,7 @@ Output:
833834
workdir: Some(workdir.to_string_lossy().to_string()),
834835
remote: false,
835836
tty,
837+
terminal_size: terminal_port.exec_terminal_size(),
836838
})
837839
.await,
838840
)

‎src/crates/contracts/runtime-ports/src/lib.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ pub use web_search::*;
8585

8686
pub type PortResult<T> = Result<T, PortError>;
8787

88+
/// Source geometry for a fixed-size ExecCommand PTY replay.
89+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90+
pub struct ExecTerminalSize {
91+
pub cols: u16,
92+
pub rows: u16,
93+
}
94+
8895
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8996
#[serde(rename_all = "snake_case")]
9097
pub enum PortErrorKind {

‎src/crates/contracts/runtime-ports/src/remote_exec_port.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ pub type RemoteExecOutputSink = mpsc::UnboundedSender<String>;
117117
pub type RemoteExecStreamingOutputSink = mpsc::Sender<String>;
118118
#[async_trait::async_trait]
119119
pub trait RemoteExecPort: RuntimeServicePort + std::fmt::Debug {
120+
/// None means the provider cannot guarantee a fixed replay geometry.
121+
fn exec_terminal_size(&self) -> Option<ExecTerminalSize> {
122+
None
123+
}
124+
120125
/// Authoritative liveness query for a remote ExecCommand session.
121126
async fn is_session_active(&self, _session_id: i32) -> PortResult<bool> {
122127
Ok(false)

‎src/crates/contracts/runtime-ports/src/terminal_port.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ pub type TerminalExecStreamingOutputSink = mpsc::Sender<String>;
105105

106106
#[async_trait::async_trait]
107107
pub trait TerminalPort: RuntimeServicePort + std::fmt::Debug {
108+
/// None means the provider cannot guarantee a fixed replay geometry.
109+
fn exec_terminal_size(&self) -> Option<ExecTerminalSize> {
110+
None
111+
}
112+
108113
/// Authoritative liveness query for an ExecCommand session.
109114
/// Providers that cannot inspect state fail closed.
110115
async fn is_session_active(&self, _session_id: i32) -> PortResult<bool> {

‎src/crates/execution/tool-execution/src/background_command_output.rs‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
use serde::Serialize;
1+
use openbitfun_runtime_ports::ExecTerminalSize;
2+
use serde::{Deserialize, Serialize};
23
use std::collections::{HashMap, VecDeque};
34
use std::sync::{Arc, OnceLock};
45
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -18,7 +19,7 @@ pub fn background_command_output_capture() -> Arc<BackgroundCommandOutputCapture
1819
.clone()
1920
}
2021

21-
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
22+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2223
#[serde(rename_all = "snake_case")]
2324
pub enum BackgroundCommandOutputStatus {
2425
Running,
@@ -35,7 +36,7 @@ struct BackgroundCommandSessionKey {
3536
exec_session_id: i32,
3637
}
3738

38-
#[derive(Debug, Clone, Serialize)]
39+
#[derive(Debug, Clone, Serialize, Deserialize)]
3940
#[serde(rename_all = "camelCase")]
4041
pub struct BackgroundCommandOutputMetadata {
4142
pub agent_session_id: Option<String>,
@@ -44,6 +45,9 @@ pub struct BackgroundCommandOutputMetadata {
4445
pub workdir: Option<String>,
4546
pub remote: bool,
4647
pub tty: bool,
48+
/// Explicit null means unknown geometry; absent on older hosts.
49+
#[serde(default)]
50+
pub terminal_size: Option<ExecTerminalSize>,
4751
pub status: BackgroundCommandOutputStatus,
4852
pub exit_code: Option<i32>,
4953
pub started_at: u64,
@@ -62,6 +66,7 @@ pub struct StartBackgroundCommandOutputCapture {
6266
pub workdir: Option<String>,
6367
pub remote: bool,
6468
pub tty: bool,
69+
pub terminal_size: Option<ExecTerminalSize>,
6570
}
6671

6772
#[derive(Debug, Clone)]
@@ -104,6 +109,7 @@ struct BackgroundCommandOutputRecord {
104109
workdir: Option<String>,
105110
remote: bool,
106111
tty: bool,
112+
terminal_size: Option<ExecTerminalSize>,
107113
exec_session_id: Option<i32>,
108114
status: BackgroundCommandOutputStatus,
109115
exit_code: Option<i32>,
@@ -135,6 +141,7 @@ impl BackgroundCommandOutputCapture {
135141
workdir: request.workdir,
136142
remote: request.remote,
137143
tty: request.tty,
144+
terminal_size: request.terminal_size.filter(|_| request.tty),
138145
exec_session_id: None,
139146
status: BackgroundCommandOutputStatus::Running,
140147
exit_code: None,
@@ -413,6 +420,7 @@ impl BackgroundCommandOutputRecord {
413420
workdir: self.workdir.clone(),
414421
remote: self.remote,
415422
tty: self.tty,
423+
terminal_size: self.terminal_size,
416424
status: self.status,
417425
exit_code: self.exit_code,
418426
started_at: self.started_at,
@@ -466,6 +474,34 @@ mod tests {
466474
ReadBackgroundCommandOutputRequest, StartBackgroundCommandOutputCapture,
467475
};
468476

477+
#[test]
478+
fn background_output_metadata_reads_legacy_payload_and_round_trips_geometry() {
479+
let legacy = serde_json::json!({
480+
"agentSessionId": null, "execSessionId": 42, "command": "test",
481+
"workdir": null, "remote": true, "tty": true, "status": "running",
482+
"exitCode": null, "startedAt": 0, "endedAt": null,
483+
"retainedBytes": 0, "retainedLimitBytes": 1048576, "truncatedFromStart": false
484+
});
485+
let mut metadata: super::BackgroundCommandOutputMetadata =
486+
serde_json::from_value(legacy.clone()).unwrap();
487+
assert_eq!(metadata.terminal_size, None);
488+
let old_round_trip = serde_json::to_value(&metadata).unwrap();
489+
for (key, value) in legacy.as_object().unwrap() {
490+
assert_eq!(&old_round_trip[key], value);
491+
}
492+
// New hosts explicitly distinguish unknown geometry from old absent fields.
493+
assert!(old_round_trip["terminalSize"].is_null());
494+
metadata.terminal_size = Some(super::ExecTerminalSize { cols: 80, rows: 24 });
495+
let encoded = serde_json::to_value(&metadata).unwrap();
496+
assert_eq!(
497+
encoded["terminalSize"],
498+
serde_json::json!({"cols": 80, "rows": 24})
499+
);
500+
let restored: super::BackgroundCommandOutputMetadata =
501+
serde_json::from_value(encoded).unwrap();
502+
assert_eq!(restored.terminal_size, metadata.terminal_size);
503+
}
504+
469505
#[tokio::test]
470506
async fn background_command_output_reads_snapshot_then_incremental_chunks() {
471507
let capture_id = format!(
@@ -484,6 +520,7 @@ mod tests {
484520
workdir: None,
485521
remote: false,
486522
tty: false,
523+
terminal_size: None,
487524
})
488525
.await;
489526

‎src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ use tokio::time::{Duration, Instant};
1919
use uuid::Uuid;
2020

2121
const DEFAULT_YIELD_TIME_MS: u64 = 10_000;
22+
pub(super) const EXEC_TERMINAL_SIZE: openbitfun_runtime_ports::ExecTerminalSize =
23+
openbitfun_runtime_ports::ExecTerminalSize { cols: 80, rows: 24 };
2224
const MAX_RETAINED_OUTPUT_BYTES: usize = 1024 * 1024;
2325
const MAX_REMOTE_EXEC_SESSIONS: usize = 64;
2426
const MAX_COMPLETED_REMOTE_EXEC_SESSIONS: usize = 64;
@@ -739,8 +741,14 @@ async fn spawn_local_workspace_pty_process(
739741
.try_into()
740742
.expect("UUID prefix is four bytes"),
741743
);
742-
let spawned = spawn_pty(process_id, &shell_config, shell_type, 80, 24)
743-
.map_err(|error| anyhow!("Failed to start local workspace PTY: {}", error))?;
744+
let spawned = spawn_pty(
745+
process_id,
746+
&shell_config,
747+
shell_type,
748+
EXEC_TERMINAL_SIZE.cols,
749+
EXEC_TERMINAL_SIZE.rows,
750+
)
751+
.map_err(|error| anyhow!("Failed to start local workspace PTY: {}", error))?;
744752
let output = Arc::new(OutputState::new(request.output_capture_tx.clone()));
745753
let (command_tx, mut command_rx) = mpsc::channel::<RemoteExecProcessCommand>(64);
746754
let owner_output = output.clone();
@@ -816,7 +824,12 @@ async fn spawn_remote_pty_process(
816824
) -> anyhow::Result<RemoteExecProcess> {
817825
let channel = request
818826
.ssh_manager
819-
.open_pty_exec_channel(&request.connection_id, &request.command, 80, 24)
827+
.open_pty_exec_channel(
828+
&request.connection_id,
829+
&request.command,
830+
EXEC_TERMINAL_SIZE.cols.into(),
831+
EXEC_TERMINAL_SIZE.rows.into(),
832+
)
820833
.await?;
821834
let output = Arc::new(OutputState::new(request.output_capture_tx.clone()));
822835
let (command_tx, command_rx) = mpsc::channel::<RemoteExecProcessCommand>(64);

‎src/crates/services/services-integrations/src/remote_ssh/remote_exec_runtime_port.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ impl ports::RuntimeServicePort for RemoteExecRuntimePort {
7272

7373
#[async_trait]
7474
impl ports::RemoteExecPort for RemoteExecRuntimePort {
75+
fn exec_terminal_size(&self) -> Option<ports::ExecTerminalSize> {
76+
Some(super::remote_exec::EXEC_TERMINAL_SIZE)
77+
}
78+
7579
async fn is_session_active(&self, session_id: i32) -> ports::PortResult<bool> {
7680
Ok(self.manager.is_session_active(session_id).await)
7781
}

‎src/crates/services/terminal/src/exec.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ use tokio::task::JoinHandle;
2323
use uuid::Uuid;
2424

2525
const DEFAULT_YIELD_TIME_MS: u64 = 10_000;
26+
pub(crate) const EXEC_TERMINAL_SIZE: openbitfun_runtime_ports::ExecTerminalSize =
27+
openbitfun_runtime_ports::ExecTerminalSize { cols: 80, rows: 24 };
2628
const MAX_RETAINED_OUTPUT_BYTES: usize = 1024 * 1024;
2729
const MAX_EXEC_SESSIONS: usize = 64;
2830
const MAX_COMPLETED_EXEC_SESSIONS: usize = 64;
@@ -1097,8 +1099,8 @@ async fn spawn_exec_process(
10971099
async fn spawn_pty_process(request: &ExecCommandRequest) -> TerminalResult<ExecProcess> {
10981100
let pty_system = native_pty_system();
10991101
let pair = pty_system.openpty(PtySize {
1100-
rows: 24,
1101-
cols: 80,
1102+
rows: EXEC_TERMINAL_SIZE.rows,
1103+
cols: EXEC_TERMINAL_SIZE.cols,
11021104
pixel_width: 0,
11031105
pixel_height: 0,
11041106
})?;

0 commit comments

Comments
 (0)