Terminal UI frameworks compatible with Remote Terminal

Hi All - I’m developing a hardware overview and debug menu on a remote sensing system using a Raspberry Pi 5. I’d love for it to be accessible from the mender remote terminal (via the troubleshoot add-on) to enable less technical users to quickly view and control various low level functions without overbearing terminal commands. I’ve designed a “static” layout where sensor and actuator information, as well as command menus update without shifting the overall layout, which makes viewing and debugging the large amount of peripherals on our system much easier.

I’ve been using Python + Rich and it’s live rendering mode to pretty good success via ssh:

However, the mender remote terminal seems to mangle or struggle with the incremental updates that this framework uses, and the layout gets corrupted easily in ways it does not over ssh.

I’m wondering if anyone has experience building statically laid out terminal user interfaces that work well via the web terminal - any design patterns, or alternative libraries, would be great!

Thank you,
-James

Hi @jdavis-codes,

Thanks for reaching out! That’s indeed a very interesting use case, and something I’d like to see :slight_smile: However it might indeed be problematic, as the remote terminal is not an SSH connection or such which can use the full terminal formatting tricks on the operator side, but effectively a PTY forwarded through websocket to the web frontend. So there’s definitely a number of moving parts involved here. Maybe @kjaskiewiczz has a pointer here, and I’ll also ask around, will keep you updated!

Greetz,
Josef

So digging more into it, it seems like the 255 byte WebSocket chunking of the PTY causes certain characters to be dropped or malformed. I found I was actually able to use most Rich features with this custom renderer polled in a loop:

  def _refresh_direct(self, force: bool = False) -> None:
      """Render the dashboard and write the complete frame to stdout.

      Instead of Rich's Live display (which relies on cursor-up or
      cursor-home sequences that get fragmented across WebSocket
      messages), we render to a string with Console.capture() and
      write cursor-home + frame + erase-below as one contiguous
      stdout.write() call.
      """
      self._capture_terminal_size()
      now = time.monotonic()
      if not force and not self._dirty.is_set():
          return
      if not force and now - self.last_render_at < self.render_config.refresh_interval:
          return

      with self.console.capture() as capture:
          self.console.print(self._render(), overflow="crop", crop=True)
      rendered = capture.get()

      # Compose the complete frame:
      #   \x1b[H   — cursor to row 1, col 1 (home)
      #   <frame>  — the rendered dashboard
      #   \x1b[J   — erase from cursor to end of screen
      frame = f"\x1b[H{rendered}\x1b[J"

      # Mender Connect reads PTY output in 255-byte chunks and bursts them to 
      # the WebSocket queue. A full 10KB+ Rich screen redraw overflows this 
      # queue, causing dropped chunks (missing lines and garbled ANSI escapes). 
      # Pacing the output prevents dropped chunks and terminal corruption.
      if self.chunked_rendering_enabled:
          chunk_size = 256
          for i in range(0, len(frame), chunk_size):
              self._screen_stream.write(frame[i : i + chunk_size])
              self._screen_stream.flush()
              time.sleep(0.002)
      else:
          self._screen_stream.write(frame)
          self._screen_stream.flush()

      self.last_render_at = now
      self._dirty.clear()

This gives me surprisingly usable results despite the added overhead. This feels pretty hacky and not something I see trying to upstream into the Rich package, but it does inspire me to think about writing my own Mender friendly TUI package when I have a free moment…

1 Like