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
2 changes: 1 addition & 1 deletion .claude/skills/test-tui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,6 @@ rm -f /tmp/scope_a /tmp/scope_b /tmp/scope_capture.bin \

## Notes

- Subcommands: `scope serial [PORT] [BAUDRATE]`, also `ble`, `rtt`, `list`. Global opts: `-t/--tag-file`, `-c/--capacity`, `-l/--latency`.
- Subcommands: `scope serial [PORT] [BAUDRATE]`, also `ble`, `rtt`, `list`, `completions`. Global opts: `-t/--tag-file`, `-c/--capacity`, `-l/--latency`.
- Add small `sleep`s after each action so the TUI redraws before you capture.
- The input bar does not colorize hex/tag sequences while typing (by design); parsing and highlighting happen on send and render in the output area.
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ cargo run --bin scope -- rtt <TARGET> <CHANNEL> # RTT via probe-rs
cargo test --bin scope # run unit tests
cargo test --bin scope <substr> # run a single test, e.g. cargo test --bin scope test_rhs
cargo test --test tui_e2e # run the end-to-end TUI tests (Unix only)
cargo test --test completions # shell-completion tests (a missing shell skips, never fails)

cargo run --bin scope -- completions zsh # emit a shell completion script
```

- This is a **binary-only crate** (no lib target). Use `cargo test --bin scope` — `cargo test --lib` fails with "no library targets". Unit tests live in `#[cfg(test)] mod tests` blocks inside the source files they cover.
- **End-to-end TUI tests** are in `tests/tui_e2e.rs` (Unix only): they spawn the real binary in a PTY (`portable-pty`), connect it to a virtual serial port (`openpty`), inject keystrokes, and assert on the screen reconstructed by a `vt100` parser. The serial-RX test is `#[ignore]`d because byte transport over a PTY-backed serial port is platform dependent (`serialport` can't set baud via ioctl on a macOS PTY); run it with `cargo test --test tui_e2e -- --ignored`.
- `src/main.rs` has `#![deny(warnings)]`, so any compiler warning fails the build. Keep the tree warning-clean.
- Global CLI options (before the subcommand): `-c/--capacity` (scrollback lines, default 2000), `-t/--tag-file` (default `tags.yml`), `-l/--latency` (ms, clamped 0..=100000, default 100), `-n/--name` (session record base name, default a timestamp), `--headless` (see below). The session can also be renamed at runtime with `!rename <name>` in the command bar.
- Global CLI options (before the subcommand): `-c/--capacity` (scrollback lines, default 2000), `-t/--tag-file` (default `tags.yml`), `-l/--latency` (**microseconds** — every polling loop but `rtt_if::wait` treats it as µs, see below — clamped 0..=100000, default 100), `-n/--name` (session record base name, default a timestamp), `--headless` (see below). The session can also be renamed at runtime with `!rename <name>` in the command bar.
- **Shell completions** (issue #231): `scope completions <SHELL>` prints a native `clap_complete` **static** (ahead-of-time) script to stdout (`bash`/`zsh`/`fish`/`powershell`/`elvish`); the shell is a required positional, so we never guess from `$SHELL` (that's the *login* shell). Two non-obvious constraints, both pinned by `tests/completions.rs`: (1) `Cli` carries an explicit `#[command(name = "scope")]`, because `clap_derive` otherwise names the command from `CARGO_PKG_NAME` (`scope-monitor`) and the script registers a command nobody runs; (2) the arm is dispatched in `main` **right after `Cli::parse()`, before the `Config::load()` closure**, and returns early — the script is evaluated on every shell start-up, so it must not read `config.toml` (a typo there would break the user's prompt, not just scope) and must not reach the `println!("See you later ^^")` epilogue, which the shell would try to execute. The `Commands::Completions { .. } => unreachable!()` arm inside the closure exists only to keep the match exhaustive. `clap_complete` needs `clap >= 4.5.20` (hence the manifest bump) and adds one crate. Deliberately **not** `unstable-dynamic`: it is semver-exempt, its bash/fish hooks drop the file-name fallback for `--tag-file`, and live port values would come from the USB-only `list::usb_ports` with no fallback — live value completion is a follow-up. Install instructions live in README.md → *Installation → Shell completions* and in `scope completions --help`.
- **Icon mode** (`src/selector.rs`, issues #229/#230): when `serial`/`rtt` is launched **without** its positional args (`scope serial`, `scope --headless rtt`, …), `main` runs an interactive ratatui picker *before* spawning any task, to choose port+baud (serial: a list from `list::usb_ports`) or target+channel (rtt: text fields, since a chip name isn't enumerable). `resolve_serial`/`resolve_rtt` in `main.rs` gate it: only when the missing arg *and* an interactive terminal (`stdin/stdout.is_terminal()`) are present — piped/scripted runs fall through to the old "start disconnected" behaviour, as does the picker's `Skip` (`s`). `Quit` (`q`/`Esc`) exits before the app starts. The picker owns its own crossterm session (raw mode + alternate screen) and restores the terminal via `Tui`'s `Drop`. Pure state logic (`move_selection`, `parse_baud`, `parse_channel`, `initial_baud_index`) is unit-tested; the render/loop is exercised via the `test-tui` skill.
- **Headless mode** (`--headless`): no TUI — a raw terminal↔wire bridge. A `graphics/headless.rs` task takes the graphics slot (same `GraphicsCommand` channel + tx/rx/logger consumers) and just writes RX bytes to stdout (logs colored via ANSI, no timestamps/scrollback/persistence). The Inputs task carries a `raw: bool` overlay on `InputsShared` (not a new `InputMode`): raw keys are encoded to VT bytes (`inputs/key_encode.rs`) and sent straight to `tx`; `Ctrl+K` drops into the existing `Normal` command bar (blinking `> ` prompt rendered by the headless task), Enter runs the command and returns to raw, Esc quits. The interface tasks forward RX immediately (per-byte / per-chunk) instead of `\n`-framing when `headless` is set.
- **Periodic full repaint** (issues #166/#233): a 3s `Timer` in the graphics draw loop forces a whole-screen repaint (`graphics/graphics_task.rs`, `force_full_repaint`) so a screen cleared from outside the app (e.g. Cmd+K in Zed's terminal) heals itself. It must **not** call `terminal.clear()`: crossterm's `execute!` flushes `ESC[2J` on a write of its own, so the terminal shows a blank frame ~2ms before the repaint arrives — that was the 1Hz blink of #233. It must **not** use `terminal.swap_buffers()` alone either: a reset diff base equals a blank cell, so ratatui skips every cell that is blank in the new frame and strands stale glyphs forever. Instead the back buffer is filled with a sentinel cell and promoted to the diff base with `swap_buffers`, so the next `draw` rewrites every cell (blanks included) in its normal flush — no erase byte is ever emitted. Rewriting the blanks makes a forced frame ~3x bigger, which is what the 3s period pays for: measured idle output is ~2.4KB/s at 160x40, just under the ~2.7KB/s the old 1s clear+repaint cost. A genuine resize still clears, inside ratatui's `Terminal::resize`. Two e2e tests pin both halves (`periodic_repaint_never_erases_the_display`, `periodic_repaint_overwrites_external_garbage`); `screen_recovers_after_external_clear` alone passes even for the two broken variants.
Expand Down
26 changes: 18 additions & 8 deletions Cargo.lock

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

12 changes: 10 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
toml = "0.8"
rand = "0.8.6"
clap = { version = "4.1.9", features = ["derive"] }
# 4.5.20 is the floor `clap_complete` requires.
clap = { version = "4.5.20", features = ["derive"] }
mlua = { version = "0.9.6", features = ["lua54", "vendored", "async", "send"] }
anyhow = "1.0.79"
dirs = "5"
Expand All @@ -55,6 +56,10 @@ lipsum = "0.9.1"
arboard = "3.6.1"
probe-rs = "0.31.0"
nucleo = "0.5.0"
# Generates the native shell completion scripts `scope completions <SHELL>`
# prints (issue #231). Static (ahead-of-time) generation only — the dynamic
# completion API is behind a semver-exempt feature flag.
clap_complete = "4.6.8"

[target.'cfg(windows)'.dependencies]
ctrlc = "3.4.3"
Expand All @@ -63,11 +68,14 @@ ctrlc = "3.4.3"
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"

# Used by `tests/completions.rs`, which runs on every platform.
[dev-dependencies]
tempfile = "3"

# Used only by the end-to-end TUI integration tests in `tests/tui_e2e.rs`.
[target.'cfg(unix)'.dev-dependencies]
portable-pty = "0.8"
vt100 = "0.15"
tempfile = "3"
nix = { version = "0.29", features = ["term"] }

# The profile that 'dist' will build with
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,46 @@ cargo build --release
# the binary is at target/release/scope
```

### Shell completions

`scope completions <SHELL>` prints a native completion script to stdout, so that `scope se` + <kbd>Tab</kbd> completes to `scope serial` using your shell's own completion engine. `bash`, `zsh`, `fish`, `powershell` and `elvish` are supported. No installer sets this up for you — install it once:

- **bash (Linux)** — drop it in the user completion directory (needs the `bash-completion` package):
```shell
mkdir -p ~/.local/share/bash-completion/completions
scope completions bash > ~/.local/share/bash-completion/completions/scope
```
- **bash (macOS)** — the system bash is 3.2 and Terminal starts it as a *login* shell, so that directory is never read. Write the script somewhere and source it from **`~/.bash_profile`** (not `~/.bashrc`):
```shell
scope completions bash > ~/.scope-completion.bash
echo 'source ~/.scope-completion.bash' >> ~/.bash_profile
```
- **zsh** — the file must be named `_scope`, and `fpath` must be extended **before** `compinit` runs:
```shell
mkdir -p ~/.zfunc
scope completions zsh > ~/.zfunc/_scope
```
then, in `~/.zshrc`:
```shell
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinit
```
- **fish** — nothing else to configure, fish autoloads it:
```shell
mkdir -p ~/.config/fish/completions
scope completions fish > ~/.config/fish/completions/scope.fish
```
- **PowerShell (Windows)** — write the script next to your profile and dot-source it:
```powershell
if (!(Test-Path $PROFILE)) { New-Item -ItemType File -Path $PROFILE -Force | Out-Null }
$comp = Join-Path (Split-Path $PROFILE) '_scope.ps1'
scope completions powershell > $comp
Add-Content $PROFILE ('. "' + $comp + '"')
```
`$PROFILE` is `Documents\PowerShell\Microsoft.PowerShell_profile.ps1` on PowerShell 7 and `Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1` on Windows PowerShell 5.1, which also defaults to a `Restricted` execution policy that blocks profile scripts entirely — allow them once with `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`. `cmd.exe` has no completion engine and is not supported.

Restart your shell afterwards (zsh: or re-run `compinit`). `scope completions --help` prints the same commands, so you don't need this page on the machine you are installing on.

## Quickstart

Open a serial port by passing it to the `serial` subcommand together with the baud rate:
Expand Down Expand Up @@ -316,6 +356,7 @@ Commands:
| `rtt [<target>] [<channel>]` | Attach to an RTT target via `probe-rs` (e.g. `scope rtt STM32F303 0`). |
| `list [-v\|--verbose]` | List the available serial ports. |
| `ble <name> <mtu>` | *(Not yet implemented.)* |
| `completions <SHELL>` | Print a shell completion script for `scope` (`bash`, `zsh`, `fish`, `powershell`, `elvish`) — see [Shell completions](#shell-completions). |

Global options (given before the command):

Expand Down Expand Up @@ -413,6 +454,8 @@ Load a plugin with `!plugin load <file>` (and `!plugin reload <file>` / `!plugin

**My `tag_file` / config path isn't found.** Path values are used verbatim: `~` and environment variables are **not** expanded. Use an absolute path (for example `/home/user/.config/scope/tags.yml`).

**<kbd>Tab</kbd> completion doesn't work.** Completions are not installed automatically — run `scope completions <SHELL>` as described in [Shell completions](#shell-completions), then start a new shell. Two mistakes fail silently: on zsh, `fpath` must be extended *before* `compinit` runs (oh-my-zsh calls `compinit` for you, so the `fpath=(~/.zfunc $fpath)` line has to come above the `oh-my-zsh.sh` source); on macOS bash, the `source` line has to be in `~/.bash_profile`, since Terminal starts a login shell that never reads `~/.bashrc`.

## Project Goals

This project has 5 pillars that direct the development of this tool:
Expand Down
68 changes: 66 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ use crate::infra::tags::TagList;
use crate::interfaces::rtt_if::{RttCommand, RttConnections, RttSetup};
use crate::interfaces::serial_if::SerialCommand;
use crate::interfaces::{InterfaceCommand, InterfaceTask, InterfaceType};
use clap::{Parser, Subcommand};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::aot::{Shell, generate};
use graphics::graphics_task::{GraphicsConnections, GraphicsTask};
use infra::config::Config;
use infra::logger::Logger;
Expand All @@ -35,14 +36,26 @@ const DEFAULT_CAPACITY: usize = 2000;
const DEFAULT_TAG_FILE: &str = "tags.yml";

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
// The name is spelled out because `clap_derive` would otherwise take it from
// `CARGO_PKG_NAME` (`scope-monitor`), and the completion scripts would then be
// registered for a command nobody runs — see `Commands::Completions`.
#[command(name = "scope", author, version, about, long_about = None)]
#[command(after_help = "Tip: run `scope completions --help` to enable <Tab> \
completion for scope in your shell.")]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Number of scrollback lines kept in memory. Falls back to `capacity` in
/// config.toml, then to 2000.
#[clap(short, long)]
capacity: Option<usize>,
/// Path to the YAML file whose entries resolve `@name` tags typed in the
/// command bar. Falls back to `tag_file` in config.toml, then to `tags.yml`.
/// Used verbatim: `~` and environment variables are not expanded.
#[clap(short, long)]
tag_file: Option<PathBuf>,
/// Polling latency in microseconds, clamped to 0..=100000. Defaults to 100;
/// 0 yields the thread instead of sleeping.
#[clap(short, long)]
latency: Option<u64>,
/// Base name for the session record file. Defaults to a timestamp.
Expand All @@ -58,22 +71,54 @@ struct Cli {

#[derive(Subcommand)]
pub enum Commands {
/// Open a serial port. Without arguments, pick one interactively.
Serial {
/// Serial port to open, e.g. /dev/ttyUSB0 or COM3. Run `scope list` to
/// see what is available.
port: Option<String>,
/// Baud rate to open the port at, e.g. 115200.
baudrate: Option<u32>,
},
/// List the available serial ports.
List {
/// Show one table row per USB port with its serial number, PID, VID and
/// manufacturer. Non-USB ports are omitted from this view.
#[clap(short, long)]
verbose: bool,
},
/// Connect to a BLE device (not yet implemented).
Ble {
/// Advertised name of the BLE device to connect to.
name_device: String,
/// ATT MTU to negotiate with the device.
mtu: u32,
},
/// Attach to an RTT target via probe-rs. Without arguments, pick one
/// interactively.
Rtt {
/// Target chip name as probe-rs spells it, e.g. STM32F303.
target: Option<String>,
/// RTT channel to attach to. Defaults to 0.
channel_num: Option<usize>,
},
/// Print a shell completion script for `scope` to stdout.
///
/// Install it once so that `scope se<TAB>` completes to `scope serial`:
///
/// bash scope completions bash > ~/.local/share/bash-completion/completions/scope
/// (macOS: write it to a file and `source` that from ~/.bash_profile)
/// zsh scope completions zsh > ~/.zfunc/_scope
/// (~/.zshrc needs `fpath=(~/.zfunc $fpath)` before `compinit`)
/// fish scope completions fish > ~/.config/fish/completions/scope.fish
/// powershell scope completions powershell >> $PROFILE
///
/// Then restart your shell. See the README for the full instructions.
#[command(verbatim_doc_comment)]
Completions {
/// Shell to generate the completion script for.
#[clap(value_enum)]
shell: Shell,
},
}

fn app_serial(
Expand Down Expand Up @@ -413,6 +458,22 @@ fn main() -> Result<(), String> {

let cli = Cli::parse();

// Emitting a completion script is pure output, and the shell evaluates it on
// every start-up — so it is handled before the fallible setup below and
// returns straight away: no `config.toml` (a typo there would break the
// user's prompt, not just scope), no keymap, no picker, and crucially not
// the `See you later ^^` epilogue, which the shell would try to run.
// Emitting a completion script is pure output, and the shell evaluates it on
// every start-up — so it is handled before the fallible setup below and
// returns straight away: no `config.toml` (a typo there would break the
// user's prompt, not just scope), no keymap, no picker, and crucially not
// the `See you later ^^` epilogue, which the shell would try to run.
if let Commands::Completions { shell } = &cli.command {
let mut cmd = Cli::command();
generate(*shell, &mut cmd, "scope", &mut stdout());
return Ok(());
}

let latency = cli.latency.unwrap_or(100).clamp(0, 100_000);

// Everything that can fail fatally — loading `~/.config/scope/config.toml`,
Expand Down Expand Up @@ -467,6 +528,9 @@ fn main() -> Result<(), String> {
),
None => Ok(()),
},
// Handled right after `Cli::parse()`, before this closure, so a
// completion script never depends on the config loading.
Commands::Completions { .. } => unreachable!(),
}
})();

Expand Down
Loading
Loading