feat: add interactive VT and workspace command plane
CI / rust (push) Successful in 3m48s

This commit is contained in:
2026-08-31 17:05:49 -07:00
parent 32d190c6c6
commit b838d000db
54 changed files with 3004 additions and 113 deletions
+4 -1
View File
@@ -21,7 +21,10 @@ Both spikes must preserve the same information architecture:
GPUI also has an integration mode with one real local PTY owned by
`lumbridge-runtime`; the other five surfaces remain deterministic. Floem and the
shared model retain the all-deterministic mode for like-for-like framework
comparison. The PTY display is plain byte line-framing, not terminal emulation.
comparison. GPUI feeds raw output through `lumbridge-terminal` and sends encoded
keyboard input and terminal protocol replies through the bounded runtime actor.
Its visual adapter currently flattens the VT snapshot to plain rows, so styled
cells, selection, and cursor painting remain intentionally unfinished.
Build independently:
+82
View File
@@ -42,6 +42,30 @@ dependencies = [
"memchr",
]
[[package]]
name = "alacritty_terminal"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59"
dependencies = [
"base64",
"bitflags 2.13.1",
"home",
"libc",
"log",
"miow",
"parking_lot",
"piper",
"polling",
"regex-automata",
"rustix 1.1.4",
"rustix-openpty",
"signal-hook",
"unicode-width",
"vte",
"windows-sys 0.59.0",
]
[[package]]
name = "aligned"
version = "0.4.3"
@@ -1252,6 +1276,12 @@ version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]]
name = "cursor-icon"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -3007,12 +3037,21 @@ dependencies = [
"gpui",
"lumbridge-runtime",
"lumbridge-spike-model",
"lumbridge-terminal",
]
[[package]]
name = "lumbridge-spike-model"
version = "0.0.1"
[[package]]
name = "lumbridge-terminal"
version = "0.0.1"
dependencies = [
"alacritty_terminal",
"thiserror 2.0.20",
]
[[package]]
name = "lyon"
version = "1.0.19"
@@ -3198,6 +3237,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "miow"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
@@ -4499,6 +4547,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustix-openpty"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393"
dependencies = [
"errno",
"libc",
"rustix 1.1.4",
]
[[package]]
name = "rustls"
version = "0.23.43"
@@ -4907,6 +4966,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -5985,6 +6054,19 @@ dependencies = [
"libc",
]
[[package]]
name = "vte"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
dependencies = [
"arrayvec",
"bitflags 2.13.1",
"cursor-icon",
"log",
"memchr",
]
[[package]]
name = "waker-fn"
version = "1.2.0"
+1
View File
@@ -11,5 +11,6 @@ publish = false
gpui = "0.2.2"
lumbridge-runtime = { path = "../../crates/lumbridge-runtime" }
lumbridge-spike-model = { path = "../ui-shell-model" }
lumbridge-terminal = { path = "../../crates/lumbridge-terminal" }
[workspace]
+267 -86
View File
@@ -6,12 +6,17 @@ use gpui::{
WindowOptions, actions, div, prelude::*, px, rgb, size,
};
use lumbridge_runtime::{
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeEvent,
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions,
RuntimeCommand, RuntimeEvent, TerminalSize,
};
use lumbridge_spike_model::{
ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction,
ShellModel, SurfaceKind, WORKSPACES,
};
use lumbridge_terminal::{
KeyModifiers, TerminalDimensions, TerminalEngine, TerminalEngineOptions, TerminalKey,
TerminalKeyEvent,
};
const BG: u32 = 0x090c12;
const PANEL: u32 = 0x101620;
@@ -28,7 +33,9 @@ const TIMING_SAMPLE_LIMIT: usize = 256;
const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16);
const RUNTIME_DRAIN_LIMIT: usize = 64;
const LIVE_PANE: PaneId = PaneId::CodexRuntime;
const LIVE_PTY_SCRIPT: &str = "i=0; printf 'Lumbridge runtime actor owns this PTY\\n'; while :; do printf '[pty %06d] /bin/sh · actor output\\n' \"$i\"; i=$((i+1)); sleep 1; done";
const LIVE_PTY_SCRIPT: &str = "printf 'Lumbridge interactive PTY · type here\\n'; exec /bin/sh -i";
const TERMINAL_ROW_STEP: u16 = 2;
const TERMINAL_COLUMN_STEP: u16 = 10;
actions!(
lumbridge,
@@ -45,6 +52,10 @@ actions!(
SelectPane4,
SelectPane5,
SelectPane6,
TerminalTaller,
TerminalShorter,
TerminalWider,
TerminalNarrower,
]
);
@@ -53,7 +64,7 @@ struct LumbridgeShell {
timing: RenderTiming,
runtime: Option<RuntimeActor>,
runtime_status: LiveRuntimeStatus,
runtime_lines: PtyLineFramer,
terminal: TerminalEngine,
last_runtime_sequence: u64,
root_focus: FocusHandle,
pane_focus: [FocusHandle; 6],
@@ -106,38 +117,6 @@ impl LiveRuntimeStatus {
}
}
#[derive(Default)]
struct PtyLineFramer {
pending: Vec<u8>,
}
impl PtyLineFramer {
fn push(&mut self, bytes: &[u8]) -> Vec<String> {
self.pending.extend_from_slice(bytes);
let mut lines = Vec::new();
while let Some(newline) = self.pending.iter().position(|byte| *byte == b'\n') {
let mut line = self.pending.drain(..=newline).collect::<Vec<_>>();
line.pop();
if line.last() == Some(&b'\r') {
line.pop();
}
lines.push(String::from_utf8_lossy(&line).into_owned());
}
lines
}
fn finish(&mut self) -> Vec<String> {
if self.pending.is_empty() {
return Vec::new();
}
let mut line = std::mem::take(&mut self.pending);
if line.last() == Some(&b'\r') {
line.pop();
}
vec![String::from_utf8_lossy(&line).into_owned()]
}
}
#[derive(Default)]
struct RenderTiming {
pending_dispatch: Option<Instant>,
@@ -228,7 +207,8 @@ impl LumbridgeShell {
})
.detach();
let (runtime, runtime_status) = match start_live_runtime() {
let terminal = TerminalEngine::new(TerminalEngineOptions::default());
let (runtime, runtime_status) = match start_live_runtime(terminal.dimensions()) {
Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting),
Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())),
};
@@ -239,7 +219,7 @@ impl LumbridgeShell {
timing: RenderTiming::default(),
runtime,
runtime_status,
runtime_lines: PtyLineFramer::default(),
terminal,
last_runtime_sequence: 0,
root_focus,
pane_focus,
@@ -296,24 +276,20 @@ impl LumbridgeShell {
break;
}
self.last_runtime_sequence = sequence;
let lines = self.runtime_lines.push(&bytes);
if !lines.is_empty() {
self.dispatch(ShellAction::AppendExternalOutput {
pane: LIVE_PANE,
lines,
});
changed = true;
let update = self.terminal.process(&bytes);
for response in update.outbound {
if let Err(error) =
self.send_runtime_command(RuntimeCommand::Input(response))
{
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
return true;
}
}
self.publish_terminal_snapshot();
changed = true;
}
RuntimeEvent::InputClosed { .. } => {}
RuntimeEvent::Exited { status, .. } => {
let lines = self.runtime_lines.finish();
if !lines.is_empty() {
self.dispatch(ShellAction::AppendExternalOutput {
pane: LIVE_PANE,
lines,
});
}
self.runtime_status =
LiveRuntimeStatus::Exited(format!("PTY exited with code {}", status.code));
changed = true;
@@ -332,6 +308,36 @@ impl LumbridgeShell {
changed
}
fn send_runtime_command(&self, command: RuntimeCommand) -> Result<(), RuntimeActorError> {
self.runtime
.as_ref()
.ok_or(RuntimeActorError::Disconnected)?
.try_send(command)
}
fn publish_terminal_snapshot(&mut self) {
let lines = self.terminal.snapshot().plain_rows();
self.dispatch(ShellAction::ReplaceExternalOutput {
pane: LIVE_PANE,
lines,
});
}
fn resize_terminal(&mut self, rows: u16, columns: u16, cx: &mut Context<Self>) {
let dimensions = TerminalDimensions::new(rows, columns)
.expect("resize actions always retain non-zero dimensions");
if !self.terminal.resize(dimensions) {
return;
}
let pty_size = TerminalSize::new(rows, columns)
.expect("terminal engine dimensions are valid PTY dimensions");
if let Err(error) = self.send_runtime_command(RuntimeCommand::Resize(pty_size)) {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
}
self.publish_terminal_snapshot();
cx.notify();
}
fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
self.dispatch(ShellAction::SelectPane(pane));
window.focus(&self.pane_focus[pane.index()]);
@@ -375,8 +381,66 @@ impl LumbridgeShell {
cx.notify();
}
fn terminal_taller(&mut self, _: &TerminalTaller, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions();
self.resize_terminal(
dimensions.rows().saturating_add(TERMINAL_ROW_STEP),
dimensions.columns(),
cx,
);
}
fn terminal_shorter(&mut self, _: &TerminalShorter, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions();
self.resize_terminal(
dimensions.rows().saturating_sub(TERMINAL_ROW_STEP).max(2),
dimensions.columns(),
cx,
);
}
fn terminal_wider(&mut self, _: &TerminalWider, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions();
self.resize_terminal(
dimensions.rows(),
dimensions.columns().saturating_add(TERMINAL_COLUMN_STEP),
cx,
);
}
fn terminal_narrower(&mut self, _: &TerminalNarrower, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions();
self.resize_terminal(
dimensions.rows(),
dimensions
.columns()
.saturating_sub(TERMINAL_COLUMN_STEP)
.max(20),
cx,
);
}
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
if !self.model.command_palette().is_open() {
if self.model.selected_pane() != LIVE_PANE || self.runtime_status.is_terminal() {
return;
}
let modifiers = key_modifiers(event);
let Some(event) = terminal_key_from_parts(
event.keystroke.key.as_str(),
event.keystroke.key_char.as_deref(),
modifiers,
) else {
return;
};
let bytes = self.terminal.encode_key(&event);
if bytes.is_empty() {
return;
}
if let Err(error) = self.send_runtime_command(RuntimeCommand::Input(bytes)) {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string());
}
cx.notify();
return;
}
@@ -390,6 +454,11 @@ impl LumbridgeShell {
cx.notify();
return;
}
"escape" => {
self.dispatch(ShellAction::CloseCommandPalette);
cx.notify();
return;
}
_ => {
if let Some(character) = event.keystroke.key_char.as_deref()
&& !event.keystroke.modifiers.control
@@ -409,6 +478,7 @@ impl LumbridgeShell {
pane: &PaneState,
selected: bool,
runtime_status: LiveRuntimeStatus,
terminal_dimensions: TerminalDimensions,
focus: FocusHandle,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
@@ -478,7 +548,7 @@ impl LumbridgeShell {
.child(format!("{}", id.index() + 1)),
)
.child(if external {
"Shell · runtime actor"
"Shell · VT session"
} else {
pane.fixture().title
}),
@@ -495,16 +565,45 @@ impl LumbridgeShell {
.text_xs()
.text_color(rgb(MUTED))
.child(if external {
runtime_status.detail()
format!(
"{} · {}×{} · Alt+Shift+arrows resize",
runtime_status.detail(),
terminal_dimensions.columns(),
terminal_dimensions.rows()
)
} else {
pane.fixture().target.to_owned()
})
.when(selected, |view| view.child("FOCUSED")),
)
.when(external && selected, |view| {
view.child(
div()
.flex()
.items_center()
.gap_3()
.h(px(26.0))
.flex_none()
.px_3()
.border_y_1()
.border_color(rgb(BORDER_QUIET))
.bg(rgb(PANEL_ALT))
.text_xs()
.child(div().text_color(rgb(ACCENT)).child("TERMINAL"))
.child(div().text_color(rgb(MUTED)).child("BROWSER"))
.child(div().text_color(rgb(MUTED)).child("TOOLS"))
.child(div().text_color(rgb(MUTED)).child("CONTEXT"))
.child(div().text_color(rgb(MUTED)).child("GOAL"))
.child(div().text_color(rgb(MUTED)).child("REVIEW")),
)
})
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_h_0()
.overflow_hidden()
.gap_1()
.px_3()
.pb_3()
@@ -512,6 +611,36 @@ impl LumbridgeShell {
.text_color(rgb(TEXT))
.children(pane.lines()[line_start..].iter().cloned()),
)
.when(external && selected, |view| {
view.child(
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.h(px(38.0))
.flex_none()
.px_3()
.border_t_1()
.border_color(rgb(BORDER_QUIET))
.bg(rgb(PANEL_ALT))
.text_xs()
.child(
div()
.text_color(rgb(MUTED))
.child("LOCAL ANALYST · OFF · suggestions only"),
)
.child(
div()
.flex()
.gap_2()
.text_color(rgb(TEXT))
.child("Continue")
.child("Review plan")
.child("Ask…"),
),
)
})
.into_any_element()
}
@@ -767,7 +896,7 @@ impl Render for LumbridgeShell {
.px_3()
.text_xs()
.text_color(rgb(MUTED))
.child("1 live PTY · 5 deterministic · 1 waiting"),
.child("1 interactive VT · 5 deterministic · 1 waiting"),
);
let pane_cards = self
@@ -779,6 +908,7 @@ impl Render for LumbridgeShell {
pane,
self.model.selected_pane() == pane.id(),
self.runtime_status.clone(),
self.terminal.dimensions(),
self.pane_focus[pane.id().index()].clone(),
cx,
)
@@ -795,11 +925,11 @@ impl Render for LumbridgeShell {
let counters = self.model.counters();
let footer_left = format!(
"rev {} · {} focus · {} external batches · {} PTY lines · {} total lines",
"rev {} · VT rev {} · {} focus · {} VT snapshots · {} synthetic lines",
self.model.revision(),
self.terminal.revision(),
counters.focus_moves,
counters.external_output_batches,
counters.external_lines_appended,
counters.external_snapshot_updates,
counters.terminal_lines_appended
);
let timing = self.timing.summary();
@@ -815,6 +945,10 @@ impl Render for LumbridgeShell {
.on_action(cx.listener(Self::focus_down))
.on_action(cx.listener(Self::open_palette))
.on_action(cx.listener(Self::close_palette))
.on_action(cx.listener(Self::terminal_taller))
.on_action(cx.listener(Self::terminal_shorter))
.on_action(cx.listener(Self::terminal_wider))
.on_action(cx.listener(Self::terminal_narrower))
.on_action(cx.listener(|shell, _: &SelectPane1, window, cx| {
shell.select_pane(PaneId::CodexRuntime, window, cx);
}))
@@ -908,13 +1042,62 @@ impl Render for LumbridgeShell {
}
}
fn start_live_runtime() -> Result<RuntimeActor, RuntimeActorError> {
fn key_modifiers(event: &KeyDownEvent) -> KeyModifiers {
let mut modifiers = KeyModifiers::default();
if event.keystroke.modifiers.shift {
modifiers = modifiers.union(KeyModifiers::SHIFT);
}
if event.keystroke.modifiers.alt {
modifiers = modifiers.union(KeyModifiers::ALT);
}
if event.keystroke.modifiers.control {
modifiers = modifiers.union(KeyModifiers::CONTROL);
}
if event.keystroke.modifiers.platform {
modifiers = modifiers.union(KeyModifiers::PLATFORM);
}
modifiers
}
fn terminal_key_from_parts(
key: &str,
key_char: Option<&str>,
modifiers: KeyModifiers,
) -> Option<TerminalKeyEvent> {
let key = match key {
"enter" => TerminalKey::Enter,
"backspace" => TerminalKey::Backspace,
"tab" => TerminalKey::Tab,
"escape" => TerminalKey::Escape,
"up" => TerminalKey::Up,
"down" => TerminalKey::Down,
"left" => TerminalKey::Left,
"right" => TerminalKey::Right,
"home" => TerminalKey::Home,
"end" => TerminalKey::End,
"insert" => TerminalKey::Insert,
"delete" => TerminalKey::Delete,
"pageup" => TerminalKey::PageUp,
"pagedown" => TerminalKey::PageDown,
function if function.starts_with('f') => {
let number = function.strip_prefix('f')?.parse::<u8>().ok()?;
TerminalKey::Function(number)
}
_ if !modifiers.contains(KeyModifiers::PLATFORM) => TerminalKey::Text(key_char?.to_owned()),
_ => return None,
};
Some(TerminalKeyEvent { key, modifiers })
}
fn start_live_runtime(dimensions: TerminalDimensions) -> Result<RuntimeActor, RuntimeActorError> {
let command = CommandConfig::new("/bin/sh")
.map_err(RuntimeActorError::Start)?
.args(["-c", LIVE_PTY_SCRIPT]);
let pty_size = TerminalSize::new(dimensions.rows(), dimensions.columns())
.map_err(RuntimeActorError::Start)?;
RuntimeActor::spawn(
command,
PtyOptions::default(),
PtyOptions::new(pty_size),
RuntimeActorOptions::default(),
)
}
@@ -922,22 +1105,21 @@ fn start_live_runtime() -> Result<RuntimeActor, RuntimeActorError> {
fn main() {
Application::new().run(|cx: &mut App| {
cx.bind_keys([
KeyBinding::new("left", FocusLeft, Some("LumbridgeShell")),
KeyBinding::new("right", FocusRight, Some("LumbridgeShell")),
KeyBinding::new("up", FocusUp, Some("LumbridgeShell")),
KeyBinding::new("down", FocusDown, Some("LumbridgeShell")),
KeyBinding::new("h", FocusLeft, Some("LumbridgeShell")),
KeyBinding::new("l", FocusRight, Some("LumbridgeShell")),
KeyBinding::new("k", FocusUp, Some("LumbridgeShell")),
KeyBinding::new("j", FocusDown, Some("LumbridgeShell")),
KeyBinding::new("alt-left", FocusLeft, Some("LumbridgeShell")),
KeyBinding::new("alt-right", FocusRight, Some("LumbridgeShell")),
KeyBinding::new("alt-up", FocusUp, Some("LumbridgeShell")),
KeyBinding::new("alt-down", FocusDown, Some("LumbridgeShell")),
KeyBinding::new("secondary-k", OpenPalette, Some("LumbridgeShell")),
KeyBinding::new("escape", ClosePalette, Some("LumbridgeShell")),
KeyBinding::new("1", SelectPane1, Some("LumbridgeShell")),
KeyBinding::new("2", SelectPane2, Some("LumbridgeShell")),
KeyBinding::new("3", SelectPane3, Some("LumbridgeShell")),
KeyBinding::new("4", SelectPane4, Some("LumbridgeShell")),
KeyBinding::new("5", SelectPane5, Some("LumbridgeShell")),
KeyBinding::new("6", SelectPane6, Some("LumbridgeShell")),
KeyBinding::new("alt-1", SelectPane1, Some("LumbridgeShell")),
KeyBinding::new("alt-2", SelectPane2, Some("LumbridgeShell")),
KeyBinding::new("alt-3", SelectPane3, Some("LumbridgeShell")),
KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")),
KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")),
KeyBinding::new("alt-6", SelectPane6, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-up", TerminalTaller, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-down", TerminalShorter, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-right", TerminalWider, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-left", TerminalNarrower, Some("LumbridgeShell")),
]);
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
@@ -959,22 +1141,21 @@ fn main() {
#[cfg(test)]
mod tests {
use super::PtyLineFramer;
use super::{KeyModifiers, TerminalKey, terminal_key_from_parts};
#[test]
fn frames_split_crlf_and_utf8_chunks_without_losing_bytes() {
let mut framer = PtyLineFramer::default();
assert!(framer.push(b"first\r").is_empty());
assert_eq!(framer.push(b"\nsecond\npart"), ["first", "second"]);
assert!(framer.push(&[0xf0, 0x9f]).is_empty());
assert!(framer.push(&[0x91, 0xa9]).is_empty());
assert_eq!(framer.finish(), ["part👩"]);
fn maps_named_and_composed_gpui_keys_to_terminal_input() {
let enter = terminal_key_from_parts("enter", None, KeyModifiers::default()).unwrap();
assert_eq!(enter.key, TerminalKey::Enter);
let composed = terminal_key_from_parts("é", Some("é"), KeyModifiers::default()).unwrap();
assert_eq!(composed.key, TerminalKey::Text("é".into()));
}
#[test]
fn replaces_invalid_utf8_only_after_a_complete_line() {
let mut framer = PtyLineFramer::default();
assert!(framer.push(&[0xff]).is_empty());
assert_eq!(framer.push(b"\n"), [""]);
fn reserves_platform_text_shortcuts_for_the_workspace() {
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap();
assert_eq!(control.key, TerminalKey::Text("c".into()));
}
}
+25
View File
@@ -288,6 +288,7 @@ pub enum ShellAction {
SetCommandPaletteQuery(String),
SyntheticStreamTick,
AppendExternalOutput { pane: PaneId, lines: Vec<String> },
ReplaceExternalOutput { pane: PaneId, lines: Vec<String> },
}
/// Counters prove both candidates replayed the same workload. Framework
@@ -308,6 +309,7 @@ pub struct MeasurementCounters {
pub terminal_lines_appended: u64,
pub external_output_batches: u64,
pub external_lines_appended: u64,
pub external_snapshot_updates: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -536,6 +538,21 @@ impl ShellModel {
changed = true;
}
}
ShellAction::ReplaceExternalOutput { pane, mut lines } => {
let pane = &mut self.panes[pane.index()];
if pane.output_source == OutputSource::External {
if lines.len() > self.terminal_line_limit {
lines.drain(..lines.len() - self.terminal_line_limit);
}
if pane.lines != lines {
pane.lines = lines;
self.counters.surface_updates += 1;
self.counters.external_output_batches += 1;
self.counters.external_snapshot_updates += 1;
changed = true;
}
}
}
}
if changed {
self.revision += 1;
@@ -786,6 +803,14 @@ mod tests {
assert_eq!(model.counters().surface_updates, 6);
assert_eq!(model.counters().external_output_batches, 1);
assert_eq!(model.counters().external_lines_appended, 2);
let snapshot = model.dispatch(ShellAction::ReplaceExternalOutput {
pane: PaneId::CodexRuntime,
lines: vec!["prompt $".into()],
});
assert!(snapshot.changed);
assert_eq!(model.pane(PaneId::CodexRuntime).lines(), ["prompt $"]);
assert_eq!(model.counters().external_snapshot_updates, 1);
}
#[test]