mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
fix(tui): the shell inspector follows a running job; Alt+V keeps the clicked card
Refs #6931. Acceptance 4 (follow mode; refresh without the model's cursor)
and acceptance 1 (a new card never takes over the target).
Follow mode:
- When the Bash Job pager opens on a running job, it records the job id.
- On its 2.5 s tick, the event loop re-reads that job through
`inspect_job_for_session`. This is a bounded read, under try_lock as
`refresh_shell_exec_live_output` does, and never touches the model's
output cursor. The event loop then swaps the text into the open pager.
- A view at the bottom stays pinned to the newest row. Scrolling up, Home,
`gg` or a search jump stops following; End, `G` or scrolling back to the
bottom resumes it.
- When the job ends or leaves the session, refresh stops and Stop is
removed, because it no longer applies (acceptance 5: inapplicable
controls are disabled).
- The Status line reads "running · no output for 2m 05s" once a live job
has been quiet for QUIET_NO_OUTPUT_AFTER. It uses the label the exec card
already uses.
- `ViewStack::top_pager_mut` is the downcast, next to `refresh_extensions`.
Alt+V target:
- 674dcc364d sent Alt+V with no selection to the newest running shell on
screen before anything else. That bypassed
`detail_cell_index_for_viewport`, which the footer hint also uses. So a
running card that scrolled into view took over the completed card the
user was reading, and the hint and the key disagreed. Alt+V now resolves
only through `detail_target_cell_index`.
`newest_visible_running_shell_cell` is deleted. A running shell stays
reachable by click, by the work dock and by /jobs.
- A selection stored only its rendered line. When a run of successful tool
cards collapsed above it, or cells were appended while an active cell was
open, that line pointed at another card. A click now also records its
owner cell as the existing `TranscriptActionOwner` (cell index plus
transcript identity epoch). Space-fold already uses it.
`rebase_active_cell_bindings` shifts the owner with the other bindings.
Any other identity change (folds, rollback) invalidates it, and the
target falls back to the line. Alt+V, the detail target and Ctrl+O
prefer the owner.
- CHANGELOG lines that advertised the old Alt+V rule are corrected.
Known limits, stated in shell_job_routing.rs:
- new output reaches the pager up to 2.5 s late;
- the work dock's inspector is still a snapshot;
- delegated agents' shells appear in /jobs and the dock with their Owner,
not in the agent's detail;
- named PTYs live in the terminal dock (#6912).
Focused tests: the issue asks for them in acceptance 7.
- pager: follow, unfollow and resume, and dropping Stop when the job ends;
- shell_job_routing: a silent running job reads quiet, not stale, with
"(no output yet)";
- the existing stale-list test now shares the snapshot fixture.
Evidence: cargo fmt --all clean. clippy -p codewhale-tui --lib --tests
(-D warnings) is running locally on this exact tree; the focused tests
run in hosted CI on codewhale-hq/Codewhale#6957.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BsXVvReYca2UxzimQtj7Mj
This commit is contained in:
8 files changed
+248
-49
No files matched your search
@@ -5630,6 +5630,34 @@ impl App {
|
||||
/// Pick the detail target for the current viewport. The footer hint and
|
||||
/// Alt+V both resolve through this so they agree on the target.
|
||||
#[must_use]
|
||||
pub(crate) fn transcript_owner_at_line(
|
||||
&self,
|
||||
line_index: usize,
|
||||
) -> Option<TranscriptActionOwner> {
|
||||
let (rendered, _) = self
|
||||
.viewport
|
||||
.transcript_cache
|
||||
.line_meta()
|
||||
.get(line_index)?
|
||||
.cell_line()?;
|
||||
Some(TranscriptActionOwner {
|
||||
cell_index: self.original_cell_index_for_rendered(rendered),
|
||||
identity_epoch: self.transcript_identity_epoch,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn selection_owner_cell(&self) -> Option<usize> {
|
||||
let selection = &self.viewport.transcript_selection;
|
||||
selection
|
||||
.owner
|
||||
.filter(|owner| {
|
||||
selection.is_active()
|
||||
&& owner.identity_epoch == self.transcript_identity_epoch
|
||||
&& self.cell_at_virtual_index(owner.cell_index).is_some()
|
||||
})
|
||||
.map(|owner| owner.cell_index)
|
||||
}
|
||||
|
||||
pub fn detail_cell_index_for_viewport(
|
||||
&self,
|
||||
top: usize,
|
||||
@@ -5641,11 +5669,14 @@ impl App {
|
||||
.map(|(idx, _)| self.original_cell_index_for_rendered(idx))
|
||||
};
|
||||
let selected = self
|
||||
.viewport
|
||||
.transcript_selection
|
||||
.ordered_endpoints()
|
||||
.and_then(|(start, _)| line_meta.get(start.line_index))
|
||||
.and_then(original)
|
||||
.selection_owner_cell()
|
||||
.or_else(|| {
|
||||
self.viewport
|
||||
.transcript_selection
|
||||
.ordered_endpoints()
|
||||
.and_then(|(start, _)| line_meta.get(start.line_index))
|
||||
.and_then(original)
|
||||
})
|
||||
.filter(|&idx| self.cell_has_detail_target(idx));
|
||||
let start = top.min(line_meta.len().saturating_sub(1));
|
||||
let end = start.saturating_add(visible).min(line_meta.len());
|
||||
@@ -5764,7 +5795,16 @@ impl App {
|
||||
}
|
||||
// A pre-insertion action still names the old virtual index. Reject
|
||||
// it until the renderer establishes the owner's new position.
|
||||
let previous_epoch = self.transcript_identity_epoch;
|
||||
self.transcript_identity_epoch = self.transcript_identity_epoch.wrapping_add(1);
|
||||
if let Some(owner) = self.viewport.transcript_selection.owner.as_mut()
|
||||
&& owner.identity_epoch == previous_epoch
|
||||
{
|
||||
if owner.cell_index >= boundary {
|
||||
owner.cell_index = owner.cell_index.saturating_add(added);
|
||||
}
|
||||
owner.identity_epoch = self.transcript_identity_epoch;
|
||||
}
|
||||
for index in self.tool_cells.values_mut() {
|
||||
if *index >= boundary {
|
||||
*index = index.saturating_add(added);
|
||||
|
||||
@@ -749,6 +749,8 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
|
||||
app.viewport.transcript_selection.anchor = Some(point);
|
||||
app.viewport.transcript_selection.head = Some(point);
|
||||
app.viewport.transcript_selection.dragging = true;
|
||||
app.viewport.transcript_selection.owner =
|
||||
app.transcript_owner_at_line(point.line_index);
|
||||
app.needs_redraw = true;
|
||||
|
||||
if app.is_loading
|
||||
|
||||
+104
-1
@@ -246,6 +246,8 @@ pub struct PagerView {
|
||||
/// (or key then Enter); Esc disarms before it closes the pager.
|
||||
destructive_action: Option<PagerDestructiveAction>,
|
||||
action_area: Cell<Option<Rect>>,
|
||||
live_shell_job: Option<String>,
|
||||
follow: bool,
|
||||
}
|
||||
|
||||
impl PagerView {
|
||||
@@ -270,9 +272,46 @@ impl PagerView {
|
||||
last_visible_height: Cell::new(0),
|
||||
destructive_action: None,
|
||||
action_area: Cell::new(None),
|
||||
live_shell_job: None,
|
||||
follow: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_live_shell_job(mut self, job_id: impl Into<String>) -> Self {
|
||||
self.live_shell_job = Some(job_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn live_shell_job(&self) -> Option<&str> {
|
||||
self.live_shell_job.as_deref()
|
||||
}
|
||||
|
||||
pub(crate) fn replace_live_text(&mut self, text: &str) -> bool {
|
||||
let page = self.current_page();
|
||||
if page.copy_text.as_deref() == Some(text) {
|
||||
return false;
|
||||
}
|
||||
if self.scroll.get() >= self.max_scroll() {
|
||||
self.follow = true;
|
||||
}
|
||||
let mut sanitized = String::with_capacity(text.len());
|
||||
crate::tui::osc8::strip_ansi_into(text, &mut sanitized);
|
||||
let page = self.current_page_mut();
|
||||
page.source = PagerSource::Text(sanitized);
|
||||
page.copy_text = Some(text.to_string());
|
||||
*page.rows.get_mut() = PagerRows::default();
|
||||
if !self.search_input.trim().is_empty() {
|
||||
self.update_search_matches();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn end_live_shell_job(&mut self) {
|
||||
self.live_shell_job = None;
|
||||
self.destructive_action = None;
|
||||
self.action_area.set(None);
|
||||
}
|
||||
|
||||
/// Attach a compact Markdown export (e.g. the #4108 turn handoff) that the
|
||||
/// `e` key copies to the clipboard. Only the Turn Inspector pager sets this;
|
||||
/// other pagers leave `e` inert.
|
||||
@@ -379,23 +418,28 @@ impl PagerView {
|
||||
self.search_index.set(0);
|
||||
self.search_mode = false;
|
||||
self.pending_g = false;
|
||||
self.follow = false;
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self, amount: usize) {
|
||||
self.scroll.set(self.scroll.get().saturating_sub(amount));
|
||||
self.follow = false;
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self, amount: usize, max_scroll: usize) {
|
||||
self.scroll
|
||||
.set(self.scroll.get().saturating_add(amount).min(max_scroll));
|
||||
self.follow = self.live_shell_job.is_some() && self.scroll.get() >= max_scroll;
|
||||
}
|
||||
|
||||
fn scroll_to_top(&mut self) {
|
||||
self.scroll.set(0);
|
||||
self.follow = false;
|
||||
}
|
||||
|
||||
fn scroll_to_bottom(&mut self, max_scroll: usize) {
|
||||
self.scroll.set(max_scroll);
|
||||
self.follow = self.live_shell_job.is_some();
|
||||
}
|
||||
|
||||
/// Plain-text rendered body of the pager joined with `\n`. This reflects
|
||||
@@ -492,6 +536,7 @@ impl PagerView {
|
||||
fn jump_to_match(&mut self) {
|
||||
if let Some(&row) = self.search_matches.get_mut().get(self.search_index.get()) {
|
||||
self.scroll.set(row);
|
||||
self.follow = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +912,12 @@ impl ModalView for PagerView {
|
||||
let rows = page.rows.borrow();
|
||||
let row_count = rows.plain.len();
|
||||
let max_scroll = row_count.saturating_sub(visible_height);
|
||||
let scroll = self.scroll.get().min(max_scroll);
|
||||
let scroll = if self.follow {
|
||||
self.scroll.set(max_scroll);
|
||||
max_scroll
|
||||
} else {
|
||||
self.scroll.get().min(max_scroll)
|
||||
};
|
||||
let end = (scroll + visible_height).min(row_count);
|
||||
let mut visible_lines: Vec<Line<'static>> = if row_count == 0 {
|
||||
vec![Line::from("")]
|
||||
@@ -1289,6 +1339,59 @@ mod tests {
|
||||
assert_eq!(p.scroll.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_shell_job_pager_follows_new_output_until_scrolled_back() {
|
||||
let body = |rows: usize| {
|
||||
(0..rows)
|
||||
.map(|row| format!("out-{row:03}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
let mut p = PagerView::from_text("Bash Job shell_1", &body(5), 40)
|
||||
.with_copy_text(body(5))
|
||||
.with_live_shell_job("shell_1");
|
||||
prime_layout(&mut p, 12);
|
||||
|
||||
assert!(p.replace_live_text(&body(60)));
|
||||
prime_layout(&mut p, 12);
|
||||
assert!(p.max_scroll() > 0);
|
||||
assert_eq!(p.scroll.get(), p.max_scroll());
|
||||
|
||||
let _ = p.handle_key(key(KeyCode::Char('k')));
|
||||
let held = p.scroll.get();
|
||||
assert!(p.replace_live_text(&body(80)));
|
||||
prime_layout(&mut p, 12);
|
||||
assert_eq!(p.scroll.get(), held);
|
||||
|
||||
let _ = p.handle_key(key(KeyCode::Char('G')));
|
||||
assert!(p.replace_live_text(&body(90)));
|
||||
prime_layout(&mut p, 12);
|
||||
assert_eq!(p.scroll.get(), p.max_scroll());
|
||||
assert!(!p.replace_live_text(&body(90)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ending_a_live_shell_job_drops_stop() {
|
||||
let mut p = make_pager(2)
|
||||
.with_live_shell_job("shell_1")
|
||||
.with_destructive_action(
|
||||
's',
|
||||
"stop",
|
||||
"confirm stop · Esc cancels",
|
||||
ViewEvent::SidebarAgentCancel {
|
||||
agent_id: "agent_1".to_string(),
|
||||
},
|
||||
);
|
||||
p.end_live_shell_job();
|
||||
assert!(p.live_shell_job().is_none());
|
||||
for _ in 0..2 {
|
||||
assert!(matches!(
|
||||
p.handle_key(key(KeyCode::Char('s'))),
|
||||
ViewAction::None
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_g_jumps_to_bottom() {
|
||||
let mut p = make_pager(50);
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct TranscriptSelection {
|
||||
pub anchor: Option<TranscriptSelectionPoint>,
|
||||
pub head: Option<TranscriptSelectionPoint>,
|
||||
pub dragging: bool,
|
||||
pub(crate) owner: Option<crate::tui::history::TranscriptActionOwner>,
|
||||
}
|
||||
|
||||
/// Drag-past-edge auto-scroll state. While the user holds the left button
|
||||
@@ -39,6 +40,7 @@ impl TranscriptSelection {
|
||||
self.anchor = None;
|
||||
self.head = None;
|
||||
self.dragging = false;
|
||||
self.owner = None;
|
||||
}
|
||||
|
||||
/// Whether a full selection is active.
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! Background shell job-center helpers for slash commands and pagers.
|
||||
//!
|
||||
//! Known limitations: the inspector is a snapshot (`/jobs poll` re-reads it,
|
||||
//! without consuming the model's output cursor); there is no follow mode.
|
||||
//! Named PTY sessions (`tools/terminal_session.rs`) are not listed here or in
|
||||
//! Known limitations: the Bash Job pager follows a running job by re-reading
|
||||
//! it on the event loop's 2.5 s refresh (a bounded read that never consumes
|
||||
//! the model's output cursor), so new output lands up to 2.5 s late; the work
|
||||
//! dock's inspector is still a snapshot. Shells run by delegated agents show
|
||||
//! in `/jobs` and the dock with their Owner, not in the agent's detail. Named
|
||||
//! PTY sessions (`tools/terminal_session.rs`) are not listed here or in
|
||||
//! `/jobs`; they live in the terminal dock (#6912).
|
||||
|
||||
use crate::tools::shell::{ShellJobDetail, ShellJobSnapshot, ShellResult, ShellStatus};
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
|
||||
use crate::tui::history::{HistoryCell, ToolCell};
|
||||
use crate::tui::pager::PagerView;
|
||||
use crate::tui::views::{CommandPaletteAction, ViewEvent};
|
||||
use codewhale_localization::MessageId;
|
||||
@@ -144,25 +147,6 @@ pub(crate) fn shell_job_for_cell(app: &App, cell_index: usize) -> Option<ShellJo
|
||||
manager.inspect_job_for_session(session, &job_id).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn newest_visible_running_shell_cell(app: &App) -> Option<usize> {
|
||||
let meta = app.viewport.transcript_cache.line_meta();
|
||||
let start = app.viewport.last_transcript_top.min(meta.len());
|
||||
let end = start
|
||||
.saturating_add(app.viewport.last_transcript_visible.max(1))
|
||||
.min(meta.len());
|
||||
meta[start..end]
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|line| line.cell_line())
|
||||
.map(|(rendered, _)| app.original_cell_index_for_rendered(rendered))
|
||||
.find(|&index| {
|
||||
matches!(
|
||||
app.cell_at_virtual_index(index),
|
||||
Some(HistoryCell::Tool(ToolCell::Exec(exec))) if exec.status == ToolStatus::Running
|
||||
) && shell_job_for_cell(app, index).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn open_shell_job_pager(app: &mut App, detail: &ShellJobDetail) {
|
||||
let width = app
|
||||
.viewport
|
||||
@@ -179,6 +163,7 @@ pub(crate) fn open_shell_job_pager(app: &mut App, detail: &ShellJobDetail) {
|
||||
)
|
||||
.with_copy_text(body);
|
||||
if live {
|
||||
pager = pager.with_live_shell_job(detail.snapshot.id.clone());
|
||||
pager = pager.with_destructive_action(
|
||||
's',
|
||||
app.tr(MessageId::SidebarStopControl),
|
||||
@@ -193,11 +178,55 @@ pub(crate) fn open_shell_job_pager(app: &mut App, detail: &ShellJobDetail) {
|
||||
app.view_stack.push(pager);
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_live_shell_job_pager(app: &mut App) -> bool {
|
||||
let Some(job_id) = app
|
||||
.view_stack
|
||||
.top_pager_mut()
|
||||
.and_then(|pager| pager.live_shell_job().map(str::to_owned))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(shell_manager) = app.runtime_services.shell_manager.clone() else {
|
||||
return false;
|
||||
};
|
||||
let session = app.current_session_id.clone().unwrap_or_default();
|
||||
let detail = {
|
||||
let Ok(mut manager) = shell_manager.try_lock() else {
|
||||
return false;
|
||||
};
|
||||
manager.inspect_job_for_session(&session, &job_id).ok()
|
||||
};
|
||||
let Some(pager) = app.view_stack.top_pager_mut() else {
|
||||
return false;
|
||||
};
|
||||
let Some(detail) = detail else {
|
||||
pager.end_live_shell_job();
|
||||
return true;
|
||||
};
|
||||
let changed = pager.replace_live_text(&format_shell_job_detail(&detail));
|
||||
if detail.snapshot.status != ShellStatus::Running {
|
||||
pager.end_live_shell_job();
|
||||
return true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn detail_status_label(job: &ShellJobSnapshot) -> String {
|
||||
let quiet =
|
||||
u64::try_from(crate::tools::shell::QUIET_NO_OUTPUT_AFTER.as_millis()).unwrap_or(u64::MAX);
|
||||
match job.elapsed_since_output_ms {
|
||||
Some(ms) if job.status == ShellStatus::Running && !job.stale && ms >= quiet => {
|
||||
crate::tui::history::stale_shell_status_label(ms)
|
||||
}
|
||||
_ => status_label(&job.status, job.stale).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_shell_job_detail(detail: &ShellJobDetail) -> String {
|
||||
let job = &detail.snapshot;
|
||||
let mut lines = vec![
|
||||
format!("Job: {}", job.id),
|
||||
format!("Status: {}", status_label(&job.status, job.stale)),
|
||||
format!("Status: {}", detail_status_label(job)),
|
||||
format!("Command: {}", job.command),
|
||||
format!("Cwd: {}", crate::utils::display_path(&job.cwd)),
|
||||
];
|
||||
@@ -260,14 +289,13 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn list_shows_controls_and_stale_state() {
|
||||
let jobs = vec![ShellJobSnapshot {
|
||||
fn snapshot(status: ShellStatus, stale: bool) -> ShellJobSnapshot {
|
||||
ShellJobSnapshot {
|
||||
id: "shell_dead".to_string(),
|
||||
job_id: "shell_dead".to_string(),
|
||||
command: "cargo test".to_string(),
|
||||
cwd: PathBuf::from("/tmp/repo"),
|
||||
status: ShellStatus::Killed,
|
||||
status,
|
||||
exit_code: None,
|
||||
elapsed_ms: 0,
|
||||
stdout_tail: String::new(),
|
||||
@@ -275,7 +303,7 @@ mod tests {
|
||||
stdout_len: 0,
|
||||
stderr_len: 8,
|
||||
stdin_available: false,
|
||||
stale: true,
|
||||
stale,
|
||||
elapsed_since_output_ms: None,
|
||||
linked_task_id: Some("task_1".to_string()),
|
||||
owner_agent_id: None,
|
||||
@@ -285,7 +313,26 @@ mod tests {
|
||||
owner_session_id: "session-test".to_string(),
|
||||
background: true,
|
||||
finished_at: Some(chrono::Utc::now()),
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_running_job_detail_reads_quiet_not_stale() {
|
||||
let mut job = snapshot(ShellStatus::Running, false);
|
||||
job.elapsed_since_output_ms = Some(125_000);
|
||||
let body = format_shell_job_detail(&ShellJobDetail {
|
||||
snapshot: job,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
});
|
||||
assert!(body.contains("Status: running · no output for"), "{body}");
|
||||
assert!(body.contains("(no output yet)"), "{body}");
|
||||
assert!(!body.contains("stale"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_shows_controls_and_stale_state() {
|
||||
let jobs = vec![snapshot(ShellStatus::Killed, true)];
|
||||
let formatted = format_shell_job_list(&jobs);
|
||||
assert!(formatted.contains("Bash jobs (1)"));
|
||||
assert!(!formatted.contains("Background commands"));
|
||||
|
||||
@@ -18,6 +18,9 @@ use crate::tui::ui_text::{
|
||||
use codewhale_localization::{MessageId, tr};
|
||||
|
||||
fn selected_transcript_cell_index(app: &App) -> Option<usize> {
|
||||
if let Some(owner) = app.selection_owner_cell() {
|
||||
return Some(owner);
|
||||
}
|
||||
app.viewport
|
||||
.transcript_selection
|
||||
.ordered_endpoints()
|
||||
@@ -275,19 +278,7 @@ const RAW_DETAIL_PAGER_INTRO: &str =
|
||||
"Raw detail for the selected item — press Ctrl+Alt+O for the whole-turn overview.";
|
||||
|
||||
pub(super) fn open_tool_details_pager(app: &mut App) -> bool {
|
||||
let target_cell = if app
|
||||
.viewport
|
||||
.transcript_selection
|
||||
.ordered_endpoints()
|
||||
.is_some()
|
||||
{
|
||||
detail_target_cell_index(app)
|
||||
} else {
|
||||
crate::tui::shell_job_routing::newest_visible_running_shell_cell(app)
|
||||
.or_else(|| detail_target_cell_index(app))
|
||||
};
|
||||
|
||||
let Some(cell_index) = target_cell else {
|
||||
let Some(cell_index) = detail_target_cell_index(app) else {
|
||||
app.status_message = Some(NO_RAW_DETAIL_HINT.to_string());
|
||||
return false;
|
||||
};
|
||||
@@ -594,6 +585,9 @@ fn turn_answer_payload(app: &App, start: usize, end: usize, width: u16) -> Optio
|
||||
}
|
||||
|
||||
pub(super) fn detail_target_cell_index(app: &App) -> Option<usize> {
|
||||
if let Some(owner) = app.selection_owner_cell() {
|
||||
return Some(owner);
|
||||
}
|
||||
if let Some((start, _)) = app.viewport.transcript_selection.ordered_endpoints() {
|
||||
return app
|
||||
.viewport
|
||||
|
||||
@@ -466,6 +466,7 @@ pub(super) fn handle_transcript_space(app: &mut App) -> bool {
|
||||
app.viewport.transcript_selection.clear();
|
||||
app.viewport.transcript_selection.anchor = Some(point);
|
||||
app.viewport.transcript_selection.head = Some(point);
|
||||
app.viewport.transcript_selection.owner = Some(owner);
|
||||
}
|
||||
app.mark_history_updated();
|
||||
true
|
||||
@@ -2228,6 +2229,9 @@ pub(crate) async fn run_event_loop(
|
||||
if refresh_shell_exec_live_output(app) {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
if crate::tui::shell_job_routing::refresh_live_shell_job_pager(app) {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
if app
|
||||
.runtime_services
|
||||
.work
|
||||
|
||||
@@ -1593,6 +1593,13 @@ impl ViewStack {
|
||||
panel.refresh_snapshot(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn top_pager_mut(&mut self) -> Option<&mut crate::tui::pager::PagerView> {
|
||||
self.views
|
||||
.last_mut()?
|
||||
.as_any_mut()
|
||||
.downcast_mut::<crate::tui::pager::PagerView>()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ViewStack {
|
||||
|
||||
Reference in new issue
Block a user