diff --git a/hermes-ssh-chat/BarWidget.qml b/hermes-ssh-chat/BarWidget.qml new file mode 100644 index 000000000..7948a28bb --- /dev/null +++ b/hermes-ssh-chat/BarWidget.qml @@ -0,0 +1,103 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Modules.Bar.Extras +import qs.Services.UI +import qs.Widgets +import "." as Local + +Item { + id: root + + property ShellScreen screen + property var pluginApi: null + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property string screenName: screen ? screen.name : "" + readonly property string barPosition: Settings.getBarPositionForScreen(screenName) + readonly property bool isVertical: barPosition === "left" || barPosition === "right" + readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName) + readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName) + readonly property bool showBarText: Local.HermesSession.setting("showBarText", true) + readonly property color stateColor: Local.HermesSession.connected ? Color.mPrimary : (Local.HermesSession.connecting ? Color.mSecondary : Color.mOnSurfaceVariant) + + implicitWidth: isVertical ? capsuleHeight : Math.round(contentRow.implicitWidth + Style.margin2M) + implicitHeight: isVertical ? Math.round(contentRow.implicitHeight + Style.margin2M) : capsuleHeight + + Component.onCompleted: Local.HermesSession.setPluginApi(pluginApi) + + NPopupContextMenu { + id: contextMenu + + model: [ + { + "label": pluginApi?.tr("menu.settings"), + "action": "settings", + "icon": "settings" + }, + ] + + onTriggered: action => { + contextMenu.close(); + PanelService.closeContextMenu(screen); + if (action === "settings" && pluginApi?.manifest) + BarService.openPluginSettings(screen, pluginApi.manifest); + } + } + + Rectangle { + anchors.fill: parent + radius: Style.radiusM + color: Style.capsuleColor + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + RowLayout { + id: contentRow + anchors.centerIn: parent + spacing: Style.marginXS + + Image { + property real iconSize: Style.toOdd(root.capsuleHeight * 0.42) + + Layout.preferredWidth: iconSize + Layout.preferredHeight: iconSize + source: Qt.resolvedUrl("assets/hermesagent.svg") + sourceSize.width: iconSize + sourceSize.height: iconSize + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + opacity: Local.HermesSession.sessionActive ? 1.0 : 0.72 + } + + NText { + visible: root.showBarText && !root.isVertical + text: Local.HermesSession.connecting ? pluginApi?.tr("widget.connecting") : pluginApi?.tr("widget.label") + pointSize: root.barFontSize + applyUiScale: false + color: root.stateColor + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + pluginApi?.togglePanel(screen, root); + } else if (mouse.button === Qt.RightButton) { + PanelService.showContextMenu(contextMenu, root, screen); + } + } + onEntered: TooltipService.show(root, Local.HermesSession.statusText) + onExited: TooltipService.hide() + } + } +} diff --git a/hermes-ssh-chat/HermesSession.qml b/hermes-ssh-chat/HermesSession.qml new file mode 100644 index 000000000..a4d9de208 --- /dev/null +++ b/hermes-ssh-chat/HermesSession.qml @@ -0,0 +1,240 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + property var pluginApi: null + property string host: "" + property int port: 22 + property string user: "" + property string password: "" + property string status: "idle" + property string statusText: "" + property string lastError: "" + property string terminalBuffer: "" + + readonly property bool connecting: status === "connecting" + readonly property bool connected: status === "connected" + readonly property bool sessionActive: connecting || connected + readonly property string targetLabel: user && host ? user + "@" + host : "Hermes" + + property var _pendingConnect: null + property var _pendingResize: null + property bool _autoConnectAttempted: false + + signal terminalOutput(string text) + signal terminalReset() + + function tr(key, args) { + return root.pluginApi ? root.pluginApi.tr(key, args || ({})) : key; + } + + function resetTerminal() { + root.terminalBuffer = ""; + root.terminalReset(); + } + + function setPluginApi(api) { + if (!api) return; + root.pluginApi = api; + root.host = api.pluginSettings.host || ""; + root.port = api.pluginSettings.port || 22; + root.user = api.pluginSettings.user || ""; + if (root.status === "idle" && root.statusText.length === 0) + root.statusText = tr("status.disconnected"); + root.maybeAutoConnect(); + } + + function setting(key, fallback) { + if (!root.pluginApi || !root.pluginApi.pluginSettings) return fallback; + var value = root.pluginApi.pluginSettings[key]; + return value === undefined ? fallback : value; + } + + function helperPath() { + var url = String(Qt.resolvedUrl("helpers/hermes_ssh_bridge.py")); + return decodeURIComponent(url.replace(/^file:\/\//, "")); + } + + function maybeAutoConnect() { + if (root._autoConnectAttempted || !setting("autoConnectOnStartup", false)) + return; + + root._autoConnectAttempted = true; + + if (!root.host || !root.user || root.sessionActive) + return; + + Qt.callLater(function() { + if (!root.sessionActive) + root.connect(root.host, root.port, root.user, "", setting("terminalRows", 32), setting("terminalCols", 180)); + }); + } + + function connect(targetHost, targetPort, targetUser, targetPassword, rows, cols) { + root.host = String(targetHost || "").trim(); + root.port = Math.max(1, Math.min(65535, Number(targetPort || 22))); + root.user = String(targetUser || "").trim(); + root.password = String(targetPassword || ""); + root.lastError = ""; + + if (!root.host || !root.user) { + root.status = "idle"; + root.statusText = tr("status.missingTarget"); + root.lastError = root.statusText; + return; + } + + if (root.pluginApi && setting("rememberLastTarget", true)) { + root.pluginApi.pluginSettings.host = root.host; + root.pluginApi.pluginSettings.port = root.port; + root.pluginApi.pluginSettings.user = root.user; + root.pluginApi.saveSettings(); + } + + root.resetTerminal(); + root.status = "connecting"; + root.statusText = tr("status.connecting", { "target": root.targetLabel }); + + root._pendingConnect = JSON.stringify({ + "type": "connect", + "host": root.host, + "port": root.port, + "user": root.user, + "password": root.password, + "rows": Math.max(1, Number(rows || setting("terminalRows", 32))), + "cols": Math.max(1, Number(cols || setting("terminalCols", 180))) + }) + "\n"; + + bridge.command = ["python3", root.helperPath()]; + if (bridge.running) { + bridge.running = false; + return; + } + bridge.running = true; + } + + function send(text) { + if (!bridge.running || !root.sessionActive) return; + bridge.write(JSON.stringify({"type": "input", "text": String(text || "")}) + "\n"); + } + + function resize(rows, cols) { + var payload = JSON.stringify({ + "type": "resize", + "rows": Math.max(1, Number(rows || setting("terminalRows", 32))), + "cols": Math.max(1, Number(cols || setting("terminalCols", 180))) + }) + "\n"; + + if (bridge.running && root.sessionActive) { + bridge.write(payload); + } else { + root._pendingResize = payload; + } + } + + function disconnect() { + root._pendingConnect = null; + root._pendingResize = null; + if (bridge.running) { + bridge.write(JSON.stringify({"type": "disconnect"}) + "\n"); + } + root.status = "idle"; + root.statusText = tr("status.disconnected"); + root.lastError = ""; + root.password = ""; + root.resetTerminal(); + } + + function handleMessage(line) { + var trimmed = String(line || "").trim(); + if (!trimmed) return; + + var msg = null; + try { + msg = JSON.parse(trimmed); + } catch (e) { + return; + } + + if (msg.type === "output") { + var text = String(msg.text || ""); + root.terminalBuffer += text; + root.terminalOutput(text); + return; + } + + if (msg.type === "status") { + if (msg.status === "idle" && root.status === "connecting") + return; + root.status = String(msg.status || root.status); + root.statusText = String(msg.message || root.statusText); + return; + } + + if (msg.type === "error") { + root.lastError = String(msg.message || tr("status.error")); + root.statusText = root.lastError; + return; + } + + if (msg.type === "exit") { + root.status = "idle"; + root.statusText = tr("status.disconnected"); + root.password = ""; + root.resetTerminal(); + } + } + + function flushPending() { + if (root._pendingConnect) { + bridge.write(root._pendingConnect); + root._pendingConnect = null; + } + if (root._pendingResize) { + bridge.write(root._pendingResize); + root._pendingResize = null; + } + } + + Process { + id: bridge + running: false + stdinEnabled: true + + stdout: SplitParser { + splitMarker: "\n" + onRead: function(data) { root.handleMessage(data); } + } + + stderr: SplitParser { + splitMarker: "\n" + onRead: function(data) { + var text = String(data || "").trim(); + if (text.length > 0) { + root.lastError = text; + root.statusText = text; + } + } + } + + onStarted: root.flushPending() + + onExited: function(exitCode, exitStatus) { + if (root._pendingConnect) { + bridge.running = true; + return; + } + if (root.sessionActive) { + root.status = "idle"; + root.statusText = tr("status.bridgeExited"); + root.password = ""; + root.resetTerminal(); + } + } + } +} diff --git a/hermes-ssh-chat/LICENSE b/hermes-ssh-chat/LICENSE new file mode 100644 index 000000000..38332e2bd --- /dev/null +++ b/hermes-ssh-chat/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 focky + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/hermes-ssh-chat/Main.qml b/hermes-ssh-chat/Main.qml new file mode 100644 index 000000000..24b7787f4 --- /dev/null +++ b/hermes-ssh-chat/Main.qml @@ -0,0 +1,44 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland + +Item { + id: root + property var pluginApi: null + + property var cfg: pluginApi?.pluginSettings || ({}) + property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + readonly property string shortcutName: cfg.toggleShortcutName ?? defaults.toggleShortcutName ?? "hermes-toggle" + + function togglePanel() { + if (root.pluginApi) + root.pluginApi.withCurrentScreen(screen => root.pluginApi.togglePanel(screen)) + } + + IpcHandler { + target: "plugin:hermes-ssh-chat" + + function toggle() { + root.togglePanel() + } + function open() { + if (root.pluginApi) + root.pluginApi.withCurrentScreen(screen => root.pluginApi.openPanel(screen)) + } + function close() { + if (root.pluginApi) + root.pluginApi.closePanel() + } + } + + // Hyprland global shortcut. The physical key combo is bound in the + // compositor config to `noctalia:`; only the name is set here. + // Other compositors fall back to the IPC command (see README). + GlobalShortcut { + appid: "noctalia" + name: root.shortcutName + description: pluginApi?.tr("shortcut.description") ?? "Toggle the Hermes SSH terminal panel" + onPressed: root.togglePanel() + } +} diff --git a/hermes-ssh-chat/Panel.qml b/hermes-ssh-chat/Panel.qml new file mode 100644 index 000000000..69a26c042 --- /dev/null +++ b/hermes-ssh-chat/Panel.qml @@ -0,0 +1,233 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Services.UI +import qs.Widgets +import "." as Local + +Item { + id: root + + property var pluginApi: null + readonly property var geometryPlaceholder: panelContainer + readonly property bool allowAttach: true + property real contentPreferredWidth: Math.round(Math.max(720, Math.min(1800, Number(cfg.panelWidth ?? defaults.panelWidth ?? 1200))) * Style.uiScaleRatio) + property real contentPreferredHeight: Math.round(Math.max(560, Number(cfg.panelHeight ?? defaults.panelHeight ?? 760)) * Style.uiScaleRatio) + + property var cfg: pluginApi?.pluginSettings || ({}) + property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + property string valueHost: cfg.host ?? defaults.host ?? "" + property int valuePort: cfg.port ?? defaults.port ?? 22 + property string valueUser: cfg.user ?? defaults.user ?? "" + property string valuePassword: "" + property int terminalCols: cfg.terminalCols ?? defaults.terminalCols ?? 180 + property int terminalRows: cfg.terminalRows ?? defaults.terminalRows ?? 32 + property int terminalFontSize: cfg.terminalFontSize ?? defaults.terminalFontSize ?? 11 + + function doConnect() { + Local.HermesSession.connect( + root.valueHost, + root.valuePort, + root.valueUser, + root.valuePassword, + root.terminalRows, + root.terminalCols + ); + root.valuePassword = ""; + passwordInput.text = ""; + } + + Component.onCompleted: Local.HermesSession.setPluginApi(pluginApi) + + Connections { + target: pluginApi + function onPluginSettingsChanged() { + root.cfg = pluginApi?.pluginSettings || ({}); + root.valueHost = cfg.host ?? defaults.host ?? ""; + root.valuePort = cfg.port ?? defaults.port ?? 22; + root.valueUser = cfg.user ?? defaults.user ?? ""; + root.terminalCols = cfg.terminalCols ?? defaults.terminalCols ?? 180; + root.terminalRows = cfg.terminalRows ?? defaults.terminalRows ?? 32; + root.terminalFontSize = cfg.terminalFontSize ?? defaults.terminalFontSize ?? 11; + Local.HermesSession.setPluginApi(pluginApi); + } + } + + ColumnLayout { + id: panelContainer + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + NBox { + Layout.fillWidth: true + implicitHeight: headerRow.implicitHeight + Style.margin2M + + RowLayout { + id: headerRow + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + Image { + property real iconSize: Math.round(Style.baseWidgetSize * 0.8) + + Layout.preferredWidth: iconSize + Layout.preferredHeight: iconSize + source: Qt.resolvedUrl("assets/hermesagent.svg") + sourceSize.width: iconSize + sourceSize.height: iconSize + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true + opacity: Local.HermesSession.sessionActive ? 1.0 : 0.72 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS + + NText { + Layout.fillWidth: true + text: pluginApi?.tr("panel.title") + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + } + + NText { + Layout.fillWidth: true + text: Local.HermesSession.statusText + pointSize: Style.fontSizeS + color: Local.HermesSession.lastError ? Color.mError : Color.mOnSurfaceVariant + elide: Text.ElideRight + } + } + + NIconButton { + visible: Local.HermesSession.sessionActive + baseSize: Style.baseWidgetSize * 0.8 + icon: "power" + tooltipText: pluginApi?.tr("panel.disconnect") + colorFg: Color.mError + onClicked: Local.HermesSession.disconnect() + } + + NIconButton { + baseSize: Style.baseWidgetSize * 0.8 + icon: "settings" + tooltipText: pluginApi?.tr("panel.settings") + onClicked: { + var screen = pluginApi?.panelOpenScreen; + if (screen && pluginApi?.manifest) + BarService.openPluginSettings(screen, pluginApi.manifest); + } + } + + NIconButton { + baseSize: Style.baseWidgetSize * 0.8 + icon: "close" + tooltipText: pluginApi?.tr("panel.close") + onClicked: pluginApi?.closePanel(pluginApi?.panelOpenScreen) + } + } + } + + NBox { + Layout.fillWidth: true + visible: !Local.HermesSession.sessionActive + implicitHeight: connectColumn.implicitHeight + Style.margin2M + + ColumnLayout { + id: connectColumn + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("panel.host.label") + placeholderText: pluginApi?.tr("panel.host.placeholder") + text: root.valueHost + enabled: !Local.HermesSession.sessionActive + onTextChanged: root.valueHost = text + onAccepted: root.doConnect() + } + + NTextInput { + Layout.preferredWidth: Math.round(100 * Style.uiScaleRatio) + label: pluginApi?.tr("panel.port.label") + placeholderText: pluginApi?.tr("panel.port.placeholder") + text: String(root.valuePort) + enabled: !Local.HermesSession.sessionActive + inputMethodHints: Qt.ImhDigitsOnly + onTextChanged: root.valuePort = Math.max(1, Math.min(65535, Number(text || 22))) + onAccepted: root.doConnect() + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("panel.user.label") + placeholderText: pluginApi?.tr("panel.user.placeholder") + text: root.valueUser + enabled: !Local.HermesSession.sessionActive + onTextChanged: root.valueUser = text + onAccepted: root.doConnect() + } + + NTextInput { + id: passwordInput + Layout.fillWidth: true + label: pluginApi?.tr("panel.password.label") + placeholderText: pluginApi?.tr("panel.password.placeholder") + enabled: !Local.HermesSession.sessionActive + inputItem.echoMode: TextInput.Password + onTextChanged: root.valuePassword = text + onAccepted: root.doConnect() + } + } + + NButton { + Layout.fillWidth: true + text: pluginApi?.tr("panel.connect") + icon: "plug-connected" + enabled: root.valueHost.length > 0 && root.valueUser.length > 0 + onClicked: root.doConnect() + } + } + } + + NBox { + Layout.fillWidth: true + Layout.fillHeight: true + visible: Local.HermesSession.sessionActive + + TerminalView { + id: terminalView + anchors.fill: parent + anchors.margins: Style.marginM + cols: root.terminalCols + rows: root.terminalRows + fontSize: root.terminalFontSize + rawText: Local.HermesSession.terminalBuffer + sessionActive: Local.HermesSession.sessionActive + onInput: function(text) { Local.HermesSession.send(text); } + } + } + } + + onVisibleChanged: { + if (visible && Local.HermesSession.sessionActive) + Qt.callLater(function() { terminalView.focusTerminal(); }); + } +} diff --git a/hermes-ssh-chat/README.md b/hermes-ssh-chat/README.md new file mode 100644 index 000000000..858bee5a6 --- /dev/null +++ b/hermes-ssh-chat/README.md @@ -0,0 +1,117 @@ +# Hermes SSH Terminal + +Run **Hermes Agent** on a remote machine from the Noctalia bar through an interactive SSH terminal. + +![Hermes SSH Terminal preview](preview.png) + +## Features + +- Bar widget with the Hermes Agent icon. +- Interactive SSH session that launches `hermes` on the remote host. +- Terminal-like input for Enter, Backspace, arrow keys, Ctrl+C, Ctrl+D, Ctrl+L, and Tab. +- Panel state survives closing and reopening while the SSH session is active. +- Optional automatic connection on Noctalia startup. +- Passwords are never saved; use SSH keys for unattended startup connections. + +## Requirements + +- Noctalia Shell >= 4.6.6. +- `python3` and `ssh` on the local machine. +- `hermes` available in `PATH` on the remote host. +- SSH key authentication for `Connect on startup`. + +## Installation + +Install from Noctalia's plugin browser when available, then: + +1. Enable **Hermes SSH Terminal** in Settings -> Plugins. +2. Add the bar widget in Settings -> Bar. +3. Open the widget and configure the SSH host, port, and user. + +## Keyboard shortcut + +You bind a physical key in your compositor; the plugin handles the toggle. The +chosen key opens and closes the panel, and an active SSH session is focused on +open so you can type immediately. + +### IPC bind (works on every compositor — recommended) + +The plugin exposes IPC commands (`toggle`, `open`, `close`). Bind a key to call: + +```bash +qs -c noctalia-shell ipc call plugin:hermes-ssh-chat toggle +``` + +**Niri** (`config.kdl`) — add inside your `binds { ... }` block: + +```kdl +binds { + Mod+H { spawn "qs" "-c" "noctalia-shell" "ipc" "call" "plugin:hermes-ssh-chat" "toggle"; } +} +``` + +**Hyprland** (`hyprland.conf`): + +```ini +bind = SUPER, H, exec, qs -c noctalia-shell ipc call plugin:hermes-ssh-chat toggle +``` + +**Mango**: + +```bash +bind=SUPER,H,spawn_shell,noctalia-shell ipc call plugin:hermes-ssh-chat toggle +``` + +### Hyprland global shortcut (optional — uses the settings field) + +Only on Hyprland (needs `hyprland_global_shortcuts_v1`). The plugin registers a +global shortcut named in **Settings -> Plugins -> Hermes SSH Terminal -> Toggle +shortcut name** (default `hermes-toggle`). Bind a key to it: + +```ini +bind = SUPER, H, global, noctalia:hermes-toggle +``` + +Use the same name on both sides. On Niri/Mango this method is inert — use the +IPC bind above. + +## Settings + +| Setting | Description | +|---|---| +| Default host / IP | Remote machine where `hermes` runs. | +| Default user | SSH user for the remote machine. | +| SSH port | SSH port, default `22`. | +| Panel width / height | Preferred panel dimensions. | +| Terminal columns / rows | PTY size sent to the remote session. | +| Terminal font size | Font size used by the terminal renderer. | +| Connect on startup | Starts the SSH session when Noctalia loads the plugin. | +| Remember last target | Saves the last host, port, and user after connecting. | +| Show text in bar | Shows the Hermes label next to the bar icon. | +| Toggle shortcut name | Hyprland global shortcut id used to open/close the panel (default `hermes-toggle`). | + +## Troubleshooting + +### The panel connects but Hermes does not open + +Run this manually from a terminal and fix any remote shell issues first: + +```bash +ssh -tt user@host "TERM=xterm-256color COLORTERM=truecolor hermes" +``` + +### Connect on startup does not finish + +This option does not save passwords. Configure SSH key authentication for the target host. + +### The terminal display looks misaligned + +Increase the panel width or lower terminal columns in the plugin settings. Hermes uses a TUI and expects enough columns for its status and input lines. + +## Credits + +The Hermes Agent icon is from [Lobe Icons](https://github.com/lobehub/lobe-icons), licensed MIT. + +## License + +MIT diff --git a/hermes-ssh-chat/Settings.qml b/hermes-ssh-chat/Settings.qml new file mode 100644 index 000000000..0d9df071c --- /dev/null +++ b/hermes-ssh-chat/Settings.qml @@ -0,0 +1,146 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + property var cfg: pluginApi?.pluginSettings || ({}) + property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + property string valueHost: cfg.host ?? defaults.host ?? "" + property int valuePort: cfg.port ?? defaults.port ?? 22 + property string valueUser: cfg.user ?? defaults.user ?? "" + property bool valueRememberLastTarget: cfg.rememberLastTarget ?? defaults.rememberLastTarget ?? true + property bool valueAutoConnectOnStartup: cfg.autoConnectOnStartup ?? defaults.autoConnectOnStartup ?? false + property int valuePanelWidth: cfg.panelWidth ?? defaults.panelWidth ?? 1200 + property int valuePanelHeight: cfg.panelHeight ?? defaults.panelHeight ?? 760 + property int valueTerminalCols: cfg.terminalCols ?? defaults.terminalCols ?? 180 + property int valueTerminalRows: cfg.terminalRows ?? defaults.terminalRows ?? 32 + property int valueTerminalFontSize: cfg.terminalFontSize ?? defaults.terminalFontSize ?? 11 + property bool valueShowBarText: cfg.showBarText ?? defaults.showBarText ?? true + property string valueToggleShortcutName: cfg.toggleShortcutName ?? defaults.toggleShortcutName ?? "hermes-toggle" + + function saveSettings() { + if (!pluginApi) + return; + + pluginApi.pluginSettings.host = root.valueHost; + pluginApi.pluginSettings.port = root.valuePort; + pluginApi.pluginSettings.user = root.valueUser; + pluginApi.pluginSettings.rememberLastTarget = root.valueRememberLastTarget; + pluginApi.pluginSettings.autoConnectOnStartup = root.valueAutoConnectOnStartup; + pluginApi.pluginSettings.panelWidth = root.valuePanelWidth; + pluginApi.pluginSettings.panelHeight = root.valuePanelHeight; + pluginApi.pluginSettings.terminalCols = root.valueTerminalCols; + pluginApi.pluginSettings.terminalRows = root.valueTerminalRows; + pluginApi.pluginSettings.terminalFontSize = root.valueTerminalFontSize; + pluginApi.pluginSettings.showBarText = root.valueShowBarText; + pluginApi.pluginSettings.toggleShortcutName = root.valueToggleShortcutName; + pluginApi.saveSettings(); + } + + spacing: Style.marginL + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.host.label") + description: pluginApi?.tr("settings.host.desc") + placeholderText: pluginApi?.tr("settings.host.placeholder") + text: root.valueHost + onTextChanged: root.valueHost = text + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.user.label") + placeholderText: pluginApi?.tr("settings.user.placeholder") + text: root.valueUser + onTextChanged: root.valueUser = text + } + + NSpinBox { + label: pluginApi?.tr("settings.port.label") + from: 1 + to: 65535 + stepSize: 1 + value: root.valuePort + onValueChanged: root.valuePort = value + } + + NSpinBox { + label: pluginApi?.tr("settings.panelWidth.label") + description: pluginApi?.tr("settings.panelWidth.desc") + from: 720 + to: 1800 + stepSize: 20 + value: root.valuePanelWidth + onValueChanged: root.valuePanelWidth = value + } + + NSpinBox { + label: pluginApi?.tr("settings.panelHeight.label") + description: pluginApi?.tr("settings.panelHeight.desc") + from: 560 + to: 1200 + stepSize: 20 + value: root.valuePanelHeight + onValueChanged: root.valuePanelHeight = value + } + + NSpinBox { + label: pluginApi?.tr("settings.terminalCols.label") + from: 80 + to: 240 + stepSize: 5 + value: root.valueTerminalCols + onValueChanged: root.valueTerminalCols = value + } + + NSpinBox { + label: pluginApi?.tr("settings.terminalRows.label") + from: 20 + to: 80 + stepSize: 1 + value: root.valueTerminalRows + onValueChanged: root.valueTerminalRows = value + } + + NSpinBox { + label: pluginApi?.tr("settings.terminalFontSize.label") + from: 8 + to: 20 + stepSize: 1 + value: root.valueTerminalFontSize + onValueChanged: root.valueTerminalFontSize = value + } + + NToggle { + label: pluginApi?.tr("settings.autoConnectOnStartup.label") + checked: root.valueAutoConnectOnStartup + onToggled: checked => root.valueAutoConnectOnStartup = checked + } + + NToggle { + label: pluginApi?.tr("settings.rememberLastTarget.label") + checked: root.valueRememberLastTarget + onToggled: checked => root.valueRememberLastTarget = checked + } + + NToggle { + label: pluginApi?.tr("settings.showBarText.label") + checked: root.valueShowBarText + onToggled: checked => root.valueShowBarText = checked + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.toggleShortcut.label") + description: pluginApi?.tr("settings.toggleShortcut.desc", { name: root.valueToggleShortcutName }) + placeholderText: "hermes-toggle" + text: root.valueToggleShortcutName + onTextChanged: root.valueToggleShortcutName = text + } +} diff --git a/hermes-ssh-chat/TerminalView.qml b/hermes-ssh-chat/TerminalView.qml new file mode 100644 index 000000000..c8c0f2ddd --- /dev/null +++ b/hermes-ssh-chat/TerminalView.qml @@ -0,0 +1,572 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import "." as Local + +Item { + id: root + + property string rawText: "" + property bool sessionActive: false + property real fontSize: Style.fontSizeS + property string fontFamily: Settings.data.ui.fontFixed + property int rows: 32 + property int cols: 180 + + signal input(string text) + + property var _ts: null + + Component.onCompleted: { + _initTerminal(); + if (rawText.length > 0) { + _processInput(rawText); + root._ts.processed = rawText.length; + _refreshDisplay(); + } + Qt.callLater(focusTerminal); + } + onRowsChanged: _initTerminal() + onColsChanged: _initTerminal() + onVisibleChanged: if (visible) Qt.callLater(focusTerminal) + onSessionActiveChanged: if (sessionActive) Qt.callLater(focusTerminal) + + Connections { + target: Local.HermesSession + function onTerminalOutput(text) { + if (!root._ts) _initTerminal(); + _processInput(text); + _renderTimer.restart(); + } + function onTerminalReset() { + _initTerminal(); + _renderTimer.restart(); + } + } + + onRawTextChanged: { + if (!root._ts) { + _initTerminal(); + if (rawText.length > 0) { + _processInput(rawText); + root._ts.processed = rawText.length; + } + _refreshDisplay(); + return; + } + if (rawText.length < root._ts.processed) { + _initTerminal(); + if (rawText.length > 0) { + _processInput(rawText); + } + root._ts.processed = rawText.length; + _refreshDisplay(); + return; + } + root._ts.processed = rawText.length; + } + + Timer { + id: _renderTimer + interval: 16 + onTriggered: _refreshDisplay() + } + + function _initTerminal() { + const sr = Math.max(12, Math.min(80, Number(root.rows || 32))); + const sc = Math.max(40, Math.min(240, Number(root.cols || 180))); + root._ts = { + screen: makeScreen(sr, sc), + rows: sr, + cols: sc, + row: 0, + col: 0, + savedRow: 0, + savedCol: 0, + scrollTop: 0, + scrollBottom: sr - 1, + originMode: false, + wrapPending: false, + sgr: { fg: "", bg: "", bold: false, dim: false }, + pending: "", + processed: 0 + }; + } + + function focusTerminal() { + if (!root.visible) return; + terminalDisplay.forceActiveFocus(); + } + + function htmlEscape(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/ /g, " ") + .replace(/\t/g, "    "); + } + + function color16(code) { + const colors = { + 30: "#2e3440", 31: "#bf616a", 32: "#a3be8c", 33: "#ebcb8b", + 34: "#81a1c1", 35: "#b48ead", 36: "#88c0d0", 37: "#e5e9f0", + 90: "#4c566a", 91: "#d08770", 92: "#8fbcbb", 93: "#f0d399", + 94: "#8ab6e6", 95: "#c895bf", 96: "#8fdbd7", 97: "#eceff4" + }; + return colors[code] || Color.mOnSurface; + } + + function color256(index) { + index = Math.max(0, Math.min(255, Number(index) || 0)); + const base = [ + "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0", + "#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff" + ]; + if (index < 16) return base[index]; + if (index >= 232) { + const level = 8 + (index - 232) * 10; + const hex = level.toString(16).padStart(2, "0"); + return "#" + hex + hex + hex; + } + const n = index - 16; + const steps = [0, 95, 135, 175, 215, 255]; + const r = steps[Math.floor(n / 36) % 6]; + const g = steps[Math.floor(n / 6) % 6]; + const b = steps[n % 6]; + return "#" + r.toString(16).padStart(2, "0") + g.toString(16).padStart(2, "0") + b.toString(16).padStart(2, "0"); + } + + function styleAttr(state) { + let css = ""; + if (state.fg) css += "color:" + state.fg + ";"; + if (state.bg) css += "background-color:" + state.bg + ";"; + if (state.bold) css += "font-weight:700;"; + if (state.dim) css += "opacity:0.72;"; + return css.length > 0 ? " style=\"" + css + "\"" : ""; + } + + function copyState(state) { + return { fg: state.fg, bg: state.bg, bold: state.bold, dim: state.dim }; + } + + function blankCell() { + return { ch: " ", fg: "", bg: "", bold: false, dim: false }; + } + + function cellFrom(ch, state) { + return { ch: ch, fg: state.fg, bg: state.bg, bold: state.bold, dim: state.dim }; + } + + function makeLine(cols) { + const line = []; + for (let i = 0; i < cols; i++) line.push(blankCell()); + return line; + } + + function makeScreen(rows, cols) { + const screen = []; + for (let r = 0; r < rows; r++) screen.push(makeLine(cols)); + return screen; + } + + function applySgr(params, state) { + if (params.length === 0) params = [0]; + for (let i = 0; i < params.length; i++) { + const code = params[i]; + if (code === 0) { state.fg = ""; state.bg = ""; state.bold = false; state.dim = false; } + else if (code === 1) { state.bold = true; } + else if (code === 2) { state.dim = true; } + else if (code === 22) { state.bold = false; state.dim = false; } + else if (code === 39) { state.fg = ""; } + else if (code === 49) { state.bg = ""; } + else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { state.fg = color16(code); } + else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { state.bg = color16(code - 10); } + else if ((code === 38 || code === 48) && params[i + 1] === 5 && i + 2 < params.length) { + if (code === 38) state.fg = color256(params[i + 2]); else state.bg = color256(params[i + 2]); + i += 2; + } + } + } + + function appendSpan(out, text, state) { + if (text.length === 0) return out; + return out + "" + htmlEscape(text) + ""; + } + + function clearLine(line, start, end) { + const from = Math.max(0, start); + const to = Math.min(line.length - 1, end); + for (let i = from; i <= to; i++) line[i] = blankCell(); + } + + function clearScreen(screen, cols) { + for (let r = 0; r < screen.length; r++) screen[r] = makeLine(cols); + } + + function parseParams(seq) { + const cleaned = String(seq || "").replace(/[?=]/g, ""); + if (cleaned.length === 0) return []; + return cleaned.split(";").map(function(p) { return Number(p || 0); }); + } + + function sameCellStyle(a, b) { + return a.fg === b.fg && a.bg === b.bg && a.bold === b.bold && a.dim === b.dim; + } + + function renderLine(line) { + let out = ""; + let buffer = ""; + let current = { fg: "", bg: "", bold: false, dim: false }; + + function flush() { out = appendSpan(out, buffer, current); buffer = ""; } + + let lastNonSpace = -1; + for (let i = 0; i < line.length; i++) { + if (line[i].ch !== " " || line[i].bg) lastNonSpace = i; + } + if (lastNonSpace === -1) return " "; + + current = copyState(line[0]); + for (let i = 0; i <= lastNonSpace; i++) { + const cell = line[i]; + if (!sameCellStyle(cell, current)) { flush(); current = copyState(cell); } + buffer += cell.ch; + } + flush(); + return out; + } + + function _processInput(input) { + const ts = root._ts; + if (!ts || !input || input.length === 0) return; + + if (ts.pending.length > 0) { + input = ts.pending + input; + ts.pending = ""; + } + + function clampCursor() { + if (ts.originMode) + ts.row = Math.max(ts.scrollTop, Math.min(ts.scrollBottom, ts.row)); + else + ts.row = Math.max(0, Math.min(ts.rows - 1, ts.row)); + ts.col = Math.max(0, Math.min(ts.cols - 1, ts.col)); + } + + function doScrollUp() { + ts.screen.splice(ts.scrollTop, 1); + ts.screen.splice(ts.scrollBottom, 0, makeLine(ts.cols)); + } + + function doScrollDown() { + ts.screen.splice(ts.scrollBottom, 1); + ts.screen.splice(ts.scrollTop, 0, makeLine(ts.cols)); + } + + function doLineFeed() { + ts.col = 0; + if (ts.row === ts.scrollBottom) doScrollUp(); + else ts.row = Math.min(ts.row + 1, ts.rows - 1); + ts.wrapPending = false; + } + + function doIndex() { + if (ts.row === ts.scrollBottom) doScrollUp(); + else ts.row = Math.min(ts.row + 1, ts.rows - 1); + ts.wrapPending = false; + } + + function doReverseIndex() { + if (ts.row === ts.scrollTop) doScrollDown(); + else ts.row = Math.max(ts.scrollTop, ts.row - 1); + ts.wrapPending = false; + } + + function putChar(ch) { + if (ts.wrapPending) { + doIndex(); + ts.col = 0; + } + ts.screen[ts.row][ts.col] = cellFrom(ch, ts.sgr); + if (ts.col === ts.cols - 1) { + ts.wrapPending = true; + } else { + ts.col++; + ts.wrapPending = false; + } + } + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + + if (ch === "\x1b" || ch === "\x9b") { + if (ch === "\x1b" && i + 1 >= input.length) { + ts.pending = input.substring(i); + return; + } + const next = ch === "\x9b" ? "[" : input[i + 1]; + + if (next === "[") { + let j = ch === "\x9b" ? i + 1 : i + 2; + let seq = ""; + while (j < input.length && !/[@-~]/.test(input[j])) { + seq += input[j]; + j++; + } + if (j >= input.length) { + ts.pending = input.substring(i); + return; + } + + const command = input[j]; + const params = parseParams(seq); + if (command === "m") { + applySgr(params, ts.sgr); + } else if (command === "A") { + ts.row -= Math.max(1, params[0] || 1); ts.wrapPending = false; clampCursor(); + } else if (command === "B") { + ts.row += Math.max(1, params[0] || 1); ts.wrapPending = false; clampCursor(); + } else if (command === "C") { + ts.col += Math.max(1, params[0] || 1); ts.wrapPending = false; clampCursor(); + } else if (command === "D") { + ts.col -= Math.max(1, params[0] || 1); ts.wrapPending = false; clampCursor(); + } else if (command === "E") { + ts.row += Math.max(1, params[0] || 1); ts.col = 0; ts.wrapPending = false; clampCursor(); + } else if (command === "F") { + ts.row -= Math.max(1, params[0] || 1); ts.col = 0; ts.wrapPending = false; clampCursor(); + } else if (command === "G") { + ts.col = Math.max(0, (params[0] || 1) - 1); ts.wrapPending = false; clampCursor(); + } else if (command === "H" || command === "f") { + ts.row = (params[0] || 1) - 1; + if (ts.originMode) ts.row += ts.scrollTop; + ts.col = Math.max(0, (params[1] || 1) - 1); + ts.wrapPending = false; + clampCursor(); + } else if (command === "s") { + ts.savedRow = ts.row; ts.savedCol = ts.col; + } else if (command === "u") { + ts.row = ts.savedRow; ts.col = ts.savedCol; ts.wrapPending = false; clampCursor(); + } else if (command === "d") { + ts.row = (params[0] || 1) - 1; + if (ts.originMode) ts.row += ts.scrollTop; + ts.wrapPending = false; + clampCursor(); + } else if (command === "K") { + const mode = params[0] || 0; + if (mode === 1) clearLine(ts.screen[ts.row], 0, ts.col); + else if (mode === 2) clearLine(ts.screen[ts.row], 0, ts.cols - 1); + else clearLine(ts.screen[ts.row], ts.col, ts.cols - 1); + } else if (command === "J") { + const mode = params[0] || 0; + if (mode === 2 || mode === 3) { + clearScreen(ts.screen, ts.cols); ts.row = 0; ts.col = 0; ts.wrapPending = false; + } else if (mode === 0) { + clearLine(ts.screen[ts.row], ts.col, ts.cols - 1); + for (let r = ts.row + 1; r < ts.rows; r++) ts.screen[r] = makeLine(ts.cols); + } else if (mode === 1) { + for (let r = 0; r < ts.row; r++) ts.screen[r] = makeLine(ts.cols); + clearLine(ts.screen[ts.row], 0, ts.col); + } + } else if (command === "X") { + clearLine(ts.screen[ts.row], ts.col, ts.col + Math.max(1, params[0] || 1) - 1); + } else if (command === "P") { + const count = Math.max(1, params[0] || 1); + for (let c = ts.col; c < ts.cols; c++) + ts.screen[ts.row][c] = c + count < ts.cols ? ts.screen[ts.row][c + count] : blankCell(); + } else if (command === "@") { + const count = Math.max(1, params[0] || 1); + for (let c = ts.cols - 1; c >= ts.col; c--) + ts.screen[ts.row][c] = c - count >= ts.col ? ts.screen[ts.row][c - count] : blankCell(); + } else if (command === "L") { + const count = Math.min(Math.max(1, params[0] || 1), ts.scrollBottom - ts.row + 1); + for (let n = 0; n < count; n++) { + ts.screen.splice(ts.scrollBottom, 1); + ts.screen.splice(ts.row, 0, makeLine(ts.cols)); + } + } else if (command === "M") { + const count = Math.min(Math.max(1, params[0] || 1), ts.scrollBottom - ts.row + 1); + for (let n = 0; n < count; n++) { + ts.screen.splice(ts.row, 1); + ts.screen.splice(ts.scrollBottom, 0, makeLine(ts.cols)); + } + } else if (command === "S") { + const count = Math.max(1, params[0] || 1); + for (let n = 0; n < count; n++) doScrollUp(); + } else if (command === "T") { + const count = Math.max(1, params[0] || 1); + for (let n = 0; n < count; n++) doScrollDown(); + } else if (command === "r") { + if (params.length === 0) { + ts.scrollTop = 0; ts.scrollBottom = ts.rows - 1; + } else { + ts.scrollTop = Math.max(0, (params[0] || 1) - 1); + ts.scrollBottom = (params.length > 1 && params[1] > 0) + ? Math.min(ts.rows - 1, params[1] - 1) : ts.rows - 1; + if (ts.scrollTop >= ts.scrollBottom) { ts.scrollTop = 0; ts.scrollBottom = ts.rows - 1; } + } + ts.row = ts.originMode ? ts.scrollTop : 0; + ts.col = 0; + ts.wrapPending = false; + } else if (command === "h" || command === "l") { + const modeNums = seq.replace(/[?]/g, "").split(";").map(function(p) { return Number(p || 0); }); + if (modeNums.some(function(n) { return n === 1049 || n === 47 || n === 1047; })) { + clearScreen(ts.screen, ts.cols); + ts.row = 0; ts.col = 0; ts.scrollTop = 0; ts.scrollBottom = ts.rows - 1; ts.originMode = false; ts.wrapPending = false; + } + if (modeNums.indexOf(6) !== -1) { + ts.originMode = command === "h"; + if (ts.originMode) { ts.row = ts.scrollTop; ts.col = 0; ts.wrapPending = false; } + } + } + i = j; + + } else if (next === "]") { + let j = i + 2; + while (j < input.length && input[j] !== "\x07" && !(input[j] === "\x1b" && j + 1 < input.length && input[j + 1] === "\\")) { + j++; + } + if (j >= input.length) { + ts.pending = input.substring(i); + return; + } + if (input[j] === "\x1b") j++; + i = j; + + } else if (next === "7" || next === "s") { + ts.savedRow = ts.row; ts.savedCol = ts.col; i++; + } else if (next === "8" || next === "u") { + ts.row = ts.savedRow; ts.col = ts.savedCol; ts.wrapPending = false; clampCursor(); i++; + } else if (next === "c") { + clearScreen(ts.screen, ts.cols); + ts.row = 0; ts.col = 0; ts.scrollTop = 0; ts.scrollBottom = ts.rows - 1; ts.originMode = false; ts.wrapPending = false; i++; + } else if (next === "D") { + doIndex(); i++; + } else if (next === "E") { + doLineFeed(); i++; + } else if (next === "M") { + doReverseIndex(); i++; + } else { + i++; + } + + } else if (ch === "\r") { + ts.col = 0; ts.wrapPending = false; + } else if (ch === "\n") { + doLineFeed(); + } else if (ch === "\x84") { + doIndex(); + } else if (ch === "\x85") { + doLineFeed(); + } else if (ch === "\x8d") { + doReverseIndex(); + } else if (ch === "\b" || ch === "\x7f") { + ts.col = Math.max(0, ts.col - 1); ts.wrapPending = false; + } else if (ch === "\t") { + const nextTab = Math.min(ts.cols - 1, ts.col + (8 - (ts.col % 8))); + while (ts.col < nextTab) putChar(" "); + } else if (ch >= " ") { + putChar(ch); + } else if (ch === "\x0c") { + clearScreen(ts.screen, ts.cols); + ts.row = 0; ts.col = 0; ts.scrollTop = 0; ts.scrollBottom = ts.rows - 1; ts.originMode = false; ts.wrapPending = false; + } + } + } + + function _refreshDisplay() { + const ts = root._ts; + if (!ts) return; + + const cr = ts.row; + const cc = ts.col; + let savedCell = null; + if (cr >= 0 && cr < ts.rows && cc >= 0 && cc < ts.cols) { + const orig = ts.screen[cr][cc]; + savedCell = { ch: orig.ch, fg: orig.fg, bg: orig.bg, bold: orig.bold, dim: orig.dim }; + ts.screen[cr][cc] = { + ch: orig.ch === " " ? "\u00a0" : orig.ch, + fg: "#0b0f14", + bg: "#d8dee9", + bold: orig.bold, + dim: false + }; + } + + const lines = []; + for (let r = 0; r < ts.rows; r++) + lines.push(renderLine(ts.screen[r])); + + if (savedCell) ts.screen[cr][cc] = savedCell; + + terminalDisplay.text = lines.join("
"); + } + + Rectangle { + anchors.fill: parent + radius: Style.radiusS + color: Color.mSurface + border.color: terminalDisplay.activeFocus ? Color.mSecondary : Color.mOutline + border.width: Style.borderS + + ScrollView { + id: scrollView + anchors.fill: parent + anchors.margins: Style.marginM + clip: true + + TextEdit { + id: terminalDisplay + width: Math.max(640, scrollView.width - Style.margin2M) + height: Math.max(implicitHeight, scrollView.availableHeight) + readOnly: true + selectByMouse: true + focus: true + wrapMode: TextEdit.NoWrap + textFormat: TextEdit.RichText + color: Color.mOnSurface + selectedTextColor: Color.mOnPrimary + selectionColor: Color.mPrimary + font.family: root.fontFamily + font.pointSize: Math.max(1, root.fontSize * Style.uiScaleRatio) + textMargin: 0 + + Keys.onPressed: event => { + if (!root.sessionActive) return; + if (event.modifiers & Qt.ControlModifier) { + if (event.key === Qt.Key_V) { + const clip = String(Quickshell.clipboardText || ""); + if (clip.length > 0) root.input(clip); + event.accepted = true; + return; + } + if (event.key === Qt.Key_C) { root.input("\x03"); event.accepted = true; } + else if (event.key === Qt.Key_D) { root.input("\x04"); event.accepted = true; } + else if (event.key === Qt.Key_L) { root.input("\x0c"); event.accepted = true; } + return; + } + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { root.input("\r"); event.accepted = true; } + else if (event.key === Qt.Key_Backspace) { root.input("\x7f"); event.accepted = true; } + else if (event.key === Qt.Key_Tab) { root.input("\t"); event.accepted = true; } + else if (event.key === Qt.Key_Left) { root.input("\x1b[D"); event.accepted = true; } + else if (event.key === Qt.Key_Right) { root.input("\x1b[C"); event.accepted = true; } + else if (event.key === Qt.Key_Up) { root.input("\x1b[A"); event.accepted = true; } + else if (event.key === Qt.Key_Down) { root.input("\x1b[B"); event.accepted = true; } + else if (event.text && event.text.length > 0) { root.input(event.text); event.accepted = true; } + } + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + propagateComposedEvents: true + onPressed: mouse => { + terminalDisplay.forceActiveFocus(); + mouse.accepted = false; + } + } + } +} diff --git a/hermes-ssh-chat/assets/hermesagent.svg b/hermes-ssh-chat/assets/hermesagent.svg new file mode 100644 index 000000000..109bc2b38 --- /dev/null +++ b/hermes-ssh-chat/assets/hermesagent.svg @@ -0,0 +1 @@ +Hermes Agent \ No newline at end of file diff --git a/hermes-ssh-chat/helpers/hermes_ssh_bridge.py b/hermes-ssh-chat/helpers/hermes_ssh_bridge.py new file mode 100644 index 000000000..e76c26b3f --- /dev/null +++ b/hermes-ssh-chat/helpers/hermes_ssh_bridge.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +import json +import os +import pty +import select +import signal +import subprocess +import sys +import termios +import threading +import time +from queue import Empty, Queue + + +ANSI_RE_TEXT = "\x1b[@-_][0-?]*[ -/]*[@-~]|\x1b\\][^\a]*(?:\a|\x1b\\\\)" + + +def emit(message): + sys.stdout.write(json.dumps(message, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def decode_output(data): + return data.decode("utf-8", errors="replace") + + +def strip_ansi(text): + import re + + return re.sub(ANSI_RE_TEXT, "", text) + + +def stdin_reader(queue): + for line in sys.stdin: + try: + queue.put(json.loads(line)) + except json.JSONDecodeError as exc: + emit({"type": "error", "message": f"invalid json command: {exc}"}) + + +def set_winsize(fd, rows=32, cols=120): + try: + import fcntl + import struct + + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + except Exception: + pass + + +class Session: + def __init__(self): + self.master_fd = None + self.proc = None + self.password = "" + self.password_sent = False + self.last_password_prompt = 0.0 + + def start(self, command, rows=32, cols=120): + self.stop() + self.master_fd, slave_fd = pty.openpty() + set_winsize(self.master_fd, rows, cols) + + env = os.environ.copy() + env.setdefault("TERM", "xterm-256color") + env.setdefault("COLORTERM", "truecolor") + + self.proc = subprocess.Popen( + command, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, + start_new_session=True, + env=env, + ) + os.close(slave_fd) + + def resize(self, rows=32, cols=120): + if self.master_fd is not None: + set_winsize(self.master_fd, rows, cols) + + def write(self, text): + if self.master_fd is None: + return + os.write(self.master_fd, text.encode("utf-8", errors="replace")) + + def read_ready(self): + if self.master_fd is None: + return "" + try: + data = os.read(self.master_fd, 4096) + except OSError: + return "" + return decode_output(data) + + def maybe_answer_password(self, text): + if not self.password or self.password_sent: + return + lower = strip_ansi(text).lower() + if "password:" not in lower: + return + now = time.monotonic() + if now - self.last_password_prompt < 0.3: + return + self.last_password_prompt = now + self.password_sent = True + self.write(self.password + "\n") + + def stop(self): + if self.proc and self.proc.poll() is None: + try: + os.killpg(self.proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + self.proc.wait(timeout=2) + except subprocess.TimeoutExpired: + try: + os.killpg(self.proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.proc.wait(timeout=2) + + if self.master_fd is not None: + try: + os.close(self.master_fd) + except OSError: + pass + + self.master_fd = None + self.proc = None + self.password = "" + self.password_sent = False + + +def ssh_command(host, port, user): + return [ + "ssh", + "-tt", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "ServerAliveInterval=30", + "-p", + str(port), + f"{user}@{host}", + "TERM=xterm-256color COLORTERM=truecolor hermes", + ] + + +def main(): + queue = Queue() + threading.Thread(target=stdin_reader, args=(queue,), daemon=True).start() + session = Session() + + emit({"type": "status", "status": "idle", "message": "Bridge ready"}) + + try: + while True: + try: + while True: + command = queue.get_nowait() + command_type = command.get("type") + + if command_type == "connect": + host = str(command.get("host", "")).strip() + user = str(command.get("user", "")).strip() + port = int(command.get("port") or 22) + rows = int(command.get("rows") or 32) + cols = int(command.get("cols") or 120) + + if not host or not user: + emit({"type": "error", "message": "host and user are required"}) + continue + + session.password = str(command.get("password", "")) + session.password_sent = False + emit( + { + "type": "status", + "status": "connecting", + "message": f"Connecting to {user}@{host}", + } + ) + session.start(ssh_command(host, port, user), rows, cols) + + elif command_type == "input": + session.write(str(command.get("text", ""))) + + elif command_type == "resize": + rows = int(command.get("rows") or 32) + cols = int(command.get("cols") or 120) + session.resize(rows, cols) + + elif command_type == "disconnect": + session.stop() + emit({"type": "exit", "code": 0}) + return + + except Empty: + pass + + if session.proc is None: + time.sleep(0.05) + continue + + readable, _, _ = select.select([session.master_fd], [], [], 0.05) + if readable: + text = session.read_ready() + if text: + session.maybe_answer_password(text) + emit({"type": "output", "text": text}) + if session.proc and session.proc.poll() is None: + emit( + { + "type": "status", + "status": "connected", + "message": "Connected", + } + ) + + code = session.proc.poll() + if code is not None: + emit({"type": "exit", "code": code}) + session.stop() + return + + except KeyboardInterrupt: + pass + finally: + session.stop() + + +if __name__ == "__main__": + main() diff --git a/hermes-ssh-chat/i18n/en.json b/hermes-ssh-chat/i18n/en.json new file mode 100644 index 000000000..65a8a3654 --- /dev/null +++ b/hermes-ssh-chat/i18n/en.json @@ -0,0 +1,86 @@ +{ + "menu": { + "settings": "Widget settings" + }, + "panel": { + "title": "Hermes Terminal", + "connect": "Connect", + "disconnect": "Disconnect", + "settings": "Settings", + "close": "Close", + "host": { + "label": "Host / IP", + "placeholder": "192.168.1.129" + }, + "port": { + "label": "Port", + "placeholder": "22" + }, + "user": { + "label": "User", + "placeholder": "user" + }, + "password": { + "label": "Password", + "placeholder": "optional (use SSH key)" + } + }, + "settings": { + "host": { + "label": "Default host / IP", + "desc": "Saved target for the panel. Passwords are never saved.", + "placeholder": "192.168.1.10" + }, + "user": { + "label": "Default user", + "placeholder": "user" + }, + "port": { + "label": "SSH port" + }, + "panelWidth": { + "label": "Panel width", + "desc": "Preferred panel width in pixels." + }, + "panelHeight": { + "label": "Panel height", + "desc": "Preferred panel height in pixels." + }, + "terminalCols": { + "label": "Terminal columns" + }, + "terminalRows": { + "label": "Terminal rows" + }, + "terminalFontSize": { + "label": "Terminal font size" + }, + "autoConnectOnStartup": { + "label": "Connect on startup" + }, + "rememberLastTarget": { + "label": "Remember last target" + }, + "showBarText": { + "label": "Show text in bar" + }, + "toggleShortcut": { + "label": "Toggle shortcut name (Hyprland)", + "desc": "Hyprland global shortcut id. Bind it in your config: bind = SUPER, H, global, noctalia:{name}. Other compositors: use the IPC command (see README)." + } + }, + "status": { + "missingTarget": "Host and user are required", + "connecting": "Connecting to {target}", + "disconnected": "Disconnected", + "error": "Error", + "bridgeExited": "Bridge exited" + }, + "widget": { + "label": "Hermes", + "connecting": "SSH" + }, + "shortcut": { + "description": "Toggle the Hermes SSH terminal panel" + } +} diff --git a/hermes-ssh-chat/i18n/pt.json b/hermes-ssh-chat/i18n/pt.json new file mode 100644 index 000000000..e9d616216 --- /dev/null +++ b/hermes-ssh-chat/i18n/pt.json @@ -0,0 +1,86 @@ +{ + "menu": { + "settings": "Configurações do widget" + }, + "panel": { + "title": "Terminal Hermes", + "connect": "Conectar", + "disconnect": "Desconectar", + "settings": "Configurações", + "close": "Fechar", + "host": { + "label": "Host / IP", + "placeholder": "192.168.1.129" + }, + "port": { + "label": "Porta", + "placeholder": "22" + }, + "user": { + "label": "Usuário", + "placeholder": "usuário" + }, + "password": { + "label": "Senha", + "placeholder": "opcional (use chave SSH)" + } + }, + "settings": { + "host": { + "label": "Host / IP padrão", + "desc": "Destino salvo para o painel. Senhas nunca são salvas.", + "placeholder": "192.168.1.10" + }, + "user": { + "label": "Usuário padrão", + "placeholder": "usuário" + }, + "port": { + "label": "Porta SSH" + }, + "panelWidth": { + "label": "Largura do painel", + "desc": "Largura preferida do painel em pixels." + }, + "panelHeight": { + "label": "Altura do painel", + "desc": "Altura preferida do painel em pixels." + }, + "terminalCols": { + "label": "Colunas do terminal" + }, + "terminalRows": { + "label": "Linhas do terminal" + }, + "terminalFontSize": { + "label": "Tamanho da fonte do terminal" + }, + "autoConnectOnStartup": { + "label": "Conectar na inicialização" + }, + "rememberLastTarget": { + "label": "Lembrar último destino" + }, + "showBarText": { + "label": "Mostrar texto na barra" + }, + "toggleShortcut": { + "label": "Nome do atalho de abrir/fechar (Hyprland)", + "desc": "Id do atalho global do Hyprland. Vincule no seu config: bind = SUPER, H, global, noctalia:{name}. Outros compositores: use o comando IPC (veja o README)." + } + }, + "status": { + "missingTarget": "Host e usuário são obrigatórios", + "connecting": "Conectando a {target}", + "disconnected": "Desconectado", + "error": "Erro", + "bridgeExited": "Bridge encerrada" + }, + "widget": { + "label": "Hermes", + "connecting": "SSH" + }, + "shortcut": { + "description": "Abrir/fechar o painel Hermes SSH" + } +} diff --git a/hermes-ssh-chat/manifest.json b/hermes-ssh-chat/manifest.json new file mode 100644 index 000000000..8c2f63dcc --- /dev/null +++ b/hermes-ssh-chat/manifest.json @@ -0,0 +1,42 @@ +{ + "id": "hermes-ssh-chat", + "name": "Hermes SSH Terminal", + "version": "1.0.0", + "minNoctaliaVersion": "4.6.6", + "author": "focky", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Use Hermes on a remote server through an interactive SSH terminal.", + "tags": [ + "Bar", + "Panel", + "Development", + "AI" + ], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "cpuIntensive": false, + "defaultSettings": { + "host": "", + "port": 22, + "user": "", + "rememberLastTarget": true, + "autoConnectOnStartup": false, + "toggleShortcutName": "hermes-toggle", + "panelWidth": 1200, + "panelHeight": 760, + "terminalCols": 180, + "terminalRows": 32, + "terminalFontSize": 11, + "showBarText": true + } + } +} diff --git a/hermes-ssh-chat/preview.png b/hermes-ssh-chat/preview.png new file mode 100644 index 000000000..aa38a2204 Binary files /dev/null and b/hermes-ssh-chat/preview.png differ diff --git a/hermes-ssh-chat/qmldir b/hermes-ssh-chat/qmldir new file mode 100644 index 000000000..2732179eb --- /dev/null +++ b/hermes-ssh-chat/qmldir @@ -0,0 +1 @@ +singleton HermesSession 1.0 HermesSession.qml