If you've ever wanted to embed a terminal emulator or other external program directly inside your PyQt6 or PySide2 application, you've probably discovered that this is one of those tasks that sounds straightforward but turns out to be surprisingly tricky. The challenge touches on window management, process control, and platform differences all at once.
In this article, we'll walk through the main approaches people use to embed terminals and external programs in Qt widgets, discuss the trade-offs, and build some working examples you can experiment with.
Why is this hard?
Qt gives you excellent control over widgets that it creates. But an external program—like xterm, urxvt, or any other terminal emulator—creates its own window, managed by the operating system's window manager. Getting that external window to behave as if it's a child widget inside your Qt application means convincing the OS to reparent that window into your widget's window hierarchy.
On Linux/X11, this is possible using the X11 embedding protocol. Qt 4 had QX11EmbedContainer for exactly this purpose, but it was removed in Qt 5 because the underlying X11 embedding mechanism is fragile and doesn't translate to Wayland or other platforms. On Windows and macOS, the situation is even more constrained—there's no general-purpose equivalent.
So the short answer is: there's no single, clean, cross-platform way to embed an arbitrary external program inside a QWidget. But there are several approaches, each with different strengths and limitations.
Embedding xterm with QProcess and winId (Linux/X11 only)
Some terminal emulators on Linux accept a command-line flag that tells them to embed themselves inside an existing window. For example, xterm has the -into flag, and urxvt has -embed. You can pass the window ID of a Qt widget to these flags, and the terminal will (usually) render inside that widget.
Here's a basic example using xterm:
import sys
from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton
from PyQt6.QtCore import QProcess
class EmbeddedTerminal(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Embedded xterm")
self.resize(800, 600)
self.terminal = QWidget(self)
layout = QVBoxLayout(self)
layout.addWidget(self.terminal)
button = QPushButton("Say hello")
layout.addWidget(button)
button.clicked.connect(self.say_hello)
self.process = QProcess(self)
self.process.start(
"xterm",
["-into", str(int(self.terminal.winId())),
"-e", "tmux", "new", "-s", "my_session"],
)
def say_hello(self):
"""Send a command to the tmux session."""
send = QProcess(self)
send.start(
"tmux",
["send-keys", "-t", "my_session:0", "echo hello", "Enter"],
)
send.waitForFinished()
def closeEvent(self, event):
"""Clean up the tmux session when closing."""
cleanup = QProcess(self)
cleanup.start("tmux", ["kill-session", "-t", "my_session"])
cleanup.waitForFinished()
self.process.terminate()
self.process.waitForFinished(3000)
super().closeEvent(event)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = EmbeddedTerminal()
window.show()
sys.exit(app.exec())
This creates a Qt window with an embedded xterm running inside a tmux session. The "Say hello" button sends a command to the terminal through tmux send-keys.
What's happening here
- We create a plain
QWidgetcalledself.terminaland add it to our layout. This widget exists mainly to give us a window ID. - We call
self.terminal.winId()to get the native window identifier, and pass that toxterm -into. This tells xterm to render inside that window rather than creating its own top-level window. - We use
tmuxas a session manager so we can send commands to the terminal from our Python code. Without tmux (or a similar multiplexer), there's no easy way to programmatically type into the embedded terminal.
Limitations
This approach has several significant limitations:
- Linux/X11 only. The
-intoflag relies on X11 window reparenting. This won't work on Wayland, Windows, or macOS. - Fragile embedding. The terminal may not resize correctly when you resize your Qt window. Focus handling can be unpredictable.
- Limited terminal choices. Only terminals that support an "embed into window ID" flag will work.
xtermandurxvtdo; many modern terminals do not. - tmux dependency. Sending commands requires a session manager like tmux, which adds another moving part.
You can adapt this for urxvt by replacing the process start call:
self.process.start(
"urxvt",
["-embed", str(int(self.terminal.winId()))],
)
urxvt uses -embed instead of -into, and you don't need tmux unless you want to send commands programmatically.
A pty-based terminal widget in pure Python
Instead of trying to embed an external terminal program, another approach is to build a terminal emulator inside your Qt application. This means using a pseudoterminal (pty) to connect to a shell process, reading its output, and rendering it in a Qt widget.
The pyqtermwidget (py3qterm) library takes this approach. It uses Python's pty module to create a pseudoterminal, connects it to a shell like /bin/bash, and renders the output using a custom QWidget with a terminal emulation layer.
How pty-based terminals work
A pseudoterminal (pty) is a pair of virtual devices—a master and a slave—that behave like a real terminal. The shell process (bash, zsh, etc.) reads from and writes to the slave side, thinking it's connected to a real terminal. Your Python code reads from and writes to the master side.
Your Qt widget <---> pty master <---> pty slave <---> /bin/bash
This means interactive programs like htop, vim, and ssh all work, because they see a real terminal environment with proper terminal capabilities (curses support, environment variables, signal handling, etc.).
A minimal pty example
Here's a simplified example showing the core idea of connecting a QWidget to a pty. This is not a full terminal emulator—it doesn't handle escape sequences, cursor positioning, or colors—but it shows the fundamental mechanism:
import os
import pty
import sys
from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPlainTextEdit
from PyQt6.QtCore import QSocketNotifier, QProcess
class SimpleTerminalWidget(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Simple PTY Example")
self.resize(700, 500)
layout = QVBoxLayout(self)
self.output = QPlainTextEdit(self)
self.output.setReadOnly(True)
self.output.setStyleSheet(
"background-color: black; color: #00ff00; "
"font-family: monospace; font-size: 12pt;"
)
layout.addWidget(self.output)
# Create a pseudoterminal.
self.master_fd, self.slave_fd = pty.openpty()
# Fork a shell process connected to the pty.
self.pid = os.fork()
if self.pid == 0:
# Child process: connect to the slave side and exec a shell.
os.close(self.master_fd)
os.setsid()
os.dup2(self.slave_fd, 0)
os.dup2(self.slave_fd, 1)
os.dup2(self.slave_fd, 2)
os.close(self.slave_fd)
os.execvp("/bin/bash", ["/bin/bash"])
else:
# Parent process: read from the master side.
os.close(self.slave_fd)
self.notifier = QSocketNotifier(
self.master_fd, QSocketNotifier.Read, self
)
self.notifier.activated.connect(self.read_output)
def read_output(self):
try:
data = os.read(self.master_fd, 4096)
if data:
text = data.decode("utf-8", errors="replace")
self.output.insertPlainText(text)
# Auto-scroll to the bottom.
scrollbar = self.output.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
except OSError:
pass
def keyPressEvent(self, event):
"""Forward key presses to the shell."""
text = event.text()
if text:
os.write(self.master_fd, text.encode("utf-8"))
def closeEvent(self, event):
os.close(self.master_fd)
os.kill(self.pid, 9)
os.waitpid(self.pid, 0)
super().closeEvent(event)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = SimpleTerminalWidget()
window.show()
sys.exit(app.exec())
Type into the window and you'll see bash responding. Simple commands like ls and echo work. But you'll immediately notice that the output includes raw escape sequences (the [0m, [32m type characters you see) because we're not interpreting ANSI terminal codes.
This example only works on Linux and macOS, because pty.openpty() and os.fork() are POSIX-only.
Making it a real terminal
Turning this into a proper terminal emulator means adding:
- ANSI escape sequence parsing — interpreting color codes, cursor movement, screen clearing, etc.
- A character grid renderer — drawing characters at specific row/column positions rather than appending to a text widget.
- Mouse event handling — for selection, copy/paste, and programs that use mouse input.
- Proper keyboard handling — mapping special keys (arrows, function keys, Home, End, etc.) to the correct escape sequences.
This is a substantial amount of work, which is why libraries like py3qterm exist. They've already implemented most of this. However, as the forum discussion that inspired this article notes, these libraries can have rough edges—issues with certain key combinations (like the tilde ~ character), imperfect selection behavior, and missing context menus.
A QProcess-based command runner
If you don't need a full interactive terminal and just want to run commands and display their output, you can use QProcess directly. This is much simpler and works cross-platform:
import sys
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QPlainTextEdit,
QLineEdit, QPushButton, QHBoxLayout,
)
from PyQt6.QtCore import QProcess
class CommandRunner(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Command Runner")
self.resize(700, 500)
layout = QVBoxLayout(self)
self.output = QPlainTextEdit(self)
self.output.setReadOnly(True)
self.output.setStyleSheet(
"background-color: #1e1e1e; color: #d4d4d4; "
"font-family: monospace; font-size: 11pt;"
)
layout.addWidget(self.output)
input_layout = QHBoxLayout()
self.command_input = QLineEdit(self)
self.command_input.setPlaceholderText("Enter a command...")
self.command_input.returnPressed.connect(self.run_command)
input_layout.addWidget(self.command_input)
self.run_button = QPushButton("Run", self)
self.run_button.clicked.connect(self.run_command)
input_layout.addWidget(self.run_button)
layout.addLayout(input_layout)
self.process = None
def run_command(self):
command = self.command_input.text().strip()
if not command:
return
self.output.appendPlainText(f"$ {command}\n")
self.command_input.clear()
self.process = QProcess(self)
self.process.setProcessChannelMode(QProcess.MergedChannels)
self.process.readyReadStandardOutput.connect(self.handle_output)
self.process.finished.connect(self.handle_finished)
# Split into program and arguments.
parts = command.split()
program = parts[0]
args = parts[1:] if len(parts) > 1 else []
self.process.start(program, args)
def handle_output(self):
data = self.process.readAllStandardOutput()
text = bytes(data).decode("utf-8", errors="replace")
self.output.insertPlainText(text)
scrollbar = self.output.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def handle_finished(self, exit_code, exit_status):
self.output.appendPlainText(f"\n[Process exited with code {exit_code}]\n")
if __name__ == "__main__":
app = QApplication(sys.argv)
window = CommandRunner()
window.show()
sys.exit(app.exec())
This gives you a text area showing command output and an input field where you can type commands. It works on all platforms, and you have full control over the UI—context menus, copy/paste, text selection all work normally because you're using standard Qt widgets.
The trade-off is that this isn't a real terminal. Interactive programs (vim, htop, ssh) won't work properly. Tab completion, command history, and other shell features are also missing. But for running commands and displaying results, it's reliable and simple.
Web-based terminal via QWebEngineView
Another approach is to use a JavaScript-based terminal emulator like xterm.js inside a QWebEngineView. This gives you a polished, fully-featured terminal interface rendered in a web view, with a WebSocket or similar connection back to a shell process running on the host.
The general architecture looks like this:
QWebEngineView (xterm.js) <---> WebSocket <---> Python server <---> pty/shell
This produces an excellent terminal experience—smooth rendering, good mouse support, proper copy/paste—but it comes with significant overhead:
- QWebEngineView is a Chromium-based web engine. It adds substantial memory usage and binary size to your application.
- On older systems or constrained environments, QWebEngineView may not be available or may be difficult to build.
- The WebSocket server adds architectural complexity.
If you're building a larger application where QWebEngineView is already in use, this can be a good option. For smaller tools, it's likely more weight than you want.
Comparing the approaches
| Approach | Cross-platform | Interactive programs | Complexity | UI control |
|---|---|---|---|---|
xterm -into (X11 embed) |
Linux/X11 only | Yes | Medium | Low |
| pty-based widget (py3qterm) | Linux/macOS | Yes | High | Medium |
| QProcess command runner | Yes | No | Low | High |
| xterm.js + QWebEngineView | Yes | Yes | High | Medium |
Recommendations
If you need to run commands and show output in a cross-platform app, the QProcess command runner approach is the most practical starting point. You get reliable behavior, full control over the Qt UI, and no platform-specific dependencies.
If you need a full interactive terminal on Linux, the pty-based approach is the most promising path. Libraries like py3qterm get you most of the way there, and the remaining issues (key handling quirks, context menus) are solvable within the Qt event system. You can subclass the terminal widget and override contextMenuEvent to add copy/paste actions, and fix key mapping issues in keyPressEvent.
If you're already using QWebEngineView in your application, the xterm.js approach gives the most polished terminal experience, at the cost of added complexity and resource usage.
The xterm -into embedding approach is a quick hack that works on X11 but shouldn't be relied on for production applications. It's fine for personal tools or prototypes where you know the target environment.
Complete example: QProcess command runner with context menu
Here's a more complete version of the QProcess-based command runner that includes a context menu for copy/paste and basic command history:
import sys
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QPlainTextEdit,
QLineEdit, QPushButton, QHBoxLayout, QMenu, QAction,
)
from PyQt6.QtCore import QProcess, Qt
from PyQt6.QtGui import QTextCursor
class TerminalOutput(QPlainTextEdit):
"""A text area with a custom context menu for copy/paste."""
def contextMenuEvent(self, event):
menu = QMenu(self)
copy_action = QAction("Copy", self)
copy_action.setShortcut("Ctrl+C")
copy_action.triggered.connect(self.copy)
copy_action.setEnabled(self.textCursor().hasSelection())
menu.addAction(copy_action)
select_all_action = QAction("Select All", self)
select_all_action.setShortcut("Ctrl+A")
select_all_action.triggered.connect(self.selectAll)
menu.addAction(select_all_action)
menu.addSeparator()
clear_action = QAction("Clear", self)
clear_action.triggered.connect(self.clear)
menu.addAction(clear_action)
menu.exec(event.globalPos())
class CommandRunner(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("PyQt6 Command Runner")
self.resize(750, 550)
self.command_history = []
self.history_index = -1
layout = QVBoxLayout(self)
self.output = TerminalOutput(self)
self.output.setReadOnly(True)
self.output.setStyleSheet(
"background-color: #1e1e1e; color: #d4d4d4; "
"font-family: 'Courier New', monospace; font-size: 11pt;"
)
layout.addWidget(self.output)
input_layout = QHBoxLayout()
self.command_input = QLineEdit(self)
self.command_input.setPlaceholderText(
"Enter a command and press Enter..."
)
self.command_input.returnPressed.connect(self.run_command)
self.command_input.installEventFilter(self)
input_layout.addWidget(self.command_input)
self.run_button = QPushButton("Run", self)
self.run_button.clicked.connect(self.run_command)
input_layout.addWidget(self.run_button)
layout.addLayout(input_layout)
self.process = None
def eventFilter(self, obj, event):
"""Handle up/down arrow keys for command history."""
if obj is self.command_input and event.type() == event.KeyPress:
if event.key() == Qt.Key_Up:
self.navigate_history(-1)
return True
elif event.key() == Qt.Key_Down:
self.navigate_history(1)
return True
return super().eventFilter(obj, event)
def navigate_history(self, direction):
if not self.command_history:
return
self.history_index += direction
self.history_index = max(
0, min(self.history_index, len(self.command_history) - 1)
)
self.command_input.setText(
self.command_history[self.history_index]
)
def run_command(self):
command = self.command_input.text().strip()
if not command:
return
# Add to history.
self.command_history.append(command)
self.history_index = len(self.command_history)
self.output.appendPlainText(f"$ {command}")
self.command_input.clear()
# Disable input while command runs.
self.command_input.setEnabled(False)
self.run_button.setEnabled(False)
self.process = QProcess(self)
self.process.setProcessChannelMode(QProcess.MergedChannels)
self.process.readyReadStandardOutput.connect(self.handle_output)
self.process.finished.connect(self.handle_finished)
parts = command.split()
program = parts[0]
args = parts[1:] if len(parts) > 1 else []
self.process.start(program, args)
def handle_output(self):
data = self.process.readAllStandardOutput()
text = bytes(data).decode("utf-8", errors="replace")
# Move cursor to end before inserting.
cursor = self.output.textCursor()
cursor.movePosition(QTextCursor.End)
self.output.setTextCursor(cursor)
self.output.insertPlainText(text)
# Auto-scroll.
scrollbar = self.output.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def handle_finished(self, exit_code, exit_status):
status_text = "normally" if exit_status == QProcess.NormalExit else "with crash"
self.output.appendPlainText(
f"[Process finished {status_text}, exit code {exit_code}]\n"
)
self.command_input.setEnabled(True)
self.run_button.setEnabled(True)
self.command_input.setFocus()
def closeEvent(self, event):
if self.process and self.process.state() == QProcess.Running:
self.process.terminate()
self.process.waitForFinished(3000)
super().closeEvent(event)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = CommandRunner()
window.show()
sys.exit(app.exec())
This gives you a working command runner with:
- A dark-themed output area with a right-click context menu for copying text and clearing the display.
- Command history navigation with the up and down arrow keys.
- Input is disabled while a command is running, preventing commands from overlapping.
- Clean process shutdown when you close the window.
Try running it and entering commands like ls -la, echo "hello world", or python --version. Each command's output appears in the terminal area, and you can scroll back through the history.
From here, you could extend it with features like support for setting the working directory, environment variable management, or even running commands over SSH using QProcess to launch an ssh client. For a full interactive terminal experience on Linux, look into the pty-based approach as your next step—but for many use cases, this QProcess pattern is all you need.
Create GUI Applications with Python & Qt6 by Martin Fitzpatrick
(PySide6 Edition) The hands-on guide to making apps with Python — Over 15,000 copies sold!