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.

2 changes: 1 addition & 1 deletion 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.6"
version = "0.1.7"
edition = "2024"
rust-version = "1.89"
description = "Agent-adjacent terminal workbench — git diff viewer + multi-terminal panes for running CLIs next to your code"
Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ nightcrow update
nightcrow update --version 0.1.1
```

By default this downloads and verifies the latest binary from the official GitHub Release. Use `--version VER` to roll back to a published patch. Use `--path DIR` for a local checkout or `--git URL` for another source repository; those explicit development modes require Rust and run a locked, forced `cargo install`. Restart a running session after updating with `nightcrow stop`, then start nightcrow again so the daemon and its panes use the new binary. On Windows, `update` moves the installed executable aside before replacing it; if that parked copy is still in use, cleanup waits for the session or updater to exit. The new binary is already installed, so a second `update` is not needed.
By default this downloads and verifies the latest binary from the official GitHub Release; the download reports its progress, since the release binary takes a while to arrive. An interrupted download leaves a `.nightcrow-download-*` temporary beside the installed binary, which a later start reclaims. Use `--version VER` to roll back to a published patch. Use `--path DIR` for a local checkout or `--git URL` for another source repository; those explicit development modes require Rust and run a locked, forced `cargo install`. Restart a running session after updating with `nightcrow stop`, then start nightcrow again so the daemon and its panes use the new binary. On Windows, `update` moves the installed executable aside before replacing it; if that parked copy is still in use, cleanup waits for the session or updater to exit. The new binary is already installed, so a second `update` is not needed.

## Building and testing

Expand Down
4 changes: 4 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ The leader followed by `t`, `w`, `s`, `z`, `c`, `l`, `b`, `o`, `x`, `p`, or `u`
- Moving a project within the tab row is a drag on the tab itself rather than a key. The order it writes is the same session-owned order the TUI's bracket keys move.
- The leader followed by `f` maximizes the focused panel and zooms the active terminal pane. A page cannot take the browser's chrome into fullscreen, and `F11` belongs to the browser, so the intent — give this panel the whole area — is kept and the mechanism is not.

### Browser only

- The leader followed by `m` opens the message form for the active terminal pane (see [Web viewer](web-viewer.md)). The TUI has no such command and leaves `m` unmapped: it reads keys from the host terminal, whose IME already composes, so there is nothing for a form to work around.

### Not bound in the browser

- Redraw: the browser repaints the page itself, so there is no stale frame to force.
Expand Down
4 changes: 3 additions & 1 deletion docs/web-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ Drag the sidebar and upper-panel dividers to resize them; double-click a divider

From `md` width up, the project tabs run across the header or down the page's left edge; the header button just right of the accent swatch moves them, and the TUI's `[layout] tabs = "left"` is the same idea for a terminal. On the left the `nightcrow` title heads the column and the tabs hang under it, one project per row, with the same labels, drag-to-reorder and close control as the row. Like the pane view mode, the choice is this browser's own — a screen with rows to spare is not the screen with columns to spare — and it is stored in the browser, not in `viewer.json`. Below `md` there is no strip either way; the header's project menu takes its place.

The terminal toolbar can add a pane, show panes as a grid or tabs, maximize the terminal panel, claim sizing for this screen, and show the on-screen key bar. A project has up to 8 panes. Pane order and zoom are shared while the session runs; they are not restored after the session ends. A PTY has one size, so the client that most recently claims sizing determines the grid rendered by every client.
The terminal toolbar can add a pane, show panes as a grid or tabs, maximize the terminal panel, claim sizing for this screen, show the on-screen key bar, and open a message form for the active pane. A project has up to 8 panes. Pane order and zoom are shared while the session runs; they are not restored after the session ends. A PTY has one size, so the client that most recently claims sizing determines the grid rendered by every client.

On phones and other narrow layouts, the bottom navigation switches among `Repo`, `Content`, and `Terminal`. Touch-dragging a terminal scrolls it; the key bar supplies Escape, Tab, arrows, and control keys when a soft keyboard cannot. A soft keyboard opening does not resize the panes: the PTY keeps its grid and the pane shows the bottom of it, so a full-screen program is not made to repaint from the top every time the keyboard comes and goes. Layout changes made while the keyboard is up are applied once it closes. Its `Ctrl` button is a latch for the next typed character. The keyboard-bar preference is stored in the browser, so it can be changed from the terminal toolbar. The shortcut leader key is stored in the browser too, and can be rebound or switched off from the shortcut sheet.

The message form (the pencil in the terminal toolbar and at the end of the key bar, or the leader followed by `m`) exists because xterm's hidden input field drops and repeats characters from an IME on iPadOS, which makes Korean typed straight into a pane unreliable; an ordinary text field composes it correctly. Send writes the message into the active pane and presses Return, so it runs at once; Cmd/Ctrl+Return does the same from the keyboard, and Return alone adds a line. When the program has turned on bracketed paste, a multi-line message goes in as one paste rather than one command per line. Clear empties the field. A draft is kept per pane for the life of the page, so closing the form does not lose it. The TUI has no such form: it reads keys from the host terminal, whose IME already composes.

Terminal programs may write to the clipboard through OSC 52; the text reaches the browser device viewing the pane. A program requesting clipboard contents is not answered. If the browser requires a user gesture to write, the viewer shows a Copy action.

## Access and security
Expand Down
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.6"
version = "0.1.7"
edition = "2024"
rust-version = "1.89"
description = "nightcrow plugin: notices a coding CLI hit its usage limit and resumes it when the limit resets"
Expand Down
1 change: 1 addition & 0 deletions src/cli/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::path::PathBuf;

mod contract;
mod http;
mod progress;
mod release;
mod replace;
mod source;
Expand Down
13 changes: 10 additions & 3 deletions src/cli/update/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ impl Client {
&self,
asset: &Asset,
destination: &mut std::fs::File,
progress: &mut dyn FnMut(u64),
) -> Result<[u8; 32]> {
let digest = self.stream_asset(asset, destination)?;
let digest = self.stream_asset(asset, destination, progress)?;
destination
.flush()
.and_then(|()| destination.sync_all())
Expand All @@ -72,11 +73,16 @@ impl Client {

pub(super) fn download_bytes(&self, asset: &Asset) -> Result<(Vec<u8>, [u8; 32])> {
let mut bytes = Vec::with_capacity(asset.size as usize);
let digest = self.stream_asset(asset, &mut bytes)?;
let digest = self.stream_asset(asset, &mut bytes, &mut |_| {})?;
Ok((bytes, digest))
}

fn stream_asset(&self, asset: &Asset, output: &mut impl Write) -> Result<[u8; 32]> {
fn stream_asset(
&self,
asset: &Asset,
output: &mut impl Write,
progress: &mut dyn FnMut(u64),
) -> Result<[u8; 32]> {
self.validate_url(&asset.browser_download_url)?;
let mut response = self
.agent
Expand Down Expand Up @@ -110,6 +116,7 @@ impl Client {
output
.write_all(&buffer[..read])
.with_context(|| format!("could not write release asset `{}`", asset.name))?;
progress(total);
}
if total != asset.size {
anyhow::bail!(
Expand Down
100 changes: 100 additions & 0 deletions src/cli/update/progress.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! The download's only sign of life.
//!
//! `update` spends the better part of a minute fetching a release binary and
//! printed nothing at all until it finished, which reads as a no-op.

use std::io::{IsTerminal, Write};
use std::time::{Duration, Instant};

/// Fast enough to look live, slow enough not to flood a slow terminal.
const REDRAW: Duration = Duration::from_millis(100);

/// A log without carriage-return support gets one line per this many percent.
const LOG_STEP: u64 = 10;

pub(super) struct Progress {
label: String,
total: u64,
interactive: bool,
redrawn: Instant,
reported: u64,
}

impl Progress {
pub(super) fn start(label: &str, total: u64) -> Self {
println!("nightcrow: downloading {label} ({})", human_bytes(total));
let _ = std::io::stdout().flush();
Self {
label: label.to_owned(),
total,
interactive: std::io::stdout().is_terminal(),
redrawn: Instant::now() - REDRAW,
reported: 0,
}
}

pub(super) fn advance(&mut self, downloaded: u64) {
let percent = percent(downloaded, self.total);
if self.interactive {
if self.redrawn.elapsed() < REDRAW {
return;
}
self.redrawn = Instant::now();
print!("\r{}", status(percent, downloaded, self.total));
let _ = std::io::stdout().flush();
} else {
if !logged(self.reported, percent) {
return;
}
println!("{}", status(percent, downloaded, self.total));
}
self.reported = percent;
}

pub(super) fn finish(self) {
if self.interactive {
// Overwrite the partial line rather than leave it mid-percentage.
print!("\r{}\n", status(100, self.total, self.total));
let _ = std::io::stdout().flush();
} else if self.reported < 100 {
println!("{}", status(100, self.total, self.total));
}
println!("nightcrow: verifying {} checksum", self.label);
}
}

fn percent(downloaded: u64, total: u64) -> u64 {
if total == 0 {
return 100;
}
(downloaded.min(total) * 100 / total).min(100)
}

fn logged(reported: u64, percent: u64) -> bool {
percent / LOG_STEP > reported / LOG_STEP
}

fn status(percent: u64, downloaded: u64, total: u64) -> String {
format!(
"nightcrow: {percent:>3}% ({} / {})",
human_bytes(downloaded.min(total)),
human_bytes(total)
)
}

fn human_bytes(bytes: u64) -> String {
const MIB: f64 = (1024 * 1024) as f64;
const KIB: f64 = 1024.0;
let bytes = bytes as f64;
if bytes >= MIB {
format!("{:.1} MiB", bytes / MIB)
} else if bytes >= KIB {
format!("{:.1} KiB", bytes / KIB)
} else {
format!("{bytes:.0} B")
}
}

#[cfg(test)]
#[path = "progress_tests.rs"]
mod tests;
29 changes: 29 additions & 0 deletions src/cli/update/progress_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use super::{human_bytes, logged, percent, status};

#[test]
fn percent_is_clamped_and_never_divides_by_zero() {
assert_eq!(percent(0, 100), 0);
assert_eq!(percent(50, 100), 50);
assert_eq!(percent(150, 100), 100);
assert_eq!(percent(7, 0), 100);
}

#[test]
fn a_log_line_is_emitted_once_per_step() {
assert!(logged(0, 10));
assert!(!logged(10, 11));
assert!(!logged(11, 19));
assert!(logged(19, 20));
}

#[test]
fn bytes_are_scaled_to_the_largest_whole_unit() {
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(2048), "2.0 KiB");
assert_eq!(human_bytes(13_974_528), "13.3 MiB");
}

#[test]
fn a_status_line_reports_both_sides_of_the_transfer() {
assert_eq!(status(50, 512, 1024), "nightcrow: 50% (512 B / 1.0 KiB)");
}
10 changes: 8 additions & 2 deletions src/cli/update/release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ use super::contract::{
platform_asset,
};
use super::http::Client;
use super::progress::Progress;
use super::replace::replace_target;
use crate::platform::self_replace;
use crate::platform::self_replace::DOWNLOAD_PREFIX;

pub(super) fn run(version: Option<&str>) -> Result<()> {
let requested = version.map(PatchVersion::requested).transpose()?;
Expand Down Expand Up @@ -48,15 +50,19 @@ fn run_with(
)
})?;
let mut temporary = tempfile::Builder::new()
.prefix(".nightcrow-download-")
.prefix(DOWNLOAD_PREFIX)
.tempfile_in(parent)
.with_context(|| {
format!(
"could not create a temporary file beside {}",
target.display()
)
})?;
let actual = client.download_file(asset, temporary.as_file_mut())?;
let mut reporter = Progress::start(asset_name, asset.size);
let actual = client.download_file(asset, temporary.as_file_mut(), &mut |downloaded| {
reporter.advance(downloaded)
})?;
reporter.finish();
if actual != expected {
anyhow::bail!(
"SHA-256 verification failed for `{asset_name}`; the installed binary was not changed"
Expand Down
35 changes: 30 additions & 5 deletions src/platform/self_replace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,19 @@

use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

/// Distinctive enough that [`sweep`] only ever deletes files this module made.
const PARKED_SUFFIX: &str = ".nightcrow-old";

/// The prefix `update` gives its download temporaries, shared so that [`sweep`]
/// recognizes the ones an interrupted download abandoned.
pub(crate) const DOWNLOAD_PREFIX: &str = ".nightcrow-download-";

/// A download temporary younger than this may belong to an update running right
/// now, whose `persist` would fail if the file were unlinked underneath it.
const DOWNLOAD_GRACE: Duration = Duration::from_secs(60 * 60);

/// Each still-locked leftover takes a slot; more than a handful is a bug.
const MAX_PARKED_SLOTS: u32 = 32;

Expand Down Expand Up @@ -68,7 +77,8 @@ pub(crate) fn make_executable(path: &Path) -> io::Result<()> {
Ok(())
}

/// Delete binaries parked beside `path` by earlier updates.
/// Delete binaries parked beside `path`, and download temporaries abandoned,
/// by earlier updates.
///
/// Best-effort: this runs on startup, where a leftover costs a few megabytes
/// and is never a reason to refuse to start.
Expand All @@ -80,13 +90,17 @@ pub(crate) fn sweep(path: &Path) {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !is_parked_name(name) {
let kind = if is_parked_name(name) {
"parked binary"
} else if name.starts_with(DOWNLOAD_PREFIX) && is_stale(&entry) {
"abandoned download"
} else {
continue;
}
};
match std::fs::remove_file(entry.path()) {
Ok(()) => tracing::debug!(file = name, "swept a binary parked by an earlier update"),
Ok(()) => tracing::debug!(file = name, kind, "swept a leftover from an earlier update"),
// Still running, or not ours to delete. Next startup tries again.
Err(err) => tracing::debug!(%err, file = name, "parked binary is still in use"),
Err(err) => tracing::debug!(%err, file = name, kind, "leftover is still in use"),
}
}
}
Expand All @@ -98,6 +112,17 @@ pub(crate) fn sweep_beside_current_exe() {
}
}

/// Treat an unreadable timestamp as young: skipping a leftover is cheaper than
/// deleting the download of an update that is still running.
fn is_stale(entry: &std::fs::DirEntry) -> bool {
let Ok(modified) = entry.metadata().and_then(|metadata| metadata.modified()) else {
return false;
};
SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age >= DOWNLOAD_GRACE)
}

fn parked_path(path: &Path, slot: u32) -> PathBuf {
let name = path
.file_name()
Expand Down
23 changes: 23 additions & 0 deletions src/platform/self_replace_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,26 @@ fn parked_names_are_recognised_only_with_a_numeric_slot() {
assert!(!is_parked_name("nightcrow.exe.nightcrow-old.keep"));
assert!(!is_parked_name("nightcrow.exe"));
}

#[test]
fn sweeping_removes_only_a_stale_download_temporary() {
let dir = tempfile::tempdir().unwrap();
let exe = bin(dir.path(), "nightcrow.exe");
let stale = bin(dir.path(), &format!("{DOWNLOAD_PREFIX}abandoned"));
let fresh = bin(dir.path(), &format!("{DOWNLOAD_PREFIX}inflight"));
let aged = SystemTime::now() - DOWNLOAD_GRACE - Duration::from_secs(60);
std::fs::File::options()
.write(true)
.open(&stale)
.unwrap()
.set_modified(aged)
.unwrap();

sweep(&exe);

assert!(!stale.exists(), "an abandoned download is reclaimed");
assert!(
fresh.exists(),
"a download this young may belong to a running update"
);
}

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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions viewer-ui/dist/assets/index-DXxHDKTE.css

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions viewer-ui/dist/assets/index-lOhfH3AI.css

This file was deleted.

4 changes: 2 additions & 2 deletions viewer-ui/dist/index.html

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

Loading
Loading