├── .gitignore ├── screenshot.png ├── embed ├── favicon.ico ├── ConsolasNerdFont.woff2 ├── index.html ├── htm.js ├── script.js ├── floating-ui.dom.js ├── preact.js └── floating-ui.core.js ├── justfile ├── .github └── workflows │ └── release.yml ├── Cargo.toml ├── .vscode └── launch.json ├── README.md ├── notes.md ├── src └── main.rs └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | stdin.txt 3 | stdout.txt 4 | vte.txt 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rgwood/escape-artist/HEAD/screenshot.png -------------------------------------------------------------------------------- /embed/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rgwood/escape-artist/HEAD/embed/favicon.ico -------------------------------------------------------------------------------- /embed/ConsolasNerdFont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rgwood/escape-artist/HEAD/embed/ConsolasNerdFont.woff2 -------------------------------------------------------------------------------- /embed/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Escape Artist 8 | 9 | 10 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set shell := ["nu", "-c"] 2 | 3 | # this doesn't quite work perfectly because the terminal is stuck in raw mode after the restart 4 | watch: 5 | watchexec --exts=rs,js,html,css --on-busy-update=restart -- cargo run 6 | 7 | run: 8 | cargo run 9 | 10 | test: 11 | cargo test 12 | 13 | watch-tests: 14 | watch . { cargo test } --glob=**/*.rs 15 | 16 | expected_filename := if os_family() == "windows" { "escape-artist.exe" } else { "escape-artist" } 17 | 18 | build-release: 19 | cargo build --release 20 | ls target/release 21 | 22 | publish-to-local-bin: build-release 23 | cp target/release/{{expected_filename}} ~/bin/ 24 | 25 | build-linux-x64: 26 | cross build --target x86_64-unknown-linux-gnu --release 27 | 28 | build-linux-arm64: 29 | cross build --target aarch64-unknown-linux-gnu --release 30 | 31 | build-windows-on-linux: 32 | cross build --target x86_64-pc-windows-gnu --release 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | jobs: 6 | upload-assets: 7 | strategy: 8 | matrix: 9 | include: 10 | - target: aarch64-unknown-linux-musl 11 | os: ubuntu-latest 12 | - target: x86_64-unknown-linux-musl 13 | os: ubuntu-latest 14 | - target: x86_64-pc-windows-msvc 15 | os: windows-latest 16 | - target: x86_64-apple-darwin 17 | os: macos-latest 18 | - target: aarch64-apple-darwin 19 | os: macos-latest 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: taiki-e/upload-rust-binary-action@v1 24 | with: 25 | # (required) Comma-separated list of binary names (non-extension portion of filename) to build and upload. 26 | # Note that glob pattern is not supported yet. 27 | bin: escape-artist 28 | target: ${{ matrix.target }} 29 | # (required) GitHub token for uploading assets to GitHub Releases. 30 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "escape-artist" 3 | description = "A visualizer for terminal escape sequences" 4 | homepage = "https://github.com/rgwood/escape-artist" 5 | repository = "https://github.com/rgwood/escape-artist" 6 | version = "0.6.7" 7 | edition = "2021" 8 | license = "MIT" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [build-dependencies] 13 | is-docker = "0.2.0" 14 | 15 | [dependencies] 16 | anyhow = "1.0.70" 17 | crossterm = "0.27.0" 18 | portable-pty = "0.8.1" 19 | 20 | axum = { version = "0.7.4", features = ["macros", "ws"] } 21 | serde = { version = "1.0.159", features = ["derive", "rc"] } 22 | serde_json = "1.0.95" 23 | tokio = { version = "1.27.0", features = ["full"] } 24 | clap = { version = "4.5.4", features = ["derive"] } 25 | rust-embed = { version = "8.2.0", features = ["axum-ex"] } 26 | mime_guess = "2.0.4" 27 | open = "5.0.1" 28 | rand = "0.8.5" 29 | signal-hook = "0.3.15" 30 | termwiz = "0.22.0" 31 | ansi_colours = "1.2.2" 32 | iconify = "0.3.0" 33 | 34 | [profile.release] 35 | lto = true # Enable Link Time Optimization 36 | opt-level = 'z' # Optimize for size. 37 | panic = 'abort' # Abort on panic 38 | 39 | 40 | # codegen-units = 1 # Set low to increase optimizations. Kills compile time though 41 | # strip = true # Strip symbols from binary. Big gains but idk if it's worth bad stack traces 42 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug executable 'escape-artist'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=escape-artist", 15 | "--package=escape-artist" 16 | ], 17 | "filter": { 18 | "name": "escape-artist", 19 | "kind": "bin" 20 | } 21 | }, 22 | "args": [], 23 | "cwd": "${workspaceFolder}" 24 | }, 25 | { 26 | "type": "lldb", 27 | "request": "launch", 28 | "name": "Debug unit tests in executable 'escape-artist'", 29 | "cargo": { 30 | "args": [ 31 | "test", 32 | "--no-run", 33 | "--bin=escape-artist", 34 | "--package=escape-artist" 35 | ], 36 | "filter": { 37 | "name": "escape-artist", 38 | "kind": "bin" 39 | } 40 | }, 41 | "args": [], 42 | "cwd": "${workspaceFolder}" 43 | } 44 | ] 45 | } -------------------------------------------------------------------------------- /embed/htm.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bundled by jsDelivr using Rollup v2.74.1 and Terser v5.15.1. 3 | * Original file: /npm/htm@3.1.1/dist/htm.module.js 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e=""},a=0;a"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0])}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]}export{s as default}; 8 | //# sourceMappingURL=/sm/3761d505ff1a3e54bcae3398870b93c29140cc811be53bcd0f48a4808c639b51.map -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Escape Artist 2 | 3 | Escape Artist is a tool for seeing ANSI escape codes in terminal applications. You interact with your shell just like you normally would, and it shows the normally-invisible escape codes in a web UI: 4 | 5 | ![screenshot](screenshot.png) 6 | 7 | ## Installation 8 | 9 | Binaries for major OSs+architectures are attached to the GitHub releases, download+expand one and put it in your PATH. Alternately, you can build+install with Rust's Cargo package manager. [Install Rust](https://rustup.rs/), then run `cargo install escape-artist` 10 | 11 | After that, you can launch Escape Artist by running `escape-artist` in your terminal. 12 | 13 | ## Project Status 14 | 15 | This was hacked together for the [Handmade Network Visibility Jam](https://handmade.network/jam/visibility-2023), so it might be a little rough around the edges. It's primarily been developed on Linux, but I used cross-platform libraries and my limited testing on Windows+macOS has gone well. 16 | 17 | ## FAQ 18 | 19 | **Q:** What are ANSI escape codes? 20 | 21 | **A:** "Invisible" byte sequences that are printed by console applications to stdout and stderr, then interpreted by your terminal emulator. For example, an escape code might tell the terminal to paint all text green until further notice. There are several kinds of escape sequences, [this page](https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797) is a great reference. 22 | 23 | **Q:** What does Escape Artist do? 24 | 25 | **A:** It launches your shell in a [pseudoterminal](https://devblogs.microsoft.com/commandline/windows-command-line-introducing-the-windows-pseudo-console-conpty/#enter-the-pseudo-terminal-pty) (AKA a pty) and starts a web interface where you can view the output of the shell with escape sequences made visible. You can mouse over individual escape codes to see their raw bytes and (sometimes) a description of what the escape code does. 26 | 27 | **Q:** Why is this useful? 28 | 29 | **A:** It's kinda like View Source for the terminal. Maybe you're curious about what your shell is doing to render your cool new prompt. Maybe you work on a shell or a TUI application and you need to debug it. 30 | 31 | **Q:** How can I configure Escape Artist? 32 | 33 | **A:** Run Escape Artist with the `-h` or `--help` flag to see all possible options.

