M4: jobs pane, subtask drill-in, reminders
Adds the jobs pane to the TUI with a new snapshot test, subtask drill-in navigation, and periodic reminders injected into the engine loop for long-running background jobs.
This commit is contained in:
@@ -23,6 +23,10 @@ pub enum InputAction {
|
||||
SessionPickerUp,
|
||||
SessionPickerDown,
|
||||
SessionPickerSelect,
|
||||
OpenJobs,
|
||||
JobsUp,
|
||||
JobsDown,
|
||||
JobsDrillIn,
|
||||
}
|
||||
|
||||
/// Translate a crossterm event into an action and/or mutate `state` directly.
|
||||
@@ -49,10 +53,21 @@ fn handle_key(key: KeyEvent, state: &mut AppState) -> InputAction {
|
||||
match &state.modal {
|
||||
ModalState::Permission { .. } => handle_permission_key(key, state),
|
||||
ModalState::SessionPicker { .. } => handle_session_picker_key(key, state),
|
||||
ModalState::JobsPane { .. } => handle_jobs_pane_key(key, state),
|
||||
ModalState::None => handle_normal_key(key, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_jobs_pane_key(key: KeyEvent, _state: &mut AppState) -> InputAction {
|
||||
match key.code {
|
||||
KeyCode::Up => InputAction::JobsUp,
|
||||
KeyCode::Down => InputAction::JobsDown,
|
||||
KeyCode::Enter => InputAction::JobsDrillIn,
|
||||
KeyCode::Esc => InputAction::CloseModal,
|
||||
_ => InputAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_permission_key(key: KeyEvent, _state: &mut AppState) -> InputAction {
|
||||
match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||
@@ -119,6 +134,7 @@ fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> InputAction {
|
||||
}
|
||||
}
|
||||
KeyCode::Char('s') if ctrl => InputAction::LoadSessions,
|
||||
KeyCode::Char('j') if ctrl => InputAction::OpenJobs,
|
||||
KeyCode::Up => {
|
||||
let (row, _) = state.input.cursor();
|
||||
if row == 0 {
|
||||
@@ -158,6 +174,7 @@ fn parse_slash_command(text: &str) -> Option<InputAction> {
|
||||
"/model" if !rest.is_empty() => Some(InputAction::SetModel(rest)),
|
||||
"/agent" if !rest.is_empty() => Some(InputAction::SetAgent(rest)),
|
||||
"/sessions" => Some(InputAction::LoadSessions),
|
||||
"/jobs" => Some(InputAction::OpenJobs),
|
||||
"/quit" => Some(InputAction::Quit),
|
||||
_ => None,
|
||||
}
|
||||
@@ -235,6 +252,45 @@ pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &En
|
||||
}
|
||||
}
|
||||
}
|
||||
InputAction::OpenJobs => {
|
||||
if let Some(session_id) = state.session_id.clone() {
|
||||
match engine.jobs(session_id).await {
|
||||
Ok(jobs) => {
|
||||
state.set_jobs(jobs);
|
||||
state.open_jobs_pane();
|
||||
}
|
||||
Err(e) => tracing::error!(error = %e, "failed to load jobs"),
|
||||
}
|
||||
}
|
||||
}
|
||||
InputAction::JobsUp => {
|
||||
if let ModalState::JobsPane { selected } = &mut state.modal {
|
||||
*selected = selected.saturating_sub(1);
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
InputAction::JobsDown => {
|
||||
let job_count = state.jobs.len();
|
||||
if let ModalState::JobsPane { selected } = &mut state.modal {
|
||||
if *selected + 1 < job_count {
|
||||
*selected += 1;
|
||||
}
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
InputAction::JobsDrillIn => {
|
||||
if let Some(child) = state.selected_job_child() {
|
||||
match engine.get_session(child).await {
|
||||
Ok(Some(session)) => {
|
||||
if let Err(e) = modal::select_session(state, engine, session).await {
|
||||
tracing::error!(error = %e, "failed to open subtask session");
|
||||
}
|
||||
}
|
||||
Ok(None) => tracing::warn!("subtask session no longer exists"),
|
||||
Err(e) => tracing::error!(error = %e, "failed to load subtask session"),
|
||||
}
|
||||
}
|
||||
}
|
||||
InputAction::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,17 @@ pub async fn select_session(
|
||||
session: Session,
|
||||
) -> Result<(), harness_app::AppError> {
|
||||
let session_id = session.id.clone();
|
||||
let messages = engine.session_messages(session_id).await?;
|
||||
let messages = engine.session_messages(session_id.clone()).await?;
|
||||
let mut all_parts = Vec::new();
|
||||
for message in &messages {
|
||||
let parts = engine.message_parts(message.id.clone()).await?;
|
||||
all_parts.extend(parts);
|
||||
}
|
||||
state.set_session(session, messages, all_parts);
|
||||
// Surface any subtasks this session spawned (drill-in and session-switch both land here).
|
||||
if let Ok(jobs) = engine.jobs(session_id).await {
|
||||
state.set_jobs(jobs);
|
||||
}
|
||||
state.modal = ModalState::None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ pub fn render(frame: &mut Frame, state: &mut AppState) {
|
||||
ModalState::SessionPicker { sessions, selected } => {
|
||||
render_session_picker(frame, sessions, *selected, area);
|
||||
}
|
||||
ModalState::JobsPane { selected } => {
|
||||
render_jobs_pane(frame, &state.jobs, *selected, area);
|
||||
}
|
||||
ModalState::None => {}
|
||||
}
|
||||
}
|
||||
@@ -169,7 +172,7 @@ fn render_status(frame: &mut Frame, state: &AppState, area: Rect) {
|
||||
let hints = if state.ctrl_c_pressed {
|
||||
"Press Ctrl+C again to quit"
|
||||
} else {
|
||||
"Ctrl+S: sessions | Esc: abort | Ctrl+C: quit"
|
||||
"Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C: quit"
|
||||
};
|
||||
let usage = format!(
|
||||
"{} · ${:.4}",
|
||||
@@ -242,6 +245,86 @@ fn render_session_picker(
|
||||
frame.render_widget(paragraph, popup);
|
||||
}
|
||||
|
||||
fn render_jobs_pane(
|
||||
frame: &mut Frame,
|
||||
jobs: &[harness_core::engine::JobRecord],
|
||||
selected: usize,
|
||||
area: Rect,
|
||||
) {
|
||||
let popup = centered_rect(70, 70, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
|
||||
let mut text: Vec<Line<'static>> =
|
||||
vec![Line::from("Background jobs").style(Style::new().add_modifier(Modifier::BOLD))];
|
||||
|
||||
if jobs.is_empty() {
|
||||
text.push(Line::default());
|
||||
text.push(Line::from("No subtasks spawned yet.").style(Style::new().fg(Color::DarkGray)));
|
||||
} else {
|
||||
for (i, job) in jobs.iter().enumerate() {
|
||||
let marker = if i == selected { "> " } else { " " };
|
||||
let (icon, color) = job_state_style(job.state);
|
||||
let header = format!(
|
||||
"{marker}{icon} {} · {} · {}",
|
||||
job.alias,
|
||||
job.agent,
|
||||
job_state_label(job.state),
|
||||
);
|
||||
let style = if i == selected {
|
||||
Style::new().bg(Color::Blue).fg(Color::White)
|
||||
} else {
|
||||
Style::new().fg(color)
|
||||
};
|
||||
text.push(Line::from(Span::styled(header, style)));
|
||||
if let Some(objective) = &job.objective {
|
||||
text.push(
|
||||
Line::from(format!(" {objective}")).style(Style::new().fg(Color::Gray)),
|
||||
);
|
||||
}
|
||||
if !job.context_files.is_empty() {
|
||||
let files: Vec<&str> = job
|
||||
.context_files
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|f| f.path.as_str())
|
||||
.collect();
|
||||
text.push(
|
||||
Line::from(format!(" read: {}", files.join(", ")))
|
||||
.style(Style::new().fg(Color::DarkGray)),
|
||||
);
|
||||
}
|
||||
}
|
||||
text.push(Line::default());
|
||||
text.push(
|
||||
Line::from("Enter: open subtask · Esc: close").style(Style::new().fg(Color::DarkGray)),
|
||||
);
|
||||
}
|
||||
|
||||
let block = Block::default().borders(Borders::ALL).title(" jobs ");
|
||||
let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: false });
|
||||
frame.render_widget(paragraph, popup);
|
||||
}
|
||||
|
||||
fn job_state_label(state: harness_core::engine::JobState) -> &'static str {
|
||||
use harness_core::engine::JobState;
|
||||
match state {
|
||||
JobState::Running => "running",
|
||||
JobState::Completed => "completed",
|
||||
JobState::Error => "error",
|
||||
JobState::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
fn job_state_style(state: harness_core::engine::JobState) -> (&'static str, Color) {
|
||||
use harness_core::engine::JobState;
|
||||
match state {
|
||||
JobState::Running => ("⚙", Color::Yellow),
|
||||
JobState::Completed => ("✓", Color::Green),
|
||||
JobState::Error => ("✗", Color::Red),
|
||||
JobState::Cancelled => ("∅", Color::DarkGray),
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
@@ -487,6 +570,59 @@ mod tests {
|
||||
insta::assert_snapshot!(buffer_to_string(terminal.backend()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_jobs_pane() {
|
||||
use harness_core::engine::{ContextFile, JobRecord, JobState};
|
||||
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new(
|
||||
"anthropic/claude-sonnet-4-5".to_string(),
|
||||
"orchestrator".to_string(),
|
||||
);
|
||||
state.session_id = Some(SessionId("ses_test_001".to_string()));
|
||||
state.jobs = vec![
|
||||
JobRecord {
|
||||
task_id: "t1".to_string(),
|
||||
alias: "exp-1".to_string(),
|
||||
parent_session: SessionId("ses_test_001".to_string()),
|
||||
child_session: SessionId("ses_child_001".to_string()),
|
||||
agent: "explorer".to_string(),
|
||||
description: "map auth".to_string(),
|
||||
objective: Some("map the auth flow".to_string()),
|
||||
state: JobState::Completed,
|
||||
reconciled: true,
|
||||
result_summary: Some("done".to_string()),
|
||||
context_files: vec![ContextFile {
|
||||
path: "src/auth.rs".to_string(),
|
||||
lines: 42,
|
||||
}],
|
||||
launched_at: 1,
|
||||
updated_at: 2,
|
||||
last_used_at: 2,
|
||||
},
|
||||
JobRecord {
|
||||
task_id: "t2".to_string(),
|
||||
alias: "fix-1".to_string(),
|
||||
parent_session: SessionId("ses_test_001".to_string()),
|
||||
child_session: SessionId("ses_child_002".to_string()),
|
||||
agent: "fixer".to_string(),
|
||||
description: "patch bug".to_string(),
|
||||
objective: Some("fix the null deref".to_string()),
|
||||
state: JobState::Running,
|
||||
reconciled: false,
|
||||
result_summary: None,
|
||||
context_files: Vec::new(),
|
||||
launched_at: 3,
|
||||
updated_at: 3,
|
||||
last_used_at: 3,
|
||||
},
|
||||
];
|
||||
state.modal = ModalState::JobsPane { selected: 1 };
|
||||
terminal.draw(|frame| render(frame, &mut state)).unwrap();
|
||||
insta::assert_snapshot!(buffer_to_string(terminal.backend()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_empty_state_produces_frame() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: crates/harness-tui/src/render.rs
|
||||
assertion_line: 300
|
||||
assertion_line: 381
|
||||
expression: buffer_to_string(terminal.backend())
|
||||
---
|
||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
source: crates/harness-tui/src/render.rs
|
||||
assertion_line: 621
|
||||
expression: buffer_to_string(terminal.backend())
|
||||
---
|
||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||
┌ chat ────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ │
|
||||
│ ┌ jobs ────────────────────────────────────────────────┐ │
|
||||
│ │Background jobs │ │
|
||||
│ │ ✓ exp-1 · explorer · completed │ │
|
||||
│ │ map the auth flow │ │
|
||||
│ │ read: src/auth.rs │ │
|
||||
│ │> ⚙ fix-1 · fixer · running │ │
|
||||
│ │ fix the null deref │ │
|
||||
│ │ │ │
|
||||
│ │Enter: open subtask · Esc: close │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ │
|
||||
└───────────└──────────────────────────────────────────────────────┘───────────┘
|
||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: crates/harness-tui/src/render.rs
|
||||
assertion_line: 430
|
||||
assertion_line: 511
|
||||
expression: buffer_to_string(terminal.backend())
|
||||
---
|
||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: crates/harness-tui/src/render.rs
|
||||
assertion_line: 487
|
||||
assertion_line: 568
|
||||
expression: buffer_to_string(terminal.backend())
|
||||
---
|
||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: crates/harness-tui/src/render.rs
|
||||
assertion_line: 357
|
||||
assertion_line: 438
|
||||
expression: buffer_to_string(terminal.backend())
|
||||
---
|
||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: crates/harness-tui/src/render.rs
|
||||
assertion_line: 407
|
||||
assertion_line: 488
|
||||
expression: buffer_to_string(terminal.backend())
|
||||
---
|
||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: crates/harness-tui/src/render.rs
|
||||
assertion_line: 382
|
||||
assertion_line: 463
|
||||
expression: buffer_to_string(terminal.backend())
|
||||
---
|
||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: crates/harness-tui/src/render.rs
|
||||
assertion_line: 331
|
||||
assertion_line: 412
|
||||
expression: buffer_to_string(terminal.backend())
|
||||
---
|
||||
new session · orchestrator · anthropic/claude-sonnet-4-5
|
||||
@@ -26,4 +26,4 @@ expression: buffer_to_string(terminal.backend())
|
||||
┌ input ───────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Esc: abort | Ctrl+C: quit
|
||||
idle · 0 tok · $0.0000 · Ctrl+S: sessions | Ctrl+J: jobs | Esc: abort | Ctrl+C:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use harness_app::EngineHandle;
|
||||
use harness_core::engine::JobRecord;
|
||||
use harness_core::event::{AppEvent, PermissionRequest, RunOutcome};
|
||||
use harness_core::permission::PermissionReply;
|
||||
use harness_core::types::{Message, Part, PartBody, PartId, Role, Session, SessionId, ToolState};
|
||||
@@ -86,6 +87,10 @@ pub enum ModalState {
|
||||
sessions: Vec<Session>,
|
||||
selected: usize,
|
||||
},
|
||||
/// Background job board for the current session, with a cursor for drill-in.
|
||||
JobsPane {
|
||||
selected: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// All mutable UI state lives here.
|
||||
@@ -109,6 +114,9 @@ pub struct AppState {
|
||||
pub session_cost: f64,
|
||||
/// Accumulated session tokens (input + output), for the status bar.
|
||||
pub session_tokens: u64,
|
||||
/// Background jobs spawned by the current session, newest activity last. Populated on
|
||||
/// session load and kept live via `JobUpdated` events; surfaced in the jobs pane.
|
||||
pub jobs: Vec<JobRecord>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -131,6 +139,7 @@ impl AppState {
|
||||
render_width: 0,
|
||||
session_cost: 0.0,
|
||||
session_tokens: 0,
|
||||
jobs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +165,8 @@ impl AppState {
|
||||
self.session_cost = session.cost;
|
||||
self.session_tokens = session.usage.input + session.usage.output;
|
||||
self.messages.clear();
|
||||
// Jobs are reloaded for the newly-selected session by the caller.
|
||||
self.jobs.clear();
|
||||
|
||||
let mut messages = messages;
|
||||
messages.sort_by_key(|m| m.created_at);
|
||||
@@ -258,14 +269,50 @@ impl AppState {
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
AppEvent::JobUpdated { .. }
|
||||
| AppEvent::AuthPrompt { .. }
|
||||
| AppEvent::ServerNotice { .. } => {
|
||||
AppEvent::JobUpdated { job } => {
|
||||
if let Ok(record) = serde_json::from_value::<JobRecord>(job.0) {
|
||||
if self.session_id.as_ref() == Some(&record.parent_session) {
|
||||
self.upsert_job(record);
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::AuthPrompt { .. } | AppEvent::ServerNotice { .. } => {
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces this session's job list (e.g. after loading a session).
|
||||
pub fn set_jobs(&mut self, jobs: Vec<JobRecord>) {
|
||||
self.jobs = jobs;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Inserts or replaces a job by `task_id`, preserving list order for stable rendering.
|
||||
fn upsert_job(&mut self, record: JobRecord) {
|
||||
if let Some(existing) = self.jobs.iter_mut().find(|j| j.task_id == record.task_id) {
|
||||
*existing = record;
|
||||
} else {
|
||||
self.jobs.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the jobs pane (cursor at the top).
|
||||
pub fn open_jobs_pane(&mut self) {
|
||||
self.modal = ModalState::JobsPane { selected: 0 };
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Child session of the currently-selected job in the jobs pane, for drill-in.
|
||||
pub fn selected_job_child(&self) -> Option<SessionId> {
|
||||
if let ModalState::JobsPane { selected } = &self.modal {
|
||||
self.jobs.get(*selected).map(|j| j.child_session.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self, n: u16) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(n);
|
||||
self.dirty = true;
|
||||
|
||||
Reference in New Issue
Block a user