From 1c48a2b8b27983daa4f262c44f2622c6fc0cf1df Mon Sep 17 00:00:00 2001 From: Felipe Mayer Date: Mon, 15 Jun 2026 01:37:50 -0300 Subject: [PATCH 1/6] feat(hermes-ssh-chat): add Hermes SSH terminal plugin --- hermes-ssh-chat/BarWidget.qml | 103 ++++ hermes-ssh-chat/HermesSession.qml | 240 ++++++++ hermes-ssh-chat/LICENSE | 21 + hermes-ssh-chat/Panel.qml | 231 ++++++++ hermes-ssh-chat/README.md | 69 +++ hermes-ssh-chat/Settings.qml | 135 +++++ hermes-ssh-chat/TerminalView.qml | 565 +++++++++++++++++++ hermes-ssh-chat/assets/hermesagent.svg | 1 + hermes-ssh-chat/helpers/hermes_ssh_bridge.py | 236 ++++++++ hermes-ssh-chat/i18n/en.json | 79 +++ hermes-ssh-chat/i18n/pt.json | 79 +++ hermes-ssh-chat/manifest.json | 40 ++ hermes-ssh-chat/preview.png | Bin 0 -> 52952 bytes hermes-ssh-chat/qmldir | 1 + 14 files changed, 1800 insertions(+) create mode 100644 hermes-ssh-chat/BarWidget.qml create mode 100644 hermes-ssh-chat/HermesSession.qml create mode 100644 hermes-ssh-chat/LICENSE create mode 100644 hermes-ssh-chat/Panel.qml create mode 100644 hermes-ssh-chat/README.md create mode 100644 hermes-ssh-chat/Settings.qml create mode 100644 hermes-ssh-chat/TerminalView.qml create mode 100644 hermes-ssh-chat/assets/hermesagent.svg create mode 100644 hermes-ssh-chat/helpers/hermes_ssh_bridge.py create mode 100644 hermes-ssh-chat/i18n/en.json create mode 100644 hermes-ssh-chat/i18n/pt.json create mode 100644 hermes-ssh-chat/manifest.json create mode 100644 hermes-ssh-chat/preview.png create mode 100644 hermes-ssh-chat/qmldir diff --git a/hermes-ssh-chat/BarWidget.qml b/hermes-ssh-chat/BarWidget.qml new file mode 100644 index 000000000..53c29ac97 --- /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 + 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/Panel.qml b/hermes-ssh-chat/Panel.qml new file mode 100644 index 000000000..461cb6911 --- /dev/null +++ b/hermes-ssh-chat/Panel.qml @@ -0,0 +1,231 @@ +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 + 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 bool allowAttach: true + + 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 { + 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..ef666c0f5 --- /dev/null +++ b/hermes-ssh-chat/README.md @@ -0,0 +1,69 @@ +# 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. + +## 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. | + +## 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..b20d82123 --- /dev/null +++ b/hermes-ssh-chat/Settings.qml @@ -0,0 +1,135 @@ +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 + + 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.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 + } +} diff --git a/hermes-ssh-chat/TerminalView.qml b/hermes-ssh-chat/TerminalView.qml new file mode 100644 index 000000000..a6e2df726 --- /dev/null +++ b/hermes-ssh-chat/TerminalView.qml @@ -0,0 +1,565 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +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_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..d642d4e9a --- /dev/null +++ b/hermes-ssh-chat/i18n/en.json @@ -0,0 +1,79 @@ +{ + "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" + } + }, + "status": { + "missingTarget": "Host and user are required", + "connecting": "Connecting to {target}", + "disconnected": "Disconnected", + "error": "Error", + "bridgeExited": "Bridge exited" + }, + "widget": { + "label": "Hermes", + "connecting": "SSH" + } +} diff --git a/hermes-ssh-chat/i18n/pt.json b/hermes-ssh-chat/i18n/pt.json new file mode 100644 index 000000000..f3f0e6673 --- /dev/null +++ b/hermes-ssh-chat/i18n/pt.json @@ -0,0 +1,79 @@ +{ + "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" + } + }, + "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" + } +} diff --git a/hermes-ssh-chat/manifest.json b/hermes-ssh-chat/manifest.json new file mode 100644 index 000000000..555c3ac44 --- /dev/null +++ b/hermes-ssh-chat/manifest.json @@ -0,0 +1,40 @@ +{ + "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": { + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "cpuIntensive": false, + "defaultSettings": { + "host": "", + "port": 22, + "user": "", + "rememberLastTarget": true, + "autoConnectOnStartup": false, + "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 0000000000000000000000000000000000000000..aa38a2204e2b6d13e29fabcc92faa8d157d3bddc GIT binary patch literal 52952 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 literal 0 HcmV?d00001 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 From 4aa18d949655820e1899a66529adfc74f0183a9c Mon Sep 17 00:00:00 2001 From: Felipe Mayer Date: Mon, 15 Jun 2026 01:49:40 -0300 Subject: [PATCH 2/6] fix(hermes-ssh-chat): add required plugin properties for code-quality check --- hermes-ssh-chat/BarWidget.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes-ssh-chat/BarWidget.qml b/hermes-ssh-chat/BarWidget.qml index 53c29ac97..7948a28bb 100644 --- a/hermes-ssh-chat/BarWidget.qml +++ b/hermes-ssh-chat/BarWidget.qml @@ -11,7 +11,7 @@ Item { id: root property ShellScreen screen - property var pluginApi + property var pluginApi: null property string widgetId: "" property string section: "" property int sectionWidgetIndex: -1 From 1647141e881a6bb13bd6270b951d982121d9d9df Mon Sep 17 00:00:00 2001 From: Felipe Mayer Date: Mon, 15 Jun 2026 01:49:41 -0300 Subject: [PATCH 3/6] fix(hermes-ssh-chat): add required plugin properties for code-quality check --- hermes-ssh-chat/Panel.qml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/hermes-ssh-chat/Panel.qml b/hermes-ssh-chat/Panel.qml index 461cb6911..69a26c042 100644 --- a/hermes-ssh-chat/Panel.qml +++ b/hermes-ssh-chat/Panel.qml @@ -9,10 +9,11 @@ import "." as Local Item { id: root - property var pluginApi + 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 bool allowAttach: true property var cfg: pluginApi?.pluginSettings || ({}) property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) @@ -54,6 +55,7 @@ Item { } ColumnLayout { + id: panelContainer anchors.fill: parent anchors.margins: Style.marginL spacing: Style.marginM From 0241ac451178d99e5f07e544e813ca28f9fd0f15 Mon Sep 17 00:00:00 2001 From: Felipe Mayer Date: Fri, 19 Jun 2026 21:37:36 -0300 Subject: [PATCH 4/6] feat(hermes-ssh-chat): support paste via Ctrl+V Forward Quickshell.clipboardText to the PTY on Ctrl+V so users can paste into the Hermes terminal. Bridge already accepts raw text, so content (\r\n, tabs, spaces) is preserved unchanged. --- hermes-ssh-chat/TerminalView.qml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hermes-ssh-chat/TerminalView.qml b/hermes-ssh-chat/TerminalView.qml index a6e2df726..c8c0f2ddd 100644 --- a/hermes-ssh-chat/TerminalView.qml +++ b/hermes-ssh-chat/TerminalView.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Quickshell import qs.Commons import "." as Local @@ -535,6 +536,12 @@ Item { 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; } From 39ccdd9d3aa12c6e2eefcbc4cdfa269983f8ff79 Mon Sep 17 00:00:00 2001 From: Felipe Mayer Date: Sat, 20 Jun 2026 14:53:52 -0300 Subject: [PATCH 5/6] feat(hermes-ssh-chat): add hotkey to toggle panel Add a Main.qml entry point exposing IPC commands (toggle/open/close) so a compositor keybind can show/hide the panel, plus a Hyprland GlobalShortcut whose name is configurable in plugin settings. Document the bind for Niri, Hyprland, and Mango. Focus is delivered to the terminal on open via the existing Panel visibility handler. Co-Authored-By: Claude Opus 4.8 --- hermes-ssh-chat/Main.qml | 44 ++++++++++++++++++++++++++++++++ hermes-ssh-chat/README.md | 48 +++++++++++++++++++++++++++++++++++ hermes-ssh-chat/Settings.qml | 11 ++++++++ hermes-ssh-chat/i18n/en.json | 4 +++ hermes-ssh-chat/i18n/pt.json | 4 +++ hermes-ssh-chat/manifest.json | 2 ++ 6 files changed, 113 insertions(+) create mode 100644 hermes-ssh-chat/Main.qml diff --git a/hermes-ssh-chat/Main.qml b/hermes-ssh-chat/Main.qml new file mode 100644 index 000000000..9c15eeafe --- /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: "Toggle the Hermes SSH terminal panel" + onPressed: root.togglePanel() + } +} diff --git a/hermes-ssh-chat/README.md b/hermes-ssh-chat/README.md index ef666c0f5..858bee5a6 100644 --- a/hermes-ssh-chat/README.md +++ b/hermes-ssh-chat/README.md @@ -28,6 +28,53 @@ Install from Noctalia's plugin browser when available, then: 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 | @@ -41,6 +88,7 @@ Install from Noctalia's plugin browser when available, then: | 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 diff --git a/hermes-ssh-chat/Settings.qml b/hermes-ssh-chat/Settings.qml index b20d82123..0d9df071c 100644 --- a/hermes-ssh-chat/Settings.qml +++ b/hermes-ssh-chat/Settings.qml @@ -21,6 +21,7 @@ ColumnLayout { 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) @@ -37,6 +38,7 @@ ColumnLayout { pluginApi.pluginSettings.terminalRows = root.valueTerminalRows; pluginApi.pluginSettings.terminalFontSize = root.valueTerminalFontSize; pluginApi.pluginSettings.showBarText = root.valueShowBarText; + pluginApi.pluginSettings.toggleShortcutName = root.valueToggleShortcutName; pluginApi.saveSettings(); } @@ -132,4 +134,13 @@ ColumnLayout { 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/i18n/en.json b/hermes-ssh-chat/i18n/en.json index d642d4e9a..073ed1e3f 100644 --- a/hermes-ssh-chat/i18n/en.json +++ b/hermes-ssh-chat/i18n/en.json @@ -63,6 +63,10 @@ }, "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": { diff --git a/hermes-ssh-chat/i18n/pt.json b/hermes-ssh-chat/i18n/pt.json index f3f0e6673..74f6c5839 100644 --- a/hermes-ssh-chat/i18n/pt.json +++ b/hermes-ssh-chat/i18n/pt.json @@ -63,6 +63,10 @@ }, "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": { diff --git a/hermes-ssh-chat/manifest.json b/hermes-ssh-chat/manifest.json index 555c3ac44..8c2f63dcc 100644 --- a/hermes-ssh-chat/manifest.json +++ b/hermes-ssh-chat/manifest.json @@ -14,6 +14,7 @@ "AI" ], "entryPoints": { + "main": "Main.qml", "barWidget": "BarWidget.qml", "panel": "Panel.qml", "settings": "Settings.qml" @@ -29,6 +30,7 @@ "user": "", "rememberLastTarget": true, "autoConnectOnStartup": false, + "toggleShortcutName": "hermes-toggle", "panelWidth": 1200, "panelHeight": 760, "terminalCols": 180, From eb80074c0bae5d8cf3cb7c0574c858c3ae98dceb Mon Sep 17 00:00:00 2001 From: Felipe Mayer Date: Sat, 20 Jun 2026 17:28:28 -0300 Subject: [PATCH 6/6] fix(hermes-ssh-chat): use i18n for GlobalShortcut description Replaces hardcoded string on Main.qml:41 with pluginApi?.tr() to satisfy the code-quality CI check. Fallback string keeps compositor label when pluginApi is not yet set. Adds shortcut.description key to en/pt. Co-Authored-By: Claude Sonnet 4.6 --- hermes-ssh-chat/Main.qml | 2 +- hermes-ssh-chat/i18n/en.json | 3 +++ hermes-ssh-chat/i18n/pt.json | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/hermes-ssh-chat/Main.qml b/hermes-ssh-chat/Main.qml index 9c15eeafe..24b7787f4 100644 --- a/hermes-ssh-chat/Main.qml +++ b/hermes-ssh-chat/Main.qml @@ -38,7 +38,7 @@ Item { GlobalShortcut { appid: "noctalia" name: root.shortcutName - description: "Toggle the Hermes SSH terminal panel" + description: pluginApi?.tr("shortcut.description") ?? "Toggle the Hermes SSH terminal panel" onPressed: root.togglePanel() } } diff --git a/hermes-ssh-chat/i18n/en.json b/hermes-ssh-chat/i18n/en.json index 073ed1e3f..65a8a3654 100644 --- a/hermes-ssh-chat/i18n/en.json +++ b/hermes-ssh-chat/i18n/en.json @@ -79,5 +79,8 @@ "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 index 74f6c5839..e9d616216 100644 --- a/hermes-ssh-chat/i18n/pt.json +++ b/hermes-ssh-chat/i18n/pt.json @@ -79,5 +79,8 @@ "widget": { "label": "Hermes", "connecting": "SSH" + }, + "shortcut": { + "description": "Abrir/fechar o painel Hermes SSH" } }