Skip to content
Draft
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
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ Default size of the virtual terminal window is 120x40 (cols by rows), which can
be changed with `--size` argument. For example: `ht --size 80x24`. The window
size can also be dynamically changed - see [resize command](#resize) below.

### Style Support

ht supports capturing and returning terminal styling information (colors, bold, italic, etc.). Use the
`--style-mode` option to enable styled output:

- `--style-mode plain` (default) - Returns only plain text in snapshots
- `--style-mode styled` - Includes styling information in snapshots

Example:
```sh
ht --style-mode styled --subscribe snapshot
```

Run `ht -h` or `ht --help` to see all available options.

## Live terminal preview
Expand Down Expand Up @@ -205,6 +218,18 @@ specifying new width (`cols`) and height (`rows`).

This command triggers `resize` event.

#### setStyleMode

`setStyleMode` command allows changing the style mode during runtime to enable or
disable styled output in snapshots.

```json
{ "type": "setStyleMode", "mode": "styled" }
{ "type": "setStyleMode", "mode": "plain" }
```

This command doesn't trigger any event but affects subsequent snapshots.

### WebSocket API

The WebSocket API currently provides 2 endpoints:
Expand Down Expand Up @@ -280,6 +305,36 @@ Event data is an object with the following fields:
- `text` - plain text snapshot as multi-line string, where each line represents a terminal row
- `seq` - a raw sequence of characters, which when printed to a blank terminal puts it in the same state as [ht's virtual terminal](https://github.com/asciinema/avt)

When color mode is set to `styled`, additional fields are included:

- `charMap` - 2D array of characters, where `charMap[row][col]` gives the character at that position
- `styleMap` - 2D array of style IDs, where `styleMap[row][col]` gives the style ID for the character at that position
- `styles` - object mapping style IDs to style definitions with `fg`, `bg`, and `attrs` fields

Example styled snapshot for `$ ls foo/b*` where `baz.sh` is styled differently than `bar.txt`:
```json
{
"type": "snapshot",
"data": {
"cols": 15, "rows": 2,
"text": "$ ls foo/b*\nbar.txt baz.sh",
"charMap": [
["$", " ", "l", "s", " ", "f", "o", "o", "/", "b", "*", " ", " ", " ", " "],
["b", "a", "r", ".", "t", "x", "t", " ", " ", "b", "a", "z", ".", "s", "h"]
],
"styleMap": [
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2]
],
"styles": {
"0": {},
"1": {"fg": {"indexed": 2}},
"2": {"fg": {"rgb": [0, 255, 127]}, "attrs": ["bold"]}
}
}
}
```

## Testing on command line

ht is aimed at programmatic use given its JSON-based API, however one can play
Expand All @@ -303,7 +358,6 @@ TODO: either pull those into this repo or fork them into their own `htlib` repo.

## Possible future work

* update the interface to return the view with additional color and style information (text color, background, bold/italic/etc) also in a simple JSON format (so no dealing with color-related escape sequence either), and the frontend could render this using HTML (e.g. with styled pre/span tags, similar to how asciinema-player does it) or with SVG.
* support subscribing to view updates, to avoid needing to poll (see [issue #9](https://github.com/andyk/ht/issues/9))
* native integration with asciinema for recording terminal sessions (see [issue #8](https://github.com/andyk/ht/issues/8))

Expand Down
8 changes: 4 additions & 4 deletions src/api/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ async fn alis_message(
use session::Event::*;

match event {
Ok(Init(time, cols, rows, _pid, seq, _text)) => Some(Ok(json_message(json!({
Ok(Init(time, cols, rows, _pid, seq, _text, _)) => Some(Ok(json_message(json!({
"time": time,
"cols": cols,
"rows": rows,
Expand All @@ -101,7 +101,7 @@ async fn alis_message(
format!("{cols}x{rows}")
])))),

Ok(Snapshot(_, _, _, _)) => None,
Ok(Snapshot(_, _, _, _, _)) => None,

Err(e) => Some(Err(axum::Error::new(e))),
}
Expand Down Expand Up @@ -158,10 +158,10 @@ async fn event_stream_message(
use session::Event::*;

match event {
Ok(e @ Init(_, _, _, _, _, _)) if sub.init => Some(Ok(json_message(e.to_json()))),
Ok(e @ Init(_, _, _, _, _, _, _)) if sub.init => Some(Ok(json_message(e.to_json()))),
Ok(e @ Output(_, _)) if sub.output => Some(Ok(json_message(e.to_json()))),
Ok(e @ Resize(_, _, _)) if sub.resize => Some(Ok(json_message(e.to_json()))),
Ok(e @ Snapshot(_, _, _, _)) if sub.snapshot => Some(Ok(json_message(e.to_json()))),
Ok(e @ Snapshot(_, _, _, _, _)) if sub.snapshot => Some(Ok(json_message(e.to_json()))),
Ok(_) => None,
Err(e) => Some(Err(axum::Error::new(e))),
}
Expand Down
34 changes: 32 additions & 2 deletions src/api/stdio.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::Subscription;
use crate::cli::StyleMode;
use crate::command::{self, Command, InputSeq};
use crate::session;
use anyhow::Result;
Expand All @@ -24,10 +25,16 @@ struct ResizeArgs {
rows: usize,
}

#[derive(Debug, Deserialize)]
struct SetStyleModeArgs {
mode: String,
}

pub async fn start(
command_tx: mpsc::Sender<Command>,
clients_tx: mpsc::Sender<session::Client>,
sub: Subscription,
_color_mode: StyleMode,
) -> Result<()> {
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
thread::spawn(|| read_stdin(input_tx));
Expand All @@ -52,7 +59,7 @@ pub async fn start(
use session::Event::*;

match event {
Some(Ok(e @ Init(_, _, _, _, _, _))) if sub.init => {
Some(Ok(e @ Init(_, _, _, _, _, _, _))) if sub.init => {
println!("{}", e.to_json());
}

Expand All @@ -64,7 +71,7 @@ pub async fn start(
println!("{}", e.to_json());
}

Some(Ok(e @ Snapshot(_, _, _, _))) if sub.snapshot => {
Some(Ok(e @ Snapshot(_, _, _, _, _))) if sub.snapshot => {
println!("{}", e.to_json());
}

Expand Down Expand Up @@ -113,6 +120,13 @@ fn build_command(value: serde_json::Value) -> Result<Command, String> {

Some("takeSnapshot") => Ok(Command::Snapshot),

Some("setStyleMode") => {
let args: SetStyleModeArgs = args_from_json_value(value)?;
let style_mode = args.mode.parse::<StyleMode>()
.map_err(|e| format!("invalid style mode: {}", e))?;
Ok(Command::SetStyleMode(style_mode))
}

other => Err(format!("invalid command type: {other:?}")),
}
}
Expand Down Expand Up @@ -282,6 +296,7 @@ fn parse_key(key: String) -> InputSeq {
mod test {
use super::{cursor_key, parse_line, standard_key, Command};
use crate::command::InputSeq;
use crate::cli::StyleMode;

#[test]
fn parse_input() {
Expand Down Expand Up @@ -483,6 +498,21 @@ mod test {
assert!(matches!(command, Command::Snapshot));
}

#[test]
fn parse_set_style_mode() {
let command = parse_line(r#"{ "type": "setStyleMode", "mode": "styled" }"#).unwrap();
assert!(matches!(command, Command::SetStyleMode(StyleMode::Styled)));

let command = parse_line(r#"{ "type": "setStyleMode", "mode": "plain" }"#).unwrap();
assert!(matches!(command, Command::SetStyleMode(StyleMode::Plain)));
}

#[test]
fn parse_set_style_mode_invalid() {
parse_line(r#"{ "type": "setStyleMode", "mode": "invalid" }"#).expect_err("should fail");
parse_line(r#"{ "type": "setStyleMode" }"#).expect_err("should fail");
}

#[test]
fn parse_invalid_json() {
parse_line("{").expect_err("should fail");
Expand Down
23 changes: 23 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@ use clap::Parser;
use nix::pty;
use std::{fmt::Display, net::SocketAddr, ops::Deref, str::FromStr};

#[derive(Debug, Clone, Copy, Default)]
pub enum StyleMode {
#[default]
Plain,
Styled,
}

impl FromStr for StyleMode {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"plain" => Ok(StyleMode::Plain),
"styled" => Ok(StyleMode::Styled),
_ => Err(format!("invalid style mode: {s}. Valid options: plain, styled")),
}
}
}

#[derive(Debug, Parser)]
#[clap(version, about)]
#[command(name = "ht")]
Expand All @@ -23,6 +42,10 @@ pub struct Cli {
/// Subscribe to events
#[arg(long, value_name = "EVENTS")]
pub subscribe: Option<Subscription>,

/// Style mode for snapshots
#[arg(short = 's', long = "style-mode", value_name = "MODE", default_value = "plain")]
pub style_mode: StyleMode,
}

impl Cli {
Expand Down
3 changes: 3 additions & 0 deletions src/command.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use crate::cli::StyleMode;

#[derive(Debug)]
pub enum Command {
Input(Vec<InputSeq>),
Snapshot,
Resize(usize, usize),
SetStyleMode(StyleMode),
}

#[derive(Debug, PartialEq)]
Expand Down
15 changes: 10 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,24 @@ async fn main() -> Result<()> {
let (clients_tx, clients_rx) = mpsc::channel(1);

start_http_api(cli.listen, clients_tx.clone()).await?;
let api = start_stdio_api(command_tx, clients_tx, cli.subscribe.unwrap_or_default());
let api = start_stdio_api(command_tx, clients_tx, cli.subscribe.unwrap_or_default(), cli.style_mode);
let (pid, pty) = start_pty(cli.command, &cli.size, input_rx, output_tx)?;
let session = build_session(&cli.size, pid);
let session = build_session(&cli.size, pid, cli.style_mode);
run_event_loop(output_rx, input_tx, command_rx, clients_rx, session, api).await?;
pty.await?
}

fn build_session(size: &cli::Size, pid: i32) -> Session {
Session::new(size.cols(), size.rows(), pid)
fn build_session(size: &cli::Size, pid: i32, style_mode: cli::StyleMode) -> Session {
Session::new(size.cols(), size.rows(), pid, style_mode)
}

fn start_stdio_api(
command_tx: mpsc::Sender<Command>,
clients_tx: mpsc::Sender<session::Client>,
sub: api::Subscription,
style_mode: cli::StyleMode,
) -> JoinHandle<Result<()>> {
tokio::spawn(api::stdio::start(command_tx, clients_tx, sub))
tokio::spawn(api::stdio::start(command_tx, clients_tx, sub, style_mode))
}

fn start_pty(
Expand Down Expand Up @@ -106,6 +107,10 @@ async fn run_event_loop(
session.resize(cols, rows);
}

Some(Command::SetStyleMode(style_mode)) => {
session.set_style_mode(style_mode);
}

None => {
eprintln!("stdin closed, shutting down...");
break;
Expand Down
Loading