Files
cowork-local/ui/terminal_panel.py
T
NamPDTandClaude Opus 5 291a611737 chore: sync local working copy as of 2026-08-15
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>
2026-08-17 11:38:18 +09:00

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")