Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 40 additions & 38 deletions src/qtxterm/assets/USAGE.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# qtxterm — Usage
# qtxterm - Usage

A tabbed terminal with one-click command buttons and reusable command presets.

Expand All @@ -19,15 +19,15 @@ A tabbed terminal with one-click command buttons and reusable command presets.

Only shells actually installed on your machine appear under **New Terminal**,
and every installed WSL distro gets its own entry (`WSL: Ubuntu-22.04`).
Docker Desktop's `docker-desktop` and `docker-desktop-data` are left out —
Docker Desktop's `docker-desktop` and `docker-desktop-data` are left out -
they're data-only and give no usable shell.

Tabs are labelled tmux-style as `index:shell` — `0:powershell`, `1:bash`,
Tabs are labelled tmux-style as `index:shell` - `0:powershell`, `1:bash`,
`2:cmd`. Hover a tab to see what the shell is reporting (usually the current
directory or running command); that goes in the tooltip rather than the
label, since some shells report a full path and would stretch the tab bar.

Double-click a tab to rename it — useful once several tabs run the same
Double-click a tab to rename it - useful once several tabs run the same
shell and `0:powershell` / `1:powershell` stop telling them apart. The index
prefix stays, and renumbering keeps your name attached to the right tab.
Leave the box blank to go back to the automatic name. A rename sticks: a
Expand All @@ -38,10 +38,10 @@ it.
on purpose: bash and readline bind `Ctrl+W` to "delete previous word", so
reusing it for "close tab" would break normal shell line-editing.

qtxterm opens with no terminal — you choose what to start, and closing the
qtxterm opens with no terminal - you choose what to start, and closing the
last tab leaves the window open rather than quitting. An empty window tells
you how to open one: `Ctrl+Shift+T` or **File → New Terminal**. The `+`
button isn't available there — Qt only draws it alongside existing tabs.
button isn't available there - Qt only draws it alongside existing tabs.

## Split panes

Expand All @@ -52,7 +52,7 @@ group.
Splits nest, so you can build columns of rows. Drag the divider to resize.

Browser panes split too, and a split gives you **another pane of the same
kind** — splitting a browser gives a browser, splitting a terminal gives a
kind** - splitting a browser gives a browser, splitting a terminal gives a
terminal. In a browser pane the shortcuts are the only route: right-clicking
a web page shows Chromium's own menu, which you want for links and images.

Expand All @@ -63,12 +63,12 @@ pane, so it answers "where will this land?".

**Pane → Move Left/Right** (labelled Up/Down in a stacked split) swaps the
active pane with its neighbour. **Move to New Tab** pulls it out into a tab
of its own — the usual fix for "I split the wrong one". Either way the
of its own - the usual fix for "I split the wrong one". Either way the
pane keeps its shell, scrollback and running processes; only its container
changes.

Panes can't be dragged with the mouse. Neither can Windows Terminal's,
iTerm2's or tmux's — it needs a custom drag-and-drop layer, and the menu
iTerm2's or tmux's - it needs a custom drag-and-drop layer, and the menu
commands cover the cases that actually come up.

**Pane → Close** (`Alt+Shift+W`) closes just that terminal; closing the last
Expand All @@ -80,44 +80,44 @@ full-screen apps rarely bind them.

**File → New Browser** opens a web page in a tab beside your terminals. Type
in the address bar and press Enter: a full URL loads as-is, a bare host like
`example.com` or `localhost:8080` gets `https://`, and anything else — words
with spaces, say — becomes a search rather than a broken URL. The `←` `→` `⟳`
`example.com` or `localhost:8080` gets `https://`, and anything else - words
with spaces, say - becomes a search rather than a broken URL. The `←` `→` `⟳`
buttons are back, forward, and reload.

Browser tabs are labelled by host (`1:example.com`), with the page title in
the tooltip. Commands and Selection Actions do nothing while a browser is the
active pane — there's no shell to send them to. They don't quietly pick some
active pane - there's no shell to send them to. They don't quietly pick some
other terminal in the tab either, which would run your command somewhere you
weren't looking; click the terminal pane you meant first.

## Commands vs. Macros

Both are the same underlying thing — a named list of shell lines — but they
Both are the same underlying thing - a named list of shell lines - but they
serve different jobs, and each preset is one or the other:

- **Command** — runs in the terminal tab you're *already working in*, and you
- **Command** - runs in the terminal tab you're *already working in*, and you
stay in it. For quick, short interactions: `git status`, `clear`.
- **Macro** — always opens a *fresh tab* and runs there. For anything
- **Macro** - always opens a *fresh tab* and runs there. For anything
long-running or disruptive that shouldn't hijack your current session:
a dev server, a build, a deploy.

