Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 65f20b899b fix(terminal): keep native kitty Shift+Enter on broadcast peers
When a target has negotiated xterm kitty keyboard and the custom
protocol setting is off, resolve the broadcast chord as CSI 13;2u
instead of the ESC+CR fallback.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:23:32 +00:00
Cursor Agentand陈大猫 3068bd9ffa fix(terminal): keep the Shift+Enter migration local
The one-time rewrite of a saved "\n" does not need a sync flag or a
three-way merge. An imported or synced old newline can stay as stored.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:23:32 +00:00
Cursor Agentand陈大猫 755eab661d fix(terminal): migrate the saved Shift+Enter newline once
Existing installs kept the pre-#3770 "\\n" default, so Shift+Enter still
submitted in TUI CLIs. Rewrite only that stored value to ESC+CR, and let
xterm's native CSI u own the chord when an app has opted in.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:44:34 +00:00
b27f0ad7d3 feat: add custom CSS hooks for the selected top tab (#3772)
Keep the default top-tab colors, and expose .top-tab[data-active] plus
--top-tab-active-bg/fg/border so Appearance custom CSS can recolor the
selected tab.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:26:39 +08:00
0c36ef7275 feat(terminal): default Shift+Enter to ESC+CR (Alt+Enter) without counting it as command submission (#3770)
* feat(terminal): default Shift+Enter text to the legacy Alt+Enter encoding

* fix(terminal): do not treat ESC+CR Shift+Enter as command submission

getShiftEnterSubmittedInput classified every CR-terminated value as a
submit. The ESC+CR default (legacy Alt+Enter) therefore recorded a
command and fired onCommandSubmitted on each Shift+Enter.

Co-authored-by: GOUKI9999 <11640942+GOUKI9999@users.noreply.github.com>

* fix(terminal): detect Alt+Enter line breaks without a control regex

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(terminal): expect ESC+CR when Shift+Enter broadcast falls back

Non-preserving Kitty targets now receive the new default Alt+Enter
encoding instead of a bare newline.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: GOUKI9999 <gouki9999@Hotmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: GOUKI9999 <11640942+GOUKI9999@users.noreply.github.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:20:19 +08:00
110c79c8dc fix(terminal): reconnect on Enter while the compose bar is open (#3768)
An open compose bar no longer hides the disconnected Enter-reconnect hint
or blocks the terminal and dialog paths. An empty compose draft reconnects
the same way; a non-empty draft still sends.

Fixes #3767

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 10:44:43 +08:00
陈大猫andyangfan e8c31a7800 fix(ai): keep Catty image attachments through routine context compression (#3765)
Typed request compression ran before every Catty request (routine pre-turn and every step) and replaced image/file parts with a text placeholder, so attached images never reached the model.

preserveAttachments now keeps image attachments on the routine pre-turn and step paths. Non-image files stay placeholders (chat providers reject e.g. text/plain; the model reads them via list_attachments/read_attachment). The explicit 413 retry still trades attachments for placeholders.

Verified in the real Electron app: image reaches the provider as image_url (fake OpenAI-compatible server and DeepSeek deepseek-flash, which described the test image correctly); text-file turns still succeed via attachment tools; request prefix stability across turns unchanged vs main (DeepSeek cache hits ~98% on follow-up turns).

Fixes #3764

Co-authored-by: yangfan <1413737839@qq.com>
2026-10-20 10:20:14 +08:00
2527f140c0 fix: dial SFTP separately on network-device SSH sessions (#3759)
* fix: dial SFTP separately on network-device SSH sessions

Huawei and Ruijie switches drop the shell when SFTP opens on the terminal TCP session. Use the existing dedicated login for network-device mode and network-vendor banners without marking those hosts single-channel bastions.

Fixes #3631

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: block follow-cwd probes on dedicated SFTP logins

Network-device mode and vendor SSH banners already dial SFTP separately,
but follow-terminal-cwd still opened an exec channel on the terminal
transport. Gate that probe with the same dedicated-login decision.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: skip terminal pwd exec for network-vendor banners when opening SFTP

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:26:25 +08:00
陈大猫 aaee43417f fix(sftp): stop follow-cwd race that leaves panel stuck after cd (#3761)
Fixes #3588

Regular follow waits for a trusted live cwd instead of racing the post-command null gap; include connectionPath in follow effect deps so aborted navigates can resync.

Codex clean on 239bda3; CI green; smoke PASS (follow on/off, first open, go-to).
2026-10-20 08:25:40 +08:00
陈大猫 a622d953ad fix(terminal): preserve IIP sequence type with external metadata (#3762) 2026-10-20 01:17:10 +08:00
陈大猫 16c9af6e32 fix(window): keep terminal focus when clicking title-bar window controls (#3758)
Fixes #3757

preventDefault on mousedown for Minimize/Maximize so the xterm textarea keeps focus after minimize/restore. Close left unchanged so inline rename can still blur/commit.
2026-10-20 00:03:41 +08:00
陈大猫 64717fffd5 Merge pull request #3756 from binaricat/fix/3746-inline-image-resize-stride
fix: preserve inline image tile stride across window resize
2026-10-19 21:57:34 +08:00
陈大猫 2ba045c8e7 fix: preserve inline image tile stride across resize 2026-10-19 21:51:46 +08:00
陈大猫 8b0b7706a3 Merge pull request #3750 from Moyucharm/fix/port-forwarding-missing-os
fix(port-forwarding): tolerate missing host OS metadata
2026-10-19 17:33:39 +08:00
陈大猫 fa34a5602e Merge pull request #3753 from GOUKI9999/fix/mimo-autodiscovery
fix(ai): raise CLI version-probe timeout so slow-starting CLIs (mimo/opencode) are discovered
2026-10-19 17:09:10 +08:00
GOUKI9999 d14591b36a fix(ai): raise CLI version-probe timeout so slow-starting CLIs are discovered
Bun-compiled OpenCode-family CLIs answer --version slowly: measured
'mimo --version' at 3.9-5.1s warm (6s+ cold) and 'opencode --version'
at 6s cold on Windows. The 5s probe timeout killed these healthy CLIs
mid-probe, so agent discovery dropped them (exitCode null ->
not plausible) and the settings card showed them as unavailable. Raise
the version-probe timeout to 15s; a missing command still fails spawn
immediately, so only existing-but-slow CLIs pay the extra wait.
2026-10-19 16:59:35 +08:00
Moyucharm 2bb7baebde Merge upstream main to pick up README paste test fix 2026-10-19 16:16:12 +08:00
aa597135b1 ci: narrow spam-comment filter and relay comment edits (#3752)
* ci: hide only risky download links in issue comments

Stop deleting comments that merely mention an archive or executable
name. Match http(s) URLs whose path contains a risky extension, skip
the issue author as well as maintainers and bots, and minimize matches
as spam instead of deleting them.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* ci: relay edited issue comments with truncated bodies

Forward issue_comment edited events and include comment identity,
association, timestamps, and bodies clipped to 4000 characters.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 16:05:29 +08:00
陈大猫 42b8e2f347 Merge pull request #3747 from binaricat/codex/fix-ssh-panel-transfer-reliability
fix: isolate SSH auxiliary failures and validate downloads
2026-10-19 15:40:24 +08:00
9c312b7b98 ci: forward issue events to Grok Bot webhook (#3751)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 15:29:49 +08:00
Moyucharm 05e4bf69f7 fix(port-forwarding): tolerate missing host OS metadata
Guard avatar fallback calculations in new/edit panels and wizard host selection. Add real React rendering regression coverage and document baseline verification.
2026-10-19 15:18:54 +08:00
陈奇 eb57b73442 fix: retire stale stats when replacement sampling starts 2026-10-19 06:23:51 +00:00
陈奇 b366967bcc test: decouple README paste coverage from screenshot dimensions 2026-10-19 06:10:55 +00:00
陈奇 8771ec9e3d fix: preserve timeout recovery for dedicated SSH companions 2026-10-19 05:54:38 +00:00
陈奇 55002aefa0 fix: retain stats history when panels read before sampling 2026-10-19 05:52:23 +00:00
陈奇 1a7490497b fix: isolate SSH auxiliary failures and validate downloads 2026-10-19 05:49:59 +00:00
github-actions[bot]andgithub-actions[bot] 786a317b26 chore: update star history charts [skip ci] (#3743)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-19 00:10:12 +00:00
陈大猫 73d15da765 Merge pull request #3742 from binaricat/codex/sync-readme-screenshots
docs: sync translated README screenshots and monitoring view
2026-10-19 00:13:12 +08:00
bincxz 6abb6635b2 docs: sync translated README screenshots and monitoring view 2026-10-19 00:09:38 +08:00
陈大猫 90cc055521 Merge pull request #3741 from binaricat/binaricat-patch-3
Enhance README with monitoring view details
2026-10-18 23:39:58 +08:00
86 changed files with 3301 additions and 460 deletions

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
name: Forward issue events to Grok Bot
on:
issues:
types: [opened, reopened, edited]
issue_comment:
types: [created, edited]
permissions: {}
jobs:
forward:
# PR comments are handled elsewhere; only forward real issues.
if: github.event_name == 'issues' || github.event.issue.pull_request == null
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Forward minimal event to Grok Bot webhook
env:
WEBHOOK_URL: ${{ secrets.GROKBOT_WEBHOOK_URL }}
WEBHOOK_KEY: ${{ secrets.GROKBOT_WEBHOOK_KEY }}
run: |
if [ -z "$WEBHOOK_URL" ] || [ -z "$WEBHOOK_KEY" ]; then
echo "Grok Bot webhook secrets not set; skipping."
exit 0
fi
jq --arg event "$GITHUB_EVENT_NAME" '
def clip:
if type == "string" then .[0:4000] else . end;
{
event: $event,
action: .action,
repository: .repository.full_name,
sender: .sender.login,
issue: (
{
number: .issue.number,
title: .issue.title,
author: .issue.user.login,
url: .issue.html_url
} + (
if $event == "issues" then {
author_association: .issue.author_association,
body: (.issue.body | clip)
} else {} end
)
),
comment: (
if .comment then
{
id: .comment.id,
author: .comment.user.login,
url: .comment.html_url,
node_id: .comment.node_id,
author_association: .comment.author_association,
user_type: .comment.user.type,
created_at: .comment.created_at,
updated_at: .comment.updated_at,
body: (.comment.body | clip)
} + (
if .action == "edited" then {
previous_body: (.changes.body.from | clip)
} else {} end
)
else null end
)
}
' "$GITHUB_EVENT_PATH" \
| curl -fsS --max-time 20 -X POST \
-H "Authorization: Bearer $WEBHOOK_KEY" \
-H "Content-Type: application/json" \
--data @- "$WEBHOOK_URL"
+44 -11
View File
@@ -2,7 +2,7 @@ name: spam-comments
on:
issue_comment:
types: [created]
types: [created, edited]
permissions:
contents: read
@@ -10,13 +10,13 @@ permissions:
pull-requests: write
jobs:
delete-spam:
hide-spam:
runs-on: ubuntu-latest
steps:
- name: Checkout filter rules
uses: actions/checkout@v7
- name: Delete suspected malware spam
- name: Hide suspected malware spam
uses: actions/github-script@v9
with:
script: |
@@ -25,26 +25,59 @@ jobs:
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
);
const comment = context.payload.comment;
const comment = context.payload.comment || {};
const issue = context.payload.issue || {};
const login = (comment.user && comment.user.login) || 'unknown';
const result = detectSpamComment({
body: comment.body || '',
authorAssociation: comment.author_association,
userType: comment.user && comment.user.type,
commentAuthor: comment.user && comment.user.login,
issueAuthor: issue.user && issue.user.login,
});
core.info(JSON.stringify(result, null, 2));
if (!result.spam) {
core.info('Comment did not match the zip/dangerous-file spam filter.');
core.info('Comment did not match the risky-download spam filter.');
return;
}
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
const matchedUrl = result.matchedUrls.join(', ');
if (!comment.node_id) {
core.setFailed(
`Comment ${comment.id} by @${login} on #${issue.number} matched ${matchedUrl} but has no node_id.`
);
return;
}
try {
await github.graphql(
`mutation($input: MinimizeCommentInput!) {
minimizeComment(input: $input) {
minimizedComment {
isMinimized
}
}
}`,
{
input: {
subjectId: comment.node_id,
classifier: 'SPAM',
},
}
);
} catch (error) {
const message = String((error && error.message) || error);
if (/already minimized/i.test(message)) {
core.info(
`Comment ${comment.id} by @${login} on #${issue.number} is already minimized. Matched URL: ${matchedUrl}`
);
return;
}
throw error;
}
core.notice(
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
`Hid comment ${comment.id} by @${login} on #${issue.number} as spam. Matched URL: ${matchedUrl}`
);
+17 -7
View File
@@ -40,7 +40,7 @@
---
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
---
@@ -63,13 +63,13 @@
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
---
@@ -82,6 +82,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
- [スクリーンショット](#スクリーンショット)
- [メインウィンドウ](#メインウィンドウ)
- [Vault ビュー](#vault-ビュー)
- [モニタリングビュー](#モニタリングビュー)
- [分割ターミナル](#分割ターミナル)
- [対応ディストリビューション](#対応ディストリビューション)
- [はじめに](#はじめに)
@@ -148,18 +149,27 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<a name="vault-ビュー"></a>
## Vault ビュー
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<a name="モニタリングビュー"></a>
## モニタリングビュー
CPU、メモリ、ディスク、ネットワーク、レイテンシーを確認でき、対応する環境では GPU の詳細も表示されます。表示する列の選択、列幅や更新設定の調整ができ、監視対象のホストから直接ターミナルを開くこともできます。
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<a name="分割ターミナル"></a>
## 分割ターミナル
+17 -7
View File
@@ -40,7 +40,7 @@
---
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
---
@@ -63,13 +63,13 @@
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
#### 🚀 多主机 —— Docker Swarm 集群搭建
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
---
@@ -82,6 +82,7 @@
- [界面截图](#界面截图)
- [主界面](#主界面)
- [Vault 视图](#vault-视图)
- [监控视图](#监控视图)
- [分屏终端](#分屏终端)
- [支持的发行版](#支持的发行版)
- [快速开始](#快速开始)
@@ -148,18 +149,27 @@
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<a name="vault-视图"></a>
## Vault 视图
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<a name="监控视图"></a>
## 监控视图
查看 CPU、内存、磁盘、网络和延迟数据;支持的环境还可显示 GPU 详情。你可以选择显示的列、调整列宽和刷新设置,并直接从受监控的主机打开终端。
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<a name="分屏终端"></a>
## 分屏终端
+17 -7
View File
@@ -40,7 +40,7 @@
---
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
---
@@ -63,13 +63,13 @@
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
---
@@ -82,6 +82,7 @@
- [介面截圖](#介面截圖)
- [主視窗](#主視窗)
- [Vault 檢視](#vault-檢視)
- [監控檢視](#監控檢視)
- [分割終端](#分割終端)
- [支援的發行版](#支援的發行版)
- [快速開始](#快速開始)
@@ -148,18 +149,27 @@
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
<a name="vault-檢視"></a>
## Vault 檢視
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
<a name="監控檢視"></a>
## 監控檢視
查看 CPU、記憶體、磁碟、網路與延遲資料;支援的環境還可顯示 GPU 詳情。你可以選擇顯示的欄位、調整欄寬與重新整理設定,並直接從受監控的主機開啟終端機。
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
<a name="分割終端"></a>
## 分割終端
+19 -3
View File
@@ -413,7 +413,7 @@ export const enCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
'Add custom CSS to personalize the app appearance. Changes apply immediately. Selected top tabs (`.top-tab[data-active="true"]`) read `--top-tab-active-bg`, `--top-tab-active-fg`, and `--top-tab-active-border`. Leave them unset to keep the current look.',
'settings.appearance.customCss.help.ariaLabel': 'Custom CSS help',
'settings.appearance.customCss.help.title': 'Custom CSS guide',
'settings.appearance.customCss.help.body': `Major UI regions expose a \`data-section\` attribute you can target. Changes apply as soon as you edit the CSS.
@@ -440,6 +440,22 @@ Because many styles come from Tailwind utilities, you often need \`!important\`
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
Selected top tabs use the class \`top-tab\` and \`data-active="true"\`. Set these variables to recolor the selected tab. Leave them unset to keep the current look. They apply without \`!important\`.
- \`--top-tab-active-bg\` — selected fill (defaults to the current tab background)
- \`--top-tab-active-fg\` — selected label color (defaults to the current tab text)
- \`--top-tab-active-border\` — 1px inset outline (defaults to transparent, so there is no border)
The Vaults tab stays flat and does not use the fill.
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### Vault
- \`vault-view\`
@@ -533,7 +549,7 @@ Highlight the focused split pane:
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Selected top tab. Leave these unset to keep the current look. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
@@ -712,7 +728,7 @@ Highlight the focused split pane:
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash. Defaults to \\e\\r (Escape + Enter, the legacy Alt+Enter encoding), which most TUI CLIs treat as a newline.',
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
'settings.terminal.behavior.shiftEnterForceText.desc':
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
+19 -3
View File
@@ -328,7 +328,7 @@ export const esCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Paleta oscura',
'settings.appearance.customCss': 'CSS personalizado',
'settings.appearance.customCss.desc':
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato.',
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato. Las pestañas superiores seleccionadas (`.top-tab[data-active="true"]`) leen `--top-tab-active-bg`, `--top-tab-active-fg` y `--top-tab-active-border`. Si no las defines, se conserva el aspecto actual.',
'settings.appearance.customCss.help.ariaLabel': 'Ayuda de CSS personalizado',
'settings.appearance.customCss.help.title': 'Guía de CSS personalizado',
'settings.appearance.customCss.help.body': `Las principales regiones de la interfaz exponen un atributo \`data-section\` que puedes usar como destino. Los cambios se aplican en cuanto editas el CSS.
@@ -355,6 +355,22 @@ Como muchos estilos provienen de utilidades de Tailwind, a menudo necesitas \`!i
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
Las pestañas superiores seleccionadas usan la clase \`top-tab\` y \`data-active="true"\`. Define estas variables para cambiar el color de la pestaña seleccionada. Si no las defines, se conserva el aspecto actual. Se aplican sin \`!important\`.
- \`--top-tab-active-bg\` — relleno seleccionado (por defecto, el fondo actual de la pestaña)
- \`--top-tab-active-fg\` — color del texto seleccionado (por defecto, el texto actual)
- \`--top-tab-active-border\` — contorno interior de 1px (transparente por defecto, así que no hay borde)
La pestaña Vaults permanece plana y no usa el relleno.
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### Vault
- \`vault-view\`
@@ -448,7 +464,7 @@ Resalta el panel dividido enfocado:
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Pestaña superior seleccionada. Si no defines estas variables, se conserva el aspecto actual. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
'settings.appearance.language': 'Idioma',
'settings.appearance.language.desc': 'Elige el idioma de la interfaz',
'settings.appearance.uiFont': 'Fuente de la interfaz',
@@ -627,7 +643,7 @@ Resalta el panel dividido enfocado:
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida. Por defecto \\e\\r (Escape + Enter, la codificación clásica de Alt+Enter), que la mayoría de las CLI TUI interpretan como nueva línea.',
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
'settings.terminal.behavior.shiftEnterForceText.desc':
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
+19 -3
View File
@@ -397,7 +397,7 @@ export const ruCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу.',
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу. Выбранные верхние вкладки (`.top-tab[data-active="true"]`) читают `--top-tab-active-bg`, `--top-tab-active-fg` и `--top-tab-active-border`. Не задавайте их, чтобы сохранить текущий вид.',
'settings.appearance.customCss.help.ariaLabel': 'Справка по пользовательскому CSS',
'settings.appearance.customCss.help.title': 'Руководство по пользовательскому CSS',
'settings.appearance.customCss.help.body': `Основные области интерфейса помечены атрибутом \`data-section\` для CSS-селекторов. Изменения применяются сразу после правки.
@@ -423,6 +423,22 @@ export const ruCoreMessages: Messages = {
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
У выбранных верхних вкладок есть класс \`top-tab\` и атрибут \`data-active="true"\`. Задайте эти переменные, чтобы перекрасить выбранную вкладку. Не задавайте их, чтобы сохранить текущий вид. \`!important\` не нужен.
- \`--top-tab-active-bg\` — заливка выбранной вкладки (по умолчанию текущий фон вкладки)
- \`--top-tab-active-fg\` — цвет подписи (по умолчанию текущий цвет текста)
- \`--top-tab-active-border\` — внутренняя обводка 1px (по умолчанию прозрачная, то есть без рамки)
Вкладка Vaults остаётся плоской и не использует заливку.
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### Хранилище
- \`vault-view\`
@@ -502,7 +518,7 @@ export const ruCoreMessages: Messages = {
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Выбранная верхняя вкладка. Не задавайте переменные, чтобы сохранить текущий вид. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса',
@@ -663,7 +679,7 @@ export const ruCoreMessages: Messages = {
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты. По умолчанию \\e\\r (Escape + Enter, классическая кодировка Alt+Enter), которую большинство TUI-CLI распознают как перевод строки.',
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
'settings.terminal.behavior.shiftEnterForceText.desc':
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
+18 -2
View File
@@ -417,7 +417,7 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。',
'使用自定义 CSS 个性化界面,修改会立即生效。选中的顶部标签(`.top-tab[data-active="true"]`)会读取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不设置则保持当前外观。',
'settings.appearance.customCss.help.ariaLabel': '自定义 CSS 说明',
'settings.appearance.customCss.help.title': '自定义 CSS 说明',
'settings.appearance.customCss.help.body': `主要 UI 区块都暴露了 \`data-section\` 属性,供你用 CSS 定位。编辑后会立即生效。
@@ -444,6 +444,22 @@ export const zhCNCoreMessages: Messages = {
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
选中的顶部标签带有 \`top-tab\` 类和 \`data-active="true"\`。设置下面的变量即可改变选中标签的颜色;不设置则保持当前外观。这些变量不需要 \`!important\`。
- \`--top-tab-active-bg\` — 选中标签的背景(默认是现在的标签背景)
- \`--top-tab-active-fg\` — 选中标签的文字颜色(默认是现在的文字颜色)
- \`--top-tab-active-border\` — 1px 内描边(默认透明,也就是没有边框)
Vaults 标签保持扁平,不使用这块背景。
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### 保险库
- \`vault-view\`
@@ -537,7 +553,7 @@ export const zhCNCoreMessages: Messages = {
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 选中的顶部标签。不设置这些变量则保持当前外观。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
+1 -1
View File
@@ -352,7 +352,7 @@ export const zhCNTerminalMessages: Messages = {
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。默认 \\e\\r(ESC+回车,传统 Alt+Enter 编码),大多数 TUI CLI 会将其识别为换行。',
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
'settings.terminal.behavior.shiftEnterForceText.desc':
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
+18 -2
View File
@@ -419,7 +419,7 @@ export const zhTWCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主題',
'settings.appearance.customCss': '自訂 CSS',
'settings.appearance.customCss.desc':
'使用自訂 CSS 個人化介面,修改會立即生效。',
'使用自訂 CSS 個人化介面,修改會立即生效。選取的頂部分頁(`.top-tab[data-active="true"]`)會讀取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不設定則維持目前外觀。',
'settings.appearance.customCss.help.ariaLabel': '自訂 CSS 說明',
'settings.appearance.customCss.help.title': '自訂 CSS 說明',
'settings.appearance.customCss.help.body': `主要 UI 區塊都公開了 \`data-section\` 屬性,供你用 CSS 定位。編輯後會立即生效。
@@ -446,6 +446,22 @@ export const zhTWCoreMessages: Messages = {
- \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\`
選取的頂部分頁帶有 \`top-tab\` 類別和 \`data-active="true"\`。設定下列變數即可改變選取分頁的顏色;不設定則維持目前外觀。這些變數不需要 \`!important\`。
- \`--top-tab-active-bg\` — 選取分頁的背景(預設為目前的分頁背景)
- \`--top-tab-active-fg\` — 選取分頁的文字顏色(預設為目前的文字顏色)
- \`--top-tab-active-border\` — 1px 內描邊(預設透明,也就是沒有邊框)
Vaults 分頁維持扁平,不使用這塊背景。
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### 保險庫
- \`vault-view\`
@@ -539,7 +555,7 @@ export const zhTWCoreMessages: Messages = {
\`\`\`
`,
'settings.appearance.customCss.placeholder':
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 選取的頂部分頁。不設定這些變數則維持目前外觀。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
'settings.appearance.language': '語言',
'settings.appearance.language.desc': '選擇介面語言',
'settings.appearance.uiFont': '介面字型',
+1 -1
View File
@@ -352,7 +352,7 @@ export const zhTWTerminalMessages: Messages = {
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。預設 \\e\\r(ESC+Enter,傳統 Alt+Enter 編碼),大多數 TUI CLI 會將其識別為換行。',
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
'settings.terminal.behavior.shiftEnterForceText.desc':
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
@@ -10,12 +10,14 @@ import {
resolveThemeAccentForeground,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
migrateMaterializedShiftEnterNewlineDefault,
} from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
} from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
@@ -214,3 +216,76 @@ test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explic
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault rewrites the old default once", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(migrated.shiftEnterNewlineText, "\\e\\r");
assert.equal(migrated.scrollback, 3000);
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
"\\n",
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault rewrites a raw stored newline", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ shiftEnterNewlineText: "\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
"\\e\\r",
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault keeps custom Shift+Enter text", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ shiftEnterNewlineText: "\\r", scrollback: 1000 }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.deepEqual(JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!), {
shiftEnterNewlineText: "\\r",
scrollback: 1000,
});
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault leaves a fresh install without a settings blob", () => {
const store = installMemoryLocalStorage();
try {
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
+28 -1
View File
@@ -1,8 +1,15 @@
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import {
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
isLegacyDefaultShiftEnterNewlineText,
type HotkeyScheme,
type SessionLogFormat,
type TerminalSettings,
} from '../../domain/models';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
} from '../../infrastructure/config/storageKeys';
import {
isDeprecatedPrimaryFontId,
@@ -91,6 +98,26 @@ export function migrateMaterializedAutoCloseOnExitDefault(): void {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
/**
* One-time migration for the Shift+Enter default flip (#3770). Builds before
* that change materialized `\\n` into saved settings, so upgraders kept a
* bare newline and TUI CLIs still submitted. Rewrite only that old default
* (escaped or raw) to ESC+CR, then persist the marker. A later explicit `\\n`
* is left alone. Custom text is never rewritten. Sync and import do not
* read this marker; an old `\\n` from another device stays as stored.
*/
export function migrateMaterializedShiftEnterNewlineDefault(): void {
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED)) return;
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
if (stored && isLegacyDefaultShiftEnterNewlineText(stored.shiftEnterNewlineText)) {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, {
...stored,
shiftEnterNewlineText: DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
});
}
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED, true);
}
// Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -530,8 +530,12 @@ test("only trusted live terminal cwd recovery resumes follow after initial probe
await act(async () => renderer?.unmount());
});
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
test("regular follow waits for trusted live cwd instead of polling after command clear", async () => {
// Regression for #3588: command submit clears cwd to null and schedules a
// backend-strict probe. Auto-follow must not preferFreshBackend-poll in that
// gap — that race aborted the navigate and left the panel stuck.
const navigatedPaths: string[] = [];
let probeCalls = 0;
const connection = {
id: "conn-1",
hostId: "host-1",
@@ -550,20 +554,15 @@ test("an in-flight follow probe cannot navigate after the focused session change
},
},
};
let activeSessionId = "session-a";
let activeTerminalCwd: string | null = "/home/alice";
let resolveCwd: ((cwd: string) => void) | null = null;
let deferProbe = false;
let activeTerminalCwdTrusted = false;
let renderer: ReactTestRenderer | null = null;
const onGetTerminalCwd = () => {
if (!deferProbe) return Promise.resolve("/root/session-a");
return new Promise<string>((resolve) => { resolveCwd = resolve; });
};
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId,
activeSessionId: "session-1",
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
@@ -575,7 +574,11 @@ test("an in-flight follow probe cannot navigate after the focused session change
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd,
onGetTerminalCwd: async () => {
probeCalls += 1;
// First-open may probe; keep the pane where it already is.
return connection.currentPath;
},
onPendingFollowOverride: () => {},
sftpRef,
});
@@ -586,23 +589,28 @@ test("an in-flight follow probe cannot navigate after the focused session change
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
const probesAfterOpen = probeCalls;
assert.ok(probesAfterOpen >= 1, "first-open sync still probes");
assert.deepEqual(navigatedPaths, []);
deferProbe = true;
// Command-submit style clear: no trusted cwd yet.
activeTerminalCwd = null;
activeTerminalCwdTrusted = false;
await act(async () => {
renderer?.update(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.ok(resolveCwd);
assert.equal(probeCalls, probesAfterOpen, "regular follow must not poll while cwd is cleared");
assert.deepEqual(navigatedPaths, []);
activeSessionId = "session-b";
await act(async () => renderer?.update(React.createElement(Probe)));
// Post-command backend-strict (or OSC 7) publishes a trusted live cwd.
activeTerminalCwd = "/etc";
activeTerminalCwdTrusted = true;
await act(async () => {
resolveCwd?.("/root/from-session-a");
renderer?.update(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
assert.deepEqual(navigatedPaths, ["/etc"]);
assert.equal(probeCalls, probesAfterOpen, "trusted live cwd must navigate without another poll");
await act(async () => renderer?.unmount());
});
@@ -302,6 +302,12 @@ export function useSftpFollowTerminalCwd({
&& previousOriginId !== expectedSessionId,
);
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
// Auto-follow must wait for a trusted live cwd (OSC 7, post-command
// backend-strict probe, or inferred). Polling preferFreshBackend here while
// cwd is null/untrusted races that probe: navigate starts, the probe then
// invalidates the sync generation, abort restores the previous path, and
// without a connectionPath effect dependency the panel stays put (#3588).
// Manual "Go to terminal directory" and first-open sync still probe.
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
if (!terminalCwd && originChanged) {
@@ -311,12 +317,6 @@ export function useSftpFollowTerminalCwd({
homeDir: sftpRef.current.leftPane.connection?.homeDir,
currentPath: sftpRef.current.leftPane.connection?.currentPath,
});
} else if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({
preferFreshBackend: true,
allowRendererFallback: false,
requireActiveShellCwd: true,
});
}
if (!terminalCwd) return;
if (!shouldApplyFollowTerminalCwdSyncResult({
@@ -430,6 +430,7 @@ export function useSftpFollowTerminalCwd({
canFollowTerminalCwd,
connectionId,
connectionIsLocal,
connectionPath,
connectionStatus,
effectiveFollowTerminalCwd,
focusedSessionId,
@@ -56,6 +56,28 @@ test('active reconnect state blocks duplicate requests and notifies subscribers'
unsubscribe();
});
test('enter reconnect stays off until the session allows it and does not queue', () => {
const registry = createTerminalReconnectRegistry();
const requests: string[] = [];
assert.equal(registry.requestEnterReconnect('session-1'), false);
registry.register('session-1', () => requests.push('session-1'));
assert.deepEqual(requests, []);
registry.setEnterReconnectEnabled('session-1', true);
assert.equal(registry.requestEnterReconnect('session-1'), true);
assert.deepEqual(requests, ['session-1']);
registry.setActive('session-1', true);
assert.equal(registry.requestEnterReconnect('session-1'), false);
assert.deepEqual(requests, ['session-1']);
registry.setActive('session-1', false);
registry.setEnterReconnectEnabled('session-1', false);
assert.equal(registry.requestEnterReconnect('session-1'), false);
assert.deepEqual(requests, ['session-1']);
});
test('unregistering the current handler clears active reconnect state', () => {
const registry = createTerminalReconnectRegistry();
const unregister = registry.register('session-1', () => {});
+22 -1
View File
@@ -4,6 +4,7 @@ export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>();
const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>();
const listeners = new Set<() => void>();
const emit = () => {
@@ -51,7 +52,27 @@ export const createTerminalReconnectRegistry = () => {
return true;
};
return { register, request, setActive, isActive, subscribe };
// Enter-reconnect while a compose bar is open. Explicit reconnect clicks use
// request(); this path stays off during auth, host-key, and blocking overlays.
const setEnterReconnectEnabled = (sessionId: string, enabled: boolean): void => {
if (enabled) enterReconnectEnabledIds.add(sessionId);
else enterReconnectEnabledIds.delete(sessionId);
};
const requestEnterReconnect = (sessionId: string): boolean => {
if (!enterReconnectEnabledIds.has(sessionId)) return false;
return request(sessionId);
};
return {
register,
request,
requestEnterReconnect,
setEnterReconnectEnabled,
setActive,
isActive,
subscribe,
};
};
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { create, type ReactTestRenderer } from 'react-test-renderer';
import { useServerStats } from './useServerStats';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
for (const scenario of ['retry', 'pause', 'manual'] as const) {
test(`server stats ${scenario} after three failures`, async t => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 10_000 });
const originalActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
let calls = 0;
t.mock.method(netcattyBridge, 'get', () => ({
getServerStats: async () => {
calls++;
return calls <= 3 ? { success: false, error: 'temporary failure' }
: { success: true, stats: { cpu: 42 } };
},
}));
let current: ReturnType<typeof useServerStats>;
function Probe({ enabled = true }: { enabled?: boolean }) {
current = useServerStats({ sessionId: `recovery-${scenario}`, enabled,
refreshInterval: 5, isSupportedOs: true, isConnected: true });
return null;
}
let root: ReactTestRenderer;
try {
await act(async () => { root = create(React.createElement(Probe)); });
await act(async () => t.mock.timers.tick(2000));
await act(async () => t.mock.timers.tick(3000));
await act(async () => t.mock.timers.tick(5000));
assert.equal(calls, 3);
assert.equal(current!.error, 'temporary failure');
if (scenario === 'pause') {
await act(async () => root!.update(React.createElement(Probe, { enabled: false })));
await act(async () => t.mock.timers.tick(60_000));
assert.equal(calls, 3, 'disabled clients must not retry');
await act(async () => root!.update(React.createElement(Probe)));
await act(async () => t.mock.timers.tick(1));
} else if (scenario === 'manual') {
await act(async () => current!.refresh());
} else {
await act(async () => t.mock.timers.tick(29_999));
assert.equal(calls, 3);
await act(async () => t.mock.timers.tick(1));
await act(async () => t.mock.timers.tick(1));
}
assert.equal(calls, 4);
assert.equal(current!.stats.cpu, 42);
assert.equal(current!.error, null);
await act(async () => t.mock.timers.tick(5000));
assert.equal(calls, 5, 'successful recovery must resume normal polling');
} finally {
await act(async () => root?.unmount());
if (originalActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', originalActEnvironment);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
t.mock.timers.reset();
}
});
}
test('stats reply after disabling cannot publish or schedule recovery', async t => {
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval', 'Date'], now: 10_000 });
const originalActEnvironment = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
let finish: (result: unknown) => void;
let calls = 0;
t.mock.method(netcattyBridge, 'get', () => ({ getServerStats: () => {
calls++;
return new Promise(resolve => { finish = resolve; });
} }));
let current: ReturnType<typeof useServerStats>;
function Probe({ enabled }: { enabled: boolean }) {
current = useServerStats({ sessionId: 'late-disabled', enabled,
refreshInterval: 5, isSupportedOs: true, isConnected: true });
return null;
}
let root: ReactTestRenderer;
try {
await act(async () => { root = create(React.createElement(Probe, { enabled: true })); });
await act(async () => t.mock.timers.tick(2000));
assert.equal(calls, 1);
await act(async () => root!.update(React.createElement(Probe, { enabled: false })));
await act(async () => finish!({ success: true, stats: { cpu: 99 } }));
assert.equal(current!.stats.lastUpdated, null);
assert.equal(current!.isLoading, false);
await act(async () => t.mock.timers.tick(60_000));
assert.equal(calls, 1);
} finally {
await act(async () => root?.unmount());
if (originalActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', originalActEnvironment);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
t.mock.timers.reset();
}
});
+24 -2
View File
@@ -86,6 +86,7 @@ interface SharedServerStatsSession {
clients: Map<number, ServerStatsClient>;
intervalRef: ReturnType<typeof setInterval> | null;
initialTimerRef: ReturnType<typeof setTimeout> | null;
retryTimerRef: ReturnType<typeof setTimeout> | null;
connectedAt: number;
hasFetched: boolean;
fetchGeneration: number;
@@ -154,6 +155,7 @@ function getSharedServerStatsSession(sessionId: string): SharedServerStatsSessio
clients: new Map(),
intervalRef: null,
initialTimerRef: null,
retryTimerRef: null,
connectedAt: 0,
hasFetched: false,
fetchGeneration: 0,
@@ -180,6 +182,10 @@ function updateServerStatsState(session: SharedServerStatsSession, patch: Partia
}
function clearServerStatsTimers(session: SharedServerStatsSession): void {
if (session.retryTimerRef) {
clearTimeout(session.retryTimerRef);
session.retryTimerRef = null;
}
if (session.initialTimerRef) {
clearTimeout(session.initialTimerRef);
session.initialTimerRef = null;
@@ -236,10 +242,17 @@ function markServerStatsFailure(session: SharedServerStatsSession, message: stri
session.consecutiveFailures += 1;
updateServerStatsState(session, { error: message });
if (session.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
// Stop polling this session. This covers network devices that advertise
// OpenSSH but do not support the POSIX stats shell command.
// Back off unsupported or temporarily unavailable probes, then recover
// automatically. Three transient failures must not disable the panel for
// the entire lifetime of an otherwise healthy terminal session.
session.givenUp = true;
clearServerStatsTimers(session);
session.retryTimerRef = setTimeout(() => {
session.retryTimerRef = null;
session.givenUp = false;
session.consecutiveFailures = 0;
reconcileSharedServerStatsSession(session);
}, 30_000);
}
}
@@ -256,6 +269,10 @@ async function fetchSharedServerStats(session: SharedServerStatsSession, force =
if (!bridge?.getServerStats) return;
if (force) {
if (session.retryTimerRef) {
clearTimeout(session.retryTimerRef);
session.retryTimerRef = null;
}
session.givenUp = false;
session.consecutiveFailures = 0;
}
@@ -319,6 +336,11 @@ function reconcileSharedServerStatsSession(session: SharedServerStatsSession): v
// (or re-enabling a terminal stats strip) can paint immediately instead of
// flashing "No system overview data yet." Still clear give-up / failure so a
// later resume can auto-retry (old reset wiped stats + flags together).
// Invalidate a request before pausing: a late reply must not publish data
// or re-arm failure/retry timers after disconnect/disable.
if (session.pollingActive || session.inflight || session.givenUp) {
session.fetchGeneration += 1;
}
clearServerStatsTimers(session);
session.givenUp = false;
session.consecutiveFailures = 0;
+2
View File
@@ -151,6 +151,7 @@ import {
isValidUiThemeId,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
migrateMaterializedShiftEnterNewlineDefault,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
@@ -360,6 +361,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
// it is ingested; afterwards stored values are the user's own and win.
migrateMaterializedAutoCloseOnExitDefault();
migrateMaterializedShiftEnterNewlineDefault();
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
});
+138
View File
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider";
import type { Host, PortForwardingRule, PortForwardingType } from "../domain/models";
import { EditPanel } from "./port-forwarding/EditPanel";
import { NewFormPanel } from "./port-forwarding/NewFormPanel";
import { WizardContent } from "./port-forwarding/WizardContent";
import { TooltipProvider } from "./ui/tooltip";
const baseHost: Omit<Host, "os"> = {
id: "host-1",
label: "Forwarding host",
hostname: "example.com",
username: "user",
tags: [],
};
const baseRule: PortForwardingRule = {
id: "rule-1",
label: "Test forwarding rule",
type: "local",
hostId: baseHost.id,
localPort: 8080,
bindAddress: "127.0.0.1",
remoteHost: "localhost",
remotePort: 80,
status: "inactive",
createdAt: 1,
};
const noop = () => undefined;
const panelRenderers = {
edit: (hosts: Host[], draft: PortForwardingRule) => (
<EditPanel
rule={draft}
draft={draft}
hosts={hosts}
onDraftChange={noop}
onSave={noop}
onClose={noop}
onDuplicate={noop}
onDelete={noop}
onOpenHostSelector={noop}
/>
),
new: (hosts: Host[], draft: PortForwardingRule) => (
<NewFormPanel
draft={draft}
hosts={hosts}
onDraftChange={noop}
onSave={noop}
onClose={noop}
onOpenHostSelector={noop}
onOpenWizard={noop}
isValid={false}
/>
),
"remote-host-selection": (hosts: Host[], draft: PortForwardingRule) => (
<WizardContent
step="remote-host-selection"
type={draft.type}
draft={draft}
hosts={hosts}
onTypeChange={noop}
onDraftChange={noop}
onOpenHostSelector={noop}
/>
),
"host-selection": (hosts: Host[], draft: PortForwardingRule) => (
<WizardContent
step="host-selection"
type={draft.type}
draft={draft}
hosts={hosts}
onTypeChange={noop}
onDraftChange={noop}
onOpenHostSelector={noop}
/>
),
};
const hostCases: { name: string; fields: { os?: Host["os"] | "" | null } }[] = [
{ name: "missing OS", fields: {} },
{ name: "undefined OS", fields: { os: undefined } },
{ name: "null OS", fields: { os: null } },
{ name: "empty OS", fields: { os: "" } },
{ name: "Linux", fields: { os: "linux" } },
{ name: "Windows", fields: { os: "windows" } },
{ name: "macOS", fields: { os: "macos" } },
];
function render(panel: React.ReactNode): string {
return renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>{panel}</TooltipProvider>
</I18nProvider>,
);
}
for (const [panelName, renderPanel] of Object.entries(panelRenderers)) {
for (const type of ["local", "remote", "dynamic"] as PortForwardingType[]) {
for (const { name, fields } of hostCases) {
test(`${panelName} renders ${type} forwarding with ${name}`, () => {
// Persisted legacy records can violate the required Host.os type at runtime.
const host = { ...baseHost, ...fields } as unknown as Host;
const markup = render(renderPanel([host], { ...baseRule, type }));
assert.match(markup, /Forwarding host/);
assert.match(markup, /lucide-server/);
});
}
}
test(`${panelName} preserves the selected host's distro icon`, () => {
const host: Host = { ...baseHost, os: "linux", distro: "ubuntu" };
const markup = render(renderPanel([host], baseRule));
assert.match(markup, /Forwarding host/);
assert.match(markup, /src="\/distro\/ubuntu\.svg"/);
});
test(`${panelName} renders the host picker when no host is selected`, () => {
const markup = render(renderPanel([], { ...baseRule, hostId: undefined }));
assert.match(markup, /Select a host/);
assert.doesNotMatch(markup, /Forwarding host/);
});
test(`${panelName} renders the host picker when the selected host is unavailable`, () => {
const markup = render(renderPanel([], baseRule));
assert.match(markup, /Select a host/);
assert.doesNotMatch(markup, /Forwarding host/);
});
}
+21 -13
View File
@@ -76,8 +76,8 @@ import {
resolveLocateSftpPathInTerminalAction,
resolveLocateSftpPathSessionId,
} from "../domain/sftpLocatePathInTerminal";
import { sftpRequiresDedicatedLogin } from "../domain/sftpDedicatedLogin";
import { classifyDistroId } from "../domain/host";
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
@@ -577,7 +577,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const terminalBackend = useTerminalBackend();
const [sftpBannerDecision, setSftpBannerDecision] = useState<{
sessionId: string;
singleChannel: boolean;
remoteSshVersion?: string;
} | null>(null);
const activeSessionStatusForBanner = activeSessionId
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
@@ -590,7 +590,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
}
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
if (!readRemoteInfo) {
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
setSftpBannerDecision({ sessionId: activeSessionId });
return;
}
let cancelled = false;
@@ -598,11 +598,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (cancelled) return;
setSftpBannerDecision({
sessionId: activeSessionId,
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
remoteSshVersion: info?.remoteSshVersion,
});
}).catch(() => {
if (!cancelled) {
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
setSftpBannerDecision({ sessionId: activeSessionId });
}
});
return () => {
@@ -733,8 +733,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
});
// The peer banner is known only after the terminal handshake. Wait for it
// before choosing a shared terminal channel, so a one-channel bastion
// dials its file login directly.
// before choosing a shared terminal channel, so a one-channel bastion or
// network-vendor switch dials its file login directly.
if (
activeSessionId
&& activeSessionStatus === "connected"
@@ -935,8 +935,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
&& sftpBannerDecision.singleChannel;
// Independent login: network-device mode, a network-vendor banner, or a
// one-channel bastion. Do not open SFTP on the terminal connection.
const dedicatedSftpLogin = sftpRequiresDedicatedLogin({
host: activeHost,
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
? sftpBannerDecision.remoteSshVersion
: undefined,
});
const reuseTerminalTransport = !dedicatedSftpLogin && (
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
);
@@ -1382,10 +1388,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={activeHost}
activeSessionId={activeSessionId}
bannerRequiresSingleChannel={
sftpBannerDecision?.sessionId === activeSessionId
&& sftpBannerDecision.singleChannel
}
bannerRequiresSingleChannel={sftpRequiresDedicatedLogin({
host: activeHost,
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
? sftpBannerDecision.remoteSshVersion
: undefined,
})}
focusedSessionId={focusedSessionId}
showWorkspaceHostHeader={showWorkspaceHostHeader}
renderOverlays={renderOverlays}
+12 -2
View File
@@ -1162,7 +1162,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
allowRendererFallback?: boolean;
requireActiveShellCwd?: boolean;
}): Promise<string | undefined> => {
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
// The backend pwd probe opens an exec channel on the terminal connection;
// network devices (mode, detected distro, or vendor banner such as Huawei
// `-`) can drop the whole session for it (#1043, #3631).
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
if (!skipBackendPwd && sessionRef.current) {
const info = await terminalBackend.getSessionRemoteInfo?.(sessionRef.current).catch(() => null);
skipBackendPwd = !shouldProbeSessionCwd({
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
remoteSshVersion: info?.remoteSshVersion,
});
}
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: terminalCwdTracker.getRendererCwd(),
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
@@ -1175,7 +1185,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
requireActiveShellCwd: options?.requireActiveShellCwd,
});
return cwd ?? undefined;
}, [terminalBackend, terminalCwdTracker]);
}, [host, terminalBackend, terminalCwdTracker]);
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
terminalCwdTracker.clearRendererCwd();
+9 -1
View File
@@ -1594,7 +1594,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}): Promise<string | null> => {
const sessionId = getActiveTerminalSessionId();
const host = sessionId ? sessionHostsMapRef.current.get(sessionId) : undefined;
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
// Same rule as the terminal's own SFTP path lookup (#3631).
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
if (!skipBackendPwd && sessionId && host) {
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId).catch(() => null);
skipBackendPwd = !shouldProbeSessionCwd({
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
remoteSshVersion: info?.remoteSshVersion,
});
}
return resolvePreferredTerminalCwd({
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
rendererCwdSource: sessionId
+20
View File
@@ -294,6 +294,26 @@ test("session close button double click stays on the close button", () => {
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
});
test("main window controls keep mouse presses from taking focus", () => {
const windowControlsSource = topTabItemsSource.slice(
topTabItemsSource.indexOf("export const WindowControls"),
topTabItemsSource.indexOf("WindowControls.displayName"),
);
assert.match(
windowControlsSource,
/const preventWindowControlFocus: React\.MouseEventHandler<HTMLButtonElement> = \(event\) => \{\s*event\.preventDefault\(\);\s*\};/,
);
assert.equal(windowControlsSource.match(/onMouseDown=\{preventWindowControlFocus\}/g)?.length, 2);
const closeButton = windowControlsSource.match(
/<button type="button" className=\{closeControlClassName\}[^>]*>/,
)?.[0] ?? "";
assert.equal(closeButton.includes("onMouseDown"), false);
assert.match(windowControlsSource, /onClick=\{handleMinimize\}/);
assert.match(windowControlsSource, /onClick=\{handleMaximize\}/);
assert.match(windowControlsSource, /onClick=\{handleClose\}/);
});
test("session top tab label only shows the host label for ssh sessions", () => {
assert.equal(
formatSessionTopTabLabel({
+1 -1
View File
@@ -134,7 +134,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
<div className="flex items-center gap-2">
<DistroAvatar
host={selectedHost}
fallback={selectedHost.os[0].toUpperCase()}
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
size="tree"
/>
<span>{selectedHost.label}</span>
+1 -1
View File
@@ -134,7 +134,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
<div className="flex items-center gap-2">
<DistroAvatar
host={selectedHost}
fallback={selectedHost.os[0].toUpperCase()}
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
size="tree"
/>
<span>{selectedHost.label}</span>
+10 -2
View File
@@ -123,7 +123,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<DistroAvatar
host={selectedHost}
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
size="sm"
/>
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>
@@ -228,7 +232,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<DistroAvatar
host={selectedHost}
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
size="sm"
/>
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>
@@ -224,9 +224,9 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")}
>
<Input
value={terminalSettings.shiftEnterNewlineText ?? "\\n"}
value={terminalSettings.shiftEnterNewlineText ?? "\\e\\r"}
onChange={(e) => updateTerminalSetting("shiftEnterNewlineText", e.target.value)}
placeholder="\\n"
placeholder="\\e\\r"
className="w-56 font-mono"
spellCheck={false}
/>
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { remoteSoftwareRequiresSingleChannel } from "../../domain/singleChannelSshBanner.shared.mjs";
import { sftpRequiresDedicatedLogin } from "../../domain/sftpDedicatedLogin";
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
const slotSource = readFileSync(
@@ -49,12 +51,99 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
assert.match(
sidePanelSource,
/const dedicatedSftpLogin = sftpBannerDecision\?\.sessionId === activeSessionId\s*&& sftpBannerDecision\.singleChannel;/,
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\);/,
);
assert.doesNotMatch(sidePanelSource, /activeHost\.singleChannelSsh/);
assert.doesNotMatch(sidePanelSource, /sftpBannerDecision\.singleChannel/);
assert.match(sidePanelSource, /reuseTransport: false/);
assert.match(
sidePanelSource,
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
);
assert.match(
sidePanelSource,
/sourceSessionId: reuseTerminalTransport/,
);
});
test("network-device mode and network-vendor banners force dedicated SFTP", () => {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "network" }, remoteSshVersion: "OpenSSH_9.6" }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "-" }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0--" }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0-RGOS_SSH" }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "OpenSSH_9.6" }),
false,
);
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
});
test("vendor banners block follow-terminal-cwd the same way bastions do", () => {
assert.match(
sidePanelSource,
/bannerRequiresSingleChannel=\{sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\)\}/,
);
assert.doesNotMatch(sidePanelSource, /remoteSoftwareRequiresSingleChannel/);
for (const remoteSshVersion of ["-", "SSH-2.0--", "BHostSSH_7.0"]) {
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "general" },
remoteSshVersion,
}),
true,
remoteSshVersion,
);
}
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "network" },
remoteSshVersion: "OpenSSH_9.6",
}),
true,
);
});
test("opening SFTP skips the terminal pwd exec for network-vendor banners", () => {
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
const assertBannerPwdGuard = (source: string, fnName: string) => {
const start = source.indexOf(`const ${fnName} = useCallback`);
assert.ok(start >= 0, fnName);
const body = source.slice(start, start + 1600);
assert.match(body, /let skipBackendPwd = hostRestrictsExtraSshChannels\(host\);/);
assert.match(body, /getSessionRemoteInfo\?\./);
assert.match(body, /skipBackendPwd = !shouldProbeSessionCwd\(\{/);
assert.match(body, /classifyDistroId\(host\.distro\) === 'network-device'/);
assert.match(body, /remoteSshVersion: info\?\.remoteSshVersion/);
assert.match(body, /getSessionPwd: skipBackendPwd/);
};
assertBannerPwdGuard(terminalSource, "resolveSftpInitialPath");
assertBannerPwdGuard(layerSource, "getTerminalCwd");
assert.match(
terminalSource,
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from "\.\.\/domain\/host";/,
);
assert.match(
layerSource,
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from '\.\.\/domain\/host';/,
);
});
@@ -17,8 +17,13 @@ test("SFTP side panel rebinds after same-tab SSH start-over", () => {
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
);
// Reuse the terminal transport only when SSH is connected, or when a strict
// pending source id is already pinned. A dedicated single-channel login never
// reuses that transport. A linked id may arrive while reconnecting.
// pending source id is already pinned. A dedicated login (bastion banner,
// network-vendor banner, or network-device mode) never reuses that
// transport. A linked id may arrive while reconnecting.
assert.match(
sidePanelSource,
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{/,
);
assert.match(
sidePanelSource,
/const reuseTerminalTransport = !dedicatedSftpLogin && \(\s*Boolean\(pendingStrictSourceSessionId\) \|\| activeSessionStatus === "connected"\s*\);/,
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { TerminalComposeBar } from './TerminalComposeBar';
import { TooltipProvider } from '../ui/tooltip';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
@@ -102,3 +103,95 @@ test('compose bar preserves a draft on pane focus and records only successful as
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
test('empty compose draft Enter reconnects; a typed draft still sends', async (t) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
for (const name of ['window', 'document', 'localStorage']) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
}
const sessionId = 'compose-enter-reconnect';
const reconnects: string[] = [];
const unregister = terminalReconnectRegistry.register(sessionId, () => {
reconnects.push(sessionId);
});
t.after(() => {
unregister();
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
dom.window.close();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const createTextarea = () => ({
value: '', selectionStart: 0, selectionEnd: 0,
focus() {},
setSelectionRange() {},
});
let textarea = createTextarea();
const sent: string[] = [];
let root!: ReactTestRenderer;
await act(async () => {
root = create(
<TooltipProvider>
<TerminalComposeBar sessionId={sessionId} onSend={(text) => { sent.push(text); return true; }} onClose={() => {}} />
</TooltipProvider>,
{ createNodeMock: (el) => {
if (el.type !== 'textarea') return null;
textarea = createTextarea();
return textarea;
} },
);
});
const key = (name: string, extra: Record<string, unknown> = {}) => {
let prevented = false;
root.root.findByType('textarea').props.onKeyDown({
key: name,
shiftKey: false,
altKey: false,
ctrlKey: false,
metaKey: false,
nativeEvent: {},
currentTarget: textarea,
preventDefault() { prevented = true; },
...extra,
});
return prevented;
};
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, true);
assert.equal(key('Enter'), true);
assert.deepEqual(reconnects, [sessionId]);
assert.deepEqual(sent, []);
assert.equal(textarea.value, '');
textarea.value = 'echo hi';
assert.equal(key('Enter'), true);
assert.deepEqual(reconnects, [sessionId]);
assert.deepEqual(sent, ['echo hi']);
assert.equal(textarea.value, '');
textarea.value = 'keep me';
assert.equal(key('Enter', { shiftKey: true }), false);
assert.equal(textarea.value, 'keep me');
assert.deepEqual(sent, ['echo hi']);
assert.deepEqual(reconnects, [sessionId]);
textarea.value = '';
assert.equal(key('Enter', { ctrlKey: true }), true);
assert.deepEqual(sent, ['echo hi']);
assert.deepEqual(reconnects, [sessionId]);
assert.equal(key('Enter', { nativeEvent: { isComposing: true, keyCode: 229 } }), false);
assert.deepEqual(reconnects, [sessionId]);
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
assert.equal(key('Enter'), true);
assert.deepEqual(reconnects, [sessionId]);
assert.deepEqual(sent, ['echo hi']);
await act(async () => { root.unmount(); });
});
+12 -1
View File
@@ -5,6 +5,7 @@
*/
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../../infrastructure/config/storageKeys';
@@ -496,12 +497,22 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
}
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
// Plain Enter on an empty draft reconnects a disconnected session.
// Modified Enter and a non-empty draft keep the existing send path (#3767).
const plainEnter = !e.altKey && !e.ctrlKey && !e.metaKey;
if (
plainEnter
&& !e.currentTarget.value
&& terminalReconnectRegistry.requestEnterReconnect(sessionId)
) {
return;
}
handleSend();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}, [handleSend, navigate, onClose]);
}, [handleSend, navigate, onClose, sessionId]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
+4 -4
View File
@@ -220,7 +220,6 @@ test("disconnected terminal reconnects on plain Enter when input is not claimed
key: "Enter",
status: "disconnected",
hasRetryHandler: true,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
@@ -234,7 +233,6 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
key: "Enter",
status: "disconnected" as const,
hasRetryHandler: true,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
@@ -243,14 +241,16 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
// Open search must not globally suppress Enter reconnect / the hint (#2546).
// Open search and an open compose bar must not globally suppress Enter
// reconnect / the hint (#2546 / #3767).
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base }), true);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, shiftKey: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposing: true }), false);
});
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
+15 -11
View File
@@ -7,6 +7,7 @@ import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuCon
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { classifyDistroId } from '../../domain/host';
import type { HostInfoBarTitleMode } from '../../domain/models';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { OSC7_SETUP_TARGETS } from './osc7Setup';
@@ -184,7 +185,6 @@ export function shouldReconnectTerminalOnEnterKey({
key,
status,
hasRetryHandler,
isComposeBarOpen,
needsAuth,
needsHostKeyVerification,
hasBlockingOverlay,
@@ -198,7 +198,6 @@ export function shouldReconnectTerminalOnEnterKey({
key: string;
status?: string;
hasRetryHandler: boolean;
isComposeBarOpen: boolean;
needsAuth: boolean;
needsHostKeyVerification: boolean;
hasBlockingOverlay: boolean;
@@ -209,15 +208,15 @@ export function shouldReconnectTerminalOnEnterKey({
shiftKey?: boolean;
isComposing?: boolean;
}): boolean {
// Search-bar open state is intentionally not a global gate. While disconnected,
// find-next is less useful than reconnect; the capture handler still refuses
// Enter only when a real interactive control outside xterm owns the event
// (compose/auth/buttons). The terminal search input is allow-listed so an open
// search bar cannot hide the hint or swallow reconnect (#2544 / #2546).
// Search and compose bars are not global gates. While disconnected, plain
// Enter still reconnects from the terminal and the disconnected dialog.
// Capture still skips real controls outside xterm (compose textarea, auth,
// buttons). Search input is allow-listed so an open search bar cannot hide
// the hint or swallow reconnect (#2544 / #2546). An empty compose draft
// reconnects from TerminalComposeBar; a non-empty draft keeps send (#3767).
return key === "Enter"
&& status === "disconnected"
&& hasRetryHandler
&& !isComposeBarOpen
&& !needsAuth
&& !needsHostKeyVerification
&& !hasBlockingOverlay
@@ -529,18 +528,24 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
key: "Enter",
status,
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
isReconnectActive,
});
// Publish Enter-reconnect so an empty compose draft can reconnect this
// session, including the workspace compose bar (#3767).
useEffect(() => {
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, showEnterReconnectHint);
return () => {
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
};
}, [sessionId, showEnterReconnectHint]);
const handleTerminalKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!shouldReconnectTerminalOnEnterKey({
key: event.key,
status,
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
@@ -563,7 +568,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
auth.needsAuth,
handleRetry,
hasBlockingReconnectOverlay,
isComposeBarOpen,
isReconnectActive,
needsHostKeyVerification,
status,
@@ -80,6 +80,10 @@ export function handleTerminalAutocompleteKeyEvent(
now = Date.now,
} = context;
if (!settingsRef.current.enabled || e.type !== "keydown") return true;
// Enter/arrows belong to the IME candidate window during composition.
// Consuming them here can accept/execute a stale terminal suggestion and
// prevent the input method from committing the user's characters.
if (e.isComposing || e.keyCode === 229) return true;
const metaFollowUp = autocompleteEscMetaFollowUpSequence(e);
if (metaFollowUp && now() < escMetaPrefixUntilRef.current) {
@@ -762,6 +762,7 @@ export function useTerminalAutocomplete(
candidate: newCommand,
os: hostOsRef.current,
});
if (seq === null) return;
if (seq) writeToTerminal(seq);
typedInputBufferRef.current = newCommand;
typedBufferReliableRef.current = true;
@@ -389,6 +389,23 @@ test("disconnected connection dialog keeps an Enter-reconnect focus sink", () =>
assert.match(source, /restoreTerminalFocusFromDisconnectedDialog\(\{[\s\S]*isFocusedPane/);
});
test("open compose bar does not globally gate enter reconnect", () => {
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
const bodyEnd = viewSource.indexOf("export function shouldBlockTerminalReconnectForTarget", gateIndex);
const gateBody = viewSource.slice(gateIndex, bodyEnd);
assert.equal(gateBody.includes("!isComposeBarOpen"), false);
assert.match(viewSource, /setEnterReconnectEnabled\(sessionId, showEnterReconnectHint\)/);
assert.match(
readFileSync(new URL("./TerminalComposeBar.tsx", import.meta.url), "utf8"),
/requestEnterReconnect\(sessionId\)/,
);
assert.match(
readFileSync(new URL("./terminalHelpers.ts", import.meta.url), "utf8"),
/data-section='terminal-compose-bar'/,
);
});
test("open terminal search does not globally gate enter reconnect", () => {
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
@@ -402,7 +419,7 @@ test("open terminal search does not globally gate enter reconnect", () => {
);
});
test("terminal view receives the effective compose bar state for enter reconnect gating", () => {
test("terminal view receives the effective compose bar state", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const effectiveDefinitionIndex = source.indexOf("const effectiveComposeBarOpen =");
const viewIndex = source.indexOf("<TerminalView ctx={{");
@@ -413,7 +430,7 @@ test("terminal view receives the effective compose bar state for enter reconnect
assert.notEqual(passEffectiveIndex, -1);
assert.ok(
effectiveDefinitionIndex < passEffectiveIndex,
"TerminalView must use the visible workspace compose state before deciding whether Enter can reconnect",
"TerminalView must receive the visible workspace compose state",
);
});
@@ -114,6 +114,8 @@ import { handleSerialLineModeInput } from "./serialLineInput";
import { isTerminalReportSequence } from "./terminalReportSequence";
import {
getShiftEnterSubmittedInput,
nativeKittyKeyboardPreservesShiftEnter,
readNativeKittyKeyboardFlags,
resolveShiftEnterText,
shouldClaimShiftEnterForText,
shouldSendShiftEnterText,
@@ -596,8 +598,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// ConPTY asks the frontend for Win32 INPUT_RECORD encoding with DECSET
// 9001. Preserve browser key modifiers for native Windows applications
// instead of collapsing them into legacy VT bytes.
// kittyKeyboard lets an app opt into xterm's CSI u. Netcatty's own
// handlers, when that setting is on, register later and consume those
// sequences first, so the two parsers do not both apply the protocol.
vtExtensions: {
win32InputMode: windowsPty?.backend === "conpty",
kittyKeyboard: true,
},
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
@@ -2528,6 +2534,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
shouldClaimShiftEnterForText(e, ctx.terminalSettingsRef.current, {
win32InputMode: term.modes.win32InputMode,
kittySequenceForKeyDown,
nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled
&& nativeKittyKeyboardPreservesShiftEnter(readNativeKittyKeyboardFlags(term)),
})
) {
const id = ctx.sessionRef.current;
@@ -3123,6 +3131,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
legacySuppressedKeys: broadcastLegacySuppressedKeys,
win32InputMode: term.modes.win32InputMode,
shiftEnterSettings: ctx.terminalSettingsRef.current,
nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled
&& nativeKittyKeyboardPreservesShiftEnter(readNativeKittyKeyboardFlags(term)),
win32ShiftEnterTextKeys: broadcastWin32ShiftEnterTextKeys,
}),
getSessionId: () => ctx.sessionRef.current,
@@ -541,7 +541,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
applicationCursorMode: false,
encodedKeys: new Set<string>(),
}), {
data: "\n",
data: "\u001b\r",
kittyEncoded: false,
urgentInterrupt: false,
});
@@ -552,7 +552,7 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
applicationCursorMode: false,
encodedKeys: new Set<string>(),
}), {
data: "\n",
data: "\u001b\r",
kittyEncoded: false,
urgentInterrupt: false,
});
@@ -571,6 +571,61 @@ test("Shift+Enter broadcast uses CSI-u only after the target negotiated a preser
});
});
test("native kitty broadcast targets keep Shift+Enter as CSI-u when custom kitty is off", () => {
const shiftEnter = {
kind: "key" as const,
fallbackToLegacy: true,
event: {
type: "keydown" as const,
key: "Enter",
code: "Enter",
shiftKey: true,
},
};
const encodedKeys = new Set<string>();
const legacySuppressedKeys = new Set<string>();
const base = {
kittyProtocolEnabled: false,
kittyMode: createKittyKeyboardModeState(),
applicationCursorMode: false,
shiftEnterSettings: {
shiftEnterNewlineEnabled: true,
shiftEnterNewlineText: "\\e\\r",
},
};
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, {
...base,
encodedKeys,
legacySuppressedKeys,
nativeKittyPreservesShiftEnter: true,
}), {
data: SHIFT_ENTER_CSI_U_SEQUENCE,
kittyEncoded: true,
urgentInterrupt: false,
});
assert.equal(resolveKittyKeyboardBroadcastInput({
kind: "legacy",
data: SHIFT_ENTER_CSI_U_SEQUENCE,
keyIdentity: "Enter",
}, {
...base,
encodedKeys,
legacySuppressedKeys,
nativeKittyPreservesShiftEnter: true,
}), null);
assert.deepEqual(resolveKittyKeyboardBroadcastInput(shiftEnter, {
...base,
encodedKeys: new Set<string>(),
nativeKittyPreservesShiftEnter: false,
}), {
data: "\u001b\r",
kittyEncoded: false,
urgentInterrupt: false,
});
});
test("Win32 broadcast targets resolve Shift+Enter from their own force-text setting", () => {
const shiftEnterRecord = "\u001b[13;28;13;1;16;1_";
const press = { type: "keydown" as const, key: "Enter", code: "Enter", shiftKey: true };
@@ -11,6 +11,7 @@ import {
isBareShiftEnterLineEnding,
resolveShiftEnterText,
resolveWin32ForcedShiftEnterText,
SHIFT_ENTER_CSI_U_SEQUENCE,
shouldSendShiftEnterText,
} from "./shiftEnterText";
@@ -253,6 +254,11 @@ type ResolveKittyKeyboardBroadcastOptions = {
TerminalSettings,
"shiftEnterNewlineEnabled" | "shiftEnterNewlineText" | "shiftEnterForceText"
>;
/**
* Target xterm negotiated native kitty flags that encode Shift+Enter as
* CSI 13;2u, while Netcatty's custom kitty setting is off.
*/
nativeKittyPreservesShiftEnter?: boolean;
/**
* Presses this Win32 target turned into its configured Shift+Enter text
* instead of a native record. Their paired releases are dropped so ConPTY
@@ -418,6 +424,18 @@ export const resolveKittyKeyboardBroadcastInput = (
if (remapped) return remapped;
return { data: encoded, kittyEncoded: true, urgentInterrupt: false };
}
// Custom kitty is off. A target whose xterm already negotiated a preserving
// native mode must get CSI 13;2u, and the source's follow-up legacy packet
// (the same sequence) must not be written again.
if (options.nativeKittyPreservesShiftEnter && shouldSendShiftEnterText(input.event)) {
options.encodedKeys.add(identity);
legacySuppressedKeys.add(identity);
return {
data: SHIFT_ENTER_CSI_U_SEQUENCE,
kittyEncoded: true,
urgentInterrupt: false,
};
}
if (!input.fallbackToLegacy || input.event.type === "keyup") return null;
const legacy = encodeLegacyKeyboardEvent(input.event, options.applicationCursorMode);
if (!legacy) return null;
@@ -0,0 +1,68 @@
import test from "node:test";
import assert from "node:assert/strict";
import xterm from "@xterm/xterm";
import {
nativeKittyKeyboardPreservesShiftEnter,
readNativeKittyKeyboardFlags,
SHIFT_ENTER_CSI_U_SEQUENCE,
} from "./shiftEnterText.ts";
const { Terminal } = xterm;
type KittyProbe = {
flags: number;
shiftEnter: string | undefined;
};
const probeShiftEnter = (setup: string): Promise<KittyProbe> => {
const term = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
vtExtensions: { kittyKeyboard: true },
});
return new Promise((resolve) => {
term.write(setup, () => {
const event = {
type: "keydown",
key: "Enter",
code: "Enter",
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
repeat: false,
keyCode: 13,
};
const core = (term as unknown as {
_core: {
_keyboardService: {
evaluateKeyDown: (ev: object) => { key?: string };
};
};
})._core;
resolve({
flags: readNativeKittyKeyboardFlags(term),
shiftEnter: core._keyboardService.evaluateKeyDown(event).key,
});
});
});
};
test("xterm kitty keyboard encodes opted-in Shift+Enter as CSI 13;2u", async () => {
const idle = await probeShiftEnter("");
assert.equal(idle.flags, 0);
assert.equal(idle.shiftEnter, "\r");
assert.equal(nativeKittyKeyboardPreservesShiftEnter(idle.flags), false);
const disambiguate = await probeShiftEnter("\u001b[=1u");
assert.equal(disambiguate.flags, 1);
assert.equal(disambiguate.shiftEnter, SHIFT_ENTER_CSI_U_SEQUENCE);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(disambiguate.flags), true);
const alternateOnly = await probeShiftEnter("\u001b[=4u");
assert.equal(alternateOnly.flags, 4);
assert.equal(alternateOnly.shiftEnter, "\r");
assert.equal(nativeKittyKeyboardPreservesShiftEnter(alternateOnly.flags), false);
});
@@ -6,6 +6,7 @@ import {
decodeTerminalTextEscapes,
doesKittyEncodingPreserveShiftEnter,
getShiftEnterSubmittedInput,
nativeKittyKeyboardPreservesShiftEnter,
isBareShiftEnterLineEnding,
isShiftEnterLineContinuationText,
resolveShiftEnterText,
@@ -26,8 +27,8 @@ const keyEvent = (overrides: Partial<KeyboardEvent> = {}) => ({
...overrides,
}) as KeyboardEvent;
test("shift enter text defaults to newline", () => {
assert.equal(resolveShiftEnterText(), "\n");
test("shift enter text defaults to the legacy Alt+Enter encoding (ESC + CR)", () => {
assert.equal(resolveShiftEnterText(), "\u001b\r");
});
test("shift enter text decodes newline, tab, carriage return, and backslash escapes", () => {
@@ -71,6 +72,20 @@ test("shift enter submitted input detects single command text with a line ending
assert.equal(getShiftEnterSubmittedInput("foo\nbar\n"), null);
});
test("Alt+Enter ESC+CR is a non-submitting logical line break", () => {
assert.equal(getShiftEnterSubmittedInput("\u001b\r"), null);
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r"), null);
assert.equal(getShiftEnterSubmittedInput("echo hi\u001b\r\n"), null);
assert.deepEqual(getShiftEnterSubmittedInput("echo hi\r"), {
text: "echo hi",
lineEnding: "\r",
});
assert.deepEqual(getShiftEnterSubmittedInput("\u001b\n"), {
text: "\u001b",
lineEnding: "\n",
});
});
test("shift enter handler only matches plain Shift+Enter keydown", () => {
assert.equal(shouldSendShiftEnterText(keyEvent()), true);
assert.equal(shouldSendShiftEnterText(keyEvent({ type: "keyup" })), false);
@@ -147,6 +162,41 @@ test("force-text opt-out only overrides the Win32 hand-off", () => {
assert.equal(shouldClaimShiftEnterForText(keyEvent({ ctrlKey: true }), forced, win32), false);
});
test("native kitty Shift+Enter steps aside only for CSI-u flag sets", () => {
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0), false);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0001), true);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0010), true);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b1000), true);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b0100), false);
assert.equal(nativeKittyKeyboardPreservesShiftEnter(0b10000), false);
const settings = { shiftEnterNewlineEnabled: true, shiftEnterForceText: false };
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), settings, {
win32InputMode: false,
kittySequenceForKeyDown: null,
nativeKittyPreservesShiftEnter: true,
}),
false,
);
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), settings, {
win32InputMode: false,
kittySequenceForKeyDown: null,
nativeKittyPreservesShiftEnter: false,
}),
true,
);
assert.equal(
shouldClaimShiftEnterForText(keyEvent(), { ...settings, shiftEnterForceText: true }, {
win32InputMode: true,
kittySequenceForKeyDown: null,
nativeKittyPreservesShiftEnter: true,
}),
true,
);
});
test("Win32 forced Shift+Enter text requires the opt-out and non-empty text", () => {
const settings = {
shiftEnterNewlineEnabled: true,
@@ -179,7 +229,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
// skipping the ConPTY Win32 hand-off only when the force-text opt-out is on.
assert.match(
source,
/if \(\s*shouldClaimShiftEnterForText\(e, ctx\.terminalSettingsRef\.current, \{\s*win32InputMode: term\.modes\.win32InputMode,\s*kittySequenceForKeyDown,\s*\}\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
/if \(\s*shouldClaimShiftEnterForText\(e, ctx\.terminalSettingsRef\.current, \{\s*win32InputMode: term\.modes\.win32InputMode,\s*kittySequenceForKeyDown,\s*nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled\s*&& nativeKittyKeyboardPreservesShiftEnter\(readNativeKittyKeyboardFlags\(term\)\),\s*\}\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
);
// The gate above only decides whether Shift+Enter is *eligible* for send-text.
// It must then claim the keydown only when resolveShiftEnterText() returned
@@ -207,7 +257,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*\},/s,
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*kittyKeyboard: true,\s*\},/s,
);
assert.match(
source,
@@ -223,7 +273,7 @@ test("runtime routes Shift+Enter text through the shared input handler", () => {
);
assert.match(
source,
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?\}\),/s,
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?nativeKittyPreservesShiftEnter: !kittyKeyboardProtocolEnabled\s*&& nativeKittyKeyboardPreservesShiftEnter\(readNativeKittyKeyboardFlags\(term\)\),[\s\S]*?\}\),/s,
);
assert.doesNotMatch(source, /resolveShiftEnterText\([\s\S]*?alternateScreen:/s);
assert.match(source, /getShiftEnterSubmittedInput\(logicalData\)/);
+36 -6
View File
@@ -1,6 +1,6 @@
import type { TerminalSettings } from "../../../domain/models";
export const DEFAULT_SHIFT_ENTER_TEXT = "\\n";
export const DEFAULT_SHIFT_ENTER_TEXT = "\\e\\r";
/** Kitty CSI-u encoding for Shift+Enter (keycode 13, modifier shift → 2). */
export const SHIFT_ENTER_CSI_U_SEQUENCE = "\u001b[13;2u";
@@ -100,6 +100,24 @@ export function doesKittyEncodingPreserveShiftEnter(
&& !isBareShiftEnterLineEnding(encoded);
}
/**
* xterm 6.1 flag bits that encode Shift+Enter as CSI 13;2u.
* Disambiguate (1), report event types (2), and report all keys (8).
* Alternate-key (4) or associated-text (16) alone still emit a bare CR.
*/
const NATIVE_KITTY_SHIFT_ENTER_CSI_U_FLAGS = 0b0001 | 0b0010 | 0b1000;
export function nativeKittyKeyboardPreservesShiftEnter(flags: number): boolean {
return (flags & NATIVE_KITTY_SHIFT_ENTER_CSI_U_FLAGS) !== 0;
}
export function readNativeKittyKeyboardFlags(term: {
_core?: { coreService?: { kittyKeyboard?: { flags?: unknown } } };
}): number {
const flags = term._core?.coreService?.kittyKeyboard?.flags;
return typeof flags === "number" && Number.isFinite(flags) ? flags : 0;
}
export function isShiftEnterLineContinuationText(text: string): boolean {
return /\\(?:\r\n|\r|\n)$/.test(text);
}
@@ -109,10 +127,15 @@ export type ShiftEnterSubmittedInput = {
lineEnding: "\r\n" | "\r" | "\n";
};
/** ESC immediately before a terminating CR is the legacy Alt+Enter line break. */
function isAltEnterLineBreak(text: string): boolean {
return text.endsWith("\u001b\r") || text.endsWith("\u001b\r\n");
}
export function getShiftEnterSubmittedInput(
text: string,
): ShiftEnterSubmittedInput | null {
if (isShiftEnterLineContinuationText(text)) return null;
if (isShiftEnterLineContinuationText(text) || isAltEnterLineBreak(text)) return null;
const match = text.match(/^([^\r\n]*)(\r\n|\r|\n)$/);
if (!match) return null;
return {
@@ -145,6 +168,8 @@ export function resolveWin32ForcedShiftEnterText(
* opted out with shiftEnterForceText; elsewhere the text is used only when the
* negotiated Kitty encoding would collapse the chord to a bare CR/LF, so the
* Win32 opt-out can never override a Kitty encoding that preserves it.
* xterm's own CSI-u mode is the same kind of preserving encoding: step aside
* so the app receives CSI 13;2u.
*/
export function shouldClaimShiftEnterForText(
event: ShiftEnterEvent,
@@ -152,10 +177,15 @@ export function shouldClaimShiftEnterForText(
TerminalSettings,
"shiftEnterNewlineEnabled" | "shiftEnterForceText"
> | undefined,
mode: { win32InputMode: boolean; kittySequenceForKeyDown: string | null },
mode: {
win32InputMode: boolean;
kittySequenceForKeyDown: string | null;
nativeKittyPreservesShiftEnter?: boolean;
},
): boolean {
if (!shouldSendShiftEnterText(event, settings)) return false;
return mode.win32InputMode
? settings?.shiftEnterForceText === true
: !doesKittyEncodingPreserveShiftEnter(mode.kittySequenceForKeyDown);
if (mode.win32InputMode) return settings?.shiftEnterForceText === true;
if (doesKittyEncodingPreserveShiftEnter(mode.kittySequenceForKeyDown)) return false;
if (mode.nativeKittyPreservesShiftEnter) return false;
return true;
}
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { reconcileAutocompletePopupState } from "./autocomplete/useTerminalAutocomplete.ts";
import { DEFAULT_AUTOCOMPLETE_SETTINGS, reconcileAutocompletePopupState } from "./autocomplete/useTerminalAutocomplete.ts";
import type { AutocompleteState } from "./autocomplete/useTerminalAutocomplete.ts";
import { handleTerminalAutocompleteKeyEvent } from "./autocomplete/terminalAutocompleteKeyEvent.ts";
@@ -58,6 +58,7 @@ function createContext(overrides: Record<string, unknown> = {}) {
context: {
settingsRef: {
current: {
...DEFAULT_AUTOCOMPLETE_SETTINGS,
enabled: true,
showGhostText: false,
showPopupMenu: true,
@@ -379,3 +380,19 @@ for (const edited of [false, true]) {
assert.equal(runs, edited ? 0 : 1);
});
}
for (const key of ['Enter', 'ArrowUp', 'ArrowDown', 'ArrowRight', 'Escape', 'Tab']) {
for (const sentinel of ['isComposing', 'keyCode'] as const) {
test(`IME ${sentinel} ${key} passes through autocomplete without accepting or rewriting input`, () => {
const { context, writes, previews, accepted, clears } = createContext();
context.stateRef.current.selectedIndex = 0;
const original = context.stateRef.current;
const event = keyEvent(key);
Object.defineProperty(event, sentinel, { value: sentinel === 'isComposing' ? true : 229 });
assert.equal(handleTerminalAutocompleteKeyEvent(event, context), true);
assert.equal(event.defaultPrevented, false);
assert.equal(context.stateRef.current, original);
assert.deepEqual([...writes, ...previews, ...accepted, ...clears], []);
});
}
}
+5
View File
@@ -448,6 +448,11 @@ export function shouldClaimDisconnectedDialogFocus({
// Sink already focused.
return active !== dialogNode;
}
// An open compose draft owns Enter: empty reconnects from the bar, and a
// non-empty draft must keep send behavior (#3767). Do not park focus here.
if (active.closest("[data-section='terminal-compose-bar']")) {
return false;
}
const terminalRoot = resolveDisconnectedDialogTerminalRoot(dialogNode, sessionRoot);
if (terminalRoot?.contains(active)) {
return true;
+19
View File
@@ -98,6 +98,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
id: string;
parent?: FakeNode | null;
interactive?: boolean;
composeBar?: boolean;
children?: FakeNode[];
},
) {
@@ -118,6 +119,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
return false;
}
closest(selector: string) {
if (selector.includes("terminal-compose-bar") && this.opts.composeBar) return this;
if (selector.includes("button") && this.opts.interactive) return this;
return null;
}
@@ -227,6 +229,23 @@ test("disconnected dialog focus claim never steals from other panes", () => {
}),
false,
);
// Compose-bar focus stays put so an empty draft can reconnect and a
// non-empty draft is not stolen by the disconnected dialog (#3767).
const composeInput = new FakeNode({ id: "compose", composeBar: true });
const composeDialog = new FakeNode({ id: "compose-dialog" });
const composeSession = new FakeNode({
id: "compose-session",
children: [composeInput, composeDialog],
});
assert.equal(
shouldClaimDisconnectedDialogFocus({
activeElement: composeInput as unknown as Element,
dialogNode: composeDialog as unknown as HTMLElement,
sessionRoot: composeSession as unknown as Element,
documentBody: body as unknown as Element,
}),
false,
);
assert.equal(
restoreTerminalFocusFromDisconnectedDialog({
+43 -76
View File
@@ -30,6 +30,13 @@ import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/t
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
import {
TOP_TAB_ITEM_CLASS,
clearInactiveTopTabHover,
paintInactiveTopTabHover,
resolveTopTabChromeStyle,
topTabActiveValue,
} from './topTabActiveStyle';
import { renderHostIconGlyph } from '../hostIconRenderer';
import type { PluginViewTab } from '../../application/state/pluginViewTabStore';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
@@ -310,13 +317,19 @@ export const WindowControls: React.FC = memo(() => {
const controlClassName = 'window-control-btn app-no-drag';
const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag';
// Mouse press would move DOM focus onto minimize/maximize. After restore
// that leaves the terminal textarea unfocused, so keystrokes do nothing.
// Close skips this so the active field still blurs (inline rename commits on blur).
const preventWindowControlFocus: React.MouseEventHandler<HTMLButtonElement> = (event) => {
event.preventDefault();
};
return (
<div className="ml-2 flex h-7 items-center overflow-visible app-no-drag">
<button type="button" className={controlClassName} onClick={handleMinimize}>
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMinimize}>
<Minus size={16} />
</button>
<button type="button" className={controlClassName} onClick={handleMaximize}>
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMaximize}>
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
</button>
<button type="button" className={closeControlClassName} onClick={handleClose}>
@@ -440,31 +453,20 @@ export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon,
data-tab-id={tabId}
data-tab-type="root"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 overflow-hidden text-xs font-semibold cursor-pointer flex items-center app-no-drag transition-[padding,gap] duration-300 ease-out",
compact ? "px-2 gap-0" : "px-3 gap-2",
className,
)}
style={{
backgroundColor: isActive && !suppressActiveBg
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
style={resolveTopTabChromeStyle(isActive, { suppressActiveBg })}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
}}
>
{shortcutNumber != null ? <TabShortcutNumberBadge number={shortcutNumber} /> : icon}
@@ -523,6 +525,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
data-tab-id={tab.id}
data-tab-type="plugin-view"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={() => activeTabStore.setActiveTabId(tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(event) => handleTabMiddleClickClose(event, close)}
@@ -533,6 +536,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(event) => onTabDrop(event, tab.id)}
className={cn(
TOP_TAB_ITEM_CLASS,
'netcatty-tab relative h-7 min-w-[140px] max-w-[240px] flex-shrink-0 cursor-pointer items-center justify-between gap-2 overflow-hidden rounded-t-md pl-3 pr-2 text-xs font-semibold app-no-drag',
'flex transition-transform duration-150',
isBeingDragged && isDraggingForReorder && 'scale-95 opacity-40',
@@ -540,8 +544,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
)}
style={{
...shiftStyle,
backgroundColor: isActive ? 'var(--top-tabs-active-bg, hsl(var(--background)))' : 'transparent',
color: isActive ? 'var(--top-tabs-fg, hsl(var(--foreground)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
...resolveTopTabChromeStyle(isActive),
}}
>
{showDropIndicatorBefore && isDraggingForReorder && <div className="absolute -left-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
@@ -643,6 +646,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
data-tab-id={tabId}
data-tab-type="editor"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
@@ -653,6 +657,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, tabId)}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
@@ -660,24 +665,13 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
)}
style={{
...shiftStyle,
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
...resolveTopTabChromeStyle(isActive),
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
}}
>
{showDropIndicatorBefore && isDraggingForReorder && (
@@ -827,6 +821,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
data-tab-id={session.id}
data-tab-type="session"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleTabMiddleMouseDown}
@@ -838,6 +833,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, session.id)}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
@@ -845,24 +841,13 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
)}
style={{
...shiftStyle,
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
...resolveTopTabChromeStyle(isActive),
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
}}
>
{showDropIndicatorBefore && isDraggingForReorder && (
@@ -1039,6 +1024,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
data-tab-id={workspace.id}
data-tab-type="workspace"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
@@ -1051,6 +1037,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, workspace.id)}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
@@ -1059,24 +1046,13 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
)}
style={{
...shiftStyle,
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
...resolveTopTabChromeStyle(isActive),
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
}}
>
{showDropIndicatorBefore && isDraggingForReorder && (
@@ -1197,6 +1173,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
data-tab-id={logView.id}
data-tab-type="logView"
data-state={isActive ? 'active' : 'inactive'}
data-active={topTabActiveValue(isActive)}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
@@ -1207,6 +1184,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
onDragLeave={onTabDragLeave}
onDrop={(e) => onTabDrop(e, logView.id)}
className={cn(
TOP_TAB_ITEM_CLASS,
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
@@ -1214,24 +1192,13 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
)}
style={{
...shiftStyle,
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
...resolveTopTabChromeStyle(isActive),
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
if (!isActive) paintInactiveTopTabHover(e.currentTarget);
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
if (!isActive) clearInactiveTopTabHover(e.currentTarget);
}}
>
{showDropIndicatorBefore && isDraggingForReorder && (
+73
View File
@@ -0,0 +1,73 @@
/**
* Stable hooks for the selected top tab.
*
* Defaults fall through to the existing chrome colors, so leaving the custom
* properties unset keeps today's look. Custom CSS can set them on
* `.top-tab[data-active="true"]` (or any ancestor) without !important.
* `--top-tab-active-border` is read by index.css as a 1px inset outline and
* defaults to transparent.
*/
export const TOP_TAB_ITEM_CLASS = 'top-tab';
/** Previous selected fill, kept as the fallback when the hook is unset. */
export const TOP_TAB_LEGACY_ACTIVE_BG = 'var(--top-tabs-active-bg, hsl(var(--background)))';
/** Previous selected label color, kept as the fallback when the hook is unset. */
export const TOP_TAB_LEGACY_ACTIVE_FG = 'var(--top-tabs-fg, hsl(var(--foreground)))';
export const TOP_TAB_ACTIVE_BG = `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`;
export const TOP_TAB_ACTIVE_FG = `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`;
export const TOP_TAB_ACTIVE_BORDER = 'var(--top-tab-active-border, transparent)';
export const TOP_TAB_INACTIVE_FG = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
/** Same 40% mix as the previous hover fill. The inner color is the active-tab hook. */
export const TOP_TAB_HOVER_BG = `color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`;
export const TOP_TAB_ACTIVE_OUTLINE = `1px solid ${TOP_TAB_ACTIVE_BORDER}`;
export function topTabActiveValue(isActive: boolean): 'true' | 'false' {
return isActive ? 'true' : 'false';
}
export interface TopTabChromeStyle {
backgroundColor: string;
color: string;
}
export function resolveTopTabChromeStyle(
isActive: boolean,
options?: { suppressActiveBg?: boolean },
): TopTabChromeStyle {
if (!isActive) {
return {
backgroundColor: 'transparent',
color: TOP_TAB_INACTIVE_FG,
};
}
return {
backgroundColor: options?.suppressActiveBg ? 'transparent' : TOP_TAB_ACTIVE_BG,
color: TOP_TAB_ACTIVE_FG,
};
}
type TopTabStyleTarget = {
style: {
backgroundColor: string;
color: string;
};
};
export function paintInactiveTopTabHover(element: TopTabStyleTarget): void {
element.style.backgroundColor = TOP_TAB_HOVER_BG;
element.style.color = TOP_TAB_ACTIVE_FG;
}
export function clearInactiveTopTabHover(element: TopTabStyleTarget): void {
element.style.backgroundColor = 'transparent';
element.style.color = TOP_TAB_INACTIVE_FG;
}
+172
View File
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { JSDOM } from 'jsdom';
import en from '../application/i18n/locales/en';
import es from '../application/i18n/locales/es';
import ru from '../application/i18n/locales/ru';
import zhCN from '../application/i18n/locales/zh-CN';
import zhTW from '../application/i18n/locales/zh-TW';
import {
TOP_TAB_ACTIVE_BG,
TOP_TAB_ACTIVE_BORDER,
TOP_TAB_ACTIVE_FG,
TOP_TAB_ACTIVE_OUTLINE,
TOP_TAB_HOVER_BG,
TOP_TAB_INACTIVE_FG,
TOP_TAB_ITEM_CLASS,
TOP_TAB_LEGACY_ACTIVE_BG,
TOP_TAB_LEGACY_ACTIVE_FG,
clearInactiveTopTabHover,
paintInactiveTopTabHover,
resolveTopTabChromeStyle,
topTabActiveValue,
} from './top-tabs/topTabActiveStyle';
const topTabItemsSource = readFileSync(
fileURLToPath(new URL('./top-tabs/TopTabItems.tsx', import.meta.url)),
'utf8',
);
const indexCss = readFileSync(fileURLToPath(new URL('../index.css', import.meta.url)), 'utf8');
const TAB_TYPES = ['root', 'plugin-view', 'editor', 'session', 'workspace', 'logView'] as const;
const LOCALES = [
{ name: 'en', messages: en },
{ name: 'zh-CN', messages: zhCN },
{ name: 'zh-TW', messages: zhTW },
{ name: 'es', messages: es },
{ name: 'ru', messages: ru },
];
const HOOKS = ['--top-tab-active-bg', '--top-tab-active-fg', '--top-tab-active-border'] as const;
test('active top tab colors fall back to the current chrome colors', () => {
assert.equal(TOP_TAB_ACTIVE_BG, `var(--top-tab-active-bg, ${TOP_TAB_LEGACY_ACTIVE_BG})`);
assert.equal(TOP_TAB_ACTIVE_FG, `var(--top-tab-active-fg, ${TOP_TAB_LEGACY_ACTIVE_FG})`);
assert.equal(TOP_TAB_ACTIVE_BORDER, 'var(--top-tab-active-border, transparent)');
assert.equal(TOP_TAB_LEGACY_ACTIVE_BG, 'var(--top-tabs-active-bg, hsl(var(--background)))');
assert.equal(TOP_TAB_LEGACY_ACTIVE_FG, 'var(--top-tabs-fg, hsl(var(--foreground)))');
assert.equal(
TOP_TAB_HOVER_BG,
`color-mix(in srgb, ${TOP_TAB_ACTIVE_BG} 40%, transparent)`,
);
const active = resolveTopTabChromeStyle(true);
assert.deepEqual(active, {
backgroundColor: TOP_TAB_ACTIVE_BG,
color: TOP_TAB_ACTIVE_FG,
});
const inactive = resolveTopTabChromeStyle(false);
assert.deepEqual(inactive, {
backgroundColor: 'transparent',
color: TOP_TAB_INACTIVE_FG,
});
assert.equal(TOP_TAB_INACTIVE_FG, 'var(--top-tabs-muted, hsl(var(--muted-foreground)))');
const vault = resolveTopTabChromeStyle(true, { suppressActiveBg: true });
assert.equal(vault.backgroundColor, 'transparent');
assert.equal(vault.color, TOP_TAB_ACTIVE_FG);
});
test('top tab active attribute is a stable true/false hook', () => {
assert.equal(topTabActiveValue(true), 'true');
assert.equal(topTabActiveValue(false), 'false');
assert.equal(TOP_TAB_ITEM_CLASS, 'top-tab');
});
test('inactive hover uses the active-tab hook and clears back to the flat tab', () => {
const element = { style: { backgroundColor: '', color: '' } };
paintInactiveTopTabHover(element);
assert.equal(element.style.backgroundColor, TOP_TAB_HOVER_BG);
assert.equal(element.style.color, TOP_TAB_ACTIVE_FG);
clearInactiveTopTabHover(element);
assert.equal(element.style.backgroundColor, 'transparent');
assert.equal(element.style.color, TOP_TAB_INACTIVE_FG);
});
test('every top tab kind exposes the active style hook', () => {
for (const type of TAB_TYPES) {
const marker = `data-tab-type="${type}"`;
const start = topTabItemsSource.indexOf(marker);
assert.notEqual(start, -1, marker);
const next = topTabItemsSource.indexOf('data-tab-type=', start + marker.length);
const slice = topTabItemsSource.slice(start, next === -1 ? undefined : next);
assert.match(slice, /data-active=\{topTabActiveValue\(isActive\)\}/);
assert.match(slice, /TOP_TAB_ITEM_CLASS/);
assert.match(slice, /resolveTopTabChromeStyle\(isActive/);
}
assert.equal(topTabItemsSource.split('data-active={topTabActiveValue(isActive)}').length - 1, TAB_TYPES.length);
assert.doesNotMatch(topTabItemsSource, /backgroundColor: isActive/);
});
test('selected top tab border hook defaults to a transparent inset outline', () => {
const start = indexCss.indexOf('[data-section="top-tabs"] .top-tab[data-active="true"]');
assert.notEqual(start, -1);
const rule = indexCss.slice(start, indexCss.indexOf('}', start));
assert.match(rule, /outline:\s*1px solid var\(--top-tab-active-border, transparent\)/);
assert.match(rule, /outline-offset:\s*-1px/);
assert.equal(TOP_TAB_ACTIVE_OUTLINE, '1px solid var(--top-tab-active-border, transparent)');
});
test('custom CSS sets the top tab hooks on the selected tab', () => {
const dom = new JSDOM(`<!doctype html><html><head></head><body>
<div data-section="top-tabs">
<div id="active" class="top-tab" data-active="true"></div>
<div id="idle" class="top-tab" data-active="false"></div>
</div>
</body></html>`);
const { document, getComputedStyle } = dom.window;
const active = document.getElementById('active')!;
const idle = document.getElementById('idle')!;
const activeStyle = resolveTopTabChromeStyle(true);
active.style.backgroundColor = activeStyle.backgroundColor;
active.style.color = activeStyle.color;
Object.assign(idle.style, resolveTopTabChromeStyle(false));
assert.equal(active.getAttribute('data-active'), 'true');
assert.equal(active.style.backgroundColor, TOP_TAB_ACTIVE_BG);
assert.equal(active.style.color, TOP_TAB_ACTIVE_FG);
assert.equal(idle.style.backgroundColor, 'transparent');
assert.equal(getComputedStyle(active).getPropertyValue('--top-tab-active-bg').trim(), '');
const custom = document.createElement('style');
custom.id = 'netcatty-custom-css';
custom.textContent = `
.top-tab[data-active="true"] {
--top-tab-active-bg: rgb(37, 99, 235);
--top-tab-active-fg: rgb(255, 255, 255);
--top-tab-active-border: rgb(29, 78, 216);
}
`;
document.head.appendChild(custom);
const styled = getComputedStyle(active);
const normalized = (value: string) => value.replace(/\s+/g, '');
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-bg')), 'rgb(37,99,235)');
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-fg')), 'rgb(255,255,255)');
assert.equal(normalized(styled.getPropertyValue('--top-tab-active-border')), 'rgb(29,78,216)');
assert.equal(getComputedStyle(idle).getPropertyValue('--top-tab-active-bg').trim(), '');
assert.match(active.style.backgroundColor, /var\(--top-tab-active-bg,/);
});
test('custom CSS help mentions the top tab hooks in every locale', () => {
for (const locale of LOCALES) {
const desc = locale.messages['settings.appearance.customCss.desc'];
const help = locale.messages['settings.appearance.customCss.help.body'];
const placeholder = locale.messages['settings.appearance.customCss.placeholder'];
assert.equal(typeof desc, 'string', locale.name);
assert.equal(typeof help, 'string', locale.name);
assert.equal(typeof placeholder, 'string', locale.name);
for (const hook of HOOKS) {
assert.ok(desc.includes(hook), `${locale.name} description missing ${hook}`);
assert.ok(help.includes(hook), `${locale.name} help missing ${hook}`);
assert.ok(placeholder.includes(hook), `${locale.name} placeholder missing ${hook}`);
}
assert.match(help, /\.top-tab\[data-active="true"\]/);
assert.match(placeholder, /\.top-tab\[data-active="true"\]/);
}
});
+12 -12
View File
@@ -31,20 +31,20 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="226.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="301.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="373.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="447.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="520.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="594.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="668.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="741.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="223.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="296.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="366.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="439.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="509.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="582.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="654.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="725.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
<circle cx="765.0" cy="106.9" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6508</text>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+12 -12
View File
@@ -31,20 +31,20 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="226.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="301.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="373.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="447.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="520.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="594.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="668.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="741.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="223.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="296.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="366.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="439.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="509.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="582.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="654.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="725.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.4,461.7 C 93.2,461.6 94.0,461.4 94.8,461.2 C 95.6,461.1 96.4,460.8 97.2,460.8 C 98.0,460.7 98.8,460.7 99.6,460.7 C 100.4,460.7 101.2,460.6 102.0,460.6 C 102.8,460.5 103.6,460.5 104.4,460.4 C 105.2,460.4 105.6,460.3 106.8,460.3 C 108.0,460.2 110.4,460.1 111.6,460.1 C 112.8,460.1 113.2,460.0 114.0,460.0 C 114.8,460.0 115.6,460.0 116.4,459.9 C 117.2,459.9 118.0,459.9 118.8,459.9 C 119.6,459.9 120.4,459.9 121.2,459.8 C 122.0,459.8 122.8,459.7 123.6,459.7 C 124.4,459.6 125.2,459.6 126.0,459.4 C 126.8,459.3 127.6,459.0 128.4,458.7 C 129.2,458.3 130.0,457.6 130.8,457.3 C 131.6,457.1 132.4,457.2 133.2,457.1 C 134.0,457.1 134.8,457.1 135.6,457.0 C 136.4,457.0 136.8,457.0 138.0,456.9 C 139.2,456.9 141.2,456.9 142.8,456.9 C 144.4,456.8 146.5,456.8 147.7,456.8 C 148.9,456.7 149.3,456.7 150.1,456.7 C 150.9,456.6 151.7,456.6 152.5,456.5 C 153.3,456.5 154.1,456.5 154.9,456.4 C 155.7,456.4 156.5,456.4 157.3,456.4 C 158.1,456.4 158.5,456.4 159.7,456.3 C 160.9,456.3 162.5,456.2 164.5,456.2 C 166.5,456.2 169.7,456.2 171.7,456.2 C 173.7,456.1 174.1,456.2 176.5,456.1 C 178.9,456.1 184.1,455.9 186.1,455.9 C 188.1,455.8 187.3,455.9 188.5,455.8 C 189.7,455.8 191.3,455.8 193.3,455.8 C 195.3,455.8 196.5,455.7 200.5,455.7 C 204.5,455.6 214.1,455.5 217.3,455.5 C 220.5,455.5 218.9,455.5 219.7,455.4 C 220.5,455.4 220.9,456.1 222.1,455.4 C 223.3,454.7 225.7,454.1 226.9,451.2 C 228.1,448.4 228.5,440.7 229.3,438.3 C 230.1,435.9 230.9,437.2 231.7,436.8 C 232.5,436.5 233.3,436.2 234.1,436.0 C 234.9,435.7 235.7,435.5 236.5,435.4 C 237.3,435.2 238.1,435.2 238.9,435.1 C 239.7,435.0 240.5,434.8 241.3,434.8 C 242.1,434.7 242.9,434.8 243.7,434.7 C 244.5,434.6 245.3,434.4 246.1,434.3 C 246.9,434.2 247.7,434.3 248.5,434.2 C 249.3,434.0 250.1,433.7 250.9,433.6 C 251.7,433.4 252.5,433.5 253.3,433.4 C 254.1,433.3 254.5,433.1 255.7,433.0 C 256.9,432.9 259.4,432.9 260.6,432.9 C 261.8,432.8 262.2,432.7 263.0,432.7 C 263.8,432.6 264.6,432.6 265.4,432.5 C 266.2,432.4 267.0,432.2 267.8,432.0 C 268.6,431.9 269.4,432.6 270.2,431.6 C 271.0,430.7 271.8,427.5 272.6,426.5 C 273.4,425.5 274.2,425.6 275.0,425.4 C 275.8,425.2 276.6,425.5 277.4,425.4 C 278.2,425.2 279.0,424.9 279.8,424.6 C 280.6,424.3 281.4,424.1 282.2,423.6 C 283.0,423.1 283.8,422.2 284.6,421.6 C 285.4,421.0 286.2,420.4 287.0,419.9 C 287.8,419.5 288.6,419.2 289.4,418.9 C 290.2,418.6 291.0,418.5 291.8,418.4 C 292.6,418.3 293.4,418.3 294.2,418.2 C 295.0,418.1 295.8,417.8 296.6,417.8 C 297.4,417.7 298.2,417.7 299.0,417.6 C 299.8,417.6 300.6,417.5 301.4,417.4 C 302.2,417.3 303.0,417.3 303.8,416.9 C 304.6,416.5 305.4,415.5 306.2,415.0 C 307.0,414.5 307.8,414.3 308.6,413.9 C 309.4,413.5 310.2,414.3 311.0,412.7 C 311.8,411.1 312.6,406.4 313.4,404.5 C 314.2,402.7 315.0,402.4 315.8,401.6 C 316.6,400.8 317.4,400.4 318.2,399.8 C 319.0,399.3 319.8,398.7 320.6,398.3 C 321.4,397.9 322.2,397.6 323.0,397.3 C 323.8,397.0 324.6,397.0 325.4,396.7 C 326.2,396.5 327.0,396.3 327.8,396.0 C 328.6,395.7 329.4,395.2 330.2,394.9 C 331.0,394.7 331.8,394.7 332.6,394.4 C 333.4,394.2 334.2,393.7 335.0,393.4 C 335.8,393.1 336.6,392.8 337.4,392.6 C 338.2,392.3 339.0,392.1 339.8,392.0 C 340.6,391.8 341.4,391.8 342.2,391.7 C 343.0,391.5 343.8,391.3 344.6,391.2 C 345.4,391.0 346.2,390.8 347.0,390.6 C 347.8,390.4 348.6,390.2 349.4,389.9 C 350.2,389.6 351.0,389.0 351.8,388.7 C 352.6,388.4 353.4,388.3 354.2,388.2 C 355.0,388.0 355.8,387.9 356.6,387.8 C 357.4,387.6 358.2,387.5 359.0,387.4 C 359.8,387.3 360.6,387.2 361.4,387.1 C 362.2,387.0 363.0,387.1 363.8,386.9 C 364.6,386.8 365.4,386.6 366.2,386.3 C 367.0,386.1 367.8,385.6 368.6,385.3 C 369.4,384.9 370.2,384.5 371.0,384.3 C 371.9,384.1 372.7,384.1 373.5,383.9 C 374.3,383.8 375.1,383.7 375.9,383.5 C 376.7,383.3 377.5,382.7 378.3,382.5 C 379.1,382.3 379.9,382.4 380.7,382.2 C 381.5,382.1 382.3,381.9 383.1,381.7 C 383.9,381.5 384.7,381.3 385.5,381.1 C 386.3,381.0 387.1,380.7 387.9,380.5 C 388.7,380.4 389.5,380.3 390.3,380.1 C 391.1,379.8 391.9,379.5 392.7,379.2 C 393.5,379.0 394.3,378.9 395.1,378.6 C 395.9,378.4 396.7,378.1 397.5,377.8 C 398.3,377.5 399.1,377.2 399.9,377.0 C 400.7,376.7 401.5,376.7 402.3,376.3 C 403.1,375.9 403.9,375.0 404.7,374.6 C 405.5,374.2 406.3,374.2 407.1,374.0 C 407.9,373.9 408.7,373.9 409.5,373.8 C 410.3,373.7 411.1,373.5 411.9,373.4 C 412.7,373.3 413.5,373.5 414.3,373.2 C 415.1,372.8 415.9,371.7 416.7,371.3 C 417.5,370.9 418.3,371.0 419.1,370.7 C 419.9,370.4 420.7,370.0 421.5,369.8 C 422.3,369.6 423.1,369.7 423.9,369.5 C 424.7,369.3 425.5,368.8 426.3,368.6 C 427.1,368.4 427.9,368.4 428.7,368.3 C 429.5,368.2 430.3,368.2 431.1,368.0 C 431.9,367.7 432.7,367.5 433.5,367.0 C 434.3,366.5 435.1,365.4 435.9,364.9 C 436.7,364.4 437.5,364.1 438.3,363.7 C 439.1,363.4 439.9,363.3 440.7,362.5 C 441.5,361.8 442.3,360.1 443.1,359.3 C 443.9,358.5 444.7,358.2 445.5,357.7 C 446.3,357.2 447.1,357.1 447.9,356.4 C 448.7,355.7 449.5,354.1 450.3,353.4 C 451.1,352.6 451.9,352.6 452.7,352.1 C 453.5,351.7 454.3,351.4 455.1,350.9 C 455.9,3Line truncated
<circle cx="765.0" cy="106.9" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6508</text>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,106 @@
# Agent Note: Keep port forwarding usable when host OS metadata is missing
Status: implemented
## Problem
[Issue #3748](https://github.com/binaricat/Netcatty/issues/3748) reports that the
port-forwarding page displays `Port forwarding could not load.` when rendering
a selected host whose legacy `os` value is absent, null, or an empty string.
`EditPanel`, `NewFormPanel`, and both host-selection steps in `WizardContent`
evaluate `selectedHost.os[0].toUpperCase()` while constructing `DistroAvatar`
props. Missing or null values fail on indexing; an empty string fails on
`toUpperCase`. This happens before the avatar can render its generic icon.
Although `Host.os` is required by TypeScript, persisted runtime records can lack
that metadata. The original source of the affected records is not established.
## Decision
The four call sites use `(selectedHost.os || 'unknown').charAt(0).toUpperCase()`
to keep the required string fallback safe without changing the shared avatar
API. Valid OS values retain their existing initials. Missing and empty values
use the explicit unknown fallback, and the avatar keeps its existing generic
icon behavior.
The fix stays in the affected presentation code. It does not change host data,
normalization, persistence, or tunnel lifecycle behavior. A shared helper for a
single short expression would add indirection without a separate responsibility,
so the four call sites use the same expression directly.
## Validation
`components/PortForwardingPanels.test.tsx` renders the real components with
`react-dom/server`, the existing i18n provider, and the tooltip provider. It does
not mock the panels or avatar and does not inspect their source text.
The table-driven cases cover all four entry points, all three forwarding types,
and missing, undefined, null, empty, Linux, Windows, and macOS values. Separate
checks preserve distro icons and host-picker behavior when no host is selected
or the selected host is no longer available. Invalid legacy fixture shapes use
one explicit type assertion rather than weakening the production `Host` type.
Before the fix, 48 of the 96 new cases fail with the reported TypeError; the
other 48 pass. After the fix, all 96 pass. Together with the existing avatar,
port-forwarding toolbar, and utility tests, the focused run passes 109 tests:
```sh
node --test --import tsx components/PortForwardingPanels.test.tsx \
components/PortForwardingNew.test.ts components/portForwardingUtils.test.ts \
components/DistroAvatar.test.tsx --test-concurrency=2
```
These are rendering regression tests, not SSH or network end-to-end tests.
Additional checks against upstream commit `786a317`:
- `npm run build` succeeds.
- `npm run lint` succeeds with the same eight existing warnings as upstream.
The four changed TypeScript/TSX files also pass lint with `--max-warnings=0`.
- `LC_ALL=C npm test -- --test-concurrency=1` runs 13,986 tests: 13,944 pass,
four fail, and 38 are skipped. An unmodified upstream worktree runs 13,890
tests with the same four failures and 38 skips, so this change adds 96 passing
cases without adding a failing case. The baseline failures are the SFTP
large-directory persistence timing test, two Bash OSC 7 setup tests, and the
README clipboard-paste screenshot-dimension assertion. The SFTP timing test
passes in isolated runs on both checkouts but fails in these full-suite runs.
A non-English locale additionally causes the existing fish error-message
assertion to fail; both comparison runs use the C locale.
- The full-project `tsc --noEmit --pretty false` check has 759 existing errors.
Its complete output matches the unmodified upstream output after normalizing
checkout paths; none of the changed files has a diagnostic. This is not a
claim that full-project type checking passes.
### Upstream CI follow-up
The original PR CI run [39215269619, attempt 2](https://github.com/binaricat/Netcatty/actions/runs/39215269619/attempts/2)
passes all 96 new regression cases and fails only the existing README paste
assertion. Upstream commit `b366967` fixes that assertion by removing its
coupling to changing screenshot dimensions. The branch incorporates this fix
through a normal merge of upstream `main` at `42b8e2f`, without rewriting the
original fix commit or adding an independent change to the unrelated test.
After synchronization, the focused run including the entire clipboard-paste
test file passes 149 tests. Lint and the production build also pass; lint keeps
the same eight existing warnings. The earlier full-suite and type-check
comparison above describes the original `786a317` baseline, not the updated
base. Current remote CI results are tracked on the PR.
## Alternatives considered
- Remove the unused fallback prop from the four calls: the shared avatar still
requires it in its TypeScript contract. Changing that API and cleaning up all
callers is unnecessary for this targeted bug fix.
- Normalize or rewrite stored host records: broadens the persistence scope,
obscures the rendering assumption, and cannot replace guarding optional
runtime metadata at the point of use.
- Add a new avatar wrapper or fallback utility: unnecessary for this small
expression and would expand the production change without improving the
boundary being fixed.
## Consequences
Incomplete host OS metadata no longer prevents new/edit panels or wizard
host-selection steps from rendering. Existing icons, component contracts, and
host records remain unchanged. The guard is local to port forwarding; auditing
unrelated callers or redesigning the unused fallback API remains out of scope.
+12 -1
View File
@@ -503,6 +503,17 @@ export const normalizeTerminalSettings = (
/** Default scrollback rows for new installs (VS Code uses 1000; we keep a modest headroom). */
export const DEFAULT_TERMINAL_SCROLLBACK = 3000;
/** Backslash-escaped Shift+Enter text for a fresh install (legacy Alt+Enter). */
export const DEFAULT_SHIFT_ENTER_NEWLINE_TEXT = '\\e\\r';
/**
* Stored forms of the pre-#3770 Shift+Enter default. `\\n` is the escaped
* setting string; `\n` is a raw newline if one was ever persisted.
*/
export function isLegacyDefaultShiftEnterNewlineText(value: unknown): boolean {
return value === '\\n' || value === '\n';
}
const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollback: DEFAULT_TERMINAL_SCROLLBACK,
drawBoldInBrightColors: true,
@@ -522,7 +533,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
altAsMeta: false,
optionArrowWordJump: false,
shiftEnterNewlineEnabled: true,
shiftEnterNewlineText: '\\n',
shiftEnterNewlineText: DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
shiftEnterForceText: false,
kittyKeyboardProtocolEnabled: false,
scrollOnInput: true,
-3
View File
@@ -269,9 +269,6 @@ test("repo README paste collapses shields badges without debris", () => {
const payload = resolveNoteClipboardPaste({ plainText: readmeHead, htmlText: "" });
assert.ok(payload.text.length > 50);
assert.doesNotMatch(payload.text, /^\s*\]\([^)\n]+\)\s*$/m);
// Large screenshot keeps dimensions in source.
assert.match(payload.text, /width="3142"/);
assert.match(payload.text, /height="1764"/);
});
test("resolve uses full turndown for browser StartFragment html", () => {
+83
View File
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
import { sftpRequiresDedicatedLogin } from "./sftpDedicatedLogin";
test("network-device mode forces a dedicated SFTP login before any banner", () => {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "network" },
remoteSshVersion: "OpenSSH_9.6",
}),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "network" },
remoteSshVersion: "SSH-2.0-OpenSSH_for_Windows_9.5",
}),
true,
);
});
test("Huawei dash and Ruijie banners force a dedicated SFTP login", () => {
for (const remoteSshVersion of ["-", "SSH-2.0--", "SSH-1.99--", "RGOS_SSH", "SSH-2.0-RGOS_SSH"]) {
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "general" },
remoteSshVersion,
}),
true,
remoteSshVersion,
);
}
// Huawei `-` must not become a global single-channel bastion token.
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
});
test("other network-vendor banners force a dedicated SFTP login", () => {
for (const remoteSshVersion of [
"SSH-2.0-Cisco-1.25",
"SSH-2.0-H3C-Comware-7.1.064",
"SSH-2.0-ROSSSH",
]) {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
true,
remoteSshVersion,
);
}
});
test("one-channel bastion banners still force a dedicated SFTP login", () => {
for (const remoteSshVersion of [
"BHostSSH_7.0",
"SSH-2.0-TERM-SSHD",
"CLOUDBILITY-4.14",
"SSH-2.0-CloudLinker",
]) {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
true,
remoteSshVersion,
);
}
});
test("ordinary SSH servers keep the shared terminal SFTP transport", () => {
assert.equal(sftpRequiresDedicatedLogin({ host: { deviceType: "general" } }), false);
assert.equal(sftpRequiresDedicatedLogin({ host: null }), false);
assert.equal(sftpRequiresDedicatedLogin({}), false);
for (const remoteSshVersion of ["OpenSSH_9.6", "SSH-2.0-OpenSSH_9.6", "JumpServer", ""]) {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
false,
remoteSshVersion,
);
}
});
+25
View File
@@ -0,0 +1,25 @@
import { detectVendorFromSshVersion, hostRestrictsExtraSshChannels } from "./host";
import type { Host } from "./models";
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
export type SftpDedicatedLoginInput = {
host?: Pick<Host, "deviceType"> | null;
remoteSshVersion?: string | null;
};
/**
* SFTP must dial its own SSH login when an extra subsystem on the terminal
* TCP session would drop the shell (#3631).
*
* Network-device mode is enough on its own, with or without a banner.
* A network-vendor identification string (Huawei `-` / `SSH-2.0--`, Ruijie
* RGOS, and the other `detectVendorFromSshVersion` matches) and one-channel
* bastion software tokens are included here. This does not stamp
* session.singleChannelSsh; that flag stays reserved for bastion banners.
*/
export function sftpRequiresDedicatedLogin(input: SftpDedicatedLoginInput): boolean {
if (hostRestrictsExtraSshChannels(input.host)) return true;
const remoteSshVersion = input.remoteSshVersion || undefined;
return remoteSoftwareRequiresSingleChannel(remoteSshVersion)
|| detectVendorFromSshVersion(remoteSshVersion) !== "";
}
+13 -2
View File
@@ -93,7 +93,7 @@ test("normalizeTerminalSettings enables Shift+Enter newline by default", () => {
const settings = normalizeTerminalSettings();
assert.equal(settings.shiftEnterNewlineEnabled, true);
assert.equal(settings.shiftEnterNewlineText, "\\n");
assert.equal(settings.shiftEnterNewlineText, "\\e\\r");
});
test("normalizeTerminalSettings disables Kitty keyboard protocol by default", () => {
@@ -111,10 +111,21 @@ test("normalizeTerminalSettings preserves Shift+Enter text", () => {
);
});
test("normalizeTerminalSettings keeps an explicit legacy Shift+Enter newline", () => {
assert.equal(
normalizeTerminalSettings({ shiftEnterNewlineText: "\\n" }).shiftEnterNewlineText,
"\\n",
);
assert.equal(
normalizeTerminalSettings({ shiftEnterNewlineText: "\n" }).shiftEnterNewlineText,
"\n",
);
});
test("normalizeTerminalSettings falls back when Shift+Enter text is not a string", () => {
assert.equal(
normalizeTerminalSettings({ shiftEnterNewlineText: null as never }).shiftEnterNewlineText,
"\\n",
"\\e\\r",
);
});
@@ -5,6 +5,12 @@ const { StringDecoder } = require("node:string_decoder");
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
const CODEX_AUTH_VALIDATION_TIMEOUT_MS = 10_000;
const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
// Bun-compiled OpenCode-family CLIs start slowly: `mimo --version` takes
// 3.9–5.1s warm (6s+ cold) and `opencode --version` can exceed 5s on a cold
// cache, so a 5s timeout killed healthy CLIs and dropped them from discovery.
// 15s only affects CLIs that exist but are slow to answer — a missing command
// fails spawn immediately.
const VERSION_PROBE_TIMEOUT_MS = 15_000;
function createAgentCliHelpers(ctx) {
with (ctx) {
@@ -120,7 +126,7 @@ function createAgentCliHelpers(ctx) {
async function probeCliVersion(probeCmd, probeArgs, env) {
try {
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: 5000 });
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: VERSION_PROBE_TIMEOUT_MS });
return {
launched: true,
exitCode: result.exitCode,
@@ -430,4 +436,5 @@ module.exports = {
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
DEFAULT_CODEX_CLI_TIMEOUT_MS,
MAX_AGENT_CLI_BUFFER_CHARS,
VERSION_PROBE_TIMEOUT_MS,
};
@@ -10,6 +10,7 @@ const {
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
DEFAULT_CODEX_CLI_TIMEOUT_MS,
MAX_AGENT_CLI_BUFFER_CHARS,
VERSION_PROBE_TIMEOUT_MS,
} = require("./agentCliHelpers.cjs");
function createHungChild() {
@@ -55,6 +56,37 @@ test("runCodexCli applies a default timeout to short status commands", async ()
assert.deepEqual(child.kills, ["SIGTERM", "SIGKILL"]);
});
test("probeCliVersion allows slow-starting CLIs beyond the old 5s probe limit", async () => {
const child = createHungChild();
const scheduled = [];
const helpers = createAgentCliHelpers({
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
spawn: () => {
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from("0.1.15\n"));
child.emit("close", 0);
});
return child;
},
stripAnsi: (value) => value,
getShellEnv: async () => ({}),
normalizeCliPathForPlatform: (value) => value,
resolveCliFromPathAsync: async () => "/fake/mimo",
setTimeout: (callback, delay) => {
scheduled.push(delay);
return { unref() {} };
},
clearTimeout() {},
});
const result = await helpers.probeCliVersion("/fake/mimo", ["--version"], {});
assert.equal(result.launched, true);
assert.equal(result.exitCode, 0);
assert.equal(result.version, "0.1.15");
assert.equal(scheduled[0], VERSION_PROBE_TIMEOUT_MS);
assert.ok(scheduled[0] > 5000, "version probe timeout must exceed 5s for bun-compiled OpenCode-family CLIs");
});
test("runCodexCli slices a single oversized output chunk to the hard limit", async () => {
const child = createHungChild();
const helpers = createAgentCliHelpers({
+35 -6
View File
@@ -7,6 +7,11 @@ const DEFAULT_SSH_EXEC_OPEN_TIMEOUT_MS = 15_000;
const DEFAULT_SSH_EXEC_RUN_TIMEOUT_MS = 10 * 60_000;
const DEFAULT_SSH_EXEC_MAX_OUTPUT_BYTES = 64 * 1024;
// A best-effort probe may abandon an open without disconnecting other leases.
// ssh2 cannot cancel that open. Fence further execs until its callback arrives,
// bounding retained requests even when a panel keeps polling a stalled server.
const abandonedExecOpens = new WeakMap();
function terminateSshExecStream(stream) {
if (!stream) return;
// Teardown can race with a final transport error after normal listeners have
@@ -112,6 +117,11 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
if (!sshClient || typeof sshClient.exec !== "function") {
return Promise.reject(new Error("SSH exec unavailable"));
}
if (abandonedExecOpens.get(sshClient)?.size) {
const error = new Error("SSH exec channel open still pending on this connection");
error.code = "SSH_EXEC_OPEN_PENDING";
return Promise.reject(error);
}
const signal = options.signal || null;
const setTimeoutFn = options.setTimeoutFn || setTimeout;
const clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
@@ -132,6 +142,19 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
const stderrDecoder = new StringDecoder("utf8");
let decodersEnded = false;
let cleanupStreamListeners = () => {};
const openToken = {};
let invoked = false;
const abandonOpen = () => {
if (!invoked || streamRef || settled) return;
let pending = abandonedExecOpens.get(sshClient);
if (!pending) abandonedExecOpens.set(sshClient, pending = new Set());
pending.add(openToken);
};
const releaseAbandonedOpen = () => {
const pending = abandonedExecOpens.get(sshClient);
pending?.delete(openToken);
if (pending?.size === 0) abandonedExecOpens.delete(sshClient);
};
const cleanup = () => {
if (openingTimer) clearTimeoutFn(openingTimer);
@@ -159,12 +182,15 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
}
return true;
};
const onAbort = () => finish(createAbortError(signal), null, {
terminate: true,
// Before the callback arrives ssh2 owns an uncancellable channel-open
// request. Closing the physical transport is the only public cleanup.
invalidateTransport: !streamRef && options.invalidateTransportOnAbort !== false,
});
const onAbort = () => {
if (options.invalidateTransportOnAbort === false) abandonOpen();
return finish(createAbortError(signal), null, {
terminate: true,
// Before the callback arrives ssh2 owns an uncancellable channel-open
// request. Preserve-and-fence callers wait for late callback cleanup.
invalidateTransport: !streamRef && options.invalidateTransportOnAbort !== false,
});
};
const append = (target, chunk) => {
if (settled) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
@@ -190,11 +216,14 @@ function executeBoundedSshCommand(sshClient, command, options = {}) {
openingTimer = setTimeoutFn(() => {
const error = new Error(`SSH exec channel open timed out after ${openingTimeoutMs} ms`);
error.code = "SSH_EXEC_OPEN_TIMEOUT";
if (!invalidateOnOpenTimeout) abandonOpen();
finish(error, null, { terminate: true, invalidateTransport: invalidateOnOpenTimeout });
}, openingTimeoutMs);
try {
invoked = true;
sshClient.exec(command, (error, stream) => {
releaseAbandonedOpen();
if (openingTimer) clearTimeoutFn(openingTimer);
openingTimer = null;
if (settled) {
+39
View File
@@ -349,3 +349,42 @@ test("bounded SSH exec still honors the channel close when no exit-status is rep
stream.emit("close", 7);
assert.deepEqual(await result, { stdout: "done", stderr: "", code: 7 });
});
for (const abandon of ['timeout', 'abort']) {
test(`preserved ${abandon} fences exec retries until the late callback settles`, async () => {
let callback;
let opens = 0;
let destroyed = 0;
const client = {
exec(_command, next) { opens++; callback = next; },
end() { destroyed++; },
destroy() { destroyed++; },
};
const controller = new AbortController();
const request = executeBoundedSshCommand(client, 'probe', {
openingTimeoutMs: 5,
invalidateOnOpenTimeout: false,
invalidateTransportOnAbort: false,
signal: controller.signal,
});
if (abandon === 'abort') controller.abort();
await assert.rejects(request);
for (let i = 0; i < 10; i++) {
await assert.rejects(executeBoundedSshCommand(client, 'probe'),
{ code: 'SSH_EXEC_OPEN_PENDING' });
}
assert.equal(opens, 1);
assert.equal(destroyed, 0);
const lateStream = createStream();
callback(null, lateStream);
assert.equal(lateStream.closed, 1);
const retry = executeBoundedSshCommand(client, 'probe');
const stream = createStream();
callback(null, stream);
stream.emit('data', Buffer.from('recovered'));
stream.emit('close', 0);
assert.equal((await retry).stdout, 'recovered');
assert.equal(opens, 2);
assert.equal(destroyed, 0);
});
}
@@ -0,0 +1,55 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { createSessionOpsApi } = require('./sessionOps.cjs');
for (const method of ['getSessionDistroInfo', 'readRemoteHistory', 'getSessionPwd', 'listSessionDir']) {
test(`${method} timeout cannot disconnect the terminal or accumulate pending opens`, async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let callback;
let opens = 0;
let destroys = 0;
const conn = {
exec(_command, next) { opens++; callback = next; },
end() { destroys++; },
destroy() { destroys++; },
};
const api = createSessionOpsApi({
sessions: new Map([['ssh', { type: 'ssh', conn, shellPid: '4242' }]]),
log() {},
quoteShellArg: value => "'" + String(value).replace(/'/g, "'\\''") + "'",
setTimeout, clearTimeout, Buffer,
});
const request = api[method]({}, { sessionId: 'ssh', path: '/tmp', limit: 5 });
assert.equal(opens, 1);
t.mock.timers.tick(10_000);
assert.equal((await request).success, false);
assert.equal(destroys, 0);
assert.equal((await api[method]({}, { sessionId: 'ssh', path: '/tmp', limit: 5 })).success, false);
assert.equal(opens, 1);
const late = new EventEmitter();
let closed = 0;
late.close = () => { closed++; };
callback(null, late);
assert.equal(closed, 1);
assert.equal(destroys, 0);
});
}
for (const method of ['getSessionDistroInfo', 'readRemoteHistory', 'getServerStats']) {
test(`${method} still tears down an unresponsive dedicated companion`, async t => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let destroys = 0;
const conn = { exec() {}, destroy() { destroys++; } };
const api = createSessionOpsApi({
sessions: new Map([['mosh', { type: 'mosh', moshStatsConn: conn }]]),
quoteShellArg: value => "'" + String(value).replace(/'/g, "'\\''") + "'",
setTimeout, clearTimeout, Buffer, log() {},
});
const request = api[method]({}, { sessionId: 'mosh', limit: 5 });
t.mock.timers.tick(10_000);
assert.equal((await request).success, false);
assert.equal(destroys, 1);
});
}
+9 -3
View File
@@ -190,6 +190,7 @@ function createSessionOpsApi(ctx) {
openingTimeoutMs: 5000,
runTimeoutMs: 5000,
maxOutputBytes: 256 * 1024,
invalidateOnOpenTimeout: !session.conn,
});
return { success: true, stdout, stderr };
} catch (err) {
@@ -307,6 +308,7 @@ function createSessionOpsApi(ctx) {
openingTimeoutMs: 8000,
runTimeoutMs: 8000,
maxOutputBytes: 10 * 1024 * 1024,
invalidateOnOpenTimeout: !session.conn,
});
return { success: true, ...parse(stdout) };
} catch (err) {
@@ -760,14 +762,14 @@ function createSessionOpsApi(ctx) {
)}`;
void executeBoundedSshCommand(session.conn, cmd, {
// Do not shorten channel opening: a timeout there invalidates the
// shared SSH transport. Only bound the best-effort command itself.
// Directory tracking is auxiliary work on the terminal transport.
// Preserve it on a slow open; the executor fences further probes.
openingTimeoutMs: 5000,
runTimeoutMs: timeoutMs,
maxOutputBytes: 256 * 1024,
setTimeoutFn: setTimeout,
clearTimeoutFn: clearTimeout,
invalidateOnOpenTimeout: !isTargetedRecovery,
invalidateOnOpenTimeout: false,
}).then(({ stdout, stderr, code }) => {
if (!isCurrentPwdOwner()) {
settle({ success: false, error: 'Session changed during cwd probe' });
@@ -1018,6 +1020,7 @@ function createSessionOpsApi(ctx) {
openingTimeoutMs: 3000,
runTimeoutMs: 3000,
maxOutputBytes: 1024 * 1024,
invalidateOnOpenTimeout: false,
}).then(({ stdout, stderr: errOut }) => {
if (settled) return;
try {
@@ -1352,6 +1355,9 @@ function createSessionOpsApi(ctx) {
const statsExecOptions = {
signal: session.statsSignal,
invalidateTransportOnAbort: false,
// Preserve shared terminal transports; dedicated Mosh/ET companions
// still need timeout teardown so their existing recovery can reconnect.
invalidateOnOpenTimeout: !session.conn,
openingTimeoutMs: statsRunTimeoutMs,
runTimeoutMs: statsRunTimeoutMs,
maxOutputBytes: 1024 * 1024,
@@ -919,7 +919,7 @@ test("getServerStats closes a stats stream delivered after timeout", async () =>
assert.equal(closeCalls, 1);
});
test("three stats retries on an unresponsive exec open leave no pooled transport or channel callbacks", async () => {
test("stats open timeout preserves shared transport and fences repeated probes", async () => {
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
const endpoint = { hostId: "stats-host", hostname: "wedged.example", username: "root" };
const conn = new EventEmitter();
@@ -962,9 +962,16 @@ test("three stats retries on an unresponsive exec open leave no pooled transport
results.push(await api.getServerStats({ sender: {} }, { sessionId: "sid" }));
assert.ok(results.every((result) => result.success === false));
assert.equal(conn.pendingChannelCallbacks.length, 0);
assert.equal(getTransportStats().transports, 0);
assert.equal(findTransportByEndpoint(endpoint), null);
assert.equal(conn.pendingChannelCallbacks.length, 1, "retries must not accumulate opens");
assert.equal(getTransportStats().transports, 1);
assert.equal(findTransportByEndpoint(endpoint), transport);
assert.equal(conn._sock.destroyed, false);
const lateStream = fakeStream("");
let lateClosed = 0;
lateStream.close = () => { lateClosed++; };
conn.pendingChannelCallbacks.shift()(null, lateStream);
assert.equal(lateClosed, 1);
conn.destroy();
const replacement = new EventEmitter();
replacement._sock = { destroyed: false };
+55 -47
View File
@@ -7,42 +7,48 @@ const identities = new Map();
const MAX_SAMPLERS = 512;
function prune() {
const now = Date.now();
for (const [key, state] of identities) {
state.points = state.points.filter((p) => now - p.at <= 30 * 60000);
if (!state.pending && now - state.at > 30 * 60000) identities.delete(key);
for (const [key, history] of identities) {
history.points = history.points.filter((p) => now - p.at <= 30 * 60000);
if (now - history.at > 30 * 60000) identities.delete(key);
}
}
const cleanupTimer = setInterval(prune, 60000);
cleanupTimer.unref?.();
function stateFor(conn, endpoint) {
const key = endpoint ? buildEndpointKey(endpoint) : null;
let state = (key ? identities.get(key) : null) || states.get(conn);
let state = states.get(conn);
const history = (key ? identities.get(key) : null) || state?.history || {
latest: null,
at: 0,
points: [],
latestFull: false,
fullResult: null,
fullAt: 0,
sequence: 0,
};
if (!state) {
state = {
context: { conn },
context: { conn: null },
pending: null,
latest: null,
at: 0,
points: [],
controller: null,
latestFull: false,
pendingFull: false,
fullResult: null,
fullAt: 0,
history,
};
states.set(conn, state);
} else if (key && !state.pending && !state.history.latest) {
// A panel may read a snapshot before its first sample supplies the endpoint.
// Attach that empty runtime to the existing history without moving live work.
state.history = history;
}
states.set(conn, state);
// Share measurements/history by identity, but in-flight requests, abort
// controllers and CPU/network baselines belong to the physical transport.
// A reconnect must never join (or cancel) work on an older connection.
if (key) {
identities.delete(key);
identities.set(key, state);
identities.set(key, state.history);
if (identities.size > MAX_SAMPLERS) {
for (const [oldKey, oldState] of identities) {
if (!oldState.pending && oldKey !== key) {
oldState.points = [];
identities.delete(oldKey);
break;
}
}
const oldKey = identities.keys().next().value;
identities.delete(oldKey);
}
}
return state;
@@ -51,11 +57,11 @@ function appendPoint(state, result, now, freshAfter = 0) {
if (
!result.success ||
!result.stats ||
(now - (state.points.at(-1)?.at ?? 0) < 1000 && (state.points.at(-1)?.at ?? 0) > freshAfter)
(now - (state.history.points.at(-1)?.at ?? 0) < 1000 && (state.history.points.at(-1)?.at ?? 0) > freshAfter)
)
return;
const s = result.stats;
state.points.push({
state.history.points.push({
at: now,
cpu: s.cpu,
memory:
@@ -66,7 +72,7 @@ function appendPoint(state, result, now, freshAfter = 0) {
rx: s.networkReady ? s.netRxSpeed : null,
tx: s.networkReady ? s.netTxSpeed : null,
});
state.points = state.points
state.history.points = state.history.points
.filter((p) => now - p.at <= 30 * 60000)
.slice(-1800);
}
@@ -79,26 +85,26 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
const state = stateFor(companion);
state.context.prevNetStats = session.prevNetStats;
state.context.prevCpuStats = session.prevCpuStats;
state.latest = result;
state.at = Date.now();
state.latestFull = true;
state.fullResult = result;
state.fullAt = state.at;
state.history.latest = result;
state.history.at = Date.now();
state.history.latestFull = true;
state.history.fullResult = result;
state.history.fullAt = state.history.at;
}
return result;
}
const state = stateFor(conn, session._reuseEndpoint);
const full = !session._monitorStats;
const cacheMs = full ? 4000 : Math.min(4000, session._monitorIntervalMs ?? 5000);
if (full && state.fullResult?.success && now - state.fullAt < 4000)
return state.fullResult;
if (full && state.history.fullResult?.success && now - state.history.fullAt < 4000)
return state.history.fullResult;
if (state.pending) {
if (full) state.terminalJoined = true;
if (!full || state.pendingFull) {
const result = await state.pending;
// A reset can join a terminal query that began before the new boundary.
if (!full && state.at > session._monitorFreshAfter)
appendPoint(state, result, state.at, session._monitorFreshAfter);
if (!full && state.history.at > session._monitorFreshAfter)
appendPoint(state, result, state.history.at, session._monitorFreshAfter);
return result;
}
// A terminal needs process and GPU fields omitted from a monitor-only query.
@@ -107,12 +113,12 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
return sampleSharedStats(session, collect);
}
if (
state.latest?.success &&
now - state.at < cacheMs &&
(full || state.at > (session._monitorFreshAfter ?? 0)) &&
(!full || state.latestFull)
state.history.latest?.success &&
now - state.history.at < cacheMs &&
(full || state.history.at > (session._monitorFreshAfter ?? 0)) &&
(!full || state.history.latestFull)
)
return state.latest;
return state.history.latest;
const collectFull = full;
// Keep transport capabilities on the collection context; baseline is independent of a terminal.
const companion = !session.conn;
@@ -125,21 +131,23 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
hostname: session.hostname,
_monitorStats: !collectFull,
});
// Starting a replacement retires older samples even if the new attempt fails.
const sequence = ++state.history.sequence;
state.controller = new AbortController();
state.terminalJoined = full;
state.pendingFull = collectFull;
state.context.statsSignal = state.controller.signal;
state.pending = Promise.resolve(collect(state.context))
.then((result) => {
if (!state.controller.signal.aborted && result.success) {
state.latest = result;
state.latestFull = collectFull;
state.at = Date.now();
if (!state.controller.signal.aborted && result.success && sequence === state.history.sequence) {
state.history.latest = result;
state.history.latestFull = collectFull;
state.history.at = Date.now();
if (collectFull) {
state.fullResult = result;
state.fullAt = state.at;
state.history.fullResult = result;
state.history.fullAt = state.history.at;
}
appendPoint(state, result, state.at, session._monitorFreshAfter);
appendPoint(state, result, state.history.at, session._monitorFreshAfter);
}
return result;
})
@@ -155,9 +163,9 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
}
function readSharedStats(conn) {
const state = stateFor(conn);
if (state.points[0] && Date.now() - state.points[0].at > 30 * 60000)
state.points = state.points.filter((p) => Date.now() - p.at <= 30 * 60000);
return { result: state.latestFull ? state.latest : withCachedMonitorGpu(conn, state.latest), at: state.at, statsAt: Math.max(state.at, monitorGpuUpdatedAt(conn)), points: state.points };
if (state.history.points[0] && Date.now() - state.history.points[0].at > 30 * 60000)
state.history.points = state.history.points.filter((p) => Date.now() - p.at <= 30 * 60000);
return { result: state.history.latestFull ? state.history.latest : withCachedMonitorGpu(conn, state.history.latest), at: state.history.at, statsAt: Math.max(state.history.at, monitorGpuUpdatedAt(conn)), points: state.history.points };
}
function cancelSharedStats(conn) {
cancelMonitorGpuProbe(conn);
@@ -0,0 +1,95 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { sampleSharedStats, readSharedStats, cancelSharedStats, resetSharedStatsForTests } = require('./sharedStats.cjs');
const endpoint = { hostname: 'stats.example', username: 'test', port: 22 };
const result = cpu => ({ success: true, stats: { cpu, memTotal: 100, memUsed: 50 } });
test('new transport samples without waiting for an old same-endpoint request', async () => {
resetSharedStatsForTests();
const oldConn = {}, newConn = {};
let finishOld;
let oldContext;
const old = sampleSharedStats({ conn: oldConn, _reuseEndpoint: endpoint }, context => {
oldContext = context;
return new Promise(resolve => { finishOld = resolve; });
});
let newCalls = 0;
const fresh = await sampleSharedStats({ conn: newConn, _reuseEndpoint: endpoint }, async context => {
newCalls++;
assert.equal(context.conn, newConn);
return result(80);
});
assert.equal(newCalls, 1);
assert.equal(fresh.stats.cpu, 80);
assert.equal(oldContext.statsSignal.aborted, false);
finishOld(result(10));
await old;
assert.equal(readSharedStats(newConn).result.stats.cpu, 80, 'late old reply must not replace newer measurements');
assert.equal(readSharedStats(oldConn).points, readSharedStats(newConn).points);
});
test('a failed replacement sample still retires pending results from the old transport', async () => {
resetSharedStatsForTests();
const oldConn = {}, newConn = {};
await sampleSharedStats({ conn: oldConn, _reuseEndpoint: endpoint }, async () => result(42));
const before = readSharedStats(oldConn);
const now = Date.now() + 5000;
let finishOld;
const old = sampleSharedStats({ conn: oldConn, _reuseEndpoint: endpoint }, () =>
new Promise(resolve => { finishOld = resolve; }), now);
const failed = await sampleSharedStats({ conn: newConn, _reuseEndpoint: endpoint }, async () =>
({ success: false, error: 'Connection closed' }), now);
assert.equal(failed.success, false);
finishOld(result(10));
await old;
const after = readSharedStats(newConn);
assert.equal(after.result.stats.cpu, 42, 'keep the last published measurement');
assert.equal(after.at, before.at);
assert.deepEqual(after.points.map(point => point.cpu), [42], 'do not publish a late old sample');
});
test('cancelling a monitor on one transport cannot abort another transport', async () => {
resetSharedStatsForTests();
const a = {}, b = {};
const contexts = [];
const finish = [];
const collect = context => {
contexts.push(context);
return new Promise(resolve => finish.push(resolve));
};
const first = sampleSharedStats({ conn: a, _reuseEndpoint: endpoint, _monitorStats: true }, collect);
const second = sampleSharedStats({ conn: b, _reuseEndpoint: endpoint, _monitorStats: true }, collect);
assert.equal(contexts.length, 2);
cancelSharedStats(a);
assert.equal(contexts[0].statsSignal.aborted, true);
assert.equal(contexts[1].statsSignal.aborted, false);
finish[0](result(10));
finish[1](result(80));
await Promise.all([first, second]);
assert.equal(readSharedStats(b).result.stats.cpu, 80);
});
test('readers sharing one physical transport still coalesce an active sample', async () => {
resetSharedStatsForTests();
const conn = {};
let resolve;
let calls = 0;
const collect = () => { calls++; return new Promise(done => { resolve = done; }); };
const a = sampleSharedStats({ conn, _reuseEndpoint: endpoint }, collect);
const b = sampleSharedStats({ conn, _reuseEndpoint: endpoint }, collect);
assert.equal(calls, 1);
resolve(result(42));
assert.deepEqual(await a, await b);
});
test('a snapshot read before sampling still joins the existing endpoint history', async () => {
resetSharedStatsForTests();
const terminal = {}, monitor = {};
await sampleSharedStats({ conn: terminal, _reuseEndpoint: endpoint }, async () => result(42));
assert.equal(readSharedStats(monitor).result, null);
await sampleSharedStats({ conn: monitor, _reuseEndpoint: endpoint }, async () => result(80));
assert.equal(readSharedStats(monitor).points, readSharedStats(terminal).points);
assert.equal(readSharedStats(monitor).result.stats.cpu, 42);
});
@@ -92,12 +92,15 @@ function createExecOnSessionApi(ctx) {
return conn;
}
function execOnConnection(conn, command, timeoutMs, execOptions = {}) {
function execOnConnection(conn, command, timeoutMs, execOptions = {}, sharedTransport = true) {
const maxBuffer = normalizeExecMaxBuffer(execOptions.maxBuffer);
return executeBoundedSshCommand(conn, command, {
openingTimeoutMs: timeoutMs,
runTimeoutMs: timeoutMs,
maxOutputBytes: maxBuffer,
// A slow auxiliary channel must not tear down the interactive shell
// or SFTP transfers on the same transport. The executor fences retries.
invalidateOnOpenTimeout: !sharedTransport,
onStream(stream) {
if (typeof execOptions.stdin === "string") {
stream.write(execOptions.stdin);
@@ -144,7 +147,7 @@ function createExecOnSessionApi(ctx) {
return { success: false, error: "Session not found or not connected" };
}
const result = await execOnConnection(conn, command, timeoutMs, execOptions);
const result = await execOnConnection(conn, command, timeoutMs, execOptions, !!session.conn);
if (
allowCompanionRetry
&& !result.success
@@ -142,3 +142,53 @@ test("execOnSession refuses extra channels when singleChannelSsh is set", async
assert.equal(result.code, "ERR_SINGLE_CHANNEL_BASTION");
assert.equal(execCalls, 0);
});
test("auxiliary exec timeout preserves the terminal and manual retry recovers after late open", async () => {
let opens = 0;
let destroyed = 0;
let callback;
const conn = {
_sock: { destroyed: false },
exec(_command, next) { opens++; callback = next; },
end() { destroyed++; },
destroy() { destroyed++; },
};
const api = createExecOnSessionApi({ sessions: new Map([['ssh', { conn, type: 'ssh' }]]) });
assert.equal((await api.execOnSession(null, 'ssh', 'docker ps', 5)).success, false);
assert.equal(destroyed, 0);
const fenced = await api.execOnSession(null, 'ssh', 'docker ps', 5);
assert.equal(fenced.success, false);
assert.match(fenced.error, /still pending/);
assert.equal(opens, 1);
const late = new EventEmitter();
let closed = 0;
late.close = () => { closed++; };
callback(null, late);
assert.equal(closed, 1);
conn.exec = (_command, next) => {
opens++;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
next(null, stream);
queueMicrotask(() => {
stream.emit('data', Buffer.from('container recovered'));
stream.emit('close', 0);
});
};
const recovered = await api.execOnSession(null, 'ssh', 'docker ps', 1000);
assert.equal(recovered.success, true);
assert.equal(recovered.stdout, 'container recovered');
assert.equal(opens, 2);
assert.equal(destroyed, 0);
});
test("auxiliary exec still reclaims an unresponsive dedicated Mosh companion", async () => {
let destroys = 0;
const conn = { exec() {}, destroy() { destroys++; } };
const api = createExecOnSessionApi({
sessions: new Map([['mosh', { type: 'mosh', moshStatsConn: conn }]]),
});
const result = await api.execOnSession(null, 'mosh', 'docker ps', 5);
assert.equal(result.success, false);
assert.equal(destroys, 1);
});
+13 -6
View File
@@ -5107,6 +5107,7 @@ async function downloadFile(
await new Promise((resolve, reject) => {
let settled = false;
let onFastSftpError = null;
const onFastSftpClose = () => finish(new Error("SFTP channel closed during download"));
const finish = (err) => {
if (settled) return;
settled = true;
@@ -5117,6 +5118,8 @@ async function downloadFile(
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
onFastSftpError = null;
}
fastSftp.removeListener("end", onFastSftpClose);
fastSftp.removeListener("close", onFastSftpClose);
releaseIsolatedDownloadChannel(client, fastSftp, {
dispose: !!err || transfer.cancelled,
});
@@ -5133,6 +5136,8 @@ async function downloadFile(
transfer.abort = abortFastTransfer;
onFastSftpError = (err) => finish(err);
fastSftp.once("error", onFastSftpError);
fastSftp.once("end", onFastSftpClose);
fastSftp.once("close", onFastSftpClose);
if (transfer.cancelled) {
finish(new Error("Transfer cancelled"));
@@ -6173,13 +6178,15 @@ async function startTransferNow(event, payload, onProgress) {
lastObservedTotal = fileSize;
lastObservedTransferred = Math.min(lastObservedTransferred, fileSize);
}
// fastGet's success callback alone does not establish a complete file.
// Enforce the planned size for direct/non-resumable downloads as well.
if (transfer.cancelled) throw new Error("Transfer cancelled");
const downloadedStat = await fs.promises.stat(downloadTargetPath);
if (transfer.cancelled) throw new Error("Transfer cancelled");
if (downloadedStat.size !== fileSize) {
throw new Error(`Downloaded file size mismatch: expected ${fileSize}, got ${downloadedStat.size}`);
}
if (stageLocalDownload) {
if (transfer.cancelled) throw new Error("Transfer cancelled");
const stagedStat = await fs.promises.stat(downloadTargetPath);
if (transfer.cancelled) throw new Error("Transfer cancelled");
if (stagedStat.size !== fileSize) {
throw new Error(`Downloaded file size mismatch: expected ${fileSize}, got ${stagedStat.size}`);
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
const {
promotionTargetPath,
@@ -0,0 +1,57 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const { randomUUID } = require('node:crypto');
const { EventEmitter } = require('node:events');
const bridge = require('./transferBridge.cjs');
const tempDirBridge = require('./tempDirBridge.cjs');
for (const scenario of ['truncated', 'oversized', 'end', 'close', 'complete']) {
test(`non-resumable fastGet handles ${scenario} without a false completion or hang`, async t => {
const id = `integrity-${randomUUID()}`;
const targetPath = tempDirBridge.getTempFilePath(`${id}.bin`);
t.after(() => fs.rm(targetPath, { force: true }));
const channel = new EventEmitter();
channel.end = () => {};
channel.fastGet = (_remote, local, _options, done) => {
if (scenario === 'end' || scenario === 'close') {
queueMicrotask(() => channel.emit(scenario));
} else {
const bytes = scenario === 'truncated' ? 'ab' : scenario === 'oversized' ? 'abcdef' : 'abcd';
fs.writeFile(local, bytes).then(() => done(), done);
}
};
const sftp = new EventEmitter();
sftp.readdir = (_path, callback) => callback(null, []);
sftp.stat = (_path, callback) => callback(null, { size: 4 });
sftp.mkdir = (_path, callback) => callback(null);
sftp.unlink = (_path, callback) => callback(null);
for (const method of ['readdir', 'stat', 'mkdir', 'unlink']) channel[method] = sftp[method];
const client = { sftp, stat: async () => ({ size: 4 }), client: {
sftp(callback) { callback(null, channel); },
} };
bridge.init({ sftpClients: new Map([['source', client]]) });
const events = [];
let timer;
const running = bridge.startTransfer({ sender: { send: (event, data) => events.push({ event, data }) } }, {
transferId: id, sourceType: 'sftp', targetType: 'local', sourceSftpId: 'source',
sourcePath: '/file.bin', targetPath, totalBytes: 4, resumable: false,
});
t.after(async () => { clearTimeout(timer); await bridge.cancelTransfer({}, { transferId: id }); await running; });
const response = await Promise.race([running, new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('download did not settle')), 1000);
})]);
if (scenario === 'complete') {
assert.equal(response.error, undefined);
assert.equal(await fs.readFile(targetPath, 'utf8'), 'abcd');
assert.ok(events.some(({ event }) => event === 'netcatty:transfer:complete'));
} else {
assert.match(response.error, scenario === 'truncated' || scenario === 'oversized'
? /size mismatch/ : /channel closed/);
assert.ok(!events.some(({ event }) => event === 'netcatty:transfer:complete'));
}
assert.equal(channel.listenerCount('end'), 0);
assert.equal(channel.listenerCount('close'), 0);
});
}
+9
View File
@@ -819,6 +819,15 @@ html[data-theme-transition] *::after {
-webkit-user-select: auto;
}
/* Selected top-tab hooks. Fill and text stay on the tab's inline style so the
unset custom properties fall through to the existing chrome colors. The
border is a 1px inset outline and stays invisible until
--top-tab-active-border is set (Settings → Appearance → Custom CSS). */
[data-section="top-tabs"] .top-tab[data-active="true"] {
outline: 1px solid var(--top-tab-active-border, transparent);
outline-offset: -1px;
}
/* Window controls: icons sit in the h-7 tab row; hover fills the full h-9 title bar */
[data-section="top-tabs"] .window-control-btn {
position: relative;
@@ -363,3 +363,56 @@ test('prepareTurnContext calls summarize when over dynamic threshold', async ()
assert.equal(prepared.trace?.didLlmSummarize, true);
assert.match(JSON.stringify(prepared.messages), /summary of earlier work/);
});
test('normal turn and step preparation keep user attachment parts', async () => {
const messages: ModelMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'what does this image say?' },
{ type: 'file', data: 'QUJD'.repeat(750), mediaType: 'image/png' },
],
},
];
const turn = await prepareTurnContext({
messages,
backend: 'catty',
contextWindow: 1_300_000,
trigger: 'pre-turn',
});
const step = await prepareStepContext({
messages: turn.messages,
contextWindow: 1_300_000,
stepNumber: 0,
});
for (const prepared of [turn, step]) {
const content = prepared.messages.find(message => message.role === 'user')?.content;
assert.ok(Array.isArray(content));
assert.equal(content.some(part => (part as { type?: string }).type === 'file'), true);
assert.doesNotMatch(JSON.stringify(prepared.messages), /attachment omitted to keep the AI request small/);
}
});
test('413 retry still trades attachments for a text placeholder', async () => {
const messages: ModelMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'what does this image say?' },
{ type: 'file', data: 'QUJD'.repeat(750), mediaType: 'image/png' },
],
},
];
const prepared = await prepareTurnContext({
messages,
backend: 'catty',
contextWindow: 1_300_000,
trigger: '413-retry',
compressForRequestTooLargeRetry: true,
});
assert.match(JSON.stringify(prepared.messages), /attachment omitted to keep the AI request small/);
});
+15 -5
View File
@@ -7,7 +7,10 @@ import {
keepRecentContextMessages,
prepareContextCompaction,
} from '../contextCompaction';
import { compressMessagesForRequestTooLargeRetry } from '../requestPayloadCompression';
import {
compressMessagesForRequestTooLargeRetry,
type CompressMessagesForRequestTooLargeRetryOptions,
} from '../requestPayloadCompression';
import {
computeCompactionThreshold,
computeTotalInputTokens,
@@ -96,11 +99,14 @@ function emitCompactionStart(
});
}
function applyTypedMessageCompression(messages: ModelMessage[]): {
function applyTypedMessageCompression(
messages: ModelMessage[],
options?: CompressMessagesForRequestTooLargeRetryOptions,
): {
messages: ModelMessage[];
didAdjust: boolean;
} {
const compressed = compressMessagesForRequestTooLargeRetry(messages);
const compressed = compressMessagesForRequestTooLargeRetry(messages, options);
return { messages: compressed.messages, didAdjust: compressed.didAdjust };
}
@@ -225,7 +231,9 @@ export async function prepareTurnContext(
did413Fallback = typed.didAdjust;
didAdjust = didAdjust || typed.didAdjust;
} else {
const typed = applyTypedMessageCompression(working);
// Routine pre-turn pass: keep the user's attachment parts. Only the explicit
// 413 retry above may trade them for text placeholders.
const typed = applyTypedMessageCompression(working, { preserveAttachments: true });
if (typed.didAdjust) {
working = typed.messages;
didTypedCompression = true;
@@ -412,7 +420,9 @@ export async function prepareStepContext(
});
let working = stale.messages;
const typed = compressMessagesForRequestTooLargeRetry(working);
// Step-level typed compression runs before every model call, so it must keep
// multimodal parts: the user's image would otherwise never reach the model.
const typed = compressMessagesForRequestTooLargeRetry(working, { preserveAttachments: true });
working = typed.messages;
const budgetGuard = applyStepBudgetGuard(working, {
@@ -90,3 +90,77 @@ test("compressMessagesForRequestTooLargeRetry reports no adjustment for compact
assert.equal(result.didAdjust, false);
assert.deepEqual(result.messages, messages);
});
test("compressMessagesForRequestTooLargeRetry keeps attachments when preserveAttachments is set", () => {
const imageData = "A".repeat(300_000);
const fileData = Buffer.from("label,hostname\nprod,prod.example.com\n").toString("base64");
const messages: ModelMessage[] = [
{
role: "user",
content: [
{ type: "text", text: "what does this screenshot say?" },
{ type: "file", data: imageData, mediaType: "image/png" },
],
},
{
role: "user",
content: [
{ type: "text", text: "import these hosts" },
{ type: "file", data: fileData, mediaType: "text/csv", filename: "hosts.csv" },
],
},
{
role: "tool",
content: [{
type: "tool-result",
toolCallId: "call-1",
toolName: "terminal_execute",
output: { type: "text", value: "X".repeat(200_000) },
}],
},
];
const result = compressMessagesForRequestTooLargeRetry(messages, { preserveAttachments: true });
// Text and tool output are still reduced. Images stay; other files become placeholders.
assert.equal(result.didAdjust, true);
const imageContent = result.messages[0].content;
assert.ok(Array.isArray(imageContent));
assert.deepEqual(imageContent[1], messages[0].content[1]);
assert.doesNotMatch(JSON.stringify(imageContent), /attachment omitted to keep the AI request small/);
const fileContent = result.messages[1].content;
assert.ok(Array.isArray(fileContent));
assert.deepEqual(fileContent[1], {
type: "text",
text: `[file attachment omitted to keep the AI request small: filename=hosts.csv, mediaType=text/csv, ${fileData.length} chars]`,
});
const toolContent = result.messages[2].content;
assert.ok(Array.isArray(toolContent));
const toolPart = toolContent[0] as { output?: { value?: string } };
assert.ok((toolPart.output?.value?.length ?? 0) < 5_000);
});
test("compressMessagesForRequestTooLargeRetry still placeholders text/plain files when preserveAttachments is set", () => {
const text = "hello from a text file";
const messages: ModelMessage[] = [
{
role: "user",
content: [
{ type: "text", text: "read this note" },
{ type: "file", data: text, mediaType: "text/plain", filename: "note.txt" },
],
},
];
const result = compressMessagesForRequestTooLargeRetry(messages, { preserveAttachments: true });
const content = result.messages[0].content;
assert.ok(Array.isArray(content));
assert.deepEqual(content[0], { type: "text", text: "read this note" });
assert.deepEqual(content[1], {
type: "text",
text: `[file attachment omitted to keep the AI request small: filename=note.txt, mediaType=text/plain, ${text.length} chars]`,
});
});
+32 -9
View File
@@ -16,6 +16,18 @@ export interface CompressMessagesForRequestTooLargeRetryResult {
didAdjust: boolean;
}
export interface CompressMessagesForRequestTooLargeRetryOptions {
/**
* Keep image attachments (`image` parts and `file` parts whose media type
* starts with `image/`) instead of replacing them with a text placeholder.
*
* Other file parts stay placeholders. Chat providers reject types such as
* `text/plain`, and the model reads those through attachment tools.
* Only the 413 retry drops images as well.
*/
preserveAttachments?: boolean;
}
/**
* Collapse noisy terminal/build output.
* Keeps semantics while removing repeated blank lines and long duplicate runs.
@@ -108,10 +120,12 @@ export function truncateTextWithHeadAndTail(
export function compressMessagesForRequestTooLargeRetry(
messages: ModelMessage[],
options: CompressMessagesForRequestTooLargeRetryOptions = {},
): CompressMessagesForRequestTooLargeRetryResult {
const preserveAttachments = options.preserveAttachments === true;
let didAdjust = false;
const compressedMessages = messages.map((message) => {
const compressed = compressModelMessageForRequestRetry(message);
const compressed = compressModelMessageForRequestRetry(message, preserveAttachments);
if (compressed !== message) didAdjust = true;
return compressed;
});
@@ -122,7 +136,10 @@ export function compressMessagesForRequestTooLargeRetry(
};
}
function compressModelMessageForRequestRetry(message: ModelMessage): ModelMessage {
function compressModelMessageForRequestRetry(
message: ModelMessage,
preserveAttachments: boolean,
): ModelMessage {
if (typeof message.content === "string") {
// User text is an instruction or exact input, not terminal output. Lossy
// cleanup can alter commands even when the request is far below its limit.
@@ -136,7 +153,7 @@ function compressModelMessageForRequestRetry(message: ModelMessage): ModelMessag
let didAdjust = false;
const content = message.content.map((part) => {
if (message.role === "user" && part.type === "text") return part;
const compressed = compressContentPartForRequestRetry(part);
const compressed = compressContentPartForRequestRetry(part, preserveAttachments);
if (compressed !== part) didAdjust = true;
return compressed;
});
@@ -144,7 +161,7 @@ function compressModelMessageForRequestRetry(message: ModelMessage): ModelMessag
return didAdjust ? ({ ...message, content } as ModelMessage) : message;
}
function compressContentPartForRequestRetry(part: unknown): unknown {
function compressContentPartForRequestRetry(part: unknown, preserveAttachments: boolean): unknown {
if (!part || typeof part !== "object") return part;
const record = part as Record<string, unknown>;
const type = record.type;
@@ -178,12 +195,18 @@ function compressContentPartForRequestRetry(part: unknown): unknown {
}
}
if (type === "image" && typeof record.image === "string") {
return omittedAttachmentTextPart("image", record.image, record);
}
const mediaType = typeof record.mediaType === "string" ? record.mediaType : "";
const preserveImage = preserveAttachments && (
type === "image" || (type === "file" && mediaType.startsWith("image/"))
);
if (!preserveImage) {
if (type === "image" && typeof record.image === "string") {
return omittedAttachmentTextPart("image", record.image, record);
}
if (type === "file" && typeof record.data === "string") {
return omittedAttachmentTextPart("file", record.data, record);
if (type === "file" && typeof record.data === "string") {
return omittedAttachmentTextPart("file", record.data, record);
}
}
return part;
+2
View File
@@ -26,6 +26,8 @@ export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';
/** One-time marker: pre-#3619 builds materialized autoCloseOnExit: true into saved settings. */
export const STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED = 'netcatty_term_auto_close_exit_migrated_v1';
/** One-time marker: pre-#3770 builds materialized shiftEnterNewlineText "\\n" into saved settings. */
export const STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED = 'netcatty_term_shift_enter_newline_migrated_v1';
export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1';
export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1';
export const STORAGE_KEY_HOTKEY_RECORDING = 'netcatty_hotkey_recording_v1';
+2 -1
View File
@@ -42,7 +42,7 @@
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && node scripts/patch-xterm-image-tile-stride.cjs && node scripts/patch-xterm-image-iip-type.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
@@ -68,6 +68,7 @@
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:local-file-drag:electron": "electron scripts/local-file-drag.live.test.cjs",
"test:tray-panel-layout": "electron scripts/tray-panel-layout.live.test.cjs",
"test:xterm-image-resize": "electron scripts/xterm-image-resize.live.test.cjs",
"test:xterm-foreground-intense": "electron scripts/xterm-foreground-intense.live.test.cjs",
"test:terminal-background-rendering": "electron scripts/terminal-background-rendering.live.test.cjs",
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/* global process, console */
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const EXPECTED_VERSION = "0.10.0-beta.292";
// External IIP metadata must not overwrite fields.type, the parser's internal
// SequenceType discriminator. In particular, nu_plugin_charton emits type=image/png
// and otherwise skips FILE decoder initialization (Netcatty #3700).
// Pinned-version workaround pending an upstream release; patch both runtime bundles.
const PATCHES = [
...["lib/addon-image.js", "lib/addon-image.mjs"].map(file => ({
file,
original: "this._key=t,this.fields[t]=null,!0",
replacement: 'this._key=t,t!=="type"&&(this.fields[t]=null),!0',
})),
...["lib/addon-image.js", "lib/addon-image.mjs"].map(file => ({
file,
original: "_storeValue(e){if(this._key){try{",
replacement: '_storeValue(e){if(this._key){if(this._key==="type")return!0;try{',
})),
];
function patchImageSource(source, patch) {
const originalCount = source.split(patch.original).length - 1;
const patchedCount = source.split(patch.replacement).length - 1;
if (originalCount === 1 && patchedCount === 0) {
return { source: source.replace(patch.original, patch.replacement), changed: true };
}
if (originalCount === 0 && patchedCount === 1) return { source, changed: false };
throw new Error(
`${patch.file}: unexpected image-iip-type patch shape ` +
`(original=${originalCount}, patched=${patchedCount})`,
);
}
function patchInstalledImage(root = process.cwd()) {
const base = path.resolve(root, "node_modules/@xterm/addon-image");
const version = JSON.parse(fs.readFileSync(path.join(base, "package.json"), "utf8")).version;
if (version !== EXPECTED_VERSION) {
throw new Error(`unsupported @xterm/addon-image version ${version}; expected ${EXPECTED_VERSION}`);
}
// Validate both complete bundles before writing either one.
const files = new Map();
let changed = 0;
for (const patch of PATCHES) {
const source = files.get(patch.file) ?? fs.readFileSync(path.join(base, patch.file), "utf8");
const result = patchImageSource(source, patch);
files.set(patch.file, result.source);
if (result.changed) changed++;
}
if (changed) {
for (const [file, source] of files) fs.writeFileSync(path.join(base, file), source);
fs.rmSync(path.resolve(root, "node_modules/.vite"), { recursive: true, force: true });
}
return { changed, checked: PATCHES.length };
}
if (require.main === module) {
try {
const result = patchInstalledImage();
console.log(`[patch-xterm-image-iip-type] changed=${result.changed} checked=${result.checked}`);
} catch (error) {
console.error(`[patch-xterm-image-iip-type] ERROR: ${error.message}`);
process.exitCode = 1;
}
}
module.exports = { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage };
+130
View File
@@ -0,0 +1,130 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { pathToFileURL } = require("node:url");
const { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage } = require("./patch-xterm-image-iip-type.cjs");
for (const patch of PATCHES) {
test(`${patch.file}: ${patch.original.slice(0, 24)} patches once and rejects drift`, () => {
const first = patchImageSource(`before;${patch.original}after`, patch);
assert.equal(first.changed, true);
assert.equal(first.source, `before;${patch.replacement}after`);
assert.deepEqual(patchImageSource(first.source, patch), { source: first.source, changed: false });
for (const source of ["upstream changed", patch.original.repeat(2), patch.replacement.repeat(2), patch.original + patch.replacement]) {
assert.throws(() => patchImageSource(source, patch), /unexpected image-iip-type patch shape/);
}
});
}
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-image-iip-type-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const base = path.join(root, "node_modules/@xterm/addon-image");
fs.mkdirSync(path.join(base, "lib"), { recursive: true });
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
for (const file of new Set(PATCHES.map(p => p.file))) {
fs.writeFileSync(path.join(base, file), PATCHES.filter(p => p.file === file).map(p => p.original).join("\n"));
}
return { root, base };
}
test("install patches both bundles, invalidates Vite cache and is idempotent", t => {
const { root, base } = fixture(t);
const cache = path.join(root, "node_modules/.vite");
fs.mkdirSync(cache);
assert.deepEqual(patchInstalledImage(root), { changed: 4, checked: 4 });
assert.equal(fs.existsSync(cache), false);
for (const patch of PATCHES) assert.ok(fs.readFileSync(path.join(base, patch.file), "utf8").includes(patch.replacement));
assert.deepEqual(patchInstalledImage(root), { changed: 0, checked: 4 });
});
test("install fails closed on a changed version or bundle before writing anything", t => {
const { root, base } = fixture(t);
const cjs = path.join(base, "lib/addon-image.js");
const original = fs.readFileSync(cjs, "utf8");
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: "future" }));
assert.throws(() => patchInstalledImage(root), /unsupported @xterm\/addon-image version/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
fs.writeFileSync(path.join(base, "lib/addon-image.mjs"), "upstream changed");
assert.throws(() => patchInstalledImage(root), /unexpected image-iip-type patch shape/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
});
// Use the shipped handler and WASM decoder, with only browser image decoding and
// storage mocked. The desktop smoke covers the actual bitmap rendering.
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==", "base64");
function setup(ImageAddon) {
const event = () => ({ dispose() {} });
const term = {
cols: 80, rows: 24,
_core: {
open() {},
_coreBrowserService: { window: { document: {} } },
optionsService: { onOptionChange: event },
_inputHandler: { onRequestReset: event, _parser: { registerOscHandler: event } },
},
parser: { registerCsiHandler: event, registerEscHandler: event },
buffer: { active: { type: "normal" }, onBufferChange: event },
onRender: event, onResize: event,
};
const addon = new ImageAddon({ enableSizeReports: false, sixelSupport: false, kittySupport: false });
addon.activate(term);
const images = [];
addon._storage.addImage = image => images.push(image);
return { addon, handler: addon._handlers.get("iip"), images };
}
async function send(handler, data, chunkSize) {
const codes = Uint32Array.from(data, c => c.charCodeAt(0));
handler.start();
for (let start = 0; start < codes.length; start += chunkSize) {
handler.put(codes, start, Math.min(start + chunkSize, codes.length));
}
await handler.end(true);
}
for (const format of ["js", "mjs"]) {
test(`${format}: type metadata preserves FILE and multipart decoding, whole and bytewise`, async t => {
const file = path.resolve(__dirname, `../node_modules/@xterm/addon-image/lib/addon-image.${format}`);
const { ImageAddon } = format === "js" ? require(file) : await import(pathToFileURL(file).href);
const originalBitmap = Object.getOwnPropertyDescriptor(globalThis, "createImageBitmap");
globalThis.createImageBitmap = async (blob, options) => ({ blob, ...options });
t.after(() => {
if (originalBitmap) Object.defineProperty(globalThis, "createImageBitmap", originalBitmap);
else delete globalThis.createImageBitmap;
});
for (const chunkSize of [4096, 1]) {
const { addon, handler, images } = setup(ImageAddon);
const body = PNG.toString("base64");
const headers = [
"inline=1", // unchanged standard header
"inline=1;type=image/png", // reporter's final metadata field
"type=image/png;inline=1", // do not prevent subsequent known fields
"inline=1;type=application/octet-stream", // no MIME hardcoding
];
for (const header of headers) {
const count = images.length;
await send(handler, `File=size=${PNG.length};${header}:${body}`, chunkSize);
assert.equal(images.length, count + 1, header);
assert.equal(handler._hp.fields.type, 1);
assert.equal(images.at(-1).blob.type, "image/png", "image type still comes from payload bytes");
assert.deepEqual(Buffer.from(await images.at(-1).blob.arrayBuffer()), PNG);
}
const count = images.length;
await send(handler, `MultipartFile=inline=1;size=${PNG.length};type=image/png`, chunkSize);
assert.equal(handler._hp.fields.type, 2, "final type value must not break HeaderParser.end()");
await send(handler, `FilePart=${body}`, chunkSize);
await send(handler, "FileEnd", chunkSize);
assert.equal(images.length, count + 1);
assert.deepEqual(Buffer.from(await images.at(-1).blob.arrayBuffer()), PNG);
await send(handler, `File=inline=0;type=image/png:${body}`, chunkSize);
assert.equal(images.length, count + 1, "non-inline files stay suppressed");
addon.dispose();
}
});
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/* global process, console */
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const EXPECTED_VERSION = "0.10.0-beta.292";
// ImageStorage.addImage assigns tile IDs using ceil(orig.width/origCellSize.width).
// Keep that stride for draw, extractTile and viewportResize. Recomputing it from
// the rescaled canvas can add a column: ceil(400 * 9.51 / 10) / 9.51 > 40.
// DOM cell widths can change fractionally on window resize, shifting each image
// row one more tile and producing diagonal corruption (Netcatty #3746).
// Local pinned-version workaround pending an upstream release, not a merged
// upstream backport. The open draft uses the same original-grid principle:
// https://github.com/xtermjs/xterm.js/issues/5861
// https://github.com/xtermjs/xterm.js/pull/6145
// Patch both runtime bundles using the existing install-time patch pattern.
const PATCHES = [
{
file: "lib/addon-image.js",
original: "const n=e.actual,h=Math.ceil(n.width/o),",
replacement: "const n=e.actual,h=Math.ceil(e.orig.width/e.origCellSize.width),",
},
{
file: "lib/addon-image.js",
original: "const s=e.actual,o=Math.ceil(s.width/A),",
replacement: "const s=e.actual,o=Math.ceil(e.orig.width/e.origCellSize.width),",
},
{
file: "lib/addon-image.js",
original: "const h=Math.ceil(((null===(t=n.actual)||void 0===t?void 0:t.width)||0)/n.actualCellSize.width);",
replacement: "const h=Math.ceil(((null===(t=n.orig)||void 0===t?void 0:t.width)||0)/n.origCellSize.width);",
},
{
file: "lib/addon-image.mjs",
original: "let h=t.actual,g=Math.ceil(h.width/o),",
replacement: "let h=t.actual,g=Math.ceil(t.orig.width/t.origCellSize.width),",
},
{
file: "lib/addon-image.mjs",
original: "let a=t.actual,n=Math.ceil(a.width/A),",
replacement: "let a=t.actual,n=Math.ceil(t.orig.width/t.origCellSize.width),",
},
{
file: "lib/addon-image.mjs",
original: "let h=Math.ceil((d.actual?.width||0)/d.actualCellSize.width);",
replacement: "let h=Math.ceil((d.orig?.width||0)/d.origCellSize.width);",
},
];
function patchImageSource(source, patch) {
const originalCount = source.split(patch.original).length - 1;
const patchedCount = source.split(patch.replacement).length - 1;
if (originalCount === 1 && patchedCount === 0) {
return { source: source.replace(patch.original, patch.replacement), changed: true };
}
if (originalCount === 0 && patchedCount === 1) return { source, changed: false };
throw new Error(
`${patch.file}: unexpected image-tile-stride patch shape ` +
`(original=${originalCount}, patched=${patchedCount})`,
);
}
function patchInstalledImage(root = process.cwd()) {
const base = path.resolve(root, "node_modules/@xterm/addon-image");
const version = JSON.parse(fs.readFileSync(path.join(base, "package.json"), "utf8")).version;
if (version !== EXPECTED_VERSION) {
throw new Error(`unsupported @xterm/addon-image version ${version}; expected ${EXPECTED_VERSION}`);
}
// Validate both complete bundles before writing either one.
const files = new Map();
let changed = 0;
for (const patch of PATCHES) {
const source = files.get(patch.file) ?? fs.readFileSync(path.join(base, patch.file), "utf8");
const result = patchImageSource(source, patch);
files.set(patch.file, result.source);
if (result.changed) changed++;
}
if (changed) {
for (const [file, source] of files) fs.writeFileSync(path.join(base, file), source);
fs.rmSync(path.resolve(root, "node_modules/.vite"), { recursive: true, force: true });
}
return { changed, checked: PATCHES.length };
}
if (require.main === module) {
try {
const result = patchInstalledImage();
console.log(`[patch-xterm-image-tile-stride] changed=${result.changed} checked=${result.checked}`);
} catch (error) {
console.error(`[patch-xterm-image-tile-stride] ERROR: ${error.message}`);
process.exitCode = 1;
}
}
module.exports = { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage };
@@ -0,0 +1,148 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { pathToFileURL } = require("node:url");
const { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage } = require("./patch-xterm-image-tile-stride.cjs");
for (const patch of PATCHES) {
test(`${patch.file}: ${patch.original.slice(0, 24)} patches once and rejects drift`, () => {
const first = patchImageSource(`before;${patch.original}after`, patch);
assert.equal(first.changed, true);
assert.equal(first.source, `before;${patch.replacement}after`);
assert.deepEqual(patchImageSource(first.source, patch), { source: first.source, changed: false });
for (const source of ["upstream changed", patch.original.repeat(2), patch.replacement.repeat(2), patch.original + patch.replacement]) {
assert.throws(() => patchImageSource(source, patch), /unexpected image-tile-stride patch shape/);
}
});
}
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-image-stride-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const base = path.join(root, "node_modules/@xterm/addon-image");
fs.mkdirSync(path.join(base, "lib"), { recursive: true });
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
for (const file of new Set(PATCHES.map(p => p.file))) {
fs.writeFileSync(path.join(base, file), PATCHES.filter(p => p.file === file).map(p => p.original).join("\n"));
}
return { root, base };
}
test("install patches both bundles, invalidates Vite cache and is idempotent", t => {
const { root, base } = fixture(t);
const cache = path.join(root, "node_modules/.vite");
fs.mkdirSync(cache);
assert.deepEqual(patchInstalledImage(root), { changed: 6, checked: 6 });
assert.equal(fs.existsSync(cache), false);
for (const patch of PATCHES) assert.ok(fs.readFileSync(path.join(base, patch.file), "utf8").includes(patch.replacement));
assert.deepEqual(patchInstalledImage(root), { changed: 0, checked: 6 });
});
test("install fails closed on a changed version or bundle before writing anything", t => {
const { root, base } = fixture(t);
const cjs = path.join(base, "lib/addon-image.js");
const original = fs.readFileSync(cjs, "utf8");
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: "future" }));
assert.throws(() => patchInstalledImage(root), /unsupported @xterm\/addon-image version/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
fs.writeFileSync(path.join(base, "lib/addon-image.mjs"), "upstream changed");
assert.throws(() => patchInstalledImage(root), /unexpected image-tile-stride patch shape/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
});
// Exercise the installed addon itself, not a copy of its stride expression.
// Canvas calls are captured so these regressions also run in the normal Node suite.
function canvas(width, height) {
const calls = [];
const element = { width, height, calls, remove() {} };
element.getContext = () => ({ canvas: element, drawImage: (...args) => calls.push(args), clearRect() {} });
return element;
}
function setup(ImageAddon, { imageWidth = 400, cellWidth = 10, cols = 80, layer = "top" } = {}) {
const event = () => ({ dispose() {} });
const lines = Array.from({ length: 24 }, () => ({
_data: new Uint32Array(100 * 3), _extendedAttrs: {},
getBg(x) { return this._data[x * 3 + 2]; },
}));
const cell = { width: cellWidth, height: 20 };
const buffer = { x: 0, y: 0, ybase: 0, lines: { get: y => lines[y], length: lines.length } };
const term = {
cols, rows: 24, dimensions: { css: { cell } },
_core: {
open() {}, buffer,
_coreBrowserService: { window: { document: { createElement: () => canvas(0, 0) } } },
optionsService: { onOptionChange: event },
_inputHandler: { onRequestReset: event, _dirtyRowTracker: { markDirty() {} }, lineFeed: () => buffer.y++ },
},
parser: { registerCsiHandler: event, registerEscHandler: event },
buffer: { active: { type: "normal" }, onBufferChange: event },
onRender: event, onResize: event,
registerMarker: () => ({ onDispose() {}, dispose() {} }),
};
const addon = new ImageAddon({ enableSizeReports: false, sixelSupport: false, iipSupport: false, kittySupport: false });
addon.activate(term);
const image = canvas(imageWidth, 400);
const imageId = addon._storage.addImage(image, { scrolling: true, layer, zIndex: 0, cursorPos: "vt340" });
const spec = addon._storage._images.get(imageId);
const output = canvas(1000, 480);
addon._renderer._layers.set(layer, output.getContext("2d"));
return { addon, cell, spec, output, lines };
}
for (const format of ["js", "mjs"]) {
const loadAddon = async () => {
const file = path.resolve(__dirname, `../node_modules/@xterm/addon-image/lib/addon-image.${format}`);
return format === "js" ? require(file) : import(pathToFileURL(file).href);
};
for (const layer of ["top", "bottom"]) {
test(`${format}: ${layer} image rows and extracted tiles keep their original stride through repeated resizes`, async () => {
const { ImageAddon } = await loadAddon();
for (const options of [{}, { imageWidth: 397 }, { imageWidth: 384, cellWidth: 9.6 }]) {
const { addon, cell, spec, output, lines } = setup(ImageAddon, { ...options, layer });
const stride = Math.ceil(spec.orig.width / spec.origCellSize.width);
assert.equal(stride, 40);
assert.equal(lines[1]._extendedAttrs[0].tileId, stride);
const originalWidth = cell.width;
for (const width of [originalWidth, 9.51, 10.02, 12, 8.73, originalWidth]) {
cell.width = width;
for (const tileId of [40, 41, 79, 80, 799]) {
addon._renderer.draw(spec, tileId, 3, 2);
const [, sx, sy, , , dx, dy] = output.calls.at(-1);
assert.equal(sx, Math.floor((tileId % stride) * width), `draw x: tile=${tileId}, width=${width}`);
assert.equal(sy, Math.floor(tileId / stride) * cell.height, `draw y: tile=${tileId}, width=${width}`);
assert.equal(dx, Math.floor(3 * width));
assert.equal(dy, 2 * cell.height);
const tile = addon._renderer.extractTile(spec, tileId);
assert.equal(tile.calls[0][1], sx, "extractTile uses the same source column as draw");
assert.equal(tile.calls[0][2], sy, "extractTile uses the same source row as draw");
}
addon._renderer.draw(spec, stride, 0, 1, stride);
assert.equal(output.calls.at(-1)[1], 0, "batched second row starts at the left edge");
assert.equal(output.calls.at(-1)[2], cell.height);
}
assert.equal(spec.actual, spec.orig, "restoring cell metrics restores the original image");
}
});
}
test(`${format}: viewport growth after rescaling does not append the next image row`, async () => {
const { ImageAddon } = await loadAddon();
for (const cols of [20, 40]) {
const { addon, cell, spec, lines } = setup(ImageAddon, { cols });
cell.width = 9.51;
addon._renderer.draw(spec, 0, 0, 0);
assert.equal(spec.actual.width, 381, "rescaled integer canvas would incorrectly imply 41 columns");
addon._storage.viewportResize({ cols: 50, rows: 24 });
for (let row = 0; row < 20; row++) {
for (let col = 0; col < 40; col++) assert.equal(lines[row]._extendedAttrs[col].tileId, row * 40 + col);
assert.equal(lines[row]._extendedAttrs[40], undefined, "must stop at the original row boundary");
}
assert.equal(spec.tileCount, 800);
}
});
}
+103 -50
View File
@@ -6,21 +6,32 @@ const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([
"COLLABORATOR",
]);
const DANGEROUS_FILE_EXTENSION =
"(?:zip|7z|rar|tar\\.gz|tgz|exe|msi|dmg|pkg|deb|rpm|appimage|bat|cmd|ps1|scr|vbs)";
const RISKY_EXTENSIONS = [
".tar.gz",
".tar.xz",
".appimage",
".msix",
".zip",
".7z",
".rar",
".tgz",
".exe",
".msi",
".dmg",
".pkg",
".deb",
".rpm",
".bat",
".cmd",
".ps1",
".scr",
".vbs",
".jar",
".iso",
".lnk",
];
const dangerousFilePattern = new RegExp(
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.${DANGEROUS_FILE_EXTENSION})(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
"gi"
);
const zipFilePattern = new RegExp(
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.zip)(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
"gi"
);
const githubUserAttachmentPattern =
/^https:\/\/github\.com\/user-attachments\/files\/\d+\//i;
const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
function normalizeAssociation(authorAssociation) {
return String(authorAssociation || "").trim().toUpperCase();
@@ -30,65 +41,107 @@ function isTrustedAuthor(authorAssociation) {
return TRUSTED_AUTHOR_ASSOCIATIONS.has(normalizeAssociation(authorAssociation));
}
function extractMatches(body, pattern) {
const files = [];
for (const match of body.matchAll(pattern)) {
files.push(match[1]);
function isSameLogin(left, right) {
const a = String(left || "").trim().toLowerCase();
const b = String(right || "").trim().toLowerCase();
return a.length > 0 && a === b;
}
function trimUrlCandidate(raw) {
let value = String(raw || "");
while (value.length > 0) {
const last = value[value.length - 1];
if (".,;:!?\"']".includes(last)) {
value = value.slice(0, -1);
continue;
}
if (last === ")") {
const opens = (value.match(/\(/g) || []).length;
const closes = (value.match(/\)/g) || []).length;
if (closes > opens) {
value = value.slice(0, -1);
continue;
}
}
break;
}
return [...new Set(files)];
return value;
}
function extractDangerousFiles(body) {
return extractMatches(body, dangerousFilePattern);
function hasRiskyExtension(segment) {
const name = String(segment || "").toLowerCase();
if (!name || name === "." || name === "..") return false;
return RISKY_EXTENSIONS.some((extension) => name.endsWith(extension));
}
function extractZipFiles(body) {
return extractMatches(body, zipFilePattern);
function urlHasRiskyPath(raw) {
let parsed;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
let pathname = parsed.pathname || "";
try {
pathname = decodeURIComponent(pathname);
} catch {
// Keep the raw pathname when it is not valid percent-encoding.
}
return pathname.split("/").some((segment) => hasRiskyExtension(segment));
}
function isGitHubUserAttachment(file) {
return githubUserAttachmentPattern.test(file);
function extractRiskyUrls(body) {
const text = String(body || "");
const found = [];
const seen = new Set();
for (const match of text.matchAll(URL_PATTERN)) {
const raw = trimUrlCandidate(match[0]);
if (!raw || seen.has(raw) || !urlHasRiskyPath(raw)) continue;
seen.add(raw);
found.push(raw);
}
return found;
}
function detectSpamComment({ body, authorAssociation, userType } = {}) {
const normalizedBody = String(body || "").replace(/\s+/g, " ").trim();
const dangerousFiles = extractDangerousFiles(normalizedBody);
const zipFiles = extractZipFiles(normalizedBody);
function detectSpamComment({
body,
authorAssociation,
userType,
commentAuthor,
issueAuthor,
} = {}) {
const trustedAuthor = isTrustedAuthor(authorAssociation);
const botAuthor = String(userType || "").toLowerCase() === "bot";
const botAuthor = String(userType || "").trim().toLowerCase() === "bot";
const authorIsIssueAuthor = isSameLogin(commentAuthor, issueAuthor);
const matchedUrls = extractRiskyUrls(body);
const reasons = [];
let score = 0;
if (zipFiles.length > 0) {
score += 10;
reasons.push(`zip attachment or link: ${zipFiles.join(", ")}`);
} else if (dangerousFiles.length > 0) {
score += 10;
reasons.push(`dangerous downloadable file: ${dangerousFiles.join(", ")}`);
}
// Hard rule: any zip (or other dangerous downloadable) from an untrusted
// human is deleted. Maintainers and bots are exempt.
// Hard rule: hide when an untrusted human who is not the issue author posts
// an http(s) URL whose path contains a risky archive or executable.
// Maintainers, bots, and the issue author are exempt. Bare filenames and
// local paths are not links, so they do not match.
const spam =
!trustedAuthor &&
!botAuthor &&
(zipFiles.length > 0 || dangerousFiles.length > 0);
!authorIsIssueAuthor &&
matchedUrls.length > 0;
return {
spam,
score: spam ? score : 0,
reasons: spam ? reasons : [],
dangerousFiles: zipFiles.length > 0 ? zipFiles : dangerousFiles,
score: spam ? 10 : 0,
reasons: spam ? [`risky download URL: ${matchedUrls.join(", ")}`] : [],
matchedUrls,
trustedAuthor,
botAuthor,
authorIsIssueAuthor,
};
}
module.exports = {
detectSpamComment,
extractDangerousFiles,
extractZipFiles,
isGitHubUserAttachment,
extractRiskyUrls,
isTrustedAuthor,
RISKY_EXTENSIONS,
};
+139 -88
View File
@@ -2,113 +2,164 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const {
detectSpamComment,
extractDangerousFiles,
extractZipFiles,
isGitHubUserAttachment,
} = require("./spam-comment-filter.cjs");
const { detectSpamComment, extractRiskyUrls } = require("./spam-comment-filter.cjs");
test("flags the fake Netcatty patch spam pattern", () => {
test("issue author pasting a local SecureCRT.exe path is not hidden", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "[netcatty_patch.zip](https://example.com/netcatty_patch.zip)\n\nMan, that terminal rendering bug is such a pain. It looks like the sftp module is tripping over the encoding when it tries to sync the current path. I found a quick patch that fixes the character mapping in the backend so those black boxes finally disappear.",
});
assert.equal(result.spam, true);
});
test("flags fake patch spam when the filename ends a sentence", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "Download this patch.zip. It fixes the backend encoding so the terminal rendering black boxes disappear.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.dangerousFiles, ["patch.zip"]);
});
test("flags any zip from an outside user, including hash-named soft bait", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "[63bf1862b52422e38b8cb170.zip](https://github.com/user-attachments/files/29784176/63bf1862b52422e38b8cb170.zip)\nI'd start with the docs inside",
});
assert.equal(result.spam, true);
assert.ok(
result.dangerousFiles.some((file) => file.includes("63bf1862b52422e38b8cb170.zip"))
);
});
test("flags ordinary zip log attachments from outside users", () => {
const result = detectSpamComment({
authorAssociation: "FIRST_TIME_CONTRIBUTOR",
userType: "User",
body: "[debug-logs.zip](https://github.com/user-attachments/files/29784176/debug-logs.zip)\nI attached logs from a failed connection. The app hangs after I click connect, and the logs show the SSH handshake timing out.",
});
assert.equal(result.spam, true);
});
test("flags other dangerous archives from outside users", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "See hotfix.dmg for the workaround.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.dangerousFiles, ["hotfix.dmg"]);
});
test("does not flag trusted maintainers even when sharing zip archives", () => {
const result = detectSpamComment({
authorAssociation: "OWNER",
userType: "User",
body: "Try this temporary netcatty_patch.zip while I prepare the signed release. It fixes the rendering issue.",
commentAuthor: "reporter",
issueAuthor: "reporter",
body: "It crashes here: C:\\Program Files\\SecureCRT\\SecureCRT.exe",
});
assert.equal(result.spam, false);
assert.deepEqual(result.matchedUrls, []);
});
test("does not flag bot comments that mention zip files", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "Bot",
body: "Uploaded build-artifacts.zip for CI.",
});
assert.equal(result.spam, false);
});
test("does not flag comments without downloadable archives", () => {
test("bare Windows path without a URL is not hidden even from a third party", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "I attached screenshots of the hang after connect. The SSH handshake times out.",
commentAuthor: "mallory",
issueAuthor: "reporter",
body: "Try Files\\SecureCRT\\SecureCRT.exe and also hotfix.dmg.",
});
assert.equal(result.spam, false);
assert.deepEqual(result.matchedUrls, []);
});
test("extracts risky file names from markdown links and plain text", () => {
assert.deepEqual(
extractDangerousFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."),
["fix.zip", "https://example.com/fix.zip", "hotfix.dmg"]
);
assert.deepEqual(extractZipFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."), [
"fix.zip",
"https://example.com/fix.zip",
test("third party github user-attachments zip URL is hidden", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
commentAuthor: "mallory",
issueAuthor: "reporter",
body: "Grab https://github.com/user-attachments/files/123/fix.zip and run it.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.matchedUrls, [
"https://github.com/user-attachments/files/123/fix.zip",
]);
});
test("identifies GitHub user attachment URLs", () => {
assert.equal(
isGitHubUserAttachment("https://github.com/user-attachments/files/29784176/netcatty_fix.zip"),
true
test("third party mediafire zip URL is hidden when the extension is a path segment", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
commentAuthor: "mallory",
issueAuthor: "reporter",
body: "Download https://www.mediafire.com/file/abc/netcatty_fix.zip/file",
});
assert.equal(result.spam, true);
assert.deepEqual(result.matchedUrls, [
"https://www.mediafire.com/file/abc/netcatty_fix.zip/file",
]);
});
test("maintainer posting a release dmg link is not hidden", () => {
const result = detectSpamComment({
authorAssociation: "OWNER",
userType: "User",
commentAuthor: "maintainer",
issueAuthor: "reporter",
body: "Signed build: https://github.com/binaricat/Netcatty/releases/download/v1.2.3/Netcatty.dmg",
});
assert.equal(result.spam, false);
assert.equal(result.trustedAuthor, true);
assert.deepEqual(result.matchedUrls, [
"https://github.com/binaricat/Netcatty/releases/download/v1.2.3/Netcatty.dmg",
]);
});
test("issue author uploading their own repro zip is not hidden", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
commentAuthor: "Reporter",
issueAuthor: "reporter",
body: "[repro.zip](https://github.com/user-attachments/files/99/repro.zip)",
});
assert.equal(result.spam, false);
assert.equal(result.authorIsIssueAuthor, true);
assert.deepEqual(result.matchedUrls, [
"https://github.com/user-attachments/files/99/repro.zip",
]);
});
test("query strings do not block a risky path, and a query-only filename does not match", () => {
assert.deepEqual(
extractRiskyUrls("https://example.com/dropper.exe?download=1&arch=win"),
["https://example.com/dropper.exe?download=1&arch=win"]
);
assert.equal(isGitHubUserAttachment("https://example.com/netcatty_fix.zip"), false);
assert.deepEqual(
extractRiskyUrls("https://example.com/download?file=fix.zip"),
[]
);
});
test("matches newly added extensions and ignores non-risky links", () => {
const body = [
"https://example.com/a.jar",
"https://example.com/b.iso",
"https://example.com/c.msix",
"https://example.com/d.lnk",
"https://example.com/e.tar.xz",
"https://example.com/f.tar.gz",
"https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00",
"https://example.com/package.json",
"https://example.com/notes.zip.txt",
].join("\n");
assert.deepEqual(extractRiskyUrls(body), [
"https://example.com/a.jar",
"https://example.com/b.iso",
"https://example.com/c.msix",
"https://example.com/d.lnk",
"https://example.com/e.tar.xz",
"https://example.com/f.tar.gz",
]);
});
test("member and collaborator exemptions still apply to http links", () => {
for (const authorAssociation of ["MEMBER", "COLLABORATOR"]) {
const result = detectSpamComment({
authorAssociation,
userType: "User",
commentAuthor: "maintainer",
issueAuthor: "reporter",
body: "Mirror: http://example.com/setup.msi",
});
assert.equal(result.spam, false);
assert.deepEqual(result.matchedUrls, ["http://example.com/setup.msi"]);
}
});
test("does not flag bot comments that include a risky URL", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "Bot",
commentAuthor: "netcatty[bot]",
issueAuthor: "reporter",
body: "Uploaded https://example.com/build-artifacts.zip for CI.",
});
assert.equal(result.spam, false);
assert.equal(result.botAuthor, true);
});
test("strips markdown and sentence punctuation around the URL", () => {
assert.deepEqual(
extractRiskyUrls("See [patch](https://example.com/patch.zip). Thanks!"),
["https://example.com/patch.zip"]
);
assert.deepEqual(
extractRiskyUrls("<https://example.com/tool.EXE>"),
["https://example.com/tool.EXE"]
);
});
+100
View File
@@ -0,0 +1,100 @@
"use strict";
/* global process, __dirname, console, document, requestAnimationFrame */
if (!process.versions.electron) {
require("node:test")("inline image pixels stay aligned across terminal resizes", {
skip: "run with Electron to exercise real DOM and canvas rendering",
}, () => {});
} else {
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const root = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("image-resize")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
let win;
const cleanup = code => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
async function exercise({ Terminal, ImageAddon }) {
const check = (actual, expected, label) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
};
const host = document.createElement("div");
document.body.appendChild(host);
const term = new Terminal({ cols: 40, rows: 18, fontFamily: "monospace", fontSize: 16, cursorBlink: false, allowProposedApi: true });
term.open(host);
const addon = new ImageAddon({ sixelSupport: false, iipSupport: false, kittySupport: false });
term.loadAddon(addon);
const render = async () => {
term.refresh(0, term.rows - 1);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
await render();
const originalCell = { ...addon._renderer.cellSize };
const source = document.createElement("canvas");
source.width = Math.round(originalCell.width * 40);
source.height = Math.round(originalCell.height * 8);
check(Math.ceil(source.width / originalCell.width), 40, "fixture covers 40 image columns");
const context = source.getContext("2d");
const color = (col, row) => [32 + (col % 7) * 32, 32 + row * 24, 192, 255];
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 40; col++) {
const [r, g, b] = color(col, row);
context.fillStyle = `rgb(${r},${g},${b})`;
context.fillRect(Math.floor(col * originalCell.width), Math.floor(row * originalCell.height), Math.ceil(originalCell.width), Math.ceil(originalCell.height));
}
}
// Insert once into the real terminal buffer. All subsequent frames must use
// the original tile IDs, just like an image already printed by any protocol.
const id = addon._storage.addImage(source, { scrolling: true, layer: "top", zIndex: 0, cursorPos: "vt340" });
const spec = addon._storage._images.get(id);
const samples = [];
let roundedStrideChanged = false;
for (const cols of [40, 53, 67, 91, 40, 53, 40]) {
term.resize(cols, 18);
await render();
const cell = addon._renderer.cellSize;
const badStride = Math.ceil(spec.actual.width / cell.width);
roundedStrideChanged ||= badStride !== 40;
const output = host.querySelector(".xterm-image-layer-top").getContext("2d");
for (const row of [0, 1, 2, 7]) {
for (const col of [0, 1, 5, 20, 38]) {
const pixel = [...output.getImageData(Math.floor((col + 0.5) * cell.width), Math.floor((row + 0.5) * cell.height), 1, 1).data];
check(pixel, color(col, row), `canvas pixel at cols=${cols}, cell=${col},${row}`);
const tile = addon.extractTileAtBufferCell(col, row);
const extracted = [...tile.getContext("2d").getImageData(Math.floor(tile.width / 2), Math.floor(tile.height / 2), 1, 1).data];
check(extracted, color(col, row), `extracted tile at cols=${cols}, cell=${col},${row}`);
}
}
samples.push({ cols, cellWidth: cell.width, canvasWidth: spec.actual.width, roundedStride: badStride });
}
check(roundedStrideChanged, true, "exercise fractional cell widths that triggered the regression");
term.dispose();
host.remove();
return { samples, pixelChecks: 280, repeatedResizes: true };
}
void app.whenReady().then(async () => {
const bundle = esbuild.buildSync({
stdin: {
contents: 'export { Terminal } from "@xterm/xterm"; export { ImageAddon } from "@xterm/addon-image";',
resolveDir: root,
},
bundle: true, format: "iife", globalName: "ImageResizeTest", platform: "browser", write: false,
}).outputFiles[0].text;
win = new BrowserWindow({ show: true, width: 1000, height: 600, webPreferences: { backgroundThrottling: false } });
await win.loadURL("data:text/html,<body style='margin:0;background:black'>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
const result = await win.webContents.executeJavaScript(`${bundle}\n(${exercise.toString()})(ImageResizeTest)`);
console.log("IMAGE_RESIZE_RENDERING_OK", JSON.stringify(result));
cleanup(0);
}).catch(error => { console.error(error); cleanup(1); });
}