34 | 35 | ## Contributions 36 | 37 | Contributions are welcome! This project could certainly use some polish. 38 | 39 | Note that I am trying to keep the web front-end of this project as simple as possible. The project currently has no front-end build step, and I would like to keep it that way unless a *very* compelling reason comes along. -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | https://asciinema.org/docs/how-it-works 2 | 3 | > A pseudo terminal is a pair of pseudo-devices, one of which, the slave, emulates a real text terminal device, the other of which, the master, provides the means by which a terminal emulator process controls the slave. 4 | 5 | > The role of the terminal emulator process is to interact with the user; to feed text input to the master pseudo-device for use by the shell (which is connected to the slave pseudo-device) and to read text output from the master pseudo-device and show it to the user. 6 | 7 | We need to: 8 | 9 | 1. spin up a pty 10 | 2. launch Nu and hook it up to the pty 11 | 1. base this on: https://github.com/rgwood/pty-driver/blob/master/src/main.rs 12 | 3. handle output from Nu and input from the keyboard 13 | 14 | Useful reading: 15 | https://poor.dev/blog/terminal-anatomy/ 16 | 17 | # TO DO 18 | 19 | - [ ] Centralize the "add an Action to the Vec of all records and send the new action to any listeners (debounced)" logic 20 | - [ ] Make newlines take up less vertical space. Solution: Add a new InvisibleLineBreak dto that we send before+after CR+LF 21 | - [ ] Show colours in tooltip (border?) instead of just "PaletteIndex(2)" or whatever 22 | - [x] use a nerd font 23 | - [x] handle failure to get a socket with a nicer error message 24 | - [x] print stack trace automatically 25 | - [ ] show raw bytes for line breaks 26 | - [x] embed all js libs so it works offline 27 | - [x] add an option to log to a file 28 | - [x] coalesce multiple chars into a single string event, cut down on data transfer and work for the front-end 29 | - [x] add a favicon 30 | - [x] use open-rs to launch to page in a browser 31 | - [x] add help page 32 | - [x] use rust-embed to serve files https://github.com/pyrossh/rust-embed 33 | - [x] show raw bytes for other 34 | - [x] display raw bytes better. replace control codes 35 | - [x] hide tooltip description when not exists 36 | - [x] use floating-ui for a custom tooltip 37 | - [x] display raw bytes in tooltip 38 | - [x] wrap items, don't overflow horizontally 39 | - [x] Use clap or argh or similar 40 | - [x] Debounce/chunk streaming events to cut down on #renders 41 | - [x] send a "disconnected" message to web browser when exiting 42 | - [x] Move more logic to Rust. Send to-be-displayed info as a new type 43 | - [x] Start using VTE to parse output stream: https://github.com/alacritty/vte 44 | - [x] Spin up web UI (Axum?) 45 | - [x] Start logging all events 46 | - [x] Send tokens to web UI 47 | - [x] Why doesn't it render prompt? 48 | - Maybe because the cursor is on the prompt line. Do I need to disable cursor? 49 | - Was because I was forgetting to flush stdin 50 | - [x] Figure out why update is so infrequent (and only prompted by several keypresses) 51 | - [x] Reset raw mode etc. when child Nu exits 52 | - [x] Can/should I detect ctrl+D/SIGQUIT? Or when the child dies more generally? 53 | - if we get an EOF from nu stdout... -------------------------------------------------------------------------------- /embed/script.js: -------------------------------------------------------------------------------- 1 | import { h, render } from "/preact.js"; 2 | import htm from "/htm.js"; 3 | import { 4 | computePosition, 5 | flip, 6 | shift, 7 | offset, 8 | arrow 9 | } from '/floating-ui.dom.js' 10 | 11 | const html = htm.bind(h); 12 | 13 | let url = new URL("/events", window.location.href); 14 | // http => ws 15 | // https => wss 16 | url.protocol = url.protocol.replace("http", "ws"); 17 | 18 | let events = []; 19 | 20 | function showTooltip(event) { 21 | const tooltip = document.querySelector('#tooltip'); 22 | tooltip.style.display = 'block'; 23 | update(event.target); 24 | } 25 | 26 | function Event(props) { 27 | let dto = props.dto; 28 | const shared_classes = "w-fit outline outline-1 rounded-sm px-1 m-1 bg-slate-800" 29 | switch (dto.type) { 30 | case "Print": 31 | if (!!dto.color && !!dto.bg_color) { 32 | return html`${dto.string}` 33 | } else if (!!dto.color) { 34 | return html`${dto.string}` 35 | } else if (!!dto.bg_color) { 36 | return html`${dto.string}` 37 | } else { 38 | return html`${dto.string}`; 39 | } 40 | case "GenericEscape": { 41 | let svg = dto.icon_svg ? html`` : html``; 42 | let title = dto.title ? html`${dto.title}` : ``; 43 | return html`
52 | ${svg} 53 | ${title} 54 |
`; 55 | } 56 | case "ColorEscape": { 57 | let svg = dto.icon_svg ? html`` : html``; 58 | let title = dto.title ? html`${dto.title}` : ``; 59 | return html`
68 | ${svg} 69 | ${title} 70 |
`; 71 | } 72 | case "InvisibleLineBreak": { 73 | return html`
`; 74 | } 75 | case "LineBreak": { 76 | return html` 77 | 78 | ${dto.title} 79 | `; 80 | } 81 | case "Disconnected": { 82 | return html`
83 | Disconnected 84 |
`; 85 | } 86 | default: 87 | return html`${dto.type}`; 88 | } 89 | } 90 | 91 | let ws = new WebSocket(url.href); 92 | ws.onmessage = async (ev) => { 93 | let deserialized = JSON.parse(ev.data); 94 | for (const event of deserialized) { 95 | events.push(event); 96 | // console.log(event); 97 | } 98 | renderAndScroll(); 99 | }; 100 | 101 | ws.onclose = (_) => { 102 | events.push({ type: "Disconnected" }); 103 | renderAndScroll(); 104 | }; 105 | 106 | function renderAndScroll() { 107 | render( 108 | html` 109 |