A desktop terminal application (Windows + Linux) built with PySide6, rendering
terminals via embedded xterm.js (in QWebEngineView), backed by real PTYs.
Supports multiple tabs, a customizable sidebar of one-click command buttons,
and a Macros menu for multi-step/multi-tab command sequences.
| Concern | Decision |
|---|---|
| Terminal rendering | xterm.js + addon-fit + addon-webgl, vendored locally in assets/xterm/ (no CDN, offline-capable) |
| Python <-> JS bridge | QWebChannel (no extra port/server) |
| PTY backend | pywinpty (ConPTY) on Windows, ptyprocess (openpty) on Linux, behind a common PtySession wrapper interface. start() takes a real argv list[str], not a bare command string - needed for e.g. ["wsl.exe", "-d", "Ubuntu"] |
| Project tooling | uv (installed via pipx install uv), pyproject.toml + uv.lock |
| Packaging (later) | PyInstaller, one spec per OS |
Commands and macros share one storage format and one Preset shape, but
target is a strict, mutually-exclusive category, not just an execution
detail - every preset is either a Command or a Macro, never both:
- Command (
target: active): a short interaction with a terminal you're actively working in and staying in -git status,clear, etc. Sent to whichever tab is currently active; you keep using that tab afterwards. - Macro (
target: new_tab): a script for something long-running or disruptive to run alongside your current work - starting a dev server, a build, a deploy - so it gets its own fresh tab rather than hijacking the terminal you're in.
Preset:
name: str
group: str # optional, for menu/sidebar section organization
lines: list[str] # one or more shell lines
target: active | new_tab # category: Command vs Macro. Default:
# active if len(lines)==1 else new_tab
show_in_sidebar: bool # opt-in to sidebar button; only meaningful
# (and only offered in the editor) for
# target: active presets - Macros don't
# appear in the sidebar, only the Macros menuStored as a single JSON file under the platformdirs user config dir
(presets.py::PresetStore, built in Phase 3).
- Commands sidebar: shows only
target: activepresets withshow_in_sidebar: true. One-click sendslinesto the currently active terminal tab. Fast, always visible. (Built in Phase 3.) - Macros menu (dropdown, grouped by
group): shows onlytarget: new_tabpresets, with Run / Edit / New / Delete. Clicking one always opens a fresh tab, spawns a PTY, and feedslinesinto it in sequence. (Phase 4.)
Decision: keep the Commands/Macros separation, and only Commands appear as
sidebar buttons. Considered and rejected: collapsing both into one type
with a run in: active | new tab field, and a separate Button/layout entity
on top of presets. What is in the code today is the intended design, not an
interim state.
The obvious objection is that Commands and Macros do the same thing: feed
lines to a shell. The only mechanical difference is which tab receives them.
That reading makes the categories look like an execution detail promoted to a
taxonomy, and it argues for one "Command" with a run in: active | new tab
field.
The split holds because the real distinction is the interaction pattern, not the execution target - where it runs falls out of that, not the other way round:
- Command - contextual and frequent. You're looking at a terminal and you
reach for it. That is a button beside the terminal.
Commandandsidebar buttonare close to synonymous; running in the active tab is a consequence of being a thing you click while working in that tab. - Macro - occasional, and it launches something that goes elsewhere. You are not mid-flow in a terminal when you start a dev server. That is a menu item, and a fresh tab is a consequence of it being disruptive.
Two things follow, and both are deliberate:
- A Command need not be a sidebar button.
show_in_sidebarkeeps the sidebar a curated subset; the right-clickCommandsubmenu lists all of them. "Button" is a property of a Command, not a synonym for it. - Commands stay out of the menu bar. The menu bar is the surface furthest
from the terminal, which contradicts the contextual argument above, and it
would be a third surface to keep in sync (sidebar, right-click, menu bar).
Individual Command listings were removed from the Commands menu in
4ce011cfor exactly this reason. If the underlying need turns out to be keyboard access, the answer is per-preset keyboard shortcuts, not menu items - that serves the contextual model instead of fighting it.
Known costs, accepted deliberately - these are consequences of the decision, not oversights to be "fixed":
- A Macro cannot be pinned to the sidebar, even if it is your most-used action. Buttons are for Commands; a Macro that you reach for constantly is a sign it wanted to be a Command.
- Macros are absent from the terminal right-click menu, where Commands appear.
- The Commands menu bar entry lists nothing while the Macros one lists everything, so two menus for one concept behave differently.
If it is ever reopened, note that unification needs no storage change:
target and show_in_sidebar already exist, so it is a presentation change,
cheap to reverse in either direction. The plausible trigger would be a
concrete case the split cannot serve - most likely wanting a one-click "start
dev server" button.
Selection Actions are a genuinely different category and are unaffected by
any of this: they consume the terminal's selection, and a url one never
touches a shell at all. The durable line is takes input vs doesn't.
Layout is a distinct, user-editable arrangement on top of the preset list - editing placement/appearance, not the command itself:
SidebarLayout:
sections:
- title: str
columns: int # buttons per row
button_size: small|medium|large
preset_refs: list[str] # ordered preset names in this sectionEdited via an "Edit Layout" mode on the sidebar (drag to reorder/add/remove),
stored separately from presets.json (e.g. sidebar_layout.json) so layout
and content can evolve independently.
Goal: prove the core rendering + I/O loop works cross-platform before any tabs/macros/sidebar complexity.
-
uv initproject, add deps:PySide6,PySide6-Addons(WebEngine),pywinpty(win only),ptyprocess(posix only) - Vendor xterm.js + addon-fit into
assets/xterm/ -
PtySessionabstract wrapper:start(shell, cols, rows),write(data),resize(cols, rows),on_outputcallback,close(),is_aliveWinPtySessionusingpywinptyPosixPtySessionusingptyprocess(verified on Ubuntu 24.04 under WSL - see Phase 4r)
-
TerminalWidget(QWidget): hostsQWebEngineViewloading localterminal.html, wiresQWebChannelobject (TerminalBridge) exposingsendInput/resize/ready/setTitle(JS->Py) andoutput/exited/title_changed(Py->JS) -
terminal.html/terminal.js: init xterm.js + fit addon, pipe keystrokes to bridge, render incoming PTY output, handle resize -> notify bridge -
MainWindow(QMainWindow): hosts terminal(s), spawns default shell ($SHELLon Linux,powershell.exeon Windows) on startup - Verify: shell prompt renders, keyboard I/O works, resizing the window resizes the PTY, full-screen apps (vim) render correctly, process exit closes cleanly (caught + fixed a PTY-leak-on-close bug: child widgets never get
closeEventfrom a closingQMainWindow) - pytest + pytest-qt suite: PTY backend (real spawn/write/read/resize), bridge signal re-emission, widget wiring against a fake PtySession
Definition of done for Phase 1: one window, one working real terminal, on both Windows and Linux, no crashes on resize/exit. (Windows verified end-to-end; Linux backend implemented but untested - no Linux machine available yet.)
-
TerminalTabWidget(QTabWidget)central widget, each tab = oneTerminalWidget+PtySession - New tab (
+corner button, Ctrl+Shift+T), close tab (per-tab "x", Ctrl+Shift+W), Ctrl+Tab/Ctrl+Shift+Tab to switch - deliberately not plain Ctrl+T/W, which would fight bash/readline's Ctrl+W word-delete - Tab labels: tmux-style
"{index}:{shell}"(bash,cmd,powershell), renumbered on add/close/reorder. Deliberately not the shell's OSC title: Git Bash sendsMINGW64:/c/Users/dev/git/qtxtermand cmd its own full exe path, which made tabs unreadably wide. The live OSC title (xterm.js onTitleChange-> bridge) becomes the tab's tooltip instead, so the cwd is still reachable -
active_terminal()tracks the current tab'sTerminalWidgetfor later command/macro targeting - Closing the last tab closes the window; closing the window (titlebar X) shuts down every tab's PTY, not just the active one
- pytest-qt suite: tab creation/labeling/renumbering, active-terminal tracking, all-tabs-closed vs close-all-tabs semantics, next/prev wraparound
-
Presetdataclass +PresetStoreJSON persistence (presets.jsonunder the platformdirs user config dir), seeded with example presets on first run -
CommandSidebardock widget: buttons grouped bygroup(ungrouped flat at top), single column - full drag-and-dropSidebarLayoutarrangement deferred to Phase 4, per-scope decision (not enough presets/macros yet to need it) - Click ->
run_in_active()sendslines+ Enter to the active terminal's PTY. Only ever showstarget: activepresets (Commands) - see the Commands vs Macros split below, finalized during Phase 4 -
PresetEditorDialog(add/edit/delete, set group + show_in_sidebar) - pytest suite: PresetStore CRUD/persistence, sidebar grouping/click emission, editor dialog New/Save/Delete flows
- Commands vs Macros finalized as a strict split on
target(see "Data Model" above): every preset is a Command (active) or a Macro (new_tab), never both.PresetEditorDialoggained an explicit Type dropdown - target is no longer inferred purely from line count, and picking Macro live-disables "Show in sidebar" -
MacrosMenu(QMenu): lists onlytarget: new_tabpresets, grouped bygroupinto submenus (ungrouped ones flat), plus New Macro.../Manage Presets... actions - Running a macro ->
TerminalTabWidget.run_in_new_tab(): opens a tab, feedslinesonce the PTY has actually started (TerminalWidget.pty_started/run_when_ready()) - fixes a real race where writing immediately afternew_tab()returns silently drops input, since the PTY only starts after an async QWebEngineView load -> xterm.js boot -> JS-calls-Python round trip -
PresetStoreis now reactive (changedsignal after everysave()), so the sidebar and Macros menu both auto-refresh regardless of which surface (or dialog) made the edit - pytest suite: MacrosMenu grouping/actions/auto-reload, run_when_ready (immediate vs deferred), run_in_new_tab against a fake PTY
- Verified end-to-end: menu renders grouped/ungrouped macros correctly; triggering one opens a new tab and runs every script line in sequence
-
Filemenu (beforeMacrosin the menu bar):New Terminalsubmenu +Exit.New Terminalalways has "Default Shell" (Ctrl+Shift+T shown as a hint, not a duplicate binding) plus one entry per shellknown_shells()(src/qtxterm/shells.py) actually finds installed - Windows-only for now (PowerShell/CMD/Git Bash/WSL are Windows concepts);
known_shells()returns[]on other platforms - Git Bash resolved via its standard install dirs, not
shutil.which(which can resolve to the unrelated legacy WSLbash.exeshim in System32) - WSL resolved by listing real distros (
wsl.exe -l -q, UTF-16LE decoded) and picking one explicitly (wsl.exe -d <name>), skipping Docker Desktop's non-interactivedocker-desktop/docker-desktop-data- barewsl.exelaunches whatever's marked "default", which can be one of those and fail outright with no usable shell -
PtySession.start()changed from a bare shell string to a real argvlist[str]throughout (TerminalWidget,TerminalTabWidget), needed forwsl.exe -d <name>-style multi-arg commands - Fixed along the way:
WinPtySessionpassed a bare string toPtyProcess.spawn(), which shlex-splits on whitespace and silently broke any shell path containing a space (e.g. Git Bash's defaultC:\Program Files\Git\bin\bash.exe) - Fixed along the way: a PySide6/shiboken lifetime issue where
QMenu.addMenu()'s returned submenu can be garbage-collected without a retained Python reference, despite being C++-parented to a live menu - affected both the new File menu andMacrosMenu's group submenus - pytest suite for
known_shells(); verified end-to-end that all four shells spawn correctly in a new tab
-
Commandsmenu item (menu bar order: File, Commands, Macros), alongside the existing sidebar rather than replacing it -
MacrosMenurefactored into a shared_PresetCategoryMenubase (src/qtxterm/preset_menu.py, replacesmacros_menu.py);CommandsMenuonly supplies its target (active), run behavior (run_in_active), and labels - grouping/reload/New/Manage logic is shared - Lists every
target: activepreset, not just sidebar-pinned ones - the sidebar stays the curated quick-access view, this menu is the full list - pytest suite (renamed to
test_preset_menu.py, covers both menus); verified end-to-end
- Right-clicking a terminal opens a context menu with a
Commandsubmenu of everytarget: activepreset, grouped bygroup; picking one sends it to that terminal. Lists all Commands, not just sidebar-pinned ones - the sidebar stays the curated view. - Copy/Paste in the same menu. Copy reads a cached selection that
xterm.js pushes over the bridge on every
onSelectionChange, rather than fetching it on demand:runJavaScriptis async, so an on-demand read would resolve too late to enable or disable a menu item that is already being shown. Paste callsterm.paste()in JS instead of writing to the PTY directly, so bracketed paste mode is honored and a multi-line clipboard isn't read as a series of typed commands. Availability is refreshed onaboutToShow- selection and clipboard both change without the preset store changing. -
QWebEngineViewneedsCustomContextMenupolicy or it shows Chromium's own Back/Reload/View Source menu. The request is re-emitted asTerminalWidget.context_menu_requested(global pos) and forwarded byTerminalTabWidget, soMainWindowwires one shared menu instead of one per tab. - Fixed along the way:
QMenu.addMenu(str)gives PySide6 ownership of the new submenu, andQAction.menu()then ties its lifetime to the returned wrapper - so a discardedaction.menu()lookup destroys the submenu for every holder, including a parent that deliberately kept a reference. Submenus are now built with an explicit C++ parent (add_submenu), which also made the old reference-retention lists unnecessary;clear_menudisposes of them on reload so rebuilding doesn't orphan a QMenu per change.
- Third preset category alongside Commands and Macros, keyed off a new
inputfield (none|selection);category_of()is now the single place the split is decided, replacing scatteredtarget ==checks. Reachable from the terminal right-click menu underSelection, which is disabled without a live selection and shows a truncated preview of what will be sent. -
kind(url|stdin) decides how the selection travels, because each route needs different escaping. The selection is never interpolated into a command line: that route (arg) is deliberately not offered, since it would mean owning a per-shell quoting matrix across PowerShell/cmd/bash/WSL, and would still break on multi-line selections and on Windows' ~8191-char command-line cap.url- percent-encoded withsafe=""into a{selection}placeholder and opened via QDesktopServices; never touches a shell. Capped at 1500 chars, below the ~2083 practical URL ceiling.stdin- written to a temp file the command reads on standard input.
- Fixed along the way:
<is not portable, contrary to the original design. Windows PowerShell reserves it ("The '<' operator is reserved for future use"), sofeed_from_file()is per-shell:Get-Content -Raw | cmdfor powershell/pwsh,type | cmdfor cmd,< pathfor POSIX shells, and< /mnt/c/...for WSL, whose Linux side can't see a Windows path. Only the generated path is interpolated, never the selection, so the safety property is unchanged. Caught by running it against a real PowerShell tab - unit tests all passed with the broken form. - Temp files are swept at startup (24h old), not on tab close: the command reading one may outlive the tab.
- Menu bar order is now File, Macros, Commands, Selection, Help - one
menu per preset category.
SelectionMenuis management-only, the same shape asCommandsMenuand for the same reason: running an action needs a live selection, which a menu bar item can't offer, so that half lives in the terminal's right-clickSelectionsubmenu instead. - Defaults are only seeded on first run, so pre-existing installs get an
empty right-click
Selection;Selection -> Manage Selection Actions... -> Add Examplesadds the built-in examples by name without duplicating.
-
File -> Preferences...gains a Default shell combo: System default, or any detected shell. Resolved inTerminalTabWidget.new_tab()rather than by callers, so every route to a new tab (+ button, Ctrl+Shift+T, Macros, Selection Actions) honours it; an explicitshell=argument (File -> New Terminal) still wins. -
ShellPreferenceStorepersists the shell's label, not its resolved argv: readable in the ini, portable between machines, and it degrades to the system default if that shell is uninstalled later - a stored argv would simply fail to spawn. -
known_shells()now lists every installed WSL distro separately (WSL: Ubuntu-22.04) instead of silently picking the first one; data-only Docker distros stay filtered out. -
selection_actions.default_shell_name()removed -shell_name_for()askstabs.default_shell_name()instead, so a stdin action opening a new tab picks the redirection form for the shell that tab will actually run.
-
File -> New Browseropens aBrowserWidget(address bar + QWebEngineView + back/forward/reload) as a tab beside the terminals. -
normalize_url()guesses between "go here" and "look this up": a scheme loads verbatim, a dotted host (or localhost[:port]) gets https://, anything else becomes a search -https://hello%20worldis never what was meant. - Address bar follows
urlChanged, not only typed input, so it tracks links clicked in the page and redirects. - Tab label is the host, live-updated - unlike a shell's OSC title, a host is short and is exactly what identifies the tab. Page title goes in the tooltip.
-
active_terminal()is now type-checked instead of returningcurrentWidget(): with browser tabs in the mix, the sidebar, Command submenu and Selection Actions would otherwise try to write to a web page. They no-op while a browser tab is active. - No
QWebChannelis registered on a browsed page, unlike terminal.html - a web page must never reachTerminalBridgeand be able to write to a PTY.
- Sidebar "Edit Layout" mode (drag reorder, section management) - deferred here from Phase 3/4 twice now; revisit once real usage shows it's actually needed
- PyInstaller specs (Windows
.exe, Linux binary/AppImage) - App icon (
src/qtxterm/branding.py): an SVG terminal-window mark, plus a simplified chevron+cursor variant used at 32px and below where the frame and title dots blur. Both are registered as per-size pixmaps on oneQIcon- Qt's SVG icon engine keeps one entry per mode/state, soQIcon.addFile()overwrites instead of keying by size and the last-added file would win everywhere. Windows also needs an explicit AppUserModelID, or the taskbar groups the app under python.exe and shows the interpreter's icon regardless ofsetWindowIcon(). - Settings persistence (last window size, default shell), themes (xterm.js theme presets)
Both Commands and Macros execute the same way - each line is written to the
PTY followed by Enter. They differ only in where they run (target), not in
how. Verified empirically:
- Sequencing works. Lines run strictly one after another, each completing
before the next starts, because the terminal's input buffer queues them and
the shell reads one line at a time. A macro whose first line is
Start-Sleep -Seconds 3does not run line 2 until the sleep finishes, and nothing is garbled or lost. This matches script behavior. - No stop-on-error. Unlike
set -eor&&chaining, a failing line does not halt the rest. Deliberately left as-is - matches "paste these commands into my terminal" semantics. If revisited, note that Windows PowerShell 5.1 (the default shell here) has no&&, so a fix needs per-shell handling. - Interactive prompts consume queued lines. If a line triggers a
sudopassword, ay/nconfirm, or a full-screen app, the buffered keystrokes are read as input to that prompt rather than as commands. Inherent to feeding a live terminal's keyboard buffer; the tradeoff for running in a session you can keep interacting with.
- Double-click a tab to rename it (
QInputDialog, prefilled with the current name); blank restores the automatic name. - Titles are two layers rather than one:
_auto_titles(shell name, or a browser tab's host) and_custom_titles(user-set, wins when present). Kept apart so a rename isn't silently overwritten the next time the automatic name changes - a browser tab renames itself on every navigation - and so clearing a rename falls back to the current automatic name, not the one the tab was born with. -
tabBarDoubleClickedfires with -1 for a double-click on empty tab bar space; that is ignored rather than prompting for a nonexistent tab.
Groundwork only - no visible change. A tab still holds exactly one terminal.
-
active_terminal()now means the focused pane of the current tab rather than "the current tab". It walks the tab's subtree, prefers the terminal last focused there, and falls back to the first one found. - Focus is tracked via
QApplication.focusChangedplus_owning_terminal(), which walks up the parent chain. An event filter per terminal would not do: keyboard focus inside a terminal lands on a Chromium child widget, not on theTerminalWidget. -
_terminals_in()/_panes_in()make the appearance and shutdown loops subtree-aware, so they already reach panes nested in a splitter.
Why this landed first, on its own: it is the only part of splitting that can
fail silently. If the active pane is wrong, a sidebar click sends git status to the wrong terminal with no error. Everything else about splitting
(the splitter tree, close semantics, the active-pane border) fails visibly.
-
split_active()builds a nestedQSplittertree;close_active_pane()closes one pane and unwraps any splitter left holding a single child, so the tree doesn't accumulate pointless single-child splitters. - Active-pane outline, painted in
TerminalWidget.paintEventrather than styled - aQWebEngineView's native surface ignores a stylesheet border on its parent. Shown only when a tab has more than one pane. - Right-click Split Right / Split Down / Close Pane, plus
Alt+Shift+=,Alt+Shift+-,Alt+Shift+W. Alt+Shift follows Windows Terminal: shells and TUIs rarely bind those, unlike Ctrl+Shift. - Three Qt traps found by measuring geometry, each of which silently
produced a zero-width pane rather than an error:
setSizes([1, 1])takes pixels, not ratios - it gives each pane one pixel and dumps the rest into the last one._even_out()uses stretch factors plus real halves, and runs again on the next event-loop turn because a freshly inserted splitter has no geometry yet.- Adding a widget to a
QSplitterpulls it out of the tab widget's stack, so the tab surgery has to happen before reparenting, not after. removeTab()explicitly hides the page it detaches, and a parentless widget starts hidden; a hidden splitter child is laid out at zero size. Both panes are shown explicitly.
-
move_active_pane(forward)swaps the focused pane with its neighbour; the size list is deliberately left alone, so positions keep their widths and the panes trade places rather than shuffling the layout. -
move_active_pane_to_new_tab()pulls a pane out into its own tab, reusing_collapse_single_child_splitters()to tidy the source. The pane keeps its shell, scrollback and PTY - only its container changes. - Menu labels follow the splitter's orientation: "Move Pane Left/Right" would be a lie in a stacked split, so it reads Up/Down there.
Mouse drag-and-drop of panes was considered and rejected for now: QSplitter
has no notion of dragging children to reorder, so it means hand-rolling a
drag source, drop targets and the tree surgery per drop - a custom docking
layer. Windows Terminal, iTerm2 and tmux don't offer it either. If it ever
becomes a hard requirement, nesting a QMainWindow per tab with panes as
QDockWidgets would get it from Qt for free, at the cost of a title bar on
every pane.
-
PaneWidgetbase class, inherited byTerminalWidgetandBrowserWidget, carrying the shared contract (default_title,shutdown(),apply_appearance()) and the active-pane outline. - Splitting works on any pane and yields the same kind: a browser splits into a browser, a terminal into a terminal. "Split" reads as "another one of these", and it keeps the shortcut unambiguous.
-
active_pane()(either kind) drives splitting, moving and closing;active_terminal()stays terminal-only and returns None when a browser pane is focused. It deliberately does not fall back to another terminal in the tab - that would run a sidebar command somewhere you weren't looking. - A shared base type is what makes
_panes_in()correct by construction. Matching onTerminalWidgetalone made it fall back to the tab's root widget, so a split tab handed aQSplittertoshutdown()- the AttributeError fixed in 2ae5958. A splitter holding only browsers would have reproduced it; now it can't. - Browser panes are keyboard-only for split/close: a web page keeps Chromium's context menu, which is what you want for links and images.
Remaining, not started:
- Focus-navigation shortcuts between panes (currently click to focus).
- Tab labels still name one shell when a tab holds several panes.
- Per-pane close on shell exit.
QDockWidget was considered and rejected for panes: docks are built for
peripheral tool windows around a central widget, carry title bars you would
not want on every pane, and bring float/drag-out behaviour that a splitter
does not need. The sidebar stays a dock; terminals do not.
- Startup no longer opens a terminal, and closing the last one no longer
closes the window (
all_tabs_closedis still emitted, just not wired toclose()). Opening the app doesn't decide what you wanted to open, and closing a tab doesn't quit the app out from under you. - An empty tab widget is a blank rectangle, so
paintEventdraws a hint naming the two ways to open a terminal. The+corner button is deliberately not named: with zero tabs Qt doesn't lay out the tab bar row, so the corner widget is never drawn - even thoughcornerWidget().isVisible()still returns True, which is how the first version of the hint came to advertise a button that wasn't on screen.
Opening the app empty (Phase 4j) exposed a cost that used to hide inside start-up: the first terminal flashed while Chromium started.
- Measured before fixing: first terminal 0.64s to
pty_started, second 0.17s. QtWebEngine spawns its render process lazily, on the first page load, so the first terminal paid all of it. - The bigger half was not speed at all: adding the first
QWebEngineView to a top-level window makes Qt rebuild the native
window. The HWND genuinely changes (measured 5179268 -> 5244804 on the
first terminal, stable after), which on screen reads as the window
closing and reopening. A
QWebEnginePagedoes not trigger it - only a view, because only a view is in the widget hierarchy. -
webengine.prepare_window()creates a 1x1WA_DontShowOnScreenQWebEngineView inside the window at the end ofMainWindow.__init__, before the window is shown, and discards it once loaded. That pays both costs off screen: the HWND then never changes, and the first terminal drops from 0.64s to ~0.26s. The view is disposable - verified the native window keeps its new form without it. -
page().setBackgroundColor(theme.background)on every terminal view. AQWebEngineViewpaints white until terminal.js applies the theme, which flashed on every new terminal, not just the first - worst on a dark theme. Re-applied on theme change. - Warm-up lives in
app.main(), notMainWindow.__init__: tests build windows constantly and shouldn't each spawn a render process.
Menus, tabs and the content frame all had borders too faint to see on dark themes.
- Cause: Fusion derives a menu's frame from
palette.window().darker(140)- darker than the menu it outlines. Measured contrast: 1.00:1 on VS Code Dark High Contrast (black on black), 1.14:1 on Solarized Dark, ~2:1 on the light themes. - Same cause for the tab strip and the frame around the terminal:
measured 1.3:1 (
#1e1e1eon#000000) on VS Code Dark High Contrast. -
chrome_border_color()mixes the window text colour into the window instead, stepping up only until the frame clears 3:1 - WCAG's bar for non-text UI, which is the right one for something you only have to see, not read. One rule works in both directions: lighter on dark themes, darker on light ones. Now 3.66 / 3.20 / 3.15 / 3.10:1. - Applied as an app style sheet (
chrome_stylesheet()) coveringQMenu,QTabWidget::paneandQTabBar::tab. Menus keep their palette-driven item painting - only the frame is restyled - but tabs have to be spelled out, because styling any part ofQTabBar::tabmakes the style sheet take over painting it. - Which tab is selected therefore needed restating, and not with
alternate_base: that is#0d0d0dagainst a#000000window, so the first attempt left the selected tab indistinguishable from its neighbour. The cue is an accent line in the theme's highlight colour plus full-strength text, with unselected tabs dimmed - the signal VS Code's own tab strip uses, and one that survives any palette. - The active-pane outline had the same problem from a different source:
it paints
QPalette::Highlightraw, which is 2.34:1 against black on VS Code Dark High Contrast - dimmer than the 3.66:1 frames beside it.ensure_contrast()lifts it in HSL rather than blending toward white, so the hue survives; an accent washed to grey is no longer an accent. - Its bar (
ACTIVE_PANE_CONTRAST, 4.0) is deliberately above the frames' 3.0: it marks state and has to out-shout the static chrome next to it. 4.0 clears them everywhere without recolouring accents that already passed - 4.5 starts dragging Solarized Dark's blue lighter for no gain. Measured on screen afterwards: #166ec6, 4.08:1. - Inactive panes previously drew no frame at all, so on a dark theme only
the splitter gap separated them. Every pane in a split now gets one,
from the same
frame_color()the tab strip and content edge use - so the three can't drift apart - while the focused pane keeps the accent. A pane alone in its tab still draws nothing: the tab already outlines it, and a second box inside the first is noise.set_pane_state(in_split, active)carries both facts, replacing a single flag that conflated them. - Side effect worth knowing: an app style sheet makes Qt wrap the style,
and the wrapper reports an empty
objectName(). A test assertingapp.style().objectName() == "fusion"had to change - restoring the native style is still covered separately.
"If a terminal starts a long-running process, does closing the tab kill it?"
Measured on Windows against real PTYs (ping -n 600 to a unique address, so
the process could be identified by command line):
| Spawned how | Closing the tab / window |
|---|---|
Direct child (ping ...) |
killed |
Grandchild (cmd /c ping ...) |
killed |
| The shell itself | killed |
App hard-killed, no closeEvent (crash, Task Manager) |
killed |
Detached (Start-Process, start) |
survives |
The mechanism is not terminate(force=True) in WinPtySession.close().
That kills the shell only, and on Windows killing a process orphans its
children. What actually cleans up is ConPTY teardown: closing the
pseudoconsole tears down the console and every process attached to it. That
is also why the crash case is safe - process death closes the handles, so the
pseudoconsole dies even though no cleanup code ran.
The survivor is correct, not a bug: Start-Process explicitly creates a
process not attached to the console, and killing it would break the
documented contract of that command. Windows Terminal, cmd and PowerShell all
behave the same way.
Not verified on Linux. PosixPtySession.close() is the same
terminate(force=True), but the mechanism differs - closing the pty master
sends SIGHUP to the foreground process group, which normally reaps
children, while setsid/nohup processes survive. Same boundary, different
plumbing; the POSIX backend has still never been run (see Phase 1).
Worth re-checking if the PTY backend, the shutdown path, or the tab-close path is ever reworked.
Found while testing multi-step Macros: a macro that split a pane and then
opened a second tab left a PowerShell stack trace across the new pane -
PSConsoleReadLine.SelfInsert → SetCursorPosition →
ArgumentOutOfRangeException: Actual value was -1.
- Not a race on typing, as it first looked. Measured: the pane's PTY started at 54x1. PSReadLine renders its prompt into a one-row console, computes a cursor row of -1, and throws.
- Cause: Chromium skips layout for a view whose tab is in the
background. Measured with the panes left in a background tab - Qt
geometry 429x248, page viewport 30px; switching to that tab made it
248px immediately.
fitAddon.fit()had faithfully fitted the stale viewport.page().setVisible(True), hide/show and a ±1px resize poke all failed to force the layout. - Fix: the page no longer measures itself.
terminal.jsexposeswindow.applySize(w, h), which sizes#terminalin explicit pixels, fits, and reportsready(first call) orresize(later ones).TerminalWidget._apply_size()pushes the view's size onresizeEventand on the newbridge.loaded()handshake - Qt logical pixels map 1:1 to CSS pixels, verified (view 425x505 → document 505px). - The old
window.addEventListener("resize")path is gone; one source of truth. Verified afterwards: 3/3 crash-free runs (was 3/3 crashing), every pane starting at a real size, and resize still reaching the shell on window resize, split, splitter drag and font-size change. - Font-size changes now report the new grid too.
applyAppearance()refitted without telling the PTY, so the shell kept wrapping to the old width - a pre-existing bug this path exposed.
Where each group sits in the terminal right-click menu is a matter of which one you reach for, so it is a preference rather than a fixed opinion.
-
menu_prefs.ContextMenuOrderStorepersists the order of the four sections (menu/context_orderin the ini) and emitschanged, so the one sharedTerminalContextMenurebuilds itself the moment it's saved - same pattern asPresetStore.changed. - Copy/Paste is a movable section too, not a pinned header. It still leads by default - it's the one thing here people hit by muscle memory, and every other terminal puts it first - but a preference that exempts the entry you most want at the bottom isn't much of one.
- It's the only section that isn't a submenu, so it carries its own separators wherever it lands: two bare actions butting straight against a run of submenus read as part of the list above them.
-
normalise_order()drops unknown sections and appends missing ones, so a stale or hand-edited setting can never make a section disappear - a silently vanishing menu item would be a miserable thing to debug. - Up/Down buttons, not drag-and-drop: four rows are too few and too short a target for dragging to be worth its discoverability cost. The moved row keeps the selection, so Move Up twice moves one entry two places.
-
QListWidgetpicked up a themed border inchrome_stylesheet()for the same reason menus and tabs did - Fusion's frame is invisible on a dark theme, which left the rows reading as loose text in the form. Also improves the Manage dialogs' lists.
A terminal is an app you open on Monday and close on Friday, so the failure
that matters is not a crash but decay: it gets heavier, then slower, then
stops drawing. tests/test_soak.py runs one compressed session over and
over and asserts that nothing keeps climbing.
- Deselected from the default run (
addopts = "-m 'not soak'").pytest -m soakis ~90s;--soak-minutes 1440is the full day.--soak-csvdumps every sample, because on a long run the shape of the curve says far more than pass/fail does. - Watched: RSS, GDI/USER handles (Windows caps these per process, and a
GUI that leaks one per window dies of that long before it runs out of
memory), kernel handles, Python objects, live widgets, and event-loop
latency. Read through
ctypes, not psutil - a soak test that needs an extra package installed is one nobody runs. - One tab is never closed and is fed output every cycle. "Open for 24 hours" usually means one terminal nobody has touched since Monday, and a test that closes everything each round would miss it entirely.
- Thresholds are per cycle, not per hour. Per-hour looked obvious and was wrong: fitted over a decelerating curve it shrinks the longer you measure - the same healthy process reported +4241 MB/h over 30s, +620 MB/h over 4min and +95 MB/h over 8min. Per-cycle growth doesn't depend on how long the run happened to be, so one threshold covers the smoke run and the 24-hour one.
- Judged on the last quarter of the run, and
DEFAULT_CYCLES = 60because that is where the measurements say things settle: handles stop climbing around cycle 45 (+6.2/cycle over the first 20, +2.1 by 40, +0.16 after), memory around 40-60 (+2.1 MB/cycle early, +0.4 by 40, negative past 130 as the working set is handed back). A run shorter than that checks the structural invariants and says why it skipped the trend, rather than reporting warm-up as a leak. - Structural invariants, which need no trend at all: only the long-lived terminal is alive at the end, the per-widget bookkeeping dicts don't hold closed widgets, the context menu doesn't accumulate submenus, and the count of receivers on each long-lived store is unchanged - a menu that connects per tab and never disconnects makes every save slower than the last.
- Results, 199 cycles: memory settles ~350MB and stops, handles ~1780 and stop, GDI/USER/widgets dead flat, event-loop latency max 16ms with a median of 0 throughout. Nothing found to fix.
- Fake PTYs by default;
--soak-real-shellopts into real ones. Spawning thousands of shells measures the OS more than it measures this app, and the leak-prone half - a QWebEngineView and a Chromium render process per terminal - runs either way.
xterm.js defaults to 1000 lines of scrollback (scrollback: 1e3 in the
vendored bundle) and terminal.js never overrode it, so that was qtxterm's
default by accident rather than by choice. It is now a preference, sitting
next to font size in Preferences.
- Lives on
Appearance, which is already the object pushed to every open terminal on change. Not strictly appearance - it is a buffer size - but it travels the same two paths as font size (query param on load,applyAppearance()live) and inventing a second store to carry one integer along the identical wires would be worse. - Passed as a query param, not pushed after load: xterm.js allocates the buffer when the Terminal is constructed.
-
0is a real setting - keep nothing but the screen - and falsy, so both the JS default (|| 1000would swallow it) and the spin box (which shows "None (screen only)") handle it explicitly. - Clamped to 0..100,000 on load, so a hand-edited ini can't ask xterm.js for a negative buffer. Above its MAX_BUFFER_SIZE xterm silently clamps, and stopping at a number the user chose beats stopping at one they didn't.
- Verified against real xterm.js by writing 2000 lines and measuring the viewport's scrollHeight: 0 -> 391px (the screen alone), 100 -> 2091px, 1000 -> 17391px, 5000 -> 34017px (capped by the 2000 lines written, not by the setting). Each is exactly 17px per retained line.
Multi-step Macros were only documented in SPEC.md, which is no help to someone staring at an empty command box.
- "Add step: New Tab | Split Right | Split Down" buttons under the command box in Manage Macros, each inserting its separator on a line of its own. Inserting mid-line breaks the line first - a separator only counts alone on its line, so otherwise the button would look like it did nothing.
- The button labels are built from
presets.MACRO_STEP_SEPARATORand the placement tokens, and a test runs what they insert back throughmacro_steps(). The two can't drift into a state where a button emits something the parser quietly treats as a plain tab. - A hint line under them says what
---does and that a macro without one runs in a single tab - the buttons cover the common case, the sentence covers the person editing an existing macro by hand. - Text inputs gained the themed border that lists got in 4n: on a dark theme Fusion's frame is invisible, and the macro editor's Name, Group and command box read as labels rather than fields. QSpinBox is deliberately excluded - styling any part of it hands its painting to the style sheet and its arrows come back as one squashed glyph.
The README claimed Windows + Linux while PosixPtySession had never been
run on Linux. Tested under WSL (Ubuntu 24.04, Python 3.12.3, PySide6 6.11.1,
QT_QPA_PLATFORM=offscreen) before wiring up CI. First run: 284 passed, 5
failed. All five are now fixed, and none were bugs in the app's behaviour.
- The argv contract was only enforced on Linux. Two tests called
start(default_shell(), ...)with a bare string.pywinptyquietly shlex-splits that and gets away with it;ptyprocessraises. The app always passed a list, so only the tests were wrong - but the same laxness is whywin.pycarries a comment about paths with spaces. -
wsl_pathwas tested withPath(r"C:\..."), which is a PosixPath on Linux and keeps the backslashes. NowPureWindowsPath, so the translation is exercised on either OS. - The offscreen platform ignores resize ("does not support
propagateSizeHints"), so the window-geometry test cannot observe a
restore. Skipped when
QT_QPA_PLATFORM=offscreen, which means CI should use xvfb if that test is to count. Note the skip has to read the env var:QGuiApplication.platformName()at import time reports the default it would pick ("xcb"), so a skipif on that never fires. - A ~30% flake on
test_the_warm_up_view_is_discarded_once_loaded, which passed alone and always passed on Windows. It was the victim, not the cause: it is the test that sits waiting on the event loop, so pytest-qt reported against it the "Signal source has been deleted" exceptions that earlier tests' teardown left queued. Markedqt_no_exception_capture; 0 failures in 22 subsequent full-suite runs. - Tightened the real cause where it was cheap: the reader thread no
longer emits once
close()has been called, andclose()waits briefly (0.5s, bounded because it runs on the GUI thread) for the thread to finish. Honest note - this did not move the flake rate; the remaining noise is Qt teardown with async work still in flight, and the proper fix is for a widget to stop its page the wayBrowserWidget.shutdown()already does.
Run a Macro on a schedule, for as long as the app is open.
Four decisions, each taken over a plausible alternative:
-
A job names a Macro; it does not carry its own commands. Macros already have an editor, and one command living in two places is how the two copies drift apart. The cost is a job whose Macro is renamed or deleted - handled by reporting it (status bar, and "(missing)" in the editor) rather than silently repointing the job at whatever is first in the list.
-
Macros only - not Commands, not Selection Actions. A Command means "send this to the terminal I am working in", which a job firing at 2am cannot honour: there may be no terminal, or the wrong one. A Selection Action needs a live selection a schedule can never provide. Enforced in the scheduler as well as the editor, so a hand-edited
cron.jsonis refused with a reason rather than quietly typing into whatever tab you were using. -
Real cron expressions, not "every N minutes". Five fields with ranges, lists and steps, because it is syntax people already know. That includes cron's day rule: with both day-of-month and day-of-week restricted, a day matching either fires. Kept deliberately, since schedules copied in from a crontab depend on it.
-
One tab per job, reused. A five-minute job would otherwise bury the tab bar, and the tab's scrollback is exactly that job's history. The tab takes the job's name. Close it and the next run opens another.
-
Saved, but the clock restarts at launch. No catch-up: the minute in progress when the app opens never fires, and nothing missed while it was closed is replayed. Otherwise launching after a weekend opens a burst of terminals before you have touched anything.
cron.jsonsits next topresets.json. -
Jobs carry an optional
group, nested in the menu exactly as a preset's group is - ungrouped first, groups after in name order. A trading setup reaches a job per feed per session quickly, which is more than a flat list holds. The menu reusesadd_submenu/clear_menufrom the preset menus rather than growing its own copy of the ownership handling those exist for.
Two qtxterm windows are two processes over one cron.json and one
presets.json. Each loaded its file once at launch, so a job added in one
was invisible to the other until restart - and the next save from the stale
window wrote its old list over the new one, losing the job silently.
- Polled once a minute, not watched. The scheduler's tick already
runs every second, so checking on the minute costs a
stat()and no extra wakeup. AQFileSystemWatchercosts a worker thread, wakes on every change in the config directory, and stops watching a file that is replaced rather than modified - which is exactly what the atomic save below does. A minute is also cron's own resolution: there is nothing to do with a job noticed sooner than it could first fire. - Stamped, so an unchanged file is never re-parsed.
(mtime_ns, size), compared before reading. Reloading unconditionally would emitchangedevery minute and rebuild every menu, including one the user has open. - Presets are polled too, not just jobs. A job added elsewhere normally arrives with the Macro it names, and a job whose Macro has not been re-read fails every single firing with "no Macro named ... any more".
- Atomic save, via a sibling temp file and
os.replace. The reader is another process polling this exact path; a plain write is visible in its truncated middle. - Reloads are suspended while an editor is open. Both editors address
entries by index, and an
exec()dialog still runs the event loop - a poll landing mid-edit would shift the list under the open form and save the user's changes onto whichever entry inherited that index. Counted, not a flag, since two editors can be open over one store. - A bad read changes nothing and is retried. Invalid JSON - a hand-edit in progress, a half-synced cloud drive, an older qtxterm without the atomic save - leaves the in-memory list alone and does not advance the stamp. Raising out of a timer would take down the window over a file that is very likely fine a second later. A deleted file is treated the same way: the presets do not evaporate mid-session.
Two things this deliberately does not fix:
- The clobber window is narrowed, not closed. Saves still write the whole file, so an edit made in two windows inside the same minute still resolves last-writer-wins. Closing it properly means merging per entry, which needs stable ids the files do not have.
- Both instances still fire the same job, each into its own tab, since each runs its own scheduler. Cron is per-instance by design. If that ever needs to change, the fix is a single-instance lock at launch, not a fourth thing for the poll to do.
Implementation note: config_store.py holds this once and both stores
inherit it - PresetStore and CronStore were already the same problem
twice (a list of dataclasses, one JSON file, editors addressing entries by
index), and the file handling is now the part they literally share.
Implementation notes:
cron.pyis schedules and storage and knows nothing about terminals;cron_scheduler.pyowns the tab-per-job and the firing. Splitting them is what let the whole expression layer be tested without a Qt widget.- The scheduler ticks every second and acts only when the minute changes. A 60s timer drifts against the wall clock and skips a minute whenever the machine sleeps. On each minute change it re-reads the config first, then fires - see Phase 4s.1.
next_run()walks minute by minute but skips whole days that cannot match, and gives up after four years - "0 0 31 2 *" parses fine and can never happen, and the alternative to a bound is a UI that hangs.- Failures go to the status bar, not a dialog: a job fires on a schedule, possibly while you are away, and a modal appearing once a minute over your work is the worst available option.
- Verified end to end against a real shell: two firings, one tab named after the job, both runs landing in the same terminal.
Move Up / Move Down in every Manage dialog. The order in that list is the order they appear in their menu, and for Commands in the sidebar too, so it was the one property of a preset you could not set.
-
PresetStore.swap()rather than a move: one list holds all three categories interleaved, and the two entries being exchanged are neighbours within their category but rarely adjacent in the file. Swapping their positions leaves every other preset exactly where it was - there is a test asserting Commands do not shuffle when a Macro moves past another. - Offered for all three categories, not only Macros. It is the same dialog and the same code, and "why can I order Macros but not Commands?" is a worse answer than the feature.
- The selection follows the entry that moved, so pressing Move Up twice moves one preset two places rather than moving two presets - the same rule the right-click menu order editor uses.
- Known limit, documented in USAGE.md rather than fixed: the menus nest grouped entries under their group, so reordering across a group boundary changes the stored order without changing what the menu shows.
Everything a mainstream terminal has that qtxterm did not. Grouped here because the individual features are small; what was not small was discovering that three of the shortcuts already documented had never worked.
- Find in the scrollback (
Ctrl+Shift+F), on the vendoredaddon-search. The bar lives in the page, not in a Qt widget above the view: a Qt bar would take rows off the grid and reflow the shell's output every time you searched.- Two failures that unit tests passed straight through. xterm 5.5 gates
registerMarker/registerDecorationbehindallowProposedApi, so the addon threw after finding its matches, while selecting one - which surfaced as "No results" on a query with three. And the two overview ruler colours read as optional but are passed straight toregisterDecoration, where an undefined colour throws. - The match highlight is a translucent tint, and that is not a
cosmetic choice. xterm draws search decorations over the glyphs, so an
opaque highlight erases the text it is pointing at. Reusing the theme's
selectionBackgroundlooked safe - it is the one background a theme guarantees its foreground on - and turned every match into a solid white block on VS Code Dark High Contrast, whose selection colour is white. Now yellow at 25%, the current match at 50% with a foreground-coloured border. Caught by looking at a screenshot, not by a test.
- Two failures that unit tests passed straight through. xterm 5.5 gates
- Clickable URLs, on the vendored
addon-web-links. Ctrl+click (Cmd on macOS), following VS Code, Windows Terminal and iTerm2: a bare click already places the cursor and starts a selection.- Opens in the system browser, not a qtxterm browser tab, even though the app has them. That is where the user's extensions, blocklists and sessions are, and the URL is untrusted output.
- The scheme is checked again in Python. The addon's regex matches only
http/https, but it is not a security boundary - the text it ran against
came out of the terminal, which over SSH means it came from the remote
host - and
QDesktopServices.openUrlwill launch a registered handler for any scheme. Tested against file, ms-msdt (Follina), javascript and vbscript URLs.
- Copy/paste, font zoom and tab-by-number shortcuts, plus close-on-exit and a background image.
Reported as "pane navigation moves between tabs". It was not the navigation:
after split_active() (and after addTab) Qt left keyboard focus on the
QTabBar, which handles Left/Right by switching tabs. So a freshly split
pane could not be typed into until it was clicked, and arrows moved tabs.
-
PaneWidget.focus_pane(), called after a split, a new tab, and a pane close. For a terminal it takes two steps:setFocus()on the view moves Qt's focus off the tab bar, andterm.focus()moves it again inside the page to the hidden textarea xterm reads from. Without the second the pane looks focused and types nowhere. A pane split a moment ago has no page yet, so the request is remembered and replayed from_on_script_loaded. - Alt+Arrow pane navigation ranks candidates by how much their edges overlap, not by distance between centres. Measured: with a tall pane left of two stacked right, the two candidates' centres sat 138 and 139 pixels off axis - a one-pixel coin flip that would flip again if the splitter moved.
The Alt+Shift splits were documented from Phase 4i and could never have
fired from a keyboard. Both classes are now covered by registering every
spelling.
| Chord | Qt matches | Keyboard sends |
|---|---|---|
| Alt+Shift+equals | Key_Equal |
Key_Plus, because Shift+equals is plus |
| Alt+Shift+minus | Key_Minus |
Key_Underscore |
| Ctrl+Shift+underscore | Key_Underscore |
Key_Minus - with Ctrl held the character is a control code, so Qt cannot derive the shifted character from the layout and reports the base key |
Worth knowing for the next such bug: QTest.keyClick cannot catch this.
It hands Qt an event you constructed, so it only ever confirms that a binding
matches the event you invented. An earlier "all six chords work" measurement
was made that way and was worthless. SendInput is the honest test, and it
is unavailable in some environments - it returned 0 here even with the window
confirmed foreground.
Qt maps Ctrl to Command on macOS, Meta to Control and Alt to Option.
That does half the job and ruins the other half, so the table spells out both
sides:
- On Windows and Linux the shell owns Ctrl+letter, so the app takes
Ctrl+Shift. On macOS the shell uses Control and Command is free, so the binding is a plain Cmd+letter - translatingCtrl+Shift+Twould give Cmd+Shift+T, a different gesture entirely. Ctrl+Tabon macOS becomes Cmd+Tab, the OS application switcher, so next-tab is spelledMeta+Tabthere.Ctrl+Con macOS is Cmd+C and is genuinely copy; the interrupt isMeta+Cand is deliberately left unbound. A test asserts it stays that way.shortcuts.conflicts()exists because a collision is invisible: two QShortcuts sharing a sequence makes Qt fire neither. A test calls it on both platforms rather than trusting the table to have been read carefully. It caught the split-down chord being a tempting second spelling for zoom-out.
A shell you exited on purpose should take its pane with it; a shell that died has usually printed why, and closing the pane throws that away exactly when you needed to read it. Default is the middle option, matching Windows Terminal's closeOnExit. Closing is deferred a turn of the event loop - deleting the widget that owns the object currently emitting is how you get a crash rather than a closed pane - and the pane is re-checked at that point, since the tab can be gone by then.
The theme colour becomes a veil over the image rather than the ground:
the image is the bottom layer, with a flat wash of the theme background over
it at (100 minus strength). Default strength 30, because a photograph at full
strength behind text is unreadable and the first thing someone tries should
still work as a terminal. allowTransparency is set at construction, and
xterm's own background goes transparent only when an image is set, so the
no-image case renders exactly as before.
Added because no default table can be right everywhere, and the reason is not taste. A tiling window manager that owns Alt+Arrow, a desktop that has claimed a chord, a shell binding somebody depends on - none of these are visible from inside qtxterm, and all of them take the key before the app sees it. Rebinding makes the defaults a starting point rather than a verdict.
The alternative considered first was adding a second default chord per action as a fallback, which is what prompted the question this settles: is binding several chords to one action good practice? The distinction that matters:
- A compatibility hedge is two spellings of one physical gesture where only one can ever fire - Alt+Shift+= and Alt+Shift++ are the same keypress. Free: no keyspace consumed, nothing shadowed, nothing extra to learn.
- A true alias is two different gestures for one action - Ctrl+= and Ctrl++, or Ctrl+Shift+C and Ctrl+Insert. Each costs a chord permanently, and in a terminal the keyspace is scarce because the shell owns most of it.
Hedge freely; alias only against evidence. Measured at the time: 26 actions resolved to 44 sequences on Windows/Linux against 29 on macOS, and the tab-number slots alone (Alt+N and Ctrl+Alt+N) accounted for roughly half the aliasing. Adding Ctrl+Shift+Arrow as a speculative Linux fallback was rejected on those grounds - it would have been a "just in case" alias, and rebinding is the honest answer to environmental capture.
Three decisions in the implementation:
- Only overrides are written. Saving the resolved table would freeze today's defaults into every config file, so a later version that improved a binding or added an action would never reach anyone who had opened the editor once. An untouched action follows the defaults forever.
- A conflict is refused, not accepted. Two QShortcuts sharing a sequence makes Qt fire neither - it reports the ambiguity and gives up - so a last-one-wins policy would silently disable both actions. The store rejects the save and names the action already holding the chord.
- Shortcuts are rebuilt, not patched. Working out which QShortcuts a changed table implies is more code than making them all again, and the set is small. The previous set is disposed first: a left-behind QShortcut keeps firing, and once its replacement exists the two are ambiguous - which is the failure above, self-inflicted.
shortcuts.py stays a pure table with no Qt storage in it; keybindings.py
layers overrides on top, and terminal_tabs resolves through the store when
it has one and straight from the table when it does not, which is what tests
and embedders get.
An empty binding list is a real setting, distinct from resetting: an action nobody wants a key for is a legitimate thing to ask for, and the difference is "no shortcut" versus "the default shortcut".
tests/test_usage_docs.py asserts that every shortcut the app binds appears
in USAGE.md, and that the guide survives QTextBrowser.setMarkdown - whose
escaping rules are not GitHub's, so the file being correct is no evidence the
dialog is. It was written after the guide was found advertising chords that
could not fire, and after a pipe character silently ate a table cell.
The window takes ~950ms to appear. Investigated and parked: what remains is a UX choice, not a missing measurement.
Where it goes, on this machine (Windows, Qt 6.11):
| Phase | Cost |
|---|---|
| Python boot | 41ms |
| Imports (Qt + the app) | ~255ms |
QApplication + icon |
~25ms |
MainWindow() minus the warm-up |
~89ms |
prepare_window() → Chromium |
~455ms |
show() → first paint |
~100ms |
The 455ms is view.load("about:blank"), not constructing the view
(0.1ms): loading is where QtWebEngine spawns its render process. Chromium
flags (--disable-gpu, --in-process-gpu, …) make no difference, because
they do not change that the process has to start.
Phase 4k put that load before show() so the native-window rebuild happens
off screen. Re-verified on Qt 6.11 and the constraint still holds - and it is
tighter than it looks:
- The rebuild fires on
load(), not on view creation (HWND 6817848 → 6883384). Cost and rebuild are the same event; neither can be moved alone. - It is per window, not per application. With Chromium fully initialised in a separate throwaway top-level window, the main window still rebuilt when its own first view arrived (31263386 → 31328922). So deferring the warm-up only decides when the flash happens, never whether.
- A 1x1
QOpenGLWidgetadded beforeshow()does not pre-empt it - the rebuild still happened, andQOpenGLWidgetitself pushed the window's appearance out to 966ms.
Options, all measured:
| Approach | Window appears | Flash |
|---|---|---|
Warm up before show() (today) |
~950ms | none |
Warm up right after show() |
~180ms | brief, just after start-up |
| Warm up in a throwaway window | ~200ms | moves to the first terminal |
The second is the one to take if start-up speed wins: the flicker lands before the user has done anything, and every later terminal is fast and clean. The third is worse - it defers the flicker to the moment attention is on the window.
Orthogonal and free of visual cost: the ~255ms of imports, and the ~89ms of
MainWindow construction, neither of which has been attacked.
Cron starts things today and never stops them. The use case that raised it:
stream market data 06:30-13:30 Mon-Fri (equities) and 15:00-14:00 the next
day Sun-Thu (futures). Parked in favour of having the streamer exit on its
own schedule, which is cheaper and cleaner - a process that knows its own
stop time can flush and close properly, and nothing has to reach into a
terminal. Start-only cron already serves that: 30 6 * * 1-5 and
0 15 * * 0-4, both verified against the parser.
Recorded because the design work is done and the blocking discovery is real.
A stop cannot be a separate job. Each job owns its own tab, so a "stop equities" job would open a second terminal and type into it, leaving the stream untouched. Stopping has to belong to the job that started it, or name it explicitly.
The better model is a window, not two events. Give one job an optional stop schedule and the tick stops being edge-triggered and becomes a reconciliation:
desired = inside_window(now)
actual = this job's tab is open and was started by it
desired and not actual -> start; actual and not desired -> stop
Three problems then dissolve without special cases: a missed start (open the
app at 07:05 and it starts, because it is inside the window), a double start
(cannot happen - actual is already true), and a missed stop edge while the
machine slept (the next tick reconciles). Note this only makes sense for
windowed jobs; a point-in-time job still cannot sensibly fire late.
The weak joint is liveness. We can see that the tab is open; we cannot see whether the process inside it is alive, so a feed that dies at 09:00 leaves a tab at a prompt and reconciliation believes all is well. The fix is a fork in the design: run a windowed job's command as the terminal process rather than typing it into a shell. Process exit then is PTY exit, which is already observable - liveness becomes real, stop becomes killing the PTY (which works), and restart-on-crash becomes possible. The cost is losing the shell around it: no profile, no aliases, and the Macro model stops being "lines typed into a shell".
Shape, if built: stop_expression, stop_action (interrupt | close),
stop_grace_seconds (escalate to close, mirroring docker stop -t), and an
opt-in start_if_inside_window. Start stays required, stop optional; a job
with no stop is exactly today's behaviour. No visible "advanced" tier - one
job type with optional fields, because two tiers is two things to learn and a
migration when a simple job later needs a window.
Re-measured during Phase 5a, and the conclusion is the opposite of what this document said before: Ctrl-C works. Pressing it in a cmd or PowerShell tab stops a running child, in a normally launched qtxterm.
What made it look broken is worth recording, because it is an easy trap and it wasted a long investigation.
Windows makes the "ignore Ctrl+C" state inheritable. A process whose
parent called SetConsoleCtrlHandler(NULL, TRUE) inherits the ignore, and so
does everything it spawns. Automation runners, CI agents and some launchers
set it as a matter of course. When qtxterm is started from such a process the
flag travels down the whole chain - qtxterm, its ConPTY, the shell in the tab,
and the shell's children - so nothing in any terminal responds to Ctrl+C. The
same build launched normally is fine.
Measured, one script, two trials, the only difference being the flag:
| Condition | Child after Ctrl-C |
|---|---|
| as launched by an automation shell | still running |
after SetConsoleCtrlHandler(NULL, FALSE) |
stopped |
Two earlier findings were real observations with the wrong explanation attached, and both dissolve once the flag is understood:
- Git Bash appeared immune. It is: MSYS2 implements POSIX signals in its own runtime and raises SIGINT for the foreground process group without ever consulting the Windows console control path, so an inherited console flag cannot affect it. That is why it kept working while cmd and PowerShell did not - not evidence of a ConPTY defect.
AttachConsole+GenerateConsoleCtrlEventreported success and killed nothing. Expected, once the target is ignoring the event.
The ConPTY-versus-WinPTY table previously recorded here should be treated as unverified: it may well have been taken under the same inherited flag, and it attributes to ConPTY a behaviour that reproduces just as readily without it.
Genuinely useful things learned along the way, kept because they are true regardless:
AttachConsoleresets the Ctrl+C ignore flag, soSetConsoleCtrlHandler(NULL, TRUE)must be called after attaching, not before. Getting this backwards takes the calling process down with the event. Doing console surgery in a throwaway helper process avoids the question entirely.- The standard handles are stale after
AttachConsole;CONIN$has to be reopened withCreateFilebeforeGetConsoleModewill work. - The pseudoconsole's input mode already has
ENABLE_PROCESSED_INPUT(measured 0x01f7), so the "processed input is off" theory is dead in any case.
Testing lesson, which is the durable part. Every measurement above was taken from a harness that silently changed the thing being measured. A test environment that disables Ctrl+C cannot be used to test Ctrl+C, and nothing in the output said so - the app simply looked broken. Where a behaviour depends on process or console state inherited from the launcher, the only trustworthy check is the application started the way a user starts it.
Give every preset an id: str (uuid4 hex), assigned in __post_init__ when
absent, and key PresetStore.update()/delete() off it instead of list
position. One namespace across all three categories, since they share one
dataclass and one presets.json.
This is future-proofing, not a bug fix. Nothing here is reproducible through normal use today:
- Index is not identity - latent, not live.
update(index, preset)/delete(index)key off list position, andPresetEditorDialogholds a store index (_current_index) across reloads. That is safe only because the editor is modal (dialog.exec()) and there is one window per process, so nothing can reorder the list underneath it. The invariant holds by circumstance rather than by construction, which is the actual objection. The one case that breaks now is two app instances: both hold the whole list in memory and rewrite the entire file on save, so the later save clobbers the earlier. An id alone would not fix that - it needs merge-on-write - but it turns "lost an edit" into "edited the right preset". - Value equality is not identity - already worked around.
Presetis a plain dataclass, so two presets with identical fields compare equal. This bit_indexed_presets(), wherepreset in filtered_listmatched the wrong entry; it was rewritten to compare by category. Fixed instance, same class of problem. - Name is not identity - only matters if references appear. Names aren't
unique, and the
SidebarLayoutsketch above refers to presets by name (preset_refs: list[str]), so a rename would orphan its button. This is the real trigger, and it only fires once something outsidepresets.jsonpoints at a preset.
Current status: no such references exist. Buttons are show_in_sidebar,
a field on the preset itself (see the split rationale above), so nothing
stores a handle to a preset. That removes the main argument for doing this
now. Even the lighter version of sidebar arrangement - an order field on
the preset - needs no ids.
Do it before, not after, if any of these land: a separate
sidebar_layout.json, per-preset keyboard bindings, recently-used tracking,
or anything else referencing a preset from elsewhere. Retrofitting then means
migrating the references as well as the presets. Otherwise it can wait
indefinitely.
Migration is additive whenever it happens: entries without an id get one on
load and the file is saved once. Existing presets.json files keep working
and stay hand-editable.
- Multi-step macro scripting (wait-for-pattern, conditional branching) -
deferred; would mean either running presets as a real temp script file (true
script semantics, but a subshell, so
cdwouldn't persist) or Expect-style prompt detection. - Session persistence across app restarts (reopen tabs) - not yet decided.
- Config file format: JSON assumed above; can switch to YAML/TOML if preferred.