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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ members = [".", "plugins/nightcrow-recovery"]

[package]
name = "nightcrow"
version = "0.1.2"
version = "0.1.3"
edition = "2024"
description = "Agent-adjacent terminal workbench — git diff viewer + multi-terminal panes for running CLIs next to your code"
license = "Apache-2.0"
Expand Down Expand Up @@ -87,4 +87,4 @@ ctrlc = "3"
# adding it directly here so the feature gate is explicit.
# `SetConsoleCtrlHandler` for `Win32_System_Console`: clearing the inherited
# ignore-Ctrl-C flag so panes get an interrupt, not just the 0x03 byte.
windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Console"] }
windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Console", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
5 changes: 4 additions & 1 deletion docs/architecture/terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
`runtime::emulator::PaneEmulator`가 pane마다 alacritty_terminal `Term`과 ANSI `Processor`를 감싼다. UI는 `ScreenView`/`CellView`만 보고, VT 구현 타입은 모듈 밖으로 새지 않는다. emulator는 최소 1행 × 2열로 clamp한다.

- PTY byte는 client emulator에 적용한다. emulator가 OSC 0/2 title, DSR/DA query reply, terminal modes를 수집하고, title은 pane metadata로 세션에 전달한다.
- `ScreenView::link_at`은 viewport 좌표에서 OSC 8 셀 링크를 먼저 조회하고, 메타데이터가 없으면 현재 visible line의 bounded URL·Markdown·파일 위치를 찾는다. soft wrap과 scrollback을 포함하지만 파일을 열거나 검증하지 않고 platform seam에 target을 넘긴다. PTY 경로에서 OSC 8 메타데이터를 받지 못하는 경우에도 visible text fallback을 사용할 수 있다.
- 이미 제거된 label-only 링크는 재생할 URI가 없어 복구할 수 없다.
- screen snapshot은 OSC 8 run과 cursor template의 활성 링크를 재생하며, 초기 화면 지우기와 행 끝 erase 전에 입력 링크를 명시적으로 닫아 이전 stream 상태가 새 셀로 번지지 않게 한다.
- hub는 재접속을 위해 mode와 screen snapshot을 별도로 보관한다. alternate screen은 현재 screen을, normal screen은 ring history와 snapshot 이후 tail을 조합해 replay한다. reconnect replay는 `screen` 뒤에 `since` byte를 붙여 snapshot 이후 broadcast를 잃지 않는다.
- replay frame은 1 MiB 이하로 분할되고 daemon frame은 4 MiB를 넘지 않는다. terminal stream은 byte를 생략하거나 conflation하지 않으며, frame/queue 상한을 넘긴 연결은 명시적으로 종료한다.

Expand All @@ -32,7 +35,7 @@

## Mouse routing

`[mouse] enabled`가 켜져 있으면 crossterm이 화면을 캡처한다. `pane_at`은 렌더와 같은 `terminal_content_areas`를 사용한다. pane press는 focus와 active pane을 바꾸고, 프로그램이 mouse button mode + SGR encoding을 요청한 경우에만 pane-local SGR button report를 보낸다. release는 포인터 현재 위치가 아니라 press를 받은 pane에 짝지으며, pane이 닫히거나 숨겨졌으면 버린다.
`[mouse] enabled`가 켜져 있으면 crossterm이 화면을 캡처한다. `pane_at`은 렌더와 같은 `terminal_content_areas`를 사용한다. pane press는 focus와 active pane을 바꾸며, modifier 없는 왼쪽 press가 링크 target을 찾으면 SGR button report보다 먼저 링크 열기 명령으로 반환한다. 링크가 없으면 프로그램이 mouse button mode + SGR encoding을 요청한 경우에만 pane-local SGR button report를 보낸다. release는 포인터 현재 위치가 아니라 press를 받은 pane에 짝지으며, pane이 닫히거나 숨겨졌으면 버린다.

wheel은 포인터 아래 pane을 대상으로 하며 sink 규칙은 keyboard scroll과 같다. tab bar와 hint bar의 클릭 대상은 렌더러가 만든 segment에서 파생하고, `<leader> s` 대기 중 pane 클릭은 swap target으로 해석한다. motion/drag는 PTY로 전달하지 않는다. 외부 터미널의 text selection은 capture bypass modifier를 사용한다.