## Running them

- **Sidebar buttons** — one click sends that Command to the active terminal.
- **Sidebar buttons** - one click sends that Command to the active terminal.
The sidebar is the curated view: only Commands you've pinned with *Show in
sidebar* appear there.
- **Right-click in a terminal → Command** — every Command, grouped the
- **Right-click in a terminal → Command** - every Command, grouped the
same way as the sidebar, without leaving the terminal you're typing in.
Unlike the sidebar this lists *all* of them, pinned or not. Picking one
sends it to that terminal. The same menu has **Copy** and **Paste** (see
below).
- **Macros menu** — every Macro. Picking one opens a new tab and runs it.
- **Macros menu** - every Macro. Picking one opens a new tab and runs it.

The Commands menu in the menu bar is management-only (New Command...,
Manage Commands..., Show Sidebar) — it doesn't list individual Commands.
Manage Commands..., Show Sidebar) - it doesn't list individual Commands.

Show or hide the sidebar with **Commands → Show Sidebar**. Closing it from
its own title-bar button works too — either way it just hides, and the menu
its own title-bar button works too - either way it just hides, and the menu
item flips back to unchecked so you can bring it back.

## Copy and paste
Expand All @@ -137,8 +137,8 @@ Select text in a terminal, right-click, and pick **Selection** to run
something against it. The submenu shows what you selected at the top, so you
can see the payload before sending it. Two built-in examples ship:

- **Search Google** — opens your browser on a search for the selected text.
- **Explain with Claude** — opens a new tab running `claude -p`, with the
- **Search Google** - opens your browser on a search for the selected text.
- **Explain with Claude** - opens a new tab running `claude -p`, with the
selection as its input.

Each action is one of two kinds, chosen in **Manage Selection Actions...**:
Expand All @@ -150,12 +150,12 @@ Each action is one of two kinds, chosen in **Manage Selection Actions...**:

The selection is never pasted into a command line. That keeps text
containing quotes, `;`, `&&` or backticks from being executed as part of the
command, and means multi-line and very long selections work — both of which
command, and means multi-line and very long selections work - both of which
break if text is spliced into a command. URL actions cap the selection at
1500 characters, since browsers and search engines reject longer URLs.

If the right-click **Selection** submenu is empty — an install created before
this feature existed keeps its own presets — open **Manage Selection
If the right-click **Selection** submenu is empty - an install created before
this feature existed keeps its own presets - open **Manage Selection
Actions...** and press **Add Examples**.

## Cron: running something on a schedule
Expand Down Expand Up @@ -203,7 +203,7 @@ Two things to know:
## Ordering

Each Manage dialog has **Move Up** and **Move Down** under its list. The
order there is the order they appear in — the Macros menu, the right-click
order there is the order they appear in - the Macros menu, the right-click
Command submenu, and the sidebar buttons.

One thing to know: entries with a **Group** are shown nested under that group
Expand All @@ -223,14 +223,15 @@ create a Command from the Macros dialog or vice versa.
| Name | Label shown in menus and on sidebar buttons |
| Group | Optional. Groups presets into sidebar sections and menu submenus |
| Commands | One shell line per row |
| Show in sidebar | Commands only — pins it as a sidebar button. Macros never appear there |
| Show in sidebar | Commands only - pins it as a sidebar button. Macros never appear there |

Changes save immediately, and the sidebar and both menus refresh straight away.

Presets are stored as JSON, so you can hand-edit or back them up:

- Windows — `%LOCALAPPDATA%\qtxterm\presets.json`
- Linux — `~/.config/qtxterm/presets.json`
- Windows - `%LOCALAPPDATA%\qtxterm\presets.json`
- Linux - `~/.config/qtxterm/presets.json`
- macOS - `~/Library/Application Support/qtxterm/presets.json`

## Preferences

Expand All @@ -239,45 +240,46 @@ font size.

### Default shell

Which shell new tabs open with — the startup tab, the `+` button,
Which shell new tabs open with - the startup tab, the `+` button,
`Ctrl+Shift+T`, and Macros. **System default** follows the OS
(`powershell.exe` on Windows, `$SHELL` elsewhere); otherwise pick any
detected shell, including a specific WSL distro. Existing tabs keep the
shell they started with, and **File → New Terminal** still opens whichever
shell you pick there regardless of this setting.

If the chosen shell later disappears — a WSL distro you removed — new tabs
If the chosen shell later disappears - a WSL distro you removed - new tabs
quietly fall back to the system default rather than failing to open.

