Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 41 additions & 48 deletions Cargo.lock

Large diffs are not rendered by default.

39 changes: 19 additions & 20 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,27 +49,26 @@ uuid = "1"

# The CLI tracks one reviewed Lash release. Dependency updates are explicit,
# testable compatibility changes rather than an implicit lockstep workspace.
lash = { package = "lash-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-core = { package = "lash-internal-core", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-lashlang-runtime = { package = "lash-internal-lashlang-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-llm-tools = { package = "lash-internal-llm-tools", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-plugin-mcp = { package = "lash-internal-plugin-mcp", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash = { package = "lash-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-lashlang-runtime = { package = "lash-internal-lashlang-runtime", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-llm-tools = { package = "lash-internal-llm-tools", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-plugin-mcp = { package = "lash-internal-plugin-mcp", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
# lash-plugin-plan-mode removed upstream
lash-plugin-process-controls = { package = "lash-internal-plugin-process-controls", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-protocol-rlm = { package = "lash-internal-protocol-rlm", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-protocol-standard = { package = "lash-internal-protocol-standard", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-provider-anthropic = { package = "lash-internal-provider-anthropic", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-provider-auth = { package = "lash-internal-provider-auth", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-provider-google = { package = "lash-internal-provider-google", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-provider-openai = { package = "lash-internal-provider-openai", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-rlm-types = { package = "lash-internal-rlm-types", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-sqlite-store = { package = "lash-internal-sqlite-store", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-standard-plugins = { package = "lash-internal-standard-plugins", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-subagents = { package = "lash-internal-subagents", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-tool-support = { package = "lash-internal-tool-support", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-trace = { package = "lash-internal-trace", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-typescript = { package = "lash-internal-typescript", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lashlang = { package = "lash-internal-lashlang", git = "https://github.com/Ascending-AI/lash.git", rev = "8dac97fa5" }
lash-plugin-process-controls = { package = "lash-internal-plugin-process-controls", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-protocol-rlm = { package = "lash-internal-protocol-rlm", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-protocol-standard = { package = "lash-internal-protocol-standard", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-provider-anthropic = { package = "lash-internal-provider-anthropic", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-provider-auth = { package = "lash-internal-provider-auth", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-provider-google = { package = "lash-internal-provider-google", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-provider-openai = { package = "lash-internal-provider-openai", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-rlm-types = { package = "lash-internal-rlm-types", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-sqlite-store = { package = "lash-internal-sqlite-store", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-standard-plugins = { package = "lash-internal-standard-plugins", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-subagents = { package = "lash-internal-subagents", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-tool-support = { package = "lash-internal-tool-support", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-trace = { package = "lash-internal-trace", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lash-typescript = { package = "lash-internal-typescript", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }
lashlang = { package = "lash-internal-lashlang", git = "https://github.com/Ascending-AI/lash.git", rev = "9debf5aab75baa3074c27cf42563e169a79ec396" }

lash-autoresearch = { path = "crates/lash-autoresearch" }
lash-cli-perf = { path = "crates/lash-cli-perf" }
Expand Down
147 changes: 131 additions & 16 deletions crates/lash-cli/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ pub(crate) use self::projection::{
};
pub(crate) use self::queues::turn_input_display_text;

const PROCESS_RETENTION: std::time::Duration = std::time::Duration::from_secs(10);

pub(crate) struct ProcessSnapshot {
pub view: ProcessHandleView,
pub updated_at_ms: Option<u64>,
}

fn process_updated_within_retention(updated_at_ms: u64, now_ms: u64) -> bool {
now_ms
.checked_sub(updated_at_ms)
.is_some_and(|age_ms| age_ms <= PROCESS_RETENTION.as_millis() as u64)
}

fn user_turn_start_indices(blocks: &[UiTimelineItem]) -> Vec<usize> {
blocks
.iter()
Expand Down Expand Up @@ -816,6 +829,14 @@ impl App {
let before_tasks = self.processes.len();
self.processes.retain(ProcessView::is_visible);
if self.processes.len() != before_tasks {
if self.selected_process_id.as_deref().is_some_and(|selected| {
!self
.processes
.iter()
.any(|process| process.process_id == selected)
}) {
self.selected_process_id = None;
}
self.invalidate_height_cache();
self.dirty = true;
}
Expand Down Expand Up @@ -1046,38 +1067,45 @@ impl App {
self.dirty = true;
}

pub fn update_processes(&mut self, tasks: Vec<ProcessHandleView>) {
pub fn update_processes(&mut self, snapshots: Vec<ProcessSnapshot>) {
let now = std::time::Instant::now();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_millis() as u64);
let previous: HashMap<String, ProcessView> = self
.processes
.iter()
.cloned()
.map(|task| (task.process_id.clone(), task))
.collect();
let mut next = Vec::new();
for task in tasks {
let old_status = previous
.get(&task.process_id)
.and_then(|item| item.status.is_terminal().then_some(item.status));
for snapshot in snapshots {
let ProcessSnapshot {
view: task,
updated_at_ms,
} = snapshot;
let previous_process = previous.get(&task.process_id);
let status = task.status.is_terminal().then_some(task.status);
let first_seen = previous
.get(&task.process_id)
.map(|item| item.first_seen)
.unwrap_or(now);
let first_seen = previous_process.map(|item| item.first_seen).unwrap_or(now);
let status_duration = if status.is_some() {
previous
.get(&task.process_id)
previous_process
.and_then(|item| item.status_duration)
.or_else(|| Some(first_seen.elapsed()))
} else {
None
};
let transient_until = if status.is_some() && old_status != status {
Some(now + std::time::Duration::from_secs(10))
let transient_until = if status.is_none() {
None
} else if previous_process.is_some_and(|item| !item.status.is_terminal()) {
Some(now + PROCESS_RETENTION)
} else if let Some(previous_process) = previous_process {
previous_process.transient_until
} else if updated_at_ms.is_some_and(|updated_at_ms| {
process_updated_within_retention(updated_at_ms, now_ms)
}) {
Some(now + PROCESS_RETENTION)
} else {
previous
.get(&task.process_id)
.and_then(|item| item.transient_until)
None
};
next.push(ProcessView::from_summary(
task,
Expand Down Expand Up @@ -1212,3 +1240,90 @@ impl App {
}

pub use lash_export::transcript::format_tokens;

#[cfg(test)]
mod process_retention_tests {
use super::*;

fn process(
process_id: &str,
status: ProcessStatus,
updated_at_ms: Option<u64>,
) -> ProcessSnapshot {
ProcessSnapshot {
view: ProcessHandleView::new(
process_id,
lash::process::ProcessIdentity::new("subagent").with_label(Some("spawn")),
status,
),
updated_at_ms,
}
}

fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("current time follows Unix epoch")
.as_millis() as u64
}

#[test]
fn running_process_stays_visible_during_terminal_retention_then_prunes() {
let mut app = App::new("test-model".into(), "test".into(), "session".into());

app.update_processes(vec![process("process-1", ProcessStatus::Running, None)]);
app.update_processes(vec![process("process-1", ProcessStatus::Completed, None)]);

assert_eq!(app.processes.len(), 1);
assert_eq!(app.processes[0].status, ProcessStatus::Completed);
assert!(app.processes[0].transient_until.is_some());

app.processes[0].transient_until = Some(std::time::Instant::now());
app.on_tick();

assert!(app.processes.is_empty());
}

#[test]
fn first_seen_terminal_process_after_initial_snapshot_is_visible_transiently() {
let mut app = App::new("test-model".into(), "test".into(), "session".into());

app.update_processes(Vec::new());
app.update_processes(vec![process(
"process-1",
ProcessStatus::Completed,
Some(now_ms()),
)]);

assert_eq!(app.processes.len(), 1);
assert!(app.processes[0].transient_until.is_some());
}

#[test]
fn first_seen_terminal_process_in_initial_snapshot_is_not_resurrected() {
let mut app = App::new("test-model".into(), "test".into(), "session".into());

app.update_processes(vec![process(
"process-1",
ProcessStatus::Completed,
Some(now_ms() - PROCESS_RETENTION.as_millis() as u64 - 1),
)]);

assert!(app.processes.is_empty());
}

#[test]
fn selected_process_clears_when_terminal_row_prunes() {
let mut app = App::new("test-model".into(), "test".into(), "session".into());

app.update_processes(vec![process("process-1", ProcessStatus::Running, None)]);
app.update_processes(vec![process("process-1", ProcessStatus::Completed, None)]);
assert!(app.select_next_process());
app.processes[0].transient_until = Some(std::time::Instant::now());

app.on_tick();

assert!(app.processes.is_empty());
assert!(app.selected_process_id.is_none());
}
}
2 changes: 2 additions & 0 deletions crates/lash-cli/src/app/runtime_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,8 @@ impl App {
/// Process a semantic turn activity, updating display blocks.
pub fn handle_turn_activity(&mut self, activity: TurnActivity) {
match activity.event {
// Active-turn targeting is owned by `ActiveTurnTargetSink`.
TurnEvent::TurnStarted { .. } => {}
TurnEvent::ReasoningDelta { text } => {
self.live.model_output_chunks.push(ModelOutputChunk {
correlation_id: activity.correlation_id,
Expand Down
1 change: 1 addition & 0 deletions crates/lash-cli/src/autonomous.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ impl AutonomousRenderer {

pub(crate) fn handle(&mut self, activity: TurnActivity) -> Result<(), String> {
match activity.event {
TurnEvent::TurnStarted { .. } => {}
TurnEvent::AssistantProseDelta { text } => {
if !text.is_empty() {
self.streamed_text = true;
Expand Down
3 changes: 1 addition & 2 deletions crates/lash-cli/src/event.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use crossterm::event::Event as TermEvent;
use lash::TurnActivity;
use lash::observe::SessionProcessEventKind;
use lash::process::ProcessHandleView;
use lash_tui_extensions::TuiHostEffect;
use tokio::sync::mpsc;

Expand Down Expand Up @@ -60,7 +59,7 @@ pub enum AppEvent {

pub struct UiSnapshotResult {
pub effects: Vec<TuiHostEffect>,
pub processes: Option<Vec<ProcessHandleView>>,
pub processes: Option<Vec<crate::app::ProcessSnapshot>>,
pub duration: std::time::Duration,
pub timed_out: bool,
pub diagnostics: Vec<String>,
Expand Down
4 changes: 2 additions & 2 deletions crates/lash-cli/src/interactive/input_handling/modals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,11 @@ pub(super) async fn cancel_selected_process(app: &mut App, runtime: &Option<Lash
summary.status.label()
),
);
match processes.list().await {
match processes.list_all().await {
Ok(processes) => app.update_processes(
processes
.into_iter()
.map(crate::ui_effects::observed_to_handle_summary)
.map(crate::ui_effects::observed_to_process_snapshot)
.collect(),
),
Err(err) => {
Expand Down
4 changes: 2 additions & 2 deletions crates/lash-cli/src/interactive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -672,8 +672,8 @@ pub(crate) async fn run_app(
timed_out = result.timed_out,
"applying UI snapshot"
);
if let Some(tasks) = result.processes {
app.update_processes(tasks);
if let Some(process_snapshots) = result.processes {
app.update_processes(process_snapshots);
}
for diagnostic in result.diagnostics {
tracing::warn!(%diagnostic, "UI snapshot diagnostic");
Expand Down
20 changes: 12 additions & 8 deletions crates/lash-cli/src/render/compositor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,14 @@ mod tests {
Arc::new(TuiExtensions::new(vec![chrome_ext]).expect("chrome extension"));
app.set_ui_extensions(ui_extensions);
app.set_chrome_state(chrome_state);
app.update_processes(vec![lash::process::ProcessHandleView::new(
"process-1",
lash::process::ProcessIdentity::new("lashlang").with_label(Some("responder")),
lash::process::ProcessStatus::Running,
)]);
app.update_processes(vec![crate::app::ProcessSnapshot {
view: lash::process::ProcessHandleView::new(
"process-1",
lash::process::ProcessIdentity::new("lashlang").with_label(Some("responder")),
lash::process::ProcessStatus::Running,
),
updated_at_ms: None,
}]);
sync_chrome_turn_status(&app);

let snapshot = lash_tui::render_snapshot(80, 10, |frame| draw(frame, &mut app));
Expand Down Expand Up @@ -421,8 +424,8 @@ mod tests {
#[test]
fn process_dock_renders_below_input_and_overview_as_overlay() {
let mut app = App::new("gpt-5.4".into(), "test".into(), "test-session-id".into());
app.update_processes(vec![
lash::process::ProcessHandleView::new(
app.update_processes(vec![crate::app::ProcessSnapshot {
view: lash::process::ProcessHandleView::new(
"process-1",
lash::process::ProcessIdentity::new("lashlang")
.with_label(Some("responder"))
Expand All @@ -437,7 +440,8 @@ mod tests {
)),
lash::process::ProcessStatus::Running,
),
]);
updated_at_ms: None,
}]);
app.select_next_process();

let areas = render::chrome_areas(&app, 80, 16);
Expand Down
8 changes: 5 additions & 3 deletions crates/lash-cli/src/startup/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,11 @@ impl CliSessionOpener {
.process_registry(process_registry)
.trigger_store(trigger_store)
.trace_level(self.trace_level);
if self.owns_visible_queued_turns {
builder = builder.disable_queued_work_driver();
}
builder = if self.owns_visible_queued_turns {
builder.without_queued_work()
} else {
builder.with_native_queued_work()
};
if let Some(trace_path) = self.trace_jsonl_path.clone() {
builder = builder.trace_jsonl_path(trace_path);
}
Expand Down
Loading
Loading