Skip to content

terminal/tmux: decode %output octal escapes#12076

Open
h3nock wants to merge 2 commits intoghostty-org:mainfrom
h3nock:fix/tmux-output-decode
Open

terminal/tmux: decode %output octal escapes#12076
h3nock wants to merge 2 commits intoghostty-org:mainfrom
h3nock:fix/tmux-output-decode

Conversation

@h3nock
Copy link
Copy Markdown

@h3nock h3nock commented Apr 2, 2026

What

Decode octal-escaped bytes in tmux control mode %output notifications.

Why

tmux control mode escapes control bytes and '\' as \ooo (backslash + three octal digits), so the parser was forwarding encoded bytes without decoding them.

From the tmux wiki:

The output has any characters less than ASCII 32 and the \ character replaced with their octal equivalent, so \ becomes \134. Otherwise, it is exactly what the application running in the pane sent to tmux.

Without decoding, pane output like hello\r\nworld is emitted as hello\015\012world in control mode and forwarded by the parser as literal characters \015\012 instead of real CR LF bytes, so the terminal never sees the line break.

Changes

  • add decodeEscapedOutput to control.zig: decodes escaped bytes in-place since the output is always ≤ the input length.
  • wire it into the %output notification path in parseNotification.
  • three tests for basic octal decode, malformed sequence handling, full integration through Parser.put.

Notes

  • used u8 to accumulate three octal digits because tmux only encodes bytes below 32 and \ (92 = \134) so 92 (\134) is the max we'd ever get. no overflow guard needed.
   // control.zig line 215-224
   while (i < 3) : (i += 1) {
      const digit = data[read_idx];
      if (digit < '0' or digit > '7') break;

      value = value * 8 + (digit - '0');
      read_idx += 1;
  }
  • used ? for malformed escaped sequences (fewer than 3 octal digits after ). realistically this shouldn't fire but worst case showing up ? is better than dropping the whole %output payload and also signals something went wrong

    Relates to Feature: Support for tmux's Control Mode #1935.


AI disclosure: Claude Code Opus 4.6 and Codex 5.4 xhigh were used for research, understanding Zig and helping me convert my ideas to Zig.


@h3nock h3nock requested a review from a team as a code owner April 2, 2026 22:39
@mitchellh
Copy link
Copy Markdown
Contributor

Without decoding, pane output like hello\r\nworld is emitted as hello\015\012world in control mode and forwarded by the parser as literal characters \015\012 instead of real CR LF bytes, so the terminal never sees the line break.

I think I brought this up in the discussion you opened (and maybe you responded but I can't find it): why do we need to decode it during parsing vs. during use? My original thinking was to minimize parsing time because there may be users who listen to tmux control mode but don't actually want to use the output, and decoding is just wasted CPU cycles in that case.

If we can decode within the same buffer, I feel like perhaps we can add a pub fn decoded() []const u8 function to the message type or something and if its decoded return it as-is otherwise decode it on first use.

Thoughts on that kind of approach?

@h3nock
Copy link
Copy Markdown
Author

h3nock commented Apr 3, 2026

why do we need to decode it during parsing vs. during use?

my thinking was that control.zig, output.zig and layout.zig together form the tmux protocol layer, so consumers don't have to know the details of tmux's protocol like decoding the response, formatting... from that pov, keeping the decode logic inside control.zig seemed like the right abstraction. aslo Viewer from viewer.zig is one of those consumers too and today it forwards output.data directly into the pane VT stream

there may be users who listen to tmux control mode but don't actually want to use the output, and decoding is just wasted CPU cycles in that case.

makes sense. esp for whatever reason some non rendering consumers are using this

If we can decode within the same buffer, I feel like perhaps we can add a pub fn decoded() []const u8 function to the message type or something and if its decoded return it as-is otherwise decode it on first use.

agreed on this. it solves both of our concerns (wasted cpu cycle & abstracting the decoding logic from the user). and just tracking whether the payload was already decoded or not will make it idempotent

one implementation detail, if decoded() does in place lazy decoding, then the payload likely needs to store []u8 instead of []const u8 exposing a mutable .data field to callers. i'm wondering if you're okay with that tradeoff or you'd prefer a different approach

@h3nock h3nock changed the title tmux: decode %output octal escapes terminal/tmux: decode %output octal escapes Apr 4, 2026
@mitchellh
Copy link
Copy Markdown
Contributor

one implementation detail, if decoded() does in place lazy decoding, then the payload likely needs to store []u8 instead of []const u8 exposing a mutable .data field to callers. i'm wondering if you're okay with that tradeoff or you'd prefer a different approach

Sorry for the delay. I think this is fine.

@h3nock
Copy link
Copy Markdown
Author

h3nock commented Apr 8, 2026

Updated this PR with lazy decoded() and follow-up tests

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants