The Gitea repo was initialised from an earlier snapshot, so main and the machine this runs on had drifted apart in 153 files before any UI work started. This commit brings the branch up to the local tree as it stood on 2026-08-15 21:31 (from cowork_local.7z), so the redesign that follows shows up as its own reviewable diff instead of being mixed in with the pre-existing divergence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
327 lines
12 KiB
Python
327 lines
12 KiB
Python
"""A collapsible CLI terminal panel — runs commands directly on the host OS
|
|
(Windows cmd / POSIX sh) via QProcess, streaming output live.
|
|
|
|
User-operated (the person types the commands themselves), so it deliberately
|
|
runs in the real shell with no agent sandbox — it's a convenience terminal, not
|
|
an agent tool.
|
|
|
|
Terminal conveniences implemented in-panel:
|
|
* ``cd`` / ``cd D:`` / ``cd /d X:\\path`` (drive + relative + absolute), ``clear``
|
|
* **Tab completion** of files/folders in the current directory
|
|
* **Up/Down** command history
|
|
* UTF-8 output (Windows ``chcp 65001``) so non-ASCII — e.g. Japanese — shows
|
|
correctly instead of mojibake.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import QProcess, Qt, Signal
|
|
from PySide6.QtGui import QFont
|
|
from PySide6.QtWidgets import (
|
|
QFrame, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton,
|
|
QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..i18n import on_language_changed, tr
|
|
from ..theme import current_palette
|
|
from .icons import icon
|
|
|
|
_IS_WIN = sys.platform == "win32"
|
|
|
|
|
|
class _TermInput(QLineEdit):
|
|
"""Command input with Tab-completion and Up/Down history (like a real shell)."""
|
|
|
|
complete_requested = Signal()
|
|
history_prev = Signal()
|
|
history_next = Signal()
|
|
|
|
def keyPressEvent(self, e): # noqa: N802 - Qt override
|
|
if e.key() == Qt.Key_Tab:
|
|
self.complete_requested.emit()
|
|
e.accept()
|
|
return
|
|
if e.key() == Qt.Key_Up:
|
|
self.history_prev.emit()
|
|
e.accept()
|
|
return
|
|
if e.key() == Qt.Key_Down:
|
|
self.history_next.emit()
|
|
e.accept()
|
|
return
|
|
super().keyPressEvent(e)
|
|
|
|
|
|
class TerminalPanel(QWidget):
|
|
"""Collapsible terminal: a header (toggle) + output console + command input."""
|
|
|
|
expanded = Signal()
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._collapsed = True
|
|
self._cwd = str(Path.home())
|
|
self._proc: QProcess | None = None
|
|
self._history: list[str] = []
|
|
self._hist_idx = 0 # points one past the last entry when idle
|
|
|
|
root = QVBoxLayout(self)
|
|
root.setContentsMargins(0, 0, 0, 0)
|
|
root.setSpacing(0)
|
|
|
|
# ---- header (always visible; click to expand/collapse) --------------
|
|
self._header = QFrame()
|
|
self._header.setObjectName("termHeader")
|
|
_tp = current_palette()
|
|
self._header.setStyleSheet(
|
|
f"#termHeader {{ background: {_tp.surface};"
|
|
f" border-radius: {_tp.radius}px; }}")
|
|
hb = QHBoxLayout(self._header)
|
|
hb.setContentsMargins(8, 4, 8, 4)
|
|
self._toggle_btn = QPushButton()
|
|
self._toggle_btn.setFlat(True)
|
|
self._toggle_btn.setFixedSize(22, 22)
|
|
self._toggle_btn.setCursor(Qt.PointingHandCursor)
|
|
self._toggle_btn.clicked.connect(self.toggle)
|
|
hb.addWidget(self._toggle_btn)
|
|
self._title = QLabel(tr("terminal.title"))
|
|
self._title.setStyleSheet("font-weight:600;")
|
|
hb.addWidget(self._title)
|
|
hb.addStretch(1)
|
|
self._cwd_lbl = QLabel("")
|
|
self._cwd_lbl.setObjectName("hint")
|
|
hb.addWidget(self._cwd_lbl)
|
|
root.addWidget(self._header)
|
|
|
|
# ---- body (hidden while collapsed) ----------------------------------
|
|
self._body = QWidget()
|
|
bl = QVBoxLayout(self._body)
|
|
bl.setContentsMargins(0, 4, 0, 0)
|
|
bl.setSpacing(4)
|
|
self.output = QPlainTextEdit()
|
|
self.output.setObjectName("termOutput")
|
|
self.output.setReadOnly(True)
|
|
self.output.setMaximumBlockCount(5000)
|
|
mono = QFont("Consolas")
|
|
mono.setStyleHint(QFont.Monospace)
|
|
mono.setPointSize(10)
|
|
self.output.setFont(mono)
|
|
# Surface comes from the central style sheet (#termOutput) — see theme.py.
|
|
self.output.setMinimumHeight(160)
|
|
bl.addWidget(self.output, 1)
|
|
|
|
row = QHBoxLayout()
|
|
self._prompt = QLabel("$")
|
|
self._prompt.setFont(mono)
|
|
row.addWidget(self._prompt)
|
|
self.input = _TermInput()
|
|
self.input.setObjectName("termInput")
|
|
self.input.setFont(mono)
|
|
# Surface comes from the central style sheet (#termInput) — see theme.py.
|
|
self.input.returnPressed.connect(self._run_current)
|
|
self.input.complete_requested.connect(self._complete)
|
|
self.input.history_prev.connect(lambda: self._history_move(-1))
|
|
self.input.history_next.connect(lambda: self._history_move(1))
|
|
row.addWidget(self.input, 1)
|
|
self._run_btn = QPushButton()
|
|
self._run_btn.setObjectName("primary")
|
|
self._run_btn.clicked.connect(self._run_current)
|
|
row.addWidget(self._run_btn)
|
|
bl.addLayout(row)
|
|
root.addWidget(self._body)
|
|
|
|
self._body.setVisible(False)
|
|
on_language_changed(self._retranslate)
|
|
self._retranslate()
|
|
self._apply_collapsed()
|
|
|
|
# ---- public API ----------------------------------------------------------
|
|
def set_cwd(self, path: str) -> None:
|
|
if path and os.path.isdir(path):
|
|
self._cwd = os.path.normpath(str(path))
|
|
self._cwd_lbl.setText(self._cwd)
|
|
self._prompt.setText(_prompt_for(self._cwd))
|
|
|
|
def toggle(self) -> None:
|
|
self._collapsed = not self._collapsed
|
|
self._apply_collapsed()
|
|
if not self._collapsed:
|
|
self.expanded.emit()
|
|
self.input.setFocus()
|
|
|
|
def set_collapsed(self, collapsed: bool) -> None:
|
|
self._collapsed = collapsed
|
|
self._apply_collapsed()
|
|
|
|
def _apply_collapsed(self) -> None:
|
|
self._body.setVisible(not self._collapsed)
|
|
self._toggle_btn.setIcon(icon("chevron-right" if self._collapsed else "chevron-down"))
|
|
self._toggle_btn.setToolTip(
|
|
tr("terminal.expand_tooltip") if self._collapsed else tr("terminal.collapse_tooltip"))
|
|
|
|
# ---- history -------------------------------------------------------------
|
|
def _history_move(self, direction: int) -> None:
|
|
if not self._history:
|
|
return
|
|
self._hist_idx = max(0, min(len(self._history), self._hist_idx + direction))
|
|
self.input.setText(self._history[self._hist_idx] if self._hist_idx < len(self._history) else "")
|
|
|
|
# ---- Tab completion ------------------------------------------------------
|
|
def _complete(self) -> None:
|
|
text = self.input.text()
|
|
head, sep, token = text.rpartition(" ")
|
|
norm = token.replace("\\", "/")
|
|
if "/" in norm:
|
|
dir_part, _, name = norm.rpartition("/")
|
|
base = dir_part if os.path.isabs(dir_part) else os.path.join(self._cwd, dir_part)
|
|
rebuilt_prefix = token[: len(token) - len(name)] # keep the dir + separator as typed
|
|
else:
|
|
base, name, rebuilt_prefix = self._cwd, token, ""
|
|
try:
|
|
entries = sorted(os.listdir(base or self._cwd))
|
|
except OSError:
|
|
return
|
|
low = name.lower()
|
|
matches = [e for e in entries if e.lower().startswith(low)]
|
|
if not matches:
|
|
return
|
|
|
|
def _decorate(entry: str) -> str:
|
|
full = os.path.join(base or self._cwd, entry)
|
|
return entry + (os.sep if os.path.isdir(full) else "")
|
|
|
|
if len(matches) == 1:
|
|
completed = _decorate(matches[0])
|
|
self.input.setText((head + sep) + rebuilt_prefix + completed)
|
|
else:
|
|
common = os.path.commonprefix(matches)
|
|
if len(common) > len(name):
|
|
self.input.setText((head + sep) + rebuilt_prefix + common)
|
|
# List the candidates (like bash's double-Tab) so the user can see them.
|
|
self._append(" ".join(_decorate(m) for m in matches) + "\n", role="out")
|
|
|
|
# ---- running commands ----------------------------------------------------
|
|
def _run_current(self) -> None:
|
|
cmd = self.input.text().strip()
|
|
if not cmd:
|
|
return
|
|
self.input.clear()
|
|
self._history.append(cmd)
|
|
self._hist_idx = len(self._history)
|
|
self.run_command(cmd)
|
|
|
|
def run_command(self, cmd: str) -> None:
|
|
self._append(f"\n{_prompt_for(self._cwd)} {cmd}\n", role="cmd")
|
|
stripped = cmd.strip()
|
|
if stripped in ("clear", "cls"):
|
|
self.output.clear()
|
|
return
|
|
if stripped == "cd" or stripped.lower().startswith(("cd ", "cd\t")):
|
|
self._change_dir(stripped[2:].strip())
|
|
return
|
|
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
|
|
self._append(tr("terminal.busy") + "\n", role="err")
|
|
return
|
|
self._start_process(cmd)
|
|
|
|
def _change_dir(self, target: str) -> None:
|
|
target = target.strip()
|
|
if target.lower().startswith("/d "): # cmd's "cd /d X:\path" flag
|
|
target = target[3:].strip()
|
|
target = target.strip('"').strip("'")
|
|
if not target:
|
|
self.set_cwd(str(Path.home()))
|
|
return
|
|
# A bare drive letter ("D:") means that drive's root ("D:\").
|
|
if _IS_WIN and re.fullmatch(r"[A-Za-z]:", target):
|
|
target = target + os.sep
|
|
new = target if os.path.isabs(target) else os.path.join(self._cwd, target)
|
|
new = os.path.normpath(new)
|
|
if os.path.isdir(new):
|
|
self.set_cwd(new)
|
|
else:
|
|
self._append(tr("terminal.cd_error", path=target) + "\n", role="err")
|
|
|
|
def _start_process(self, cmd: str) -> None:
|
|
proc = QProcess(self)
|
|
proc.setWorkingDirectory(self._cwd)
|
|
proc.setProcessChannelMode(QProcess.SeparateChannels)
|
|
proc.readyReadStandardOutput.connect(
|
|
lambda: self._append(_decode(bytes(proc.readAllStandardOutput()))))
|
|
proc.readyReadStandardError.connect(
|
|
lambda: self._append(_decode(bytes(proc.readAllStandardError())), role="err"))
|
|
proc.finished.connect(self._on_finished)
|
|
proc.errorOccurred.connect(
|
|
lambda _e: self._append(tr("terminal.launch_error") + "\n", role="err"))
|
|
self._proc = proc
|
|
self._set_running(True)
|
|
if _IS_WIN:
|
|
# ``chcp 65001`` switches cmd to the UTF-8 codepage so non-ASCII
|
|
# (e.g. Japanese) output isn't mojibake. (No ``/u`` — that would emit
|
|
# UTF-16 and fight the UTF-8 decode.)
|
|
proc.start("cmd.exe", ["/c", f"chcp 65001>nul & {cmd}"])
|
|
else:
|
|
proc.start(os.environ.get("SHELL", "/bin/sh"), ["-c", cmd])
|
|
|
|
def _on_finished(self, code: int, _status=None) -> None:
|
|
self._append(tr("terminal.exit", code=code) + "\n",
|
|
role="ok" if code == 0 else "err")
|
|
self._set_running(False)
|
|
|
|
def _set_running(self, running: bool) -> None:
|
|
self.input.setEnabled(not running)
|
|
self._run_btn.setEnabled(not running)
|
|
if not running:
|
|
self.input.setFocus()
|
|
|
|
def _append(self, text: str, role: str = "out") -> None:
|
|
if not text:
|
|
return
|
|
from PySide6.QtGui import QColor, QTextCursor
|
|
p = current_palette()
|
|
colors = {"cmd": p.code_type, "err": p.code_error, "ok": p.code_comment, "out": p.code_fg}
|
|
cursor = self.output.textCursor()
|
|
cursor.movePosition(QTextCursor.End)
|
|
fmt = cursor.charFormat()
|
|
fmt.setForeground(QColor(colors.get(role, p.code_fg)))
|
|
cursor.setCharFormat(fmt)
|
|
cursor.insertText(text)
|
|
self.output.setTextCursor(cursor)
|
|
self.output.ensureCursorVisible()
|
|
|
|
# ---- lifecycle -----------------------------------------------------------
|
|
def stop(self) -> None:
|
|
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
|
|
self._proc.kill()
|
|
|
|
def _retranslate(self) -> None:
|
|
self._title.setText(tr("terminal.title"))
|
|
self._run_btn.setText(tr("terminal.run"))
|
|
self.input.setPlaceholderText(tr("terminal.placeholder"))
|
|
self._apply_collapsed()
|
|
|
|
|
|
def _prompt_for(cwd: str) -> str:
|
|
name = Path(cwd).name or cwd
|
|
return f"{name} >" if _IS_WIN else f"{name} $"
|
|
|
|
|
|
def _decode(data: bytes) -> str:
|
|
import locale
|
|
encs = ["utf-8"]
|
|
try:
|
|
encs.append(locale.getpreferredencoding(False))
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
encs += ["cp932", "cp1252", "latin-1"] # cp932 = Japanese Windows console
|
|
for enc in encs:
|
|
try:
|
|
return data.decode(enc)
|
|
except (UnicodeDecodeError, LookupError):
|
|
continue
|
|
return data.decode("utf-8", errors="replace")
|