Skip to content

WezTerm: ship Lua module + activation OSC for app-scoped shortcuts #49

Closed

Description

@jamescrosswell

Sub-issue of #40. Sibling of #39 (iTerm2).

Updated (post-#59): terminal-integration ownership now lives inside TuiCode itself (ITerminalIntegration interface, see Iterm2Integration for the reference impl). WezTerm support should follow the same pattern — a WezTermIntegration class in src/TuiCode.Workbench/TerminalIntegration/, surfaced through the existing --install-terminal-integration CLI flag and Settings → Terminal Integration UI. No Homebrew formula changes, no per-channel install logic.

Goal

When TuiCode runs under WezTerm, the same OS-native shortcuts (Cmd+C/V/X/Z, Cmd+arrow navigation, Option+arrow word movement, etc.) work as they do under iTerm2 — and only while TuiCode is running, so a user's normal WezTerm Cmd+C (copy from terminal selection) still works in their shell.

Mechanism

WezTerm has no equivalent of iTerm2's auto-watched DynamicProfiles/ directory. Instead we use two WezTerm features in combination:

  1. Key tables (config.key_tables) — define a named set of bindings that can be activated/deactivated on demand. We define a tuicode table whose bindings send the same byte sequences as the iTerm2 profile (control bytes for Ctrl+letter, CSI sequences for navigation, CSI u for chord combinations).
  2. User-var events (wezterm.on('user-var-changed', ...)) — TuiCode emits an OSC 1337 SetUserVar sequence on startup and exit; WezTerm reacts by pushing / popping the tuicode key table on the active pane.

This is cleaner than iTerm2's process-name matching because it's app-driven: works regardless of binary rename, wrapper scripts, or being launched through SSH (user vars travel through the pty).

Distribution — via WezTermIntegration : ITerminalIntegration

Following the #59 pattern. The integration class is the single source of truth:

  • IsAvailable() → $TERM_PROGRAM == "WezTerm".
  • Install() →
    1. Writes ~/.config/wezterm/tuicode.lua (full module embedded as a resource, idempotent overwrite — analogous to how Iterm2Integration ships the JSON).
    2. Appends a fenced -- TuiCode begin / -- TuiCode end block to ~/.config/wezterm/wezterm.lua containing require 'tuicode'.apply(config). If the block already exists, replaces it in place. If wezterm.lua doesn't exist, creates it.
  • Uninstall() → removes the Lua file and strips the fenced block from wezterm.lua.
  • GetStatus() → reads both files, checks for the fenced block + a TuiCodeIntegrationVersion marker comment inside it. Distinguishes NotInstalled / Installed / Stale.

This is more aggressive than the original "never edit wezterm.lua without consent" position, but the in-app Install action and --install-terminal-integration CLI flag are both explicit consent — the user clicked/typed it. The fenced block makes the edit reversible and visible, which is the actual concern config-purists have with installers.

Register the class in Program.cs alongside Iterm2Integration; the Settings UI auto-picks it up via the IEnumerable<ITerminalIntegration> injection.

Module sketch

-- TuiCodeIntegrationVersion: 1
local M = {}

function M.apply(config)
  local wezterm = require 'wezterm'

  config.key_tables = config.key_tables or {}
  config.key_tables.tuicode = {
    { key = 'c', mods = 'CMD',       action = wezterm.action.SendString '\x03' },
    { key = 'v', mods = 'CMD',       action = wezterm.action.SendString '\x16' },
    { key = 'x', mods = 'CMD',       action = wezterm.action.SendString '\x18' },
    { key = 'z', mods = 'CMD',       action = wezterm.action.SendString '\x1a' },
    { key = 'z', mods = 'CMD|SHIFT', action = wezterm.action.SendString '\x19' },
    { key = 'a', mods = 'CMD',       action = wezterm.action.SendString '\x01' },
    { key = 'LeftArrow',  mods = 'CMD', action = wezterm.action.SendString '\x1b[H' },
    { key = 'RightArrow', mods = 'CMD', action = wezterm.action.SendString '\x1b[F' },
    { key = 'LeftArrow',  mods = 'OPT', action = wezterm.action.SendString '\x1b[1;5D' },
    { key = 'RightArrow', mods = 'OPT', action = wezterm.action.SendString '\x1b[1;5C' },
    -- ... full set mirroring the iTerm2 profile from the #41 comment
  }

  wezterm.on('user-var-changed', function(window, pane, name, value)
    if name == 'TUICODE_ACTIVE' then
      if value == '1' then
        window:perform_action(
          wezterm.action.ActivateKeyTable { name = 'tuicode', one_shot = false },
          pane
        )
      else
        window:perform_action(wezterm.action.PopKeyTable, pane)
      end
    end
  end)
end

return M

App-side work (TuiCode)

Emit OSC 1337 user-var sequences from WorkbenchHost:

  • On startup (after IApplication.Init): write \x1b]1337;SetUserVar=TUICODE_ACTIVE=MQ==\x07 to stdout. (MQ== = base64 "1".)
  • On shutdown (dispose path, matching the TerminalFlowControl lifecycle): write \x1b]1337;SetUserVar=TUICODE_ACTIVE=MA==\x07. (MA== = base64 "0".)

Should be unconditional — terminals that don't understand OSC 1337 ignore it silently. No detection needed.

Gotchas

  • tmux pass-through. If a user launches TuiCode inside tmux, OSC 1337 needs allow-passthrough on in their tmux config to reach WezTerm. Document this somewhere the user will see — likely the Settings → Terminal Integration panel when $TERM_PROGRAM == "tmux" is detected wrapped around WezTerm.
  • Per-pane scope. The user-var-changed event gives us the originating pane, and ActivateKeyTable applies per-window. Need to verify behavior when the user has multiple TuiCode instances in different tabs/panes (key table is window-scoped, so cross-pane bleed is possible). Worth a manual test.
  • Same byte sequences as iTerm2. Both terminals end up sending the same bytes to TuiCode, so the in-app key handling is unchanged. Any fix to the byte-sequence mapping (e.g. the Redo issue Cmd+Shift+Z (Redo) doesn't work in TextView #46) benefits both terminals.
  • Editing user's wezterm.lua. The fenced-block edit is reversible and visible, but it is an edit. The CLI flag and Settings UI are the consent points; document the block format so a user who later reads their config understands what's there.

Work breakdown

  • App: emit OSC 1337 user-var on startup/exit (WorkbenchHost).
  • Embed tuicode.lua as a resource string in WezTermIntegration (analogous to Iterm2Integration.ProfileJson).
  • Implement WezTermIntegration : ITerminalIntegration — Install/Uninstall/GetStatus including the fenced-block edit to wezterm.lua.
  • Register in Program.cs.
  • Unit tests with MockFileSystem + FakeEnvironment: install creates both files / appends block, idempotent reinstall, stale detection, uninstall strips the block and leaves the rest of wezterm.lua intact.
  • Manual test: multi-pane behavior, tmux nesting, SSH session.
  • Update the Settings → Terminal Integration panel's "Supported so far" line to include WezTerm once the integration ships.

Dependencies

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions