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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ own command. Shown in the default theme - several dark themes ship with it.*

## Features

- **Tabs and split panes** - split any pane right or down, move panes between
tabs or out into their own.
- **Tabs and split panes** - split any pane right or down, starting in the
directory you were already in; move panes between tabs or out into their own.
- **Any shell on the box** - PowerShell, Command Prompt, Git Bash or a specific
WSL distro, discovered at runtime.
- **Commands and Macros** - saved one-liners sent to the terminal you're in;
Expand Down
11 changes: 11 additions & 0 deletions src/qtxterm/assets/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ there are two pairs and either works:
Everything that rearranges panes lives under that one **Pane** group.
Splits nest, so you can build columns of rows. Drag the divider to resize.

A split pane starts **in the directory the pane it came from is in**, so
splitting to run something beside your build lands you where you already
were. That works for PowerShell, cmd and Git Bash, which qtxterm asks to
report their directory as it launches them - a shell it did not launch that
way (a WSL distro, or anything you started yourself inside a pane) has
nothing to report, and a split off it opens where a new tab would.

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
terminal. In a browser pane the shortcuts are the only route: right-clicking
Expand Down Expand Up @@ -492,6 +499,10 @@ zooming *is* editing the preference - which is why `Ctrl+0` returns to the
default rather than to whatever the dialog last held, since otherwise it
would have nothing to mean.

Ctrl+scrolling the mouse wheel does *not* resize the text. A stray scroll
over the buffer would otherwise scale the page's pixels without resizing the
grid the shell is drawing into, leaving the two disagreeing.

## What else is remembered

The window's size and position, and whether the Commands sidebar is showing,
Expand Down
37 changes: 37 additions & 0 deletions src/qtxterm/assets/shell_integration/qtxterm.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Reports the working directory to qtxterm after every prompt, as OSC 7.
#
# Dot-sourced by qtxterm when it launches PowerShell, *after* the user's
# profile has run, so $function:prompt here is whatever the profile left -
# the original is called for its output and this only prefixes the escape
# sequence. PowerShell needs to be asked because, unlike cmd and bash, it
# does not update the process's own working directory when you `cd`, so
# there is nothing for the app to read from the outside.

