Files
cowork-local/ui/terminal_panel.py
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

362 lines
15 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
"""Tab yêu cầu tự hoàn tất đường dẫn; Lên/Xuống duyệt lịch sử lệnh."""
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):
"""Terminal thu gọn ở đáy tab Thư mục.
Mở ở trạng thái gập lại; ``_hist_idx`` trỏ quá phần tử cuối khi không duyệt
lịch sử, nên mũi tên lên lần đầu ra lệnh gần nhất.
"""
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:
"""Đổi thư mục làm việc (bỏ qua nếu đường dẫn không tồn tại) và cập nhật dấu nhắc."""
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:
"""Gập/mở panel; mở ra thì phát ``expanded`` để chỗ gọi trỏ shell về đúng thư mục."""
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:
"""Đặt thẳng trạng thái gập/mở, không phát tín hiệu."""
self._collapsed = collapsed
self._apply_collapsed()
def _apply_collapsed(self) -> None:
"""Áp trạng thái gập/mở lên phần thân, icon và tooltip của nút."""
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:
"""Đi lên/xuống trong lịch sử lệnh; đi quá cuối thì trả ô nhập về rỗng."""
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:
"""Tự hoàn tất đường dẫn cho đoạn đang gõ (giống Tab của shell)."""
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:
"""Thêm dấu phân cách vào cuối tên nếu đó là thư mục."""
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:
"""Chạy lệnh đang gõ trong ô nhập rồi xoá ô."""
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:
"""Chạy một lệnh: ``clear``/``cls`` và ``cd`` xử lý tại chỗ, còn lại giao cho
tiến trình con.
"""
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:
"""Xử lý ``cd`` ngay trong panel.
Phải tự xử lý vì mỗi lệnh chạy trong một tiến trình riêng — ``cd`` giao cho
tiến trình con sẽ đổi thư mục của chính nó rồi biến mất cùng nó.
"""
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:
"""Khởi động tiến trình con chạy lệnh, đọc stdout/stderr theo luồng."""
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:
"""Tiến trình kết thúc: in mã thoát (xanh nếu 0, đỏ nếu khác) và mở khoá ô nhập."""
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:
"""Khoá/mở ô nhập và nút Chạy theo trạng thái đang chạy."""
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:
"""Nối văn bản vào khung kết quả, tô màu theo vai trò (lệnh / ra / lỗi / mã thoát)."""
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:
"""Giết tiến trình đang chạy, nếu có."""
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
self._proc.kill()
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
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:
"""Dấu nhắc lệnh theo thư mục hiện tại: ``tên >`` trên Windows, ``tên $`` nơi khác."""
name = Path(cwd).name or cwd
return f"{name} >" if _IS_WIN else f"{name} $"
def _decode(data: bytes) -> str:
"""Giải mã đầu ra tiến trình con: thử UTF-8 trước, rồi tới bảng mã mặc định
của hệ điều hành.
Cần vì console Windows tiếng Việt/Nhật trả về cp1258/cp932 chứ không phải
UTF-8, giải mã cứng một bảng mã là ra chữ rác.
"""
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")