### Appearance

| Theme | Look |
|---|---|
| Qt Default | The original terminal colors, with your platform's native window chrome left untouched |
| VS Code Dark High Contrast | Pure black ground, saturated ANSI colors — VS Code's `hc-black` |
| VS Code Dark High Contrast | Pure black ground, saturated ANSI colors - VS Code's `hc-black` |
| VS Code Light+ | VS Code's default light theme |
| Solarized Dark | The classic low-contrast dark palette |
| Solarized Light | Solarized on its cream ground |

Everything except **Qt Default** themes the whole window — menus, tabs,
sidebar, and dialogs — not only the terminal grid. **Qt Default** deliberately
Everything except **Qt Default** themes the whole window - menus, tabs,
sidebar, and dialogs - not only the terminal grid. **Qt Default** deliberately
leaves the native look alone.

Changes apply immediately to every open tab, and are remembered for next time.

## What else is remembered

The window's size and position, and whether the Commands sidebar is showing,
are restored the next time you open qtxterm — alongside the appearance
are restored the next time you open qtxterm - alongside the appearance
settings above. They live next to your presets:

- Windows — `%LOCALAPPDATA%\qtxterm\window_state.ini`
- Linux — `~/.config/qtxterm/window_state.ini`
- Windows - `%LOCALAPPDATA%\qtxterm\window_state.ini`
- Linux - `~/.config/qtxterm/window_state.ini`
- macOS - `~/Library/Application Support/qtxterm/window_state.ini`

## How multiline presets run

Each line is typed into the terminal followed by Enter. Lines run strictly in
order, each finishing before the next begins — a preset starting with a
order, each finishing before the next begins - a preset starting with a
3-second sleep won't run its second line until that sleep is done.

Two differences from a real `.sh` or `.bat` script are worth knowing:
Expand Down
4 changes: 2 additions & 2 deletions src/qtxterm/cron_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def _reload_list(self) -> None:
self._list.clear()
for job in self._store.jobs:
suffix = "" if job.enabled else " (disabled)"
self._list.addItem(f"{job.name} — {job.expression}{suffix}")
self._list.addItem(f"{job.name} - {job.expression}{suffix}")
self._list.blockSignals(False)

def _on_row_changed(self, row: int) -> None:
Expand Down Expand Up @@ -229,7 +229,7 @@ def _save_current(self) -> bool:
except CronError as exc:
# Refused rather than stored: a job with an unreadable schedule
# would sit there doing nothing.
self._next_run_label.setText(f"⚠ not saved — {exc}")
self._next_run_label.setText(f"⚠ not saved - {exc}")
return False
saved_index = self._current_index
self._store.update(saved_index, job)
Expand Down
4 changes: 2 additions & 2 deletions src/qtxterm/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
# The separator carries where that terminal goes, so the layout is authored
# inline and needs no extra fields or editor controls:
#
# npm run dev
# uv run python -m app.server
# --- right
# npm run test:watch
# uv run pytest -q
# --- down
# git status
#
Expand Down
24 changes: 20 additions & 4 deletions tests/test_pty_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,26 @@ def test_real_pty_roundtrip(qtbot) -> None:
session.start([default_shell()], cols=80, rows=24)
qtbot.waitUntil(lambda: session.is_alive, timeout=5000)

session.write("echo pytest_roundtrip_ok\r\n")
qtbot.waitUntil(
lambda: "pytest_roundtrip_ok" in "".join(output_chunks), timeout=10000
)
# `is_alive` only means the process exists - ConPTY reports that the
# instant it spawns, well before the shell starts reading. Wait for it to
# say something first; a banner or a prompt is the shell announcing it is
# up. Without this the command below can be typed into a shell that is not
# listening yet, and PSReadLine drops typeahead rather than buffering it.
qtbot.waitUntil(lambda: "".join(output_chunks).strip() != "", timeout=30000)

# Even then, "it printed something" is not "it is reading stdin", and the
# gap between the two stretches on a loaded CI runner. Re-send until the
# echo comes back rather than trusting one write to land - `echo` is
# idempotent, so a duplicate costs nothing but a second line of output.
def echoed() -> bool:
return "pytest_roundtrip_ok" in "".join(output_chunks)

for _ in range(15):
session.write("echo pytest_roundtrip_ok\r\n")
qtbot.wait(1000)
if echoed():
break
assert echoed(), f"no echo after 15 attempts; got {''.join(output_chunks)!r}"

session.close()
qtbot.waitUntil(lambda: not session.is_alive, timeout=5000)
Expand Down
Loading