if (-not $global:__qtxtermPromptWrapped) {
$global:__qtxtermPromptWrapped = $true
$global:__qtxtermInnerPrompt = $function:prompt

function global:prompt {
try {
# ProviderPath, not Path: inside a PSDrive or a registry
# location Path reads "HKLM:\...", and only ProviderPath is a
# directory anything else could start in. It is empty for a
# location with no filesystem path at all, hence the guard.
$qtxtermPath = (Get-Location).ProviderPath
if ($qtxtermPath) {
# .Replace, not -replace: the latter takes a regex, in which
# a lone backslash is a syntax error rather than a backslash.
$qtxtermUrl = [uri]::EscapeUriString($qtxtermPath.Replace('\', '/'))
# Empty host (file:///...) - the path is on this machine, and
# it keeps the sequence the same shape as the other shells'.
# BEL-terminated, which every terminal accepts and which
# avoids ending a PowerShell string on a backslash.
[Console]::Write("$([char]27)]7;file:///$qtxtermUrl$([char]7)")
}
} catch {
# A prompt that throws leaves the shell unusable; a missing cwd
# report only costs a split pane its starting directory.
}
& $global:__qtxtermInnerPrompt
}
}
9 changes: 9 additions & 0 deletions src/qtxterm/assets/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,15 @@

term.onData((data) => bridge.sendInput(data));
term.onTitleChange((title) => bridge.setTitle(title));

// OSC 7 - the shell saying which directory it is in, so a pane split
// off this one can start there. Only shells qtxterm has hooked (see
// shell_integration.py) send it; returning true marks it handled so
// xterm.js does not pass the payload on to be printed.
term.parser.registerOscHandler(7, (payload) => {
bridge.setCwd(payload);
return true;
});
term.onSelectionChange(() => bridge.setSelection(term.getSelection()));

bridge.output.connect((data) => term.write(data));
Expand Down
18 changes: 16 additions & 2 deletions src/qtxterm/pty_backend/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,22 @@ def _emit_from_reader(self, signal, value) -> None:
pass

@abstractmethod
def start(self, command: list[str], cols: int, rows: int) -> None:
"""Spawn `command` (argv: executable + args) attached to a new PTY."""
def start(
self,
command: list[str],
cols: int,
rows: int,
cwd: str | None = None,
env: dict[str, str] | None = None,
) -> None:
"""Spawn `command` (argv: executable + args) attached to a new PTY.

`cwd` is the directory the shell starts in - a split pane passes the
directory its sibling was in - and `env` replaces the inherited
environment wholesale, which is how the shell-integration hooks in
`shell_integration` reach the shell. Both default to this process's
own, which is what every caller wanted before either existed.
"""

@abstractmethod
def write(self, data: str) -> None:
Expand Down
13 changes: 11 additions & 2 deletions src/qtxterm/pty_backend/posix.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@ def __init__(self) -> None:
self._process: PtyProcessUnicode | None = None
self._reader_thread: threading.Thread | None = None

def start(self, command: list[str], cols: int, rows: int) -> None:
self._process = PtyProcessUnicode.spawn(command, dimensions=(rows, cols))
def start(
self,
command: list[str],
cols: int,
rows: int,
cwd: str | None = None,
env: dict[str, str] | None = None,
) -> None:
self._process = PtyProcessUnicode.spawn(
command, cwd=cwd, env=env, dimensions=(rows, cols)
)
self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
self._reader_thread.start()

Expand Down
13 changes: 11 additions & 2 deletions src/qtxterm/pty_backend/win.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,21 @@ def __init__(self) -> None:
self._process: PtyProcess | None = None
self._reader_thread: threading.Thread | None = None

def start(self, command: list[str], cols: int, rows: int) -> None:
def start(
self,
command: list[str],
cols: int,
rows: int,
cwd: str | None = None,
env: dict[str, str] | None = None,
) -> None:
# Must be a real argv list, not a joined string: PtyProcess.spawn()
# shlex-splits string argv on whitespace, which breaks paths like
# "C:\Program Files\Git\bin\bash.exe" (splits into "C:\Program" +
# the rest, and "C:\Program" isn't found on PATH).
self._process = PtyProcess.spawn(command, dimensions=(rows, cols))
self._process = PtyProcess.spawn(
command, cwd=cwd, env=env, dimensions=(rows, cols)
)
self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
self._reader_thread.start()

Expand Down
100 changes: 100 additions & 0 deletions src/qtxterm/shell_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Teach the shells we launch to report their working directory (OSC 7).

A split pane opens where its sibling was, which means the app has to know
where that is. Asking the OS for the shell process's working directory only
works for some shells - PowerShell, the Windows default, never updates it on
`cd` - so instead each shell is asked to *say* where it is, using the same
OSC 7 sequence VS Code and Windows Terminal rely on:

ESC ] 7 ; file://<host>/<path> ESC \

Nothing here is required for a terminal to work. A shell we do not
recognise, or one whose prompt the user has since replaced, simply never
reports, and a pane split off it starts in the default directory.
"""

from __future__ import annotations

import os
from pathlib import Path
from urllib.parse import unquote

ASSETS_DIR = Path(__file__).parent / "assets"
POWERSHELL_HOOK = ASSETS_DIR / "shell_integration" / "qtxterm.ps1"

_POWERSHELLS = frozenset({"powershell", "pwsh"})
_BASHES = frozenset({"bash", "sh"})

# printf, not echo -e: echo's escape handling differs between bash and the
# sh some distros link it to. cygpath translates Git Bash's /c/Users/... into
# C:/Users/..., which is the only form Windows can start a process in; on
# Linux and macOS there is no cygpath and $PWD is already the right thing.
#
# The host is left empty (file:///...) rather than filled in with $HOSTNAME:
# an empty host means "this machine", which is the only case worth reporting,
# and it makes the sequence identical in shape whether the path that follows
# is /home/dev or C:/Users/dev. Stripping a leading slash before adding one
# back is what keeps both of those to exactly three slashes.
_BASH_PROMPT_COMMAND = (
'__qtxterm_cwd=$(cygpath -m "$PWD" 2>/dev/null || printf %s "$PWD"); '
'printf "\x1b]7;file:///%s\x07" "${__qtxterm_cwd#/}"'
)

_CMD_PROMPT = r"$E]7;file:///$P$E\$P$G"


def stem(command: str) -> str:
"""Lowercased executable name without its extension, e.g. 'powershell'."""
return Path(command).stem.lower()


def decorate(command: list[str]) -> tuple[list[str], dict[str, str] | None]:
"""The argv and environment to launch `command` with, hooks included.

Returns the command unchanged and `None` for the environment when the
shell is one we have no hook for - `None` rather than a copy of
os.environ so the backends keep their plain inherit-everything path.
"""
if not command:
return command, None

name = stem(command[0])
if name in _POWERSHELLS:
# -Command has to come last: PowerShell treats everything after it as
# the command. -NoExit keeps the session interactive afterwards.
quoted = str(POWERSHELL_HOOK).replace("'", "''")
return [*command, "-NoExit", "-Command", f". '{quoted}'"], None
if name in _BASHES:
# An environment variable rather than --rcfile, which would replace
# the user's own startup files instead of running alongside them.
return command, os.environ | {"PROMPT_COMMAND": _BASH_PROMPT_COMMAND}
if name == "cmd":
return command, os.environ | {"PROMPT": _CMD_PROMPT}
return command, None


def path_from_osc7(uri: str) -> str | None:
"""The local directory an OSC 7 payload names, or None if it names none.

Parsed here rather than with QUrl because the payloads are not all
well-formed URLs: cmd's PROMPT can only produce a native path, complete
with backslashes and unescaped spaces, and a Windows drive letter arrives
as the "/C:/Users/..." Qt would hand back as a UNC path.
"""
text = (uri or "").strip()
if not text:
return None
if text.startswith("file://"):
rest = text[len("file://") :]
# Everything up to the first separator is the hostname, which is
# deliberately ignored: a remote host's path is not ours to open.
slash = rest.find("/")
text = rest[slash:] if slash != -1 else ""
text = unquote(text).replace("\\", "/")
if not text:
return None
# "/C:/Users/dev" -> "C:/Users/dev". Only a drive letter, so a POSIX
# "/home/dev" keeps its root.
if len(text) > 2 and text[0] == "/" and text[2] == ":":
text = text[1:]
return text or None
11 changes: 11 additions & 0 deletions src/qtxterm/terminal_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class needing to know about PTYs at all.
title_changed = Signal(str)
selection_changed = Signal(str)
link_activated = Signal(str)
cwd_changed = Signal(str)

@Slot(str)
def sendInput(self, data: str) -> None:
Expand Down Expand Up @@ -51,6 +52,16 @@ def loaded(self) -> None:
def setTitle(self, title: str) -> None:
self.title_changed.emit(title)

@Slot(str)
def setCwd(self, uri: str) -> None:
"""The shell reported its working directory (OSC 7).

The payload is whatever the shell printed - on an SSH session, a
remote path that means nothing locally - so TerminalWidget checks it
against the filesystem before starting anything in it.
"""
self.cwd_changed.emit(uri)

@Slot(str)
def setSelection(self, text: str) -> None:
"""Pushed by xterm.js on every selection change.
Expand Down
11 changes: 9 additions & 2 deletions src/qtxterm/terminal_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ def _make_terminal(
self,
shell: str | list[str] | None = None,
pty_session: PtySession | None = None,
cwd: str | None = None,
) -> TerminalWidget:
appearance = (
self._appearance_store.current if self._appearance_store else Appearance()
Expand All @@ -200,7 +201,7 @@ def _make_terminal(
if shell is None:
shell = self.preferred_shell()
widget = TerminalWidget(
shell=shell, pty_session=pty_session, appearance=appearance
shell=shell, pty_session=pty_session, appearance=appearance, cwd=cwd
)
# Deliberately not wired to the tab label. Shells set wildly
# different OSC titles - Git Bash sends
Expand Down Expand Up @@ -377,7 +378,13 @@ def _clone_kind_of(
"""
if isinstance(pane, BrowserWidget):
return BrowserWidget()
return self._make_terminal(shell, pty_session)
# Splitting is "another one of these, here": the new pane starts in
# the directory the old one is in, the way a split does in tmux or
# Windows Terminal. Only shells that report their directory (see
# shell_integration) have one to inherit; the rest start where a new
# tab would.
cwd = pane.current_directory if isinstance(pane, TerminalWidget) else None
return self._make_terminal(shell, pty_session, cwd=cwd)

def split_active(
self,
Expand Down
Loading
Loading