Files
wezterm-config/wezterm.lua
gm 2de7e3a2bb Spawn SSH directly on startup instead of a local shell first
The old update-right-status auto-attach flashed a local cmd.exe tab
first and only attached the SSH domain after a delay once the mux
had registered it, so startup never actually connected immediately.
Now gui-startup spawns the first configured SSH domain's `ssh ...`
command directly as the window's first program, so there's no local
shell tab and no attach-timing race to work around.
2026-07-25 19:10:37 +09:00

1041 lines
38 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
local wezterm = require("wezterm")
local config = wezterm.config_builder() -- 구버전 wezterm이면 config = {} 로 바꾸세요
local act = wezterm.action
-- ============================================================
-- 폰트 & 테마
-- ============================================================
config.font = wezterm.font_with_fallback({
"JetBrains Mono", -- 영어/기본 폰트
"Noto Sans KR", -- 한글 폰트
})
config.font_size = 9
config.line_height = 1.1
config.color_scheme = "Catppuccin Mocha" -- wezterm show-config 나 https://wezterm.org/colorschemes/ 에서 이름 확인
-- 색상표를 직접 지정하고 싶으면 color_scheme 대신 아래처럼:
-- config.colors = {
-- foreground = "#c0caf5",
-- background = "#1a1b26",
-- cursor_bg = "#c0caf5",
-- cursor_border = "#c0caf5",
-- }
config.window_background_opacity = 0.92 -- 1.0이면 완전 불투명
config.macos_window_background_blur = 20 -- macOS 전용, 배경 블러
config.text_background_opacity = 1.0
-- ============================================================
-- 창 & 탭바 레이아웃
-- ============================================================
config.window_padding = {
left = 8,
right = 8,
top = 8,
bottom = 8,
}
config.initial_cols = 140
config.initial_rows = 40
config.use_fancy_tab_bar = true -- OS 스타일 탭바: 탭에 마우스 올리면 닫기(x) 버튼이 보이고, 탭을 밖으로 드래그하면 새 창으로 분리됨
config.tab_bar_at_bottom = false -- 탭바/상태바를 터미널 상단에 표시
config.hide_tab_bar_if_only_one_tab = false -- 탭이 하나뿐이어도 탭바를 항상 표시
config.show_new_tab_button_in_tab_bar = true
config.window_decorations = "INTEGRATED_BUTTONS | RESIZE" -- OS 타이틀바 대신 탭바에 최소화/최대화/닫기 버튼을 통합 표시
-- ------------------------------------------------------------
-- [MobaXterm 스타일] 호스트(도메인)별 탭 색상 자동 구분
-- 같은 SSH 호스트/도메인에서 연 탭은 항상 같은 색으로 표시됨
-- ------------------------------------------------------------
local host_colors = {
"#f38ba8", "#fab387", "#f9e2af", "#a6e3a1",
"#94e2d5", "#89b4fa", "#cba6f7", "#f5c2e7",
}
local function color_for_host(name)
local hash = 0
for i = 1, #name do
hash = (hash * 31 + string.byte(name, i)) % 100000
end
return host_colors[(hash % #host_colors) + 1]
end
-- ------------------------------------------------------------
-- [1] 탭 제목 커스터마이징
-- 탭에 "인덱스: 실행중인 프로세스명 [호스트]" 형식으로 표시
-- local(로컬 셸)이면 회색조, SSH 도메인이면 호스트별 고유 색
-- ------------------------------------------------------------
wezterm.on("format-tab-title", function(tab, tabs, panes, config, hover, max_width)
local process = tab.active_pane.foreground_process_name or ""
local name = process:match("([^/\\]+)$") or process
if name == "" then
name = tab.active_pane.title
end
local domain = tab.active_pane.domain_name or "local"
local title = " " .. name .. " "
local accent = domain == "local" and "#6c7086" or color_for_host(domain)
if tab.is_active then
return {
{ Background = { Color = accent } },
{ Foreground = { Color = "#1e1e2e" } },
{ Text = title },
}
end
return {
{ Foreground = { Color = accent } },
{ Text = title },
}
end)
-- ------------------------------------------------------------
-- [MobaXterm 스타일] 우측 상태바: 접속 호스트 + 워크스페이스 + 접속 경과시간 + 날짜/시간
-- MobaXterm 하단 상태바처럼 "지금 어디에 붙어있는지"를 한눈에 표시
-- ------------------------------------------------------------
local pane_start_times = {}
wezterm.on("update-right-status", function(window, pane)
local pane_id = pane:pane_id()
if not pane_start_times[pane_id] then
pane_start_times[pane_id] = os.time()
end
local elapsed = os.time() - pane_start_times[pane_id]
local elapsed_str =
string.format("%02d:%02d:%02d", elapsed // 3600, (elapsed % 3600) // 60, elapsed % 60)
local domain = pane:get_domain_name() or "local"
local workspace = window:active_workspace()
local date = wezterm.strftime("%Y-%m-%d %H:%M:%S")
window:set_right_status(wezterm.format({
{ Foreground = { Color = color_for_host(domain) } },
{ Text = " " .. domain .. " " },
{ Foreground = { Color = "#89b4fa" } },
{ Text = "| " .. workspace .. " " },
{ Foreground = { Color = "#f9e2af" } },
{ Text = "| " .. elapsed_str .. " " },
{ Foreground = { Color = "#cdd6f4" } },
{ Text = "| " .. date .. " " },
}))
end)
-- ============================================================
-- [2] 세션 / 멀티플렉싱
-- ============================================================
-- SSH 도메인: 자주 접속하는 원격 서버를 wezterm이 네이티브로 관리
-- 실제 서버 정보가 준비되면 아래 예시를 복사해서 채우세요.
config.ssh_domains = {
{
name = "du5t",
remote_address = "172.30.1.41:228",
username = "gm",
multiplexing = "None",
},
}
-- Unix domain (mux server): wezterm을 서버로 띄워두면 터미널 껐다 켜도 세션 유지
-- 사용법: 먼저 `wezterm-mux-server --daemonize` 실행 후, 클라이언트에서 아래 도메인에 접속
config.unix_domains = {
{ name = "unix" },
}
local is_windows = wezterm.target_triple:find("windows") ~= nil
local config_file = wezterm.config_file or (is_windows and (wezterm.home_dir .. "\\.wezterm.lua") or "/mnt/storage/wezterm.lua")
local function notify(window, message, duration_ms)
if window.toast_notification then
window:toast_notification("WezTerm", message, nil, duration_ms or 4000)
else
wezterm.log_info(message)
end
end
local function read_config_text()
local file = io.open(config_file, "r")
if not file then
return nil, "설정 파일을 열 수 없습니다: " .. config_file
end
local text = file:read("*a")
file:close()
return text
end
local function write_config_text(text)
local file = io.open(config_file, "w")
if not file then
return false, "설정 파일에 쓸 수 없습니다: " .. config_file
end
file:write(text)
file:close()
return true
end
local function set_config_value(window, pane, key, value)
local text, err = read_config_text()
if not text then
notify(window, err)
return
end
local pattern = "(config%." .. key .. "%s*=%s*)[^\n]+"
local updated, count = text:gsub(pattern, function(prefix)
return prefix .. value
end, 1)
if count == 0 then
notify(window, "설정 항목을 찾지 못했습니다: config." .. key)
return
end
local ok, write_err = write_config_text(updated)
if not ok then
notify(window, write_err)
return
end
notify(window, "저장됨: config." .. key .. " = " .. value)
window:perform_action(act.ReloadConfiguration, pane)
end
local function prompt_config_value(window, pane, title, key, initial_value, quote_string)
window:perform_action(
act.PromptInputLine({
description = title .. " (현재: " .. tostring(initial_value) .. ")",
action = wezterm.action_callback(function(cb_window, cb_pane, line)
if not line or line == "" then
return
end
local value = quote_string and string.format("%q", line) or line
set_config_value(cb_window, cb_pane, key, value)
end),
}),
pane
)
end
-- config.font = wezterm.font_with_fallback({ "영어 폰트", "한글 폰트" }) 블록에서
-- 현재 지정된 두 폰트 이름을 읽어온다 (설정 파일 텍스트 기준, 실행 중인 config 객체가 아님)
local function get_current_fonts()
local text = read_config_text()
if not text then
return nil, nil
end
local block = text:match("config%.font = wezterm%.font_with_fallback%(%{(.-)\n%}%)")
if not block then
return nil, nil
end
local fonts = {}
for f in block:gmatch('"([^"]*)"') do
table.insert(fonts, f)
end
return fonts[1], fonts[2]
end
local function set_fonts(window, pane, primary, korean)
local text, err = read_config_text()
if not text then
notify(window, err)
return
end
local new_block = string.format('\n\t%q, -- 영어/기본 폰트\n\t%q, -- 한글 폰트\n', primary, korean)
local safe_block = new_block:gsub("%%", "%%%%")
local updated, count = text:gsub("(config%.font = wezterm%.font_with_fallback%(%{).-\n(%}%))", "%1" .. safe_block .. "%2", 1)
if count == 0 then
notify(window, "config.font 설정을 찾지 못했습니다")
return
end
local ok, write_err = write_config_text(updated)
if not ok then
notify(window, write_err)
return
end
notify(window, "폰트 저장됨: 영어=" .. primary .. ", 한글=" .. korean)
window:perform_action(act.ReloadConfiguration, pane)
end
local function prompt_font(window, pane, which)
local primary, korean = get_current_fonts()
primary = primary or "JetBrains Mono"
korean = korean or "Noto Sans Mono CJK KR"
local current = which == "primary" and primary or korean
local title = (which == "primary" and "영어/기본 폰트" or "한글 폰트") .. " (현재: " .. current .. ")"
window:perform_action(
act.PromptInputLine({
description = title,
action = wezterm.action_callback(function(cb_window, cb_pane, line)
if not line or line == "" then
return
end
if which == "primary" then
set_fonts(cb_window, cb_pane, line, korean)
else
set_fonts(cb_window, cb_pane, primary, line)
end
end),
}),
pane
)
end
-- `wezterm ls-fonts --list-system`는 동기(블로킹) 호출이라 매번 실행하면 메뉴 전환 사이에
-- 밑에 있던 셸이 잠깐 보이는 깜빡임이 생긴다. 한 번 조회한 결과를 캐싱해 재사용한다
local system_fonts_cache = nil
local function list_system_fonts()
if system_fonts_cache then
return system_fonts_cache
end
local exe = is_windows and (wezterm.executable_dir .. "\\wezterm.exe") or (wezterm.executable_dir .. "/wezterm")
local ok, stdout = wezterm.run_child_process({ exe, "ls-fonts", "--list-system" })
if not ok or not stdout then
return nil
end
-- 실제 출력은 `wezterm.font("이름", {weight=...}) -- (AKA: ...) 경로, DirectWrite` 형태의
-- 코드 스니펫이라, 그 안의 family 이름만 뽑아낸다. 이 패턴에 안 맞는 줄은 그대로 버린다
-- (예전에는 안 맞는 줄을 통째로 "이름"으로 취급해서, 그 스니펫 텍스트 안에 있던 "})"가
-- config.font 블록 편집 시 조기 종료를 일으켜 설정 파일이 깨지는 사고가 있었다)
local seen = {}
local fonts = {}
for line in stdout:gmatch("[^\r\n]+") do
local name = line:match('wezterm%.font%("([^"]+)"') or line:match('^"([^"]+)"')
if name and name ~= "" and not seen[name] then
seen[name] = true
table.insert(fonts, name)
end
end
table.sort(fonts)
system_fonts_cache = fonts
return fonts
end
local function show_font_picker(window, pane, which)
local primary, korean = get_current_fonts()
primary = primary or "JetBrains Mono"
korean = korean or "Noto Sans Mono CJK KR"
local fonts = list_system_fonts()
if not fonts or #fonts == 0 then
notify(window, "설치된 폰트 목록을 가져오지 못했습니다. 직접 입력으로 진행합니다")
prompt_font(window, pane, which)
return
end
local choices = { { label = "⌨️ 직접 입력...", id = "__manual__" } }
for _, name in ipairs(fonts) do
table.insert(choices, { label = name, id = name })
end
local current = which == "primary" and primary or korean
window:perform_action(
act.InputSelector({
title = (which == "primary" and "영어/기본 폰트" or "한글 폰트") .. " 선택 (현재: " .. current .. ")",
fuzzy = true,
choices = choices,
action = wezterm.action_callback(function(inner_window, inner_pane, id)
if not id then
return
end
if id == "__manual__" then
prompt_font(inner_window, inner_pane, which)
return
end
if which == "primary" then
set_fonts(inner_window, inner_pane, id, korean)
else
set_fonts(inner_window, inner_pane, primary, id)
end
end),
}),
pane
)
end
local function show_font_settings(window, pane)
window:perform_action(
act.InputSelector({
title = "폰트 설정",
fuzzy = true,
choices = {
{ label = "📏 폰트 크기", id = "font_size" },
{ label = "🔤 영어 폰트", id = "font_primary" },
{ label = "🇰🇷 한글 폰트", id = "font_korean" },
{ label = "📐 줄 높이", id = "line_height" },
{ label = "🎨 색상 테마", id = "color_scheme" },
{ label = "🪟 창 투명도", id = "opacity" },
},
action = wezterm.action_callback(function(inner_window, inner_pane, id)
if id == "font_size" then
prompt_config_value(inner_window, inner_pane, "폰트 크기", "font_size", config.font_size, false)
elseif id == "font_primary" then
show_font_picker(inner_window, inner_pane, "primary")
elseif id == "font_korean" then
show_font_picker(inner_window, inner_pane, "korean")
elseif id == "line_height" then
prompt_config_value(inner_window, inner_pane, "줄 높이", "line_height", config.line_height, false)
elseif id == "color_scheme" then
prompt_config_value(inner_window, inner_pane, "색상 테마", "color_scheme", config.color_scheme, true)
elseif id == "opacity" then
prompt_config_value(inner_window, inner_pane, "창 투명도", "window_background_opacity", config.window_background_opacity, false)
end
end),
}),
pane
)
end
local function escape_pattern(s)
return (s:gsub("[%^%$%(%)%.%[%]%*%+%-%?%%]", "%%%1"))
end
-- "host:port" 형태의 remote_address를 host, port로 분리 (port 없으면 port는 nil)
local function split_remote_address(remote_address)
local host, port = remote_address:match("^(.-):(%d+)$")
if host then
return host, port
end
return remote_address, nil
end
local function append_ssh_domain(window, pane, name, remote_address, username)
local text, err = read_config_text()
if not text then
notify(window, err)
return
end
local entry = string.format(
'\t{\n\t\tname = %q,\n\t\tremote_address = %q,\n\t\tusername = %q,\n\t\tmultiplexing = "None",\n\t},\n',
name,
remote_address,
username
)
local updated, count = text:gsub("\n}%s*\n\n%-%- Unix domain", "\n" .. entry .. "}\n\n-- Unix domain", 1)
if count == 0 then
notify(window, "config.ssh_domains 끝 위치를 찾지 못했습니다")
return
end
local ok, write_err = write_config_text(updated)
if not ok then
notify(window, write_err)
return
end
notify(
window,
"저장됨: " .. name .. " -> " .. remote_address .. "\n"
.. "⚠ 지금 바로 연결하면 실패합니다. 작업 관리자에서 wezterm-gui.exe를 완전히 종료 후 재시작하세요.",
10000
)
window:perform_action(act.ReloadConfiguration, pane)
end
local function update_ssh_domain(window, pane, old_name, name, remote_address, username)
local text, err = read_config_text()
if not text then
notify(window, err)
return
end
local quoted_old_name = escape_pattern(string.format("%q", old_name))
local pattern = "\t{\n\t\tname = " .. quoted_old_name .. ",\n.-\n\t},\n"
local entry = string.format(
'\t{\n\t\tname = %q,\n\t\tremote_address = %q,\n\t\tusername = %q,\n\t\tmultiplexing = "None",\n\t},\n',
name,
remote_address,
username
)
local safe_entry = entry:gsub("%%", "%%%%")
local updated, count = text:gsub(pattern, safe_entry, 1)
if count == 0 then
notify(window, "SSH 도메인 항목을 찾지 못했습니다: " .. old_name)
return
end
local ok, write_err = write_config_text(updated)
if not ok then
notify(window, write_err)
return
end
notify(
window,
"수정됨: " .. name .. " -> " .. remote_address .. "\n"
.. "⚠ 지금 바로 연결하면 실패합니다. 작업 관리자에서 wezterm-gui.exe를 완전히 종료 후 재시작하세요.",
10000
)
window:perform_action(act.ReloadConfiguration, pane)
end
-- SSH 도메인 등록을 이름 -> 주소 -> 포트 -> 사용자명 순서로 한 칸씩 물어봄
-- (WezTerm에는 입력칸을 여러 개 한 화면에 배치하는 폼 위젯이 없어, 한 줄짜리
-- PromptInputLine을 단계별로 이어서 띄우는 방식으로 흉내낸다)
local ssh_domain_steps = {
{ field = "name", title = "SSH 이름 (1/4, 예: prod)" },
{ field = "host", title = "SSH 주소 (2/4, 예: myserver.example.com)" },
{ field = "port", title = "SSH 포트 (3/4, 비워두면 기본값 22)", optional = true },
{ field = "username", title = "SSH 사용자명 (4/4, 예: ubuntu)" },
}
local function prompt_ssh_domain_step(window, pane, step_index, data)
local step = ssh_domain_steps[step_index]
window:perform_action(
act.PromptInputLine({
description = step.title,
action = wezterm.action_callback(function(cb_window, cb_pane, line)
if (not line or line == "") and not step.optional then
notify(cb_window, "SSH 도메인 추가를 취소했습니다")
return
end
data[step.field] = line or ""
if step_index < #ssh_domain_steps then
prompt_ssh_domain_step(cb_window, cb_pane, step_index + 1, data)
else
local remote_address = data.host
if data.port ~= "" then
remote_address = remote_address .. ":" .. data.port
end
append_ssh_domain(cb_window, cb_pane, data.name, remote_address, data.username)
end
end),
}),
pane
)
end
local function prompt_ssh_domain(window, pane)
prompt_ssh_domain_step(window, pane, 1, {})
end
-- SSH 도메인 수정: 기존 값은 비워두면 그대로 유지, 입력하면 해당 항목만 교체
local edit_ssh_domain_steps = {
{ field = "name", label = "SSH 이름" },
{ field = "host", label = "SSH 주소" },
{ field = "port", label = "SSH 포트" },
{ field = "username", label = "SSH 사용자명" },
}
local function prompt_edit_ssh_domain_step(window, pane, step_index, data)
local step = edit_ssh_domain_steps[step_index]
local current_display = data[step.field]
if step.field == "port" and current_display == "" then
current_display = "기본값 22"
end
window:perform_action(
act.PromptInputLine({
description = string.format(
"%s (%d/%d, 현재: %s, 비워두면 유지)",
step.label,
step_index,
#edit_ssh_domain_steps,
current_display
),
action = wezterm.action_callback(function(cb_window, cb_pane, line)
if line and line ~= "" then
data[step.field] = line
end
if step_index < #edit_ssh_domain_steps then
prompt_edit_ssh_domain_step(cb_window, cb_pane, step_index + 1, data)
else
local remote_address = data.host
if data.port ~= "" then
remote_address = remote_address .. ":" .. data.port
end
update_ssh_domain(cb_window, cb_pane, data.original_name, data.name, remote_address, data.username)
end
end),
}),
pane
)
end
local function prompt_edit_ssh_domain(window, pane, domain)
local host, port = split_remote_address(domain.remote_address)
prompt_edit_ssh_domain_step(window, pane, 1, {
original_name = domain.name,
name = domain.name,
host = host,
port = port or "",
username = domain.username,
})
end
local function show_edit_ssh_domain_list(window, pane)
local choices = {}
for _, domain in ipairs(config.ssh_domains or {}) do
table.insert(choices, {
label = "🖥️ " .. domain.name .. " (" .. domain.remote_address .. ")",
id = domain.name,
})
end
if #choices == 0 then
notify(window, "등록된 SSH 도메인이 없습니다")
return
end
window:perform_action(
act.InputSelector({
title = "SSH 도메인 수정",
fuzzy = true,
choices = choices,
action = wezterm.action_callback(function(inner_window, inner_pane, id)
if not id then
return
end
for _, domain in ipairs(config.ssh_domains or {}) do
if domain.name == id then
prompt_edit_ssh_domain(inner_window, inner_pane, domain)
return
end
end
end),
}),
pane
)
end
local function show_connection_settings(window, pane)
window:perform_action(
act.InputSelector({
title = "연결 설정",
fuzzy = true,
choices = {
{ label = " SSH 도메인 추가", id = "add_ssh" },
{ label = "✏️ SSH 도메인 수정", id = "edit_ssh" },
{ label = "📋 SSH 도메인 목록", id = "domains" },
{ label = "🚀 통합 런처", id = "launcher" },
},
action = wezterm.action_callback(function(inner_window, inner_pane, id)
if id == "add_ssh" then
prompt_ssh_domain(inner_window, inner_pane)
elseif id == "edit_ssh" then
show_edit_ssh_domain_list(inner_window, inner_pane)
elseif id == "domains" then
inner_window:perform_action(act.ShowLauncherArgs({ flags = "DOMAINS" }), inner_pane)
elseif id == "launcher" then
inner_window:perform_action(act.ShowLauncherArgs({ flags = "FUZZY|TABS|DOMAINS|WORKSPACES|LAUNCH_MENU_ITEMS" }), inner_pane)
end
end),
}),
pane
)
end
-- WebGpu는 GPU/드라이버 조합에 따라 window_background_opacity(투명도)가 제대로 반영되지
-- 않는 알려진 문제가 있다 (wezterm#5790, #6359, #4502 등). 문제가 재발하면 여기서 OpenGL로
-- 돌려서 투명도를 확인해볼 수 있게 그래픽 백엔드를 메뉴에서 바로 바꿀 수 있게 한다.
local function show_graphics_backend_menu(window, pane)
window:perform_action(
act.InputSelector({
title = "그래픽 백엔드 (현재: " .. tostring(config.front_end) .. ")",
fuzzy = true,
choices = {
{ label = "🟢 OpenGL (투명도 정상 동작, 안정적 - 권장)", id = "OpenGL" },
{ label = "🟡 WebGpu (일부 GPU에서 투명도 깨짐)", id = "WebGpu" },
{ label = "⚪ Software (GPU 미사용, 다른 방법 실패 시 최후 수단)", id = "Software" },
},
action = wezterm.action_callback(function(inner_window, inner_pane, backend_id)
if not backend_id then
return
end
set_config_value(inner_window, inner_pane, "front_end", string.format("%q", backend_id))
end),
}),
pane
)
end
local function show_other_settings(window, pane)
window:perform_action(
act.InputSelector({
title = "기타 설정",
fuzzy = true,
choices = {
{ label = "📜 스크롤백 줄 수", id = "scrollback" },
{ label = "⚡ 최대 FPS", id = "max_fps" },
{ label = "🖥️ 그래픽 백엔드", id = "backend" },
{ label = "⬆️ 탭바 위치: 상단", id = "tab_top" },
{ label = "⬇️ 탭바 위치: 하단", id = "tab_bottom" },
{ label = "✅ 창 닫기 확인 켜기", id = "close_prompt" },
{ label = "🚫 창 닫기 확인 끄기", id = "close_no_prompt" },
},
action = wezterm.action_callback(function(inner_window, inner_pane, id)
if id == "scrollback" then
prompt_config_value(inner_window, inner_pane, "스크롤백 줄 수", "scrollback_lines", config.scrollback_lines, false)
elseif id == "max_fps" then
prompt_config_value(inner_window, inner_pane, "최대 FPS", "max_fps", config.max_fps, false)
elseif id == "backend" then
show_graphics_backend_menu(inner_window, inner_pane)
elseif id == "tab_top" then
set_config_value(inner_window, inner_pane, "tab_bar_at_bottom", "false")
elseif id == "tab_bottom" then
set_config_value(inner_window, inner_pane, "tab_bar_at_bottom", "true")
elseif id == "close_prompt" then
set_config_value(inner_window, inner_pane, "window_close_confirmation", string.format("%q", "AlwaysPrompt"))
elseif id == "close_no_prompt" then
set_config_value(inner_window, inner_pane, "window_close_confirmation", string.format("%q", "NeverPrompt"))
end
end),
}),
pane
)
end
local function show_settings_menu(window, pane)
window:perform_action(
act.InputSelector({
title = "설정",
fuzzy = true,
choices = {
{ label = "🔤 폰트 설정", id = "font" },
{ label = "🔌 연결 설정", id = "connection" },
{ label = "🛠️ 기타 설정", id = "other" },
{ label = "🚀 통합 런처", id = "launcher" },
{ label = "🔄 설정 다시 불러오기", id = "reload" },
},
action = wezterm.action_callback(function(inner_window, inner_pane, id)
if not id then
return
end
if id == "font" then
show_font_settings(inner_window, inner_pane)
elseif id == "connection" then
show_connection_settings(inner_window, inner_pane)
elseif id == "other" then
show_other_settings(inner_window, inner_pane)
elseif id == "launcher" then
inner_window:perform_action(
act.ShowLauncherArgs({ flags = "FUZZY|TABS|DOMAINS|WORKSPACES|LAUNCH_MENU_ITEMS" }),
inner_pane
)
elseif id == "reload" then
inner_window:perform_action(act.ReloadConfiguration, inner_pane)
end
end),
}),
pane
)
end
-- 메인 메뉴: 등록된 SSH 연결들을 맨 위에, 설정 항목은 맨 아래에 배치
local function show_main_menu(window, pane)
local choices = {}
for _, domain in ipairs(config.ssh_domains or {}) do
table.insert(choices, {
label = "🖥️ " .. domain.name .. " (" .. domain.remote_address .. ")",
id = "domain:" .. domain.name,
})
end
table.insert(choices, { label = "⚙️ 설정", id = "settings" })
window:perform_action(
act.InputSelector({
title = "메인 메뉴",
fuzzy = true,
choices = choices,
action = wezterm.action_callback(function(inner_window, inner_pane, id)
if not id then
return
end
local domain_name = id:match("^domain:(.+)$")
if domain_name then
-- SSH 도메인은 wezterm-gui 프로세스가 시작할 때 딱 한 번만 mux에 등록된다.
-- 방금 추가한 도메인은 ReloadConfiguration으로는 mux에 반영되지 않아서
-- AttachDomain을 호출해도 아무 일도 안 일어난다(탭이 안 뜨는 원인). 이 경우
-- wezterm-gui 완전 재시작 전까지 임시로 일반 ssh 명령을 새 탭에 띄워 우회한다.
local mux_domain = wezterm.mux.get_domain(domain_name)
if mux_domain then
-- SpawnTab 대신 AttachDomain 사용 이유는 update-right-status 훅 근처 주석 참고
inner_window:perform_action(act.AttachDomain(domain_name), inner_pane)
else
local target
for _, domain in ipairs(config.ssh_domains or {}) do
if domain.name == domain_name then
target = domain
break
end
end
if target then
local host, port = split_remote_address(target.remote_address)
local args = { "ssh" }
if port then
table.insert(args, "-p")
table.insert(args, port)
end
table.insert(args, target.username .. "@" .. host)
inner_window:perform_action(act.SpawnCommandInNewTab({ args = args }), inner_pane)
notify(
inner_window,
"'" .. domain_name .. "'는 아직 wezterm에 등록되지 않아 일반 ssh로 접속했습니다.\n"
.. "wezterm-gui.exe를 완전히 재시작하면 네이티브 연결(멀티플렉싱)을 쓸 수 있습니다.",
8000
)
end
end
elseif id == "settings" then
show_settings_menu(inner_window, inner_pane)
end
end),
}),
pane
)
end
wezterm.on("show-main-menu", function(window, pane)
show_main_menu(window, pane)
end)
-- 시작 시 첫 화면으로 메인 메뉴를 띄워 설정과 연결 메뉴에 바로 접근합니다.
--
-- update-right-status 훅에서 지연 접속을 시도하던 예전 방식은 로컬 셸(cmd.exe) 탭이
-- 먼저 보였다가 뒤늦게 SSH 탭이 따로 생기는 형태라 "cmd가 잠깐 보였다 바뀌는" 것처럼
-- 느껴지고, 첫 도메인이 mux에 실제로 붙기까지 딜레이도 있었다. 대신 여기서 첫 번째 SSH
-- 도메인의 접속 정보로 곧장 `ssh ...` 명령을 만들어 그 창의 첫 프로그램으로 실행한다.
-- (네이티브 ssh_domains/AttachDomain이 아니라 일반 ssh 클라이언트를 그대로 띄우는 것이라,
-- config.ssh_domains 등록 시점 문제 없이 항상 즉시 연결된다. 로컬 셸이 필요하면 메인
-- 메뉴나 leader+e/leader+Tab 런처에서 언제든 새로 열 수 있다.)
wezterm.on("gui-startup", function(cmd)
local spawn_cmd = cmd
if not spawn_cmd then
local first_domain = config.ssh_domains and config.ssh_domains[1]
if first_domain then
local host, port = split_remote_address(first_domain.remote_address)
local args = { "ssh" }
if port then
table.insert(args, "-p")
table.insert(args, port)
end
table.insert(args, first_domain.username .. "@" .. host)
spawn_cmd = { args = args }
end
end
local tab, pane, window = wezterm.mux.spawn_window(spawn_cmd or {})
list_system_fonts() -- 폰트 목록을 미리 캐싱해서, 나중에 폰트 설정 메뉴에서 조회 지연으로 화면이 깜빡이지 않게 함
show_main_menu(window:gui_window(), pane)
end)
-- 시작 시 자동으로 워크스페이스/레이아웃을 구성하려면 아래 예시를 참고하세요.
-- wezterm.on("gui-startup", function(cmd)
-- local tab, pane, window = wezterm.mux.spawn_window(cmd or {})
-- pane:split({ direction = "Right", size = 0.3 })
-- end)
-- 워크스페이스 전환 (tmux 세션 개념) : leader+w 로 목록에서 선택/생성
-- (아래 keys 테이블에 SwitchToWorkspace, ShowLauncherArgs 바인딩 있음)
-- ============================================================
-- 키바인딩 (Leader key: tmux 스타일)
-- ============================================================
config.leader = { key = "a", mods = "CTRL", timeout_milliseconds = 1000 }
config.keys = {
-- 탭 (Windows 관례: Ctrl+Shift+*, 셸 시그널/기존 단축키와 충돌 피함)
{ key = "t", mods = "CTRL|SHIFT", action = act.SpawnTab("CurrentPaneDomain") },
{ key = "w", mods = "CTRL|SHIFT", action = act.CloseCurrentTab({ confirm = true }) },
{ key = "1", mods = "CTRL|SHIFT", action = act.ActivateTab(0) },
{ key = "2", mods = "CTRL|SHIFT", action = act.ActivateTab(1) },
{ key = "3", mods = "CTRL|SHIFT", action = act.ActivateTab(2) },
-- 창 분할 (leader + | / -)
{ key = "\\", mods = "LEADER", action = act.SplitHorizontal({ domain = "CurrentPaneDomain" }) },
{ key = "-", mods = "LEADER", action = act.SplitVertical({ domain = "CurrentPaneDomain" }) },
-- 패인(pane) 이동: leader + hjkl
{ key = "h", mods = "LEADER", action = act.ActivatePaneDirection("Left") },
{ key = "l", mods = "LEADER", action = act.ActivatePaneDirection("Right") },
{ key = "k", mods = "LEADER", action = act.ActivatePaneDirection("Up") },
{ key = "j", mods = "LEADER", action = act.ActivatePaneDirection("Down") },
-- 패인 닫기 / 전체화면 토글
{ key = "x", mods = "LEADER", action = act.CloseCurrentPane({ confirm = true }) },
{ key = "z", mods = "LEADER", action = act.TogglePaneZoomState },
-- 폰트 크기 조절 (브라우저와 동일한 관례: Ctrl+=/-/0)
{ key = "=", mods = "CTRL", action = act.IncreaseFontSize },
{ key = "-", mods = "CTRL", action = act.DecreaseFontSize },
{ key = "0", mods = "CTRL", action = act.ResetFontSize },
-- 복사/붙여넣기 (Ctrl+C/V는 셸 시그널/붙여넣기와 겹치므로 Shift 추가)
{ key = "c", mods = "CTRL|SHIFT", action = act.CopyTo("Clipboard") },
{ key = "v", mods = "CTRL|SHIFT", action = act.PasteFrom("Clipboard") },
-- 메인 메뉴: 설정 메뉴와 통합 런처를 다시 표시
{ key = "m", mods = "LEADER", action = act.EmitEvent("show-main-menu") },
-- 현재 탭을 새 창으로 분리 (탭바 드래그 대신 확실하게 동작하는 방법)
{
key = "Enter",
mods = "LEADER",
action = wezterm.action_callback(function(window, pane)
pane:move_to_new_window()
end),
},
-- [2] 워크스페이스(=tmux 세션) 전환/생성 목록
{ key = "w", mods = "LEADER", action = act.ShowLauncherArgs({ flags = "WORKSPACES" }) },
-- [2] SSH 도메인 접속 (config.ssh_domains에 등록한 서버 목록에서 선택)
{ key = "s", mods = "LEADER", action = act.ShowLauncherArgs({ flags = "DOMAINS" }) },
-- [MobaXterm 스타일] 저장된 세션 바로가기 목록 (config.launch_menu, 퍼지 검색 가능)
{ key = "e", mods = "LEADER", action = act.ShowLauncherArgs({ flags = "FUZZY|LAUNCH_MENU_ITEMS" }) },
-- [MobaXterm 스타일] 탭/도메인/워크스페이스/launch_menu 전부 합친 통합 런처
{ key = "Tab", mods = "LEADER", action = act.ShowLauncherArgs({ flags = "FUZZY|TABS|DOMAINS|WORKSPACES|LAUNCH_MENU_ITEMS" }) },
-- [3] 검색 모드: leader+/ 로 스크롤백 문자열 검색
{ key = "/", mods = "LEADER", action = act.Search({ CaseInSensitiveString = "" }) },
-- [3] Copy mode: leader+[ 로 진입, vim 키(hjkl/v/y)로 스크롤백 탐색 & 선택
{ key = "[", mods = "LEADER", action = act.ActivateCopyMode },
-- [3] Quick select: leader+space 로 화면에 보이는 URL/해시/경로 등을 정규식으로 잡아 즉시 복사
{ key = "Space", mods = "LEADER", action = act.QuickSelect },
-- [6] Resize 모드 진입: leader+r 누른 뒤 모드 안에서 hjkl로 계속 크기 조절, Esc로 빠져나감
{ key = "r", mods = "LEADER", action = act.ActivateKeyTable({ name = "resize_pane", one_shot = false }) },
}
-- [6] key_tables: resize 모드처럼 진입 후 여러 번 연속 입력 가능한 모달 키바인딩
config.key_tables = {
resize_pane = {
{ key = "h", action = act.AdjustPaneSize({ "Left", 3 }) },
{ key = "l", action = act.AdjustPaneSize({ "Right", 3 }) },
{ key = "k", action = act.AdjustPaneSize({ "Up", 3 }) },
{ key = "j", action = act.AdjustPaneSize({ "Down", 3 }) },
{ key = "Escape", action = "PopKeyTable" },
{ key = "q", action = "PopKeyTable" },
},
}
-- [6] 마우스 바인딩: 우클릭으로 붙여넣기, Ctrl+클릭으로 링크 열기
--
-- 우클릭 붙여넣기는 수정키 없이(mods = "NONE") 기본으로 동작한다. 다만 Claude Code처럼
-- 마우스 리포팅(SGR 마우스 모드)을 켜는 전체화면 앱 안에서는, 수정키 없는 클릭이
-- wezterm이 아니라 그 앱으로 그대로 전달돼서 붙여넣기가 안 먹는다. wezterm은 SHIFT를
-- 누르고 있으면 앱의 마우스 캡처를 무시하고 항상 로컬 바인딩을 실행하도록 되어 있어서,
-- 같은 붙여넣기 동작을 SHIFT 조합에도 등록해 둔다 (Claude Code 전체화면에서는 Shift+우클릭 사용).
-- Ctrl+Shift+우클릭도 같은 이유로 계속 남겨둔다(기존 습관/호환용).
config.mouse_bindings = {
{
event = { Down = { streak = 1, button = "Right" } },
mods = "NONE",
action = act.PasteFrom("Clipboard"),
},
{
event = { Down = { streak = 1, button = "Right" } },
mods = "SHIFT",
action = act.PasteFrom("Clipboard"),
},
{
event = { Down = { streak = 1, button = "Right" } },
mods = "CTRL|SHIFT",
action = act.PasteFrom("Clipboard"),
},
{
-- ALT+클릭은 wezterm 기본 사각형(블록) 선택 제스처와 충돌해서 동작하지 않음
event = { Up = { streak = 1, button = "Left" } },
mods = "CTRL|SHIFT",
action = act.EmitEvent("show-main-menu"),
},
{
event = { Up = { streak = 1, button = "Left" } },
mods = "CTRL",
action = act.OpenLinkAtMouseCursor,
},
{
-- 수정키 없는 더블클릭은 단어 선택 제스처라 겹치지 않게 Ctrl+Shift로 묶음
event = { Up = { streak = 2, button = "Left" } },
mods = "CTRL|SHIFT",
action = wezterm.action_callback(function(window, pane)
pane:move_to_new_window()
end),
},
}
-- [3] Quick select 패턴 추가 (기본 URL/git hash 외에 IP주소, K8s pod명 등 추가 가능)
config.quick_select_patterns = {
"\\b(?:25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\\.(?:25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\\b", -- IPv4
}
-- ============================================================
-- [4] 하이퍼링크 / 이미지
-- ============================================================
-- 기본 URL 규칙 + 커스텀 규칙 추가
config.hyperlink_rules = wezterm.default_hyperlink_rules()
-- JIRA-1234 같은 티켓 링크가 필요하면 실제 도메인으로 바꾼 뒤 활성화하세요.
-- table.insert(config.hyperlink_rules, {
-- regex = [[\b[A-Z]{2,}-\d+\b]],
-- format = "https://jira.example.com/browse/$0",
-- })
-- Kitty graphics / iTerm2 / Sixel 이미지 프로토콜 활성화 (chafa, catimg 등으로 터미널 이미지 출력 가능)
config.enable_kitty_graphics = true
-- ============================================================
-- [5] 쉘 연동
-- ============================================================
-- 기본 실행 쉘/프로그램 (미지정 시 OS 기본 쉘)
-- config.default_prog = { "/bin/zsh", "-l" }
-- OSC 7으로 현재 디렉토리를 추적하려면 쉘 설정(.zshrc/.bashrc)에 shell integration 스크립트 추가 필요:
-- zsh: eval "$(wezterm shell-integration --shell zsh)" (또는 wezterm 문서의 zshrc 스니펫)
-- 적용되면 새 탭/분할이 현재 pane과 같은 디렉토리에서 시작됨
config.default_cwd = wezterm.home_dir
-- [MobaXterm 스타일] 저장된 세션 목록: leader+e (또는 우클릭 메뉴)로 목록을 띄워
-- 원하는 서버/커맨드를 바로 골라 새 탭으로 실행. 항목은 필요한 만큼 추가하면 됨.
-- (아래는 예시이며 실제 서버 정보로 교체 필요)
config.launch_menu = {
{ label = "Local bash", args = { "bash", "-l" } },
{ label = "htop", args = { "htop" } },
-- { label = "SSH: production", args = { "ssh", "user@production.example.com" } },
-- { label = "SSH: devbox", args = { "ssh", "user@devbox.example.com" } },
-- { label = "SSH: root custom port", args = { "ssh", "-p", "2222", "root@host.example.com" } },
}
-- ============================================================
-- [7] 렌더링 / 성능
-- ============================================================
-- WebGpu는 GPU/드라이버 조합에 따라 window_background_opacity(투명도)가 적용되지 않는
-- 알려진 문제가 있어(wezterm#5790, #6359, #4502) 투명도가 정상 동작하는 OpenGL을 기본값으로 둔다.
-- 설정 > 기타 설정 > 그래픽 백엔드 메뉴에서 언제든 WebGpu/Software로 바꿀 수 있다.
config.front_end = "OpenGL"
config.webgpu_power_preference = "HighPerformance"
config.max_fps = 120
config.animation_fps = 60
-- 리가처(ligature) 비활성화 (=> 같은 코드 기호가 하나로 합쳐지는 게 싫으면 끄기)
config.harfbuzz_features = { "calt=0", "clig=0", "liga=0" }
-- ============================================================
-- 기타
-- ============================================================
config.scrollback_lines = 10000
config.enable_scroll_bar = false
config.audible_bell = "Disabled"
config.default_cursor_style = "BlinkingBar"
config.window_close_confirmation = "AlwaysPrompt"
return config