In short. Developing on Windows, the terminal is always in the way. ls a directory with Korean file names in it and the columns go out of line. Start a full-screen tool such as lazygit or btop and the borders step sideways. You end up with four windows and alt+tab. To use tmux you have to go inside WSL. That leaves the PowerShell and .NET builds you need outside tmux.
I settled all of this with WezTerm. One configuration file (.wezterm.lua) in your home folder is the whole setup. That file behaves identically on Windows, macOS and Linux. Below is the configuration I actually use, broken into pieces. Copy them in order and you will have my environment.
Installing and the configuration file
On Windows, download the installer from the WezTerm releases page or use winget.
winget install wez.wezterm
Create the configuration file in your home folder as .wezterm.lua. That is usually C:\Users\<name>\.wezterm.lua. The beginning and end of the file always look like this.
local wezterm = require("wezterm")
local act = wezterm.action
local config = wezterm.config_builder()
-- configuration goes in here
return config
It takes effect the moment you save. There is no need to restart the terminal. Edit the config and save, and the colours, font and keys of the open window change. That alone makes tinkering with the configuration feel different.
Korean works without a single line of configuration
These two lines are the only font settings in my config. There is no Korean font specified anywhere.
config.font = wezterm.font("JetBrains Mono", { weight = "Light" })
config.font_size = 13
JetBrains Mono has no Korean glyphs. Korean still renders fine. WezTerm walks the font list per character. When a glyph is missing it falls back to a system font. You can check it yourself.
wezterm ls-fonts --text "한글 테스트 ABC"
This is the output on my machine.
한 cells=2 x_adv=17 Malgun Gothic C:\WINDOWS\FONTS\MALGUN.TTF
글 cells=2 x_adv=17 Malgun Gothic C:\WINDOWS\FONTS\MALGUN.TTF
A cells=1 x_adv=10 JetBrains Mono
The number that matters is cells. Korean counts as two columns, Latin as one. Get that wrong and table borders shift and progress bars bend. The cursor no longer sits where the text does. Most of what CJK users suffer in a terminal comes from that single number. WezTerm computes it according to the character width standard.
Korean input needs no switch either. Change input mode and type: the syllable being composed appears right at the cursor. It never shows up somewhere else or disappears before it is finished.
You only have to set the shell's character set to UTF-8. I use Git Bash and give it these environment variables.
LANG=ko_KR.UTF-8
LC_ALL=ko_KR.UTF-8
The main point: tmux behaviour without tmux
People use tmux mostly for one thing: splitting the screen to watch several things at once. WezTerm has that in the terminal itself. There is no tmux to install.
First pick the prefix key, the equivalent of tmux's Ctrl+B. Mine is Ctrl+U.
config.leader = { key = "u", mods = "CTRL", timeout_milliseconds = 2000 }
Now press Ctrl+U, let go, and press a letter within two seconds. This is my layout.
Splitting
Ctrl+Uthenw: split left/right (Width)Ctrl+Uthenv: split top/bottom (Vertical)
Moving between panes
Ctrl+Uthenh: to the left paneCtrl+Uthenj: to the pane belowCtrl+Uthenk: to the pane aboveCtrl+Uthenl: to the right pane
Resizing
Ctrl+Uthen an arrow key : grow by five cells in that direction
Tabs
Ctrl+Uthenc: new tab (Create)Ctrl+Uthenn: next tab,b: previous tabCtrl+Uthen0-9: straight to that tab
Other
Ctrl+Uthenu: new windowCtrl+Uthend: detach this pane into its own window (Detach)Ctrl+Uthenx: close this pane (it asks first)
The movement keys are h/j/k/l to match vim, so your hands stay put. Splits are w/v. Remembering left/right as Width and top/bottom as Vertical keeps them straight.
The configuration looks like this. It is long but regular, so copy the block and change the letters.
local keys = {}
local function leader(key, action)
table.insert(keys, { mods = "LEADER", key = key, action = action })
end
leader("w", act.SplitHorizontal({ domain = "CurrentPaneDomain" }))
leader("v", act.SplitVertical({ domain = "CurrentPaneDomain" }))
leader("h", act.ActivatePaneDirection("Left"))
leader("j", act.ActivatePaneDirection("Down"))
leader("k", act.ActivatePaneDirection("Up"))
leader("l", act.ActivatePaneDirection("Right"))
leader("LeftArrow", act.AdjustPaneSize({ "Left", 5 }))
leader("RightArrow", act.AdjustPaneSize({ "Right", 5 }))
leader("UpArrow", act.AdjustPaneSize({ "Up", 5 }))
leader("DownArrow", act.AdjustPaneSize({ "Down", 5 }))
leader("c", act.SpawnTab("CurrentPaneDomain"))
leader("u", act.SpawnWindow)
leader("n", act.ActivateTabRelative(1))
leader("b", act.ActivateTabRelative(-1))
leader("x", act.CloseCurrentPane({ confirm = true }))
-- detach the current pane into its own window
leader("d", wezterm.action_callback(function(_, pane)
pane:move_to_new_window()
end))
-- Ctrl+U then a digit jumps to that tab
for i = 0, 9 do
table.insert(keys, { mods = "LEADER", key = tostring(i), action = act.ActivateTab(i) })
end
config.keys = keys
To number tabs from zero, add the first line below. The leftmost key in the number row becomes the first tab, which is easier on the hands. I also moved the tab bar to the bottom.
config.tab_and_split_indices_are_zero_based = true
config.tab_bar_at_bottom = true
config.use_fancy_tab_bar = false
The screen tells you whether the prefix is armed
There is a common annoyance with tmux. You do not know whether you pressed Ctrl+B, so a stray character lands in the terminal. I made a wave appear in the status bar while the prefix is live.
wezterm.on("update-status", function(window, _)
local prefix = ""
if window:leader_is_active() then
prefix = " " .. utf8.char(0x1f30a) -- wave emoji
end
window:set_left_status(wezterm.format({ { Text = prefix } }))
end)
Press Ctrl+U and a wave appears at the bottom left. Press the next key only while you can see it. It is a small device that cuts typos sharply.
Where it is clearly better than tmux
Each pane can run a different shell. Git Bash on the left, PowerShell at the top right, WSL Ubuntu at the bottom right. tmux is confined to the Linux world it runs inside. WezTerm's splitting is a terminal feature. Windows programs and Linux shells can share one window. For anyone developing on Windows that difference is decisive.
Copy and paste work with the OS clipboard directly. There is no copy mode to enter, no range to mark, no separate clipboard bridge to bolt on. Drag with the mouse and it is on the clipboard.
config.selection_word_boundary = " \t\n{}[]()\"'`"
config.mouse_bindings = {
-- left click selects only (so links are not opened by accident)
{
event = { Up = { streak = 1, button = "Left" } },
mods = "NONE",
action = act.CompleteSelection("ClipboardAndPrimarySelection"),
},
-- open links with Ctrl+click
{
event = { Up = { streak = 1, button = "Left" } },
mods = "CTRL",
action = act.OpenLinkAtMouseCursor,
},
}
For pasting I left both the terminal convention (Ctrl+Shift+V) and the general one (Ctrl+V) open. Either habit works. These entries go into the keys table built earlier.
table.insert(keys, { key = "c", mods = "CTRL|SHIFT", action = act.CopyTo("Clipboard") })
table.insert(keys, { key = "v", mods = "CTRL|SHIFT", action = act.PasteFrom("Clipboard") })
table.insert(keys, { key = "v", mods = "CTRL", action = act.PasteFrom("Clipboard") })
table.insert(keys, { key = "Insert", mods = "SHIFT", action = act.PasteFrom("Clipboard") })
Scrolling is just scrolling. Turn the wheel and it scrolls. I keep thirty thousand lines.
config.scrollback_lines = 30000
config.enable_scroll_bar = true
When a long build log has made the window heavy, I clear the scrollback.
table.insert(keys, {
key = "k", mods = "CTRL|SHIFT",
action = act.ClearScrollback("ScrollbackAndViewport"),
})
I also made Ctrl plus the mouse wheel change the font size. Being able to write that behaviour in the config file says a lot about WezTerm's character. The configuration is Lua, a programming language. You write behaviour rather than fill in prepared values.
local min_font_size, max_font_size = 8, 24
local function zoom(delta)
return wezterm.action_callback(function(window)
local overrides = window:get_config_overrides() or {}
local size = overrides.font_size or config.font_size
overrides.font_size = math.max(min_font_size, math.min(size + delta, max_font_size))
window:set_config_overrides(overrides)
end)
end
-- these go inside the mouse_bindings table above
{ event = { Down = { streak = 1, button = { WheelUp = 1 } } }, mods = "CTRL", action = zoom(1) },
{ event = { Down = { streak = 1, button = { WheelDown = 1 } } }, mods = "CTRL", action = zoom(-1) },
Picking one of six shells from a menu
Alt+L brings up a shell menu, and the one you pick opens in a new tab. I have six registered.
config.default_prog = { "C:\Program Files\Git\bin\bash.exe" }
config.launch_menu = {
{ label = "Git Bash", args = { "C:\Program Files\Git\bin\bash.exe" } },
{ label = "Clean Git Bash", args = { "C:\Program Files\Git\bin\bash.exe", "--noprofile", "--norc", "-i" } },
{ label = "PowerShell", args = { "powershell.exe" } },
{ label = "Command Prompt", args = { "cmd.exe" } },
{ label = "WSL Ubuntu", args = { "wsl.exe", "-d", "Ubuntu" } },
}
table.insert(keys, { key = "l", mods = "ALT", action = act.ShowLauncher })
The second entry, Clean Git Bash, is worth a look. --noprofile --norc means start the shell skipping every startup script. When something behaves oddly, it answers "is this my .bashrc?" in thirty seconds. You write the command and its arguments as an array, so you can keep as many variants as you like. I have one that starts with every environment variable cleared, too.
I also changed the new-tab button (+) so that it shows this menu instead of opening an empty tab.
wezterm.on("new-tab-button-click", function(window, pane, button, default_action)
window:perform_action(act.ShowLauncher, pane)
return false -- cancel the default action (creating an empty tab)
end)
What I use most: driving the terminal from the command line
WezTerm has a wezterm cli command that manipulates the open windows, tabs and panes from outside.
wezterm cli list list the open windows, tabs and panes
wezterm cli split-pane --right split to the right and print the new pane id
wezterm cli send-text --pane-id 3 "make build" type text into pane 3
wezterm cli get-text --pane-id 3 read what is visible in pane 3
wezterm cli zoom-pane --pane-id 3 zoom that pane
wezterm cli kill-pane --pane-id 3 close that pane
This matters because the terminal becomes something a script can drive. split-pane prints the id of the pane it created, so you can capture it in a variable and keep going.
ID=$(wezterm cli split-pane --right)
wezterm cli send-text --pane-id "$ID" --no-paste "make build"
wezterm cli send-text --pane-id "$ID" --no-paste $'\r'
# a moment later
wezterm cli get-text --pane-id "$ID" | tail -20
Two pieces of practical knowledge here. First, you need --no-paste. Without it the text arrives as a paste. Modern shells do not run pasted content immediately; they leave it sitting on the input line.
Second, send Enter separately from the command. Sending CR ($'\r') as its own call is more reliable than appending a newline to the command string. Not knowing these two can leave you stuck on "the text went in but nothing ran".
The reading side has an option too. get-text gives you what is currently on screen by default. A negative --start-line reaches back into the scrollback. I use it when I need a whole build log.
wezterm cli get-text --pane-id "$ID" --start-line -2000
I use this to run several AI agents on one screen. Open a new pane and start another agent in it. Send instructions with send-text and read the reply with get-text. The next instruction is written from that.
I set two AIs to argue over a design. Or I split the roles so one writes code and the other only verifies it. The human's job is to watch from the side.
Terminals that allow this are not common. Plenty of them split the screen. Few open a channel that lets a program create the split from outside. Fewer still let it type in and read the result back.
There is a way out when it freezes
The terminal draws text on the GPU, which makes it very fast. In exchange, a bad graphics driver match can freeze the window. WezTerm lets you choose the rendering back end.
config.front_end = "WebGpu" -- this is my default
config.webgpu_power_preference = "HighPerformance"
-- if it freezes, switch to "OpenGL", and to "Software" if that still fails
Software draws on the CPU, so it is a little slower but comes up in any environment. Knowing there is a guaranteed escape one config line away is a real comfort in practice. You can tune the refresh load as well.
config.max_fps = 120
config.animation_fps = 60
config.status_update_interval = 1000
I also turned off the confirmation when closing a window, since I open and close them constantly.
config.exit_behavior = "Close"
config.window_close_confirmation = "NeverPrompt"
Where tmux is still better
To be fair about it: one of tmux's core features is that the session stays alive. Connect to a remote server, start something and drop the connection. It is still running when you attach again. WezTerm's screen splitting does not have that property. Close the window and the work inside it ends.
So for starting a job that takes hours on a server over SSH, tmux on that server is the right tool. And it does not conflict with WezTerm: connect to the server with WezTerm and use tmux inside it. The point of this post is not to abandon tmux. You no longer need to install tmux and go into WSL just to split the screen on your own PC.
Wrapping up
In short:
- Korean renders with the right width calculation. No Korean font in the config; the system font fills in
- Making
Ctrl+Uthe prefix reproduces tmux's splitting, movement, resizing and tabs almost exactly - Panes can mix Git Bash, PowerShell and WSL
- The clipboard, the mouse and scrolling behave the way you expect
wezterm clilets a script drive the terminal itself- The config is Lua, so you write behaviour directly, and it applies the moment you save
- The same single file behaves identically on Windows, macOS and Linux
That last line matters more than it looks. I keep .wezterm.lua in a dotfiles repository. Setting up a new PC means installing with winget and copying that one file into my home folder. Years of hand-tuned key layout, colours and shell list come back in five minutes.
Program: WezTerm (wezterm.org)
Configuration file: a single .wezterm.lua in your home folder
