A Rust-powered desktop SSH terminal and server-ops tool
Wrolp Terminal is a Tauri 2 + React 19 + TypeScript desktop terminal client for SSH, Telnet and serial (COM) ports. The native backend is written in Rust and connects directly to remote servers using the pure-Rust async russh library. File transfer and remote editing use russh-sftp. Windows is the primary target — the release bundle target is MSI only — but the app runs and can be developed on Linux/macOS too.
Note: This README tracks the current state of the code, not a roadmap. When in doubt, trust the code.
- 🖥️ Multi-tab SSH terminal with xterm.js, password/key auth, and a keepalive-driven health indicator
- 🔌 Telnet & serial (COM) — plain-TCP sessions with IAC negotiation, and serial ports with auto-detected baud rate
- 🔀 SSH tunnels — save local port-forwarding rules per connection and start/stop them from the sidebar
- 💻 Local terminal — open shells on your own machine and browse local files, side by side with remote tabs
- 📂 SFTP file manager — upload/download with pause & resume, remote file tree
- ✍️ Remote file editor (Monaco) with UTF-8/GBK encoding auto-detect
- 🔍 Hex & image viewer — inspect binary files as a hex dump or preview images directly
- 🎬 Session recording to per-session NDJSON files, replayable from the bottom panel
- 🌐 Built-in FTP / HTTP / TFTP tools — serve or fetch files without leaving the app
- 🐳 Docker & host analysis — inspect containers, stream logs, analyze servers
- ☸️ Kubernetes panel — contexts and namespaces, ten resource kinds, pod logs and exec, port-forwards, Helm releases
- 🌿 Git panel — status, diffs, history graph, branches, fetch / pull / push, conflicts, stash, blame, clone and repository scan
- 🤖 AI assistant with tool-calling agent mode, multimodal input, and encrypted API-key storage
- 🗄️ Portable data folder — move all app data anywhere and monitor / shrink
wrolp.dbfrom Settings - 🪟 Floating panes & split layout — split the terminal area any way you like and pop panes out into independent floating windows
- 🪟 Polished UX — custom titlebar, window geometry persistence, auto-updater
- Connection management: CRUD connection profiles, grouping, drag-to-reorder, rename groups. Right-click a connection for connect / edit / delete and split-pane open (right / below); deletes use an in-app confirmation dialog (
ConfirmDialog), not a browserconfirm(). - Interactive terminal: xterm.js rendering per tab, multi-tab switching.
- Authentication: password and SSH key authentication.
- PTY:
xterm-256colorPTY + shell, resize support. - Connection health: an SSH-level keepalive (interval + max retries, configurable in Settings) drives the tab status dot — green → yellow (
connection-suspect) on a failed probe, red (connection-closed) once the retries are exhausted. A Reconnect button re-uses the same tab instance; a stale-task guard (session_idmonotonic counter) prevents a superseded background task from corrupting state. - Working directory sync: SFTP operations stay in sync with
cdtyped in the shell (poll_working_dirrunspwdon a throwaway exec channel). cddirectory suggestions: while the input line readscd <partial path>, a dropdown under the caret lists the path's real subdirectories (read through the session itself — SFTP / local FS / docker exec / WSL). ↑↓ move, Tab drills one level deeper, Enter completes without submitting (a second Enter actually runscd), Esc closes.cd C:on a local Windows shell lists drives. Toggle in Settings → Terminal or the terminal's right-click menu; sessions without a filesystem (Telnet, serial) stay excluded.- Network scanning: scan a subnet for hosts with an open SSH or Telnet port and create a connection straight from the results (
network_scan.rs).
- Save local port-forwarding rules on a connection (
TunnelConfig) and manage them from the sidebar — add, edit, delete, start, stop. - Each saved rule shows its local address and remote target and is marked active while forwarding.
- The backend emits
tunnel-changedwhenever a tunnel starts, stops or dies, so the sidebar stays in sync (including after a tab disconnects).
- Plain-TCP sessions (
connect_telnet) with a small built-in IAC negotiation state machine (ECHO / SGA / TERMINAL-TYPE / NAWS). - Window resizes are reported to the server through the NAWS sub-option (Telnet has no PTY).
- Optional best-effort auto-login that matches
login:/Password:prompts — off by default, since Telnet is unencrypted.
- Enumerates available ports with friendly names (USB manufacturer / product, VID:PID); pick one from the list or type a custom port name.
- Configurable baud rate, data bits, stop bits, parity and flow control; the baud rate can also be picked from a dropdown of common values.
- Baud-rate auto-detection: UART has no clock line and no negotiation, so the peer's real rate cannot be read. The backend instead probes each common rate, listens (nudging silent devices with a newline) and scores how much the received bytes look like terminal text — results come back ranked with a confidence score and a sample preview.
- Remote file tree browser (sidebar
FilePanel). - List / read / write / rename / delete / create directories.
- Upload and download (file or raw bytes), with pause / resume support and
transfer-progressevents. - Transfer queue (
transferQueue.ts): every file has its own row with bytes done / total and a live speed, in the file panel and in the bottom drawer. Directory and drag-dropped uploads are scanned once withwalkdiron the Rust side and stream over one shared SFTP connection, ticking progress per 4 MiB chunk — so a single multi-GB file shows a moving bar and speed instead of jumping from 0 % to done. - Cancelling an upload removes the half-written files it created on the remote, so the directory does not fill up with unusable truncated copies. Files that finished before the cancel are kept, and a destination that already existed is never deleted (the upload truncated it, so deleting it would destroy the original).
- Switching the SFTP user (
switch_sftp_user/revert_sftp_user) to operate as a different account.
- Monaco-based inline editor for remote files, shown as a split-tree pane or a floating window.
- Encoding auto-detect: UTF-8 → GBK (
encoding_rs); non-UTF-8 files are flagged and must be re-saved in the same charset.
- Open a local shell as a top-level tab or as a split pane inside an existing workspace (
openLocalShellTab/handleOpenLocalSplit); runs a native shell on your own machine. - Local file browser: navigate and edit files on the local machine through the same
FilePanel/FileEditorUI. ALocalFs(local_fs.rs) implements the sameRemoteFstrait as SFTP, so local and remote targets share one code path. On Windows the local root maps to the current drive's root.
- Binary files opened in the editor can be viewed as a hex dump (
hex_base64+HexViewer.tsx). - Image files are previewed inline; the backend reports the MIME type via
image_mime/detect_image_mime.
- Opened from the Network tools mode in the activity rail (or the Network file tools button in the titlebar); it docks in the mode column rather than floating over the window, so it can stay open next to a terminal. Everything runs inside the app, so no external client is needed.
- FTP server — share a local folder over FTP (PASV / EPSV).
- HTTP/HTTPS file server — serve a folder with a built-in page for browsing and uploading; HTTPS can use a self-signed certificate generated with one click.
- TFTP server — minimal TFTP serving for legacy / PXE equipment.
- FTP client — connect to a remote FTP server (no TLS, explicit or implicit) and browse, upload and download files.
- TFTP client — get / put single files over TFTP.
- The terminal area is a split-tree layout (
splitTree.ts): tabs can be split horizontally or vertically, and each leaf shows a terminal, a docker-log, or an open file editor. - Any pane can be popped out into a floating window (
floatPane/FloatingWindow.tsx,position: fixed, z-index starting at 1000). Terminal floats detach the leaf from the tree (the session stays alive); file-editor / docker-log floats render as an overlay above the still-mounted shell and restoreshellViewon close. - Per-session view state (
shellView/activeEditorKeyareRecord<number, ...>) keeps each tab's open files and docker logs isolated — files opened in one session don't appear in another.
- Recording is off by default. Turn on auto-recording in Settings, or force it globally with the env var
WROLP_RECORDING=1/true(0/falseforces it off). - Events are buffered in memory and appended every 5s (and on disconnect) to one NDJSON file per session:
<data dir>/recordings/<workspace>/<group>/<connection>/<YYYYMMDD-HHMMSS>_<session8>.jsonl. The SQLitesessionstable only keeps the index (events_file,event_count, plus a workspace/group snapshot), which keepswrolp.dbsmall. - Two event kinds:
input(raw keystrokes) andcommand(full command line captured on Enter, preserving tab-completed text). - Browser in the bottom panel (
SessionListPanel/SessionViewer): each row shows its workspace / group breadcrumb, can be revealed in the file manager, exported as asciinema v2 (.cast), replayed or deleted. - Maintenance actions: one-click migration of legacy recordings still
8000
stored in
session_events, and rescan which rebuilds the index from therecordings/folder (dropping empty orphan files).
- Save reusable groups of commands (optionally scoped to a connection) in SQLite.
- Managed via
CommandSetPanelin the bottom panel.
- Two daemons, one command surface. Every Docker feature runs against a
DockerHostRef: either{kind:'ssh', jumpTabId}— a container on the jump host of a connected SSH session, reached withdocker execover the exec channel — or{kind:'local'}, this machine's own daemon, reached by spawning the CLI directly (no shell, so--formattemplates and container names need no quoting).probe_local_dockerdecides whether the local half exists at all: it triesdocker, thenpodman, and distinguishes "not installed" from "installed but the daemon is not running"; withDOCKER_HOSTset onlydockeris tried, since podman would answer for a different endpoint. The sidebar has one Docker group, and its host follows the focused terminal: a local shell — or no terminal at all — reads this machine, a connected SSH / Telnet session reads that host. Serial has no shell to exec in, so the group is absent there. - List, start / stop / restart / remove, one-shot and streaming logs, the three-layer analysis report, container file browsing and entering a container's shell all work on both hosts. The shells get there differently: a jump-host container opens a new pane of that session with
docker exec -it …typed into it (persisted aspostConnectCmd, so a reconnect re-sends it), while a local one runs the exec as its own PTY (open_local_docker_shell, which picksbashwhen the image has it andshotherwise). - A log view normally rides the focused pane and the Files panel is normally a session section, but the group needs no session when its host is this machine — so neither action asks for one. With no pane on screen 「View Logs」 opens the log as a tab-bar entry that takes the main area (like a Settings tab), and opening a container's files shows the Files section over an empty workspace (only the Docker mode is offered there; SSH and ProxyJump point at a session).
analyze_host: read-only analysis of a connected server (OS, kernel, arch, packages, installed tools).analyze_docker_container+docker_container_logs: inspect containers and stream their logs (docker_logs_stream_start/poll_docker_logs/stop_docker_logs_stream).- Docker log tabs & floating: a container's logs can be opened as a dedicated log tab and popped out into a floating window. When a tab reconnects, its
postConnectCmd(e.g. the originaldocker exec) is re-sent automatically so the pane stays consistent across reconnects. command_help: look up man-page / help text for a command on a connected server.- UI panels:
DockerPanel,DockerLogViewer,DockerAnalysisPanel,HostAnalysisPanel.
- The K8s mode in the activity rail follows the focused terminal the same way Docker does: a
K8sHostRefis either{kind:'ssh', jumpTabId}—kubectlruns on the jump host of a connected SSH session — or{kind:'local'}, this machine's own CLI and kubeconfig. Context and namespace are pickers at the top of the panel, and the row list is filtered by a search box. All namespaces is the default (switching contexts resets to it), so trouble in a namespace the kubeconfig does not name is visible immediately. In that mode the namespace label under each name is tinted with a stable colour derived from the namespace, so rows from different namespaces can be told apart at a glance. - Ten resource kinds share one generic row shape (
K8sResource) insrc-tauri/src/k8s_host.rs: pods, deployments, statefulsets, daemonsets, services, ingresses, jobs, cronjobs, configmaps and secrets. Each row carries its status chip,ready/total, restart count and a one-line summary; a pod's summary is its image and the node sits on its own line underneath. - Per row: view / edit the manifest YAML and apply it (
k8s_apply_yaml), scale deployments and statefulsets, delete with an in-app confirmation, and open pod logs (one-shot or streaming with ANSI colour) or a shell inside the pod — locally as its own PTY shell, on an SSH host as a split pane that typeskubectl execfor you. - Port-forwarding: forward a pod/service port, see live forwards in the panel and reuse saved rules; the backend reports forwards over the same event channel the terminal uses, so a closed tab cannot leave an orphan forward behind.
- Helm:
helm_probesays whether the CLI exists; the Helm tab lists releases in the namespace, shows values and revision history, and rolls back or uninstalls a release.
- The Git mode in the activity rail manages one repository at a time on the focused host — an SSH session through its exec channel, or this machine when no session is focused — with one UI and one command surface (
src-tauri/src/commands/git.rs). - Everything is the real
gitCLI (no bundled library): remote calls runenv LC_ALL=C GIT_TERMINAL_PROMPT=0 … bash -lc '<quoted command>'over a fresh exec channel, local calls spawngitdirectly withHideConsole.git_probefinds the binary, including the usual Windows install paths. - Status (porcelain v2), diffs (2 MB cap), paged history, branches and remotes; stage / unstage / discard / commit (with amend), branch create / switch / delete, abort merge or rebase, and
git initfor a plain folder. - Fetch / pull / push stream
git-progressevents, can be cancelled withgit_cancel, and pull is--ff-only— a non-fast-forward ends asgit_not_fast_forwardwith a hint to merge in the terminal. A forced push exists only as--force-with-leaseand always asks first. - History shows a commit-graph lane column; expanding a commit lists its changed files, and a file opens its commit-scope diff. A read-only blame view is available per tracked file.
- Conflicts: the panel lists unmerged paths, offers ours / theirs / mark-resolved, a read-only three-way view (base / ours / theirs), and aborts an in-progress merge, rebase, cherry-pick or revert.
- Stash (push / pop / apply / drop), clone with progress, remote add / remove,
git init, a bounded repository scan from the picker, and local ignore that appends to.git/info/excludeinstead of editing the tracked.gitignore. git.localBin(Settings registry) pins the local executable when a GUI process has no Git inPATH; the picker's "git not installed" state accepts the path inline.- Paths never reach a shell: remote arguments are quoted one by one, and the backend rejects absolute paths, drive letters, backslashes and
...
- Chat panel (
AiChatPanel) with two modes:- Chat (non-streaming
ai_chat_sync). - Agent (streaming with tool calling,
run_agent_stream) — a full agent loop that can call tools on the connected servers.
- Chat (non-streaming
- Two separate chats: the inspector column's AI tab is the app-level conversation — it belongs to no terminal, opens with zero sessions running, and the backend refuses every shell tool for its runs. Terminal-bound help lives in each pane's own docked AI, where
run_command, file reads and Send to terminal are available. - Multimodal input: attach images to a message (the backend accepts
contentas structured multi-part viaopenai_content), so the model can reason about screenshots, diagrams, etc. Images can also be pasted straight into the chat input; click a thumbnail for a full-size preview. - Send to terminal: send AI-generated code blocks or selected text to the active pane, routed by connection type (SSH, local shell, serial, Telnet). The text is inserted without being executed, so you can review it first.
- Templates & re-send: insert prompts from a template dropdown next to the image button; edit and re-send any previous message (re-runs from the edited content).
- Pause: abort an in-flight agent/chat generation with
cancel_ai_chatand continue from where it stopped. - Multiple API endpoints: save several
AiEndpointProfiles, each with its own encrypted API key, endpoint URL, model, and system prompt. Select/apply one active profile. Legacy single-endpoint configs are migrated automatically. - Model fetching:
list_ai_modelscalls{endpoint}/v1/modelsto populate the model dropdown; manual entry is always available. - Agent tools (dispatched to the Rust backend, which has
AppStateaccess):run_command,analyze_server,list_directory,read_file,list_connections,search_help, and more. Tool calls are grouped under their assistant message and surfaced as tool cards with lifecycle events (pending→executing→done/error). Bash code blocks in assistant messages get a one-click copy button. - Security: API keys are encrypted at rest via an AES-256-GCM file vault (
encrypt_api_key/decrypt_api_key). The encryption key is a machine-specific file (vault.key); no OS keyring is used.
- Relocatable data folder: pick any directory in Settings → Data and
wrolp.db,recordings/,connections.jsonand the window config all move there. The choice is stored in adata_root.jsonanchor (kept in the default location) and applied on the next launch, which copies the data over when the target is empty and otherwise uses what is already there — nothing is overwritten. - Vault key option: optionally carry
vault.keyalong with the data folder on the next migration, making the folder portable as a whole. Off by default, so the key stays machine-anchored. - Database maintenance: Settings shows the current
wrolp.dbsize and how much of it is reclaimable, with a Shrink now action (VACUUM). Deleting sessions or migrating legacy recordings also releases the freed pages automatically once the freed space is worth a rewrite, so the database no longer keeps its old size after data is removed.
- Custom titlebar (window
decorations: false); window geometry/opacity persisted (window.json). - Auto-updater (GitHub release endpoint — set a real
pubkeyintauri.conf.jsonbefore shipping). - Persistent connection configs (
connections.json, encrypted — it also stores workspaces) and SQLite DB (wrolp.db, WAL mode) under the OS config dir +wrolp-terminal/.
- Every combination key the app binds lives in one table (
src/lib/keybindings.ts), and Settings → Shortcuts edits it: click a row's key, press a new combination, and the next keystroke obeys it. Search matches either the action or the combo. - A binding matches exactly, modifiers included —
Ctrl+Bdoes not also fire onCtrl+Alt+B. Each row can be unbound, restored on its own, or reset as a whole; overrides persist inlocalStorage. - Keys the shell or the system cannot do without (Tab, Enter, Ctrl+C/V/X/Z/D/W) are refused, and readline's other control keys bind with a visible warning that they stop reaching the shell.
Tabin particular stays the shell's completion key everywhere. - Defaults:
Ctrl+Kpalette ·Ctrl+Shift+Psnippet list ·Ctrl+Shift+Hcommand history ·Ctrl+Shift+Fdual-pane files ·Ctrl+B/Ctrl+Alt+Bsidebar ·Ctrl+J/Ctrl+Alt+Jpanel ·Ctrl+Alt+Iinspector ·Ctrl+\split ·Alt+/completion ·Shift+PgUp/PgDn/Home/Endscrollback ·Ctrl+Ssave ·Escclose pop-up.
sudo apt-get install -y pkg-config libdbus-1-dev libssl-dev libgtk-3-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev libwebkit2gtk-4.1-devNo extra dependencies.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shyarn.lock is committed, so prefer yarn:
yarn # or: npm installyarn tauri dev # or: npm run tauri devTauri 2 auto-starts the Vite dev server via beforeDevCommand before loading the frontend.
Frontend-only dev server (no Rust/SSH — useful for UI work):
yarn dev- Logging: backend diagnostics use
eprintln!to stderr (visible in theyarn tauri devterminal). Addeprintln!("[module] ...")where needed. - Static checks / tests:
cd src-tauri cargo check # type-check cargo build # debug build cargo clippy # lints cargo test # all Rust unit tests cargo run --bin ssh_test # standalone russh connectivity probe (src/ssh_test.rs)
- Breakpoints: a
.vscode/launch.json(Debug Tauri (dev), CodeLLDB extension) builds and launches the binary withRUST_BACKTRACE=1. Note it launches a standalone process — don't also runyarn tauri devsimultaneously. For most backend work,eprintln!logging viayarn tauri devis the usual workflow. - Gotchas: terminal output is polled (
poll_output) not event-streamed; new#[tauri::command]s require a backend rebuild (restartyarn tauri dev).
yarn build:clean # cleans its own target dir first, then builds — use this for a shippable bundle
yarn tauri build # incremental dev build into src-tauri/target/build:clean builds into src-tauri/target-release/ (bundle at src-tauri/target-release/release/bundle/), so cleaning never fights rust-analyzer or a running dev build over files in src-tauri/target/ — on Windows that lock makes cargo clean fail with os error 5. Bundle target is ["msi"] only.
After a successful build only target-release/release/ is kept: debug/, tmp/ and cargo's .fingerprint cache are deleted to reclaim disk, so the next release compiles the dependency tree again. Set RELEASE_KEEP_TARGET=1 to keep them (useful while iterating on the packaging steps).
yarn clean # dev output: cargo clean + remove dist/
yarn tauri buildyarn clean is the dev-side clean and needs src-tauri/target/ to be unlocked (close the app, or restart rust-analyzer, if Windows reports access denied). Releases never touch that directory.
gh auth login -h github.com
yarn run publishpublish wipes its own target dir (src-tauri/target-release) and only then builds, so a release bundle never inherits objects — icons above all — from an earlier build, and the clean cannot be blocked by locks in the dev target. Once the build succeeds, everything under that dir except release/ is pruned to reclaim disk. Set RELEASE_SKIP_CLEAN=1 to reuse the existing output while iterating on the packaging steps.
cd src-tauri
cargo test # run all Rust unit tests (#[cfg(test)] in local_fs, ai_term, …)
cargo run --bin ssh_test # standalone russh connectivity probe binary- Backend unit tests live inside each module (e.g.
local_fs.rs,ai_term.rs) using#[cfg(test)]+#[tokio::test], covering pure logic (path parsing, protocol (de)serialization, RemoteFs adapters, config validation, …). - Prefer adding unit tests for pure-logic features; for real network/process behaviour use a standalone binary such as
ssh_testas a smoke probe.
E2E is powered by Playwright, Phase 1: the Vite frontend (yarn dev) runs in a real browser, and the Tauri backend is stubbed by injecting a fake window.__TAURI_INTERNALS__ (see e2e/ui/helpers/tauriMock.ts) — no Rust build required to verify the UI.
# install Playwright browsers on first run
npx playwright install
# run all E2E
yarn test:e2e # equivalent to npx playwright test
# custom port (CI, or when 1420 is already taken locally)
E2E_PORT=1430 yarn test:e2e- Specs live in
e2e/ui/(e.g.terminal.spec.ts,connections.spec.ts,command-list.spec.ts,app-boot.spec.ts). - The backend stub lives in
e2e/ui/helpers/tauriMock.ts: it returns fixed/parameterised responses keyed by theinvokecommand name, so frontend behaviour can be verified without a real SSH server / file server.
- Frontend: React 19 + TypeScript + xterm.js + Monaco editor + Vite + SCSS
- Backend: Tauri 2 + Rust (tokio) + russh / russh-sftp
- SSH: pure-Rust
russhasync SSH client - IPC: Tauri
invokecommands + frontend polling for terminal output (Windows background-task workaround). Tauri events are used only for the few push notifications the UI cannot poll:connection-closed,connection-suspect,connection-ok,transfer-progress,tunnel-changed,ai-term-mark,native-drag-drop,baud-detect-progress. - Storage: everything lives under one relocatable data folder —
window.json(window geometry, opacity, recording and keepalive settings), an encryptedconnections.json(connections and workspaces),wrolp.db(SQLite, WAL: session index, command sets, snippets) andrecordings/(per-session NDJSON event files); encrypted secrets via an AES-256-GCM file vault (no OS keyring).
- Types shared with Rust use
#[serde(rename_all = "camelCase")]on the Rust side and matching camelCase interfaces insrc/types.ts. - Frontend formatting is enforced by Prettier (
.prettierrc: singleQuote, no semi, printWidth 100) — runyarn format. - Backend commands live in focused submodules under
src-tauri/src/commands/(e.g.serial.rs); a new command must also be registered in thegenerate_handler!list insrc-tauri/src/lib.rs, and its frontend wrapper goes insrc/commands.ts.
Released under the MIT License (Copyright (c) 2026 Wrolp). In short:
- You may freely use, copy, modify, merge, publish, distribute, sublicense and sell copies of this software, including for commercial purposes.
- The copyright notice and this license notice must be included in all copies or substantial portions of the software.
- The software is provided "as is", without warranty of any kind, express or implied; the authors or copyright holders are not liable for any claim, damages or other liability.
- russh — pure-Rust async SSH client
- Tauri — the app framework
- React — UI library
- xterm.js — terminal rendering
- Monaco Editor — code editor
- …and many other open-source libraries that make this project possible.