Expand Down
2 changes: 2 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,5 @@ The leader followed by `t`, `w`, `s`, `z`, `c`, `l`, `b`, `o`, `x`, `p`, or `u`
Mouse capture is enabled by default. Click a project tab or panel to focus it; click a terminal pane to focus it and forward the report to programs that requested mouse input. The wheel scrolls the pane under the pointer. Clickable hint-bar commands behave like their key equivalents.

While capture is enabled, hold the outer terminal's selection modifier while dragging to select text: `Shift` in xterm-family terminals, `Option` in iTerm2, and `Fn` or `Option` in macOS Terminal.app. Set `[mouse] enabled = false` to restore ordinary outer-terminal selection and disable click forwarding.

An unmodified left click on a terminal hyperlink opens `http`/`https` links in the system browser. Local links open through a GUI editor (`Code.exe` or Notepad on Windows, `code`, `gedit`, `kate`, or `geany` on Unix, and TextEdit on macOS) and accept repository-relative paths, `file://` URLs, Windows drive paths, and `:line[:column]` or `#Lline` locations. Repository-relative files must exist inside the current worktree; network, device, traversal, `.git`, unsupported-scheme, and control-character targets are refused. Hold the outer terminal's selection modifier to bypass link handling.
2 changes: 1 addition & 1 deletion plugins/nightcrow-recovery/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "nightcrow-recovery"
version = "0.1.2"
version = "0.1.3"
edition = "2024"
description = "nightcrow plugin: notices a coding CLI hit its usage limit and resumes it when the limit resets"
license = "Apache-2.0"
Expand Down
8 changes: 8 additions & 0 deletions src/application/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ pub(crate) fn apply_outcome(
KeyOutcome::Quit => return Ok(true),
KeyOutcome::Redraw => terminal.clear()?,
KeyOutcome::Continue => {}
KeyOutcome::OpenLink { target, base } => {
if let Err(error) = crate::platform::links::open(&target, std::path::Path::new(&base)) {
ws.raise_notice(
crate::app::NoticeKind::Terminal,
format!("could not open terminal link {target:?}: {error}"),
);
}
}
// Through the link: attached, opening and closing a tab is a request to
// whoever owns the tab list, not a local edit.
KeyOutcome::Project(request) => link.request(ws, request),
Expand Down
6 changes: 6 additions & 0 deletions src/application/input/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ pub(crate) enum KeyOutcome {
/// take `&mut App` — one project — so they cannot reach the tab list;
/// they name the intent here and `main_loop` carries it out.
Project(ProjectRequest),
/// Open a link detected in a terminal screen. Launching is kept out of
/// input handlers so failures can become a visible terminal notice.
OpenLink {
target: String,
base: String,
},
}

/// A workspace-level action requested by a key or click.
Expand Down
14 changes: 14 additions & 0 deletions src/application/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,21 @@ pub(crate) fn handle_mouse(
let row = mouse.row - rect.y + 1;
match mouse.kind {
MouseEventKind::Down(button) => {
// Link handling keeps the ordinary click's focus behavior. A
// link is opened by the app after the clicked pane is active.
focus_clicked_pane(app, id);
if button == crossterm::event::MouseButton::Left
&& mouse.modifiers == KeyModifiers::NONE
&& let Some(target) = app
.terminal
.screen_for_pane(id)
.and_then(|screen| screen.link_at(row - 1, col - 1))
{
return KeyOutcome::OpenLink {
target,
base: app.repository_path().to_owned(),
};
}
if app.terminal.click_pane(id, button, true, col, row) {
app.interaction.pending_mouse_press = Some((id, button, col, row));
}
Expand Down
1 change: 1 addition & 0 deletions src/application/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod helpers;
mod mouse;
mod mouse_clicks;
mod mouse_empty;
mod mouse_links;
mod mouse_release;
mod paste;
mod paste_burst;
Expand Down
158 changes: 158 additions & 0 deletions src/application/tests/mouse_links.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
use super::helpers::*;
use crate::app::Focus;
use crate::application::input::dispatch::KeyOutcome;
use crate::application::input::mouse::handle_mouse;
use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind};

fn osc8(target: &str) -> Vec<u8> {
format!("\x1b]8;;{target}\x1b\\open\x1b]8;;\x1b\\").into_bytes()
}

#[test]
fn left_link_click_focuses_pane_and_preempts_mouse_reporting() {
let (mut app, areas) = app_with_two_panes_and_areas();
let (id, rect) = areas[0];
let target = "https://example.test/docs";
app.terminal
.emulators
.get_mut(&id)
.unwrap()
.process(&osc8(target));
app.terminal
.emulators
.get_mut(&id)
.unwrap()
.process(b"\x1b[?1000h\x1b[?1006h");
app.focus = Focus::FileList;

let outcome = handle_mouse(
&mut app,
test_tab_view(&test_tabs()),
mouse(MouseEventKind::Down(MouseButton::Left), rect.x, rect.y),
MOUSE_TEST_SCREEN,
&crate::config::LayoutConfig::default(),
);

assert_eq!(
outcome,
KeyOutcome::OpenLink {
target: target.into(),
base: ".".into(),
}
);
assert_eq!(app.focus, Focus::Terminal);
assert_eq!(app.terminal.active, 0, "ordinary click focus is preserved");
assert!(backend_payloads(&app).is_empty());
assert!(app.interaction.pending_mouse_press.is_none());
}

#[test]
fn link_release_does_not_create_an_unpaired_mouse_report() {
let (mut app, areas) = app_with_two_panes_and_areas();
let (id, rect) = areas[0];
app.terminal
.emulators
.get_mut(&id)
.unwrap()
.process(&osc8("https://example.test"));
let layout = crate::config::LayoutConfig::default();
handle_mouse(
&mut app,
test_tab_view(&test_tabs()),
mouse(MouseEventKind::Down(MouseButton::Left), rect.x, rect.y),
MOUSE_TEST_SCREEN,
&layout,
);
handle_mouse(
&mut app,
test_tab_view(&test_tabs()),
mouse(MouseEventKind::Up(MouseButton::Left), rect.x, rect.y),
MOUSE_TEST_SCREEN,
&layout,
);
assert!(backend_payloads(&app).is_empty());
}

#[test]
fn selection_modifier_bypasses_link_opening() {
let (mut app, areas) = app_with_two_panes_and_areas();
let (id, rect) = areas[0];
app.terminal
.emulators
.get_mut(&id)
.unwrap()
.process(&osc8("https://example.test"));
app.terminal
.emulators
.get_mut(&id)
.unwrap()
.process(b"\x1b[?1000h\x1b[?1006h");
let mut event = mouse(MouseEventKind::Down(MouseButton::Left), rect.x, rect.y);
event.modifiers = KeyModifiers::SHIFT;

let outcome = handle_mouse(
&mut app,
test_tab_view(&test_tabs()),
event,
MOUSE_TEST_SCREEN,
&crate::config::LayoutConfig::default(),
);
assert_eq!(outcome, KeyOutcome::Continue);
assert_eq!(backend_payloads(&app), vec![b"\x1b[<0;1;1M".to_vec()]);
}

#[test]
fn link_click_keeps_a_previous_press_paired_for_release() {
let (mut app, areas) = app_with_two_panes_and_areas();
let (first_id, first_rect) = areas[0];
let (second_id, second_rect) = areas[1];
app.terminal
.emulators
.get_mut(&first_id)
.unwrap()
.process(b"\x1b[?1000h\x1b[?1006h");
app.terminal
.emulators
.get_mut(&second_id)
.unwrap()
.process(&osc8("https://example.test"));
let layout = crate::config::LayoutConfig::default();
handle_mouse(
&mut app,
test_tab_view(&test_tabs()),
mouse(
MouseEventKind::Down(MouseButton::Left),
first_rect.x,
first_rect.y,
),
MOUSE_TEST_SCREEN,
&layout,
);
let outcome = handle_mouse(
&mut app,
test_tab_view(&test_tabs()),
mouse(
MouseEventKind::Down(MouseButton::Left),
second_rect.x,
second_rect.y,
),
MOUSE_TEST_SCREEN,
&layout,
);
assert!(matches!(outcome, KeyOutcome::OpenLink { .. }));
handle_mouse(
&mut app,
test_tab_view(&test_tabs()),
mouse(
MouseEventKind::Up(MouseButton::Left),
second_rect.x,
second_rect.y,
),
MOUSE_TEST_SCREEN,
&layout,
);
assert_eq!(
backend_payloads(&app),
vec![b"\x1b[<0;1;1M".to_vec(), b"\x1b[<0;48;1m".to_vec()]
);
}
2 changes: 2 additions & 0 deletions src/backend/pty_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use crate::config::ShellConfig;
mod identity;
#[path = "pty_tests/lifecycle.rs"]
mod lifecycle;
#[path = "pty_tests/links.rs"]
mod links;
#[path = "pty_tests/relaunch.rs"]
mod relaunch;

Expand Down
Loading
Loading