969 lines
35 KiB
Lua
969 lines
35 KiB
Lua
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 = {}
|
||
|
||
-- gui-startup 시점엔 SSH 도메인이 아직 mux에 등록되기 전이라 바로 접속을 시도하면 항상
|
||
-- "domain name is invalid" 에러가 난다 (고정 지연 0.5초/2초 모두 시도했지만 재현됨).
|
||
-- 대신 창이 실제로 정상 작동 중일 때만 도는 update-right-status 훅에서 딱 한 번,
|
||
-- 등록된 SSH 도메인이 있으면 그걸로 자동 접속한다 (특정 이름 하드코딩 없음)
|
||
local startup_ssh_connect_tried = false
|
||
|
||
wezterm.on("update-right-status", function(window, pane)
|
||
if not startup_ssh_connect_tried then
|
||
startup_ssh_connect_tried = true
|
||
local first_domain = config.ssh_domains and config.ssh_domains[1]
|
||
if first_domain then
|
||
-- 아직 attach 안 된 도메인에 SpawnTab을 쓰면 "암묵적 attach가 만드는 기본 탭" +
|
||
-- "명시적으로 요청한 탭"이 겹쳐서 탭이 2개 생긴다. AttachDomain은 attach와 동시에
|
||
-- 탭이 없을 때만 하나 만들어주므로 중복이 없다.
|
||
window:perform_action(act.AttachDomain(first_domain.name), pane)
|
||
end
|
||
end
|
||
|
||
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 = {
|
||
}
|
||
|
||
-- 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
|
||
|
||
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 = "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 == "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
|
||
-- SpawnTab 대신 AttachDomain 사용 이유는 update-right-status 훅 근처 주석 참고
|
||
inner_window:perform_action(act.AttachDomain(domain_name), inner_pane)
|
||
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)
|
||
|
||
|
||
-- 시작 시 첫 화면으로 메인 메뉴를 띄워 설정과 연결 메뉴에 바로 접근합니다.
|
||
-- SSH 자동 접속은 update-right-status 훅에서 처리 (위 pane_start_times 근처 참고)
|
||
wezterm.on("gui-startup", function(cmd)
|
||
local tab, pane, window = wezterm.mux.spawn_window(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] 렌더링 / 성능
|
||
-- ============================================================
|
||
config.front_end = "WebGpu" -- GPU 가속 렌더러 (문제 있으면 "OpenGL" 또는 "Software"로 변경)
|
||
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
|