feat(core): add mouse support to the terminal UI (#35868)

## Current Behavior

The Nx terminal UI (TUI) does not capture mouse events. Scroll only
works in
terminals that translate the wheel into arrow keys, so it is
non-functional in
terminals that don't (notably macOS Terminal.app). Clicks,
double-clicks,
opening the Nx Cloud link, and selecting terminal output are all
unavailable.

## Expected Behavior

The full-screen TUI now captures the mouse (and releases it again when
dropping
to the inline view and on every teardown path — normal restore, `Drop`,
the
panic hook, and the JS `restoreTerminal` path — so the terminal is never
left
emitting mouse escape sequences):

- **Wheel** scrolls whatever is under the cursor (an output pane scrolls
its
  buffer; the task list moves its selection).
- **Click** a task row to select it; **click** the Nx Cloud link to open
it.
- **Double-click** an output pane, or a selected task row, to drop into
the
  inline view.
- **Drag** in a focused pane to select its output, with auto-scroll when
the
drag reaches the top/bottom edge; the selection is copied to the
clipboard on
  release and highlighted while active.

Mouse reporting uses DECSET `1000`/`1002`/`1006` (press, drag, SGR) only
— not
`1003` "any-motion" — to avoid a flood of hover events. Capture is
intentionally
**off** in the inline view, where the moving sub-region of the
scrollback makes
absolute mouse coordinates unreliable and native selection is
preferable.

### Implementation notes

- New per-frame hit-test region map (panes + task list) resolves what's
under
the cursor; the `TasksList` component owns its own row/cloud-link
geometry.
- Text selection is tracked in absolute content coordinates so it stays
anchored
as the pane scrolls; the highlight is painted by reverse-videoing the
selected
  cells after `tui-term` renders (it exposes no selection API).
- Unit tests cover selection containment/normalization and text
extraction
  (`cargo test --lib tui::` — 210 passing).

> [!IMPORTANT]
> Mouse interaction has been verified by compilation and unit tests, but
the
> on-screen behavior (click targeting, drag feel, auto-scroll cadence,
> wide-character selection fidelity) should be **dogfooded in a real
terminal**
> before this is marked ready. Opening as a draft for that reason.

## Related Issue(s)

Implements the Nx TUI mouse-capture work:

- NXC-3945 — enable mouse capture when entering full-screen terminal
view
- NXC-3944 — disable mouse capture when entering inline view
- NXC-3941 — click on task in tasks list to select it
- NXC-3940 — click to open cloud link
- NXC-3942 — double-click terminal pane to enter inline mode
- NXC-3943 — double-click already-selected task to enter inline view
- NXC-3946 — text selection within terminal pane
- NXC-3558 — likely fixed (TUI shifted off screen on scroll) since the
wheel no
  longer leaks to the real terminal

NXC-4199 (mouse/resize forwarding to interactive child programs) is
intentionally
deferred as a separate follow-up.

---------

Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com>
This commit is contained in:
Craigory Coppola
2026-07-02 11:02:28 -04:00
committed by GitHub
parent e29c053a25
commit b7afb7351c
24 changed files with 3903 additions and 374 deletions
+2
View File
@@ -1,4 +1,6 @@
disallowed-types = [
# We need to ensure adjustments for light and dark themes are applied appropriately
{ path = "ratatui::style::Color", reason = "Use our utils from crate::native::tui::colors instead to ensure appropriate light/dark theme support" },
# Embedded links need their rendered position; ratatui's Paragraph discards it.
{ path = "ratatui::widgets::Paragraph", reason = "Use crate::native::tui::components::nx_paragraph::NxParagraph instead (supports clickable links; nx_paragraph.rs is the only sanctioned wrapper)" },
]
+6
View File
@@ -47,6 +47,12 @@ export declare class AppLifeCycle {
registerRunningBatch(batchId: string, batchInfo: BatchInfo): void
appendBatchOutput(batchId: string, output: string): void
setBatchStatus(batchId: string, status: BatchStatus): void
/**
* Set a clickable Nx Cloud link in the TUI: `label` is the text shown,
* `url` is opened when it's clicked. This is a `LifeCycle` method so the Nx
* Cloud client can call it via the lifecycle it already receives.
*/
setCloudLink(label: string, url: string): void
}
export declare class ChildProcess {
+5
View File
@@ -33,11 +33,16 @@ pub enum Action {
UpdateTaskStatus(String, TaskStatus),
SetTaskTiming(String, i64, i64),
UpdateCloudMessage(String),
/// Set a structured Nx Cloud link to render as a clickable label (display
/// label, href URL). Distinct from `UpdateCloudMessage` so the displayed
/// text and the opened URL can differ (e.g. "View in Nx Cloud").
UpdateCloudLink(String, String),
UpdateFocus(Focus),
StartCommand(Option<u32>),
StartTasks(Vec<Task>),
EndTasks(Vec<TaskResult>),
ToggleDebugMode,
ToggleMouseCapture,
SendConsoleMessage(String),
ConsoleMessengerAvailable(bool),
EndCommand,
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
use arboard::Clipboard;
/// Copy `text` to the system clipboard, returning whether it succeeded. Keeps
/// the `Clipboard::new()` / `set_text` dance in one place so every copy path
/// (pane selection, full output, region selection, inline output) stays in sync.
pub(crate) fn copy_to_clipboard(text: &str) -> bool {
Clipboard::new()
.and_then(|mut clipboard| clipboard.set_text(text))
.is_ok()
}
+16
View File
@@ -8,6 +8,7 @@ use super::{
action::Action,
tui::{Event, Frame},
};
use link::LinkRegistry;
pub mod countdown_popup;
pub mod dependency_view;
@@ -15,6 +16,8 @@ pub mod help_popup;
pub mod help_text;
pub mod hint_popup;
pub mod layout_manager;
pub mod link;
pub mod nx_paragraph;
pub mod task_selection_manager;
pub mod tasks_list;
pub mod terminal_pane;
@@ -104,6 +107,19 @@ pub trait Component: Any + Send {
/// * `Result<()>` - An Ok result or an error.
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()>;
/// The component's clickable external links recorded during the last
/// `draw`, if it has any. The app hit-tests these on click. Components
/// without links use the default (`None`).
fn link_registry(&self) -> Option<&LinkRegistry> {
None
}
/// Mutable access to the link registry so the app can clear it at the start
/// of each draw pass (the component repopulates it while drawing).
fn link_registry_mut(&mut self) -> Option<&mut LinkRegistry> {
None
}
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
}
@@ -1,116 +1,67 @@
use color_eyre::eyre::Result;
use ratatui::{
Frame,
buffer::CellDiffOption,
layout::{Alignment, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
Block, BorderType, Borders, Clear, Padding, Scrollbar, ScrollbarOrientation,
ScrollbarState, Wrap,
},
};
use std::any::Any;
use std::num::NonZeroU16;
use std::time::{Duration, Instant};
use crate::native::tui::lifecycle::PerformanceSummaryPayload;
use crate::native::tui::lifecycle::{Link as SummaryLink, PerformanceSummaryPayload};
use crate::native::tui::theme::THEME;
use crate::native::tui::utils::{format_duration, pluralize};
use super::Component;
use super::link::{Link, LinkRegistry};
use super::nx_paragraph::{NxLine, NxParagraph, NxSpan, NxText};
/// Word-wrapped row count for `lines` at `width`, using the same wrapping the
/// Paragraph applies — a hand-rolled character-wrap estimate diverges from
/// ratatui's word wrapping.
fn wrapped_rows(lines: &[Line], width: u16) -> usize {
if lines.is_empty() {
return 0;
/// Convert a styled line into an `NxLine`, turning any occurrence of a known
/// link phrase into a clickable [`Link`]. Replaces the old buffer-scanning OSC 8
/// injection: the link's rendered position now falls out of `NxParagraph`'s
/// placement instead of being recovered after the fact.
fn linkify_line(line: Line<'static>, links: &[SummaryLink]) -> NxLine {
let mut out: Vec<NxSpan> = Vec::new();
for span in line.spans {
linkify_span(span, links, &mut out);
}
Paragraph::new(lines.to_vec())
.wrap(Wrap { trim: false })
.line_count(width.max(1))
NxLine::from_spans(out)
}
/// Make `visible` a clickable OSC 8 hyperlink to `href` by rewriting the buffer:
/// ratatui's text path strips the escape framing from cells (ratatui#1028), so we
/// scan for the rendered text and replace each per-row run with one self-contained
/// OSC 8 cell (ForcedWidth = the run's width), blanking the rest. Whitespace is
/// collapsed when matching so a wrapped phrase still matches; no match → no-op.
fn inject_osc8(f: &mut Frame<'_>, inner_area: Rect, visible: &str, href: &str) {
if visible.is_empty() {
return;
}
// Flatten the inner area, recording each kept char's (col, row); whitespace
// runs (incl. row breaks) collapse to a single space.
let mut flat = String::new();
let mut pos: Vec<(u16, u16)> = Vec::new();
let mut prev_space = true; // collapse + trim leading
for row in inner_area.y..inner_area.bottom() {
for col in inner_area.x..inner_area.right() {
let ch = f
.buffer_mut()
.cell((col, row))
.and_then(|c| c.symbol().chars().next())
.unwrap_or(' ');
if ch == ' ' {
if !prev_space {
flat.push(' ');
pos.push((u16::MAX, u16::MAX)); // sentinel: collapsed space
prev_space = true;
fn linkify_span(span: Span<'static>, links: &[SummaryLink], out: &mut Vec<NxSpan>) {
let style = span.style;
let content = span.content.into_owned();
let mut rest = content.as_str();
loop {
// Earliest occurrence of any link phrase in the remaining text.
let next = links
.iter()
.filter(|l| !l.text.is_empty())
.filter_map(|l| rest.find(l.text.as_str()).map(|idx| (idx, l)))
.min_by_key(|(idx, _)| *idx);
match next {
Some((idx, link)) => {
if idx > 0 {
out.push(NxSpan::Text(Span::styled(rest[..idx].to_string(), style)));
}
out.push(NxSpan::Link(Link::new(
link.text.clone(),
link.href.clone(),
)));
rest = &rest[idx + link.text.len()..];
if rest.is_empty() {
return;
}
} else {
flat.push(ch);
pos.push((col, row));
prev_space = false;
}
}
if !prev_space {
flat.push(' '); // a row break wraps like a space
pos.push((u16::MAX, u16::MAX));
prev_space = true;
}
}
let target: String = visible.split_whitespace().collect::<Vec<_>>().join(" ");
// Injects at the FIRST match. Invariant: each linked phrase must be unique
// within the popup's rendered text, else the link lands on the wrong run.
let Some(byte_idx) = flat.find(&target) else {
return;
};
let char_start = flat[..byte_idx].chars().count();
let target_len = target.chars().count();
// Group the matched chars into per-row [first_col, last_col] segments.
let mut segments: Vec<(u16, u16, u16)> = Vec::new();
for (col, row) in pos.iter().skip(char_start).take(target_len).copied() {
if col == u16::MAX {
continue; // collapsed space between rows/words
}
match segments.last_mut() {
Some((r, _first, last)) if *r == row => *last = col,
_ => segments.push((row, col, col)),
}
}
for (row, first_col, last_col) in segments {
let mut segment_text = String::new();
for col in first_col..=last_col {
if let Some(cell) = f.buffer_mut().cell((col, row)) {
segment_text.push_str(cell.symbol());
}
}
let seq = format!("\x1b]8;;{href}\x07{segment_text}\x1b]8;;\x07");
if let Some(cell) = f.buffer_mut().cell_mut((first_col, row)) {
cell.set_symbol(&seq);
cell.set_style(Style::default().fg(THEME.info));
if let Some(width) = NonZeroU16::new(last_col - first_col + 1) {
cell.set_diff_option(CellDiffOption::ForcedWidth(width));
}
}
// The ForcedWidth cell owns the run's columns; blank the rest.
for col in (first_col + 1)..=last_col {
if let Some(cell) = f.buffer_mut().cell_mut((col, row)) {
cell.set_symbol(" ");
None => {
if !rest.is_empty() {
out.push(NxSpan::Text(Span::styled(rest.to_string(), style)));
}
return;
}
}
}
@@ -150,11 +101,20 @@ pub struct CountdownPopup {
scrollbar_state: ScrollbarState,
content_height: usize,
viewport_height: usize,
/// Screen rect of the bordered popup box from the last render, used for
/// click-outside-to-dismiss hit-testing.
last_area: Option<Rect>,
/// Screen rect of the inner text area (inside the border, clear of the
/// scrollbar) from the last render, used to bound text selection/links.
content_area: Option<Rect>,
/// The run report shown above the hint text (None until set).
summary: Option<PerformanceSummaryPayload>,
/// When pinned, the auto-exit countdown is stopped and the popup stays open
/// until the user explicitly quits.
pinned: bool,
/// Clickable report links recorded during render; the app hit-tests these
/// (via the modal mouse path) to open them.
link_registry: LinkRegistry,
}
impl CountdownPopup {
@@ -167,8 +127,11 @@ impl CountdownPopup {
scrollbar_state: ScrollbarState::default(),
content_height: 0,
viewport_height: 0,
last_area: None,
content_area: None,
summary: None,
pinned: false,
link_registry: LinkRegistry::new(),
}
}
@@ -265,6 +228,16 @@ impl CountdownPopup {
self.content_height > self.viewport_height
}
/// The bordered popup box drawn last frame, if visible.
pub fn last_area(&self) -> Option<Rect> {
self.last_area
}
/// The inner text area drawn last frame, if visible.
pub fn content_area(&self) -> Option<Rect> {
self.content_area
}
pub fn start_countdown(&mut self, duration_secs: u64) {
self.visible = true;
self.start_time = Some(Instant::now());
@@ -349,17 +322,12 @@ impl CountdownPopup {
}
}
/// Report-mode content: the report body, then the "longest tasks" rec so its list
/// ends the popup.
fn report_mode_content(
&self,
mut content: Vec<Line<'static>>,
) -> (Vec<Line<'static>>, Option<usize>) {
let url_line_index = content.len();
/// Report-mode content: the report body, then the "longest tasks" rec so its
/// list ends the popup. The recommendation phrases in `links` become clickable
/// via `linkify_line` at render.
fn report_mode_content(&self, mut content: Vec<Line<'static>>) -> Vec<Line<'static>> {
content.extend(self.longest_tasks_lines());
// Some(..) marks report mode so the OSC 8 pass runs for the recommendation
// links; the index value itself is unused.
(content, Some(url_line_index))
content
}
/// Exit-dialog content shown when there's no report (e.g. q pressed mid-run) — just
@@ -448,12 +416,26 @@ impl CountdownPopup {
let has_report = !report.is_empty();
// Two modes: a finished run shows the Performance Report; otherwise (e.g. q
// pressed mid-run) the original exit dialog with just the interactive hints.
let (content, url_line_index) = if has_report {
let content = if has_report {
self.report_mode_content(report)
} else {
(Self::exit_dialog_content(), None)
Self::exit_dialog_content()
};
// Turn the recommendation phrases into clickable links, then lay the report
// out through NxParagraph so each link's rect is recorded.
self.link_registry.clear();
let all_links: Vec<SummaryLink> = self
.summary
.as_ref()
.map(|s| s.links.clone())
.unwrap_or_default();
let nx_content: NxText = content
.into_iter()
.map(|line| linkify_line(line, &all_links))
.collect::<Vec<NxLine>>()
.into();
let seconds_remaining = if let Some(start_time) = self.start_time {
let elapsed = start_time.elapsed();
if elapsed >= self.duration {
@@ -511,7 +493,7 @@ impl CountdownPopup {
// Size the popup to its content so a short cached-run report doesn't float in a
// fixed-width box, while staying wide enough for the title row and the bottom
// keybindings; cap at 70 so long recommendations still wrap.
let content_width = content.iter().map(|l| l.width() as u16).max().unwrap_or(0);
let content_width = nx_content.max_width();
let title_row_width: u16 = title_spans.iter().map(|s| s.width() as u16).sum::<u16>()
+ close_hint.iter().map(|s| s.width() as u16).sum::<u16>();
let bottom_width: u16 = bottom_hints
@@ -534,7 +516,7 @@ impl CountdownPopup {
.inner(Rect::new(0, 0, popup_width, 1))
.width
.max(1);
let estimated_rows = wrapped_rows(&content, inner_width);
let estimated_rows = nx_content.wrapped_rows(inner_width, false);
let popup_height = ((estimated_rows as u16).saturating_add(4))
.min(safe_area.height.saturating_sub(4))
.max(5);
@@ -544,6 +526,9 @@ impl CountdownPopup {
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
// Record the popup box so the app can hit-test mouse events against it.
self.last_area = Some(popup_area);
let mut block = Block::default()
.title(Line::from(title_spans))
.title_alignment(Alignment::Left)
@@ -557,11 +542,16 @@ impl CountdownPopup {
}
let inner_area = block.inner(popup_area);
// Record the inner text area so the app can bound selection/link hit-tests.
self.content_area = Some(inner_area);
self.viewport_height = inner_area.height as usize;
// The text area sits inside the border + padding, so it never includes
// the scrollbar (drawn on the far-right border column).
self.content_area = Some(inner_area);
// Content height in wrapped rows, driving the scrollbar and the scroll
// bound in scroll_down.
self.content_height = wrapped_rows(&content, inner_area.width);
self.content_height = nx_content.wrapped_rows(inner_area.width, false);
let scrollable_rows = self.content_height.saturating_sub(self.viewport_height);
let needs_scrollbar = scrollable_rows > 0;
@@ -579,7 +569,7 @@ impl CountdownPopup {
// rows by scroll_down's max_scroll, matching Paragraph::scroll's row-based
// offset — slicing the unwrapped `content` with a wrapped-row offset
// panicked the process.
let popup = Paragraph::new(content.clone())
let popup = NxParagraph::new(nx_content)
.block(block.clone())
// trim: false preserves leading whitespace so the report's
// indentation renders.
@@ -587,18 +577,9 @@ impl CountdownPopup {
.scroll((self.scroll_offset as u16, 0));
f.render_widget(Clear, popup_area);
f.render_widget(popup, popup_area);
// Turn the report's links into real OSC 8 hyperlinks (see inject_osc8).
if url_line_index.is_some() {
if let Some(s) = self.summary.as_ref() {
// Hyperlink the recommendation phrases (labels and hrefs from the
// payload); a phrase that isn't shown isn't found.
for link in &s.links {
inject_osc8(f, inner_area, &link.text, &link.href);
}
}
}
// NxParagraph records each rendered link's rect into the registry, which
// the app's modal mouse handler hit-tests to open it.
f.render_stateful_widget(popup, popup_area, &mut self.link_registry);
if needs_scrollbar {
// Blank out the corners so the scrollbar arrows don't collide with
@@ -623,14 +604,14 @@ impl CountdownPopup {
};
f.render_widget(
Paragraph::new(top_text)
NxParagraph::new(top_text)
.alignment(Alignment::Right)
.style(Style::default().fg(THEME.info)),
top_right_area,
);
f.render_widget(
Paragraph::new(bottom_text)
NxParagraph::new(bottom_text)
.alignment(Alignment::Right)
.style(Style::default().fg(THEME.info)),
bottom_right_area,
@@ -651,10 +632,22 @@ impl Component for CountdownPopup {
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
if self.visible {
self.render(f, rect);
} else {
self.last_area = None;
self.content_area = None;
self.link_registry.clear();
}
Ok(())
}
fn link_registry(&self) -> Option<&LinkRegistry> {
Some(&self.link_registry)
}
fn link_registry_mut(&mut self) -> Option<&mut LinkRegistry> {
Some(&mut self.link_registry)
}
fn as_any(&self) -> &dyn Any {
self
}
@@ -840,41 +833,31 @@ mod tests {
.unwrap();
let buffer = terminal.backend().buffer().clone();
// Every OSC 8 cell targets a known page; at least one the remote cache.
let mut cache_links = 0;
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
let sym = buffer.cell((x, y)).unwrap().symbol();
if sym.contains("\x1b]8;;") {
assert!(sym.contains(CACHE_HREF), "unexpected OSC 8 target");
if sym.contains(CACHE_HREF) {
cache_links += 1;
}
}
}
}
assert!(cache_links >= 1, "the cache phrase should be linked");
let registry = popup
.link_registry()
.expect("countdown exposes a link registry");
// The cache phrase wraps to multiple rows; it must be clickable on every
// one of them (a rect was recorded per wrapped row).
let mut hit_rows: Vec<u16> = (0..buffer.area.height)
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
.filter(|&(x, y)| registry.hit_test(x, y) == Some(CACHE_HREF))
.map(|(_, y)| y)
.collect();
hit_rows.sort_unstable();
hit_rows.dedup();
assert!(
hit_rows.len() >= 2,
"the wrapped cache phrase should be clickable on each of its rows"
);
// The raw URL is never visible, and the phrase (start AND end, i.e. every
// wrapped row) is consumed by the links rather than left plain.
for y in 0..buffer.area.height {
let mut text = String::new();
for x in 0..buffer.area.width {
let s = buffer.cell((x, y)).unwrap().symbol();
text.push_str(if s.contains('\x1b') { "\u{0}" } else { s });
}
assert!(
!text.contains("https://nx.dev/ci/features/remote-cache"),
"raw remote-cache URL visible on row {y}"
);
assert!(
!text.contains("Drastically reduce"),
"phrase start left unlinked on row {y}"
);
assert!(
!text.contains("team and CI"),
"phrase end (wrapped row) left unlinked on row {y}"
);
}
// The raw URL is never visible (the phrase is the display text).
let visible: String = (0..buffer.area.height)
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
.map(|(x, y)| buffer.cell((x, y)).unwrap().symbol().to_string())
.collect();
assert!(
!visible.contains("https://nx.dev/ci/features/remote-cache"),
"raw remote-cache URL must not be visible"
);
}
}
@@ -2,8 +2,10 @@ use std::collections::HashMap;
use crate::native::tasks::types::TaskGraph;
use crate::native::tui::action::Action;
use crate::native::tui::components::nx_paragraph::NxParagraph;
use crate::native::tui::components::tasks_list::TaskStatus;
use crate::native::tui::graph_utils::{get_dependency_chain_failures, is_task_continuous};
use crate::native::tui::scroll_momentum::ScrollDirection;
use crate::native::tui::status_icons;
use crate::native::tui::theme::THEME;
use ratatui::{
@@ -12,8 +14,8 @@ use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, StatefulWidget, Widget,
Block, BorderType, Borders, Padding, Scrollbar, ScrollbarOrientation, ScrollbarState,
StatefulWidget, Widget,
},
};
@@ -27,6 +29,14 @@ pub struct DependencyViewState {
pub scroll_offset: usize,
pub scrollbar_state: ScrollbarState,
pub pane_area: ratatui::layout::Rect,
/// Clickable dependency rows captured during the last render: screen `y` →
/// task name. Lets a mouse click navigate to the dependency under the cursor.
pub dep_row_hits: Vec<(u16, String)>,
/// Horizontal extent `[x0, x1)` of the dependency rows, for click hit-testing.
pub dep_row_x_range: (u16, u16),
/// Inner text region from the last render, used to bound a drag-based text
/// selection over the dependency view (mirrors the task list).
pub selection_area: Option<ratatui::layout::Rect>,
}
impl DependencyViewState {
@@ -65,9 +75,25 @@ impl DependencyViewState {
scroll_offset: 0,
scrollbar_state: ScrollbarState::default(),
pane_area,
dep_row_hits: Vec::new(),
dep_row_x_range: (0, 0),
selection_area: None,
}
}
/// Resolve a click at terminal cell `(col, row)` to the dependency task under
/// it, if any, using the row hit-map captured during the last render.
pub fn handle_click(&self, col: u16, row: u16) -> Option<String> {
let (x0, x1) = self.dep_row_x_range;
if col < x0 || col >= x1 {
return None;
}
self.dep_row_hits
.iter()
.find(|(y, _)| *y == row)
.map(|(_, task)| task.clone())
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
@@ -80,6 +106,19 @@ impl DependencyViewState {
}
}
/// Scroll one row in `direction`, deriving the viewport from the stored pane
/// area. Used by the mouse wheel so it scrolls the dependency view itself
/// (mirroring the keyboard path) rather than the hidden terminal buffer.
pub fn scroll(&mut self, direction: ScrollDirection) {
match direction {
ScrollDirection::Up => self.scroll_up(),
ScrollDirection::Down => {
let viewport_height = Self::calculate_viewport_height(self.pane_area);
self.scroll_down(viewport_height);
}
}
}
/// Updates the dependency view state with new data, preserving scroll position if the task is the same.
/// Returns true if the state was updated, false if no update was needed.
pub fn update(
@@ -274,10 +313,13 @@ impl<'a> DependencyView<'a> {
};
if Self::fits_in_buffer(&top_area, buf) {
Paragraph::new(padding_text.clone())
.alignment(Alignment::Right)
.style(style)
.render(top_area, buf);
Widget::render(
NxParagraph::new(padding_text.clone())
.alignment(Alignment::Right)
.style(style),
top_area,
buf,
);
}
// Render bottom padding
@@ -289,10 +331,13 @@ impl<'a> DependencyView<'a> {
};
if Self::fits_in_buffer(&bottom_area, buf) {
Paragraph::new(padding_text)
.alignment(Alignment::Right)
.style(style)
.render(bottom_area, buf);
Widget::render(
NxParagraph::new(padding_text)
.alignment(Alignment::Right)
.style(style),
bottom_area,
buf,
);
}
}
@@ -306,7 +351,7 @@ impl<'a> DependencyView<'a> {
no_deps_style,
)])];
let paragraph = Paragraph::new(no_deps_message)
let paragraph = NxParagraph::new(no_deps_message)
.alignment(Alignment::Center)
.style(Style::default());
@@ -387,6 +432,11 @@ impl<'a> DependencyView<'a> {
outer_area: Rect,
buf: &mut Buffer,
) {
// Rebuilt below for the rows actually drawn; clear up front so the
// early-return paths (no deps / invalid area) leave no stale click map.
state.dep_row_hits.clear();
state.dep_row_x_range = (0, 0);
if state.dependencies.is_empty() {
self.render_no_dependencies(state, inner_area, buf);
return;
@@ -430,22 +480,6 @@ impl<'a> DependencyView<'a> {
ScrollbarState::default()
};
// Apply scroll offset to lines
let visible_lines: Vec<Line> =
if state.scroll_offset > 0 && content_height > viewport_height {
let start = state
.scroll_offset
.min(content_height.saturating_sub(viewport_height));
let end = (start + viewport_height).min(content_height);
lines[start..end].to_vec()
} else {
lines
};
let paragraph = Paragraph::new(visible_lines)
.alignment(Alignment::Left)
.style(Style::default());
// Apply safety bounds to content area
let content_area = Rect {
x: inner_area.x,
@@ -463,6 +497,39 @@ impl<'a> DependencyView<'a> {
return; // Don't render if the area is invalid
}
// First visible global line index after scrolling. Global indices 0 and 1
// are the header and the spacing line; dependencies begin at index 2.
let start = if state.scroll_offset > 0 && content_height > viewport_height {
state
.scroll_offset
.min(content_height.saturating_sub(viewport_height))
} else {
0
};
let end = (start + viewport_height).min(content_height);
// Capture the clickable dependency rows that are actually drawn so a mouse
// click can navigate to the task under the cursor.
let mut dep_row_hits = Vec::new();
for global in start..end {
let row_offset = (global - start) as u16;
if row_offset >= content_area.height {
break;
}
if let Some(dep_idx) = global.checked_sub(2)
&& let Some(dep) = state.dependencies.get(dep_idx)
{
dep_row_hits.push((content_area.y + row_offset, dep.clone()));
}
}
state.dep_row_hits = dep_row_hits;
state.dep_row_x_range = (content_area.x, content_area.x + content_area.width);
let visible_lines: Vec<Line> = lines[start..end].to_vec();
let paragraph = NxParagraph::new(visible_lines)
.alignment(Alignment::Left)
.style(Style::default());
Widget::render(paragraph, content_area, buf);
// Render scrollbar if needed (using outer_area to extend to border edge)
@@ -543,6 +610,9 @@ impl<'a> StatefulWidget for DependencyView<'a> {
.padding(Padding::new(2, 2, 1, 1));
let inner_area = block.inner(area);
// Record the inner text region so the App can bound a drag-based text
// selection over the dependency view.
state.selection_area = Some(inner_area);
block.render(area, buf);
// Show different content based on task status
@@ -565,7 +635,7 @@ impl<'a> StatefulWidget for DependencyView<'a> {
Style::default().fg(THEME.secondary_fg),
)])];
let paragraph = Paragraph::new(message_line)
let paragraph = NxParagraph::new(message_line)
.alignment(Alignment::Center)
.style(Style::default());
@@ -577,7 +647,113 @@ impl<'a> StatefulWidget for DependencyView<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::native::tasks::types::TaskGraph;
use ratatui::buffer::Buffer;
use std::collections::HashMap;
fn empty_task_graph() -> TaskGraph {
TaskGraph {
tasks: HashMap::new(),
dependencies: HashMap::new(),
continuous_dependencies: HashMap::new(),
roots: vec![],
}
}
#[test]
fn test_handle_click_maps_rows_to_dependencies() {
let task_graph = empty_task_graph();
let status_map: HashMap<String, TaskStatus> = HashMap::new();
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 20,
};
let mut buf = Buffer::empty(area);
let mut state = DependencyViewState {
current_task: "app:build".to_string(),
task_status: TaskStatus::NotStarted,
dependencies: vec![
"lib-a:build".to_string(),
"lib-b:build".to_string(),
"lib-c:build".to_string(),
],
dependency_levels: HashMap::new(),
is_focused: true,
throbber_counter: 0,
scroll_offset: 0,
scrollbar_state: ScrollbarState::default(),
pane_area: area,
dep_row_hits: Vec::new(),
dep_row_x_range: (0, 0),
selection_area: None,
};
let view = DependencyView::new(&status_map, &task_graph);
StatefulWidget::render(view, area, &mut buf, &mut state);
// Inner content begins at y=2 (border + top padding). Lines are laid out as
// header (y=2), spacing (y=3), then one dependency per row from y=4.
assert_eq!(state.handle_click(5, 4).as_deref(), Some("lib-a:build"));
assert_eq!(state.handle_click(5, 5).as_deref(), Some("lib-b:build"));
assert_eq!(state.handle_click(5, 6).as_deref(), Some("lib-c:build"));
// Header and spacing rows are not clickable.
assert_eq!(state.handle_click(5, 2), None);
assert_eq!(state.handle_click(5, 3), None);
// A row with no dependency under it is not clickable.
assert_eq!(state.handle_click(5, 7), None);
// Clicks outside the dependency rows' x-range miss.
assert_eq!(state.handle_click(200, 4), None);
// The inner text region is recorded for drag-based selection and spans
// the rendered dependency rows.
let sel = state.selection_area.expect("selection area recorded");
assert!(sel.y <= 4 && sel.y + sel.height > 6);
}
#[test]
fn test_handle_click_respects_scroll_offset() {
let task_graph = empty_task_graph();
let status_map: HashMap<String, TaskStatus> = HashMap::new();
// Short viewport so the list scrolls: header + spacing + 8 deps = 10 lines.
let area = Rect {
x: 0,
y: 0,
width: 60,
height: 8, // inner height 4 → only a few rows visible at once
};
let mut buf = Buffer::empty(area);
let deps: Vec<String> = (0..8).map(|i| format!("lib-{i}:build")).collect();
let mut state = DependencyViewState {
current_task: "app:build".to_string(),
task_status: TaskStatus::NotStarted,
dependencies: deps,
dependency_levels: HashMap::new(),
is_focused: true,
throbber_counter: 0,
scroll_offset: 3,
scrollbar_state: ScrollbarState::default(),
pane_area: area,
dep_row_hits: Vec::new(),
dep_row_x_range: (0, 0),
selection_area: None,
};
let view = DependencyView::new(&status_map, &task_graph);
StatefulWidget::render(view, area, &mut buf, &mut state);
// With scroll_offset 3, global line 3 (the first dependency, lib-0 is global
// index 2) is scrolled past, so the top visible row maps to lib-1.
let top = state.dep_row_hits.first().expect("a row should be visible");
assert_eq!(top.1, "lib-1:build");
assert_eq!(state.handle_click(5, top.0).as_deref(), Some("lib-1:build"));
}
#[test]
fn test_render_scrollbar_padding_normal_case() {
@@ -1,14 +1,14 @@
use super::{Component, Frame};
use crate::native::ide::detection::{SupportedEditor, get_current_editor};
use crate::native::tui::action::Action;
use crate::native::tui::components::nx_paragraph::NxParagraph;
use color_eyre::eyre::Result;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState,
Block, BorderType, Borders, Clear, Padding, Scrollbar, ScrollbarOrientation, ScrollbarState,
},
};
use std::any::Any;
@@ -25,6 +25,12 @@ pub struct HelpPopup {
visible: bool,
action_tx: Option<UnboundedSender<Action>>,
console_available: bool,
/// Screen rect of the bordered popup box from the last render, used for
/// click-outside-to-dismiss hit-testing.
last_area: Option<Rect>,
/// Screen rect of the inner text area (inside the border, clear of the
/// scrollbar) from the last render, used to bound text selection/links.
content_area: Option<Rect>,
}
impl HelpPopup {
@@ -37,6 +43,8 @@ impl HelpPopup {
visible: false,
action_tx: None,
console_available: false,
last_area: None,
content_area: None,
}
}
@@ -44,6 +52,16 @@ impl HelpPopup {
self.visible = visible;
}
/// The bordered popup box drawn last frame, if visible.
pub fn last_area(&self) -> Option<Rect> {
self.last_area
}
/// The inner text area drawn last frame, if visible.
pub fn content_area(&self) -> Option<Rect> {
self.content_area
}
pub fn set_console_available(&mut self, available: bool) {
self.console_available = available;
}
@@ -139,6 +157,9 @@ impl HelpPopup {
])
.split(popup_layout[1])[1];
// Record the popup box so the app can hit-test mouse events against it.
self.last_area = Some(popup_area);
let mut keybindings = vec![
// Misc
("?", "Toggle this popup"),
@@ -171,7 +192,11 @@ impl HelpPopup {
"<tab>",
"Move focus between task list and output panes 1 and 2",
),
("c", "Copy focused output to clipboard"),
("c", "Copy selection (or full output) to clipboard"),
(
"F10",
"Toggle mouse capture (off lets your terminal select cells natively)",
),
("", ""),
// Interactive Mode
("i", "Interact with a continuous task when it is in focus"),
@@ -321,6 +346,9 @@ impl HelpPopup {
let inner_area = block.inner(popup_area);
self.viewport_height = inner_area.height as usize;
// The text area sits inside the border + padding, so it never includes
// the scrollbar (drawn on the far-right border column).
self.content_area = Some(inner_area);
// Calculate wrapped height by measuring each line
let wrapped_height = content
@@ -358,7 +386,7 @@ impl HelpPopup {
let scroll_end = (self.scroll_offset + self.viewport_height).min(content.len());
let visible_content = content[scroll_start..scroll_end].to_vec();
let popup = Paragraph::new(visible_content)
let popup = NxParagraph::new(visible_content)
.block(block)
.alignment(Alignment::Left)
.wrap(ratatui::widgets::Wrap { trim: true });
@@ -392,14 +420,14 @@ impl HelpPopup {
// Render padding text
f.render_widget(
Paragraph::new(top_text)
NxParagraph::new(top_text)
.alignment(Alignment::Right)
.style(Style::default().fg(THEME.info)),
top_right_area,
);
f.render_widget(
Paragraph::new(bottom_text)
NxParagraph::new(bottom_text)
.alignment(Alignment::Right)
.style(Style::default().fg(THEME.info)),
bottom_right_area,
@@ -425,6 +453,9 @@ impl Component for HelpPopup {
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
if self.visible {
self.render(f, rect);
} else {
self.last_area = None;
self.content_area = None;
}
Ok(())
}
@@ -3,9 +3,9 @@ use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::native::tui::components::nx_paragraph::NxParagraph;
use crate::native::tui::theme::THEME;
pub struct HelpText {
@@ -70,7 +70,7 @@ impl HelpText {
Span::styled("?", key_style),
];
f.render_widget(
Paragraph::new(Line::from(hint)).alignment(if self.align_left {
NxParagraph::new(Line::from(hint)).alignment(if self.align_left {
Alignment::Left
} else {
Alignment::Right
@@ -110,7 +110,7 @@ impl HelpText {
}
f.render_widget(
Paragraph::new(Line::from(shortcuts)).alignment(Alignment::Right),
NxParagraph::new(Line::from(shortcuts)).alignment(Alignment::Right),
safe_area,
);
}
@@ -4,11 +4,12 @@ use ratatui::{
layout::{Alignment, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph},
widgets::{Block, BorderType, Borders, Clear, Padding},
};
use std::any::Any;
use std::time::{Duration, Instant};
use crate::native::tui::components::nx_paragraph::NxParagraph;
use crate::native::tui::theme::THEME;
use super::Component;
@@ -27,6 +28,12 @@ pub struct HintPopup {
message: String,
shown_at: Option<Instant>,
auto_dismiss_duration: Duration,
/// Screen rect of the bordered popup box from the last render, used for
/// click-outside-to-dismiss hit-testing.
last_area: Option<Rect>,
/// Screen rect of the inner text area (inside the border) from the last
/// render, used to bound text selection.
content_area: Option<Rect>,
}
impl HintPopup {
@@ -36,9 +43,21 @@ impl HintPopup {
message: String::new(),
shown_at: None,
auto_dismiss_duration: AUTO_DISMISS_DURATION,
last_area: None,
content_area: None,
}
}
/// The bordered popup box drawn last frame, if visible.
pub fn last_area(&self) -> Option<Rect> {
self.last_area
}
/// The inner text area drawn last frame, if visible.
pub fn content_area(&self) -> Option<Rect> {
self.content_area
}
/// Shows the popup with the given message
pub fn show(&mut self, message: String) {
self.visible = true;
@@ -92,8 +111,11 @@ impl HintPopup {
// Create popup area with fixed dimensions
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
// Record the popup box so the app can hit-test mouse events against it.
self.last_area = Some(popup_area);
let content = vec![Line::from(vec![Span::styled(
&self.message,
self.message.clone(),
Style::default().fg(THEME.primary_fg),
)])];
@@ -123,7 +145,10 @@ impl HintPopup {
.border_style(Style::default().fg(THEME.info))
.padding(Padding::proportional(1));
let popup = Paragraph::new(content)
// The text area sits inside the border + padding.
self.content_area = Some(block.inner(popup_area));
let popup = NxParagraph::new(content)
.block(block)
.wrap(ratatui::widgets::Wrap { trim: true });
@@ -143,6 +168,9 @@ impl Component for HintPopup {
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
if self.visible {
self.render(f, rect);
} else {
self.last_area = None;
self.content_area = None;
}
Ok(())
}
@@ -0,0 +1,305 @@
//! A reusable, clickable link to an external HTTP resource.
//!
//! Ratatui spans don't know where they were rendered, so a clickable piece of
//! text has to record its own rect at draw time. [`Link`] is a
//! [`StatefulWidget`] whose state is a [`LinkRegistry`]: rendering a link draws
//! its (styled, underlined) display text into the given area and pushes the
//! drawn rect plus the underlying `href` into the registry. The app hit-tests
//! the registry on click and opens the matching `href`.
//!
//! The display text and the `href` are intentionally separate, so a link can
//! show "View in Nx Cloud" while opening `https://nx.app/...`. This also
//! subsumes the "truncate the visible URL but open the full one" behaviour: the
//! registered `href` is always the full target even when the display is clipped.
//!
//! Links are styled persistently (underline + the info/accent colour) rather
//! than reacting to pointer hover. The TUI deliberately enables only a narrow
//! set of mouse modes (no all-motion tracking), so bare hover produces no
//! events to react to — see `tui.rs`.
use ratatui::{
buffer::Buffer,
layout::{Position, Rect},
style::{Modifier, Style},
text::Span,
widgets::StatefulWidget,
};
use crate::native::tui::theme::THEME;
/// A single rendered link's clickable rect and the resource it opens.
#[derive(Debug, Clone, PartialEq, Eq)]
struct LinkHit {
area: Rect,
href: String,
}
/// Per-frame collection of rendered links for one component.
///
/// Cleared at the top of every draw pass and repopulated as links render, so a
/// component that isn't drawn this frame holds no stale hits.
#[derive(Debug, Default, Clone)]
pub struct LinkRegistry {
hits: Vec<LinkHit>,
}
impl LinkRegistry {
pub fn new() -> Self {
Self { hits: Vec::new() }
}
/// Drop all recorded hits. Call at the start of each draw.
pub fn clear(&mut self) {
self.hits.clear();
}
/// Record a rendered link.
pub fn push(&mut self, area: Rect, href: String) {
self.hits.push(LinkHit { area, href });
}
/// Return the `href` of the link at `(col, row)`, if any. Later-registered
/// links win on overlap (they were drawn on top).
pub fn hit_test(&self, col: u16, row: u16) -> Option<&str> {
self.hits
.iter()
.rev()
.find(|hit| hit.area.contains(Position::new(col, row)))
.map(|hit| hit.href.as_str())
}
}
/// A clickable link to an external HTTP resource.
#[derive(Debug, Clone)]
pub struct Link {
display: String,
href: String,
dim: bool,
}
impl Link {
pub fn new(display: impl Into<String>, href: impl Into<String>) -> Self {
Self {
display: display.into(),
href: href.into(),
dim: false,
}
}
/// Render the link dimmed (e.g. for an unfocused pane).
pub fn dim(mut self, dim: bool) -> Self {
self.dim = dim;
self
}
pub fn href(&self) -> &str {
&self.href
}
/// The text shown for the link (distinct from the opened `href`).
pub fn display(&self) -> &str {
&self.display
}
pub(crate) fn style(&self) -> Style {
let mut style = Style::default()
.fg(THEME.info)
.add_modifier(Modifier::UNDERLINED);
if self.dim {
style = style.add_modifier(Modifier::DIM);
}
style
}
}
impl StatefulWidget for &Link {
type State = LinkRegistry;
fn render(self, area: Rect, buf: &mut Buffer, registry: &mut Self::State) {
if area.width == 0 || area.height == 0 {
return;
}
let text = fit_with_ellipsis(&self.display, area.width as usize);
let drawn_width = (display_width(&text) as u16).min(area.width);
if drawn_width == 0 {
return;
}
buf.set_stringn(area.x, area.y, &text, area.width as usize, self.style());
registry.push(
Rect {
x: area.x,
y: area.y,
width: drawn_width,
height: 1,
},
self.href.clone(),
);
}
}
/// Display width (in terminal columns) of a string, honouring wide characters.
fn display_width(text: &str) -> usize {
Span::raw(text).width()
}
/// Fit `display` into `max_width` columns, appending an ellipsis when it must be
/// truncated. Width-aware so wide characters never overflow the area.
fn fit_with_ellipsis(display: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if display_width(display) <= max_width {
return display.to_string();
}
// No room for text plus an ellipsis — just fill with dots.
if max_width <= 3 {
return ".".repeat(max_width);
}
let budget = max_width - 3;
let mut out = String::new();
let mut width = 0usize;
for ch in display.chars() {
let char_width = display_width(&ch.to_string());
if width + char_width > budget {
break;
}
out.push(ch);
width += char_width;
}
out.push_str("...");
out
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: u16, y: u16, width: u16, height: u16) -> Rect {
Rect {
x,
y,
width,
height,
}
}
#[test]
fn hit_test_returns_href_for_point_inside() {
let mut registry = LinkRegistry::new();
registry.push(rect(5, 2, 16, 1), "https://nx.app/run".to_string());
assert_eq!(registry.hit_test(5, 2), Some("https://nx.app/run"));
assert_eq!(registry.hit_test(20, 2), Some("https://nx.app/run"));
}
#[test]
fn hit_test_returns_none_for_point_outside() {
let mut registry = LinkRegistry::new();
registry.push(rect(5, 2, 16, 1), "https://nx.app/run".to_string());
assert_eq!(registry.hit_test(4, 2), None); // left of link
assert_eq!(registry.hit_test(21, 2), None); // right of link
assert_eq!(registry.hit_test(10, 3), None); // wrong row
}
#[test]
fn hit_test_is_empty_when_nothing_registered() {
let registry = LinkRegistry::new();
assert_eq!(registry.hit_test(0, 0), None);
}
#[test]
fn hit_test_overlap_prefers_last_registered() {
let mut registry = LinkRegistry::new();
registry.push(rect(0, 0, 10, 1), "first".to_string());
registry.push(rect(0, 0, 10, 1), "second".to_string());
assert_eq!(registry.hit_test(3, 0), Some("second"));
}
#[test]
fn clear_drops_all_hits() {
let mut registry = LinkRegistry::new();
registry.push(rect(0, 0, 10, 1), "x".to_string());
registry.clear();
assert_eq!(registry.hit_test(3, 0), None);
}
#[test]
fn render_draws_display_text_and_registers_href() {
let area = rect(0, 0, 20, 1);
let mut buf = Buffer::empty(area);
let mut registry = LinkRegistry::new();
let link = Link::new("View in Nx Cloud", "https://nx.app/runs/abc");
StatefulWidget::render(&link, area, &mut buf, &mut registry);
let rendered: String = (0..16).map(|x| buf[(x, 0)].symbol()).collect();
assert_eq!(rendered, "View in Nx Cloud");
// The drawn text (16 cols), not the whole area, is the click target, and
// the full href is recorded.
assert_eq!(registry.hit_test(0, 0), Some("https://nx.app/runs/abc"));
assert_eq!(registry.hit_test(15, 0), Some("https://nx.app/runs/abc"));
assert_eq!(registry.hit_test(16, 0), None); // blank space past the text
}
#[test]
fn render_styles_text_underlined_in_info_colour() {
let area = rect(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let mut registry = LinkRegistry::new();
let link = Link::new("docs", "https://nx.dev");
StatefulWidget::render(&link, area, &mut buf, &mut registry);
let cell = &buf[(0, 0)];
assert_eq!(cell.fg, THEME.info);
assert!(cell.modifier.contains(Modifier::UNDERLINED));
assert!(!cell.modifier.contains(Modifier::DIM));
}
#[test]
fn render_dim_applies_dim_modifier() {
let area = rect(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let mut registry = LinkRegistry::new();
let link = Link::new("docs", "https://nx.dev").dim(true);
StatefulWidget::render(&link, area, &mut buf, &mut registry);
assert!(buf[(0, 0)].modifier.contains(Modifier::DIM));
}
#[test]
fn render_truncates_with_ellipsis_but_registers_full_href() {
let area = rect(0, 0, 8, 1);
let mut buf = Buffer::empty(area);
let mut registry = LinkRegistry::new();
let link = Link::new("View in Nx Cloud", "https://nx.app/runs/abc");
StatefulWidget::render(&link, area, &mut buf, &mut registry);
let rendered: String = (0..8).map(|x| buf[(x, 0)].symbol()).collect();
assert_eq!(rendered, "View ...");
// Clicking the truncated display still opens the full href.
assert_eq!(registry.hit_test(0, 0), Some("https://nx.app/runs/abc"));
assert_eq!(registry.hit_test(7, 0), Some("https://nx.app/runs/abc"));
}
#[test]
fn render_into_zero_width_area_registers_nothing() {
let area = rect(0, 0, 0, 1);
let mut buf = Buffer::empty(rect(0, 0, 1, 1));
let mut registry = LinkRegistry::new();
let link = Link::new("docs", "https://nx.dev");
StatefulWidget::render(&link, area, &mut buf, &mut registry);
assert_eq!(registry.hit_test(0, 0), None);
}
}
@@ -0,0 +1,713 @@
//! `NxParagraph` — a paragraph widget that can embed clickable [`Link`]s and
//! reports their rendered positions, so links never need a post-render buffer
//! scan.
//!
//! For link-less content it delegates to ratatui's `Paragraph` (byte-identical
//! output, zero migration churn). When a line actually contains a [`Link`] it
//! owns the placement instead, drawing each span with `Buffer::set_stringn` and
//! recording every link's drawn rect into a [`LinkRegistry`] — the position is a
//! byproduct of placing the span, not something recovered afterwards.
//!
//! This module is the ONLY sanctioned user of ratatui's `Paragraph`; everywhere
//! else it's banned via clippy `disallowed_types`. Use `NxParagraph` instead.
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::Style,
text::{Line, Span, Text},
widgets::{Block, StatefulWidget, Widget, Wrap},
};
use super::link::{Link, LinkRegistry};
fn text_width(s: &str) -> u16 {
Span::raw(s).width() as u16
}
/// One inline piece of a line: plain styled text, or a clickable link.
#[derive(Debug, Clone)]
pub enum NxSpan {
Text(Span<'static>),
Link(Link),
}
impl NxSpan {
fn content(&self) -> String {
match self {
NxSpan::Text(s) => s.content.to_string(),
NxSpan::Link(l) => l.display().to_string(),
}
}
fn style(&self) -> Style {
match self {
NxSpan::Text(s) => s.style,
NxSpan::Link(l) => l.style(),
}
}
fn href(&self) -> Option<String> {
match self {
NxSpan::Link(l) => Some(l.href().to_string()),
NxSpan::Text(_) => None,
}
}
/// The equivalent ratatui span (keeps styled text, drops link semantics).
fn to_span(&self) -> Span<'static> {
match self {
NxSpan::Text(s) => s.clone(),
NxSpan::Link(l) => Span::styled(l.display().to_string(), l.style()),
}
}
}
impl From<Span<'static>> for NxSpan {
fn from(s: Span<'static>) -> Self {
NxSpan::Text(s)
}
}
impl From<Link> for NxSpan {
fn from(l: Link) -> Self {
NxSpan::Link(l)
}
}
/// A line of [`NxSpan`]s.
#[derive(Debug, Clone, Default)]
pub struct NxLine {
spans: Vec<NxSpan>,
}
impl NxLine {
pub fn from_spans(spans: Vec<NxSpan>) -> Self {
Self { spans }
}
fn has_links(&self) -> bool {
self.spans.iter().any(|s| matches!(s, NxSpan::Link(_)))
}
fn to_line(&self) -> Line<'static> {
Line::from(self.spans.iter().map(NxSpan::to_span).collect::<Vec<_>>())
}
/// Unwrapped display width of the line.
pub fn width(&self) -> u16 {
self.spans.iter().map(|s| text_width(&s.content())).sum()
}
}
impl From<Line<'static>> for NxLine {
fn from(l: Line<'static>) -> Self {
Self {
spans: l.spans.into_iter().map(NxSpan::Text).collect(),
}
}
}
impl From<Span<'static>> for NxLine {
fn from(s: Span<'static>) -> Self {
Self {
spans: vec![NxSpan::Text(s)],
}
}
}
impl From<Vec<NxSpan>> for NxLine {
fn from(spans: Vec<NxSpan>) -> Self {
Self { spans }
}
}
/// Paragraph content: a sequence of [`NxLine`]s.
#[derive(Debug, Clone, Default)]
pub struct NxText {
lines: Vec<NxLine>,
}
impl NxText {
fn has_links(&self) -> bool {
self.lines.iter().any(NxLine::has_links)
}
/// Widest unwrapped line.
pub fn max_width(&self) -> u16 {
self.lines.iter().map(NxLine::width).max().unwrap_or(0)
}
/// Number of visual rows when word-wrapped to `width`.
pub fn wrapped_rows(&self, width: u16, trim: bool) -> usize {
self.layout_rows(width.max(1), true, trim).len()
}
fn to_text(&self) -> Text<'static> {
Text::from(self.lines.iter().map(NxLine::to_line).collect::<Vec<_>>())
}
/// Lay the content out into visual rows of placed pieces, exactly as it will
/// be drawn — the basis for both rendering link rects and counting rows.
fn layout_rows(&self, width: u16, wrap: bool, trim: bool) -> Vec<Vec<Piece>> {
let mut rows = Vec::new();
for line in &self.lines {
if wrap {
wrap_line(line, width.max(1), trim, &mut rows);
} else {
rows.push(
line.spans
.iter()
.map(|s| {
let text = s.content();
let w = text_width(&text);
Piece {
text,
style: s.style(),
href: s.href(),
width: w,
}
})
.collect(),
);
}
}
rows
}
}
impl From<&str> for NxText {
fn from(s: &str) -> Self {
Text::raw(s.to_string()).into()
}
}
impl From<String> for NxText {
fn from(s: String) -> Self {
Text::raw(s).into()
}
}
impl From<Span<'static>> for NxText {
fn from(s: Span<'static>) -> Self {
Self {
lines: vec![NxLine::from(s)],
}
}
}
impl From<Line<'static>> for NxText {
fn from(l: Line<'static>) -> Self {
Self {
lines: vec![NxLine::from(l)],
}
}
}
impl From<Text<'static>> for NxText {
fn from(t: Text<'static>) -> Self {
Self {
lines: t.lines.into_iter().map(NxLine::from).collect(),
}
}
}
impl From<Vec<Line<'static>>> for NxText {
fn from(lines: Vec<Line<'static>>) -> Self {
Self {
lines: lines.into_iter().map(NxLine::from).collect(),
}
}
}
impl From<NxLine> for NxText {
fn from(line: NxLine) -> Self {
Self { lines: vec![line] }
}
}
impl From<Vec<NxLine>> for NxText {
fn from(lines: Vec<NxLine>) -> Self {
Self { lines }
}
}
/// A placed run of text on a single visual row, with its display width and
/// (optional) link target.
#[derive(Debug, Clone)]
struct Piece {
text: String,
style: Style,
href: Option<String>,
width: u16,
}
/// Greedily word-wrap one line into `rows` at `width` columns (matching
/// ratatui's `Wrap { trim }` closely enough; the only consumers are link-bearing
/// paragraphs, whose snapshots are reconciled against this).
fn wrap_line(line: &NxLine, width: u16, trim: bool, rows: &mut Vec<Vec<Piece>>) {
// Tokenize into whitespace / non-whitespace runs, carrying each run's style
// and link target.
let mut tokens: Vec<(String, Style, Option<String>, bool)> = Vec::new();
for span in &line.spans {
let content = span.content();
let style = span.style();
let href = span.href();
let mut cur = String::new();
let mut cur_is_space: Option<bool> = None;
for ch in content.chars() {
let is_space = ch == ' ';
match cur_is_space {
Some(s) if s == is_space => cur.push(ch),
_ => {
if let Some(was_space) = cur_is_space {
tokens.push((std::mem::take(&mut cur), style, href.clone(), was_space));
}
cur.push(ch);
cur_is_space = Some(is_space);
}
}
}
if let Some(was_space) = cur_is_space {
tokens.push((cur, style, href.clone(), was_space));
}
}
let mut row: Vec<Piece> = Vec::new();
let mut row_w: u16 = 0;
for (text, style, href, is_space) in tokens {
let tw = text_width(&text);
if is_space && row.is_empty() && trim {
continue; // trim leading whitespace at the start of a wrapped row
}
if row_w + tw > width && !row.is_empty() {
rows.push(std::mem::take(&mut row));
row_w = 0;
if is_space && trim {
continue; // the space that caused the wrap is dropped when trimming
}
}
// A single token wider than the line is hard-broken across rows.
if tw > width {
if !row.is_empty() {
rows.push(std::mem::take(&mut row));
row_w = 0;
}
for chunk in hard_break(&text, width) {
let cw = text_width(&chunk);
rows.push(vec![Piece {
text: chunk,
style,
href: href.clone(),
width: cw,
}]);
}
continue;
}
push_piece(&mut row, &mut row_w, text, style, href, tw);
}
rows.push(row); // a trailing (possibly empty) row preserves blank lines
}
/// Append a run to the current row, merging into the previous piece when the
/// style and link target match (so a wrapped link stays one rect per row).
fn push_piece(
row: &mut Vec<Piece>,
row_w: &mut u16,
text: String,
style: Style,
href: Option<String>,
tw: u16,
) {
match row.last_mut() {
Some(last) if last.style == style && last.href == href => {
last.text.push_str(&text);
last.width += tw;
}
_ => row.push(Piece {
text,
style,
href,
width: tw,
}),
}
*row_w += tw;
}
/// Break an over-wide token into chunks each no wider than `width` columns.
fn hard_break(text: &str, width: u16) -> Vec<String> {
let mut chunks = Vec::new();
let mut cur = String::new();
let mut cur_w = 0u16;
for ch in text.chars() {
let cw = text_width(&ch.to_string());
if cur_w + cw > width && !cur.is_empty() {
chunks.push(std::mem::take(&mut cur));
cur_w = 0;
}
cur.push(ch);
cur_w += cw;
}
if !cur.is_empty() {
chunks.push(cur);
}
chunks
}
/// A paragraph that can embed clickable [`Link`]s. Drop-in for ratatui's
/// `Paragraph`; render with `render_stateful_widget(.., &mut LinkRegistry)` to
/// capture link positions, or `render_widget` when there are no links.
pub struct NxParagraph<'a> {
text: NxText,
block: Option<Block<'a>>,
style: Style,
wrap: Option<Wrap>,
scroll: (u16, u16),
alignment: Alignment,
}
impl<'a> NxParagraph<'a> {
pub fn new(text: impl Into<NxText>) -> Self {
Self {
text: text.into(),
block: None,
style: Style::default(),
wrap: None,
scroll: (0, 0),
alignment: Alignment::Left,
}
}
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn wrap(mut self, wrap: Wrap) -> Self {
self.wrap = Some(wrap);
self
}
pub fn scroll(mut self, scroll: (u16, u16)) -> Self {
self.scroll = scroll;
self
}
pub fn alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
pub fn left_aligned(self) -> Self {
self.alignment(Alignment::Left)
}
pub fn centered(self) -> Self {
self.alignment(Alignment::Center)
}
pub fn right_aligned(self) -> Self {
self.alignment(Alignment::Right)
}
/// Number of visual rows the content wraps to at `width` — uses the same
/// layout as rendering, so scroll math stays consistent.
pub fn line_count(&self, width: u16) -> usize {
self.text
.layout_rows(width.max(1), self.wrap.is_some(), self.trim())
.len()
}
fn trim(&self) -> bool {
self.wrap.map(|w| w.trim).unwrap_or(false)
}
/// Equivalent ratatui `Paragraph` for the link-less fast path. This module
/// is the sanctioned wrapper, so ratatui's banned widget is allowed here.
#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
fn to_ratatui(self) -> ratatui::widgets::Paragraph<'a> {
let mut p = ratatui::widgets::Paragraph::new(self.text.to_text())
.style(self.style)
.scroll(self.scroll)
.alignment(self.alignment);
if let Some(block) = self.block {
p = p.block(block);
}
if let Some(wrap) = self.wrap {
p = p.wrap(wrap);
}
p
}
/// Own the placement so each link's drawn rect can be recorded.
fn render_with_links(self, area: Rect, buf: &mut Buffer, registry: &mut LinkRegistry) {
let style = self.style;
let alignment = self.alignment;
let wrap = self.wrap.is_some();
let trim = self.trim();
let scroll = self.scroll;
let text_area = if let Some(block) = self.block {
let inner = block.inner(area);
block.render(area, buf);
inner
} else {
area
};
if text_area.width == 0 || text_area.height == 0 {
return;
}
buf.set_style(text_area, style);
let rows = self.text.layout_rows(text_area.width, wrap, trim);
let scroll_y = scroll.0 as usize;
let scroll_x = scroll.1;
for (vis_idx, row) in rows
.iter()
.enumerate()
.skip(scroll_y)
.take(text_area.height as usize)
{
let y = text_area.y + (vis_idx - scroll_y) as u16;
let row_w: u16 = row.iter().map(|p| p.width).sum();
// Row origin before horizontal scroll, per alignment.
let aligned_x = match alignment {
Alignment::Left => text_area.x,
Alignment::Center => text_area.x + text_area.width.saturating_sub(row_w) / 2,
Alignment::Right => text_area.x + text_area.width.saturating_sub(row_w),
};
let mut col_in_row: u16 = 0;
for piece in row {
// Where the piece's start lands once horizontally scrolled.
let start = aligned_x as i32 + col_in_row as i32 - scroll_x as i32;
col_in_row = col_in_row.saturating_add(piece.width);
// Clip the part scrolled off the left edge.
let (text, draw_x) = if start >= text_area.x as i32 {
(piece.text.clone(), start as u16)
} else {
let hidden = (text_area.x as i32 - start) as u16;
if hidden >= piece.width {
continue; // entirely scrolled off the left
}
(drop_leading_cols(&piece.text, hidden), text_area.x)
};
if draw_x >= text_area.right() {
continue; // off the right edge
}
let max = (text_area.right() - draw_x) as usize;
let (end_x, _) = buf.set_stringn(draw_x, y, &text, max, piece.style);
if let Some(href) = &piece.href {
let drawn_w = end_x.saturating_sub(draw_x);
if drawn_w > 0 {
registry.push(
Rect {
x: draw_x,
y,
width: drawn_w,
height: 1,
},
href.clone(),
);
}
}
}
}
}
}
/// Drop the first `skip` display columns from `text` (for horizontal scroll). A
/// wide character straddling the boundary is dropped whole.
fn drop_leading_cols(text: &str, skip: u16) -> String {
let mut dropped = 0u16;
let mut chars = text.chars();
while dropped < skip {
match chars.next() {
Some(ch) => dropped = dropped.saturating_add(text_width(&ch.to_string())),
None => return String::new(),
}
}
chars.as_str().to_string()
}
impl Widget for NxParagraph<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.text.has_links() {
// No registry to record into, but still draw correctly (links render
// as styled text, just not clickable).
let mut throwaway = LinkRegistry::new();
self.render_with_links(area, buf, &mut throwaway);
} else {
self.to_ratatui().render(area, buf);
}
}
}
impl StatefulWidget for NxParagraph<'_> {
type State = LinkRegistry;
fn render(self, area: Rect, buf: &mut Buffer, registry: &mut Self::State) {
if self.text.has_links() {
self.render_with_links(area, buf, registry);
} else {
self.to_ratatui().render(area, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: u16, y: u16, width: u16, height: u16) -> Rect {
Rect {
x,
y,
width,
height,
}
}
#[test]
fn records_link_rect_inline() {
let area = rect(0, 0, 30, 1);
let mut buf = Buffer::empty(area);
let mut registry = LinkRegistry::new();
let line = NxLine::from_spans(vec![
Span::raw("see ").into(),
Link::new("the docs", "https://nx.dev").into(),
Span::raw(" now").into(),
]);
StatefulWidget::render(NxParagraph::new(line), area, &mut buf, &mut registry);
let rendered: String = (0..16).map(|x| buf[(x, 0)].symbol()).collect();
assert_eq!(rendered, "see the docs now");
// Only "the docs" (cols 4..12) is the link.
assert_eq!(registry.hit_test(3, 0), None);
assert_eq!(registry.hit_test(4, 0), Some("https://nx.dev"));
assert_eq!(registry.hit_test(11, 0), Some("https://nx.dev"));
assert_eq!(registry.hit_test(12, 0), None);
}
#[test]
fn wrapped_link_records_a_rect_per_row() {
let area = rect(0, 0, 10, 3);
let mut buf = Buffer::empty(area);
let mut registry = LinkRegistry::new();
// "alpha beta gamma" (16 cols) wraps within width 10.
let line = NxLine::from_spans(vec![Link::new("alpha beta gamma", "u").into()]);
StatefulWidget::render(
NxParagraph::new(line).wrap(Wrap { trim: false }),
area,
&mut buf,
&mut registry,
);
// Link is clickable on both wrapped rows.
assert_eq!(registry.hit_test(0, 0), Some("u"));
assert_eq!(registry.hit_test(0, 1), Some("u"));
}
#[test]
fn link_less_paragraph_renders_and_registers_nothing() {
let area = rect(0, 0, 12, 1);
let mut buf = Buffer::empty(area);
let mut registry = LinkRegistry::new();
StatefulWidget::render(
NxParagraph::new(Span::raw("plain text")),
area,
&mut buf,
&mut registry,
);
let rendered: String = (0..10).map(|x| buf[(x, 0)].symbol()).collect();
assert_eq!(rendered, "plain text");
assert_eq!(registry.hit_test(0, 0), None);
}
#[test]
fn a_link_label_that_wraps_mid_label_is_clickable_on_every_row() {
// "hello my dear friend" is one link; at width 10 the label wraps. Every
// wrapped row-segment of it must be clickable, and the whole label renders.
let area = rect(0, 0, 10, 5);
let mut buf = Buffer::empty(area);
let mut registry = LinkRegistry::new();
let line = NxLine::from_spans(vec![Link::new("hello my dear friend", "u").into()]);
StatefulWidget::render(
NxParagraph::new(line).wrap(Wrap { trim: true }),
area,
&mut buf,
&mut registry,
);
let mut hit_rows: Vec<u16> = (0..area.height)
.flat_map(|y| (0..area.width).map(move |x| (x, y)))
.filter(|&(x, y)| registry.hit_test(x, y) == Some("u"))
.map(|(_, y)| y)
.collect();
hit_rows.sort_unstable();
hit_rows.dedup();
assert!(
hit_rows.len() >= 2,
"a wrapped link label must be clickable on each of its rows, got {hit_rows:?}"
);
let text: String = (0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("");
for word in ["hello", "my", "dear", "friend"] {
assert!(text.contains(word), "label word {word:?} did not render");
}
}
#[test]
fn horizontal_scroll_clips_and_shifts_link_rects() {
let area = rect(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
let mut registry = LinkRegistry::new();
// "abc" + link "LINK" (cols 3..7) + "defghij", unwrapped (wider than area).
let line = NxLine::from_spans(vec![
Span::raw("abc").into(),
Link::new("LINK", "u").into(),
Span::raw("defghij").into(),
]);
// Scroll right 4 cols: 'a','b','c','L' are hidden; row starts at 'I'.
StatefulWidget::render(
NxParagraph::new(line).scroll((0, 4)),
area,
&mut buf,
&mut registry,
);
let rendered: String = (0..10).map(|x| buf[(x, 0)].symbol()).collect();
assert_eq!(rendered, "INKdefghij");
// Only the visible tail of the link ("INK", screen cols 0..3) is clickable.
assert_eq!(registry.hit_test(0, 0), Some("u"));
assert_eq!(registry.hit_test(2, 0), Some("u"));
assert_eq!(registry.hit_test(3, 0), None); // 'd', past the link
}
#[test]
fn line_count_matches_wrapping() {
let para = NxParagraph::new(Span::raw("alpha beta gamma")).wrap(Wrap { trim: false });
// 16 cols / width 10 -> 2 rows.
assert_eq!(para.line_count(10), 2);
}
}
@@ -3,12 +3,10 @@ use hashbrown::{HashMap, HashSet};
use parking_lot::Mutex;
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
layout::{Alignment, Constraint, Direction, Layout, Position, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{
Block, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table,
},
widgets::{Block, Cell, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table},
};
use serde::{Deserialize, Serialize};
use std::any::Any;
@@ -16,9 +14,11 @@ use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use super::help_text::HelpText;
use super::link::{Link, LinkRegistry};
use super::task_selection_manager::{
ScrollMetrics, SelectionEntry, SelectionMode, TaskSection, TaskSelectionManager,
};
use crate::native::tui::components::nx_paragraph::NxParagraph;
use crate::native::{
tasks::types::{Task, TaskResult},
tui::{
@@ -259,6 +259,9 @@ pub struct TasksList {
filter_persisted: bool, // Whether the filter is in a persisted state
spacebar_mode: bool, // Whether we're in spacebar mode (output follows selection)
cloud_message: Option<String>,
/// Structured Nx Cloud link (display label, href URL). When set, it renders
/// as a clickable label in place of the raw `cloud_message`.
cloud_link: Option<(String, String)>,
max_parallel: usize, // Maximum number of parallel tasks
title_text: String,
pub action_tx: Option<UnboundedSender<Action>>,
@@ -272,10 +275,32 @@ pub struct TasksList {
// and running batch groups, in start order. Excludes tasks nested in a batch
// (the batch group represents them).
in_progress_entries: Vec<SelectionEntry>,
needs_sort: bool, // Deferred sort flag - sort once per render frame
needs_sort: bool, // Deferred sort flag - sort once per render frame
/// Screen rect of the scrollable rows area (the table minus its header
/// overhead), captured during render so mouse clicks can map a row to an
/// entry. Each visible viewport entry occupies one row.
rows_hit_area: Option<Rect>,
/// External links (currently the Nx Cloud message link) recorded during
/// render. The app hit-tests this on click to open the link.
link_registry: LinkRegistry,
/// Screen rect of the table's text region (excluding the scrollbar column),
/// captured during render so a drag can select task ids/statuses/durations.
text_selection_area: Option<Rect>,
perf_report_available: bool, // Whether the performance report exists yet (run finished)
}
/// Outcome of a mouse click landing inside the task list, returned to the App so
/// it can react (focus, mode switch, or open a link) outside the component's
/// borrow.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskListClick {
/// A task/batch row was selected.
Select,
/// A task/batch row was double-clicked — open and focus it in the main
/// terminal pane area (the same as pressing Enter on the row).
OpenInPane,
}
impl TasksList {
/// Creates a new TasksList with the given tasks.
/// Converts the input tasks into TaskItems and initializes the UI state.
@@ -310,6 +335,7 @@ impl TasksList {
filter_persisted: false,
spacebar_mode: false,
cloud_message: None,
cloud_link: None,
max_parallel: DEFAULT_MAX_PARALLEL,
title_text,
action_tx: None,
@@ -321,6 +347,9 @@ impl TasksList {
terminal_width: None,
in_progress_entries: Vec::new(),
needs_sort: false,
rows_hit_area: None,
link_registry: LinkRegistry::new(),
text_selection_area: None,
perf_report_available: false,
};
@@ -1935,7 +1964,7 @@ impl TasksList {
Line::from(vec![Span::styled(instruction_text, filter_style)]),
];
let filter_paragraph = Paragraph::new(filter_lines).alignment(Alignment::Left);
let filter_paragraph = NxParagraph::new(filter_lines).alignment(Alignment::Left);
f.render_widget(filter_paragraph, filter_area);
}
@@ -1947,6 +1976,76 @@ impl TasksList {
total_entries > dynamic_viewport_height
}
/// The table's text region from the last render, used to bound a drag-based
/// text selection (and exclude the scrollbar).
pub fn selection_area(&self) -> Option<Rect> {
self.text_selection_area
}
/// Resolve a left-click at terminal cell `(col, row)` within the task list.
///
/// The cloud link is checked first; otherwise the row is mapped to a viewport
/// entry (rows begin `TABLE_HEADER_OVERHEAD_ROWS` below the table top and are
/// one entry tall). A task/batch row updates the selection and returns
/// `Select`, or `OpenInPane` on a double-click; a single click on a batch row
/// also toggles its expansion. Returns `None` when the click doesn't land on
/// anything actionable (header, blank row, gap).
pub fn handle_click(&mut self, col: u16, row: u16, is_double: bool) -> Option<TaskListClick> {
// External links (e.g. the cloud message) are hit-tested by the app via
// `link_registry`, before row clicks, so they aren't handled here.
let area = self.rows_hit_area?;
if !area.contains(Position::new(col, row)) {
return None;
}
// Rows begin below the header overhead; clicks above that aren't rows.
let first_row_y = area.y.saturating_add(TABLE_HEADER_OVERHEAD_ROWS);
if row < first_row_y {
return None;
}
let index = (row - first_row_y) as usize;
// Map the row index to the visible viewport entry. Blank/placeholder
// rows are `None` and are not selectable.
let entry = {
let manager = self.selection_manager.lock();
manager
.get_viewport_entries()
.into_iter()
.nth(index)
.flatten()
}?;
match &entry {
SelectionEntry::Task(task_id) => {
self.selection_manager.lock().select_task(task_id);
}
SelectionEntry::BatchGroup(batch_id) => {
self.selection_manager.lock().select_batch_group(batch_id);
// A single click on a batch row toggles its expansion so the
// mouse can open/close batches like the ←/→ keys do. (The first
// click of a double-click toggles; the second opens it in a pane.)
if !is_double {
let expanded = self
.get_batch_group_by_id(batch_id)
.map(|batch| batch.is_expanded)
.unwrap_or(false);
if expanded {
self.collapse_batch(batch_id);
} else {
self.expand_batch(batch_id);
}
}
}
}
if is_double {
Some(TaskListClick::OpenInPane)
} else {
Some(TaskListClick::Select)
}
}
/// Renders the main task table with scrollbar if needed.
fn render_task_table(
&mut self,
@@ -1956,6 +2055,9 @@ impl TasksList {
needs_scrollbar: bool,
scroll_metrics: &ScrollMetrics,
) {
// Record the table rect for mouse hit-testing. Rows start
// TABLE_HEADER_OVERHEAD_ROWS below the top and are one viewport entry tall.
self.rows_hit_area = Some(table_area);
let visible_entries = self.selection_manager.lock().get_viewport_entries();
let selected_style = Style::default()
.fg(THEME.primary_fg)
@@ -2288,6 +2390,11 @@ impl TasksList {
f.render_widget(t, table_render_area);
// The text region is the table minus the scrollbar/padding, so a drag
// selection never grabs the scrollbar glyph. (Set after rendering to
// avoid overlapping the immutable `header` borrow above.)
self.text_selection_area = Some(table_render_area);
// Render scrollbar if needed
if let Some(scrollbar_area) = scrollbar_area {
// Position scrollbar below top_margin + header, spanning the spacing row and content
@@ -2567,87 +2674,107 @@ impl TasksList {
help_text.render(f, help_text_area);
}
/// Renders messages received from Nx Cloud
fn render_cloud_message(&self, f: &mut Frame<'_>, cloud_message_area: Rect, is_dimmed: bool) {
if let Some(message) = &self.cloud_message {
let available_width = cloud_message_area.width;
// Ensure minimum width to render anything
if available_width == 0 || cloud_message_area.height == 0 {
return;
}
let message_style = if is_dimmed {
Style::default().fg(THEME.secondary_fg).dim()
} else {
Style::default().fg(THEME.secondary_fg)
};
// No URL present in the message, render the message as is if it fits, otherwise truncate
if !message.contains("https://") {
let message_line = Line::from(Span::styled(message.as_str(), message_style));
// Line fits as is
if message_line.width() <= available_width as usize {
let cloud_message_paragraph =
Paragraph::new(message_line).alignment(Alignment::Left);
f.render_widget(cloud_message_paragraph, cloud_message_area);
return;
}
// Line doesn't fit, truncate
let max_message_render_len = available_width.saturating_sub(3); // Reserve for "..."
let truncated_message =
format!("{}...", &message[..max_message_render_len as usize]);
let cloud_message_paragraph =
Paragraph::new(Line::from(Span::styled(truncated_message, message_style)))
.alignment(Alignment::Left);
f.render_widget(cloud_message_paragraph, cloud_message_area);
return;
}
// Find URL position
let url_start_pos = message.find("https://").unwrap_or(message.len());
// Figure out the "prefix" (i.e. any message contents before the URL)
let prefix = &message[0..url_start_pos];
let url = &message[url_start_pos..];
let prefix_len = prefix.len() as u16;
let url_len = url.len() as u16;
let mut spans = vec![];
let url_style = if is_dimmed {
Style::default().fg(THEME.info).underlined().dim()
} else {
Style::default().fg(THEME.info).underlined()
};
// Determine what fits, prioritizing the URL
if url_len <= available_width {
// Full URL Fits, check if the full message does, and if so, render the full thing
if prefix_len + url_len <= available_width {
spans.push(Span::styled(prefix, message_style));
spans.push(Span::styled(url, url_style));
} else {
// Only URL fits, do not render the prefix
spans.push(Span::styled(url, url_style));
}
} else if available_width >= MIN_CLOUD_URL_WIDTH {
// Full URL doesn't fit, but Truncated URL does.
let max_url_render_len = available_width.saturating_sub(3); // Reserve for "..."
let truncated_url = format!("{}...", &url[..max_url_render_len as usize]);
spans.push(Span::styled(truncated_url, url_style));
} else {
// Not enough space for even truncated URL, show nothing...
// Hopefully in this situation user can make their terminal bigger or switch layout mode
}
if !spans.is_empty() {
let message_line = Line::from(spans);
let cloud_message_paragraph =
Paragraph::new(message_line).alignment(Alignment::Left);
f.render_widget(cloud_message_paragraph, cloud_message_area);
}
/// Renders messages received from Nx Cloud.
///
/// When the message contains a URL, the URL is rendered as a clickable
/// [`Link`] (recorded in `link_registry` for the app to hit-test). The link
/// truncates its display with an ellipsis when space is tight while still
/// opening the full href.
fn render_cloud_message(
&mut self,
f: &mut Frame<'_>,
cloud_message_area: Rect,
is_dimmed: bool,
) {
let available_width = cloud_message_area.width;
if available_width == 0 || cloud_message_area.height == 0 {
return;
}
// A structured cloud link takes precedence: render its label as a
// clickable link that opens the (different) href. Clone so the borrow of
// `self.cloud_link` ends before we touch `&mut self.link_registry`.
if let Some((label, url)) = self.cloud_link.clone() {
let link = Link::new(label, url).dim(is_dimmed);
f.render_stateful_widget(&link, cloud_message_area, &mut self.link_registry);
return;
}
// Clone so the borrow of `self.cloud_message` ends before we render the
// link, which needs `&mut self.link_registry`.
let Some(message) = self.cloud_message.clone() else {
return;
};
let message_style = if is_dimmed {
Style::default().fg(THEME.secondary_fg).dim()
} else {
Style::default().fg(THEME.secondary_fg)
};
// No URL present: render the message as-is if it fits, otherwise truncate.
if !message.contains("https://") {
let message_line = Line::from(Span::styled(message.clone(), message_style));
if message_line.width() <= available_width as usize {
let cloud_message_paragraph =
NxParagraph::new(message_line).alignment(Alignment::Left);
f.render_widget(cloud_message_paragraph, cloud_message_area);
return;
}
let max_message_render_len = available_width.saturating_sub(3) as usize; // Reserve for "..."
let truncated_message = format!("{}...", &message[..max_message_render_len]);
let cloud_message_paragraph =
NxParagraph::new(Line::from(Span::styled(truncated_message, message_style)))
.alignment(Alignment::Left);
f.render_widget(cloud_message_paragraph, cloud_message_area);
return;
}
// Split into a plain-text prefix and the URL.
let url_start_pos = message.find("https://").unwrap_or(message.len());
let prefix = &message[0..url_start_pos];
let url = &message[url_start_pos..];
let prefix_width = Span::raw(prefix).width() as u16;
let url_width = Span::raw(url).width() as u16;
// The full URL doesn't fit and there isn't even room for a useful
// truncation: render nothing (user can widen the terminal).
if url_width > available_width && available_width < MIN_CLOUD_URL_WIDTH {
return;
}
// Show the prefix only when it fits alongside the full URL; otherwise the
// URL link takes the whole row (the link truncates itself if needed).
let show_prefix = prefix_width > 0 && prefix_width + url_width <= available_width;
let link_x = if show_prefix {
let prefix_area = Rect {
width: prefix_width,
..cloud_message_area
};
let prefix_paragraph =
NxParagraph::new(Line::from(Span::styled(prefix.to_string(), message_style)))
.alignment(Alignment::Left);
f.render_widget(prefix_paragraph, prefix_area);
cloud_message_area.x.saturating_add(prefix_width)
} else {
cloud_message_area.x
};
let link_width = cloud_message_area.right().saturating_sub(link_x);
if link_width == 0 {
return;
}
let link_area = Rect {
x: link_x,
y: cloud_message_area.y,
width: link_width,
height: 1,
};
// Display text and href are the same here (the URL); a caller that wants
// friendly text like "View in Nx Cloud" passes a distinct display.
let link = Link::new(url, url).dim(is_dimmed);
f.render_stateful_widget(&link, link_area, &mut self.link_registry);
}
}
@@ -2658,6 +2785,11 @@ impl Component for TasksList {
}
fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> {
// Reset per-frame link hit-testing; repopulated as the cloud message
// renders below. (The app also clears this before the draw pass, but
// clearing here keeps the component correct when drawn directly, e.g. in
// tests.)
self.link_registry.clear();
// Flush any pending sort before rendering
self.prepare_for_render();
@@ -2668,7 +2800,9 @@ impl Component for TasksList {
// --- 1. Initial Context ---
let filter_is_active = self.filter_mode || !self.filter_text.is_empty();
let is_dimmed = !self.is_task_list_focused();
let has_cloud_message = self.cloud_message.is_some();
// A structured cloud link takes precedence over a raw cloud message, but
// either counts as "has cloud content" for laying out the bottom bar.
let has_cloud_message = self.cloud_message.is_some() || self.cloud_link.is_some();
// --- 2. Determine Bottom Layout Mode ---
enum BottomLayoutMode {
@@ -2681,7 +2815,10 @@ impl Component for TasksList {
if has_cloud_message {
// Calculate the actual cloud message width that will be rendered
// This accounts for the URL-only fallback when the full message doesn't fit
let cloud_text_width = if let Some(message) = &self.cloud_message {
let cloud_text_width = if let Some((label, _url)) = &self.cloud_link {
// A structured link renders just its (short) label.
Span::raw(label.as_str()).width() as u16
} else if let Some(message) = &self.cloud_message {
if message.contains("https://") {
let url_start_pos = message.find("https://").unwrap_or(message.len());
let prefix = &message[0..url_start_pos];
@@ -3037,6 +3174,9 @@ impl Component for TasksList {
Action::UpdateCloudMessage(message) => {
self.cloud_message = Some(message);
}
Action::UpdateCloudLink(label, url) => {
self.cloud_link = Some((label, url));
}
Action::ScrollUp => {
self.scroll_up();
}
@@ -3075,6 +3215,14 @@ impl Component for TasksList {
Ok(None)
}
fn link_registry(&self) -> Option<&LinkRegistry> {
Some(&self.link_registry)
}
fn link_registry_mut(&mut self) -> Option<&mut LinkRegistry> {
Some(&mut self.link_registry)
}
fn as_any(&self) -> &dyn Any {
self
}
@@ -4057,6 +4205,113 @@ mod tests {
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn cloud_message_url_is_registered_as_a_clickable_link() {
let (mut tasks_list, test_tasks) = create_test_tasks_list();
let mut terminal = create_test_terminal(120, 15);
for task in &test_tasks {
tasks_list
.update(Action::UpdateTaskStatus(
task.id.clone(),
TaskStatus::Success,
))
.unwrap();
}
let url = "https://nx.app/runs/KnGk4A47qk";
tasks_list
.update(Action::UpdateCloudMessage(format!(
"View logs and run details at {url}"
)))
.ok();
render_to_test_backend(&mut terminal, &mut tasks_list);
let registry = tasks_list
.link_registry()
.expect("the task list exposes a link registry");
// Every clickable cell resolves to the full URL, and the clickable region
// spans exactly the URL's width on a single row (the prefix text is not
// part of the link).
let mut hits = 0usize;
for y in 0..15u16 {
for x in 0..120u16 {
if let Some(href) = registry.hit_test(x, y) {
assert_eq!(href, url, "only the cloud URL should be clickable");
hits += 1;
}
}
}
assert_eq!(
hits,
url.chars().count(),
"the clickable region matches the URL width"
);
}
#[test]
fn cloud_link_renders_label_and_opens_url() {
let (mut tasks_list, test_tasks) = create_test_tasks_list();
let mut terminal = create_test_terminal(120, 15);
for task in &test_tasks {
tasks_list
.update(Action::UpdateTaskStatus(
task.id.clone(),
TaskStatus::Success,
))
.unwrap();
}
let label = "View in Nx Cloud";
let url = "https://nx.app/runs/KnGk4A47qk";
tasks_list
.update(Action::UpdateCloudLink(label.to_string(), url.to_string()))
.ok();
render_to_test_backend(&mut terminal, &mut tasks_list);
// The friendly label is drawn (not the URL) and clicking anywhere on it
// opens the full URL. Scan row-major so horizontal text is contiguous.
let buffer = terminal.backend().buffer().clone();
let rendered: String = (0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains(label),
"the friendly label should be rendered somewhere"
);
assert!(
!rendered.contains(url),
"the raw URL should not be shown when a structured link is set"
);
let registry = tasks_list
.link_registry()
.expect("the task list exposes a link registry");
let mut hits = 0usize;
for y in 0..15u16 {
for x in 0..120u16 {
if let Some(href) = registry.hit_test(x, y) {
assert_eq!(href, url, "the link opens the full URL");
hits += 1;
}
}
}
assert_eq!(
hits,
label.chars().count(),
"the clickable region matches the label width"
);
}
#[test]
fn test_not_focused() {
let (mut tasks_list, test_tasks) = create_test_tasks_list();
@@ -1,4 +1,4 @@
use arboard::Clipboard;
use crate::native::tui::clipboard::copy_to_clipboard;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
@@ -6,14 +6,15 @@ use ratatui::{
style::{Modifier, Style, Stylize},
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, StatefulWidget, Widget,
Block, BorderType, Borders, Padding, Scrollbar, ScrollbarOrientation, ScrollbarState,
StatefulWidget, Widget,
},
};
use std::{io, sync::Arc, time::Instant};
use tracing::debug;
use tui_term::widget::PseudoTerminal;
use crate::native::tui::components::nx_paragraph::NxParagraph;
use crate::native::tui::components::tasks_list::TaskStatus;
use crate::native::tui::scroll_momentum::{ScrollDirection, ScrollMomentum};
use crate::native::tui::theme::THEME;
@@ -23,6 +24,41 @@ use crate::native::tui::utils::{
use crate::native::tui::vt100_adapter::Vt100CttScreen;
use crate::native::tui::{action::Action, pty::PtyInstance};
/// A text selection within a terminal pane, tracked in absolute content
/// (visual-row, column) coordinates so it stays anchored to the text as the
/// pane scrolls.
#[derive(Debug, Clone, Copy)]
struct TextSelection {
anchor: (usize, usize),
cursor: (usize, usize),
/// Whether a drag is currently in progress (vs. a finalized selection).
dragging: bool,
}
impl TextSelection {
/// Normalized `(start, end)` with `start <= end` in reading order.
fn range(&self) -> ((usize, usize), (usize, usize)) {
if self.anchor <= self.cursor {
(self.anchor, self.cursor)
} else {
(self.cursor, self.anchor)
}
}
/// True once the selection spans more than its origin cell (a real drag).
fn is_nonempty(&self) -> bool {
self.anchor != self.cursor
}
/// Whether content cell `(row, col)` falls inside the selection (inclusive).
fn contains(&self, row: usize, col: usize) -> bool {
let (start, end) = self.range();
let after_start = row > start.0 || (row == start.0 && col >= start.1);
let before_end = row < end.0 || (row == end.0 && col <= end.1);
after_start && before_end
}
}
/// Configuration for terminal pane layout and display constants
#[derive(Debug, Clone)]
struct TerminalPaneConfig {
@@ -53,6 +89,11 @@ pub struct TerminalPaneData {
scroll_momentum: ScrollMomentum,
// Transient status message with timestamp for auto-clear
pub status_message: Option<(String, Instant)>,
/// Active text selection within this pane, if any (NXC-3946).
selection: Option<TextSelection>,
/// Inner content rect (inside borders/padding) captured during the last
/// render, used to translate mouse coordinates into content coordinates.
last_content_area: Option<Rect>,
}
impl TerminalPaneData {
@@ -64,6 +105,8 @@ impl TerminalPaneData {
can_be_interactive: false,
scroll_momentum: ScrollMomentum::new(),
status_message: None,
selection: None,
last_content_area: None,
}
}
@@ -116,24 +159,31 @@ impl TerminalPaneData {
pty_mut.scroll_down(12);
return Ok(None);
}
// Handle 'c' for copying when not in interactive mode
// Handle 'c' for copying when not in interactive mode. Prefer an
// active selection; fall back to copying the whole output.
KeyCode::Char('c') if !self.is_interactive => {
let status_message = if let Some(screen) = pty.get_screen() {
// Unformatted output (no ANSI escape codes)
let output = screen.all_contents();
match Clipboard::new() {
Ok(mut clipboard) => {
if clipboard.set_text(output).is_ok() {
Some("Output copied")
} else {
Some("Copy failed")
}
let status_message =
if let Some(sel) = self.selection.filter(|s| s.is_nonempty()) {
let (start, end) = sel.range();
let text = pty.selected_text(start, end);
if text.is_empty() {
None
} else if copy_to_clipboard(&text) {
Some("Selection copied")
} else {
Some("Copy failed")
}
Err(_) => Some("Copy failed"),
}
} else {
None
};
} else if let Some(screen) = pty.get_screen() {
// Unformatted output (no ANSI escape codes)
let output = screen.all_contents();
if copy_to_clipboard(&output) {
Some("Output copied")
} else {
Some("Copy failed")
}
} else {
None
};
// Set status message outside the pty borrow
if let Some(msg) = status_message {
self.status_message = Some((msg.to_owned(), Instant::now()));
@@ -225,6 +275,130 @@ impl TerminalPaneData {
}
}
}
/// Public entry point for mouse-wheel scrolling of the terminal output.
/// Reuses the same momentum model as keyboard scrolling so the wheel and
/// arrow keys feel consistent.
pub fn handle_mouse_scroll(&mut self, direction: ScrollDirection) {
self.scroll(direction);
}
// --- Text selection (NXC-3946) -------------------------------------------
/// Translate a terminal cell `(col, row)` into absolute content coordinates
/// `(visual_row, column)` within this pane, if the pane has rendered content
/// and the cell falls inside the content area.
pub fn content_coords_at(&self, col: u16, row: u16) -> Option<(usize, usize)> {
let area = self.last_content_area?;
if col < area.x
|| col >= area.x.saturating_add(area.width)
|| row < area.y
|| row >= area.y.saturating_add(area.height)
{
return None;
}
let pty = self.pty.as_ref()?;
let screen_row = (row - area.y) as usize;
let screen_col = (col - area.x) as usize;
// Map the on-screen row to an absolute content row: the top visible row
// is `total - viewport_height - scrollback` rows from the start.
let top = pty
.get_total_content_rows()
.saturating_sub(area.height as usize)
.saturating_sub(pty.get_scroll_offset());
Some((top + screen_row, screen_col))
}
/// Like [`content_coords_at`](Self::content_coords_at) but clamps the cell
/// into the content area first, so a drag that strays outside the pane still
/// extends the selection to the nearest edge.
pub fn content_coords_clamped(&self, col: u16, row: u16) -> Option<(usize, usize)> {
let area = self.last_content_area?;
if area.width == 0 || area.height == 0 {
return None;
}
let c = col.clamp(area.x, area.x + area.width - 1);
let r = row.clamp(area.y, area.y + area.height - 1);
self.content_coords_at(c, r)
}
/// Vertical edge of the content area the cell is at, for drag auto-scroll:
/// `-1` at/above the top edge, `1` at/below the bottom edge, `0` otherwise.
pub fn content_edge(&self, row: u16) -> i8 {
match self.last_content_area {
Some(area) if area.height > 0 => {
if row <= area.y {
-1
} else if row >= area.y + area.height - 1 {
1
} else {
0
}
}
_ => 0,
}
}
/// Begin a selection drag at the given content coordinates.
pub fn begin_selection(&mut self, row: usize, col: usize) {
self.selection = Some(TextSelection {
anchor: (row, col),
cursor: (row, col),
dragging: true,
});
}
/// Update the in-progress selection's cursor to new content coordinates.
pub fn update_selection(&mut self, row: usize, col: usize) {
if let Some(sel) = &mut self.selection
&& sel.dragging
{
sel.cursor = (row, col);
}
}
/// Finish the current selection drag. A plain click (no movement) clears the
/// selection. Returns true if a non-empty selection remains.
pub fn finish_selection(&mut self) -> bool {
if let Some(sel) = &mut self.selection {
sel.dragging = false;
if !sel.is_nonempty() {
self.selection = None;
return false;
}
return true;
}
false
}
/// Clear any selection.
pub fn clear_selection(&mut self) {
self.selection = None;
}
/// Copy the current selection to the clipboard and set a status message.
pub fn copy_selection(&mut self) {
let Some(sel) = self.selection else {
return;
};
if !sel.is_nonempty() {
return;
}
let Some(pty) = self.pty.as_ref() else {
return;
};
let (start, end) = sel.range();
let text = pty.selected_text(start, end);
if text.is_empty() {
return;
}
let msg = if copy_to_clipboard(&text) {
"Selection copied"
} else {
"Copy failed"
};
self.status_message = Some((msg.to_owned(), Instant::now()));
}
}
impl Default for TerminalPaneData {
@@ -432,7 +606,7 @@ impl<'a> TerminalPane<'a> {
impl<'a> StatefulWidget for TerminalPane<'a> {
type State = TerminalPaneState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
// Clamp to the buffer to avoid rendering outside bounds
let safe_area = area.intersection(*buf.area());
if safe_area.width == 0 || safe_area.height == 0 {
@@ -444,7 +618,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
if safe_area.width < 5 || safe_area.height < 5 {
// Just render a minimal indicator instead of a full pane
let text = "...";
let paragraph = Paragraph::new(text)
let paragraph = NxParagraph::new(text)
.style(Style::default().fg(THEME.secondary_fg))
.alignment(Alignment::Center);
Widget::render(paragraph, safe_area, buf);
@@ -547,7 +721,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
},
)])];
let paragraph = Paragraph::new(message)
let paragraph = NxParagraph::new(message)
.block(block)
.alignment(Alignment::Center)
.style(Style::default());
@@ -570,7 +744,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
},
)])];
let paragraph = Paragraph::new(message)
let paragraph = NxParagraph::new(message)
.block(block)
.alignment(Alignment::Center)
.style(Style::default());
@@ -591,7 +765,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
},
)])];
let paragraph = Paragraph::new(message)
let paragraph = NxParagraph::new(message)
.block(block)
.alignment(Alignment::Center)
.style(Style::default());
@@ -613,7 +787,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
message_style,
)])];
let paragraph = Paragraph::new(message)
let paragraph = NxParagraph::new(message)
.block(block)
.alignment(Alignment::Center)
.style(Style::default());
@@ -642,7 +816,7 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
message_style,
)])];
let paragraph = Paragraph::new(message)
let paragraph = NxParagraph::new(message)
.block(block)
.alignment(Alignment::Center)
.style(Style::default());
@@ -653,20 +827,36 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
let inner_area = block.inner(safe_area);
// Record the content rect so mouse events can map cells to content
// coordinates for text selection. Scoped so the mutable borrow ends
// before the immutable render borrow below.
if let Some(pty_data) = &mut self.pty_data {
pty_data.last_content_area = Some(inner_area);
}
if let Some(pty_data) = &self.pty_data {
if let Some(pty) = &pty_data.pty {
if let Some(screen) = pty.get_screen() {
let viewport_height = inner_area.height;
// Read every value that needs the parser lock BEFORE acquiring the
// `screen` read guard below. parking_lot's RwLock is non-reentrant
// and writer-preferring: a blocking `read()` taken while `screen`
// is still held deadlocks the render thread against the PTY's
// output-writer thread the moment that thread has a `write()`
// queued. That is what froze the entire TUI when selecting text in
// a pane that was still producing output.
let viewport_height = inner_area.height;
let current_scroll = pty.get_scroll_offset();
// Calculate content based on expected dimensions, not current PTY
// dimensions. This prevents scrollbar flash when the PTY hasn't
// been resized yet.
let total_content_rows =
self.calculate_content_rows_for_viewport(pty, viewport_height);
// Absolute content-row count used to map the selection overlay,
// matching `content_coords_at`'s basis.
let selection_content_rows = pty.get_total_content_rows();
if let Some(screen) = pty.get_screen() {
// Cache expected viewport height for consistent calculations
state.expected_viewport_height = Some(viewport_height);
let current_scroll = pty.get_scroll_offset();
// Calculate content based on expected dimensions, not current PTY dimensions
// This prevents scrollbar flash when PTY hasn't been resized yet
let total_content_rows =
self.calculate_content_rows_for_viewport(pty, viewport_height);
let scrollable_rows =
total_content_rows.saturating_sub(viewport_height as usize);
@@ -690,6 +880,27 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
PseudoTerminal::new(Vt100CttScreen::wrap(&screen)).block(block);
Widget::render(pseudo_term, safe_area, buf);
// Overlay the text-selection highlight (NXC-3946). tui-term has
// no selection concept, so we reverse-video the selected cells
// after it has rendered. Map each visible cell to its content
// row and test it against the selection.
if let Some(selection) = pty_data.selection {
let top = selection_content_rows
.saturating_sub(inner_area.height as usize)
.saturating_sub(current_scroll);
for sy in 0..inner_area.height {
let content_row = top + sy as usize;
for sx in 0..inner_area.width {
if selection.contains(content_row, sx as usize)
&& let Some(cell) =
buf.cell_mut((inner_area.x + sx, inner_area.y + sy))
{
cell.modifier |= Modifier::REVERSED;
}
}
}
}
// Only render scrollbar if needed
if needs_scrollbar {
let scrollbar = Scrollbar::default()
@@ -811,10 +1022,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
height: 1,
};
Paragraph::new(bottom_text)
.alignment(Alignment::Right)
.style(border_style)
.render(bottom_right_area, buf);
Widget::render(
NxParagraph::new(bottom_text)
.alignment(Alignment::Right)
.style(border_style),
bottom_right_area,
buf,
);
}
}
@@ -845,10 +1059,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
height: 1,
};
Paragraph::new(help_line)
.alignment(Alignment::Left)
.style(border_style)
.render(bottom_left_area, buf);
Widget::render(
NxParagraph::new(help_line)
.alignment(Alignment::Left)
.style(border_style),
bottom_left_area,
buf,
);
}
}
@@ -870,10 +1087,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
height: 1,
};
Paragraph::new(padding_text.clone())
.alignment(Alignment::Right)
.style(border_style)
.render(top_right_area, buf);
Widget::render(
NxParagraph::new(padding_text.clone())
.alignment(Alignment::Right)
.style(border_style),
top_right_area,
buf,
);
// Bottom padding (only if interactive status is not being displayed)
if !show_interactive_status {
@@ -886,10 +1106,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
height: 1,
};
Paragraph::new(padding_text)
.alignment(Alignment::Right)
.style(border_style)
.render(bottom_right_area, buf);
Widget::render(
NxParagraph::new(padding_text)
.alignment(Alignment::Right)
.style(border_style),
bottom_right_area,
buf,
);
}
}
}
@@ -922,10 +1145,13 @@ impl<'a> StatefulWidget for TerminalPane<'a> {
height: 1,
};
Paragraph::new(duration_line)
.alignment(Alignment::Right)
.style(border_style)
.render(duration_area, buf);
Widget::render(
NxParagraph::new(duration_line)
.alignment(Alignment::Right)
.style(border_style),
duration_area,
buf,
);
}
}
}
@@ -940,6 +1166,54 @@ mod tests {
use super::*;
use ratatui::layout::Rect;
#[test]
fn test_text_selection_contains_inclusive_range() {
let sel = TextSelection {
anchor: (1, 2),
cursor: (3, 4),
dragging: false,
};
// Before the start of the selection.
assert!(!sel.contains(1, 1));
assert!(!sel.contains(0, 9));
// Start cell is inclusive.
assert!(sel.contains(1, 2));
// A fully-covered middle row.
assert!(sel.contains(2, 0));
assert!(sel.contains(2, 999));
// End row is covered up to and including the end column.
assert!(sel.contains(3, 4));
assert!(!sel.contains(3, 5));
}
#[test]
fn test_text_selection_normalizes_reversed_drag() {
// Dragging up/left puts the cursor before the anchor; the range should
// still be normalized.
let sel = TextSelection {
anchor: (3, 4),
cursor: (1, 2),
dragging: true,
};
assert!(sel.contains(1, 2));
assert!(sel.contains(2, 10));
assert!(sel.contains(3, 4));
assert!(!sel.contains(1, 1));
assert!(!sel.contains(3, 5));
}
#[test]
fn test_text_selection_empty_is_single_cell() {
let sel = TextSelection {
anchor: (2, 5),
cursor: (2, 5),
dragging: true,
};
assert!(!sel.is_nonempty());
assert!(sel.contains(2, 5));
assert!(!sel.contains(2, 6));
}
// Helper function to create a TerminalPane for testing
fn create_terminal_pane() -> TerminalPane<'static> {
TerminalPane::new()
+14 -17
View File
@@ -1,4 +1,3 @@
use arboard::Clipboard;
use color_eyre::eyre::Result;
use crossterm::event::{KeyCode, KeyModifiers};
use hashbrown::HashSet;
@@ -6,7 +5,6 @@ use parking_lot::Mutex;
use ratatui::layout::{Constraint, Direction, Layout, Size};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
@@ -16,6 +14,7 @@ use tracing::debug;
/// Duration before status messages are automatically cleared
const STATUS_MESSAGE_DURATION: std::time::Duration = std::time::Duration::from_secs(3);
use crate::native::tui::components::nx_paragraph::NxParagraph;
use crate::native::tui::utils::{
calculate_actual_duration_ms, format_duration_with_estimate, get_task_status_style,
};
@@ -25,6 +24,7 @@ use crate::native::{
};
use super::action::Action;
use super::clipboard::copy_to_clipboard;
use super::components::countdown_popup::CountdownPopup;
use super::components::task_selection_manager::SelectionEntry;
use super::components::tasks_list::TaskStatus;
@@ -491,12 +491,10 @@ impl TuiApp for InlineApp {
// Unformatted output (no ANSI escape codes)
let output = screen.all_contents();
drop(state); // Release lock before clipboard operations
if let Ok(mut clipboard) = Clipboard::new() {
if clipboard.set_text(output).is_ok() {
// Show status message in bottom chrome
self.status_message =
Some((String::from("Output copied"), Instant::now()));
}
if copy_to_clipboard(&output) {
// Show status message in bottom chrome
self.status_message =
Some((String::from("Output copied"), Instant::now()));
}
}
}
@@ -781,20 +779,19 @@ impl InlineApp {
use crate::native::tui::theme::THEME;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
let height = lines_to_render as u16;
// Call insert_before on the dereferenced Terminal
// This only works with inline viewport
if let Ok(()) = tui.insert_before(height, |buf| {
// Convert batched scrollback lines to ratatui Lines
let lines: Vec<Line> =
batch.iter().map(|line| Line::from(line.as_str())).collect();
// Convert batched scrollback lines to owned ratatui Lines
let lines: Vec<Line<'static>> =
batch.iter().map(|line| Line::from(line.clone())).collect();
// Create a paragraph with the buffered scrollback content
let paragraph =
Paragraph::new(lines).style(Style::default().fg(THEME.secondary_fg));
NxParagraph::new(lines).style(Style::default().fg(THEME.secondary_fg));
// Render using the Widget trait
use ratatui::widgets::Widget;
@@ -993,7 +990,7 @@ impl InlineApp {
Span::styled(task_name.clone(), status_style),
];
f.render_widget(Paragraph::new(Line::from(left_spans)), chunks[0]);
f.render_widget(NxParagraph::new(Line::from(left_spans)), chunks[0]);
// Build right side: status msg + esc hint + interactive hint (if space) + cloud message (if space) + duration
let mut right_spans = Vec::new();
@@ -1032,7 +1029,7 @@ impl InlineApp {
));
}
}
f.render_widget(Paragraph::new(Line::from(right_spans)), chunks[1]);
f.render_widget(NxParagraph::new(Line::from(right_spans)), chunks[1]);
}
fn render_inline_main_content(&mut self, f: &mut ratatui::Frame, area: ratatui::layout::Rect) {
@@ -1057,9 +1054,9 @@ impl InlineApp {
use crate::native::tui::theme::THEME;
use ratatui::layout::Alignment;
use ratatui::style::Style;
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::{Block, Borders};
let message = Paragraph::new(" Waiting for tasks to start... ")
let message = NxParagraph::new(" Waiting for tasks to start... ")
.style(Style::default().fg(THEME.secondary_fg))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::NONE));
+13
View File
@@ -760,6 +760,15 @@ impl AppLifeCycle {
self.with_app(|app| app.set_batch_status(batch_id, status));
Ok(())
}
/// Set a clickable Nx Cloud link in the TUI: `label` is the text shown,
/// `url` is opened when it's clicked. This is a `LifeCycle` method so the Nx
/// Cloud client can call it via the lifecycle it already receives.
#[napi]
pub fn set_cloud_link(&self, label: String, url: String) -> napi::Result<()> {
self.with_app(|app| app.set_cloud_link(label, url));
Ok(())
}
}
#[napi]
@@ -767,6 +776,10 @@ pub fn restore_terminal() -> napi::Result<()> {
// Clear terminal progress indicator
App::clear_terminal_progress();
// Disable mouse capture (safe even if it was never enabled) so the terminal
// stops emitting mouse escape sequences once the TUI tears down.
let _ = super::tui::disable_mouse_capture();
// Drain pending terminal responses (e.g., OSC color query responses)
// to prevent escape sequences from leaking to the terminal on exit
super::tui::drain_stdin();
+1
View File
@@ -1,5 +1,6 @@
pub mod action;
pub mod app;
pub mod clipboard;
pub mod components;
pub mod config;
pub mod escape_sequences;
+165
View File
@@ -712,6 +712,97 @@ impl PtyInstance {
self.parser.read().screen().get_total_content_rows()
}
/// Extract the plain text covered by a selection expressed in absolute
/// content-row + column coordinates (both ends inclusive).
///
/// The selection arrives in *visual* (wrapped) row coordinates — the basis
/// `content_coords_at`, the overlay, and `get_total_content_rows()` share.
/// The buffer is read once as unwrapped logical lines with
/// `Screen::all_contents` (scrollback included, no scrolling). Each visual
/// endpoint is translated to a character offset inside its logical line by
/// counting how many visual rows the preceding lines occupy, then the
/// unwrapped lines are sliced directly between the two offsets. This yields
/// unwrapped text without ever materializing the wrapped rows, and a
/// newline appears only at real line breaks. Trailing whitespace is trimmed
/// per line.
pub fn selected_text(&self, start: (usize, usize), end: (usize, usize)) -> String {
let (start_row, start_col) = start;
let (end_row, end_col) = end;
let (contents, cols) = {
let parser = self.parser.read();
let screen = parser.screen();
let (_, cols) = screen.size();
(screen.all_contents(), cols as usize)
};
if cols == 0 {
return String::new();
}
let logical: Vec<&str> = contents.lines().collect();
// Translate an absolute visual row to a position in the unwrapped lines:
// the logical line it falls on, plus the character offset where that
// visual sub-row begins (`sub_row * cols`, since the terminal hard-wraps
// at the column count). `None` once the row is past the wrapped content
// (the viewport's trailing empty rows, trimmed from `all_contents`).
let locate = |visual_row: usize| -> Option<(usize, usize)> {
let mut consumed = 0usize;
for (index, line) in logical.iter().enumerate() {
let height = wrap_logical_line(line, cols);
if visual_row < consumed + height {
return Some((index, (visual_row - consumed) * cols));
}
consumed += height;
}
None
};
// A selection that starts past the content selects nothing.
let Some((start_line, start_base)) = locate(start_row) else {
return String::new();
};
let start_offset = start_base + start_col;
// An end dragged into the empty rows below the content selects through
// the end of the last logical line.
let (end_line, end_offset) = match locate(end_row) {
Some((line, base)) => (line, base + end_col + 1),
None => {
let last = logical.len().saturating_sub(1);
(
last,
logical.get(last).map_or(0, |line| line.chars().count()),
)
}
};
if end_line < start_line {
return String::new();
}
let mut lines: Vec<String> = Vec::new();
for (offset, line) in logical[start_line..=end_line].iter().enumerate() {
let line_index = start_line + offset;
let chars: Vec<char> = line.chars().collect();
let len = chars.len();
let (from, to) = if start_line == end_line {
(start_offset.min(len), end_offset.min(len))
} else if line_index == start_line {
(start_offset.min(len), len)
} else if line_index == end_line {
(0, end_offset.min(len))
} else {
(0, len)
};
let slice: String = chars[from..to.max(from)].iter().collect();
lines.push(slice.trim_end().to_string());
}
// Drop blank lines dragged past the end of the content so the copy
// doesn't gain trailing newlines.
while lines.last().is_some_and(|line| line.is_empty()) {
lines.pop();
}
lines.join("\n")
}
/// Checks if the process is likely in interactive/raw mode
/// Uses both alternate screen detection and cursor movement patterns
pub fn handles_arrow_keys(&self) -> bool {
@@ -855,6 +946,80 @@ mod tests {
);
}
#[test]
fn test_selected_text_single_and_multi_row() {
let pty = PtyInstance::non_interactive();
pty.process_output(b"line one\r\nline two\r\nline three\r\n");
// Single row, partial columns.
assert_eq!(pty.selected_text((0, 0), (0, 3)), "line");
// Whole second row (trailing whitespace trimmed).
assert_eq!(pty.selected_text((1, 0), (1, 20)), "line two");
// Spanning two rows: tail of row 0 + head of row 1.
assert_eq!(pty.selected_text((0, 5), (1, 3)), "one\nline");
}
#[test]
fn test_selected_text_out_of_range_is_empty() {
let pty = PtyInstance::non_interactive();
pty.process_output(b"hello\r\n");
// A start row past the content yields nothing rather than panicking.
assert_eq!(pty.selected_text((9999, 0), (9999, 5)), "");
}
#[test]
fn test_selected_text_joins_wrapped_rows_unwrapped() {
let pty = PtyInstance::non_interactive();
// 100 chars at 80 cols wraps onto two visual rows (0: 80, 1: 20).
let long = "a".repeat(100);
pty.process_output(format!("{long}\r\n").as_bytes());
// Selecting across both visual rows yields the original unwrapped line
// with no newline injected at the wrap point.
assert_eq!(pty.selected_text((0, 0), (1, 19)), long);
assert!(!pty.selected_text((0, 0), (1, 19)).contains('\n'));
}
#[test]
fn test_selected_text_partial_span_across_wrap_boundary() {
let pty = PtyInstance::non_interactive();
// A 100-char line of repeating digits so each column maps to a known
// character: index i holds the digit (i % 10).
let line: String = (0..100)
.map(|i| char::from(b'0' + (i % 10) as u8))
.collect();
pty.process_output(format!("{line}\r\n").as_bytes());
// Select from visual row 0 col 75 through visual row 1 col 4. The wrap
// is at column 80, so this is character offsets 75..=84 of the unwrapped
// line — the slice must cross the wrap point with the right columns.
assert_eq!(pty.selected_text((0, 75), (1, 4)), "5678901234");
}
#[test]
fn test_selected_text_trailing_empty_rows_dont_shift_columns() {
let pty = PtyInstance::non_interactive();
pty.process_output(b"alpha\r\nbravo\r\n");
// Dragging the selection end down into the empty rows below the content
// must not pull the end column up onto "bravo" (the previous bug, where
// the trimmed re-wrapped array clamped end_row and truncated the line).
assert_eq!(pty.selected_text((0, 0), (5, 3)), "alpha\nbravo");
}
#[test]
fn test_selected_text_includes_scrollback_rows() {
let pty = PtyInstance::non_interactive();
// 24-row viewport: writing 30 lines pushes the first 6 into scrollback.
for i in 0..30 {
pty.process_output(format!("line {i}\r\n").as_bytes());
}
// Absolute row 0 is the oldest scrollback row, lining up with the
// coordinates `content_coords_at` produces.
assert_eq!(pty.selected_text((0, 0), (0, 5)), "line 0");
assert_eq!(pty.selected_text((2, 0), (2, 5)), "line 2");
}
#[test]
fn test_handles_arrow_keys_enquirer_style_output() {
let pty = create_test_pty_instance(false);
+61 -3
View File
@@ -3,7 +3,7 @@ use crate::native::tui::theme::THEME;
use color_eyre::eyre::Result;
use crossterm::{
cursor,
event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind},
event::{self, Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
@@ -37,6 +37,7 @@ pub enum Event {
FocusLost,
Paste(String),
Key(KeyEvent),
Mouse(MouseEvent),
Resize(u16, u16),
}
@@ -54,6 +55,39 @@ pub struct Tui {
pub current_mode: TuiMode,
}
/// DEC private mode sequences for enabling mouse reporting.
///
/// We deliberately enable a narrow set of modes rather than crossterm's bundled
/// `EnableMouseCapture` (which also enables `?1003h`, "any-event" / all-motion
/// tracking). The modes we use:
/// - `?1000h` — normal tracking: button press and release.
/// - `?1002h` — button-event tracking: adds motion reports **while a button is
/// held** (drag). This is what text selection needs; it does NOT report bare
/// hover motion, avoiding a flood of events when the user merely moves the mouse.
/// - `?1006h` — SGR extended coordinates, so columns/rows past 223 are reported
/// correctly and release events are distinguishable.
const ENABLE_MOUSE_CAPTURE_SEQ: &[u8] = b"\x1b[?1000h\x1b[?1002h\x1b[?1006h";
/// Disable sequence — the same modes reset, in reverse order.
const DISABLE_MOUSE_CAPTURE_SEQ: &[u8] = b"\x1b[?1006l\x1b[?1002l\x1b[?1000l";
/// Enable mouse reporting on the terminal (stderr — the same stream the TUI
/// renders to). Idempotent: re-enabling already-enabled modes is a no-op for
/// the terminal.
pub(crate) fn enable_mouse_capture() -> std::io::Result<()> {
let mut stderr = std::io::stderr();
stderr.write_all(ENABLE_MOUSE_CAPTURE_SEQ)?;
stderr.flush()
}
/// Disable mouse reporting on the terminal. Safe to call even when capture was
/// never enabled (the terminal ignores resets for modes that aren't set), so we
/// can call it unconditionally on every teardown path.
pub(crate) fn disable_mouse_capture() -> std::io::Result<()> {
let mut stderr = std::io::stderr();
stderr.write_all(DISABLE_MOUSE_CAPTURE_SEQ)?;
stderr.flush()
}
/// Drain any pending input from stdin to prevent escape sequence leakage.
/// This consumes terminal responses (e.g., from OSC color queries) that may
/// arrive after a query was sent but before the response was fully read.
@@ -266,6 +300,9 @@ impl Tui {
CrosstermEvent::Paste(s) => {
_event_tx.send(Event::Paste(s)).unwrap();
},
CrosstermEvent::Mouse(mouse) => {
_event_tx.send(Event::Mouse(mouse)).unwrap();
},
_ => {
debug!("Unhandled Crossterm Event: {:?}", evt);
continue;
@@ -361,6 +398,10 @@ impl Tui {
match mode {
TuiMode::FullScreen => {
execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
// Capture the mouse only in fullscreen. This enables scroll-wheel,
// click and drag handling, at the cost of the terminal's own
// click-drag text selection (we provide in-app selection instead).
enable_mouse_capture()?;
}
TuiMode::Inline => {
// Inline terminal must exist (created upfront if stdin is TTY)
@@ -369,6 +410,10 @@ impl Tui {
"Cannot enter inline mode: inline terminal not available (stdin is not a TTY)"
));
}
// Inline mode intentionally does NOT capture the mouse: the inline
// viewport occupies a moving sub-region of the normal scrollback,
// so absolute mouse coordinates don't map to widgets, and leaving
// the mouse uncaptured preserves the terminal's native selection.
execute!(std::io::stderr(), cursor::Hide)?;
execute!(std::io::stderr(), cursor::MoveTo(0, 0))?;
execute!(
@@ -394,8 +439,14 @@ impl Tui {
if crossterm::terminal::is_raw_mode_enabled()? {
self.flush()?;
// Drain pending terminal responses (e.g., OSC color query responses)
// to prevent escape sequences from leaking to the terminal on exit
// Disable mouse capture before anything else so the terminal stops
// sending mouse escape sequences. Safe to call unconditionally — the
// terminal ignores resets for modes that were never set (e.g. inline).
let _ = disable_mouse_capture();
// Drain pending terminal responses (e.g., OSC color query responses,
// and any in-flight mouse reports) to prevent escape sequences from
// leaking to the terminal on exit
drain_stdin();
// Only leave alternate screen if we're in full-screen mode
@@ -492,6 +543,8 @@ impl Tui {
// Clear the new terminal's buffers to force a full redraw
self.terminal_mut().clear()?;
execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
// Capture the mouse in fullscreen (NXC-3945).
enable_mouse_capture()?;
// Fresh event channel + restart, mirroring reinitialize_inline_terminal.
let (new_tx, new_rx) = mpsc::unbounded_channel();
self.event_tx = new_tx;
@@ -508,6 +561,8 @@ impl Tui {
if let Err(e) = self.reinitialize_inline_terminal().await {
// Roll the terminal back to the mode we came from so we never
// surface a half-switched ("neither") terminal to the caller.
// (Mouse capture is released only after this point, so the
// fullscreen rollback still has the mouse captured.)
self.current_mode = previous_mode;
if previous_mode == TuiMode::FullScreen {
let _ = execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide);
@@ -515,6 +570,9 @@ impl Tui {
}
return Err(e);
}
// Release the mouse when dropping to inline (NXC-3944) so the
// terminal regains native scroll/selection in the inline viewport.
disable_mouse_capture()?;
execute!(std::io::stderr(), cursor::Hide)?;
}
}
+9
View File
@@ -392,6 +392,15 @@ pub trait TuiApp: Send {
.map(|s| s.to_string())
}
/// Set a structured Nx Cloud link (display label + href URL) to display.
///
/// Default implementation stores the link directly in TuiState.
/// Mode-specific implementations override this to also dispatch a UI action
/// so the rendered TasksList picks the link up.
fn set_cloud_link(&mut self, label: String, url: String) {
self.state().lock().set_cloud_link(Some((label, url)));
}
/// Set the run report shown in the exit-countdown popup.
///
/// Default implementation is a no-op; mode-specific implementations forward
+14
View File
@@ -83,6 +83,9 @@ pub struct TuiState {
// === Cloud Message ===
cloud_message: Option<String>,
/// Structured Nx Cloud link (display label, href URL), shown as a clickable
/// label in place of the raw cloud message when set.
cloud_link: Option<(String, String)>,
// === Performance Report ===
/// Stored here (not on the per-instance popup) so it survives mode switches;
@@ -154,6 +157,7 @@ impl TuiState {
is_forced_shutdown: false,
user_has_interacted: false,
cloud_message: None,
cloud_link: None,
exit_summary: None,
ui_pane_tasks: [None, None],
ui_spacebar_mode: false,
@@ -499,6 +503,16 @@ impl TuiState {
self.cloud_message.as_deref()
}
/// Set the structured cloud link (display label, href URL).
pub fn set_cloud_link(&mut self, link: Option<(String, String)>) {
self.cloud_link = link;
}
/// Get the structured cloud link (if any).
pub fn get_cloud_link(&self) -> Option<&(String, String)> {
self.cloud_link.as_ref()
}
// === UI State Methods (for mode switching persistence) ===
/// Save the UI state from full-screen mode for later restoration
@@ -93,6 +93,13 @@ export interface LifeCycle {
appendBatchOutput?(batchId: string, output: string): void;
setBatchStatus?(batchId: string, status: BatchStatus): void;
/**
* Set a clickable Nx Cloud link in the terminal UI: `label` is the text
* shown, `url` is opened when it's clicked. Implemented by the TUI lifecycle;
* callers (e.g. the Nx Cloud client) should feature-detect it.
*/
setCloudLink?(label: string, url: string): void | Promise<void>;
}
export class CompositeLifeCycle implements LifeCycle {
@@ -255,4 +262,12 @@ export class CompositeLifeCycle implements LifeCycle {
}
}
}
async setCloudLink(label: string, url: string): Promise<void> {
for (let l of this.lifeCycles) {
if (l.setCloudLink) {
await l.setCloudLink(label, url);
}
}
}
}
+13 -10
View File
@@ -1,5 +1,4 @@
import { prompt } from 'enquirer';
import { join } from 'node:path';
import { stripVTControlCharacters } from 'node:util';
import type { Observable } from 'rxjs';
@@ -28,6 +27,7 @@ import { NxArgs } from '../utils/command-line-utils';
import { handleErrors } from '../utils/handle-errors';
import { isCI } from '../utils/is-ci';
import { isNxCloudDisabled, isNxCloudUsed } from '../utils/nx-cloud-utils';
import { getBundleInstallDefaultLocation } from '../nx-cloud/update-manager';
import { logger } from '../utils/logger';
import {
createNxKeyLicenseeInformation,
@@ -133,15 +133,22 @@ async function getTerminalOutputLifeCycle(
if (isTuiEnabled()) {
const interceptedNxCloudLogs: (string | Uint8Array<ArrayBufferLike>)[] = [];
// Resolve where the Nx Cloud client bundle actually loads from so the
// stack-trace check below matches its frames. It is NOT always
// `{workspaceRoot}/.nx/cache/cloud`: in a git worktree the cache dir is
// shared with the main repo, and it can also be relocated via
// NX_CACHE_DIRECTORY, a custom `cacheDirectory` in nx.json, or the lerna
// `node_modules/.cache` location. Using the client's own resolver keeps the
// interception working in all of those cases.
const nxCloudClientDir = getBundleInstallDefaultLocation();
const createPatchedConsoleMethod = (
originalMethod: typeof console.log | typeof console.error
): typeof console.log | typeof console.error => {
return (...args: any[]) => {
// Check if the log came from the Nx Cloud client, otherwise invoke the original write method
const stackTrace = new Error().stack;
const isNxCloudLog = stackTrace.includes(
join(workspaceRoot, '.nx', 'cache', 'cloud')
);
const isNxCloudLog = stackTrace.includes(nxCloudClientDir);
if (!isNxCloudLog) {
return originalMethod(...args);
}
@@ -276,9 +283,7 @@ async function getTerminalOutputLifeCycle(
// Check if the log came from the Nx Cloud client, otherwise invoke the original write method
const stackTrace = new Error().stack;
const isNxCloudLog = stackTrace.includes(
join(workspaceRoot, '.nx', 'cache', 'cloud')
);
const isNxCloudLog = stackTrace.includes(nxCloudClientDir);
if (isNxCloudLog) {
interceptedNxCloudLogs.push(chunk);
// Do not bother to store logs with only whitespace characters, they aren't relevant for the TUI
@@ -305,9 +310,7 @@ async function getTerminalOutputLifeCycle(
return (...args: any[]) => {
// Check if the log came from the Nx Cloud client, otherwise invoke the original write method
const stackTrace = new Error().stack;
const isNxCloudLog = stackTrace.includes(
join(workspaceRoot, '.nx', 'cache', 'cloud')
);
const isNxCloudLog = stackTrace.includes(nxCloudClientDir);
if (!isNxCloudLog) {
return originalMethod(...args);
}