Compare commits

..
Author SHA1 Message Date
bincxz 8cbcb73b61 fix(ssh): retain vnode directory cwd fallback 2026-09-18 11:49:37 +08:00
bincxz d3eb3007bb fix(ssh): reject lsof diagnostics as terminal directories 2026-09-18 11:45:25 +08:00
陈大猫 c16f0c43d4 Merge pull request #3401 from OOKAMISAMA/main
feat(tab): Add configurable terminal tab double-click behavior
2026-09-15 22:52:12 +08:00
OOKAMI 4df94736d0 Update TopTabs.test.ts 2026-09-15 17:55:59 +08:00
OOKAMI 91c3defe66 Add configurable terminal tab double-click behavior
Introduce a new TerminalTabDoubleClickBehavior setting (duplicate | copy | disabled) with default 'duplicate'. Add model field, normalization, default, and sync key (tabDoubleClickBehavior). UI: TerminalBehaviorSettings adds a Select; AppView and TopTabs pass the setting through; TopTabItems implements createTopTabSessionDoubleClickHandler to perform duplicate/copy with a safe fallback. Add i18n strings, settings search entry, and update tests to cover behavior and serialization. Improves user control over double-click actions on session tabs.
2026-09-15 16:56:55 +08:00
陈大猫 faf74ae9be Merge pull request #3395 from binaricat/ai/issue-3394-34924104389
fix(ai): replay signed Anthropic thinking blocks in Catty follow-up turns
2026-09-15 13:47:20 +08:00
陈大猫 cbace2f2ac Merge pull request #3389 from lengyuqu/codebuddy
feat: upgrade CodeBuddy SDK integration
2026-09-15 13:46:41 +08:00
netcatty-bot a52726aef4 fix(#3394): automated AI fix 2026-09-15 03:36:18 +00:00
lengyuqu 605dc94b13 fix: clarify CodeBuddy session persistence scope 2026-09-14 17:02:06 +08:00
lengyuqu 9e74618f2e feat: upgrade CodeBuddy SDK integration 2026-09-14 16:38:33 +08:00
github-actions[bot]andgithub-actions[bot] b46910d46e chore: update star history charts [skip ci] (#3383)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-14 00:11:12 +00:00
陈大猫 d8ea77c28b Merge pull request #3373 from binaricat/codex/3135-custom-provider-headers
feat(ai): configure custom provider HTTP headers securely
2026-09-13 16:37:09 +08:00
bincxz 210879b848 fix: prepare sync credentials before protected apply begins 2026-09-13 16:22:11 +08:00
bincxz db0238ce6e fix: prepare provider headers before applying sync data 2026-09-13 16:07:26 +08:00
bincxz 6f30bbefb5 Merge main into custom provider headers PR 2026-09-13 15:46:12 +08:00
陈大猫 843c81ca44 Merge pull request #3376 from binaricat/ai/issue-3375-34697848012
fix(sftp): expand tree folders in place on double click
2026-09-13 15:44:39 +08:00
陈大猫 f1e59913c2 Merge pull request #3368 from binaricat/codex/issue-3153-right-long-press
feat(terminal): add optional right-click hold menu
2026-09-13 15:44:05 +08:00
bincxz a95eb62cb1 fix(sftp): isolate tree arrow double clicks and test real events 2026-09-13 15:32:32 +08:00
bincxz 7205c40a7c Merge latest main into right-click hold menu PR 2026-09-13 15:31:53 +08:00
陈大猫 7cf8ebac7e Merge pull request #3359 from binaricat/ai/issue-3358-34674962452
feat(ai): add reasoning effort option to provider advanced params
2026-09-13 15:29:24 +08:00
陈大猫 48c721bf43 Merge pull request #3372 from binaricat/codex/issue-3151-group-notes
feat: add Markdown notes to host groups
2026-09-13 15:29:20 +08:00
陈大猫 756f332db5 Merge pull request #3371 from binaricat/codex/issue-2383-sftp-command-refresh
feat(sftp): refresh visible sidebar after terminal commands
2026-09-13 15:29:16 +08:00
陈大猫 2ee55d5b4b Merge pull request #3370 from binaricat/codex/3154-terminal-read-context
feat: expose scoped terminal screen reads to MCP
2026-09-13 15:29:12 +08:00
陈大猫 2e588630a1 Merge pull request #3366 from binaricat/codex/issue-2407-tabs-position
feat(ui): allow placing the tab bar at the bottom
2026-09-13 15:29:07 +08:00
陈大猫 e9cc0d54d2 Merge pull request #3367 from binaricat/codex/issue-2450-ip-sort
feat: add optional IP sorting for vault hosts
2026-09-13 15:29:03 +08:00
陈大猫 57cd40a192 Merge pull request #3365 from binaricat/codex/issue-2645-compose-history
feat: recall compose history within the current terminal session
2026-09-13 15:28:59 +08:00
陈大猫 e5dc30ba2f Merge pull request #3364 from binaricat/codex/issue-3273-save-terminal-screen
feat(terminal): save the current rendered screen
2026-09-13 15:28:55 +08:00
陈大猫 e9d2963fe5 Merge pull request #3362 from binaricat/codex/issue-3139-command-arrows
feat(terminal): support Command+Left/Right line navigation on macOS
2026-09-13 15:28:51 +08:00
陈大猫 d0af56b4dd Merge pull request #3369 from binaricat/codex/issue-3351-unsaved-password-sftp
fix(sftp): reuse terminal login with an unsaved password
2026-09-13 15:28:47 +08:00
陈大猫 09f7fcf248 Merge pull request #3363 from binaricat/codex/issue-2326-tab-menu-order
fix(tabs): reorder session menu to reduce accidental reconnects
2026-09-13 15:28:42 +08:00
陈大猫 9e63ffd99e Merge pull request #3361 from binaricat/codex/issue-2773-scoop-docs
docs: add community Scoop installation instructions
2026-09-13 15:28:38 +08:00
netcatty-bot f24b5f4202 fix(#3375): automated AI fix 2026-09-12 14:14:36 +00:00
bincxz 26278a8694 fix(ai): reject transport-owned custom framing headers 2026-09-12 18:08:55 +08:00
bincxz e10e5ccbc2 Revert "fix: address Codex review on PR #3373"
This reverts commit dd5749460f.
2026-09-12 17:59:28 +08:00
bincxz d9ca951cc9 fix: restore compose draft when leaving session history 2026-09-12 17:47:27 +08:00
bincxz 00ec0d04c4 fix: keep compose history browsing stable during late sends 2026-09-12 17:45:00 +08:00
netcatty-bot dd5749460f fix: address Codex review on PR #3373 2026-09-12 09:43:28 +00:00
bincxz 00c9cf440f fix: record only deliverable workspace compose sends 2026-09-12 17:36:55 +08:00
bincxz 6cdae7af9c fix: keep group notes visible during tree multi-select 2026-09-12 17:28:27 +08:00
bincxz 3b1a4adc82 fix: keep compose history in submission order 2026-09-12 17:27:36 +08:00
bincxz ac65d465cf fix(ai): preserve ChatGPT 4o reasoning guard 2026-09-12 17:22:03 +08:00
bincxz 2738adb2f8 fix: route terminal reads to the owning window 2026-09-12 17:20:59 +08:00
bincxz 688f544034 test(ai): complete custom provider fixture 2026-09-12 17:18:37 +08:00
bincxz f9ad1ddfe0 fix(sftp): retain selection reveal after explicit view changes 2026-09-12 17:17:20 +08:00
bincxz a0d032ec3c fix(terminal): guard password prompts and bypass directory completion for Command arrows 2026-09-12 17:16:38 +08:00
bincxz e9b0dadd2a fix(ai): limit reasoning alias opt-in to custom providers 2026-09-12 17:16:04 +08:00
bincxz be511f8546 test: include screen reads in builtin RPC coverage 2026-09-12 17:15:42 +08:00
bincxz 214bc8e7f1 test(ai): verify custom header removal persists 2026-09-12 17:13:42 +08:00
bincxz 758029bc29 feat(ai): configure and securely store custom provider headers 2026-09-12 17:12:34 +08:00
bincxz 3e17cdd62e feat: add Markdown notes to host groups 2026-09-12 17:10:59 +08:00
bincxz 8b5db49001 fix(terminal): sync hold preference and allow disconnected menu 2026-09-12 17:09:49 +08:00
bincxz 87f794d17d fix: preserve compose drafts and await send results 2026-09-12 17:09:05 +08:00
bincxz cb35353fde test(ai): type provider reasoning request checks 2026-09-12 17:08:48 +08:00
bincxz ce86e8e426 feat(sftp): refresh visible sidebar after terminal commands 2026-09-12 17:07:35 +08:00
bincxz 0714ca5ee0 fix: compare IPv6 host addresses numerically 2026-09-12 17:07:22 +08:00
bincxz b081673c2b feat: expose scoped terminal screen reads to MCP 2026-09-12 17:06:08 +08:00
bincxz 1fc98cd654 fix(ai): keep non-reasoning model defaults compatible 2026-09-12 17:05:27 +08:00
bincxz 28f7193c4a fix(terminal): keep Command arrow edits local during broadcast 2026-09-12 17:03:51 +08:00
bincxz dbdc8631dd fix(sftp): reuse terminal login with an unsaved password 2026-09-12 17:03:41 +08:00
bincxz a05e004259 feat(terminal): add optional right-click hold menu 2026-09-12 17:02:46 +08:00
bincxz cae4169948 fix: translate IP sorting in Spanish 2026-09-12 17:02:45 +08:00
bincxz 3987e465b6 feat: add optional IP sorting for vault hosts 2026-09-12 16:56:25 +08:00
bincxz d8761c156e feat(terminal): save the current rendered screen 2026-09-12 16:55:18 +08:00
bincxz 47bb5c2b55 feat: recall compose bar history within each terminal session 2026-09-12 16:51:52 +08:00
bincxz dc05697ede fix(ai): honor provider reasoning defaults for custom models 2026-09-12 16:50:40 +08:00
bincxz 51d4d97419 feat(terminal): support macOS Command arrows for line navigation 2026-09-12 16:48:31 +08:00
bincxz 530f57c95c fix(tabs): place copy actions before reconnect in session menu 2026-09-12 16:48:20 +08:00
bincxz da0938c209 docs: separate Scoop update and uninstall examples 2026-09-12 16:40:52 +08:00
bincxz 9e6846e0d5 docs: document community Scoop installation for Windows 2026-09-12 16:39:50 +08:00
netcatty-bot 0b96d70702 fix: address Codex review on PR #3359 2026-09-12 05:59:48 +00:00
netcatty-bot 818c6fd551 fix(#3358): automated AI fix 2026-09-12 05:35:51 +00:00
157 changed files with 4055 additions and 297 deletions

No files matched your search

+2 -2
View File
@@ -94,7 +94,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
|---------|-------------------|--------|
| Catty (sidebar) tools | `npm run generate:capability-tools` → `infrastructure/ai/harness/generated/cattyToolSpecs.json` | Sidebar agent tool set. CI verifies JSON drift. |
| Global agent tools | same script → `globalAgentToolSpecs.json` | Prepared for future global agent runtime; shared RPC tools only today. |
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Harness tools are **not** on MCP. |
| MCP stdio | `electron/capabilities/codegen/mcpToolRegistry.cjs` → `electron/mcp/netcatty-mcp-server.cjs` | Registry-driven; external agents. Only `harness.terminal.read_context` is also on MCP. |
| CLI | `electron/cli/netcatty-tool-cli.cjs` + `electron/capabilities/adapters/cliAdapter.cjs` | **30** catalog commands; exec/sftp/session remain special-case; vault/portforward/snippets use catalog fallback dispatch |
| RPC dispatch | `electron/bridges/mcpServerBridge.cjs` + `capabilityRpcDispatch.cjs` | `netcatty/*` builtin handlers via `buildBuiltinRpcHandlerRegistry` (catalog-aligned); `public/*`, `vault/*`, `portforward/*` → services |
| Vault bridge | `electron/bridges/aiBridge/vaultAgentBridge.cjs` + `infrastructure/ai/vaultAgentBridgeClient.ts` | Renderer vault state; **never** returns password/privateKey |
@@ -104,7 +104,7 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
**Handles:** `ToolOutputStore` persists across turns per chat session; cleared on chat session delete. Large `sftp.read` results spill to `tool_output_read`.
**Harness domain (`catalog/harness.cjs`):** Catty-only surface (`surfaces.catty.toolName`). Registered in the capability catalog but executed locally in `capabilityTools.executeLocalCattyCapability` (not MCP/CLI). `harness.web.search` is omitted when web search is not configured.
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
## Plugin host runtime (internal preview)
+24
View File
@@ -220,6 +220,30 @@ Windows リリースは未署名の場合があります。
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
### Scoop(Windows x64、コミュニティ管理)
[Scoop](https://scoop.sh/) をインストール済みの場合、コミュニティが管理する [lemon バケット](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) から Netcatty をインストールできます。このバケットは Netcatty の公式 GitHub Releases からインストーラーをダウンロードします:
```powershell
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
scoop install lemon/netcatty
```
更新またはアンインストールの前に Netcatty を終了してください:
```powershell
scoop update
scoop update netcatty
```
アンインストールする場合:
```powershell
scoop uninstall netcatty
```
これはサードパーティ製パッケージであり、Netcatty の公式バケットではありません。更新は GitHub Releases より遅れる場合があります。現在は x64 のみに対応しています。設定は `%APPDATA%\netcatty` に保存され、更新や通常のアンインストール後も残ります。`scoop uninstall netcatty --purge` はこれらの設定も削除します。この方法でインストールした場合は、Scoop で更新を管理してください。
### Nix / NixOS
Netcatty は Nix および NixOS ユーザー向けに、公式 Linux AppImage リリースをラップした flake を提供しています:
+25
View File
@@ -127,6 +127,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
### 🖥️ Terminal Workspaces
- **Split panes** — horizontal and vertical splits for multi-tasking
- **Session management** — run multiple connections side-by-side
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
### 📁 SFTP + Built-in Editor
@@ -221,6 +222,30 @@ application and integration are complete.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
### Scoop (Windows x64, community-maintained)
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
```powershell
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
scoop install lemon/netcatty
```
Exit Netcatty before updating or uninstalling:
```powershell
scoop update
scoop update netcatty
```
To uninstall:
```powershell
scoop uninstall netcatty
```
This is a third-party package, not an official Netcatty bucket, and updates may lag behind GitHub Releases. It currently supports x64 only. Settings remain in `%APPDATA%\netcatty` across updates and normal uninstalls; `scoop uninstall netcatty --purge` also deletes those settings. Use Scoop to manage updates for this installation.
### Nix / NixOS
Netcatty provides a flake that wraps the official Linux AppImage release for Nix and NixOS users:
+24
View File
@@ -219,6 +219,30 @@ Windows 发布文件可能仍未签名。
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
### Scoop(Windows x64,社区维护)
安装 [Scoop](https://scoop.sh/) 后,可以通过社区维护的 [lemon 软件源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安装 Netcatty。该软件源从 Netcatty 官方 GitHub Releases 下载安装文件:
```powershell
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
scoop install lemon/netcatty
```
更新或卸载前,请先退出 Netcatty:
```powershell
scoop update
scoop update netcatty
```
卸载:
```powershell
scoop uninstall netcatty
```
这是第三方软件包,并非 Netcatty 官方软件源,更新可能晚于 GitHub Releases。目前仅支持 x64。设置保存在 `%APPDATA%\netcatty`,更新和普通卸载会保留这些设置;`scoop uninstall netcatty --purge` 还会删除这些设置。通过此方式安装后,请使用 Scoop 管理更新。
### Nix / NixOS
Netcatty 提供了一个 flake,为 Nix 和 NixOS 用户封装了官方 Linux AppImage 发行版:
+24
View File
@@ -219,6 +219,30 @@ Windows 發行檔可能仍未簽章。
> **macOS 使用者請注意:** 目前的發行版本應該都已完成程式碼簽章與公證。如果 Gatekeeper 仍然跳出警告,請確認你下載的是 GitHub Releases 上最新的官方建置版本。
### Scoop(Windows x64,社群維護)
安裝 [Scoop](https://scoop.sh/) 後,可以透過社群維護的 [lemon 軟體來源](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json) 安裝 Netcatty。此軟體來源從 Netcatty 官方 GitHub Releases 下載安裝檔:
```powershell
scoop bucket add lemon https://github.com/hoilc/scoop-lemon
scoop install lemon/netcatty
```
更新或解除安裝前,請先結束 Netcatty:
```powershell
scoop update
scoop update netcatty
```
解除安裝:
```powershell
scoop uninstall netcatty
```
這是第三方套件,並非 Netcatty 官方軟體來源,更新可能晚於 GitHub Releases。目前僅支援 x64。設定儲存在 `%APPDATA%\netcatty`,更新和一般解除安裝會保留這些設定;`scoop uninstall netcatty --purge` 也會刪除這些設定。透過此方式安裝後,請使用 Scoop 管理更新。
### Nix / NixOS
Netcatty 提供了一個 flake,替 Nix 與 NixOS 使用者包裝官方的 Linux AppImage 發行版:
+9 -6
View File
@@ -57,7 +57,7 @@ import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
import type { SyncPayload } from '../../domain/sync';
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
import { prepareSyncPayloadApply, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
import {
applyProtectedSyncPayload,
ensureVersionChangeBackup,
@@ -502,8 +502,8 @@ export function AppSideEffects() {
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: () =>
applySyncPayload(payload, {
prepareApply: () =>
prepareSyncPayloadApply(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
@@ -525,14 +525,17 @@ export function AppSideEffects() {
(payload: SyncPayload, commitReplica: () => Promise<void>) =>
applyProtectedSyncPayload({
buildPreApplyPayload: () => buildCurrentSyncPayload(),
applyPayload: async () => {
prepareApply: async () => {
const portable = stripSyncPayloadEncryptedCredentials(payload);
await applySyncPayload(portable, {
const applyPreparedPayload = await prepareSyncPayloadApply(portable, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
}, { currentHosts: hosts });
await commitReplica();
return async () => {
await applyPreparedPayload();
await commitReplica();
};
},
translateProtectiveBackupFailure: (message) =>
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
+1
View File
@@ -504,6 +504,7 @@ function AppViewInner({ domains }: AppViewProps) {
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onDuplicateSession={duplicateSessionWithCurrentShell}
tabDoubleClickBehavior={terminalSettings.tabDoubleClickBehavior}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onEditHost={handleEditHostFromOverlay}
onRenameWorkspace={startWorkspaceRename}
+58 -5
View File
@@ -136,7 +136,7 @@ test('initialization applies the protected preview before persisting and enablin
return liveLocalPayload;
},
translateProtectiveBackupFailure: (message) => message,
applyPayload: async (incoming) => {
preparePayloadApply: async (incoming) => async () => {
calls.push('apply');
assert.equal(incoming.convergentSync?.schemaVersion, 2);
},
@@ -212,7 +212,7 @@ test('initialization applies a concurrent provider merge before releasing the mi
buildCurrentPayload: () => currentPayload,
buildPreApplyPayload: () => currentPayload,
translateProtectiveBackupFailure: (message) => message,
applyPayload: async (incoming) => {
preparePayloadApply: async (incoming) => async () => {
assert.equal(lockHeld, true);
applied.push(incoming);
currentPayload = stripConvergentSyncEnvelope(incoming);
@@ -265,7 +265,7 @@ test('initialization rejects a stale preview before backup or apply', async () =
buildCurrentPayload: () => changedPayload,
buildPreApplyPayload: () => changedPayload,
translateProtectiveBackupFailure: (message) => message,
applyPayload: () => {
preparePayloadApply: async () => async () => {
applied = true;
},
runProtectedApply: async (options) => {
@@ -300,7 +300,7 @@ test('blocked previews cannot enter the protected initialization transaction', a
buildCurrentPayload: () => localPayload,
buildPreApplyPayload: () => localPayload,
translateProtectiveBackupFailure: (message) => message,
applyPayload: () => {},
preparePayloadApply: async () => async () => {},
runProtectedApply: async () => {
entered = true;
},
@@ -337,7 +337,7 @@ test('a locked manager cannot enter the protected initialization transaction', a
return localPayload;
},
translateProtectiveBackupFailure: (message) => message,
applyPayload: () => {
preparePayloadApply: async () => async () => {
applied = true;
},
runProtectedApply: async () => {
@@ -352,3 +352,56 @@ test('a locked manager cannot enter the protected initialization transaction', a
assert.equal(applied, false);
assert.deepEqual(getConvergentSyncLocalConfig(), { enabled: false, initialized: false });
});
for (const failDuringPublish of [false, true]) {
test(`migration header failure precedes protected writes (${failDuringPublish ? 'publish' : 'initialize'})`, async () => {
const { prepareSyncPayloadApply } = await import('./syncPayload.ts');
const { readInterruptedVaultApply } = await import('./localVaultBackups.ts');
const headers = { ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] } };
const localPayload: SyncPayload = { ...payload(), ...(failDuringPublish ? {} : { settings: headers }) };
let currentPayload = localPayload;
let imports = 0;
let replicaCommits = 0;
let snapshots = 0;
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => {
assert.equal(readInterruptedVaultApply(), null);
throw new Error('keychain locked');
} },
dispatchEvent: () => true,
} });
const plan = planConvergentSyncMigration({
localPayload, localTrustedBaseline: null, providers: [], deviceId: 'device-a', now: NOW,
});
const manager = {
isUnlocked: () => true,
withConvergentSyncLock: async (task: () => Promise<void>) => task(),
saveConvergentReplica: async () => { replicaCommits++; },
saveConvergentProviderBaseline: async () => {},
syncConvergentProvidersUnderLock: async (
_incoming: SyncPayload,
apply: (incoming: SyncPayload, commit: () => Promise<void>) => Promise<void>,
) => {
await apply({ ...currentPayload, settings: headers }, async () => { replicaCommits++; });
return new Map();
},
} as unknown as CloudSyncManager;
await assert.rejects(initializePreparedConvergentMigration({
prepared: { plan, providerBaselines: [], localSnapshot: localPayload }, manager, now: NOW,
buildCurrentPayload: () => currentPayload,
buildPreApplyPayload: () => { snapshots++; return payload(); },
translateProtectiveBackupFailure: (message) => message,
preparePayloadApply: async (incoming) => {
const apply = await prepareSyncPayloadApply(incoming, { importVaultData: () => { imports++; } });
return async () => {
await apply();
currentPayload = stripConvergentSyncEnvelope(incoming);
};
},
}));
assert.equal(readInterruptedVaultApply(), null);
assert.equal(imports, failDuringPublish ? 1 : 0);
assert.equal(replicaCommits, failDuringPublish ? 1 : 0);
assert.equal(snapshots, failDuringPublish ? 1 : 0);
});
}
+5 -3
View File
@@ -137,7 +137,7 @@ export async function initializePreparedConvergentMigration(options: {
prepared: PreparedConvergentMigration;
buildCurrentPayload: () => SyncPayload | Promise<SyncPayload>;
buildPreApplyPayload: () => SyncPayload;
applyPayload: (payload: SyncPayload) => void | Promise<void>;
preparePayloadApply: (payload: SyncPayload) => Promise<() => Promise<void>>;
translateProtectiveBackupFailure: (message: string) => string;
manager?: CloudSyncManager;
now?: number;
@@ -164,8 +164,9 @@ export async function initializePreparedConvergentMigration(options: {
'Local sync data changed after the migration preview. Review the updated migration before enabling convergent sync.',
);
}
const applyPreparedPayload = await options.preparePayloadApply(prepared.plan.payload as SyncPayload);
return async () => {
await options.applyPayload(prepared.plan.payload as SyncPayload);
await applyPreparedPayload();
for (const baseline of prepared.providerBaselines) {
await manager.saveConvergentProviderBaseline(baseline);
}
@@ -195,8 +196,9 @@ export async function initializePreparedConvergentMigration(options: {
'Local sync data changed while publishing the convergent migration. Retry sync to preserve the newer local edits.',
);
}
const applyPreparedPayload = await options.preparePayloadApply(mergedPayload);
return async () => {
await options.applyPayload(mergedPayload);
await applyPreparedPayload();
await commitReplica();
};
},
+17 -2
View File
@@ -8,6 +8,7 @@ export const enAiMessages: Messages = {
'ai.chat.compactingStep': 'Trimming context for next step…',
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
'ai.chat.compactionBanner': 'Context compacted: {before}K → {after}K tokens',
'ai.chat.contextCompacted': 'Context compacted',
'ai.chat.contextUsage': 'Context usage: {used} / {max} tokens',
'ai.chat.activity.title': 'Agent activity',
'ai.chat.activity.plan': 'Plan',
@@ -53,6 +54,15 @@ export const enAiMessages: Messages = {
'ai.providers.icon.close': 'Close',
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
'ai.providers.icon.errorType': 'Please choose an image file.',
'ai.providers.headers': 'Custom HTTP Headers',
'ai.providers.headers.help': 'Optional headers for this provider. Values are hidden and encrypted when saved.',
'ai.providers.headers.name': 'Header name',
'ai.providers.headers.value': 'Header value',
'ai.providers.headers.add': 'Add header',
'ai.providers.headers.remove': 'Remove header',
'ai.providers.headers.invalid': 'Use valid, unique HTTP headers. Content-Length and Transfer-Encoding are managed automatically.',
'ai.providers.headers.loadError': 'Unable to unlock saved headers. Cancel and try again after unlocking your system keychain.',
'ai.providers.headers.saveError': 'Unable to save securely. Check that your system keychain is available and try again.',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
@@ -225,20 +235,24 @@ export const enAiMessages: Messages = {
'ai.codebuddy.envVars': 'Environment variables',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.230)',
'ai.codebuddy.advancedSection': 'Advanced options (SDK 0.3.258)',
'ai.codebuddy.effort': 'Reasoning Effort',
'ai.codebuddy.effort.default': 'Default',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Low for simple commands to save tokens, High/XHigh for complex diagnostics.',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': 'Controls model reasoning depth. Use Minimal/Low for simple commands to save tokens, Max/High for complex diagnostics.',
'ai.codebuddy.maxTurns': 'Max Turns',
'ai.codebuddy.maxTurns.hint': 'Limits the maximum conversation turns per request to prevent runaway loops. Leave empty for default.',
'ai.codebuddy.maxBudget': 'Max Budget (USD)',
'ai.codebuddy.maxBudget.hint': 'Maximum spend (USD) per request. Stops automatically when exceeded. Leave empty for no limit.',
'ai.codebuddy.sandbox': 'Sandbox Mode',
'ai.codebuddy.sandbox.hint': 'Execute tool calls in a sandbox, restricting filesystem and network access.',
'ai.codebuddy.persistSession': 'Session Persistence',
'ai.codebuddy.persistSession.hint': "Keep CodeBuddy's own session transcript on disk so CodeBuddy can resume it later. Turn off to disable CodeBuddy session storage; Netcatty may still save chat history and external session metadata. File checkpointing is skipped. Requires CodeBuddy CLI 2.125.1 or newer.",
'ai.codebuddy.fileCheckpointing': 'File Checkpointing',
'ai.codebuddy.fileCheckpointing.hint': 'Enable file operation checkpoints so AI file modifications can be rolled back.',
'ai.codebuddy.elicitation.title': 'CodeBuddy needs your input',
@@ -471,6 +485,7 @@ export const enAiMessages: Messages = {
'ai.chat.useCustomModel': 'Use "{id}"',
'ai.chat.thinkingLevel': 'Thinking',
'ai.chat.thinkingOff': 'Off',
'ai.chat.thinkingDefault': 'Provider default',
'ai.chat.pinModel': 'Pin model',
'ai.chat.unpinModel': 'Unpin model',
'ai.chat.loadingModels': 'Loading models...',
+8
View File
@@ -56,6 +56,7 @@ export const enCoreMessages: Messages = {
'sort.newest': 'Newest to oldest',
'sort.oldest': 'Oldest to newest',
'sort.group': 'By group',
'sort.ip': 'By IP',
'field.label': 'Label',
'field.type': 'Type',
'auth.keyType': 'Type {type}',
@@ -652,6 +653,8 @@ Highlight the focused split pane:
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
'settings.terminal.behavior.rightClick': 'Right-click behavior',
'settings.terminal.behavior.rightClick.desc': 'Action when right-clicking in terminal',
'settings.terminal.behavior.rightClick.longPressMenu': 'Right-click hold opens menu',
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'When right-click pastes or selects a word, hold for half a second to open the menu. Does not override terminal apps that capture the mouse.',
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Show menu over fullscreen apps',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'Show the context menu even when tmux, vim or other fullscreen apps take over the mouse. When off, right-click is passed to the app (Shift+Right-Click still opens the menu).',
@@ -709,6 +712,11 @@ Highlight the focused split pane:
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
'settings.terminal.behavior.tabDoubleClick': 'Tab double-click behavior',
'settings.terminal.behavior.tabDoubleClick.desc': 'Choose the action when double-clicking a terminal tab.',
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicate session (new connection)',
'settings.terminal.behavior.tabDoubleClick.copy': 'Copy tab (reuse connection)',
'settings.terminal.behavior.tabDoubleClick.disabled': 'Do nothing',
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
'settings.terminal.behavior.oscNotifications.desc':
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
+2
View File
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.menu.saveScreen': 'Save current screen',
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
'terminal.passwordPicker.title': 'Saved passwords',
'terminal.passwordPicker.empty': 'No saved passwords',
+1
View File
@@ -417,6 +417,7 @@ export const enVaultMessages: Messages = {
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.locatePathInTerminal': 'Open path in terminal',
'sftp.refreshAfterCommand': 'Refresh after terminal commands',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
+8 -2
View File
@@ -8,6 +8,7 @@ export const esAiMessages: Messages = {
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
'ai.chat.contextCompacted': 'Contexto compactado',
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
'ai.chat.activity.title': 'Actividad del agente',
'ai.chat.activity.plan': 'Plan',
@@ -225,20 +226,24 @@ export const esAiMessages: Messages = {
'ai.codebuddy.envVars': 'Variables de entorno',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.258)',
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
'ai.codebuddy.effort.default': 'Predeterminado',
'ai.codebuddy.effort.minimal': 'Mínimo',
'ai.codebuddy.effort.low': 'Bajo',
'ai.codebuddy.effort.medium': 'Medio',
'ai.codebuddy.effort.high': 'Alto',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
'ai.codebuddy.effort.max': 'Máximo',
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Mínimo/Bajo para comandos simples y ahorrar tokens, y Máximo/Alto para diagnósticos complejos.',
'ai.codebuddy.maxTurns': 'Máximo de turnos',
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
'ai.codebuddy.sandbox': 'Modo sandbox',
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
'ai.codebuddy.persistSession': 'Persistencia de sesión',
'ai.codebuddy.persistSession.hint': 'Guarda en disco la transcripción de la sesión propia de CodeBuddy para que CodeBuddy pueda reanudarla más tarde. Desactívalo para deshabilitar el almacenamiento de sesiones de CodeBuddy; Netcatty aún puede guardar el historial del chat y los metadatos de la sesión externa. Se omiten los puntos de control de archivos. Requiere CodeBuddy CLI 2.125.1 o superior.',
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
@@ -471,6 +476,7 @@ export const esAiMessages: Messages = {
'ai.chat.useCustomModel': 'Usar "{id}"',
'ai.chat.thinkingLevel': 'Razonamiento',
'ai.chat.thinkingOff': 'Off',
'ai.chat.thinkingDefault': 'Predeterminado del proveedor',
'ai.chat.pinModel': 'Fijar modelo',
'ai.chat.unpinModel': 'Quitar modelo fijado',
'ai.chat.loadingModels': 'Cargando modelos...',
+8
View File
@@ -55,6 +55,7 @@ export const esCoreMessages: Messages = {
'sort.za': 'Z-a',
'sort.newest': 'Más reciente a más antiguo',
'sort.oldest': 'Más antiguo a más reciente',
'sort.ip': 'Por IP',
'sort.group': 'Por grupo',
'field.label': 'Etiqueta',
'field.type': 'Tipo',
@@ -567,6 +568,8 @@ Resalta el panel dividido enfocado:
'Ajusta los colores para cumplir con los requisitos de contraste (1 = desactivado, 21 = máximo)',
'settings.terminal.behavior.rightClick': 'Comportamiento del clic derecho',
'settings.terminal.behavior.rightClick.desc': 'Acción al hacer clic derecho en la terminal',
'settings.terminal.behavior.rightClick.longPressMenu': 'Mantener clic derecho abre el menú',
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Si el clic derecho pega o selecciona una palabra, mantén pulsado medio segundo para abrir el menú. No afecta a las aplicaciones que capturan el ratón.',
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Mostrar menú sobre aplicaciones en pantalla completa',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'Muestra el menú contextual incluso cuando tmux, vim u otras aplicaciones en pantalla completa toman el control del mouse. Cuando está desactivado, el clic derecho se pasa a la aplicación (Shift+Clic derecho sigue abriendo el menú).',
@@ -624,6 +627,11 @@ Resalta el panel dividido enfocado:
'settings.terminal.behavior.dynamicTabTitle.off': 'Desactivado',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Solo agentes',
'settings.terminal.behavior.dynamicTabTitle.all': 'Todas las sesiones',
'settings.terminal.behavior.tabDoubleClick': 'Comportamiento de doble clic en pestaña',
'settings.terminal.behavior.tabDoubleClick.desc': 'Elige la acción al hacer doble clic en una pestaña de terminal.',
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Duplicar sesión (nueva conexión)',
'settings.terminal.behavior.tabDoubleClick.copy': 'Copiar pestaña (reutilizar conexión)',
'settings.terminal.behavior.tabDoubleClick.disabled': 'No hacer nada',
'settings.terminal.behavior.oscNotifications': 'Notificaciones de escritorio OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Muestra una notificación del sistema cuando un programa emite OSC 9, OSC 777 notify o kitty OSC 99. Lo usan Codex y otros agentes para avisos de turno completado y de aprobación.',
+2
View File
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.menu.saveScreen': 'Guardar pantalla actual',
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
'terminal.passwordPicker.title': 'Contraseñas guardadas',
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
+22
View File
@@ -5,6 +5,7 @@ export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.chat.contextCompacted': 'Контекст сжат',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
'ai.providers': 'Провайдеры',
@@ -206,6 +207,26 @@ export const ruAiMessages: Messages = {
'ai.codebuddy.envVars': 'Переменные окружения',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
'ai.codebuddy.advancedSection': 'Расширенные параметры (SDK 0.3.258)',
'ai.codebuddy.effort': 'Усилие рассуждения',
'ai.codebuddy.effort.default': 'По умолчанию',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': 'Управляет глубиной рассуждения модели. Для простых команд используйте Minimal/Low, чтобы экономить токены, а для сложной диагностики — Max/High.',
'ai.codebuddy.maxTurns': 'Максимум шагов',
'ai.codebuddy.maxTurns.hint': 'Ограничивает максимальное число шагов диалога за запрос, предотвращая зацикливание. Оставьте пустым для значения по умолчанию.',
'ai.codebuddy.maxBudget': 'Максимальный бюджет (USD)',
'ai.codebuddy.maxBudget.hint': 'Максимальные расходы (USD) за запрос. При превышении выполнение останавливается автоматически. Оставьте пустым, чтобы не ограничивать.',
'ai.codebuddy.sandbox': 'Режим песочницы',
'ai.codebuddy.sandbox.hint': 'Выполняет вызовы инструментов в песочнице, ограничивая доступ к файловой системе и сети.',
'ai.codebuddy.persistSession': 'Сохранение сессии',
'ai.codebuddy.persistSession.hint': 'Сохраняет на диск собственную историю сессии CodeBuddy, чтобы CodeBuddy мог возобновить её позже. Отключите, чтобы запретить хранение сессии CodeBuddy; Netcatty всё ещё может сохранять историю чата и метаданные внешней сессии. Контрольные точки файлов пропускаются. Требуется CodeBuddy CLI 2.125.1 или новее.',
'ai.codebuddy.fileCheckpointing': 'Контрольные точки файлов',
'ai.codebuddy.fileCheckpointing.hint': 'Включает контрольные точки файловых операций, чтобы изменения файлов ИИ можно было откатить.',
'ai.codebuddy.elicitation.title': 'CodeBuddy требуется ваш ответ',
'ai.codebuddy.elicitation.description': 'Проверьте запрос, чтобы продолжить текущий ход.',
'ai.codebuddy.elicitation.select': 'Выберите вариант',
@@ -423,6 +444,7 @@ export const ruAiMessages: Messages = {
'ai.chat.useCustomModel': 'Использовать «{id}»',
'ai.chat.thinkingLevel': 'Мышление',
'ai.chat.thinkingOff': 'Выкл.',
'ai.chat.thinkingDefault': 'По умолчанию провайдера',
'ai.chat.pinModel': 'Закрепить модель',
'ai.chat.unpinModel': 'Открепить модель',
'ai.chat.loadingModels': 'Загрузка моделей...',
+8
View File
@@ -55,6 +55,7 @@ export const ruCoreMessages: Messages = {
'sort.newest': 'Сначала новые',
'sort.oldest': 'Сначала старые',
'sort.group': 'По группе',
'sort.ip': 'По IP',
'field.label': 'Метка',
'field.type': 'Тип',
'auth.keyType': 'Тип {type}',
@@ -609,6 +610,8 @@ export const ruCoreMessages: Messages = {
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
'settings.terminal.behavior.rightClick': 'Поведение правой кнопки мыши',
'settings.terminal.behavior.rightClick.desc': 'Действие при щелчке правой кнопкой в терминале',
'settings.terminal.behavior.rightClick.longPressMenu': 'Удержание правой кнопки открывает меню',
'settings.terminal.behavior.rightClick.longPressMenu.desc': 'Если правая кнопка вставляет текст или выделяет слово, удерживайте её полсекунды для открытия меню. Не влияет на приложения, захватывающие мышь.',
'settings.terminal.behavior.rightClick.fullscreenMenu': 'Показывать меню поверх полноэкранных приложений',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'Показывать контекстное меню, даже когда tmux, vim или другие полноэкранные приложения перехватывают мышь. Когда выключено, правый щелчок передаётся приложению (Shift+правый щелчок всё равно открывает меню).',
@@ -666,6 +669,11 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
'settings.terminal.behavior.tabDoubleClick': 'Действие по двойному щелчку вкладки',
'settings.terminal.behavior.tabDoubleClick.desc': 'Выберите действие при двойном щелчке вкладки терминала.',
'settings.terminal.behavior.tabDoubleClick.duplicate': 'Дублировать сеанс (новое подключение)',
'settings.terminal.behavior.tabDoubleClick.copy': 'Копировать вкладку (использовать подключение)',
'settings.terminal.behavior.tabDoubleClick.disabled': 'Ничего не делать',
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
+2
View File
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.menu.saveScreen': 'Сохранить текущий экран',
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
'terminal.passwordPicker.title': 'Сохранённые пароли',
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
+17 -2
View File
@@ -8,6 +8,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
'ai.chat.compactionBanner': '上下文已压缩:{before}K → {after}K tokens',
'ai.chat.contextCompacted': '上下文已压缩',
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
'ai.chat.activity.title': 'Agent 活动',
'ai.chat.activity.plan': '计划',
@@ -53,6 +54,15 @@ export const zhCNAiMessages: Messages = {
'ai.providers.icon.close': '收起',
'ai.providers.icon.uploadedNote': '自定义图标(64×64 WebP)',
'ai.providers.icon.errorType': '请选择图片文件。',
'ai.providers.headers': '自定义 HTTP 请求头',
'ai.providers.headers.help': '为此服务添加可选请求头。内容会隐藏显示并在保存时加密。',
'ai.providers.headers.name': '请求头名称',
'ai.providers.headers.value': '请求头内容',
'ai.providers.headers.add': '添加请求头',
'ai.providers.headers.remove': '删除请求头',
'ai.providers.headers.invalid': '请填写有效且不重复的请求头。Content-Length 和 Transfer-Encoding 由应用自动设置。',
'ai.providers.headers.loadError': '无法解锁已保存的请求头。请取消编辑,解锁系统钥匙串后重试。',
'ai.providers.headers.saveError': '无法安全保存。请确认系统钥匙串可用后重试。',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
@@ -225,20 +235,24 @@ export const zhCNAiMessages: Messages = {
'ai.codebuddy.envVars': '环境变量',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE,传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.230)',
'ai.codebuddy.advancedSection': '高级选项(SDK 0.3.258)',
'ai.codebuddy.effort': '推理力度',
'ai.codebuddy.effort.default': '默认',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Low 节省 token,复杂诊断用 High/XHigh。',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': '控制模型推理深度。简单命令用 Minimal/Low 节省 token,复杂诊断用 Max/High。',
'ai.codebuddy.maxTurns': '最大轮次',
'ai.codebuddy.maxTurns.hint': '限制每次请求的最大对话轮次,防止 AI 失控循环。留空使用默认值。',
'ai.codebuddy.maxBudget': '预算上限 (USD)',
'ai.codebuddy.maxBudget.hint': '每次请求的最大花费(美元)。超出后自动停止。留空不限制。',
'ai.codebuddy.sandbox': '沙箱模式',
'ai.codebuddy.sandbox.hint': '在沙箱中执行工具调用,限制文件系统和网络访问。',
'ai.codebuddy.persistSession': '会话持久化',
'ai.codebuddy.persistSession.hint': '将 CodeBuddy 自身的会话记录保存到磁盘,以便 CodeBuddy 后续恢复会话。关闭后将停用 CodeBuddy 的会话存储;Netcatty 仍可能保存聊天记录和外部会话元数据。文件检查点会跳过。需要 CodeBuddy CLI 2.125.1 或更高版本。',
'ai.codebuddy.fileCheckpointing': '文件检查点',
'ai.codebuddy.fileCheckpointing.hint': '启用文件操作检查点,AI 修改文件后可回滚。',
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的输入',
@@ -471,6 +485,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.useCustomModel': '使用 “{id}”',
'ai.chat.thinkingLevel': '思考强度',
'ai.chat.thinkingOff': '关闭',
'ai.chat.thinkingDefault': '跟随提供商默认',
'ai.chat.pinModel': '固定模型',
'ai.chat.unpinModel': '取消固定',
'ai.chat.loadingModels': '正在加载模型...',
+2
View File
@@ -42,6 +42,7 @@ export const zhCNCoreMessages: Messages = {
'sort.newest': '从新到旧',
'sort.oldest': '从旧到新',
'sort.group': '按分组',
'sort.ip': '按 IP',
'field.label': 'Label',
'field.type': '类型',
'auth.keyType': '类型 {type}',
@@ -1049,6 +1050,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.locatePathInTerminal': '定位路径到终端',
'sftp.refreshAfterCommand': '终端命令完成后刷新',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.menu.saveScreen': '保存当前屏幕',
'terminal.saveScreen.failed': '保存终端屏幕失败。',
'terminal.sudoHint.pressEnter': '按 Enter 粘贴已保存的密码',
'terminal.passwordPicker.title': '已保存的密码',
'terminal.passwordPicker.empty': '没有已保存的密码',
@@ -289,6 +291,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右键行为',
'settings.terminal.behavior.rightClick.desc': '在终端中右键时执行的操作',
'settings.terminal.behavior.rightClick.longPressMenu': '右键长按显示菜单',
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右键设为粘贴或选词时,按住半秒打开菜单。不影响接管鼠标的终端应用。',
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全屏应用中也显示菜单',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'即使 tmux、vim 等全屏应用接管了鼠标,也显示右键菜单。关闭时右键会交给应用处理(Shift+右键仍可打开菜单)。',
@@ -343,6 +347,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
'settings.terminal.behavior.tabDoubleClick': '标签页双击行为',
'settings.terminal.behavior.tabDoubleClick.desc': '选择双击顶部终端标签页时执行的操作。',
'settings.terminal.behavior.tabDoubleClick.duplicate': '复制会话(新建连接)',
'settings.terminal.behavior.tabDoubleClick.copy': '复制标签页(复用连接)',
'settings.terminal.behavior.tabDoubleClick.disabled': '无操作',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
+8 -2
View File
@@ -8,6 +8,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
'ai.chat.compactionBanner': '上下文已壓縮:{before}K → {after}K tokens',
'ai.chat.contextCompacted': '上下文已壓縮',
'ai.chat.contextUsage': '上下文使用:{used} / {max} tokens',
'ai.chat.activity.title': 'Agent 活動',
'ai.chat.activity.plan': '計畫',
@@ -225,20 +226,24 @@ export const zhTWAiMessages: Messages = {
'ai.codebuddy.envVars': '環境變數',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一個 KEY=VALUE,傳給 CodeBuddy agent。可在此設定 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成認證。明文存在本機。',
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.230)',
'ai.codebuddy.advancedSection': '進階選項(SDK 0.3.258)',
'ai.codebuddy.effort': '推理力度',
'ai.codebuddy.effort.default': '預設',
'ai.codebuddy.effort.minimal': 'Minimal',
'ai.codebuddy.effort.low': 'Low',
'ai.codebuddy.effort.medium': 'Medium',
'ai.codebuddy.effort.high': 'High',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Low 節省 token,複雜診斷用 High/XHigh。',
'ai.codebuddy.effort.max': 'Max',
'ai.codebuddy.effort.hint': '控制模型推理深度。簡單命令用 Minimal/Low 節省 token,複雜診斷用 Max/High。',
'ai.codebuddy.maxTurns': '最大輪次',
'ai.codebuddy.maxTurns.hint': '限制每次請求的最大對話輪次,防止 AI 失控循環。留空使用預設值。',
'ai.codebuddy.maxBudget': '預算上限 (USD)',
'ai.codebuddy.maxBudget.hint': '每次請求的最大花費(美元)。超出後自動停止。留空不限制。',
'ai.codebuddy.sandbox': '沙箱模式',
'ai.codebuddy.sandbox.hint': '在沙箱中執行工具呼叫,限制檔案系統和網路存取。',
'ai.codebuddy.persistSession': '工作階段持久化',
'ai.codebuddy.persistSession.hint': '將 CodeBuddy 自身的工作階段記錄儲存到磁碟,以便 CodeBuddy 後續恢復工作階段。關閉後將停用 CodeBuddy 的工作階段儲存;Netcatty 仍可能儲存聊天記錄和外部工作階段中繼資料。檔案檢查點會略過。需要 CodeBuddy CLI 2.125.1 或更高版本。',
'ai.codebuddy.fileCheckpointing': '檔案檢查點',
'ai.codebuddy.fileCheckpointing.hint': '啟用檔案操作檢查點,AI 修改檔案後可回滾。',
'ai.codebuddy.elicitation.title': 'CodeBuddy 需要你的輸入',
@@ -470,6 +475,7 @@ export const zhTWAiMessages: Messages = {
'ai.chat.useCustomModel': '使用「{id}」',
'ai.chat.thinkingLevel': '思考強度',
'ai.chat.thinkingOff': '關閉',
'ai.chat.thinkingDefault': '跟隨提供商預設',
'ai.chat.pinModel': '釘選模型',
'ai.chat.unpinModel': '取消釘選',
'ai.chat.loadingModels': '正在載入模型...',
+2
View File
@@ -42,6 +42,7 @@ export const zhTWCoreMessages: Messages = {
'sort.newest': '從新到舊',
'sort.oldest': '從舊到新',
'sort.group': '依群組',
'sort.ip': '依 IP',
'field.label': 'Label',
'field.type': '型別',
'auth.keyType': '型別 {type}',
@@ -1051,6 +1052,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.locatePathInTerminal': '定位路徑到終端',
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.menu.saveScreen': '儲存目前畫面',
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
'terminal.sudoHint.pressEnter': '按 Enter 貼上已儲存的密碼',
'terminal.passwordPicker.title': '已儲存的密碼',
'terminal.passwordPicker.empty': '沒有已儲存的密碼',
@@ -289,6 +291,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.accessibility.minimumContrastRatio.desc': '調整顏色以滿足對比度要求 (1 = 停用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右鍵行為',
'settings.terminal.behavior.rightClick.desc': '在終端中右鍵時執行的操作',
'settings.terminal.behavior.rightClick.longPressMenu': '右鍵長按顯示選單',
'settings.terminal.behavior.rightClick.longPressMenu.desc': '右鍵設為貼上或選詞時,按住半秒開啟選單。不影響接管滑鼠的終端應用。',
'settings.terminal.behavior.rightClick.fullscreenMenu': '在全螢幕應用中也顯示選單',
'settings.terminal.behavior.rightClick.fullscreenMenu.desc':
'即使 tmux、vim 等全螢幕應用接管了滑鼠,也顯示右鍵選單。關閉時右鍵會交給應用處理(Shift+右鍵仍可開啟選單)。',
@@ -343,6 +347,11 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
'settings.terminal.behavior.tabDoubleClick': '分頁雙擊行為',
'settings.terminal.behavior.tabDoubleClick.desc': '選擇雙擊頂部終端分頁時要執行的動作。',
'settings.terminal.behavior.tabDoubleClick.duplicate': '複製工作階段(新連線)',
'settings.terminal.behavior.tabDoubleClick.copy': '複製分頁(重用連線)',
'settings.terminal.behavior.tabDoubleClick.disabled': '無動作',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
+54
View File
@@ -78,3 +78,57 @@ test('prepared apply callback runs after the snapshot with sentinel protection',
assert.equal(observedSentinel, true);
assert.equal(readInterruptedVaultApply(), null);
});
for (const localRestore of [false, true]) {
for (const encryptionFails of [false, true]) {
test(`protected header preparation (${localRestore ? 'restore' : 'sync'}, ${encryptionFails ? 'failure' : 'success'})`, async () => {
const { prepareSyncPayloadApply, prepareLocalVaultPayloadApply } = await import('./syncPayload.ts');
const { STORAGE_KEY_THEME, STORAGE_KEY_AI_PROVIDERS } = await import('../infrastructure/config/storageKeys.ts');
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
const calls: string[] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => {
calls.push('encrypt');
assert.equal(readInterruptedVaultApply(), null);
if (encryptionFails) throw new Error('keychain locked');
return sealed;
} },
dispatchEvent: () => true,
} });
localStorageValues.set(STORAGE_KEY_THEME, 'light');
const payload: SyncPayload = { ...emptyPayload(), settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
} };
const original = JSON.stringify(payload);
const importers = { importVaultData: () => {
calls.push('import');
assert.notEqual(readInterruptedVaultApply(), null);
} };
const applying = applyProtectedSyncPayload({
buildPreApplyPayload: () => { calls.push('snapshot'); return emptyPayload(); },
prepareApply: () => localRestore
? prepareLocalVaultPayloadApply(payload, importers, { prepareConvergentRestore: async () => {
calls.push('prepareReplica');
return async () => { calls.push('commitReplica'); };
} })
: prepareSyncPayloadApply(payload, importers),
translateProtectiveBackupFailure: (message) => message,
});
if (encryptionFails) {
await assert.rejects(applying);
assert.deepEqual(calls, ['encrypt']);
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'light');
assert.equal(localStorageValues.has(STORAGE_KEY_AI_PROVIDERS), false);
} else {
await applying;
assert.deepEqual(calls, localRestore
? ['encrypt', 'prepareReplica', 'snapshot', 'import', 'commitReplica']
: ['encrypt', 'snapshot', 'import']);
assert.equal(localStorageValues.get(STORAGE_KEY_THEME), 'dark');
assert.equal(JSON.parse(localStorageValues.get(STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders['X-Tenant'], sealed);
}
assert.equal(readInterruptedVaultApply(), null);
assert.equal(JSON.stringify(payload), original);
});
}
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
test('compose history is isolated per terminal and removed when its session closes', () => {
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'echo a\necho again');
recordComposeBarHistory('b', 'echo b');
assert.deepEqual(getComposeBarHistory('a'), ['echo a\necho again']);
assert.deepEqual(getComposeBarHistory('b'), ['echo b']);
pruneComposeBarHistory(['b']);
assert.deepEqual(getComposeBarHistory('a'), []);
assert.deepEqual(getComposeBarHistory('b'), ['echo b']);
pruneComposeBarHistory([]);
});
test('late send completion cannot restore a closed session or overwrite another send', async () => {
const { createComposeBarHistoryRecorder } = await import('./composeBarHistoryStore');
pruneComposeBarHistory([]);
const first = createComposeBarHistoryRecorder('a');
const second = createComposeBarHistoryRecorder('a');
second('second');
first('first');
assert.deepEqual(getComposeBarHistory('a'), ['first', 'second']);
const late = createComposeBarHistoryRecorder('a');
pruneComposeBarHistory([]);
late('late');
assert.deepEqual(getComposeBarHistory('a'), []);
});
test('failed submissions unblock later successes in submission order', async () => {
const { createComposeBarHistoryRecorder } = await import('./composeBarHistoryStore');
pruneComposeBarHistory([]);
const failed = createComposeBarHistoryRecorder('a');
const accepted = createComposeBarHistoryRecorder('a');
accepted('accepted');
assert.deepEqual(getComposeBarHistory('a'), []);
failed();
assert.deepEqual(getComposeBarHistory('a'), ['accepted']);
pruneComposeBarHistory([]);
});
@@ -0,0 +1,46 @@
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
// Session-only memory: survives closing the bar and moving a terminal into a
// workspace, but is discarded when that terminal session is removed.
type PendingSend = { settled: boolean; command?: string };
const histories = new Map<string, { entries: readonly string[]; pending: PendingSend[] }>();
export function getComposeBarHistory(sessionId: string): readonly string[] {
return histories.get(sessionId)?.entries ?? [];
}
export function createComposeBarHistoryRecorder(sessionId: string): (command?: string) => void {
let history = histories.get(sessionId);
if (!history) {
history = { entries: [], pending: [] };
histories.set(sessionId, history);
}
const target = history;
const submission: PendingSend = { settled: false };
target.pending.push(submission);
// Capture the session's object before an async send. A late completion after
// session removal cannot recreate that session's history in the map.
return (command) => {
if (submission.settled) return;
submission.settled = true;
submission.command = command;
// Settle failures too, so they cannot block later successful submissions.
while (target.pending[0]?.settled) {
const next = target.pending.shift()!;
if (next.command !== undefined) {
target.entries = appendComposeBarHistory(target.entries, next.command);
}
}
};
}
export function recordComposeBarHistory(sessionId: string, command: string): void {
createComposeBarHistoryRecorder(sessionId)(command);
}
export function pruneComposeBarHistory(sessionIds: readonly string[]): void {
const active = new Set(sessionIds);
for (const id of histories.keys()) {
if (!active.has(id)) histories.delete(id);
}
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { publishTerminalCommandCompletion } from '../terminalCommandCompletion';
import { useSftpCommandRefresh } from './useSftpCommandRefresh';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('completion refresh is coalesced, visible, idle and bound to its original pane', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let count = 0;
let options = {
sessionId: 'terminal-1', enabled: true, visible: true, busy: false,
matchesTerminal: true, paneId: 'pane-1', connectionId: 'connection-1',
path: '/browsed', connected: true, refresh: async () => { count += 1; },
};
function Probe() { useSftpCommandRefresh(options); return null; }
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
const update = async (patch: Partial<typeof options>) => {
options = { ...options, ...patch };
await act(async () => { renderer.update(React.createElement(Probe)); });
};
const finish = async () => { await act(async () => { t.mock.timers.tick(250); }); };
publishTerminalCommandCompletion('another-terminal');
await finish();
assert.equal(count, 0);
publishTerminalCommandCompletion('terminal-1');
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
for (const patch of [{ enabled: false }, { visible: false }, { connected: false }, { matchesTerminal: false }]) {
await update(patch);
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ enabled: true, visible: true, busy: false, connected: true, matchesTerminal: true });
}
await update({ busy: true });
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ busy: false });
await finish();
assert.equal(count, 2);
for (const patch of [{ path: '/new' }, { paneId: 'pane-2' }, { connectionId: 'connection-2' }, { sessionId: 'terminal-2' }]) {
publishTerminalCommandCompletion(options.sessionId);
await update(patch);
await finish();
assert.equal(count, 2);
}
publishTerminalCommandCompletion(options.sessionId);
await act(async () => { renderer.unmount(); });
await finish();
assert.equal(count, 2);
});
@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { subscribeTerminalCommandCompletion } from '../terminalCommandCompletion';
interface Options {
sessionId: string | null;
enabled: boolean;
visible: boolean;
busy: boolean;
matchesTerminal: boolean;
paneId: string;
connectionId: string | null;
path: string | null;
connected: boolean;
refresh: () => Promise<void>;
}
/** Coalesce completion bursts; never poll or carry refreshes across pane changes. */
export function useSftpCommandRefresh(options: Options): void {
const latest = useRef(options);
latest.current = options;
const drain = useRef(() => {});
useEffect(() => {
if (!options.enabled || !options.visible || !options.sessionId) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
let refreshing = false;
let disposed = false;
const eligible = () => {
const live = latest.current;
return !disposed && live.enabled && live.visible && live.connected && live.matchesTerminal
&& live.sessionId === options.sessionId && live.paneId === options.paneId
&& live.connectionId === options.connectionId && live.path === options.path;
};
const schedule = () => {
if (!pending || refreshing || latest.current.busy || !eligible()) return;
clearTimeout(timer);
timer = setTimeout(() => {
if (!eligible() || latest.current.busy) return;
pending = false;
refreshing = true;
void latest.current.refresh().catch(() => {}).finally(() => {
refreshing = false;
schedule();
});
}, 250);
};
drain.current = schedule;
const unsubscribe = subscribeTerminalCommandCompletion((sessionId) => {
if (sessionId !== options.sessionId || !eligible()) return;
pending = true;
schedule();
});
return () => { disposed = true; clearTimeout(timer); unsubscribe(); drain.current = () => {}; };
}, [options.enabled, options.visible, options.sessionId, options.paneId, options.connectionId, options.path]);
useEffect(() => { drain.current(); }, [options.busy]);
}
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useSftpPaneActions } from './useSftpPaneActions';
import { createEmptyPane, type SftpPane } from './types';
import type { SftpFileEntry } from '../../../types';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('command refresh keeps current directory/filter/selection and drops deleted selections', async () => {
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '1970-01-01' });
let result = [file('kept'), file('new')];
const selection = new Set(['kept']);
const pane: SftpPane = { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', status: 'connected', currentPath: '/browsed', homeDir: '/', isLocal: true }, files: [file('kept')], selectedFiles: selection, filter: 'ke' };
const left = { current: { tabs: [pane], activeTabId: pane.id } };
const calls: string[] = [];
let actions: ReturnType<typeof useSftpPaneActions>;
const update: Parameters<typeof useSftpPaneActions>[0]['updateTab'] = (_side, id, updater) => {
left.current.tabs = left.current.tabs.map((p) => p.id === id ? updater(p) : p);
};
function Probe() {
actions = useSftpPaneActions({
hosts: [], getActivePane: () => left.current.tabs[0], updateTab: update,
updateActiveTab: (side, updater) => update(side, pane.id, updater),
leftTabsRef: left, rightTabsRef: { current: { tabs: [], activeTabId: null } },
navSeqRef: { current: { left: 0, right: 0 } }, dirCacheRef: { current: new Map() },
sftpSessionsRef: { current: new Map() }, lastConnectedHostRef: { current: { left: null, right: null } },
connectionCacheKeyMapRef: { current: new Map() }, makeCacheKey: (id, path) => id + path,
clearCacheForConnection() {}, listLocalFiles: async (path) => { calls.push(path); return result; },
listRemoteFiles: async () => [], handleSessionError() {}, releaseConnection: async () => {},
isSessionError: () => false, clearSelectionsExcept() {}, dirCacheTtlMs: 1000,
});
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
await actions!.refresh('left', { tabId: pane.id, preserveSelection: true });
assert.deepEqual(calls, ['/browsed']);
assert.equal(left.current.tabs[0].selectedFiles, selection);
assert.equal(left.current.tabs[0].filter, 'ke');
assert.deepEqual(left.current.tabs[0].files.map((f) => f.name), ['kept', 'new']);
result = [file('new')];
await actions!.refresh('left', { preserveSelection: true });
assert.equal(left.current.tabs[0].selectedFiles.size, 0);
assert.equal(left.current.tabs[0].connection?.currentPath, '/browsed');
await act(async () => { renderer.unmount(); });
});
+15 -6
View File
@@ -27,6 +27,13 @@ import {
/** Shared empty set for navigation resets — never mutate this. */
const EMPTY_SET = new Set<string>();
/** Retain selection identity when no selected entry disappeared. */
export function retainSftpSelection(selected: Set<string>, files: SftpFileEntry[]): Set<string> {
const names = new Set(files.map((file) => file.name));
const retained = new Set([...selected].filter((name) => names.has(name)));
return retained.size === selected.size ? selected : retained;
}
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -53,13 +60,14 @@ interface UseSftpPaneActionsParams {
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
preserveSelection?: boolean;
tabId?: string;
shouldApply?: () => boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
@@ -179,6 +187,7 @@ export const useSftpPaneActions = ({
pane.connection.homeDir,
);
const preserveSelection = options?.preserveSelection && normalizedPath === pane.connection.currentPath;
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
@@ -281,7 +290,7 @@ export const useSftpPaneActions = ({
connection: prev.connection
? { ...prev.connection, currentPath: normalizedPath }
: null,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
loading: true,
error: null,
@@ -369,7 +378,7 @@ export const useSftpPaneActions = ({
connectionId,
path: normalizedPath,
files,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
filter: nextConfirmedFilter,
});
@@ -380,7 +389,7 @@ export const useSftpPaneActions = ({
: null,
files,
loading: false,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
}));
if (!pane.connection.isLocal) {
@@ -445,7 +454,7 @@ export const useSftpPaneActions = ({
);
const refresh = useCallback(
async (side: "left" | "right", options?: { tabId?: string }) => {
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
@@ -476,7 +485,7 @@ export const useSftpPaneActions = ({
}
return;
}
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
// the active tab). Just leave the error state for the user to see
@@ -0,0 +1,12 @@
type Listener = (sessionId: string) => void;
const listeners = new Set<Listener>();
/** Renderer-local fan-out of the terminal's existing completion detection. */
export function publishTerminalCommandCompletion(sessionId: string): void {
for (const listener of listeners) listener(sessionId);
}
export function subscribeTerminalCommandCompletion(listener: Listener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
+4 -1
View File
@@ -31,6 +31,7 @@ import {
type TerminalSessionInfo,
} from '../../infrastructure/ai/aiChatStreamingSupport';
import { useAgentCompactionUi } from './useAgentCompactionUi';
import { useI18n } from '../i18n/I18nProvider';
export { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
export type { ActiveCompactionUi } from './useAgentCompactionUi';
@@ -134,6 +135,7 @@ export function useAIChatStreaming({
updateMessageById,
persistContextCompaction,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
const { t } = useI18n();
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
);
@@ -195,7 +197,8 @@ export function useAIChatStreaming({
setStreamingForScope,
getLatestSession: (sessionId: string) => latestAISessionsSnapshot?.find(s => s.id === sessionId),
persistContextCompaction,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction]);
translate: t,
}), [addMessageToSession, updateLastMessage, updateMessageById, reportStreamError, setStreamingForScope, persistContextCompaction, t]);
const sendToExternalAgent = useCallback(async (
sessionId: string,
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useComposeBarHistory } from './useComposeBarHistory';
import { pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
test('changing workspace focus resets navigation without replacing the draft', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
pruneComposeBarHistory([]);
recordComposeBarHistory('a', 'a1');
recordComposeBarHistory('a', 'a2');
recordComposeBarHistory('b', 'b1');
let history!: ReturnType<typeof useComposeBarHistory>;
function Harness({ sessionId }: { sessionId: string }) {
history = useComposeBarHistory(sessionId);
return null;
}
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness, { sessionId: 'a' })); });
assert.equal(history.navigate('unsent draft', 'up'), 'a2');
await act(async () => { root.update(React.createElement(Harness, { sessionId: 'b' })); });
assert.equal(history.navigate('unsent draft', 'down'), undefined);
assert.equal(history.navigate('unsent draft', 'up'), 'b1');
assert.equal(history.navigate('b1', 'down'), 'unsent draft');
recordComposeBarHistory('b', 'later completion');
assert.equal(history.navigate('unsent draft', 'up'), 'later completion');
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
test('an in-progress history walk stays stable when a late send evicts the oldest entry', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
pruneComposeBarHistory([]);
for (let index = 0; index < 100; index += 1) recordComposeBarHistory('a', `h${index}`);
let history!: ReturnType<typeof useComposeBarHistory>;
function Harness() { history = useComposeBarHistory('a'); return null; }
let root!: ReactTestRenderer;
await act(async () => { root = create(React.createElement(Harness)); });
const finish = history.prepareRecord();
assert.equal(history.navigate('draft', 'up'), 'h99');
finish('h100');
assert.equal(history.navigate('h99', 'up'), 'h98');
assert.equal(history.navigate('h98', 'down'), 'h99');
assert.equal(history.navigate('h99', 'down'), 'draft');
assert.equal(history.navigate('draft', 'up'), 'h100');
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import { navigateComposeBarHistory, type ComposeBarHistoryDirection } from '../../domain/composeBarHistory';
import { getComposeBarHistory, createComposeBarHistoryRecorder } from './composeBarHistoryStore';
export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: string) => void) {
const cursor = useRef<{ index: number; draft: string; entries?: readonly string[] }>({ index: Infinity, draft: '' });
const reset = useCallback(() => {
cursor.current = { index: Infinity, draft: '' };
}, []);
// Keep the existing textarea draft when workspace focus changes, but begin
// a fresh history walk in the newly focused session.
useLayoutEffect(() => {
if (cursor.current.entries) restoreDraft?.(cursor.current.draft);
reset();
}, [reset, restoreDraft, sessionId]);
const prepareRecord = useCallback(() => createComposeBarHistoryRecorder(sessionId), [sessionId]);
const navigate = useCallback((currentValue: string, direction: ComposeBarHistoryDirection) => {
// A late send can evict old entries. Keep one walk stable until the user
// returns to the draft or edits, then pick up the latest history.
const entries = cursor.current.entries ?? getComposeBarHistory(sessionId);
const result = navigateComposeBarHistory({
...cursor.current,
entries,
currentValue,
}, direction);
if (result) {
cursor.current = {
index: result.index === entries.length ? Infinity : result.index,
draft: result.draft,
entries: result.index === entries.length ? undefined : entries,
};
}
return result?.value;
}, [sessionId]);
return { prepareRecord, navigate, reset };
}
+86
View File
@@ -1981,3 +1981,89 @@ test("tab bar position survives settings export and import, with a safe fallback
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
});
for (const enabled of [true, false]) {
test(`right-click long press preference survives sync (${enabled})`, async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ rightClickLongPressMenu: enabled }));
const payload = buildSyncPayload(vault());
assert.equal(payload.settings?.terminalSettings?.rightClickLongPressMenu, enabled);
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ rightClickLongPressMenu: !enabled }));
await applySyncPayload(payload, { importVaultData: () => {} });
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restored.rightClickLongPressMenu, enabled);
});
}
test('custom provider headers use portable cloud secrets and local encryption with legacy preservation', async () => {
localStorage.clear();
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 2)]).toString('base64')}`;
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
credentialsEncrypt: async () => sealed,
credentialsDecrypt: async (value: string) => value === sealed ? 'tenant-secret' : value,
},
dispatchEvent: () => true,
} });
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: 'custom-headers', providerId: 'custom', enabled: true, customHeaders: { 'X-Tenant': sealed },
}]));
const plainSettings = buildSyncPayload(vault([]));
assert.equal(plainSettings.settings?.ai?.providers?.[0]?.customHeaders, undefined);
const cloud = await buildCloudSyncPayload(vault([]));
assert.deepEqual(cloud.settings?.ai?.providers?.[0]?.customHeaders, { 'X-Tenant': 'tenant-secret' });
await applySyncPayload(cloud, { importVaultData: () => {} });
const read = () => JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0];
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
await applySyncPayload(plainSettings, { importVaultData: () => {} });
assert.deepEqual(read().customHeaders, { 'X-Tenant': sealed });
cloud.settings!.ai!.providers![0].customHeaders = {};
await applySyncPayload(cloud, { importVaultData: () => {} });
assert.deepEqual(read().customHeaders, {});
});
for (const localRestore of [false, true]) {
test(`header encryption failure leaves vault and settings untouched (${localRestore ? 'restore' : 'sync'})`, async () => {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_THEME, 'light');
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, '[]');
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => { throw new Error('keychain locked'); } },
dispatchEvent: () => true,
} });
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { Authorization: 'secret' } }] },
} };
const original = JSON.stringify(payload);
let imports = 0;
let completed = 0;
let commits = 0;
const importers = { importVaultData: () => { imports++; }, onSettingsApplied: () => { completed++; } };
await assert.rejects(localRestore
? applyLocalVaultPayload(payload, importers, { prepareConvergentRestore: async () => async () => { commits++; } })
: applySyncPayload(payload, importers));
assert.equal(imports, 0);
assert.equal(completed, 0);
assert.equal(commits, 0);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_THEME), 'light');
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS), '[]');
assert.equal(JSON.stringify(payload), original);
});
}
test('sync prepares header encryption once before importing and leaves input unchanged', async () => {
localStorage.clear();
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 4)]).toString('base64')}`;
const events: string[] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: { credentialsEncrypt: async () => { events.push('encrypt'); return sealed; } },
dispatchEvent: () => true,
} });
const payload: SyncPayload = { ...vault([]), syncedAt: 1, settings: {
theme: 'dark', ai: { providers: [{ id: 'custom', customHeaders: { 'X-Tenant': 'secret' } }] },
} };
const original = JSON.stringify(payload);
await applySyncPayload(payload, { importVaultData: () => { events.push('import'); } });
assert.deepEqual(events, ['encrypt', 'import']);
assert.equal(JSON.stringify(payload), original);
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!)[0].customHeaders, { 'X-Tenant': sealed });
});
+50 -21
View File
@@ -1,4 +1,5 @@
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
/**
* Sync Payload Builders - Single source of truth for constructing and applying
* the encrypted cloud-sync payload.
@@ -261,11 +262,11 @@ const SYNCABLE_TERMINAL_KEYS = [
'kittyKeyboardProtocolEnabled',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
'rightClickBehavior', 'rightClickLongPressMenu', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'autoUploadClipboardImageOnPaste',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode', 'tabDoubleClickBehavior',
'autoCloseOnExit', 'disconnectedNoticeMode',
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
'serverStatsRefreshInterval',
@@ -402,13 +403,13 @@ const mergeAiProvidersPreservingLocalApiKeys = (
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
}
return incoming.map((provider) => {
if (provider.apiKey != null) return provider;
const id = typeof provider.id === 'string' ? provider.id : undefined;
const localProvider = id != null ? localById.get(id) : undefined;
if (localProvider && typeof localProvider.apiKey === 'string') {
return { ...provider, apiKey: localProvider.apiKey };
}
return provider;
return {
...provider,
...(provider.apiKey == null && typeof localProvider?.apiKey === 'string' ? { apiKey: localProvider.apiKey } : {}),
...(provider.customHeaders == null && localProvider?.customHeaders != null ? { customHeaders: localProvider.customHeaders } : {}),
};
});
};
@@ -564,7 +565,11 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
if (providers) ai.providers = providers.map((provider) => {
const next = { ...stripDeviceBoundApiKey(provider) };
delete next.customHeaders;
return next;
});
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
@@ -626,7 +631,12 @@ export async function collectCloudSyncableSettings(): Promise<SyncPayload['setti
};
if (providers) {
ai.providers = await Promise.all(providers.map(withPortableApiKey));
ai.providers = await Promise.all(providers.map(async (provider) => {
const next = await withPortableApiKey(provider);
return isRecord(provider.customHeaders)
? { ...next, customHeaders: await decryptProviderHeaders(provider.customHeaders as Record<string, string>) }
: next;
}));
}
if (webSearchConfig) {
ai.webSearchConfig = await withPortableApiKey(webSearchConfig);
@@ -663,7 +673,10 @@ function collectLocalBackupSettings(): SyncPayload['settings'] {
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
async function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): Promise<void> {
async function applySyncableSettings(
settings: NonNullable<SyncPayload['settings']>,
preparedProviders: Record<string, unknown>[] | undefined,
): Promise<void> {
// Theme & Appearance
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
@@ -824,11 +837,10 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
const ai = settings.ai;
if (ai) {
if (ai.providers != null) {
const providers = await Promise.all(ai.providers.map(withLocalEncryptedApiKey));
if (preparedProviders != null) {
localStorageAdapter.write(
STORAGE_KEY_AI_PROVIDERS,
mergeAiProvidersPreservingLocalApiKeys(providers),
mergeAiProvidersPreservingLocalApiKeys(preparedProviders),
);
}
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
@@ -1145,7 +1157,7 @@ export async function buildLocalVaultPayloadAsync(
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
function applyPayload(
async function preparePayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
options: {
@@ -1153,11 +1165,20 @@ function applyPayload(
applyPluginSidecars?: PluginSyncSidecarApplier;
currentHosts?: Host[];
},
): Promise<void> {
): Promise<() => Promise<void>> {
// Portable payloads must never keep device-bound enc:v1 blobs. Strip them
// so a previously poisoned cloud/backup snapshot can still restore host
// shells and let the user re-enter secrets (#2702).
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
// Prepare credentials before any vault or settings mutation. Encryption can
// fail when local credential storage is unavailable; reuse the result below.
const providers = sanitizedPayload.settings?.ai?.providers;
const preparedProviders = providers == null ? undefined : await Promise.all(providers.map(async (provider) => {
const next = await withLocalEncryptedApiKey(provider);
return isRecord(provider.customHeaders)
? { ...next, customHeaders: await encryptProviderHeaders(await decryptProviderHeaders(provider.customHeaders as Record<string, string>)) }
: next;
}));
const legacyLineTimestampsEnabled = sanitizedPayload.settings?.terminalSettings?.showLineTimestamps === true;
let hosts = migrateHostsFromLegacyLineTimestamps(sanitizedPayload.hosts, legacyLineTimestampsEnabled);
if (!options.includeLocalOnlyData) {
@@ -1189,7 +1210,7 @@ function applyPayload(
vaultImport.groupConfigs = sanitizedPayload.groupConfigs;
}
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
return () => Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(async () => {
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
@@ -1200,7 +1221,7 @@ function applyPayload(
// Apply synced settings
if (sanitizedPayload.settings) {
await applySyncableSettings(sanitizedPayload.settings);
await applySyncableSettings(sanitizedPayload.settings, preparedProviders);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (sanitizedPayload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
@@ -1215,21 +1236,28 @@ function applyPayload(
});
}
export function applySyncPayload(
export function prepareSyncPayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
options?: {
applyPluginSidecars?: PluginSyncSidecarApplier;
currentHosts?: Host[];
},
): Promise<void> {
return applyPayload(payload, importers, {
): Promise<() => Promise<void>> {
return preparePayloadApply(payload, importers, {
includeLocalOnlyData: false,
applyPluginSidecars: options?.applyPluginSidecars,
currentHosts: options?.currentHosts,
});
}
export async function applySyncPayload(
...args: Parameters<typeof prepareSyncPayloadApply>
): Promise<void> {
const applyPreparedPayload = await prepareSyncPayloadApply(...args);
await applyPreparedPayload();
}
export async function prepareLocalVaultPayloadApply(
payload: SyncPayload,
importers: SyncPayloadImporters,
@@ -1244,9 +1272,10 @@ export async function prepareLocalVaultPayloadApply(
const sanitizedPayload = stripSyncPayloadEncryptedCredentials(payload);
const prepareConvergentRestore = dependencies.prepareConvergentRestore
?? prepareRestoredPayloadConvergentWrites;
const applyPreparedPayload = await preparePayloadApply(sanitizedPayload, importers, { includeLocalOnlyData: true });
const commitConvergentRestore = await prepareConvergentRestore(sanitizedPayload);
return async () => {
await applyPayload(sanitizedPayload, importers, { includeLocalOnlyData: true });
await applyPreparedPayload();
await commitConvergentRestore();
};
}
+1 -1
View File
@@ -189,7 +189,7 @@ test('normalizeStoredAgentModelSelection keeps bare CodeBuddy ids unsuffixed', (
const presets: AgentModelPreset[] = [{
id: 'glm-5.1',
name: 'GLM 5.1',
thinkingLevels: ['low', 'medium', 'high', 'xhigh'],
thinkingLevels: ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
defaultThinkingLevel: 'medium',
encodeDefaultThinking: false,
}];
+5 -5
View File
@@ -50,7 +50,7 @@ import { CloudSyncDashboardTabs } from './cloud-sync/CloudSyncDashboardTabs';
interface SyncDashboardProps {
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
onBuildLocalPayload: () => SyncPayload;
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyConvergentPayload: (
payload: SyncPayload,
@@ -63,7 +63,7 @@ interface SyncDashboardProps {
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onBuildLocalPayload,
onApplyMigrationPayload,
onPrepareMigrationPayload,
onApplyPayload,
onApplyConvergentPayload,
onApplyLocalPayload,
@@ -336,7 +336,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
prepared: preparedConvergentMigration,
buildCurrentPayload: onBuildPayload,
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: onApplyMigrationPayload,
preparePayloadApply: onPrepareMigrationPayload,
translateProtectiveBackupFailure: (message) =>
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
});
@@ -349,7 +349,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
} finally {
setConvergentBusy(false);
}
}, [onApplyMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
}, [onPrepareMigrationPayload, onBuildLocalPayload, onBuildPayload, preparedConvergentMigration, sync, t]);
const handleResolveConvergentConflict = useCallback(async (
addressKey: string,
@@ -1053,7 +1053,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
interface CloudSyncSettingsProps {
onBuildPayload: () => SyncPayload | Promise<SyncPayload>;
onBuildLocalPayload: () => SyncPayload;
onApplyMigrationPayload: (payload: SyncPayload) => void | Promise<void>;
onPrepareMigrationPayload: (payload: SyncPayload) => Promise<() => Promise<void>>;
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
onApplyConvergentPayload: (
payload: SyncPayload,
+8
View File
@@ -57,6 +57,7 @@ import { Switch } from "./ui/switch";
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast";
import { HostNotesEditor } from "./host/HostNotesEditor";
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
@@ -547,6 +548,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
const result: GroupConfig = {
path: newPath,
notes: form.notes?.trim() || undefined,
// Only include SSH fields if SSH section is enabled
...(sshEnabled && {
protocol: 'ssh' as const,
@@ -994,6 +996,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
</HostDetailsSection>
</>)}
<HostNotesEditor
panelKey={groupPath}
value={form.notes ?? ""}
onChange={(notes) => update("notes", notes)}
/>
{/* Add Protocol Button — always at the bottom */}
{addableProtocols.length > 0 && (
<Dropdown open={addProtocolOpen} onOpenChange={setAddProtocolOpen}>
+19
View File
@@ -168,6 +168,7 @@ test("HostTreeView shows selected groups in multi-select mode", () => {
children: {},
hosts: [],
}]}
groupConfigs={[{ path: "production", notes: "# VPN instructions" }]}
hosts={[]}
expandedPaths={new Set<string>()}
onTogglePath={() => undefined}
@@ -188,6 +189,7 @@ test("HostTreeView shows selected groups in multi-select mode", () => {
/>,
);
assert.match(markup, /aria-label="Group notes"/);
assert.match(markup, /data-selected="true"/);
assert.match(markup, /data-group-path="production"/);
assert.match(markup, /role="tree"/);
@@ -478,3 +480,20 @@ test("HostTreeView announces sibling position within each tree level", () => {
assert.equal(byKey.get("host:host-c")?.posInSet, 1);
assert.equal(byKey.get("host:host-c")?.setSize, 1);
});
test("HostTreeView IP sorting stays inside each group and leaves manual order intact", () => {
const pair = (prefix: string, group = ""): Host[] => [
{ ...baseHost, id: `${prefix}-ten`, label: "Alpha", hostname: "10.0.0.10", group, order: 1 },
{ ...baseHost, id: `${prefix}-two`, label: "Zulu", hostname: "10.0.0.2", group, order: 2 },
];
const groupTree: GroupNode[] = [{
name: "prod", path: "prod", hosts: pair("prod", "prod"),
children: { east: { name: "east", path: "prod/east", hosts: pair("east", "prod/east"), children: {} } },
}];
const options = {
groupTree, ungroupedHosts: pair("root"), expandedPaths: new Set(["prod", "prod/east"]), groupConfigs: [],
};
const keys = (sortMode: "ip" | "manual") => buildVisibleHostTreeItems({ ...options, sortMode }).map(item => item.key);
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
});
+21 -14
View File
@@ -1,3 +1,4 @@
import { compareHostAddresses } from "../domain/hostAddressSort";
import { CheckSquare, Edit2, FileSymlink, Server, Square, Expand, Minimize2 } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -39,7 +40,7 @@ const getTreeGroupDropIntent = (
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
type HostTreeSortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
type HostTreeSortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group' | 'ip';
export type VisibleHostTreeItem =
| {
@@ -90,6 +91,7 @@ const sortHostTreeHosts = (hosts: Host[], sortMode: HostTreeSortMode): Host[] =>
if (sortMode === 'newest') return (b.createdAt || 0) - (a.createdAt || 0);
if (sortMode === 'oldest') return (a.createdAt || 0) - (b.createdAt || 0);
if (sortMode === 'manual') return 0;
if (sortMode === 'ip') return compareHostAddresses(a.hostname, b.hostname) || a.label.localeCompare(b.label);
return a.label.localeCompare(b.label);
});
return sortMode === 'manual' ? sortByVaultOrder(sorted) : sorted;
@@ -538,19 +540,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
? <CheckSquare size={16} />
: <Square size={16} />
: undefined}
labelActions={!isMultiSelectMode && (
<button
aria-label={`Edit ${node.name}`}
tabIndex={-1}
data-host-tree-group-edit-button={node.path}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={12} />
</button>
labelActions={(
<span className="inline-flex items-center gap-1">
<HostNotesIndicator notes={groupConfigs.find((config) => config.path === node.path)?.notes} label="Group notes" />
{!isMultiSelectMode && (
<button
aria-label={`Edit ${node.name}`}
tabIndex={-1}
data-host-tree-group-edit-button={node.path}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={12} />
</button>
)}
</span>
)}
/>
</CollapsibleTrigger>
+1 -1
View File
@@ -880,7 +880,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
<SortDropdown
value={sortMode}
onChange={(mode) => {
if (mode !== "group") setSortMode(mode);
if (mode !== "group" && mode !== "ip") setSortMode(mode);
}}
modes={["manual", "az", "za", "newest", "oldest"]}
className={vaultHeaderIconButtonClass}
+3
View File
@@ -1,3 +1,4 @@
import { compareHostAddresses } from "../domain/hostAddressSort";
import {
Check,
CheckSquare,
@@ -200,6 +201,8 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
result = [...result].sort((a, b) => {
switch (sortMode) {
case 'ip':
return compareHostAddresses(a.hostname, b.hostname) || a.label.localeCompare(b.label);
case 'az':
return a.label.localeCompare(b.label);
case 'za':
+21
View File
@@ -1,3 +1,4 @@
import { useSftpCommandRefresh } from "../application/state/sftp/useSftpCommandRefresh";
/**
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
@@ -1619,6 +1620,22 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|| (sftp.activeExternalEditCount ?? 0) > 0;
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
const [autoRefresh, setAutoRefresh] = useState(true);
useSftpCommandRefresh({
sessionId: focusedSessionId ?? activeSessionId,
enabled: autoRefresh,
visible: isVisible && ownerPanelOpen,
busy: hasActiveWork || sftp.leftPane.loading,
matchesTerminal: !!activeHost && !!connectedHostObjRef.current
&& sftpHostEndpointsEqual(activeHost, connectedHostObjRef.current)
&& sftp.leftPane.connection?.hostId === activeHost.id,
paneId: sftp.leftPane.id,
connectionId,
path: connectionPath,
connected: sftp.leftPane.connection?.status === "connected",
refresh: () => sftpRef.current.refresh("left", { tabId: sftp.leftPane.id, preserveSelection: true }),
});
const {
handleGoToTerminalCwd,
@@ -1860,6 +1877,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
data-section="terminal-sftp-panel"
onClick={handlePaneFocus}
>
<label className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-1 text-xs text-muted-foreground">
<input type="checkbox" checked={autoRefresh} onChange={(event) => setAutoRefresh(event.target.checked)} />
{t("sftp.refreshAfterCommand")}
</label>
{showWorkspaceHostHeader && displayHost && (
<div
className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 bg-muted/20 px-3 flex items-center`}
+1
View File
@@ -1738,6 +1738,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={sortMode}
onChange={setSortMode}
className={vaultHeaderIconButtonClass}
modes={['manual', 'az', 'za', 'newest', 'oldest', 'group']}
/>
<Tooltip>
<TooltipTrigger asChild>
+6 -3
View File
@@ -1,3 +1,4 @@
import { publishTerminalCommandCompletion } from "../application/state/terminalCommandCompletion";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
@@ -2367,9 +2368,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandSubmitted?.(...args);
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
const pluginAwareOnCommandCompleted = useCallback(() => {
publishTerminalCommandCompletion(sessionId);
pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}, [pluginTerminalLifecycle]);
}, [pluginTerminalLifecycle, sessionId]);
const pluginAwareOnTerminalCwdChange = useCallback((
changedSessionId: string,
cwd: string | null,
@@ -2841,8 +2843,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
currentRow: buffer.baseY + buffer.cursorY,
lines,
};
});
}, [sessionId]);
}, readTerminalContext);
}, [readTerminalContext, sessionId]);
useEffect(() => {
const startHandler = (event: Event) => {
@@ -3377,6 +3379,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalContextActions = useTerminalContextActions({
termRef,
sessionName: sessionDisplayName,
sourceSessionId: sessionId,
sessionRef,
scrollOnPasteRef,
+21 -5
View File
@@ -1,3 +1,4 @@
import { pruneComposeBarHistory } from '../application/state/composeBarHistoryStore';
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore } from '../application/state/activeTabStore';
@@ -301,6 +302,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
closePane: closeSidePanelPaneForTab,
resizeSplit: resizeSidePanelSplitForTab,
} = useTerminalSidePanelLayoutState();
useEffect(() => {
pruneComposeBarHistory(sessions.map((session) => session.id));
}, [sessions]);
useEffect(() => {
setMagnifiedPane((current) => {
if (!current) return current;
@@ -2137,9 +2142,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
toast.info(t('scripts.recording.started'));
}, [getActiveTerminalSessionId, t]);
const handleComposeSend = useCallback((text: string) => {
const handleComposeSend = useCallback(async (text: string) => {
const activeWorkspace = activeWorkspaceRef.current;
if (!activeWorkspace) return;
if (!activeWorkspace) return false;
let recordHistory = false;
const pendingSends: Promise<boolean>[] = [];
const payload = text + '\r';
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
const focusedSessionId = activeWorkspace.focusedSessionId;
@@ -2155,11 +2162,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (isTerminalSensitiveInputActive(sid)) continue;
const executor = snippetExecutorsRef.current.get(sid);
if (executor) {
executor(text, false, { broadcast: false });
pendingSends.push(Promise.resolve(executor(text, false, { broadcast: false })).then(
(sent) => sent && !isTerminalSensitiveInputActive(sid),
));
} else {
const session = sessionsRef.current.find((candidate) => candidate.id === sid);
if (!session || !canUseDirectSessionWriteFallback(session)) continue;
terminalBackend.writeToSession(sid, payload, { sensitive: false });
recordHistory = recordHistory || session.status === 'connected';
}
}
} else {
@@ -2171,16 +2181,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (targetId) {
const executor = snippetExecutorsRef.current.get(targetId);
if (executor) {
executor(text, false);
const sensitive = isTerminalSensitiveInputActive(targetId);
pendingSends.push(Promise.resolve(executor(text, false)).then(
(sent) => sent && !sensitive && !isTerminalSensitiveInputActive(targetId),
));
} else {
const session = sessionsRef.current.find((candidate) => candidate.id === targetId);
if (!session || !canUseDirectSessionWriteFallback(session)) return;
if (!session || !canUseDirectSessionWriteFallback(session)) return false;
recordHistory = session.status === 'connected' && !isTerminalSensitiveInputActive(targetId);
terminalBackend.writeToSession(targetId, payload, {
sensitive: isTerminalSensitiveInputActive(targetId),
});
}
}
}
const results = await Promise.allSettled(pendingSends);
return recordHistory || results.some((result) => result.status === 'fulfilled' && result.value);
}, [isBroadcastEnabled, terminalBackend]);
const sessionLogConfig = useMemo(
+3
View File
@@ -1,3 +1,4 @@
import { setupVaultAgentBridge } from "../infrastructure/ai/vaultAgentBridgeClient";
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
@@ -537,6 +538,8 @@ export default function TerminalPopupPage({
settings: SettingsState;
allowTerminalStart?: boolean;
}) {
useEffect(() => setupVaultAgentBridge(), []);
return (
<I18nProvider locale={settings.uiLanguage}>
<TerminalPopupPageInner
+27 -7
View File
@@ -32,7 +32,7 @@ const {
} = await import("../application/state/terminalDragData.ts");
const {
activateLogViewTab,
createTopTabCopyDoubleClickHandler,
createTopTabSessionDoubleClickHandler,
formatSessionTopTabLabel,
formatSessionTopTabTooltip,
resolveSessionTabCodingCliIconState,
@@ -41,6 +41,7 @@ const {
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const topTabsQuickControlsSource = readFileSync(new URL("./TopTabsQuickControls.tsx", import.meta.url), "utf8");
const syncStatusButtonSource = readFileSync(new URL("./SyncStatusButton.tsx", import.meta.url), "utf8");
const switchSource = readFileSync(new URL("./ui/switch.tsx", import.meta.url), "utf8");
@@ -53,7 +54,6 @@ const appSource = [
].join("\n");
const externalMcpToggleSource = readFileSync(new URL("../application/state/useExternalMcpToggleState.ts", import.meta.url), "utf8");
const zhTwAiSource = readFileSync(new URL("../application/i18n/locales/zh-TW/ai.ts", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
test("host tree tab gutter fills the remaining sidebar width", () => {
@@ -248,17 +248,37 @@ test("disabling dynamic titles freezes a stored coding CLI icon and stops title
);
});
test("session top tabs copy the session on double click through the existing copy handler", () => {
test("session top tab double click dispatches the configured behavior", () => {
const copiedSessionIds: string[] = [];
const handleDoubleClick = createTopTabCopyDoubleClickHandler(
(sessionId) => copiedSessionIds.push(sessionId),
"session-1",
const duplicatedSessionIds: string[] = [];
const handlers = (["duplicate", "copy", "disabled"] as const).map((behavior) =>
createTopTabSessionDoubleClickHandler({
behavior,
onCopySession: (sessionId) => copiedSessionIds.push(sessionId),
onDuplicateSession: (sessionId) => duplicatedSessionIds.push(sessionId),
sessionId: `session-${behavior}`,
}),
);
handlers.forEach((handler) => handler({} as Parameters<typeof handler>[0]));
assert.deepEqual(duplicatedSessionIds, ["session-duplicate"]);
assert.deepEqual(copiedSessionIds, ["session-copy"]);
assert.match(topTabItemsSource, /behavior: tabDoubleClickBehavior/);
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
});
test("duplicate double-click behavior falls back to copying when unavailable", () => {
const copiedSessionIds: string[] = [];
const handleDoubleClick = createTopTabSessionDoubleClickHandler({
behavior: "duplicate",
onCopySession: (sessionId) => copiedSessionIds.push(sessionId),
sessionId: "session-1",
});
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
assert.deepEqual(copiedSessionIds, ["session-1"]);
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
});
test("session close button double click stays on the close button", () => {
+5 -1
View File
@@ -11,7 +11,7 @@ import {
appendHostFromWorkspaceDrop,
resolveFocusSidebarDragKind,
} from '../domain/focusSidebarHostDrop';
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
import type { DynamicTabTitleMode, KeyBinding, TerminalTabDoubleClickBehavior } from '../domain/models';
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
import {
@@ -168,6 +168,7 @@ interface TopTabsProps {
showHostTreeSidebar: boolean;
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
dynamicTabTitleMode?: DynamicTabTitleMode;
tabDoubleClickBehavior: TerminalTabDoubleClickBehavior;
editorTabs: readonly EditorTabChrome[];
pluginViewTabs: readonly PluginViewTab[];
onClosePluginViewTab: (tabId: string) => void;
@@ -217,6 +218,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
showHostTreeSidebar,
switchTabKeyBinding,
dynamicTabTitleMode,
tabDoubleClickBehavior,
editorTabs,
pluginViewTabs,
onClosePluginViewTab,
@@ -901,6 +903,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onEditHost={onEditHost}
renderBulkCloseItems={renderBulkCloseItems}
dynamicTabTitleMode={dynamicTabTitleMode}
tabDoubleClickBehavior={tabDoubleClickBehavior}
t={t}
tabAnimationClass={getTabAnimationClass(session.id)}
shortcutNumber={tabShortcutNumbers?.get(session.id)}
@@ -1320,6 +1323,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.showHostTreeSidebar === next.showHostTreeSidebar &&
prev.switchTabKeyBinding === next.switchTabKeyBinding &&
prev.dynamicTabTitleMode === next.dynamicTabTitleMode &&
prev.tabDoubleClickBehavior === next.tabDoubleClickBehavior &&
prev.hostById === next.hostById
);
};
@@ -162,3 +162,12 @@ test("Hosts manual sort mode uses saved order", () => {
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
});
test("Hosts IP sort is restored and compares octets instead of labels", () => {
const markup = renderVault("ip", [
{ ...host("ten", "Alpha Ten", 1), hostname: "10.0.0.10" },
{ ...host("two", "Zulu Two", 2), hostname: "10.0.0.2" },
]);
assert.ok(markup.includes("Zulu Two"));
assert.ok(markup.indexOf("Zulu Two") < markup.indexOf("Alpha Ten"));
});
+2 -1
View File
@@ -207,7 +207,8 @@ const isSortMode = (value: string): value is SortMode =>
value === "za" ||
value === "newest" ||
value === "oldest" ||
value === "group";
value === "group" ||
value === "ip";
// Props without isActive - it's now subscribed internally
interface VaultViewProps {
+1
View File
@@ -1612,6 +1612,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
<ComposerThinkingChip
levels={thinkingLevels}
selectedLevel={visibleThinking}
allowDefault={hasProviderSwitcher && !!onThinkingLevelChange}
disabled={lockTurnConfiguration}
open={showThinkingPicker}
menuPos={showThinkingPicker ? menuPos : null}
+15 -7
View File
@@ -1,4 +1,4 @@
import { Brain, Check, ChevronDown } from 'lucide-react';
import { Brain, Check, ChevronDown, CircleSlash } from 'lucide-react';
import React, { useRef } from 'react';
import { createPortal } from 'react-dom';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -9,6 +9,8 @@ export interface ComposerThinkingChipProps {
levels: readonly string[];
selectedLevel?: string;
disabled?: boolean;
/** Offer a "provider default" entry that clears the override (empty selection). */
allowDefault?: boolean;
open: boolean;
menuPos: { left: number; bottom: number } | null;
onToggle: (rect: DOMRect | undefined) => void;
@@ -23,6 +25,7 @@ export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
levels,
selectedLevel,
disabled = false,
allowDefault = false,
open,
menuPos,
onToggle,
@@ -71,16 +74,17 @@ export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
className="fixed z-[1000] min-w-[148px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
{levels.map((level) => {
const isSelected = selectedLevel === level;
{(allowDefault ? [undefined, ...levels] : levels).map((level) => {
const isDefault = level === undefined;
const isSelected = isDefault ? !selectedLevel : selectedLevel === level;
return (
<button
key={level}
key={isDefault ? '__default__' : level}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => {
if (!isSelected) onSelect(level);
if (!isSelected) onSelect(isDefault ? '' : level);
else onClose();
}}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
@@ -88,8 +92,12 @@ export const ComposerThinkingChip: React.FC<ComposerThinkingChipProps> = ({
{isSelected
? <Check size={11} className="text-primary shrink-0" />
: <span className="w-[11px] shrink-0" />}
<Brain size={12} className="text-violet-400/70 shrink-0" />
<span className="text-foreground/85">{formatLevel(level)}</span>
{isDefault
? <CircleSlash size={12} className="text-muted-foreground/60 shrink-0" />
: <Brain size={12} className="text-violet-400/70 shrink-0" />}
<span className="text-foreground/85">
{isDefault ? t('ai.chat.thinkingDefault') : formatLevel(level)}
</span>
</button>
);
})}
@@ -0,0 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { HostNotesIndicator } from "./HostNotesIndicator";
test("notes indicators distinguish groups from hosts and hide empty notes", () => {
assert.match(renderToStaticMarkup(<HostNotesIndicator notes="Host" />), /aria-label="Host notes"/);
assert.match(renderToStaticMarkup(<HostNotesIndicator notes="# Project" label="Group notes" />), /aria-label="Group notes"/);
assert.equal(renderToStaticMarkup(<HostNotesIndicator notes=" " label="Group notes" />), "");
});
+3 -1
View File
@@ -7,11 +7,13 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "../ui/hover-card"
export interface HostNotesIndicatorProps {
notes?: string;
className?: string;
label?: string;
}
export const HostNotesIndicator: React.FC<HostNotesIndicatorProps> = ({
notes,
className,
label = "Host notes",
}) => {
const trimmed = notes?.trim();
if (!trimmed) return null;
@@ -25,7 +27,7 @@ export const HostNotesIndicator: React.FC<HostNotesIndicatorProps> = ({
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground",
className,
)}
aria-label="Host notes"
aria-label={label}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
@@ -18,6 +18,12 @@ const dynamicTabTitleModeOptions = (
}
).DYNAMIC_TAB_TITLE_MODE_OPTIONS;
const tabDoubleClickBehaviorOptions = (
terminalBehaviorSettings as {
TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS?: Array<{ value: string; labelKey: string }>;
}
).TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS;
test("middle-click settings expose only supported behaviors", () => {
assert.ok(Array.isArray(middleClickBehaviorOptions));
assert.deepEqual(
@@ -34,6 +40,15 @@ test("dynamic tab title settings expose off, agent-only, and all modes", () => {
);
});
test("tab double-click settings expose duplicate, copy, and disabled modes", () => {
assert.ok(Array.isArray(tabDoubleClickBehaviorOptions));
assert.deepEqual(
tabDoubleClickBehaviorOptions.map((option) => option.value),
["duplicate", "copy", "disabled"],
);
assert.match(source, /updateTerminalSetting\("tabDoubleClickBehavior", v as TerminalTabDoubleClickBehavior\)/);
});
test("terminal behavior settings expose word separator editing", () => {
assert.match(source, /settings\.terminal\.behavior\.wordSeparators/);
assert.match(source, /terminalSettings\.wordSeparators/);
+1 -1
View File
@@ -112,7 +112,7 @@ function getSavedManagedAgentPathInfo(
return {
path: command,
binPath: command,
version: null,
version: managed.cliVersion ?? null,
available: savedAvailable,
installed: true,
authenticated: undefined,
+13 -10
View File
@@ -3,7 +3,7 @@ import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import {
buildCloudSyncPayload,
applySyncPayload,
prepareSyncPayloadApply,
getEffectivePortForwardingRulesForSync,
prepareLocalVaultPayloadApply,
} from "../../../application/syncPayload";
@@ -49,9 +49,9 @@ export default function SettingsSyncTab(props: {
);
}, [vault, getEffectivePortForwardingRules]);
const onApplyMigrationPayload = useCallback(
const onPrepareMigrationPayload = useCallback(
(payload: SyncPayload) =>
applySyncPayload(payload, {
prepareSyncPayloadApply(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
@@ -63,11 +63,11 @@ export default function SettingsSyncTab(props: {
(payload: SyncPayload) =>
applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: () => onApplyMigrationPayload(payload),
prepareApply: () => onPrepareMigrationPayload(payload),
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[onApplyMigrationPayload, onBuildLocalPayload, t],
[onPrepareMigrationPayload, onBuildLocalPayload, t],
);
const onApplyConvergentPayload = useCallback(
@@ -76,14 +76,17 @@ export default function SettingsSyncTab(props: {
commitReplica: () => Promise<void>,
) => applyProtectedSyncPayload({
buildPreApplyPayload: onBuildLocalPayload,
applyPayload: async () => {
await onApplyMigrationPayload(payload);
await commitReplica();
prepareApply: async () => {
const applyPreparedPayload = await onPrepareMigrationPayload(payload);
return async () => {
await applyPreparedPayload();
await commitReplica();
};
},
translateProtectiveBackupFailure: (message) =>
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
}),
[onApplyMigrationPayload, onBuildLocalPayload, t],
[onPrepareMigrationPayload, onBuildLocalPayload, t],
);
const onApplyLocalPayload = useCallback(
@@ -112,7 +115,7 @@ export default function SettingsSyncTab(props: {
<CloudSyncSettings
onBuildPayload={onBuildPayload}
onBuildLocalPayload={onBuildLocalPayload}
onApplyMigrationPayload={onApplyMigrationPayload}
onPrepareMigrationPayload={onPrepareMigrationPayload}
onApplyPayload={onApplyPayload}
onApplyConvergentPayload={onApplyConvergentPayload}
onApplyLocalPayload={onApplyLocalPayload}
@@ -1,6 +1,6 @@
import React from "react";
import { DEFAULT_TERMINAL_WORD_SEPARATORS } from "../../../domain/models";
import type { DisconnectedNoticeMode, DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import type { DisconnectedNoticeMode, DynamicTabTitleMode, LinkModifier, MiddleClickBehavior, OscNotificationMode, RightClickBehavior, TerminalSettings, TerminalTabDoubleClickBehavior } from "../../../domain/models";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsAnchor, SettingRow, Toggle } from "../settings-ui";
@@ -31,6 +31,15 @@ export const DYNAMIC_TAB_TITLE_MODE_OPTIONS: Array<{
{ value: "all", labelKey: "settings.terminal.behavior.dynamicTabTitle.all" },
];
export const TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS: Array<{
value: TerminalTabDoubleClickBehavior;
labelKey: string;
}> = [
{ value: "duplicate", labelKey: "settings.terminal.behavior.tabDoubleClick.duplicate" },
{ value: "copy", labelKey: "settings.terminal.behavior.tabDoubleClick.copy" },
{ value: "disabled", labelKey: "settings.terminal.behavior.tabDoubleClick.disabled" },
];
export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> = ({
t,
terminalSettings,
@@ -83,6 +92,16 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.rightClick.longPressMenu")}
description={t("settings.terminal.behavior.rightClick.longPressMenu.desc")}
>
<Toggle
checked={terminalSettings.rightClickLongPressMenu ?? false}
onChange={(v) => updateTerminalSetting("rightClickLongPressMenu", v)}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.rightClick.fullscreenMenu")}
description={t("settings.terminal.behavior.rightClick.fullscreenMenu.desc")}
@@ -204,6 +223,22 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
<Toggle checked={terminalSettings.forcePromptNewLine ?? false} onChange={(v) => updateTerminalSetting("forcePromptNewLine", v)} />
</SettingRow>
<SettingRow
anchorId="terminal-tab-double-click"
label={t("settings.terminal.behavior.tabDoubleClick")}
description={t("settings.terminal.behavior.tabDoubleClick.desc")}
>
<Select
value={terminalSettings.tabDoubleClickBehavior}
options={TAB_DOUBLE_CLICK_BEHAVIOR_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
onChange={(v) => updateTerminalSetting("tabDoubleClickBehavior", v as TerminalTabDoubleClickBehavior)}
className="w-44"
/>
</SettingRow>
<SettingRow
anchorId="terminal-dynamic-tab-title"
label={t("settings.terminal.behavior.dynamicTabTitle")}
+41 -2
View File
@@ -4,7 +4,10 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import type { CodebuddyAdvancedOptions } from "../../../../infrastructure/ai/types";
import {
isCodebuddySessionPersistenceSupported,
type CodebuddyAdvancedOptions,
} from "../../../../infrastructure/ai/types";
import { parseEnvLines, serializeEnvLines } from "./codebuddyConfigEnv";
const INTERNET_ENV_OPTIONS = [
@@ -15,10 +18,12 @@ const INTERNET_ENV_OPTIONS = [
const EFFORT_OPTIONS = [
{ value: "", labelKey: "ai.codebuddy.effort.default" },
{ value: "minimal", labelKey: "ai.codebuddy.effort.minimal" },
{ value: "low", labelKey: "ai.codebuddy.effort.low" },
{ value: "medium", labelKey: "ai.codebuddy.effort.medium" },
{ value: "high", labelKey: "ai.codebuddy.effort.high" },
{ value: "xhigh", labelKey: "ai.codebuddy.effort.xhigh" },
{ value: "max", labelKey: "ai.codebuddy.effort.max" },
] as const;
export const CodebuddyCard: React.FC<{
@@ -50,6 +55,13 @@ export const CodebuddyCard: React.FC<{
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const persistSessionSupported = Boolean(
found && isCodebuddySessionPersistenceSupported(pathInfo?.version),
);
// Unsupported/unknown CLIs keep their own default (persistence enabled),
// even if a stale false value remains in saved settings.
const persistSessionEnabled = !persistSessionSupported
|| advancedOptions?.persistSession !== false;
// Collapsed by default; auto-expand when the user already has config so it
// isn't hidden. Local UI state — not persisted.
const [configOpen, setConfigOpen] = useState(
@@ -193,7 +205,7 @@ export const CodebuddyCard: React.FC<{
)}
</div>
{/* Advanced SDK options (SDK 0.3.230) */}
{/* Advanced SDK options (SDK 0.3.258) */}
{onAdvancedOptionsChange && (
<div className="border-t border-border/60 pt-3">
<button
@@ -279,6 +291,33 @@ export const CodebuddyCard: React.FC<{
)} />
</button>
</div>
{/* Session Persistence */}
<div className="flex items-center justify-between gap-2">
<div>
<span className="text-xs text-muted-foreground">{t('ai.codebuddy.persistSession')}</span>
<p className="text-[11px] text-muted-foreground leading-4">{t('ai.codebuddy.persistSession.hint')}</p>
</div>
<button
type="button"
role="switch"
disabled={!persistSessionSupported}
aria-checked={persistSessionEnabled}
onClick={() => {
if (!persistSessionSupported) return;
updateAdvanced({ persistSession: advancedOptions?.persistSession === false ? undefined : false });
}}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-colors",
!persistSessionSupported && "cursor-not-allowed opacity-50",
persistSessionEnabled ? "bg-primary" : "bg-muted",
)}
>
<span className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
persistSessionEnabled ? "translate-x-[18px]" : "translate-x-[3px]",
)} />
</button>
</div>
{/* File Checkpointing */}
<div className="flex items-center justify-between gap-2">
<div>
@@ -95,12 +95,13 @@ export const ModelSelector: React.FC<{
presetModels?: readonly string[];
placeholder?: string;
apiKey?: string;
customHeaders?: Record<string, string>;
providerId?: AIProviderId;
/** Optional protocol-family override; falls back to `providerId` via {@link resolveProviderStyle}. */
style?: ProviderStyle;
skipTLSVerify?: boolean;
onModelMetadata?: (model: FetchedModel) => void;
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, providerId, style, skipTLSVerify, onModelMetadata }) => {
}> = ({ value, onChange, baseURL, modelsEndpoint, presetModels, placeholder, apiKey, customHeaders, providerId, style, skipTLSVerify, onModelMetadata }) => {
const { t } = useI18n();
const [models, setModels] = useState<FetchedModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -124,6 +125,7 @@ export const ModelSelector: React.FC<{
baseURL,
effectiveModelsEndpoint,
apiKey,
customHeaders,
resolvedStyle,
skipTLSVerify,
});
@@ -152,7 +154,7 @@ export const ModelSelector: React.FC<{
await bridge.aiAllowlistAddHost(baseURL);
}
const url = buildProviderProbeUrl(baseURL, effectiveModelsEndpoint);
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey);
const headers = buildModelDiscoveryHeaders(resolvedStyle, apiKey, customHeaders);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
if (discoveryKeyRef.current !== requestKey) return;
@@ -171,7 +173,7 @@ export const ModelSelector: React.FC<{
} finally {
if (discoveryKeyRef.current === requestKey) setIsLoading(false);
}
}, [baseURL, effectiveModelsEndpoint, apiKey, resolvedStyle, skipTLSVerify, discoveryKey]);
}, [baseURL, effectiveModelsEndpoint, apiKey, customHeaders, resolvedStyle, skipTLSVerify, discoveryKey]);
// Auto-fetch when dropdown first opens
useEffect(() => {
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import React, { act } from 'react';
import { JSDOM } from 'jsdom';
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
import { TooltipProvider } from '../../../ui/tooltip';
import { ProviderConfigForm } from './ProviderConfigForm';
import type { ProviderConfig } from '../../../../infrastructure/ai/types';
const sealed = `enc:v1:${Buffer.concat([Buffer.from('v10'), Buffer.alloc(32, 3)]).toString('base64')}`;
test('loading saved headers preserves model metadata and blocks saving until decrypted', async () => {
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = { window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true };
for (const [name, value] of Object.entries(globals)) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
}
let unlock!: (value: string) => void;
Object.assign(dom.window, { netcatty: {
credentialsDecrypt: () => new Promise<string>((resolve) => { unlock = resolve; }),
credentialsEncrypt: async () => sealed,
} });
const { createRoot } = await import('react-dom/client');
const root = createRoot(dom.window.document.getElementById('root')!);
let saved: Partial<ProviderConfig> | undefined;
try {
await act(async () => root.render(<I18nProvider locale="en"><TooltipProvider>
<ProviderConfigForm provider={{ id: 'custom', name: 'Custom', providerId: 'custom', enabled: true,
customHeaders: { 'X-Tenant': sealed }, modelContextWindows: { model: 12345 },
}} onSave={(updates) => { saved = updates; }} onCancel={() => {}} />
</TooltipProvider></I18nProvider>));
const save = () => [...dom.window.document.querySelectorAll('button')].find((button) => button.textContent === 'Save')!;
assert.equal(save().disabled, true);
await act(async () => { unlock('tenant-secret'); });
assert.equal(save().disabled, false);
assert.equal((dom.window.document.querySelector('[aria-label="Header value"]') as HTMLInputElement).value, 'tenant-secret');
await act(async () => save().click());
assert.deepEqual(saved?.customHeaders, { 'X-Tenant': sealed });
assert.deepEqual(saved?.modelContextWindows, { model: 12345 });
await act(async () => (dom.window.document.querySelector('[aria-label="Remove header"]') as HTMLButtonElement).click());
await act(async () => save().click());
assert.deepEqual(saved?.customHeaders, {});
assert.equal(dom.window.document.querySelector('[aria-label="Header name"]'), null);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
}
});
@@ -1,3 +1,5 @@
import { decryptProviderHeaders, encryptProviderHeaders, parseProviderHeaderRows, type HeaderRow } from '../../../../infrastructure/ai/providerHeaderCredentials';
import { ProviderHeadersEditor } from './ProviderHeadersEditor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderStyle } from "../../../../infrastructure/ai/types";
@@ -55,6 +57,8 @@ async function compressIconFileToDataUrl(file: File): Promise<string> {
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
const OPENAI_API_OPTIONS: ReadonlyArray<OpenAIApiFormat> = ["chat", "responses"];
/** Selectable thinking-depth levels; empty string means "provider default" (field not sent). */
const REASONING_EFFORT_OPTIONS = ["low", "medium", "high"] as const;
/** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */
const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none";
@@ -81,6 +85,27 @@ export const ProviderConfigForm: React.FC<{
iconId: provider.iconId ?? "",
iconDataUrl: provider.iconDataUrl ?? "",
});
const [headersSourceVersion, setHeadersSourceVersion] = useState(0);
const [headerRows, setHeaderRows] = useState<HeaderRow[]>([]);
const [headersLoading, setHeadersLoading] = useState(true);
const [headersLoadError, setHeadersLoadError] = useState(false);
const [saveError, setSaveError] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const parsedHeaders = useMemo(() => {
try { return { headers: parseProviderHeaderRows(headerRows), valid: true }; }
catch { return { headers: {}, valid: false }; }
}, [headerRows]);
const customHeaders = parsedHeaders.headers;
useEffect(() => {
let cancelled = false;
setHeadersLoading(true);
setHeadersLoadError(false);
decryptProviderHeaders(provider.customHeaders).then((headers) => {
if (!cancelled) setHeaderRows(Object.entries(headers).map(([name, value]) => ({ name, value })));
}).catch(() => { if (!cancelled) setHeadersLoadError(true); })
.finally(() => { if (!cancelled) setHeadersLoading(false); });
return () => { cancelled = true; };
}, [provider.customHeaders]);
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -105,12 +130,14 @@ export const ProviderConfigForm: React.FC<{
baseURL: form.baseURL || preset?.defaultBaseURL || "",
modelsEndpoint: preset?.modelsEndpoint ?? "",
apiKeySourceVersion,
headersSourceVersion,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
}), [
provider.providerId,
form.baseURL,
apiKeySourceVersion,
headersSourceVersion,
form.skipTLSVerify,
preset?.defaultBaseURL,
preset?.modelsEndpoint,
@@ -119,11 +146,13 @@ export const ProviderConfigForm: React.FC<{
const probeFingerprint = useMemo(() => JSON.stringify({
baseURL: form.baseURL || preset?.defaultBaseURL || "",
apiKey: form.apiKey,
customHeaders,
style: resolvedStyle,
skipTLSVerify: form.skipTLSVerify,
modelsEndpoint: preset?.modelsEndpoint ?? "",
}), [
form.apiKey,
customHeaders,
form.baseURL,
form.skipTLSVerify,
preset?.defaultBaseURL,
@@ -182,7 +211,8 @@ export const ProviderConfigForm: React.FC<{
}, [probeFingerprint]);
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
/** Numeric advanced params; the string-valued reasoningEffort uses {@link handleAdvancedParamSelect}. */
const handleAdvancedParam = useCallback((key: Exclude<keyof ProviderAdvancedParams, "reasoningEffort">, raw: string) => {
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
setForm((prev) => {
const next = { ...prev.advancedParams };
@@ -197,6 +227,17 @@ export const ProviderConfigForm: React.FC<{
return { ...prev, advancedParams: next };
});
}, []);
const handleAdvancedParamSelect = useCallback((raw: string) => {
setForm((prev) => {
const next = { ...prev.advancedParams };
if (raw) {
next.reasoningEffort = raw;
} else {
delete next.reasoningEffort;
}
return { ...prev, advancedParams: next };
});
}, []);
const handleIconFileSelect = useCallback(async (file: File | null) => {
setIconError(null);
@@ -257,6 +298,7 @@ export const ProviderConfigForm: React.FC<{
bridge: getFetchBridge(),
baseURL,
apiKey: form.apiKey,
customHeaders,
providerId: provider.providerId,
style: resolvedStyle,
presetModelsEndpoint: preset?.modelsEndpoint,
@@ -310,9 +352,10 @@ export const ProviderConfigForm: React.FC<{
} finally {
if (probeRequestIdRef.current === requestId) setIsTesting(false);
}
}, [form.apiKey, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
}, [form.apiKey, customHeaders, form.skipTLSVerify, preset?.modelsEndpoint, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
const handleSave = useCallback(async () => {
if (isSaving || headersLoading || headersLoadError || !parsedHeaders.valid) return;
const cleanedParams: ProviderAdvancedParams = {};
const ap = form.advancedParams;
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
@@ -320,6 +363,9 @@ export const ProviderConfigForm: React.FC<{
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
if (ap.reasoningEffort && (REASONING_EFFORT_OPTIONS as readonly string[]).includes(ap.reasoningEffort)) {
cleanedParams.reasoningEffort = ap.reasoningEffort;
}
const trimmedName = form.name.trim();
const defaultName = PROVIDER_PRESETS[provider.providerId]?.name ?? "";
@@ -352,15 +398,24 @@ export const ProviderConfigForm: React.FC<{
iconDataUrl: form.iconDataUrl || undefined,
};
// Encrypt API key before saving
if (form.apiKey) {
updates.apiKey = await encryptField(form.apiKey);
} else {
updates.apiKey = undefined;
}
setIsSaving(true);
setSaveError(false);
try {
updates.customHeaders = await encryptProviderHeaders(customHeaders);
// Encrypt API key before saving
if (form.apiKey) {
updates.apiKey = await encryptField(form.apiKey);
} else {
updates.apiKey = undefined;
}
onSave(updates);
}, [form, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
onSave(updates);
} catch {
setSaveError(true);
} finally {
setIsSaving(false);
}
}, [form, customHeaders, isSaving, headersLoading, headersLoadError, parsedHeaders.valid, onSave, provider.providerId, resolvedBaseURL, resolvedStyle, t]);
return (
<div className="mt-3 space-y-3 border-t border-border/40 pt-3">
@@ -539,6 +594,17 @@ export const ProviderConfigForm: React.FC<{
</div>
</div>
<ProviderHeadersEditor
rows={headerRows}
onChange={(rows) => { setHeaderRows(rows); setHeadersSourceVersion((version) => version + 1); }}
disabled={headersLoading || headersLoadError || isSaving}
/>
{(!parsedHeaders.valid || headersLoadError || saveError) && (
<p role="alert" className="text-xs text-destructive">{t(!parsedHeaders.valid
? 'ai.providers.headers.invalid' : headersLoadError
? 'ai.providers.headers.loadError' : 'ai.providers.headers.saveError')}</p>
)}
{/* Base URL */}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{t('ai.providers.baseUrl')}</label>
@@ -572,7 +638,8 @@ export const ProviderConfigForm: React.FC<{
baseURL={resolvedBaseURL}
modelsEndpoint={preset?.modelsEndpoint}
presetModels={preset?.defaultModels}
apiKey={form.apiKey}
apiKey={headersLoading || headersLoadError || !parsedHeaders.valid ? undefined : form.apiKey}
customHeaders={headersLoading || headersLoadError || !parsedHeaders.valid ? undefined : customHeaders}
providerId={provider.providerId}
style={resolvedStyle}
skipTLSVerify={form.skipTLSVerify}
@@ -698,6 +765,20 @@ export const ProviderConfigForm: React.FC<{
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* reasoning_effort */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">reasoning_effort</label>
<select
value={form.advancedParams.reasoningEffort ?? ""}
onChange={(e) => handleAdvancedParamSelect(e.target.value)}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">{t('ai.providers.advancedParams.default')}</option>
{REASONING_EFFORT_OPTIONS.map((level) => (
<option key={level} value={level}>{level}</option>
))}
</select>
</div>
</div>
)}
</div>
@@ -709,6 +790,7 @@ export const ProviderConfigForm: React.FC<{
variant="default"
size="sm"
className={cn(PROVIDER_ACTION_CLASS, "border border-transparent")}
disabled={isSaving || isDecrypting || headersLoading || headersLoadError || !parsedHeaders.valid}
onClick={() => void handleSave()}
>
<Check size={14} className="size-3.5 shrink-0" />
@@ -719,7 +801,7 @@ export const ProviderConfigForm: React.FC<{
size="sm"
className={PROVIDER_ACTION_CLASS}
onClick={() => void handleTestConnection()}
disabled={isTesting || isDecrypting}
disabled={isTesting || isDecrypting || headersLoading || headersLoadError || !parsedHeaders.valid}
>
<RefreshCw size={14} className={cn("size-3.5 shrink-0", isTesting && "animate-spin")} />
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
@@ -0,0 +1,30 @@
import React from 'react';
import { Plus, X } from 'lucide-react';
import { useI18n } from '../../../../application/i18n/I18nProvider';
import type { HeaderRow } from '../../../../infrastructure/ai/providerHeaderCredentials';
import { Button } from '../../../ui/button';
export function ProviderHeadersEditor({ rows, onChange, disabled }: {
rows: HeaderRow[];
onChange: (rows: HeaderRow[]) => void;
disabled: boolean;
}) {
const { t } = useI18n();
const inputClass = 'min-w-0 flex-1 h-8 rounded-md border border-input bg-background px-2 text-xs disabled:opacity-50';
return <fieldset disabled={disabled} className="space-y-2">
<legend className="text-xs font-medium text-muted-foreground">{t('ai.providers.headers')}</legend>
<p className="text-[11px] text-muted-foreground">{t('ai.providers.headers.help')}</p>
{rows.map((row, index) => <div key={index} className="flex items-center gap-2">
<input aria-label={t('ai.providers.headers.name')} placeholder="X-Header" className={inputClass}
value={row.name} onChange={(e) => onChange(rows.map((item, i) => i === index ? { ...item, name: e.target.value } : item))} />
<input type="password" autoComplete="off" aria-label={t('ai.providers.headers.value')}
placeholder={t('ai.providers.headers.value')} className={inputClass}
value={row.value} onChange={(e) => onChange(rows.map((item, i) => i === index ? { ...item, value: e.target.value } : item))} />
<Button type="button" variant="ghost" size="sm" aria-label={t('ai.providers.headers.remove')}
onClick={() => onChange(rows.filter((_, i) => i !== index))}><X size={14} /></Button>
</div>)}
<Button type="button" variant="outline" size="sm" onClick={() => onChange([...rows, { name: '', value: '' }])}>
<Plus size={12} className="mr-1.5" />{t('ai.providers.headers.add')}
</Button>
</fieldset>;
}
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { SftpPaneFileList } from './SftpPaneFileList';
import { TooltipProvider } from '../ui/tooltip';
import { installDomEnvironment } from '../test-support/renderReactDom';
import { createEmptyPane } from '../../application/state/sftp/types';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('refresh keeps scroll, while explicit sorting and filters reveal the selection', async (t) => {
const dom = installDomEnvironment();
t.after(() => dom.cleanup());
let scrolls = 0;
const container = { querySelectorAll: () => [{ dataset: { entryName: 'selected' }, scrollIntoView: () => { scrolls += 1; } }] };
const noop = () => {};
const selected = { name: 'selected', type: 'file' as const, size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '' };
let props: React.ComponentProps<typeof SftpPaneFileList> = {
t: (key) => key, pane: { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', isLocal: false, status: 'connected', currentPath: '/browsed' }, selectedFiles: new Set(['selected']), files: [selected] },
side: 'left', isPaneFocused: true,
sorting: { sortField: 'name', sortOrder: 'asc', directoriesFirst: true,
columnWidths: { name: 50, modified: 24, size: 7, type: 9, owner: 10 },
visibleColumns: { name: true, modified: true, size: true, type: true, owner: true },
handleSort: noop, handleResizeStart: noop, toggleColumnVisibility: noop, toggleDirectoriesFirst: noop },
fileListRef: { current: container as unknown as HTMLDivElement },
handleFileListScroll: noop, shouldVirtualize: false, totalHeight: 0, sortedDisplayFiles: [selected],
isDragOverPane: false, draggedFiles: null, onRefresh: noop, onNavigateTo: noop, onClearSelection: noop,
setShowNewFolderDialog: noop, setShowNewFileDialog: noop, getNextUntitledName: () => '', setNewFileName: noop, setFileNameError: noop,
dragOverEntry: null, handleRowSelect: noop, handleRowOpen: noop, handleFileDragStart: noop,
onDragEnd: noop, handleEntryDragOver: noop, handleRowDragLeave: noop, handleEntryDrop: noop,
onCopyToOtherPane: noop, onMoveEntriesToPath: async () => {}, openRenameDialog: noop, openDeleteConfirm: noop,
rowHeight: 28, visibleRows: [],
};
const render = () => React.createElement(TooltipProvider, null, React.createElement(SftpPaneFileList, props));
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(render(), { createNodeMock: (element) => element.props['data-section'] === 'terminal-sftp-list' ? container : null }); });
assert.equal(scrolls, 1);
props = { ...props, pane: { ...props.pane, files: [...props.pane.files] }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 1, 'refresh must not pull the viewport back to the selection');
props = { ...props, sorting: { ...props.sorting, sortOrder: 'desc' }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 2, 'sorting retains the prior reveal-selection behavior');
props = { ...props, pane: { ...props.pane, filter: 'selected' }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 3, 'filter changes retain the prior reveal-selection behavior');
await act(async () => { renderer.unmount(); });
});
+17 -1
View File
@@ -196,7 +196,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
return () => sftpListOrderStore.clearPane(pane.id);
}, [sortedDisplayFiles, pane.id]);
const selectionScrollContext = useMemo(() => ({
paneId: pane.id, filter: pane.filter, showHiddenFiles: pane.showHiddenFiles,
sortField, sortOrder, directoriesFirst, rowHeight, shouldVirtualize,
}), [
pane.id, pane.filter, pane.showHiddenFiles, sortField, sortOrder,
directoriesFirst, rowHeight, shouldVirtualize,
]);
const lastScrolledSelectionRef = useRef<{
selection: Set<string>;
context: object;
} | null>(null);
useEffect(() => {
// Refreshes preserve selection identity; explicit sorting/filter/layout
// changes still reveal the selected row using the usual behavior.
const last = lastScrolledSelectionRef.current;
if (last?.selection === pane.selectedFiles && last.context === selectionScrollContext) return;
lastScrolledSelectionRef.current = { selection: pane.selectedFiles, context: selectionScrollContext };
if (pane.selectedFiles.size !== 1) return;
const selectedName = Array.from(pane.selectedFiles)[0];
if (!selectedName) return;
@@ -220,7 +236,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
currentScrollTop: container.scrollTop,
viewportHeight: container.clientHeight,
});
}, [fileListRef, pane.selectedFiles, rowHeight, shouldVirtualize, sortedDisplayFiles]);
}, [fileListRef, pane.selectedFiles, rowHeight, selectionScrollContext, shouldVirtualize, sortedDisplayFiles]);
// Use refs for frequently-changing values in context-menu actions
const selectedFilesRef = useRef(pane.selectedFiles);
+55 -4
View File
@@ -1,6 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { TreeNode } from "./SftpPaneTreeNode.tsx";
import { getSftpTreeEntryOpenAction } from "./SftpPaneTreeView.tsx";
import type { SftpFileEntry } from "../../types";
@@ -18,7 +21,55 @@ test("tree activation enters directories and keeps file opening separate", () =>
assert.equal(getSftpTreeEntryOpenAction(entry("notes.txt", "file")), "open");
});
test("tree row double click routes through the open action", () => {
const source = fs.readFileSync(new URL("./SftpPaneTreeNode.tsx", import.meta.url), "utf8");
assert.match(source, /onDoubleClick=\{\(\) => onOpenEntry\(entry, entryPath\)\}/);
test("tree double clicks preserve folder, parent, file and arrow actions", async () => {
const dom = new JSDOM('<div id="root"></div>');
const keys = ["window", "document", "IS_REACT_ACT_ENVIRONMENT"] as const;
const originals = keys.map(key => Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const container = dom.window.document.getElementById("root")!;
const root = createRoot(container);
const calls: string[] = [];
const noop = () => {};
const render = async (item: SftpFileEntry, isLoading = false) => {
await act(async () => root.render(React.createElement(TreeNode, {
entry: item, entryPath: `/root/${item.name}`, depth: 0,
columnTemplate: "1fr", visibleColumns: { name: true, modified: false, size: false, type: false, owner: false },
isSelected: false, isExpanded: false, isLoading, isDragOver: false,
onToggleExpand: () => calls.push("toggle"), onNodeClick: () => calls.push("select"),
onOpenEntry: () => calls.push("open"), onContextMenu: () => calls.push("menu"),
onDragStart: noop, onDragEnd: noop, onDragOverEntry: noop, onDropEntry: noop, onDragLeaveEntry: noop,
})));
calls.length = 0;
};
const doubleClick = async (target: Element) => {
for (const [type, detail] of [["click", 1], ["click", 2], ["dblclick", 2]] as const) {
await act(async () => { target.dispatchEvent(new dom.window.MouseEvent(type, { bubbles: true, detail })); });
}
};
try {
await render(entry("docs", "directory"));
await doubleClick(container.querySelector('[data-entry-name="docs"]')!);
assert.deepEqual(calls, ["select", "select", "toggle"]);
calls.length = 0;
await doubleClick(container.querySelector('.lucide-chevron-right')!);
assert.deepEqual(calls, ["toggle", "toggle"]);
await render(entry("docs", "directory"), true);
await doubleClick(container.querySelector('.lucide-loader-circle, .lucide-loader-2')!);
assert.deepEqual(calls, ["select", "select"]);
for (const item of [entry("..", "directory"), entry("notes.txt", "file")]) {
await render(item);
await doubleClick(container.querySelector('[data-section]')!);
assert.deepEqual(calls, ["select", "select", "open"]);
}
} finally {
await act(async () => root.unmount());
dom.window.close();
keys.forEach((key, index) => {
const descriptor = originals[index];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
});
}
});
+9 -2
View File
@@ -67,7 +67,11 @@ export const TreeNode = React.memo<TreeNodeProps>(({
)}
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
onClick={e => onNodeClick(entry, entryPath, e)}
onDoubleClick={() => onOpenEntry(entry, entryPath)}
onDoubleClick={() => {
if (isParentEntry) { onOpenEntry(entry, entryPath); return; }
if (isDir) void onToggleExpand(entry, entryPath);
else onOpenEntry(entry, entryPath);
}}
onContextMenu={e => {
if (!isParentEntry) {
onContextMenu(entry, entryPath, e);
@@ -84,7 +88,10 @@ export const TreeNode = React.memo<TreeNodeProps>(({
className="flex min-w-0 items-center gap-1"
style={{ paddingLeft: depth * 16 + 8 }}
>
<span className="shrink-0 w-4 flex items-center justify-center">
<span
className="shrink-0 w-4 flex items-center justify-center"
onDoubleClick={e => { if (!isParentEntry && isDir) e.stopPropagation(); }}
>
{isParentEntry ? (
<CornerUpLeft size={14} className="text-muted-foreground" />
) : isDir ? (
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { JSDOM } from 'jsdom';
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';
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' });
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] });
}
t.after(() => {
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 });
pruneComposeBarHistory([]);
const createTextarea = () => ({
value: '', selectionStart: 0, selectionEnd: 0,
focus() {},
setSelectionRange(start: number, end: number) { this.selectionStart = start; this.selectionEnd = end; },
});
let textarea = createTextarea();
let finish!: (sent: boolean) => void;
let rejectSend!: (error: Error) => void;
const onSend = () => new Promise<boolean>((resolve, reject) => { finish = resolve; rejectSend = reject; });
const render = (sessionId: string) => (
<TooltipProvider><TerminalComposeBar sessionId={sessionId} onSend={onSend} onClose={() => {}} /></TooltipProvider>
);
let root!: ReactTestRenderer;
await act(async () => {
root = create(render('a'), { createNodeMock: (el) => {
if (el.type !== 'textarea') return null;
textarea = createTextarea();
return textarea;
} });
});
const key = (name: string) => root.root.findByType('textarea').props.onKeyDown({
key: name, nativeEvent: {}, currentTarget: textarea, preventDefault() {},
});
textarea.value = 'unsent draft';
await act(async () => { root.update(render('b')); });
assert.equal(textarea.value, 'unsent draft');
await act(async () => { root.update(render('a')); });
recordComposeBarHistory('a', 'old command from A');
key('ArrowUp');
assert.equal(textarea.value, 'old command from A');
await act(async () => { root.update(render('b')); });
assert.equal(textarea.value, 'unsent draft');
key('Enter');
assert.equal(textarea.value, '');
assert.deepEqual(getComposeBarHistory('b'), []);
await act(async () => { finish(false); });
assert.deepEqual(getComposeBarHistory('b'), []);
textarea.value = 'accepted command';
key('Enter');
textarea.value = 'next draft';
await act(async () => { finish(true); });
assert.deepEqual(getComposeBarHistory('b'), ['accepted command']);
assert.equal(textarea.value, 'next draft');
key('ArrowUp');
assert.equal(textarea.value, 'accepted command');
key('ArrowDown');
assert.equal(textarea.value, 'next draft');
textarea.value = 'first';
key('Enter');
const finishFirst = finish;
textarea.value = 'second';
key('Enter');
const finishSecond = finish;
await act(async () => { finishSecond(true); });
await act(async () => { finishFirst(true); });
assert.deepEqual(getComposeBarHistory('b'), ['accepted command', 'first', 'second']);
key('ArrowUp');
assert.equal(textarea.value, 'second');
textarea.value = 'rejected';
key('Enter');
const finishRejected = finish;
textarea.value = 'after rejected';
key('Enter');
await act(async () => { finish(true); });
await act(async () => { finishRejected(false); });
assert.deepEqual(getComposeBarHistory('b'), ['accepted command', 'first', 'second', 'after rejected']);
const errorLog = t.mock.method(console, 'error', () => {});
textarea.value = 'exception';
key('Enter');
const rejectFirst = rejectSend;
textarea.value = 'after exception';
key('Enter');
await act(async () => { finish(true); });
await act(async () => { rejectFirst(new Error('send failed')); });
assert.equal(errorLog.mock.callCount(), 1);
assert.deepEqual(getComposeBarHistory('b'), ['accepted command', 'first', 'second', 'after rejected', 'after exception']);
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
+46 -5
View File
@@ -5,6 +5,8 @@
*/
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
import { useComposeBarHeight } from '../../application/state/useComposeBarHeight';
import { useComposeBarPinnedSnippets } from '../../application/state/useComposeBarPinnedSnippets';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -267,7 +269,9 @@ const ComposeBarSnippetManagePopover = memo(function ComposeBarSnippetManagePopo
});
export interface TerminalComposeBarProps {
onSend: (text: string) => void;
// Return false for rejected or sensitive input so it is not recalled.
onSend: (text: string) => boolean | void | Promise<boolean | void>;
sessionId: string;
onClose: () => void;
onSnippetClick?: (snippet: Snippet) => void;
snippets?: Snippet[];
@@ -280,6 +284,7 @@ export interface TerminalComposeBarProps {
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
onSend,
sessionId,
onClose,
onSnippetClick,
snippets = [],
@@ -288,6 +293,10 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
}) => {
const { t } = useI18n();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const restoreDraft = useCallback((draft: string) => {
if (textareaRef.current) textareaRef.current.value = draft;
}, []);
const { prepareRecord, navigate, reset } = useComposeBarHistory(sessionId, restoreDraft);
const isComposingRef = useRef(false);
const resizeCleanupRef = useRef<(() => void) | null>(null);
const [barHeight, setBarHeight, persistBarHeight] = useComposeBarHeight();
@@ -338,18 +347,33 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
if (!el) return;
const text = el.value;
if (!text) return;
onSend(text);
const recordSent = prepareRecord();
let result: ReturnType<typeof onSend>;
try {
result = onSend(text);
} catch (error) {
recordSent();
throw error;
}
void Promise.resolve(result).then((sent) => {
recordSent(sent !== false ? text : undefined);
}).catch((error: unknown) => {
recordSent();
console.error('Compose bar send failed', error);
});
reset();
el.value = '';
el.focus();
}, [onSend]);
}, [onSend, prepareRecord, reset]);
const insertCommand = useCallback((command: string) => {
const el = textareaRef.current;
if (!el) return;
reset();
const prefix = el.value && !el.value.endsWith('\n') ? '\n' : '';
el.value = el.value ? `${el.value}${prefix}${command}` : command;
el.focus();
}, []);
}, [reset]);
const handleSnippetActivate = useCallback(async (snippet: Snippet, sendImmediately: boolean) => {
if (sendImmediately) {
@@ -368,6 +392,22 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
}, [insertCommand, onSend, onSnippetClick]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isComposingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') &&
!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
const el = e.currentTarget;
const direction = e.key === 'ArrowUp' ? 'up' : 'down';
if (canNavigateComposeBarHistory(el.value, el.selectionStart, direction, el.selectionEnd)) {
const value = navigate(el.value, direction);
if (value !== undefined) {
e.preventDefault();
el.value = value;
const position = direction === 'up' ? 0 : value.length;
el.setSelectionRange(position, position);
}
}
return;
}
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
handleSend();
@@ -375,7 +415,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
e.preventDefault();
onClose();
}
}, [handleSend, onClose]);
}, [handleSend, navigate, onClose]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
@@ -489,6 +529,7 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
style={{ color: theme.resolvedFg }}
placeholder={t('terminal.composeBar.placeholder')}
onKeyDown={handleKeyDown}
onInput={reset}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => { isComposingRef.current = false; }}
/>
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('long press opens the existing menu without running the short-click action', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: 'http://localhost',
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
installGlobal('window', window);
installGlobal('document', window.document);
installGlobal('navigator', window.navigator);
installGlobal('HTMLElement', window.HTMLElement);
installGlobal('HTMLInputElement', window.HTMLInputElement);
installGlobal('HTMLTextAreaElement', window.HTMLTextAreaElement);
installGlobal('Element', window.Element);
installGlobal('SVGElement', window.SVGElement);
installGlobal('Node', window.Node);
installGlobal('NodeFilter', window.NodeFilter);
installGlobal('MutationObserver', window.MutationObserver);
installGlobal('CustomEvent', window.CustomEvent);
installGlobal('DOMRect', window.DOMRect);
installGlobal('Event', window.Event);
installGlobal('KeyboardEvent', window.KeyboardEvent);
installGlobal('MouseEvent', window.MouseEvent);
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
installGlobal('ResizeObserver', ResizeObserverStub);
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
const { TerminalContextMenu } = await import('./TerminalContextMenu.tsx');
const rootNode = window.document.getElementById('root');
assert.ok(rootNode);
const root = createRoot(rootNode);
let pastes = 0;
let mode = 'none';
const fire = async (type: string) => {
await act(async () => {
window.document.querySelector('[data-testid="surface"]')!.dispatchEvent(new window.MouseEvent(type, {
bubbles: true, cancelable: true, button: 2, buttons: type === 'mouseup' ? 0 : 2,
clientX: 30, clientY: 30,
}));
});
};
try {
await act(async () => root.render(<I18nProvider locale="en">
<TerminalContextMenu sessionId="3153" status="connected" rightClickBehavior="paste"
rightClickLongPressMenu getMouseTrackingMode={() => mode} onPaste={() => pastes++}>
<div data-testid="surface">Terminal</div>
</TerminalContextMenu>
</I18nProvider>));
await act(async () => {
const surface = window.document.querySelector('[data-testid="surface"]')!;
for (const type of ['mousedown', 'contextmenu', 'mouseup']) {
surface.dispatchEvent(new window.MouseEvent(type, {
bubbles: true, cancelable: true, button: 2,
}));
}
});
assert.equal(pastes, 1);
assert.equal(window.document.querySelector('[role="menu"]'), null);
await fire('mousedown');
await fire('contextmenu');
await act(async () => { await new Promise(resolve => setTimeout(resolve, 550)); });
assert.ok(window.document.querySelector('[role="menu"]'));
await fire('mouseup');
await fire('contextmenu'); // Windows release-time native event must not paste.
assert.equal(pastes, 1);
const pasteItem = Array.from(window.document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find(item => item.textContent === 'Paste');
assert.ok(pasteItem);
await act(async () => pasteItem.click());
assert.equal(pastes, 2);
assert.equal(window.document.querySelector('[role="menu"]'), null);
mode = 'vt200';
await fire('mousedown');
await act(async () => { await new Promise(resolve => setTimeout(resolve, 550)); });
await fire('mouseup');
assert.equal(window.document.querySelector('[role="menu"]'), null);
assert.equal(pastes, 2);
let reconnects = 0;
await act(async () => root.render(<I18nProvider locale="en">
<TerminalContextMenu sessionId="3153" status="disconnected" rightClickBehavior="paste"
rightClickLongPressMenu isReconnectable onReconnect={() => reconnects++}
getMouseTrackingMode={() => mode} onPaste={() => pastes++}>
<div data-testid="surface">Terminal</div>
</TerminalContextMenu>
</I18nProvider>));
await fire('mousedown');
await act(async () => { await new Promise(resolve => setTimeout(resolve, 550)); });
await fire('mouseup');
const reconnectItem = Array.from(window.document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find(item => item.textContent === 'Reconnect');
assert.ok(reconnectItem);
assert.equal(pastes, 2);
await act(async () => reconnectItem.click());
assert.equal(reconnects, 1);
} finally {
await act(async () => root.unmount());
window.close();
for (const [key, previous] of previousGlobals) {
if (previous) Object.defineProperty(globalThis, key, previous);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
+24 -2
View File
@@ -16,7 +16,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { KeyBinding, RightClickBehavior } from '../../domain/models';
import {
@@ -27,6 +27,7 @@ import {
ContextMenuShortcut,
ContextMenuTrigger,
} from '../ui/context-menu';
import { installRightClickLongPress } from './runtime/rightClickLongPress';
import { isMiddleClickContextMenuEvent, isMouseTrackingActive } from './runtime/middleClickBehavior';
import { isHistoryPreviewContextMenuTarget } from './runtime/terminalHistoryScrollOverride';
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
@@ -44,11 +45,13 @@ export interface TerminalContextMenuProps {
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
rightClickLongPressMenu?: boolean;
isAlternateScreen?: boolean;
/** Read the current xterm mouse-tracking mode when handling a right-click. */
getMouseTrackingMode?: () => string | undefined;
/** When true, show the app context menu even while a fullscreen app (tmux/vim) holds mouse tracking. */
showContextMenuOverFullscreenApps?: boolean;
onSaveScreen?: () => void;
onCopy?: () => void;
onPaste?: () => void;
onUploadClipboardImage?: () => void;
@@ -203,9 +206,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
hotkeyScheme = 'mac',
keyBindings,
rightClickBehavior = 'context-menu',
rightClickLongPressMenu = false,
isAlternateScreen = false,
getMouseTrackingMode,
showContextMenuOverFullscreenApps = false,
onSaveScreen,
onCopy,
onPaste,
onUploadClipboardImage,
@@ -224,6 +229,17 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onRename,
onDetach,
}) => {
const showReconnectAction = shouldShowReconnectAction({ isReconnectable, onReconnect });
const surfaceRef = useRef<HTMLDivElement>(null);
const canStartLongPress = useEffectEvent(() => rightClickLongPressMenu
&& rightClickBehavior !== 'context-menu'
&& (showReconnectAction || !isMouseTrackingActive({
mouseTracking: isAlternateScreen,
terminalMouseTrackingMode: getMouseTrackingMode?.(),
})));
useEffect(() => {
if (surfaceRef.current) return installRightClickLongPress(surfaceRef.current, canStartLongPress);
}, []);
const { t } = useI18n();
const [menuOpen, setMenuOpen] = useState(false);
const terminalContext = buildTerminalPluginContributionContext({
@@ -277,7 +293,6 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const splitHShortcut = getShortcut('split-horizontal');
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showReconnectAction = shouldShowReconnectAction({ isReconnectable, onReconnect });
const terminalMouseTrackingMode = getMouseTrackingMode?.();
@@ -348,6 +363,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
<ContextMenu onOpenChange={handleOpenChange}>
<ContextMenuTrigger
asChild
ref={surfaceRef}
onContextMenu={handleRightClick}
>
{children}
@@ -438,6 +454,12 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
<ContextMenuSeparator />
{onSaveScreen && (
<ContextMenuItem onClick={onSaveScreen}>
<Download size={14} className="mr-2" />
{t('terminal.menu.saveScreen')}
</ContextMenuItem>
)}
<ContextMenuItem onClick={onClear}>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.clearBuffer')}
+10 -2
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import { ChevronsLeft, GripVertical, Minimize2, Network, PanelLeft, X as XIcon } from 'lucide-react';
import { isTerminalSensitiveInputActive } from './runtime/terminalSensitiveInputRegistry';
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
@@ -579,9 +580,11 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
rightClickLongPressMenu={terminalSettings?.rightClickLongPressMenu}
isAlternateScreen={hasMouseTracking}
getMouseTrackingMode={() => termRef.current?.modes.mouseTrackingMode}
showContextMenuOverFullscreenApps={terminalSettings?.showContextMenuOverFullscreenApps}
onSaveScreen={terminalContextActions.onSaveScreen}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
onUploadClipboardImage={status === "connected" ? terminalContextActions.onUploadClipboardImage : undefined}
@@ -1304,10 +1307,15 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
{isComposeBarOpen && !inWorkspace && (
<TerminalComposeBar
onSend={(text) => {
key={sessionId}
sessionId={sessionId}
onSend={async (text) => {
if (sessionRef.current) {
executeSnippetCommand(text, false);
const sensitive = isTerminalSensitiveInputActive(sessionId);
const sent = await executeSnippetCommand(text, false);
return sent && !sensitive && !isTerminalSensitiveInputActive(sessionId);
}
return false;
}}
onSnippetClick={(snippet) => void executeSnippet(snippet)}
snippets={snippets}
@@ -1,5 +1,5 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import type { RefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
@@ -19,6 +19,10 @@ import {
findHistoryPreviewOverlay,
} from "../runtime/terminalHistoryScrollOverride";
import { readTerminalScreenText } from "../terminalContextBuffer";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { toast } from "../../ui/toast";
type BroadcastPasteRefs = {
sourceSessionId: string;
sessionRef: RefObject<string | null>;
@@ -52,6 +56,7 @@ export const broadcastTerminalPasteData = (
export const useTerminalContextActions = ({
termRef,
sourceSessionId,
sessionName,
sessionRef,
onHasSelectionChange,
scrollOnPasteRef,
@@ -71,6 +76,7 @@ export const useTerminalContextActions = ({
termRef: RefObject<XTerm | null>;
sourceSessionId: string;
sessionRef: RefObject<string | null>;
sessionName?: string;
onHasSelectionChange?: (hasSelection: boolean) => void;
scrollOnPasteRef?: RefObject<boolean>;
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
@@ -91,6 +97,35 @@ export const useTerminalContextActions = ({
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
}) => {
const { t } = useI18n();
const savingScreenRef = useRef(false);
const onSaveScreen = useCallback(async () => {
const term = termRef.current;
if (!term || savingScreenRef.current) return;
// Capture before opening the dialog: output may continue while it is open.
const preview = findHistoryPreviewOverlay(term.element?.parentElement);
const terminalData = preview?.textContent ?? readTerminalScreenText(term);
savingScreenRef.current = true;
try {
const bridge = netcattyBridge.get();
if (!bridge?.exportSessionLog) throw new Error("Screen export unavailable");
const result = await bridge.exportSessionLog({
terminalData,
hostLabel: sessionName || "terminal-screen",
hostname: "",
startTime: Date.now(),
format: "txt",
plainText: true,
});
if (!result.success && !result.canceled) throw new Error("Screen export failed");
} catch (err) {
logger.warn("Failed to save terminal screen", err);
toast.error(t("terminal.saveScreen.failed"));
} finally {
savingScreenRef.current = false;
}
}, [sessionName, t, termRef]);
const broadcastUserPasteData = useCallback((data: string) => {
return broadcastTerminalPasteData(data, {
sourceSessionId,
@@ -239,6 +274,7 @@ export const useTerminalContextActions = ({
}, [onHasSelectionChange, termRef]);
return {
onSaveScreen,
onCopy,
onPaste,
onUploadClipboardImage: supportsRemoteImagePaste ? onUploadClipboardImage : undefined,
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { commandArrowLineJumpSequence } from "./commandArrowLineJump";
const event = {
key: "ArrowLeft", metaKey: true, altKey: false, ctrlKey: false,
shiftKey: false, isComposing: false, keyCode: 37,
};
test("macOS Command arrows send beginning/end-of-line controls", () => {
assert.equal(commandArrowLineJumpSequence(event, true), "\x01");
assert.equal(commandArrowLineJumpSequence({ ...event, key: "ArrowRight" }, true), "\x05");
});
test("other platforms, keys, modifiers and composition are untouched", () => {
assert.equal(commandArrowLineJumpSequence(event, false), null);
for (const override of [
{ metaKey: false }, { altKey: true }, { ctrlKey: true }, { shiftKey: true },
{ isComposing: true }, { keyCode: 229 }, { key: "ArrowUp" }, { key: "ArrowDown" },
{ key: "Home" }, { key: "a" },
]) {
assert.equal(commandArrowLineJumpSequence({ ...event, ...override }, true), null);
}
});
// Exercise the runtime fallback itself, including its protocol/screen guards.
// This avoids constructing the renderer's WebGL/addon stack in Node.
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const start = runtime.indexOf(" const lineJumpSequence =");
const end = runtime.indexOf(" // Autocomplete key handler", start);
const applyStart = runtime.indexOf(" if (lineJumpSequence && ctx.sessionRef.current)");
const applyEnd = runtime.indexOf(" // macOS Option+", applyStart);
assert.ok(start > 0 && end > start && applyEnd > applyStart);
const fallback = `(function () { ${runtime.slice(start, end)} ${runtime.slice(applyStart, applyEnd)} return true; })()`;
for (const mode of ["shell", "alternate", "kitty", "win32", "disconnected", "non-mac", "password", "sudo-picker"]) {
test(`runtime Command arrow fallback: ${mode}`, () => {
const writes: string[] = [];
let prevented = 0;
let stopped = 0;
const result = runInNewContext(fallback, {
e: { ...event, preventDefault: () => prevented++, stopPropagation: () => stopped++ },
term: { buffer: { active: { type: mode === "alternate" ? "alternate" : "normal" } }, modes: { win32InputMode: mode === "win32" } },
ctx: {
sessionRef: { current: mode === "disconnected" ? null : "session" },
passwordPromptActiveRef: { current: mode === "password" },
},
sudoAutofill: { isPromptPending: () => mode === "sudo-picker" },
kittyKeyboardMode: {},
isKittyKeyboardModeActive: () => mode === "kitty",
isMacPlatform: () => mode !== "non-mac",
commandArrowLineJumpSequence,
handleTerminalInputData: (data: string, options: { skipBroadcast: boolean }) => {
assert.equal(options.skipBroadcast, true, "a peer TUI must not receive mapped Ctrl+A/E");
writes.push(data);
},
scrollToBottomAfterInput: () => {},
});
assert.equal(result, mode !== "shell");
assert.deepEqual(writes, mode === "shell" ? ["\x01"] : []);
assert.equal(prevented, mode === "shell" ? 1 : 0);
assert.equal(stopped, prevented);
});
}
for (const key of ["ArrowLeft", "ArrowRight"]) {
test(`Command ${key} bypasses directory autocomplete, bare arrows still navigate it`, () => {
const autocompleteEnd = runtime.indexOf(" const kittySequenceForKeyDown =", end);
assert.ok(autocompleteEnd > end);
const route = `(function () { ${runtime.slice(start, autocompleteEnd)} ${runtime.slice(applyStart, applyEnd)} return true; })()`;
for (const metaKey of [true, false]) {
let autocompleteCalls = 0;
const writes: string[] = [];
runInNewContext(route, {
e: { ...event, key, metaKey, preventDefault: () => {}, stopPropagation: () => {} },
term: { buffer: { active: { type: "normal" } }, modes: {} },
ctx: {
sessionRef: { current: "session" },
// A focused directory panel consumes either arrow.
onAutocompleteKeyEvent: () => { autocompleteCalls++; return false; },
},
sudoAutofill: undefined,
kittyKeyboardMode: {},
isKittyKeyboardModeActive: () => false,
isMacPlatform: () => true,
commandArrowLineJumpSequence,
handleTerminalInputData: (data: string) => writes.push(data),
scrollToBottomAfterInput: () => {},
});
assert.equal(autocompleteCalls, metaKey ? 0 : 1);
assert.deepEqual(writes, metaKey ? [key === "ArrowLeft" ? "\x01" : "\x05"] : []);
}
});
}
@@ -0,0 +1,11 @@
/** macOS line editing fallback for shells using readline/ZLE bindings. */
export function commandArrowLineJumpSequence(
e: Pick<KeyboardEvent, "key" | "metaKey" | "altKey" | "ctrlKey" | "shiftKey" | "isComposing" | "keyCode">,
isMac: boolean,
): string | null {
if (!isMac || !e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return null;
if (e.isComposing || e.keyCode === 229) return null;
if (e.key === "ArrowLeft") return "\x01";
if (e.key === "ArrowRight") return "\x05";
return null;
}
@@ -2811,6 +2811,7 @@ test("startSSH enables sudo autofill only with the host saved password", async (
test("startSSH does not use unsaved retry passwords for sudo autofill", async () => {
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
let captured: NetcattySSHOptions | undefined;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
@@ -2818,7 +2819,7 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startSSHSession: async (options: NetcattySSHOptions) => { captured = options; return "ssh-session"; },
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
@@ -2856,6 +2857,18 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
onData?.("[sudo] password for alice: ");
assert.deepEqual(sent, []);
assert.equal(captured?.password, "temporary-secret");
assert.equal(captured?.sftpReuseOptions?.hostId, "host-1");
assert.equal(captured?.sftpReuseOptions?.password, undefined);
assert.equal(JSON.stringify(captured?.sftpReuseOptions).includes("temporary-secret"), false);
assert.equal("password" in ctx.host, false);
// An unreadable saved credential must not block a manual password override.
Object.assign(ctx.host, { password: ENCRYPTED_CREDENTIAL_PLACEHOLDER });
captured = undefined;
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(captured?.password, "temporary-secret");
assert.equal(captured?.sftpReuseOptions, undefined);
});
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
@@ -60,6 +60,7 @@ import { hasConnectionPassedTcpDial } from "../connectionTimeouts";
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
import { isPluginHostProtocol, sanitizePluginConnection } from "../../../domain/pluginConnection";
import { hydrateVaultStoredKeys } from "../../../infrastructure/persistence/secureFieldAdapter";
import { buildSftpHostCredentials } from "../../../application/state/sftp/useSftpHostCredentials";
const collectConnectKeyIds = (
host: Host,
@@ -647,6 +648,23 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
);
const connectionTimeouts = resolveHostSshConnectionTimeouts(ctx.host);
const requiresFreshSshConnection = ctx.shouldUseFreshSshConnection?.() === true;
// Keep the original profile identity for SFTP borrowing when the user
// supplies a password for this terminal only. Never persist that password.
let sftpReuseOptions: NetcattySSHOptions | undefined;
if (pendingAuth?.authMethod === "password" && !pendingAuth.savedToHost) {
try {
sftpReuseOptions = buildSftpHostCredentials({
host: ctx.host,
hosts: ctx.resolvedChainHosts,
keys,
identities: ctx.identities ?? [],
knownHosts: ctx.knownHosts,
terminalSettings: globalTerminalSettings,
});
} catch {
// A broken saved credential must not prevent a manual SSH login.
}
}
const startedSessionId = await ctx.terminalBackend.startSSHSession({
sessionId: ctx.sessionId,
hostLabel: ctx.host.label,
@@ -657,6 +675,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
requiresMfa: !!ctx.host.requiresMfa,
port: ctx.host.port || 22,
password: attempt.password,
sftpReuseOptions: sftpReuseOptions?.password ? undefined : sftpReuseOptions,
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
certificate: attempt.key?.certificate,
publicKey: attempt.key?.publicKey,
@@ -91,6 +91,7 @@ import {
import { installUserCursorPreferenceGuard } from "./cursorPreference";
import { terminalAltKeyOptions } from "./altKeyOptions";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
import { commandArrowLineJumpSequence } from "./commandArrowLineJump";
import { optionYankLastArgSequence } from "./optionYankLastArg";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { dispatchWin32InputModeEvent } from "./win32InputMode";
@@ -1958,8 +1959,19 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return false;
}
// Resolve the local shell chord before autocomplete can consume directory
// arrows, but apply it only after app/snippet shortcuts below.
const lineJumpSequence =
term.buffer.active.type === "normal" &&
ctx.passwordPromptActiveRef?.current !== true &&
!sudoAutofill?.isPromptPending() &&
!term.modes.win32InputMode &&
!isKittyKeyboardModeActive(kittyKeyboardMode)
? commandArrowLineJumpSequence(e, isMacPlatform())
: null;
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
if (ctx.onAutocompleteKeyEvent && !lineJumpSequence && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
const consumed = ctx.onAutocompleteKeyEvent(e);
if (!consumed) return false; // Event was consumed by autocomplete
}
@@ -2267,6 +2279,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return false;
}
// Keep app/snippet shortcuts and negotiated keyboard protocols ahead of
// the shell-only macOS line-editing fallback (issue #3139).
if (lineJumpSequence && ctx.sessionRef.current) {
e.preventDefault();
e.stopPropagation();
// This is a local shell editing shortcut. Peers may be running a TUI
// or another keyboard protocol, so do not fan out the mapped Ctrl key.
handleTerminalInputData(lineJumpSequence, { skipBroadcast: true });
scrollToBottomAfterInput(lineJumpSequence);
return false;
}
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
// #826). After kitty mode so apps using the kitty protocol keep their own
// arrow encoding; read live so the toggle applies without reconnecting.
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { installRightClickLongPress, RIGHT_CLICK_LONG_PRESS_MS } from './rightClickLongPress';
for (const nativeTiming of ['press', 'release'] as const) {
for (const long of [false, true]) {
test(`${nativeTiming}-time contextmenu: ${long ? 'long' : 'short'} press dispatches once`, (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const dom = new JSDOM('<div id="root"><span></span></div>');
const root = dom.window.document.querySelector<HTMLElement>('#root')!;
const target = root.firstElementChild!;
const dispose = installRightClickLongPress(root, () => true);
const received: boolean[] = [];
root.addEventListener('contextmenu', (event) => received.push(event.shiftKey));
const fire = (type: string) => target.dispatchEvent(new dom.window.MouseEvent(type, {
bubbles: true, cancelable: true, button: 2, buttons: type === 'mouseup' ? 0 : 2,
}));
fire('mousedown');
if (nativeTiming === 'press') fire('contextmenu');
assert.deepEqual(received, []);
t.mock.timers.tick(long ? RIGHT_CLICK_LONG_PRESS_MS : 100);
fire('mouseup');
if (nativeTiming === 'release') fire('contextmenu');
t.mock.timers.tick(1000);
assert.deepEqual(received, [long]);
dispose();
dom.window.close();
});
}
}
for (const cancelBy of ['move', 'blur', 'outside-release', 'dispose', 'mouse-mode']) {
test(`long press cancels on ${cancelBy}`, (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const dom = new JSDOM('<div id="root"></div>');
const root = dom.window.document.querySelector<HTMLElement>('#root')!;
let enabled = true;
const dispose = installRightClickLongPress(root, () => enabled);
let menus = 0;
root.addEventListener('contextmenu', () => menus++);
root.dispatchEvent(new dom.window.MouseEvent('mousedown', { bubbles: true, button: 2 }));
if (cancelBy === 'move') root.dispatchEvent(new dom.window.MouseEvent('mousemove', { bubbles: true, buttons: 2, clientX: 20 }));
if (cancelBy === 'blur') dom.window.dispatchEvent(new dom.window.Event('blur'));
if (cancelBy === 'outside-release') dom.window.document.body.dispatchEvent(new dom.window.MouseEvent('mouseup', { bubbles: true, button: 2 }));
if (cancelBy === 'dispose') dispose();
if (cancelBy === 'mouse-mode') enabled = false;
t.mock.timers.tick(1000);
assert.equal(menus, 0);
dispose();
dom.window.close();
});
}
test('disabled or mouse-owned gestures retain the full original event sequence', () => {
const dom = new JSDOM('<div></div>');
const root = dom.window.document.querySelector('div')!;
const dispose = installRightClickLongPress(root, () => false);
const received: string[] = [];
for (const type of ['mousedown', 'contextmenu', 'mouseup']) {
root.addEventListener(type, () => received.push(type));
root.dispatchEvent(new dom.window.MouseEvent(type, { bubbles: true, button: 2 }));
}
assert.deepEqual(received, ['mousedown', 'contextmenu', 'mouseup']);
dispose();
dom.window.close();
});
@@ -0,0 +1,79 @@
// Delay the configured short-click action only while a local long press is
// possible. Mouse-reporting applications retain their original press/release.
export const RIGHT_CLICK_LONG_PRESS_MS = 500;
export function installRightClickLongPress(
root: HTMLElement,
canStart: () => boolean,
): () => void {
const doc = root.ownerDocument;
const win = doc.defaultView!;
let timer: ReturnType<typeof setTimeout> | undefined;
let press: MouseEvent | undefined;
let suppressContextMenu = false;
const replayEvents = new WeakSet<Event>();
const cancel = () => {
clearTimeout(timer);
timer = undefined;
press = undefined;
};
const dispatch = (event: MouseEvent, longPress: boolean) => {
const replay = new win.MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2,
clientX: event.clientX,
clientY: event.clientY,
// The existing Shift+right-click path opens the same menu.
shiftKey: longPress,
});
replayEvents.add(replay);
// Dispatch on the surface to avoid xterm's textarea focus interception.
root.dispatchEvent(replay);
};
const onDown = (event: MouseEvent) => {
cancel();
suppressContextMenu = false;
if (event.button !== 2 || event.shiftKey || event.ctrlKey || event.altKey || event.metaKey
|| !canStart()
|| (event.target as Element | null)?.closest('[data-terminal-history-preview]')) return;
press = event;
suppressContextMenu = true;
timer = setTimeout(() => {
const pending = press;
cancel();
if (pending && canStart()) dispatch(pending, true);
}, RIGHT_CLICK_LONG_PRESS_MS);
};
const onUp = (event: MouseEvent) => {
if (event.button !== 2 || !press) return;
const pending = press;
cancel();
if (root.contains(event.target as Node) && canStart()) dispatch(pending, false);
};
const onMove = (event: MouseEvent) => {
if (press && (Math.hypot(event.clientX - press.clientX, event.clientY - press.clientY) > 8
|| !(event.buttons & 2))) cancel();
};
const onContextMenu = (event: MouseEvent) => {
// Native contextmenu arrives on press on macOS/Linux, on release on Windows.
if (suppressContextMenu && event.button === 2 && !replayEvents.has(event)) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
root.addEventListener('mousedown', onDown, true);
root.addEventListener('contextmenu', onContextMenu, true);
doc.addEventListener('mouseup', onUp, true);
doc.addEventListener('mousemove', onMove, true);
win.addEventListener('blur', cancel);
return () => {
cancel();
root.removeEventListener('mousedown', onDown, true);
root.removeEventListener('contextmenu', onContextMenu, true);
doc.removeEventListener('mouseup', onUp, true);
doc.removeEventListener('mousemove', onMove, true);
win.removeEventListener('blur', cancel);
};
}
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { Terminal } from '@xterm/xterm';
import { useTerminalContextActions } from './hooks/useTerminalContextActions';
test('saving captures the screen before the dialog, prevents duplicate dialogs, and supports preview and cancellation', async () => {
const dom = new JSDOM('<div id="root"></div><div id="terminal"></div>', { url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const requests: Array<Parameters<NonNullable<NetcattyBridge['exportSessionLog']>>[0]> = [];
let finishSave: (result: { success: boolean; canceled?: boolean }) => void = () => {};
Object.assign(dom.window, { netcatty: { exportSessionLog: (payload: typeof requests[number]) => {
requests.push(payload);
return new Promise((resolve) => { finishSave = resolve; });
} } });
let visibleText = 'corrected';
const term = {
cols: 80, rows: 1, element: dom.window.document.createElement('div'),
buffer: { active: { type: 'normal', viewportY: 0, length: 1,
getLine: () => ({ translateToString: () => visibleText }),
} },
};
dom.window.document.getElementById('terminal')!.append(term.element);
const termRef = { current: term as unknown as Terminal };
let actions!: ReturnType<typeof useTerminalContextActions>;
function Harness() {
actions = useTerminalContextActions({ termRef, sourceSessionId: 'session', sessionName: 'My terminal',
sessionRef: { current: null }, isLocalConnection: true, supportsRemoteImagePaste: false,
terminalBackend: { writeToSession() {} },
});
return null;
}
const root = createRoot(dom.window.document.getElementById('root')!);
try {
await act(async () => root.render(<Harness />));
const firstSave = actions.onSaveScreen();
visibleText = 'later output';
await actions.onSaveScreen();
assert.equal(requests.length, 1);
assert.equal(requests[0].terminalData, 'corrected');
assert.equal(requests[0].plainText, true);
assert.equal(requests[0].hostLabel, 'My terminal');
finishSave({ success: false, canceled: true });
await firstSave;
const preview = dom.window.document.createElement('pre');
preview.setAttribute('data-terminal-history-preview', 'true');
preview.textContent = 'history\n\n row';
term.element.parentElement!.append(preview);
const secondSave = actions.onSaveScreen();
assert.equal(requests.length, 2);
assert.equal(requests[1].terminalData, 'history\n\n row');
finishSave({ success: true });
await secondSave;
termRef.current = null as unknown as Terminal;
await actions.onSaveScreen();
assert.equal(requests.length, 2);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -57,3 +57,26 @@ test("readActiveTerminalBufferTextRange reads the active alternate buffer withou
"vim title\nfile body\n:",
);
});
import { readTerminalScreenText } from "./terminalContextBuffer.ts";
test("screen export follows the scrolled viewport and excludes hidden history", () => {
const buffer = { ...createBuffer(["old", "visible one", " visible two", "newest"]),
viewportY: 1, length: 4, type: "normal" };
assert.equal(readTerminalScreenText({ cols: 80, rows: 2, buffer: { active: buffer } } as never),
"visible one\n visible two");
});
test("screen export preserves blank and wrapped physical rows and clips hidden columns", () => {
const buffer = { ...createBuffer(["123456", "78", "", "end", ""]),
viewportY: 0, length: 5, type: "normal" };
assert.equal(readTerminalScreenText({ cols: 4, rows: 5, buffer: { active: buffer } } as never),
"1234\n78\n\nend\n");
});
test("screen export reads only the alternate screen while fullscreen apps are active", () => {
const buffer = { ...createBuffer(["vim", "text", ""]),
viewportY: 0, length: 3, type: "alternate" };
assert.equal(readTerminalScreenText({ cols: 80, rows: 3, buffer: { active: buffer } } as never),
"vim\ntext\n");
});
@@ -40,3 +40,13 @@ export function readTerminalBufferTextRange(
}
return lines.join("\n");
}
/** Read the rendered viewport, preserving its physical row boundaries. */
export function readTerminalScreenText(term: Pick<XTerm, "buffer" | "cols" | "rows">): string {
const buffer = term.buffer.active;
const startLine = buffer.type === "alternate" ? 0 : buffer.viewportY;
return readActiveTerminalBufferTextRange(term, {
startLine,
endLine: Math.min(buffer.length, startLine + term.rows) - 1,
});
}
@@ -1,3 +1,4 @@
import { publishTerminalCommandCompletion } from "../../application/state/terminalCommandCompletion";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { publishPluginTerminalRuntimeLifecycleEvent } from '../../application/state/pluginTerminalRuntimeLifecycle';
@@ -189,6 +190,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(args[0]);
};
const pluginAwareOnCommandCompleted = () => {
publishTerminalCommandCompletion(sessionId);
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
};
@@ -338,8 +338,9 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
workspaceOuterRef.current,
)}
{activeWorkspace && isComposeBarOpen && (
{activeWorkspace && focusedSessionId && isComposeBarOpen && (
<TerminalComposeBar
sessionId={focusedSessionId}
onSend={handleComposeSend}
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
snippets={snippets}
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import test from 'node:test';
import ts from 'typescript';
import { canUseDirectSessionWriteFallback } from './terminalLayerSessionRouting';
// Exercise the actual workspace send callback without constructing terminals.
const source = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
const start = source.indexOf(' const handleComposeSend =');
const end = source.indexOf(' const sessionLogConfig =', start);
assert.ok(start >= 0 && end > start);
const code = ts.transpileModule(source.slice(start, end) + '\nglobalThis.send = handleComposeSend;', {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
type Status = 'connected' | 'connecting' | 'disconnected';
type Executor = () => boolean | Promise<boolean>;
function setup(statuses: Status[], broadcast = false, executors = new Map<string, Executor>(), sensitive = new Set<string>()) {
const writes: string[] = [];
const context = {
send: undefined as unknown as (text: string) => Promise<boolean>,
useCallback: (callback: unknown) => callback,
activeWorkspaceRef: { current: { id: 'workspace', focusedSessionId: '0' } },
sessionsRef: { current: statuses.map((status, index) => ({ id: String(index), workspaceId: 'workspace', status })) },
isBroadcastEnabled: () => broadcast,
isTerminalSensitiveInputActive: (id: string) => sensitive.has(id),
snippetExecutorsRef: { current: executors },
canUseDirectSessionWriteFallback,
terminalBackend: { writeToSession: (id: string) => writes.push(id) },
};
runInNewContext(code, context);
return { send: context.send, writes };
}
for (const broadcast of [false, true]) {
for (const status of ['connected', 'connecting', 'disconnected'] as const) {
test(`fallback history requires a connected session (${status}, broadcast=${broadcast})`, async () => {
const { send, writes } = setup([status], broadcast);
assert.equal(await send('command'), status === 'connected');
assert.deepEqual(writes, ['0']); // Existing fallback dispatch is unchanged.
});
}
for (const accepted of [false, true]) {
test(`executor result controls history (${accepted}, broadcast=${broadcast})`, async () => {
const { send } = setup(['connected'], broadcast, new Map([['0', async () => accepted]]));
assert.equal(await send('command'), accepted);
});
}
}
test('a disconnected broadcast peer cannot undo another successful fallback', async () => {
assert.equal(await setup(['connected', 'disconnected'], true).send('command'), true);
});
test('sensitive input is excluded for executor and fallback paths', async () => {
for (const broadcast of [false, true]) {
for (const executors of [new Map<string, Executor>(), new Map<string, Executor>([['0', async () => true]])]) {
assert.equal(await setup(['connected'], broadcast, executors, new Set(['0'])).send('secret'), false);
}
}
});
test('broadcast still records a successful send when another executor rejects', async () => {
const executors = new Map<string, Executor>([
['0', async () => true],
['1', async () => { throw new Error('failed'); }],
]);
assert.equal(await setup(['connected', 'connected'], true, executors).send('command'), true);
});
test('all failed workspace executors do not record history', async () => {
for (const broadcast of [false, true]) {
const executors = new Map<string, Executor>([['0', async () => { throw new Error('failed'); }]]);
assert.equal(await setup(['connected'], broadcast, executors).send('command'), false);
}
});
test('executor waking into a sensitive prompt does not record history', async () => {
const sensitive = new Set<string>();
const executors = new Map<string, Executor>([['0', async () => { sensitive.add('0'); return true; }]]);
assert.equal(await setup(['connected'], false, executors, sensitive).send('command'), false);
});
const soloSource = readFileSync(new URL('../terminal/TerminalView.tsx', import.meta.url), 'utf8');
const soloStart = soloSource.indexOf('onSend={async (text) => {');
const soloEnd = soloSource.indexOf('\n }}', soloStart);
assert.ok(soloStart >= 0 && soloEnd > soloStart);
const soloCode = ts.transpileModule('globalThis.send = ' + soloSource.slice(
soloStart + 'onSend={'.length, soloEnd + '\n }'.length,
), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
for (const result of [true, false, Promise.resolve(true), Promise.resolve(false)]) {
test(`solo send uses executor acknowledgement (${typeof result})`, async () => {
const context = {
send: undefined as unknown as (text: string) => Promise<boolean>,
sessionId: 'a', sessionRef: { current: 'a' },
executeSnippetCommand: () => result,
isTerminalSensitiveInputActive: () => false,
};
runInNewContext(soloCode, context);
assert.equal(await context.send('command'), await result);
});
}
test('solo missing sessions and sensitive input are excluded; rejection propagates to the compose handler', async () => {
const context = {
send: undefined as unknown as (text: string) => Promise<boolean>,
sessionId: 'a', sessionRef: { current: null as string | null },
executeSnippetCommand: async () => true,
isTerminalSensitiveInputActive: () => false,
};
runInNewContext(soloCode, context);
assert.equal(await context.send('command'), false);
context.sessionRef.current = 'a';
context.isTerminalSensitiveInputActive = () => true;
assert.equal(await context.send('secret'), false);
context.isTerminalSensitiveInputActive = () => false;
context.executeSnippetCommand = async () => { throw new Error('failed'); };
await assert.rejects(context.send('command'), /failed/);
});
@@ -43,12 +43,26 @@ export function SessionTabContextMenuContent({
}: SessionTabContextMenuContentProps) {
return (
<ContextMenuContent>
<ContextMenuItem
disabled={isSessionReconnectDisabled(sessionStatus, reconnectActive)}
onClick={() => onReconnectSession(sessionId)}
>
{t('terminal.menu.reconnect')}
</ContextMenuItem>
{onCopySession && (
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
{t('tabs.copyTab')}
</ContextMenuItem>
)}
{onCopySessionToNewWindow && (
<ContextMenuItem onClick={() => onCopySessionToNewWindow(sessionId)}>
{t('tabs.copyTabToNewWindow')}
</ContextMenuItem>
)}
{onDuplicateSession && (
<ContextMenuItem onClick={() => onDuplicateSession(sessionId)}>
{t('tabs.duplicateSession')}
</ContextMenuItem>
)}
{onDetachSession && (
<ContextMenuItem onClick={() => onDetachSession(sessionId)}>
{t('terminal.menu.detach')}
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onRenameSession(sessionId)}>
{t('common.rename')}
</ContextMenuItem>
@@ -57,26 +71,12 @@ export function SessionTabContextMenuContent({
{t('terminal.layer.hostTree.editHost')}
</ContextMenuItem>
)}
{onCopySession && (
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
{t('tabs.copyTab')}
</ContextMenuItem>
)}
{onDuplicateSession && (
<ContextMenuItem onClick={() => onDuplicateSession(sessionId)}>
{t('tabs.duplicateSession')}
</ContextMenuItem>
)}
{onCopySessionToNewWindow && (
<ContextMenuItem onClick={() => onCopySessionToNewWindow(sessionId)}>
{t('tabs.copyTabToNewWindow')}
</ContextMenuItem>
)}
{onDetachSession && (
<ContextMenuItem onClick={() => onDetachSession(sessionId)}>
{t('terminal.menu.detach')}
</ContextMenuItem>
)}
<ContextMenuItem
disabled={isSessionReconnectDisabled(sessionStatus, reconnectActive)}
onClick={() => onReconnectSession(sessionId)}
>
{t('terminal.menu.reconnect')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(sessionId)}>
{t('common.close')}
</ContextMenuItem>
+32 -6
View File
@@ -20,7 +20,7 @@ import { resolveSessionCodingCliProvider } from '../../domain/codingCliProviderM
import type { CodingCliProvider } from '../../domain/codingCliProviders';
import { resolveCodingCliActivityPhase, type CodingCliActivityPhase } from '../../domain/codingCliTitleParse';
import { resolveSessionTabTitle, resolveWorkspaceTabLabel } from '../../domain/sessionTabTitle';
import type { DynamicTabTitleMode } from '../../domain/models';
import type { DynamicTabTitleMode, TerminalTabDoubleClickBehavior } from '../../domain/models';
import { CodingCliProviderIcon } from '../icons/CodingCliProviderIcon';
import { cn } from '../../lib/utils';
import { Host, TerminalSession, Workspace } from '../../types';
@@ -242,9 +242,28 @@ export const formatSessionTopTabLabel = (
};
export const createTopTabCopyDoubleClickHandler = (
onCopySession: (sessionId: string) => void,
sessionId: string,
): React.MouseEventHandler<HTMLDivElement> => () => onCopySession(sessionId);
onCopy: (id: string) => void,
id: string,
): React.MouseEventHandler<HTMLDivElement> => () => onCopy(id);
export const createTopTabSessionDoubleClickHandler = ({
behavior,
onCopySession,
onDuplicateSession,
sessionId,
}: {
behavior: TerminalTabDoubleClickBehavior;
onCopySession: (sessionId: string) => void;
onDuplicateSession?: (sessionId: string) => void;
sessionId: string;
}): React.MouseEventHandler<HTMLDivElement> => () => {
if (behavior === 'disabled') return;
if (behavior === 'duplicate') {
(onDuplicateSession ?? onCopySession)(sessionId);
return;
}
onCopySession(sessionId);
};
export const stopCloseButtonDoubleClickPropagation = (
event: Pick<React.MouseEvent, 'stopPropagation'>,
@@ -713,6 +732,7 @@ interface SessionTopTabProps {
onEditHost?: (host: Host) => void;
renderBulkCloseItems: RenderBulkCloseItems;
dynamicTabTitleMode?: DynamicTabTitleMode;
tabDoubleClickBehavior: TerminalTabDoubleClickBehavior;
t: TranslateFn;
tabAnimationClass?: string;
shortcutNumber?: number;
@@ -739,6 +759,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
onEditHost,
renderBulkCloseItems,
dynamicTabTitleMode,
tabDoubleClickBehavior,
t,
tabAnimationClass,
shortcutNumber,
@@ -757,8 +778,13 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
activeTabStore.setActiveTabId(session.id);
}, [session.id]);
const handleDoubleClick = useMemo(
() => createTopTabCopyDoubleClickHandler(onCopySession, session.id),
[onCopySession, session.id],
() => createTopTabSessionDoubleClickHandler({
behavior: tabDoubleClickBehavior,
onCopySession,
onDuplicateSession,
sessionId: session.id,
}),
[tabDoubleClickBehavior, onCopySession, onDuplicateSession, session.id],
);
const addressTooltip = formatSessionTopTabTooltip(session);
const tabTitle = formatSessionTopTabLabel(session, dynamicTabTitleMode);
+3 -2
View File
@@ -1,10 +1,10 @@
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,GripVertical,SortAsc,SortDesc } from 'lucide-react';
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,GripVertical,Network,SortAsc,SortDesc } from 'lucide-react';
import React from 'react';
import { useI18n } from "../../application/i18n/I18nProvider";
import { Button } from './button';
import { Dropdown,DropdownContent,DropdownTrigger } from './dropdown';
export type SortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
export type SortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group' | 'ip';
const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElement; triggerIcon: React.ReactElement }> = {
manual: { labelKey: 'sort.manual', icon: <GripVertical className="w-4 h-4 shrink-0" />, triggerIcon: <GripVertical className="w-4 h-4" /> },
@@ -13,6 +13,7 @@ const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElemen
newest: { labelKey: 'sort.newest', icon: <Calendar className="w-4 h-4 shrink-0" />, triggerIcon: <Calendar className="w-4 h-4" /> },
oldest: { labelKey: 'sort.oldest', icon: <CalendarClock className="w-4 h-4 shrink-0" />, triggerIcon: <CalendarClock className="w-4 h-4" /> },
group: { labelKey: 'sort.group', icon: <FolderTree className="w-4 h-4 shrink-0" />, triggerIcon: <FolderTree className="w-4 h-4" /> },
ip: { labelKey: 'sort.ip', icon: <Network className="w-4 h-4 shrink-0" />, triggerIcon: <Network className="w-4 h-4" /> },
};
interface SortDropdownProps {
@@ -893,6 +893,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold flex items-center gap-1.5 min-w-0">
<span className="truncate">{node.name}</span>
<HostNotesIndicator notes={groupConfigs.find((config) => config.path === node.path)?.notes} label="Group notes" />
{!isMultiSelectMode && viewMode !== "grid" && renderGroupEditButton(node.path, true)}
{managedGroupPaths.has(node.path) && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo } from "react";
import { compareHostAddresses } from "../../domain/hostAddressSort";
import { upsertKnownHost } from "../../domain/knownHosts";
import { sortByVaultOrder, sortVaultStringsByOrder } from "../../domain/vaultOrder";
import { matchesHostSearchQuery, matchesSearchQuery } from "../../lib/searchMatcher";
@@ -123,6 +124,10 @@ export function useVaultHostCollections({
const groupCmp = groupA.localeCompare(groupB);
return groupCmp !== 0 ? groupCmp : a.label.localeCompare(b.label);
}
case "ip": {
const addrCmp = compareHostAddresses(a.hostname, b.hostname);
return addrCmp !== 0 ? addrCmp : a.label.localeCompare(b.label);
}
default:
return 0;
}
+12 -12
View File
@@ -29,20 +29,20 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</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="169.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="246.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="331.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="413.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="498.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="581.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="666.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="751.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="167.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="242.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="324.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="404.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="487.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="567.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="650.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="733.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 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.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<circle cx="765.0" cy="100.2" 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">5683</text>
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
<circle cx="765.0" cy="85.3" 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">5917</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: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+12 -12
View File
@@ -29,20 +29,20 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</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="169.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="246.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="331.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="413.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="498.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="581.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="666.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="751.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="167.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
<text x="242.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="324.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="404.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="487.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="567.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="650.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="733.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 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.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
<circle cx="765.0" cy="100.2" 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">5683</text>
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
<path d="M 90.0,462.0 C 90.4,462.0 92.2,461.6 92.7,461.5 C 93.6,461.4 94.4,461.1 95.3,461.0 C 96.2,460.8 97.1,460.5 98.0,460.4 C 98.9,460.3 99.8,460.4 100.7,460.3 C 101.6,460.3 102.5,460.2 103.3,460.2 C 104.2,460.1 105.1,460.1 106.0,460.0 C 106.9,459.9 107.3,459.9 108.7,459.8 C 110.0,459.7 112.7,459.7 114.0,459.6 C 115.3,459.6 115.8,459.5 116.7,459.5 C 117.6,459.5 118.5,459.4 119.3,459.4 C 120.2,459.4 121.1,459.4 122.0,459.4 C 122.9,459.3 123.8,459.3 124.7,459.3 C 125.6,459.3 126.5,459.2 127.4,459.1 C 128.2,459.0 129.1,459.0 130.0,458.9 C 130.9,458.7 131.8,458.4 132.7,458.0 C 133.6,457.5 134.5,456.7 135.4,456.4 C 136.2,456.1 137.1,456.2 138.0,456.2 C 138.9,456.1 139.8,456.1 140.7,456.0 C 141.6,456.0 142.0,455.9 143.4,455.9 C 144.7,455.9 146.9,455.9 148.7,455.9 C 150.5,455.8 152.7,455.8 154.0,455.7 C 155.4,455.7 155.8,455.6 156.7,455.6 C 157.6,455.6 158.5,455.5 159.4,455.5 C 160.3,455.4 161.1,455.4 162.0,455.3 C 162.9,455.3 163.8,455.3 164.7,455.3 C 165.6,455.3 166.0,455.2 167.4,455.2 C 168.7,455.2 170.5,455.1 172.7,455.1 C 174.9,455.1 178.5,455.0 180.7,455.0 C 182.9,455.0 183.4,455.0 186.0,455.0 C 188.7,454.9 194.5,454.8 196.7,454.7 C 198.9,454.6 198.1,454.7 199.4,454.6 C 200.7,454.6 202.5,454.6 204.7,454.6 C 206.9,454.5 208.3,454.5 212.7,454.4 C 217.2,454.4 227.8,454.3 231.4,454.3 C 235.0,454.2 233.2,454.2 234.1,454.2 C 235.0,454.2 235.4,454.9 236.7,454.1 C 238.1,453.3 240.7,452.6 242.1,449.3 C 243.4,445.9 243.9,436.9 244.7,434.1 C 245.6,431.3 246.5,432.9 247.4,432.5 C 248.3,432.0 249.2,431.8 250.1,431.5 C 251.0,431.2 251.9,430.9 252.7,430.8 C 253.6,430.6 254.5,430.5 255.4,430.4 C 256.3,430.3 257.2,430.1 258.1,430.0 C 259.0,429.9 259.9,430.0 260.8,429.9 C 261.6,429.8 262.5,429.6 263.4,429.5 C 264.3,429.4 265.2,429.4 266.1,429.3 C 267.0,429.1 267.9,428.7 268.8,428.6 C 269.6,428.4 270.5,428.5 271.4,428.4 C 272.3,428.3 273.2,428.0 274.1,428.0 C 275.0,427.9 275.9,427.9 276.8,427.9 C 277.6,427.8 278.5,427.8 279.4,427.7 C 280.3,427.6 281.2,427.6 282.1,427.5 C 283.0,427.4 283.9,427.4 284.8,427.3 C 285.7,427.2 286.5,426.9 287.4,426.7 C 288.3,426.6 289.2,427.4 290.1,426.3 C 291.0,425.2 291.9,421.5 292.8,420.3 C 293.7,419.1 294.5,419.2 295.4,419.0 C 296.3,418.8 297.2,419.1 298.1,419.0 C 299.0,418.8 299.9,418.4 300.8,418.1 C 301.7,417.7 302.5,417.5 303.4,416.9 C 304.3,416.3 305.2,415.2 306.1,414.5 C 307.0,413.8 307.9,413.1 308.8,412.6 C 309.7,412.1 310.6,411.7 311.4,411.4 C 312.3,411.1 313.2,410.9 314.1,410.8 C 315.0,410.6 315.9,410.7 316.8,410.5 C 317.7,410.4 318.6,410.1 319.4,410.0 C 320.3,409.9 321.2,410.0 322.1,409.9 C 323.0,409.8 323.9,409.8 324.8,409.6 C 325.7,409.5 326.6,409.5 327.5,409.1 C 328.3,408.6 329.2,407.3 330.1,406.8 C 331.0,406.2 331.9,406.0 332.8,405.6 C 333.7,405.1 334.6,406.0 335.5,404.1 C 336.3,402.3 337.2,396.7 338.1,394.6 C 339.0,392.4 339.9,392.1 340.8,391.2 C 341.7,390.3 342.6,389.7 343.5,389.1 C 344.3,388.4 345.2,387.8 346.1,387.3 C 347.0,386.8 347.9,386.4 348.8,386.1 C 349.7,385.8 350.6,385.7 351.5,385.4 C 352.4,385.2 353.2,384.9 354.1,384.5 C 355.0,384.2 355.9,383.6 356.8,383.3 C 357.7,383.0 358.6,383.1 359.5,382.8 C 360.4,382.5 361.2,381.9 362.1,381.5 C 363.0,381.2 363.9,380.9 364.8,380.6 C 365.7,380.3 366.6,380.1 367.5,379.9 C 368.4,379.7 369.2,379.7 370.1,379.5 C 371.0,379.3 371.9,379.1 372.8,378.9 C 373.7,378.7 374.6,378.5 375.5,378.3 C 376.4,378.0 377.3,377.8 378.1,377.5 C 379.0,377.1 379.9,376.4 380.8,376.1 C 381.7,375.7 382.6,375.6 383.5,375.4 C 384.4,375.2 385.3,375.1 386.1,375.0 C 387.0,374.8 387.9,374.7 388.8,374.6 C 389.7,374.5 390.6,374.3 391.5,374.2 C 392.4,374.1 393.3,374.2 394.2,374.0 C 395.0,373.9 395.9,373.6 396.8,373.3 C 397.7,373.0 398.6,372.4 399.5,372.0 C 400.4,371.6 401.3,371.2 402.2,371.0 C 403.0,370.7 403.9,370.7 404.8,370.5 C 405.7,370.3 406.6,370.3 407.5,370.0 C 408.4,369.7 409.3,369.1 410.2,368.8 C 411.0,368.6 411.9,368.7 412.8,368.5 C 413.7,368.4 414.6,368.1 415.5,367.9 C 416.4,367.7 417.3,367.5 418.2,367.2 C 419.1,367.0 419.9,366.8 420.8,366.5 C 421.7,366.3 422.6,366.2 423.5,366.0 C 424.4,365.7 425.3,365.3 426.2,365.0 C 427.1,364.7 427.9,364.6 428.8,364.3 C 429.7,364.0 430.6,363.7 431.5,363.4 C 432.4,363.0 433.3,362.7 434.2,362.4 C 435.1,362.1 435.9,362.0 436.8,361.6 C 437.7,361.1 438.6,360.0 439.5,359.6 C 440.4,359.2 441.3,359.1 442.2,359.0 C 443.1,358.8 444.0,358.8 444.8,358.7 C 445.7,358.6 446.6,358.4 447.5,358.2 C 448.4,358.1 449.3,358.4 450.2,357.9 C 451.1,357.5 452.0,356.2 452.8,355.7 C 453.7,355.2 454.6,355.3 455.5,355.0 C 456.4,354.7 457.3,354.1 458.2,353.9 C 459.1,353.7 460.0,353.8 460.8,353.6 C 461.7,353.4 462.6,352.7 463.5,352.5 C 464.4,352.3 465.3,352.3 466.2,352.2 C 467.1,352.1 468.0,352.1 468.9,351.8 C 469.7,351.5 470.6,351.2 471.5,350.6 C 472.4,350.0 473.3,348.8 474.2,348.2 C 475.1,347.5 476.0,347.3 476.9,346.8 C 477.7,346.4 478.6,346.3 479.5,345.4 C 480.4,344.6 481.3,342.6 482.2,341.7 C 483.1,340.7 484.0,340.3 484.9,339.7 C 485.8,339.2 486.6,339.1 487.5,338.2 C 488.4,337.4 489.3,335.5 490.2,334.6 C 491.1,333.8 492.0,333.7 492.9,333.2 C 493.8,Line truncated
<circle cx="765.0" cy="85.3" 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">5917</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: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
appendComposeBarHistory,
canNavigateComposeBarHistory,
COMPOSE_BAR_HISTORY_MAX,
navigateComposeBarHistory,
} from './composeBarHistory';
test('appendComposeBarHistory ignores empty/whitespace-only commands', () => {
assert.deepEqual(appendComposeBarHistory(['ls'], ' '), ['ls']);
assert.deepEqual(appendComposeBarHistory([], ''), []);
});
test('appendComposeBarHistory appends newest entries and dedupes consecutive repeats', () => {
assert.deepEqual(appendComposeBarHistory([], 'ls -la'), ['ls -la']);
assert.deepEqual(appendComposeBarHistory(['ls -la'], 'ls -la'), ['ls -la']);
assert.deepEqual(
appendComposeBarHistory(['ls -la'], 'df -h'),
['ls -la', 'df -h'],
);
});
test('appendComposeBarHistory preserves multi-line commands and caps length', () => {
const multi = 'echo one\necho two';
assert.deepEqual(appendComposeBarHistory(['pwd'], multi), ['pwd', multi]);
const seeded = Array.from({ length: COMPOSE_BAR_HISTORY_MAX }, (_, i) => `cmd-${i}`);
const next = appendComposeBarHistory(seeded, 'overflow');
assert.equal(next.length, COMPOSE_BAR_HISTORY_MAX);
assert.equal(next[0], 'cmd-1');
assert.equal(next[next.length - 1], 'overflow');
});
test('canNavigateComposeBarHistory only steals arrows at first/last line', () => {
const multi = 'line1\nline2\nline3';
assert.equal(canNavigateComposeBarHistory(multi, 0, 'up'), true);
assert.equal(canNavigateComposeBarHistory(multi, 3, 'up'), true);
assert.equal(canNavigateComposeBarHistory(multi, 6, 'up'), false);
assert.equal(canNavigateComposeBarHistory(multi, multi.length, 'down'), true);
assert.equal(canNavigateComposeBarHistory(multi, 6, 'down'), false);
assert.equal(canNavigateComposeBarHistory('single', 3, 'up'), true);
assert.equal(canNavigateComposeBarHistory('single', 3, 'down'), true);
assert.equal(canNavigateComposeBarHistory(multi, 0, 'up', 6), false);
});
test('navigateComposeBarHistory walks older/newer entries and restores the draft', () => {
const entries = ['one', 'two', 'three'];
const firstUp = navigateComposeBarHistory(
{ entries, index: entries.length, draft: '', currentValue: 'draft' },
'up',
);
assert.deepEqual(firstUp, { index: 2, value: 'three', draft: 'draft' });
const secondUp = navigateComposeBarHistory(
{ entries, index: 2, draft: 'draft', currentValue: 'three' },
'up',
);
assert.deepEqual(secondUp, { index: 1, value: 'two', draft: 'draft' });
const downToDraft = navigateComposeBarHistory(
{ entries, index: 2, draft: 'draft', currentValue: 'three' },
'down',
);
assert.deepEqual(downToDraft, { index: 3, value: 'draft', draft: 'draft' });
assert.equal(
navigateComposeBarHistory(
{ entries, index: 0, draft: 'draft', currentValue: 'one' },
'up',
),
null,
);
assert.equal(
navigateComposeBarHistory(
{ entries, index: entries.length, draft: 'draft', currentValue: 'draft' },
'down',
),
null,
);
assert.equal(
navigateComposeBarHistory(
{ entries: [], index: 0, draft: '', currentValue: '' },
'up',
),
null,
);
});
+78
View File
@@ -0,0 +1,78 @@
/** Max in-memory compose-bar send history entries (oldest dropped). */
export const COMPOSE_BAR_HISTORY_MAX = 100;
export type ComposeBarHistoryDirection = 'up' | 'down';
export interface ComposeBarHistoryNavInput {
entries: readonly string[];
/** `entries.length` means the live draft; `0..length-1` browses history. */
index: number;
draft: string;
currentValue: string;
}
export interface ComposeBarHistoryNavResult {
index: number;
value: string;
draft: string;
}
/**
* Append a sent compose-bar command. Skips empties, consecutive duplicates,
* and trims the ring to {@link COMPOSE_BAR_HISTORY_MAX}.
*/
export function appendComposeBarHistory(
entries: readonly string[],
command: string,
max = COMPOSE_BAR_HISTORY_MAX,
): string[] {
if (!command || !command.trim()) return [...entries];
if (entries[entries.length - 1] === command) return [...entries];
return [...entries, command].slice(-Math.max(1, max));
}
/** True when Up/Down should recall history instead of moving within the textarea. */
export function canNavigateComposeBarHistory(
value: string,
selectionStart: number,
direction: ComposeBarHistoryDirection,
selectionEnd = selectionStart,
): boolean {
if (selectionStart !== selectionEnd) return false;
const cursor = Math.max(0, Math.min(selectionStart, value.length));
if (direction === 'up') {
return !value.slice(0, cursor).includes('\n');
}
return !value.slice(cursor).includes('\n');
}
/**
* Shell-style history walk for the compose bar: Up goes older, Down goes newer,
* and moving past the newest entry restores the saved draft.
*/
export function navigateComposeBarHistory(
state: ComposeBarHistoryNavInput,
direction: ComposeBarHistoryDirection,
): ComposeBarHistoryNavResult | null {
const { entries } = state;
if (entries.length === 0) return null;
const liveIndex = entries.length;
let index = Number.isFinite(state.index) ? state.index : liveIndex;
if (index < 0) index = 0;
if (index > liveIndex) index = liveIndex;
if (direction === 'up') {
if (index <= 0) return null;
const draft = index === liveIndex ? state.currentValue : state.draft;
const nextIndex = index - 1;
return { index: nextIndex, value: entries[nextIndex] ?? '', draft };
}
if (index >= liveIndex) return null;
const nextIndex = index + 1;
if (nextIndex >= liveIndex) {
return { index: liveIndex, value: state.draft, draft: state.draft };
}
return { index: nextIndex, value: entries[nextIndex] ?? '', draft: state.draft };
}
+7
View File
@@ -581,3 +581,10 @@ test("applyGroupDefaults keeps host algorithm overrides instead of inheriting",
);
assert.deepEqual(result.algorithms, hostOverrides);
});
test("group notes survive sanitization but are not connection defaults", () => {
const config = { path: "Project", notes: "# VPN\nConnect before SSH.", username: "admin" };
assert.equal(sanitizeGroupConfig(config).notes, config.notes);
assert.equal(resolveGroupDefaults("Project", [config]).notes, undefined);
assert.equal(resolveGroupDefaults("Project/Servers", [config]).notes, undefined);
});
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more