Status: experimental. The app builds on Windows 11 x64, and CI builds,
installs and launches it on every push (.github/workflows/windows.yml),
but nobody has used it day to day yet. Releases carry an NSIS installer
signed for the in-app updater (release.yml, build-windows), which is not
Authenticode-signed, so SmartScreen warns on the first install. Some features
are not available yet (see "Not on Windows yet"). What is
still to do, and the measurements that decide how, is in
ideas/windows.md.
It needs Git for Windows (the app needs Git anyway). The first run is from Git Bash, in the clone:
bash scripts/setup-windows.sh # or `make setup` once GNU make is installedAfter that make works from Git Bash, PowerShell or cmd alike: the Makefile
finds Git's own bash from git --exec-path and runs every recipe under it,
rather than trusting a bare bash, which outside Git Bash is System32's WSL
launcher.
It installs whatever is missing through winget and skips what is there:
the Visual Studio C++ build tools, WebView2, Rust (rustup), Node 22 and GNU
make, picking each one up in the same run (no new shell needed). Then it sets
git config --global core.longpaths true, runs npm install, seeds the e2e
fixture and runs a first cargo check. WITH_DOCKER=1 also installs Docker
Desktop, for the Docker sandbox (use the WSL2 backend, Linux containers).
It never elevates. Two machine-wide settings need an admin, so it checks them and prints the command instead:
- Windows long paths (recommended: worktrees with
node_modulesortargetpass 260 characters). Admin PowerShell, then reboot:New-ItemProperty HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem -Name LongPathsEnabled -Value 1 -PropertyType DWORD -Force - Developer Mode (optional). Without it the app links directories with
junctions and files with hard links instead of symlinks
(
src-tauri/src/fs_link.rs), which works for everything it links.
make doctor checks the result without installing anything. CI runs
make setup on every push, on a runner that already has everything.
Daily use is the same as on macOS:
| Command | On Windows |
|---|---|
make dev |
Vite + tauri dev. |
make check, make check-web, npm test, cargo test |
As on macOS. |
make build |
An NSIS installer in src-tauri/target/release/bundle/nsis/. No MSI: WiX depends on VBScript and is unreliable on Windows 11 24H2. |
make install |
Builds, installs per user (silent NSIS, no admin prompt) into %LOCALAPPDATA%\Termic, launches it. |
make beta |
The current branch as Termic Beta, installed next to the shipped app and sharing its data dir, exactly as on macOS. |
make uninstall |
Runs both apps' uninstallers silently. |
make reset, make reset_dev |
The Windows data locations (%LOCALAPPDATA%\termic, the WebView2 profile, window state). |
make cli-dev |
Copies (not links) the debug CLI to ~/.local/bin/termic-dev.exe. Re-run after rebuilding it. |
make e2e |
Runs; CI runs it on every push (reporting, not gating). Most specs pass; the open ones are in ideas/windows.md. The fake agent is a bash script started through scripts/fake-agent.cmd. |
make perf |
The CI section runs; the local section (idle CPU / GPU) is macOS-only and says so. |
make release and make icons are maintainer tooling and stay macOS.
Without the updater signing key, make build skips the updater artifacts
rather than failing (on every platform).
Each of these is a deliberate choice; the reasoning lives next to the code.
- Sandbox: Docker only. The macOS Seatbelt sandbox does not exist on
Windows, and is not offered. A Seatbelt mode stored on a task (a Mac
teammate's committed
.termic.yaml, a project default,--sandbox enforce) reads as Off everywhere, backend (Task::effective_sandbox_mode) and frontend (effectiveSandboxMode), so it can never spawn uncaged while the UI calls it caged. See sandbox.md. - Docker paths. The container is Linux, so a host path maps to
/c/Users/u/repoinside it (docker::in_container, mirrored bytoContainerPathinsrc/lib/osPath.tsfor pasted and dropped files). A worktree's.gitpointer file is rewritten to that form and mounted read-only over the host one.core.autocrlfis passed into the container, and automaticgit gcis off there, because the worktree metadata holds host paths. Containers run as1000:1000: Claude refuses its skip-permissions mode as root. "Docker is ready" means a daemon running Linux containers. - Shells.
.termic.yamlscripts, command tabs, and session-capture commands run under Git for Windows' ownbash.exe, found fromgit's location (shell_env::script_bash). A barebashon Windows is System32's WSL launcher. Plain terminal tabs openpwsh, then Windows PowerShell, thencmd. - PATH. No login-shell probe: PATH is read from the registry (machine,
then user) on every use, plus what the process inherited, so a tool
installed while Termic runs (Git, an agent CLI) is found without a
restart (
shell_env::windows_live_path). Every PATH walk splits with the platform separator and resolves PATHEXT (shell_env::which_in), skipping npm's extensionless shell shim that sits next to every.cmdone, and a bare agent command is resolved that way before the spawn. - Processes. No process groups: stopping a script, a language server or
an agent kills its process tree (
proc_ctl.rs, a ToolHelp snapshot with a creation-time check so a reused pid is never mistaken for a child). There is no SIGTERM for console programs: archiving a task types Ctrl+C into each agent's terminal and gives it the same grace period before the tree kill; a script's Stop is forceful. Every background command is spawned withCREATE_NO_WINDOW. - Terminals (ConPTY). Two things a unix PTY never does, both measured
by
src-tauri/examples/conpty_osc_probe.rs. The reader gets no EOF when the child exits by itself: ConPTY holds its output pipe open until the pseudoconsole is closed, so the PTY waiter closes it (drops the slot) after a short drain, orpty-exitwould never fire. And ConPTY announces the program's path as the window title before the program runs (isConsoleHostTitledrops it). Archiving stops every PTY of the task, its shell and command tabs included, before deleting its worktree, because Windows will not delete a directory that is a live process's working directory. The ConPTY is Microsoft's newerconpty.dll+OpenConsole.exe, shipped besidetermic.exe(src-tauri/resources/windows/conpty, placed bytauri.windows.conf.json), not the one built into Windows: that one does not forward a program's mouse reporting request, so the wheel scrolled xterm's scrollback instead of reaching TUIs like opencode (GH #437). portable-pty prefers aconpty.dllnext to the exe and falls back to the system one without it. The EOF and title behaviour above was measured on the system ConPTY.src-tauri/tests/conpty_mouse.rsgates the mouse request in the Windows CI job. - CLI control plane. Loopback TCP on an ephemeral port, whose address
is written into the
termic.sockpath (termic_proto::local). The per-boot token in the per-user data dir remains the credential. This is weaker than unix (no kernel peer-identity check): another local account can reach the unauthenticatedhello,raiseandopen_urlverbs.termic attachuses console VT mode, with the window size polled. termicon PATH. A copy of the sidecar, not a link (creating a symlink needs elevation or developer mode):bin\termic.exeunder the data dir for the user,%ProgramFiles%\Termic\bin\termic.exefor every user. Both directories are Termic's own, which is what lets a file found there be treated as ours. Never the install dir itself, wheretermicwould resolve toTermic.exe. "Add to PATH" appends the directory toHKCU\Environment\Path; the all-users install copies and appends to the machinePathbehind one UAC prompt. Both edits read the value unexpanded and write it back expandable, so an existing%USERPROFILE%\...entry stays a reference (cli_server::add_to_path_script). The user copy is refreshed at launch when an update changed the sidecar, by renaming the old exe aside (a running exe renames, it does not overwrite). The all-users copy cannot be refreshed without a prompt, so it stays at the version it was installed from until "Install system-wide" is clicked again, and the machinePathcomes before the user one.- MCP headers helper. Claude runs a server's
headersHelperthrough cmd.exe (measured on 2.1.291, with Git Bash installed), where the POSIXprintf ... "$(cat ...)"registered on unix prints no token. The request then goes out bare, the 401 sends claude into its OAuth probe, and the failure reads "Dynamic Client Registration rejected (HTTP 404)". Windows registers"<termic-cli.exe>" mcp-headers "<token file>"instead (mcp_server::helper_command). A registration made by an older build keeps the old helper until "Add to Claude" is clicked again. Which shell codex runs its helper in on Windows is unmeasured. - MCP port. Hyper-V (WSL, Docker) reserves blocks of 100 ports inside
the dynamic range 49152-65535 and reshuffles them on restart (
netsh int ipv4 show excludedportrange protocol=tcp). A port in a reserved block cannot be bound by anyone, the bind answers WSAEACCES with nothing listening, so an endpoint on 65510 served for days and then refused. An unset port is therefore picked from 23517-23616 (AUTO_PORTS), never OS-assigned, and a refused typed port is reported in Settings > MCP as reserved rather than as held by another process. - LAN sharing (peers.md). The first wildcard bind (the
peer listener, and the mDNS socket on 5353) raises the Defender
Firewall prompt: choose Private networks. On a network Windows marks
Public, other machines cannot reach this one whatever was chosen, and
Settings says so. Port 5353 is shared with the DNS Client service
through
SO_REUSEADDR, and interfaces come fromGetAdaptersAddresses. The listener port is picked from 23620-23719, below the Hyper-V reserved range (see MCP port, above). All of it type-checks for Windows; none of it has run on real Windows hardware yet. - Paths in the UI.
src/lib/osPath.tsholds the platform rules: segment-saferelUnder,baseName, standardfile:///C:/...URIs for the language servers (mirrored bylsp_path_to_uri), and quoting rather than backslash-escaping a dropped path. - Window. No native frame (
decorations(false)inbuild_profile_window): the app's own top bar is the title bar, as on macOS. It drags the window, a double click maximizes or restores it (read off the mousedown's click count, since the system's move loop swallows a dblclick), andWindowControlsdraws minimize, maximize / restore and close in Windows' look. The window keeps its shadow and resize edges.-webkit-app-regionis still applied only on macOS: WebView2 honours it (WKWebView ignores it), so a dialog's full-screen backdrop would become window caption on Windows. While a modal dialog is open its backdrop covers the caption buttons (Radix blocks and dismisses on outside clicks), so the window is closed from the dialog first. No Windows 11 snap-layout flyout on the maximize button yet. WebView2's browser keys (F5 and Ctrl+R reload, Ctrl+P prints) are switched off (disable_browser_accelerators). - Text. Grayscale antialiasing, not ClearType: every window is created
with
--disable-lcd-text(with_grayscale_text), because ClearType's colour fringes read as a shadow on a dark theme. A WebView2 browser argument has a cost worth knowing: every process sharing a WebView2 user-data folder shares one browser process, and a webview asking for different arguments than the running one is never created. Measured: a build with the flag, started beside an installed build without it, came up with no webview at all. Two builds that differ in their arguments cannot run side by side on one folder (WEBVIEW2_USER_DATA_FOLDERmoves one of them; the e2e suite sets it). - Keys. Ctrl stands in for Cmd, and so does the Windows key (the same
rule as Linux, see docs/shortcuts.md "What Cmd is on each platform").
Shortcut hints read
Ctrl+Alt+P. In a terminal, plain Ctrl+letter goes to the shell (Ctrl+P is readline's, not the file finder), and Ctrl+V pastes, as in every Windows terminal. With an image and no text on the clipboard, Ctrl+V saves the image and pastes its path, which the agent attaches (windowsPasteTextinsrc/lib/clipboardImage.ts). The agents bind their own image paste to Alt+V on Windows, because Ctrl+V never reaches them. Ctrl+Shift+F in a terminal opens that terminal's find, as in Windows Terminal, rather than find-in-files, which has the same keys there. Ctrl+Shift+W closes the tab from inside a terminal, for the same reason: close-tab is Ctrl+W, and in a terminal that is the shell's delete-previous-word. Outside a terminal Ctrl+W closes as before. The same holds on Linux (isTerminalCloseCombo,lib/terminalFind.ts). - Agent hooks. There is no PTY slave to write to, so each PTY gets a
named pipe the app serves (
hook_pipe.rs), exported asTERMIC_PTY; whatever a hook writes there joins that PTY's output. Git Bash cannot open a named pipe with>, so on Windows the generated scripts write through"$TERMIC_CLI" hook-emit "$TERMIC_PTY"(bound_emits). Claude only for now: its hooks run in Git Bash. An end-to-end test runs claude's real scripts on the Windows runner. - Activity monitor.
procmon_windows.rs: one ToolHelp snapshot is the pid table, and only the processes under one of our roots are opened. Memory is the private working set, the figure Task Manager's Memory column shows. A parent pid is kept only when the child is younger than the parent (the ruleproc_ctluses, for the same reason). WebView2's processes are the app's own descendants, so they are in the Termic row with no attribution step, except when another Termic on the same WebView2 data folder started first (a dev or e2e build beside the installed app): there is one browser process per folder, the later instance's renderers hang under the other app, and its Termic row says the webview is not attributable. There are no signals: Stop ends the row's process tree, and Pause / Resume suspend and resume all of it (NtSuspendProcess), counted so that one Resume undoes any number of Pauses. - Editor. A file whose line breaks are all CRLF is saved as CRLF.
- Language servers. The pinned downloads have Windows x64 and arm64
entries; a server in the checkout is looked up in
.venv\Scriptsand as npm's.cmd(lsp_local_exe). - Names. A task name never becomes a Windows reserved device name
(
con,nul,com1...), on every OS, so it checks out everywhere. - AltGr never fires a Ctrl+Alt shortcut.
- Closing the window quits, as Windows users expect. The macOS close-to-menu-bar behaviour is macOS-only.
release.yml builds the NSIS installer on windows-latest with the same
updater key as the Mac and Linux builds, and checks the signature against the
public key in tauri.conf.json (scripts/verify-updater-sig.mjs) before
uploading. The job is not in the release job's needs: release-windows
attaches the installer to the finished release, and latest.json gains a
windows-x86_64 entry only when that upload happened, so a Windows failure
costs Windows that version and nothing else. The recipe was rehearsed on the
Windows runner before the first release (signed build, signature check,
silent install, launch, uninstall). The updater installs in passive mode (progress bar, no
prompts); Windows cannot replace a running exe, so the app closes for the
install.
- Agent hooks for agents other than claude. Claude's hooks work (they run in Git Bash). Which shell codex, gemini and the rest run hooks in on Windows is unmeasured, so theirs are not offered yet.
- CLI auto-launch. With the app closed,
termicsays so instead of starting it (termic-cli/src/client.rslaunches on macOS only). - PDF preview (needs a CSP change) and code signing (updates work; the installer is not Authenticode-signed, so SmartScreen warns on the first install).