diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 384bb7a..6a07944 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "conn", "source": "./plugin", "description": "Conn: MCP server + working-etiquette skill.", - "version": "0.8.5" + "version": "0.8.6" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed4419..90a9c9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +## 0.8.6 — Preview · 2026-09-22 + +- Keep terminal dimensions and shell output in the same order when an approval bar opens and closes, including in a fixed window. This fixes reproduced stale sizes and cursor disagreement after delayed resizes and shell redraws while retaining the existing motion. +- Preserve JSON and other output exactly: a response without a trailing newline can share its last line with the next shell prompt. Regression coverage distinguishes this from lost newlines and incorrect wrapping. + +한국어: 창을 고정한 상태에서 승인 바가 열리고 닫힐 때도 터미널 크기와 셸 출력을 같은 순서로 반영합니다. 모션은 유지하면서 지연된 크기 변경·화면 재출력으로 크기와 커서가 어긋나는 재현된 결함을 수정했습니다. JSON 등 실제 출력은 그대로 유지하며, 끝에 개행이 없어 프롬프트가 붙는 경우와 줄바꿈 오류를 구분해 검사합니다. + +Validation: 259 Rust tests, 55 frontend tests, real SSH browser regressions and the 12-stage collaboration context harness passed. The same product code passed native Apple Silicon macOS 27.0 checks in an isolated ad-hoc debug app with real WebKit, Tauri IPC and loopback SSH: 18 approval cycles, including 12 rapid denials, and 38 full visible-grid/cursor comparisons passed. Human editing/takeover, session allowance, shell preservation and output-ending checks also passed. [Verification report](docs/ssh-input-review-results.md#native-macos-verification-of-pr-64). + +Remaining coverage: ResizeObserver warnings remain (24 during the rapid-denial cycles), without persistent disagreement at the checked post-motion points. The user's actual remote environment, physical-keyboard IME and separate canonical `/bin/sh` wrapped-text behavior remain unverified or unresolved. Signed release artifacts and installed-app upgrades are separate from these debug-app results. + +한국어: Rust 259개·프런트엔드 55개·실제 SSH 브라우저 회귀·협업 맥락 12단계를 통과했습니다. 같은 제품 코드를 Apple Silicon macOS 27.0의 별도 네이티브 디버그 앱과 실제 SSH에서도 확인했습니다. 승인 동작 18회(빠른 거부 12회 포함), 전체 화면·커서 비교 38회, 사람의 편집·제어권 회수, 세션 허용, 기존 셸 유지와 개행 구분이 통과했습니다. 빠른 거부 중 ResizeObserver 경고 24건은 남았지만 확인한 모션 종료 지점에서는 지속되는 불일치가 없었습니다. 사용자 실제 원격 환경·물리 키보드 IME·별도 canonical `/bin/sh` 현상은 미검증 또는 미해결이며, 배포 파일 서명·공증과 설치 앱 업데이트 검증은 별개입니다. [검증 보고서](docs/ssh-input-review-results.ko.md#pr-64-네이티브-macos-검증). + ## 0.8.5 — Preview · 2026-09-22 - Restore policy-based Autopilot in POSIX SSH: ordinary commands run, risky commands request approval, and deny rules remain blocked. Remote paths are never inspected as host files. diff --git a/Cargo.lock b/Cargo.lock index f90e332..0458a10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "conn" -version = "0.8.5" +version = "0.8.6" dependencies = [ "clap", "conn-core", @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "conn-browser-harness" -version = "0.8.5" +version = "0.8.6" dependencies = [ "axum", "conn-core", @@ -719,7 +719,7 @@ dependencies = [ [[package]] name = "conn-core" -version = "0.8.5" +version = "0.8.6" dependencies = [ "chrono", "dirs 5.0.1", @@ -742,7 +742,7 @@ dependencies = [ [[package]] name = "conn-desktop" -version = "0.8.5" +version = "0.8.6" dependencies = [ "base64 0.22.1", "cc", @@ -764,7 +764,7 @@ dependencies = [ [[package]] name = "conn-frontend" -version = "0.8.5" +version = "0.8.6" dependencies = [ "base64 0.22.1", "conn-core", diff --git a/Cargo.toml b/Cargo.toml index 0e139d0..1386641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,14 +6,14 @@ members = ["crates/core", "crates/cli", "crates/frontend", "crates/browser-harne default-members = ["crates/core", "crates/cli", "crates/frontend", "crates/browser-harness"] [workspace.package] -version = "0.8.5" +version = "0.8.6" edition = "2021" license = "MIT" repository = "https://github.com/eggp-dev/conn" [workspace.dependencies] -conn-core = { path = "crates/core", version = "0.8.5" } -conn-frontend = { path = "crates/frontend", version = "0.8.5" } +conn-core = { path = "crates/core", version = "0.8.6" } +conn-frontend = { path = "crates/frontend", version = "0.8.6" } tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "sync", "time", "macros", "signal"] } portable-pty = "0.8" vt100 = { path = "vendor/vt100", version = "0.15.2" } diff --git a/README.ko.md b/README.ko.md index 5a72de1..7a09c4f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -29,13 +29,13 @@ curl -fsSL https://conn.eggp.dev/install.sh | sh Rust나 Node.js는 필요 없습니다. 에이전트 연결 도구가 함께 들어 있습니다. 두 방법 모두 아래 다운로드와 같은 서명된 릴리스 파일을 설치하며, 이후에는 앱 안에서 업데이트됩니다. Windows는 설치 파일을 사용하세요. -| 운영체제 | v0.8.5 프리뷰 | +| 운영체제 | v0.8.6 프리뷰 | |---|---| -| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Mac용 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [설치 파일 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [.deb 다운로드](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-x86_64-unknown-linux-gnu-desktop.AppImage) | -Mac 배포 파일은 서명·공증되어 있습니다. Windows 프리뷰는 무서명이며, Linux는 Ubuntu 24.04에서 빌드하며, AppImage는 앱 안에서 업데이트되고 `.deb`는 새 패키지를 설치해 업데이트합니다. Intel Mac 배포는 잠시 중단했습니다. [설치·업데이트 안내](docs/getting-started.ko.md) · [전체 파일·체크섬](https://github.com/eggp-dev/conn/releases/tag/v0.8.5) +Mac 배포 파일은 서명·공증되어 있습니다. Windows 프리뷰는 무서명이며, Linux는 Ubuntu 24.04에서 빌드하며, AppImage는 앱 안에서 업데이트되고 `.deb`는 새 패키지를 설치해 업데이트합니다. Intel Mac 배포는 잠시 중단했습니다. [설치·업데이트 안내](docs/getting-started.ko.md) · [전체 파일·체크섬](https://github.com/eggp-dev/conn/releases/tag/v0.8.6) ## 처음 5분 diff --git a/README.md b/README.md index e814df3..61da86e 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,13 @@ curl -fsSL https://conn.eggp.dev/install.sh | sh No Rust or Node.js required; the agent connector is included. Both install the same signed release files as the downloads below, and the app updates itself afterwards. Windows: use the installer. -| Platform | v0.8.5 preview | +| Platform | v0.8.6 preview | |---|---| -| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-aarch64-apple-darwin-desktop.dmg) | -| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-pc-windows-msvc-setup.exe) | -| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.AppImage) | +| macOS · Apple Silicon | [Download for Mac](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-aarch64-apple-darwin-desktop.dmg) | +| Windows · x64 | [Download installer](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-x86_64-pc-windows-msvc-setup.exe) | +| Ubuntu · x64 | [Download .deb](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-x86_64-unknown-linux-gnu-desktop.deb) · [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-x86_64-unknown-linux-gnu-desktop.AppImage) | -Mac downloads are signed and notarized. Windows previews are unsigned. Linux builds use Ubuntu 24.04, and the AppImage updates in-app while the `.deb` updates by installing a new package; Intel Mac distribution is paused. [Installation and updates](docs/getting-started.md) · [All assets and checksums](https://github.com/eggp-dev/conn/releases/tag/v0.8.5) +Mac downloads are signed and notarized. Windows previews are unsigned. Linux builds use Ubuntu 24.04, and the AppImage updates in-app while the `.deb` updates by installing a new package; Intel Mac distribution is paused. [Installation and updates](docs/getting-started.md) · [All assets and checksums](https://github.com/eggp-dev/conn/releases/tag/v0.8.6) ## First five minutes diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index bf1d9cb..ce7ab29 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -402,6 +402,7 @@ pub struct OutputFrame { pub data: Vec, pub output_seq: u64, pub generation: u64, + pub size: crate::screen::Size, } pub type OutputFrameSink = Box; @@ -851,9 +852,13 @@ impl Session { fn write_output(&mut self, bytes: &[u8]) { if bytes.is_empty() { return; } + self.emit_output_frame(bytes); + } + + fn emit_output_frame(&mut self, bytes: &[u8]) { self.output_seq = self.output_seq.saturating_add(1); if let Some(sink) = &self.output_frame_sink { - sink(&OutputFrame { data: bytes.to_vec(), output_seq: self.output_seq, generation: self.surface_generation }); + sink(&OutputFrame { data: bytes.to_vec(), output_seq: self.output_seq, generation: self.surface_generation, size: self.screen.size() }); } } @@ -1025,6 +1030,9 @@ impl Session { pub fn resize(&mut self, rows: u16, cols: u16) { self.screen.resize(rows, cols); self.screen_dirty = true; + // Order renderer dimensions with PTY bytes under the same session lock. + // An IPC acknowledgement alone can overtake output event delivery. + self.emit_output_frame(&[]); if let Some(m) = &self.master { let _ = m.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 }); } diff --git a/crates/frontend/src/lib.rs b/crates/frontend/src/lib.rs index 69962e1..178bab7 100644 --- a/crates/frontend/src/lib.rs +++ b/crates/frontend/src/lib.rs @@ -204,7 +204,7 @@ fn terminal_output(app: AppHandle, session: String) -> conn_core::session::Outpu use base64::Engine as _; if let Some(state) = app.state.upgrade() { let data = base64::engine::general_purpose::STANDARD.encode(&frame.data); - let value = json!({"session":session,"data":data,"outputSeq":frame.output_seq,"generation":frame.generation}); + let value = json!({"session":session,"data":data,"outputSeq":frame.output_seq,"generation":frame.generation,"size":frame.size}); let mut output = state.output.lock(); match output.get_mut(&session) { Some(Some(buffer)) => { diff --git a/crates/frontend/tests/surface_harness.rs b/crates/frontend/tests/surface_harness.rs index c356ac2..aea92a1 100644 --- a/crates/frontend/tests/surface_harness.rs +++ b/crates/frontend/tests/surface_harness.rs @@ -38,6 +38,27 @@ fn native_output_is_sequenced_and_renderer_cannot_inject_observations() { assert_eq!(h.invoke("status",json!({"session":id})).unwrap()["surfaceAvailable"],true); } #[test] +fn resize_is_ordered_with_owner_output_even_when_the_shell_is_silent() { + use base64::Engine as _; + let (_dir,h,output,id)=setup(); + h.invoke("resize",json!({"session":id,"rows":27,"cols":104})).unwrap(); + let marker=output.lock().unwrap().iter().rposition(|v|v["data"]==""&&v["size"]==json!({"rows":27,"cols":104})).expect("silent resize frame"); + h.invoke("input",json!({"session":id,"data":"printf '\\nSYNTHETIC_RESIZE_OUTPUT\\n'\n"})).unwrap(); + let deadline=Instant::now()+Duration::from_secs(4); + loop { + let entries=output.lock().unwrap(); + let bytes:Vec=entries[marker+1..].iter().flat_map(|v|base64::engine::general_purpose::STANDARD.decode(v["data"].as_str().unwrap()).unwrap()).collect(); + if String::from_utf8_lossy(&bytes).contains("SYNTHETIC_RESIZE_OUTPUT") { + assert!(entries[marker..].iter().all(|v|v["size"]==json!({"rows":27,"cols":104}))); + assert!(entries.windows(2).all(|w|w[0]["outputSeq"].as_u64()` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. +원하면 파일과 함께 [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.6/SHA256SUMS)를 받으세요. Linux는 `sha256sum --ignore-missing -c SHA256SUMS`, macOS는 `shasum -a 256 <파일>` 결과와 해당 줄 비교, Windows는 `Get-FileHash <파일> -Algorithm SHA256`을 사용합니다. 체크섬은 파일 손상을 확인하며 코드 서명과는 별개입니다. ## 2. 터미널 설정 diff --git a/docs/getting-started.md b/docs/getting-started.md index a93f08c..e0a8f7d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -13,37 +13,37 @@ brew install --cask eggp-dev/tap/conn curl -fsSL https://conn.eggp.dev/install.sh | sh # Linux x86_64 ``` -The cask installs the signed and notarized DMG and checks it against the SHA-256 published with the release. The Linux script downloads the AppImage, verifies it against the release's `SHA256SUMS`, and installs it for your user only (`~/.local/bin`, an app-menu entry, no sudo); it refuses to install on a checksum mismatch. `CONN_VERSION=v0.8.5` pins a release. AppImages need FUSE 2: on Ubuntu 24.04 and newer, `sudo apt install libfuse2t64`. Both keep updating from inside the app. Read the script before piping it to a shell if you like: [`scripts/install.sh`](../scripts/install.sh). +The cask installs the signed and notarized DMG and checks it against the SHA-256 published with the release. The Linux script downloads the AppImage, verifies it against the release's `SHA256SUMS`, and installs it for your user only (`~/.local/bin`, an app-menu entry, no sudo); it refuses to install on a checksum mismatch. `CONN_VERSION=v0.8.6` pins a release. AppImages need FUSE 2: on Ubuntu 24.04 and newer, `sudo apt install libfuse2t64`. Both keep updating from inside the app. Read the script before piping it to a shell if you like: [`scripts/install.sh`](../scripts/install.sh). Or download a file yourself: -Download the [v0.8.5 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.5) for your OS and CPU. See the release notes for the exact signing and runtime verification results. +Download the [v0.8.6 preview](https://github.com/eggp-dev/conn/releases/tag/v0.8.6) for your OS and CPU. See the release notes for the exact signing and runtime verification results. | Platform | Download | Install | |---|---|---| -| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.deb) | Run the command below, then open Conn from your applications. | -| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.AppImage) | Make the file executable, then open it. | -| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-aarch64-apple-darwin-desktop.dmg) | Open the DMG, drag Conn into Applications, then launch it there. | -| Windows x64 | [Installer `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.5/conn-v0.8.5-x86_64-pc-windows-msvc-setup.exe) | Run the installer, then open Conn from the Start menu. | +| Ubuntu x64 | [`.deb`](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-x86_64-unknown-linux-gnu-desktop.deb) | Run the command below, then open Conn from your applications. | +| Ubuntu x64 | [AppImage](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-x86_64-unknown-linux-gnu-desktop.AppImage) | Make the file executable, then open it. | +| macOS Apple Silicon | [`.dmg`](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-aarch64-apple-darwin-desktop.dmg) | Open the DMG, drag Conn into Applications, then launch it there. | +| Windows x64 | [Installer `.exe`](https://github.com/eggp-dev/conn/releases/download/v0.8.6/conn-v0.8.6-x86_64-pc-windows-msvc-setup.exe) | Run the installer, then open Conn from the Start menu. | Intel Mac distribution is paused for future releases. Previously published Intel packages remain in [past releases](https://github.com/eggp-dev/conn/releases); do not install the Apple Silicon package on an Intel Mac. For Ubuntu, run the matching command in your download directory: ```sh -sudo apt install ./conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.deb +sudo apt install ./conn-v0.8.6-x86_64-unknown-linux-gnu-desktop.deb ``` Or, for AppImage: ```sh -chmod +x ./conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.AppImage -./conn-v0.8.5-x86_64-unknown-linux-gnu-desktop.AppImage +chmod +x ./conn-v0.8.6-x86_64-unknown-linux-gnu-desktop.AppImage +./conn-v0.8.6-x86_64-unknown-linux-gnu-desktop.AppImage ``` The Linux build targets Ubuntu 24.04 and 26.04 x64; AppImage still depends on system libraries. Windows previews are intentionally unsigned, so SmartScreen or an unknown-publisher prompt may appear; managed PCs may block installation. Public Mac assets must pass the release's Developer ID signing and notarization checks. See [platform support](platform-support.md) for the limits of each target. -Optional: download [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.5/SHA256SUMS) alongside your file. On Linux, run `sha256sum --ignore-missing -c SHA256SUMS`; on macOS, compare `shasum -a 256 ` with its line; on Windows, use `Get-FileHash -Algorithm SHA256`. Checksums detect corruption and are separate from code signing. +Optional: download [SHA256SUMS](https://github.com/eggp-dev/conn/releases/download/v0.8.6/SHA256SUMS) alongside your file. On Linux, run `sha256sum --ignore-missing -c SHA256SUMS`; on macOS, compare `shasum -a 256 ` with its line; on Windows, use `Get-FileHash -Algorithm SHA256`. Checksums detect corruption and are separate from code signing. ## 2. Set up your terminal diff --git a/docs/platform-support.ko.md b/docs/platform-support.ko.md index 3610d40..6dc8291 100644 --- a/docs/platform-support.ko.md +++ b/docs/platform-support.ko.md @@ -2,7 +2,7 @@ [English](platform-support.md) · 한국어 · [설치](getting-started.ko.md) · [릴리스](releasing.ko.md) -Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.5)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. +Conn은 MIT 라이선스 소스와 네이티브 바이너리를 [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.6)로 제공합니다. 모든 데스크톱 패키지는 같은 Rust 엔진과 Svelte UI를 사용하며 브라우저 하네스는 개발용 어댑터입니다. ## 다음 프리뷰 릴리스 대상 diff --git a/docs/platform-support.md b/docs/platform-support.md index 7cef440..dc25eb9 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -2,7 +2,7 @@ English · [한국어](platform-support.ko.md) · [Install](getting-started.md) · [Release](releasing.md) -Conn provides its MIT-licensed source and native binaries through [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.5). All desktop packages use the same Rust engine and Svelte UI. The browser harness is a development adapter. +Conn provides its MIT-licensed source and native binaries through [GitHub Releases](https://github.com/eggp-dev/conn/releases/tag/v0.8.6). All desktop packages use the same Rust engine and Svelte UI. The browser harness is a development adapter. ## Targets for upcoming preview releases diff --git a/docs/product-backlog.ko.md b/docs/product-backlog.ko.md index b42e305..911a72a 100644 --- a/docs/product-backlog.ko.md +++ b/docs/product-backlog.ko.md @@ -40,6 +40,10 @@ ### UX-01 · 긴 출력의 표시 정확성 +- 2026-09-22: **v0.8.5 이후에도 미해결**. 업데이트·재시작 뒤에도 고정 창에서 알림·승인 바가 + 움직일 때 가로·세로 영역 제한과 입력 위치 깨짐이 보고됐다. 크기 변경·출력 순서 결함을 재현하고 + v0.8.6에서 수정했으나 실제 설치된 네이티브·원격 환경은 미검증이다. + [터미널 크기 후속 조사](ssh-input-review-results.ko.md#v085-이후-터미널-크기-후속-조사)를 참고한다. - 문제: 긴 경로와 OAuth 응답이 줄바꿈 때문에 깨져 보인다는 보고. - 범위: 실제 출력, 터미널 그리드, 렌더링, 에이전트 스냅샷을 비교해 원인을 분리한다. - 완료 기준: 합성 긴 경로·URL·토큰 형태 문자열, 한글/유니코드, 붙여넣기, 창 크기 변경, diff --git a/docs/product-backlog.md b/docs/product-backlog.md index 69b4662..d12ebb5 100644 --- a/docs/product-backlog.md +++ b/docs/product-backlog.md @@ -41,6 +41,10 @@ Do not retain real addresses, accounts, command payloads or authentication respo ### UX-01 · Display fidelity for long output +- 2026-09-22: **still open after v0.8.5**. The user reports constrained width/height and misplaced input + after updating/restarting, even with a fixed window when notification/approval bars animate. Resize + and output ordering defects were reproduced and fixed in v0.8.6; the exact installed native/remote + environment remains unverified. See [dimension follow-up](ssh-input-review-results.md#terminal-dimensions-after-v085). - Problem: long paths and OAuth responses reportedly look broken when wrapped. - Scope: compare actual output, terminal grid, rendering and agent snapshots to locate the defect. - Acceptance: synthetic long paths, URLs, token-shaped strings, Korean/Unicode, paste, resize and diff --git a/docs/ssh-input-review-results.ko.md b/docs/ssh-input-review-results.ko.md index cacb64f..c6534ce 100644 --- a/docs/ssh-input-review-results.ko.md +++ b/docs/ssh-input-review-results.ko.md @@ -2,7 +2,121 @@ [English](ssh-input-review-results.md) · [테스트 재현 방법](browser-testing.md#ssh-input-cursor-and-approval-regression) -2026-09-22 · v0.8.4 (`06143d0`) 기준 v0.8.5 패치. +2026-09-22 · v0.8.5 수정과 v0.8.6 터미널 크기 후속 수정. + +## v0.8.5 이후 터미널 크기 후속 조사 + +사용자는 업데이트·재시작 후에도 문제가 남고, 창 크기를 바꾸지 않은 상태에서 알림·승인 바의 +모션이 나타날 때 가로와 세로가 모두 제한되어 보인다고 설명했다. 창이 고정이어도 승인 바가 +열리고 닫히면 터미널에 남는 높이는 변한다. 이전 설정 모달 수정만으로 이 보고를 닫지 않는다. +사용자의 실제 중간 SSH 접속 구현은 아직 검증하지 않았다. + +### 재현한 결함과 변경 + +- 브라우저 서버는 명령을 직렬 처리하지만 네이티브 Tauri는 별도 작업으로 실행한다. + 첫 크기 변경을 350ms 지연하자 화면이 104열로 돌아온 뒤에도 PTY는 83열로 남았다. + 별도 검사에서는 137열 대 83열도 확인했다. +- 창을 860×640으로 고정하고 승인 바의 높이 축소 전달을 500ms 늦추자, 화면은 38행으로 + 돌아왔지만 PTY는 27행으로 남았고 커서는 37행 대 26행으로 어긋났다(0부터 센 행 번호). + 공통 UI·실제 Rust·SSH 경로에 지연을 주입한 재현이며 네이티브 타이밍을 직접 캡처한 것은 아니다. +- 크기 요청 직렬화만으로는 부족했다. 더 긴 승인 대기 명령을 빠르게 거부하자 두 그리드가 + 모두 38행인데도 사용자 커서는 23행, 코어 커서는 37행인 두 번째 결함을 재현했다. + 셸의 화면 재출력과 렌더러 크기 변경이 서로 다른 순서로 적용됐다. +- 프런트엔드는 터미널마다 크기 요청 하나의 완료를 기다리고 가장 최근 대기 크기를 유지한다. + 백엔드는 출력과 같은 세션 잠금 안에서 적용 크기를 출력 프레임에 담고, 출력이 없는 크기 변경도 + 빈 프레임으로 전달한다. 렌더러는 이 순서대로 크기를 반영하며 이전 xterm 출력의 처리가 끝난 + 뒤 다음 프레임을 적용한다. 화면 영역 측정은 크기를 요청하고, 백엔드보다 먼저 렌더러를 바꾸지 않는다. +- 승인 바의 모션은 유지한다. 새 셸·원격 훅·숨겨진 입력·접근 조건·권한은 추가하지 않는다. + +### 검증과 남은 범위 + +- 최종 수정 후 가로 지연 검사와 고정 창 승인 검사가 통과했다. 그리드·커서 일치와 사용 가능한 + 전체 영역의 크기를 확인했다. 일반 고정 창 검사는 실제 터미널 모션을 기록하고, 지연 검사는 + 늦은 크기 변경이 도착하기 전에 승인 바를 닫는다. +- Linux Chromium·실제 SSH 하네스 전체가 통과했다. 240~7,000자 입력, 유니코드, 설정, + 긴 승인, 세션 허용, 사람의 커서 편집·제어권 회수와 로컬 셸 복귀를 포함한다. + 화면 배율 1·1.25·1.5·2배에서도 영역 채움을 확인했다. 이는 Mac의 실제 다중 모니터 검증이 아니다. +- Linux WebKit 26.6에서 커서를 맨 아래로 내린 뒤 고정 창 승인 열기·거부 18회를 통과했다. + 실제 터미널 모션, 짧은 명령·줄바꿈된 명령, 빠른 닫기를 포함한다. 최종 실행에도 수정 전과 같은 + ResizeObserver 경고가 한 번 있었으나 지속되는 그리드·커서 불일치는 없었다. + 오류 없는 콘솔 실행이나 네이티브 macOS 검증으로 기록하지 않는다. +- 협업 맥락 하네스 12단계도 통과했다. 백그라운드 탭, 준비 요청, 비공유 터미널 생성과 + 공유 전환을 포함한다. +- 기본 Rust 테스트 259개(선택적 SSH 1개 제외), 프런트엔드 55개와 프런트엔드 빌드가 통과했다. + 이 Linux 호스트에는 Pango 개발 패키지가 없어 데스크톱 번들은 빌드하지 못했다. +- 앞선 직접 OpenSSH와 중첩 대화형 SSH 검사는 각각 세 가지 너비에서 입력·승인 18회를 + 통과했다. 사용자의 실제 중간 접속 구현이 올바르다는 근거로 확대하지 않는다. + +크기·출력 순서 수정은 v0.8.6에 포함되며 v0.8.5에는 포함되지 않았다. 사진과 정확히 같은 깨짐은 사용자의 +설치된 macOS 앱·SSH 환경에서 확인해야 한다. 크기·출력 검사가 네이티브 키보드 입력 순서나 +물리 키보드 IME 동작까지 검증하는 것은 아니다. 별도 `/bin/sh` canonical 입력 실험에서 +승인 거절 후 이전 줄바꿈 글자가 남은 항목도 미해결이며 사진의 원인으로 확정하지 않았다. + +### PR #64 네이티브 macOS 검증 + +2026-09-22, 별도 체크아웃의 `codex/terminal-resize-order` HEAD가 +`e97c2048e320d6c09fa1d556bd343c52deab0b58`임을 확인하고 검사했다. +[해당 커밋의 CI](https://github.com/eggp-dev/conn/actions/runs/35705822838)는 모두 성공했다. +Rust는 Linux·macOS·Windows, 데스크톱 CI는 Linux에서 실행됐다. 아래 Mac 앱 검증은 별도 결과다. +[집계 증거](validation/macos-resize-order-results.json)에는 개인 경로나 원시 출력이 없다. + +**환경·실행 확인:** macOS 27.0 (`26A428`), arm64, Node.js 24.13.0, Rust 1.95.0. +루트 `npm ci`, Rust 259개(선택적 SSH 1개 제외), 프런트엔드 55개, 프런트엔드 빌드가 통과했다. +`frontends/tauri`에서 `npm run tauri -- build --debug --bundles app --config <임시 설정>`으로 +ad-hoc 서명 네이티브 번들을 만들었다. 앱 이름은 `Conn Resize Validation`, 식별자는 +`dev.eggp.conn.resizevalidation`이며 `CONN_CONFIG_DIR`와 `CONN_SOCKET`을 분리했다. +실행 파일은 새 체크아웃 아래 +`frontends/tauri/src-tauri/target/debug/bundle/macos/Conn Resize Validation.app/Contents/MacOS/conn-desktop`이고, +PID `34233`이 테스트 소켓을 소유했다. 바이너리 SHA-256은 +`b199fa89ef6bf141b4c30e989d620d1e51fae5312c712b57d4e4ea813621c139`이다. +표시 버전 0.8.5만으로 설치된 앱을 식별하지 않았다. 실제 `tauri://localhost` WebKit과 Tauri IPC를 사용했다. + +**SSH·측정 방법:** 임시 키와 고정 호스트 키를 사용하는 별도 루프백 OpenSSH에 기존 Conn PTY로 +접속했다. 실제 원격 셸은 `/bin/bash --noprofile --norc -i`, Bash `3.2.57(1)-release`, +`TERM=xterm-256color`, `LC_ALL=en_US.UTF-8`였다. 전후 `stty size`는 `41 137`이었다. +이 결과는 Bash의 줄 편집 경로이며 별도 `/bin/sh` canonical 입력 결함의 해결 근거가 아니다. +네이티브 캡처와 접근성 트리를 확인하고, WebKit 검사기에 임시로 연결한 측정기로 실제 표시 DOM의 +전체 행·커서 위치·영역·모션을 로컬에 기록해 에이전트 스냅샷과 대조했다. +빠른 닫기 12회는 카드가 나타난 뒤 80~82ms에 실제 DOM 거부 버튼을 클릭하도록 제한한 임시 UI +검사로 실행했다. 제품 전송 계층·크기 요청·출력에는 지연이나 대체 구현을 넣지 않았고, +측정 중에는 검사기를 닫았다. 두 임시 검사 장치는 실행 후 제거했다. + +| 실기기 항목 | 결과 | +| --- | --- | +| 고정 창 승인 바 | 창 1120×720, WebKit 내용 1120×688을 고정했다. 충분한 출력으로 커서를 맨 아래에 둔 뒤 짧은 명령·478~1,678자 명령을 검사했다. 승인 바 열기·승인·거부·사람의 중단 총 18회에 빠른 거부 12회를 포함했다. | +| 실제 모션과 복귀 | 빠른 반복에서 창 크기는 동일했고 302개 프레임에 실제 터미널 이동이 기록됐다. 137열을 유지하며 승인 카드 높이에 따라 41→30·26·20행으로 줄었다가 복귀했다. 사람에게 제어권을 돌려주는 바가 보일 때는 정상적으로 38행이었다. 최종 터미널은 사용 가능한 영역을 채웠다. | +| 화면·코어 비교 | 승인 전후, 편집, 출력과 셸 복귀의 38개 지점에서 전체 표시 행·커서·행 수가 일치했다. 이 수치는 네이티브 DOM 비교이며 기존 브라우저 하네스나 파서 테스트 수치가 아니다. | +| 사람의 입력 | 1,221자 영문·한글·이모지 명령에서 좌우 이동·중간 삽입·Backspace·Enter를 조작하고 `수정END>` 출력을 확인했다. 첫 방향키가 `human_input`으로 제어권을 회수했고 다음 에이전트 쓰기는 `not_controller`였다. | +| 명령 정책 | 일반 `printf`·`touch`는 자동 실행됐다. **세션 허용**으로 임시 파일 하나를 삭제한 뒤 같은 규칙의 두 번째 파일은 자동 삭제됐다. `recursive delete`·`privilege escalation`은 계속 승인을 요구했다. 금지 규칙은 위험 문자열을 인자로 담은 무해한 `printf`로 확인했다. 실제 삭제는 합성 파일 두 개뿐이다. | +| 승인 중 사람 입력 | Ctrl-C는 권한 상승 요청을 실행하지 않고 `denied (human_input)`으로 정리하며 제어권을 회수했다. 이를 프로토콜의 cancelled 상태로 기록하지 않는다. | +| 셸 유지 | 원격 Bash PID는 전후 `34307`로 같았고 SSH 종료 후 원래 로컬 Bash PID `34253`으로 돌아왔다. 같은 화면 식별자를 유지했다. | +| 개행 구분 | 짧은 JSON과 줄바꿈되는 JSON의 개행 없음·LF·CRLF 6건이 일치했다. 추가로 정확히 411자=137열×3인 출력 3건도 비교했다. 이 경계에서는 개행이 없어도 다음 프롬프트가 자동 줄바꿈된다. 임의 개행은 넣지 않았다. | + +**남은 범위:** 빠른 거부 12회에서 `ResizeObserver loop completed with undelivered notifications` +경고 24건이 기록됐다. 측정 시작 후 전체 42건이며, 검사기에 이전 경고까지 합쳐 46건이 표시됐다. +확인한 모션 종료 지점에서 지속되는 그리드·커서 불일치는 없었지만 경고 자체는 해결하지 않았다. +물리 키보드 한글 IME, 사용자의 실제 원격 계정·중간 접속 구현, 별도 canonical `/bin/sh` 현상은 +미검증 또는 미해결이다. 실행 중인 사용자 Conn/SSH 세션이 없어 실제 계정 인증은 시도하지 않았다. +확인한 입력·크기 경로에서 추가 제품 수정이 필요한 실패는 재현되지 않아 제품 코드는 바꾸지 않았다. +개인 경로·인증 키·원시 소켓/DOM/감사 로그·캡처는 커밋하지 않았다. 서명·공증된 설치 앱의 +업데이트나 공개 배포 검증은 아니다. 이 네이티브 검사 당시 버전은 0.8.5, 변경 내역은 Unreleased였다. + +## 추가 보고: JSON 뒤에 바로 붙는 프롬프트 + +추가 사진에는 JSON의 닫는 괄호 바로 뒤에 색상이 있는 `[DEV]` 프롬프트가 붙어 있다. +출력 끝에 개행이 없을 때와 같은 모양이지만, 해당 명령의 원본 응답 바이트를 확보하지 못했으므로 +사진만으로 원인을 확정하지 않는다. + +현재 공통 UI·Rust·실제 SSH 하네스에서 짧은 JSON과 줄바꿈되는 긴 JSON 각각에 개행 없음·LF·CRLF를 +적용한 6개 비교가 통과했다. 개행이 없으면 프롬프트가 JSON 뒤 같은 줄에 붙고, LF·CRLF가 있으면 +다음 줄에서 시작했다. 6개 모두 사용자 화면의 행·커서와 코어가 일치했다. Conn을 거치지 않는 +일반 Bash와 `/bin/sh` PTY도 세 경우가 같은 동작이었다. 사용자의 실제 응답 서버나 설치된 Mac 앱을 +검증한 것이 아니라 같은 출력 바이트를 구성한 비교다. + +두 동작을 구분하는 회귀 검사를 추가했다. Conn에서 임의로 개행을 끼워 넣으면 실제 셸·TUI의 +커서 동작을 바꾸므로 그렇게 보정하지 않는다. 개행 없는 본문을 반환하는 명령 뒤에 +`; printf '\n'`을 명시하면 다음 프롬프트를 분리할 수 있다. 반대되는 원본 바이트 증거가 없는 +현재로서는 위에서 재현한 크기·출력 순서 결함과 구분해서 기록한다. ## 사용자 보고와 재현한 원인 diff --git a/docs/ssh-input-review-results.md b/docs/ssh-input-review-results.md index 4b076a2..eb74ef0 100644 --- a/docs/ssh-input-review-results.md +++ b/docs/ssh-input-review-results.md @@ -2,7 +2,128 @@ [한국어](ssh-input-review-results.ko.md) · [Reproduce the tests](browser-testing.md#ssh-input-cursor-and-approval-regression) -2026-09-22 · v0.8.5 patch based on v0.8.4 (`06143d0`). +2026-09-22 · v0.8.5 fixes and v0.8.6 terminal-dimensions follow-up. + +## Terminal dimensions after v0.8.5 + +The user confirmed the issue after updating and restarting, then clarified that both axes appear +constrained when notification/approval bars animate, while the application window stays fixed. +A fixed window still changes the terminal's available height as the approval dock opens and closes. +The previous settings-modal fix does not close this report. The actual intermediate SSH gateway +has not been tested. + +### Reproduced defects and changes + +- The browser server serializes commands; native Tauri dispatch uses independent blocking workers. + Delaying the first resize by 350 ms left the PTY at 83 columns after the UI returned to 104. + A separate probe produced 83 versus 137 columns. +- With the window fixed at 860×640, delaying the approval dock's shrink by 500 ms left the PTY at + 27 rows after the UI returned to 38, with cursor rows 26 versus 37 (zero-based). These are controlled + delay injections into the production UI / real Rust / real SSH route, not captures of native timing. +- Serializing resize requests alone was insufficient. A longer pending command and quick denial + reproduced a second ordering defect: both grids were 38 rows, but the visible cursor was at row 23 + while the core cursor was at 37. Shell redraw bytes and renderer resizes were applied in different orders. +- The frontend now permits one resize request per terminal and retains the newest pending dimensions. + The backend includes the applied size in the owner output stream and emits an empty frame on resize, + under the same session lock as output. The renderer applies these sizes in stream order and waits for + earlier xterm writes to finish before applying the next frame. Pane measurement requests a size; + it no longer independently resizes the renderer ahead of the backend. +- Dock motion is retained. No replacement shell, remote hook, hidden input, access condition or + permission is introduced. + +### Verification and remaining scope + +- Both delayed-width and fixed-window approval regressions pass after the final change, including + grid/cursor equality and full available pane dimensions. The normal fixed-window case records + actual terminal motion; the delayed case closes the dock before the late resize arrives. +- The complete Linux Chromium / real SSH harness passes: 240–7,000-character input, Unicode, + settings, long approval, session allowance, human cursor editing/takeover and return to the local shell. + Pixel densities 1, 1.25, 1.5 and 2 preserve pane coverage. Density emulation is not native multi-monitor acceptance. +- Linux WebKit 26.6 passes 18 fixed-window approval open/deny cycles with the cursor initially at the + bottom, including actual terminal animation, short and wrapped commands and rapid dismissal. + The final run recorded one ResizeObserver warning, also seen before the change, without a persistent + grid/cursor mismatch. This is not an error-free-console or native macOS acceptance claim. +- The 12-stage collaboration context harness also passes, including background tabs, preparation, + private-terminal creation and sharing transitions. +- 259 default Rust tests pass (one optional SSH test excluded), as do 55 frontend tests and the frontend + build. The Linux desktop bundle was not built: the host lacks the required Pango development package. +- Earlier direct OpenSSH and nested interactive SSH probes each passed 18 input/approval cases at + three widths. They do not establish correctness of the user's gateway implementation. + +The resize/output ordering fixes are included in v0.8.6; they were not in v0.8.5. The exact photographed corruption still needs +acceptance in the user's installed macOS app and SSH environment. Resize/output tests do not establish +native keyboard-input ordering or physical-keyboard IME behavior. A separate `/bin/sh` canonical-input +experiment left old wrapped text after approval denial; it remains open and is not identified as the photo's cause. + +### Native macOS verification of PR #64 + +On 2026-09-22, a separate checkout of `codex/terminal-resize-order` was verified at +`e97c2048e320d6c09fa1d556bd343c52deab0b58`. +[CI for that commit](https://github.com/eggp-dev/conn/actions/runs/35705822838) passed: Rust on +Linux/macOS/Windows, and desktop CI on Linux. The Mac application checks below are separate evidence. +The [aggregate evidence](validation/macos-resize-order-results.json) contains no personal paths or raw output. + +**Environment and process identity:** macOS 27.0 (`26A428`), arm64, Node.js 24.13.0, Rust 1.95.0. +Root `npm ci`, 259 Rust tests (one optional SSH test excluded), 55 frontend tests and the frontend build passed. +An ad-hoc signed native bundle was built from `frontends/tauri` with +`npm run tauri -- build --debug --bundles app --config `. +The app name was `Conn Resize Validation`, identifier `dev.eggp.conn.resizevalidation`, with separate +`CONN_CONFIG_DIR` and `CONN_SOCKET`. The executable was inside the new checkout at +`frontends/tauri/src-tauri/target/debug/bundle/macos/Conn Resize Validation.app/Contents/MacOS/conn-desktop`; +PID `34233` owned the test socket. Its SHA-256 was +`b199fa89ef6bf141b4c30e989d620d1e51fae5312c712b57d4e4ea813621c139`. +The displayed 0.8.5 version was not used to identify the candidate. Tests used the actual +`tauri://localhost` WebKit application and Tauri IPC. + +**SSH and measurement:** SSH was entered through the existing Conn PTY into a separate loopback OpenSSH +server with temporary keys and a pinned host key. The actual remote shell was +`/bin/bash --noprofile --norc -i`, Bash `3.2.57(1)-release`, with `TERM=xterm-256color` and +`LC_ALL=en_US.UTF-8`. Before/after `stty size` returned `41 137`. This exercises Bash line editing; +it does not resolve the separate canonical `/bin/sh` behavior. +Native captures and accessibility trees were inspected. A temporary WebKit-inspector probe recorded +the actual visible DOM rows, cursor geometry, pane dimensions and motion locally for comparison with +agent snapshots. Twelve rapid denials used a bounded temporary UI probe that clicked the actual DOM +deny button 80–82 ms after each card appeared. No product transport, resize or output implementation +was replaced or delayed. The inspector was closed during measurements, and both probes were removed afterward. + +| Native check | Result | +| --- | --- | +| Fixed-window approval dock | Kept the outer window at 1120×720 and WebKit content at 1120×688. Filled the screen to put the cursor at the bottom, then tested short and 478–1,678-character commands. Eighteen approval cycles included approval, denial, human interruption and twelve rapid denials. | +| Actual motion and restoration | The rapid cycles retained a fixed window and recorded 302 frames of actual terminal movement. Columns stayed at 137; card height reduced 41 rows to 30, 26 or 20 and then restored them. The human hand-back bar correctly reserved space for a 38-row terminal. The final terminal filled its available pane. | +| Visible/core parity | All 38 checkpoints matched full visible rows, cursor and row count across approvals, editing, output and shell return. These are native DOM comparisons, separate from browser-harness or parser-test counts. | +| Human input | Used left/right movement, insertion, Backspace and Enter in a 1,221-character ASCII/Korean/emoji command, verifying `수정END>` output. The first arrow key reclaimed control with `human_input`; the next agent write returned `not_controller`. | +| Command policy | Ordinary `printf` and `touch` executed automatically. **Allow this session** removed one disposable file; the second deletion under the same rule ran automatically. `recursive delete` and `privilege escalation` still required approval. A harmless `printf` containing dangerous text as an argument confirmed the deny rule. Actual deletions affected only two synthetic files. | +| Human input during approval | Ctrl-C resolved the privileged request as `denied (human_input)` without execution and reclaimed control. This is not the protocol's cancelled state. | +| Same shells | Remote Bash PID `34307` remained unchanged; SSH exit returned to the original local Bash PID `34253`. The screen identity also remained unchanged. | +| Output endings | Six short/wrapped JSON comparisons covered no newline, LF and CRLF. Three additional comparisons used an exact 411-character = 137-column × 3 boundary: even without a newline, the prompt then wraps to the next row. No artificial newline was inserted. | + +**Remaining scope:** the twelve rapid denials recorded 24 +`ResizeObserver loop completed with undelivered notifications` warnings. The probe recorded 42 overall; +the inspector displayed 46 including earlier warnings. No persistent grid/cursor disagreement appeared +at the checked post-motion points, but the warning itself remains unresolved. +Physical-keyboard Korean IME, the user's actual remote account/gateway and the separate canonical +`/bin/sh` behavior remain unverified or unresolved. No running user Conn/SSH session was available, +and no real-account authentication was attempted. No additional input/resize failure requiring a product +change was reproduced, so product code was unchanged. Personal paths, keys, raw socket/DOM/audit logs +and captures were not committed. This does not verify signed/notarized installed-app upgrades or a public +release. At the time of this native run, version was 0.8.5 and the change was Unreleased. + +## Additional report: JSON followed immediately by the prompt + +A further photo shows the closing JSON braces immediately followed by a colored `[DEV]` prompt. +This matches output without a trailing newline, but the response bytes from that particular command +have not been captured, so the photograph alone does not establish the cause. + +The current production-UI/Rust/real-SSH harness passed six comparisons: short and wrapped synthetic +JSON, each with no trailing newline, LF or CRLF. Without a newline the prompt followed the JSON on +the same line; with LF or CRLF it began on the next line. Rendered rows and cursor matched the core +in all six cases. Independent ordinary Bash and `/bin/sh` PTYs, without Conn, showed the same three +behaviors. These fixtures reproduce the output bytes, not the user's endpoint or installed Mac app. + +A regression now preserves both cases. Conn must not insert newlines into arbitrary terminal output: +that would alter real shell/TUI cursor behavior. When a command returns a body without a newline, +appending `; printf '\n'` explicitly separates the next prompt. This report is distinct from the +reproduced resize/output ordering defects above unless contrary byte-level evidence appears. ## Report and reproduced causes diff --git a/docs/validation/macos-resize-order-results.json b/docs/validation/macos-resize-order-results.json new file mode 100644 index 0000000..328ad15 --- /dev/null +++ b/docs/validation/macos-resize-order-results.json @@ -0,0 +1,67 @@ +{ + "testedCommit": "e97c2048e320d6c09fa1d556bd343c52deab0b58", + "platform": { + "macOS": "27.0 (26A428)", + "arch": "arm64", + "node": "24.13.0", + "rust": "1.95.0" + }, + "candidate": { + "kind": "ad-hoc signed native debug app", + "version": "0.8.5", + "binarySha256": "b199fa89ef6bf141b4c30e989d620d1e51fae5312c712b57d4e4ea813621c139" + }, + "automatedTests": { + "rustPassed": 259, + "rustIgnored": 1, + "frontendPassed": 55, + "frontendBuild": "PASS", + "nativeBundleBuild": "PASS" + }, + "native": { + "approvalCycles": 18, + "fastDenyCycles": 12, + "fastDenyDelayMs": [ + 82, + 81, + 81, + 81, + 82, + 80, + 81, + 80, + 81, + 80, + 82, + 81 + ], + "fastDenyWindowSizes": [ + [ + 1120, + 688 + ] + ], + "movingFramesDuringFastCycles": 302, + "gridComparisons": 38, + "allGridsAndCursorsMatch": true, + "remoteShell": "Bash 3.2.57(1)-release", + "remoteTerm": "xterm-256color", + "remoteSttyBeforeAfter": { + "rows": 41, + "cols": 137 + }, + "sameRemotePid": true, + "sameLocalPidAfterSSH": true, + "jsonEndingComparisons": 6, + "exactWidthBoundaryComparisons": 3, + "resizeObserverWarningsDuringFastCycles": 24, + "resizeObserverWarningsCapturedAfterProbeStart": 42 + }, + "limitations": [ + "user actual remote environment unverified", + "physical keyboard IME unverified", + "separate canonical /bin/sh wrapped-text behavior not resolved", + "signed installed-app upgrade and public release not performed" + ], + "instrumentation": "Temporary native WebKit visible-DOM readout and bounded DOM deny-button clicks; no product transport overrides; removed after run." +} diff --git a/frontends/tauri/package.json b/frontends/tauri/package.json index 46e2fbd..3419897 100644 --- a/frontends/tauri/package.json +++ b/frontends/tauri/package.json @@ -1,14 +1,14 @@ { "name": "conn-desktop", "private": true, - "version": "0.8.5", + "version": "0.8.6", "type": "module", "scripts": { "dev": "vite", "build": "svelte-check --threshold error && vite build", "tauri": "node scripts/tauri.mjs", "test:browser": "node scripts/browser-test.mjs", - "test": "node tests/timeline/events.test.ts && node tests/icon/state.test.ts && node tests/connections/status.test.ts && node tests/terminal/input.test.ts && node tests/collaboration/state.test.ts", + "test": "node tests/timeline/events.test.ts && node tests/icon/state.test.ts && node tests/connections/status.test.ts && node tests/terminal/input.test.ts && node tests/terminal/resize.test.ts && node tests/terminal/output.test.ts && node tests/collaboration/state.test.ts", "test:collaboration": "node scripts/collaboration-test.mjs", "test:collaboration:harness": "node scripts/collaboration-harness-test.mjs", "test:context:harness": "node scripts/context-harness-test.mjs", diff --git a/frontends/tauri/scripts/input-review-harness-test.mjs b/frontends/tauri/scripts/input-review-harness-test.mjs index 0f39e59..02b0d60 100644 --- a/frontends/tauri/scripts/input-review-harness-test.mjs +++ b/frontends/tauri/scripts/input-review-harness-test.mjs @@ -30,6 +30,26 @@ async function until(test,what){for(let i=0;i<100;i++){if(await test())return;aw async function allow(agent){await page.locator('.decision-card .primary').click();await until(async()=>(await state()).activity.connections.some(a=>a.connId===agent.hello.conn),'admission resolved');} // fill() cannot deliver terminal input; keyboard insertion emits the real onData path. async function typeHuman(text){const input=page.locator('.xterm-helper-textarea:visible');await input.click();await page.keyboard.insertText(text);await input.press('Enter');await delay(300);} +// Native IPC uses independent workers; the browser server serializes commands. +// A delayed transport request exposes an old size overtaking a newer one. +async function delayFirstResize(milliseconds){ + await page.evaluate(milliseconds=>{ + const send=WebSocket.prototype.send;let delayNextResize=true; + window.__requestedTerminalSizes=[]; + window.__restoreResizeTransport=()=>{WebSocket.prototype.send=send;}; + WebSocket.prototype.send=function(data){ + const message=JSON.parse(data); + if(message.name==='resize'){ + window.__requestedTerminalSizes.push({rows:message.args.rows,cols:message.args.cols}); + if(delayNextResize){delayNextResize=false;setTimeout(()=>send.call(this,data),milliseconds);return;} + } + return send.call(this,data); + }; + },milliseconds); +} +async function restoreResizeTransport(){ + await page.evaluate(()=>{window.__restoreResizeTransport();delete window.__restoreResizeTransport;delete window.__requestedTerminalSizes;}); +} async function grid(agent,label){ await delay(700); const snapshot=await agent.call('snapshot'); @@ -43,8 +63,8 @@ async function grid(agent,label){ return {row:Array.from(row.parentElement.children).indexOf(row),col:Math.round((cell.getBoundingClientRect().left-screen.getBoundingClientRect().left)/(screen.clientWidth/cols))}; },snapshot.size.cols); const result={label,size:snapshot.size,core:snapshot.screen,visible:rows,cursor:snapshot.cursor,visibleCursor:cursor,equal:JSON.stringify(snapshot.screen)===JSON.stringify(rows)}; - assert.deepEqual(cursor,snapshot.cursor,`${label}: user and agent cursor positions agree`); - writeFileSync(join(output,`grid-${label}.json`),JSON.stringify(result,null,2));return result; + writeFileSync(join(output,`grid-${label}.json`),JSON.stringify(result,null,2)); + assert.deepEqual(cursor,snapshot.cursor,`${label}: user and agent cursor positions agree`);return result; } try{ for(let i=0;!existsSync(join(dir,'connection.json'));i++){if(i>100||server.exitCode!==null)throw Error('Rust startup failed');await delay(100)} @@ -66,6 +86,68 @@ try{ const agent=await connect('input-repro');await allow(agent); const control=agent.call('request_control',{reason:'Reproduce long command rendering.'});control.catch(()=>{}); await page.locator('.decision-card .primary').click();await control;await delay(500); + await delayFirstResize(350); + try { + await page.setViewportSize({width:700,height:640});await delay(80); + await page.setViewportSize({width:860,height:640});await delay(700); + const requested=await page.evaluate(()=>window.__requestedTerminalSizes); + assert.ok(requested.length>=2 && requested[0].cols{ + const host=document.querySelector('.host:not([hidden])'); + const screen=host.querySelector('.xterm-screen'); + return {hostWidth:host.clientWidth,screenWidth:screen.clientWidth,dpr:devicePixelRatio}; + }); + assert.ok(geometry.hostWidth-geometry.screenWidth>=0 && geometry.hostWidth-geometry.screenWidth<=32,`terminal fills pane after density change: ${JSON.stringify(geometry)}`); + assert.ok((await grid(agent,`density-${deviceScaleFactor}`)).equal); + } + await metrics.detach(); + // A fixed window still resizes its PTY when the approval dock opens/closes. + // Fill the screen so the cursor moves and the terminal's actual motion runs. + await agent.call('type',{text:"printf 'synthetic row\\n%.0s' {1..70}"}); + assert.equal((await agent.call('send_key',{key:'ENTER',intent:'Fill the fixture screen to exercise dock motion.'})).status,'executed'); + const beforeDock=await grid(agent,'before-dock-motion');assert.ok(beforeDock.equal); + for(const latency of [0,500]){ + await delayFirstResize(latency); + await page.evaluate(()=>{ + window.__dockFrames=[]; + const sample=()=>{ + const host=document.querySelector('.host:not([hidden])'); + window.__dockFrames.push({width:innerWidth,height:innerHeight,hostHeight:host.clientHeight,screenHeight:host.querySelector('.xterm-screen').clientHeight,transform:getComputedStyle(host).transform}); + window.__dockFrame=requestAnimationFrame(sample); + };sample(); + }); + try { + await agent.call('type',{text:`rm -- '${join(dir,'missing-dock-'+('x'.repeat(120)))}'`}); + const review=await agent.call('send_key',{key:'ENTER',intent:'Exercise approval motion without executing the fixture command.'}); + assert.equal(review.status,'pending'); + const deny=page.locator('.decision-card .btn.danger');await deny.waitFor();await delay(latency?10:90); + await deny.click({force:true}); + await until(async()=>(await agent.call('check_approval',{approvalId:review.approvalId})).state==='denied','dock review denied'); + const afterDock=await grid(agent,`after-dock-motion-${latency}`); + const motion=await page.evaluate(()=>({frames:window.__dockFrames,sizes:window.__requestedTerminalSizes})); + writeFileSync(join(output,`dock-motion-${latency}.json`),JSON.stringify(motion,null,2)); + assert.ok(motion.frames.every(f=>f.width===860&&f.height===640),'window stays fixed throughout dock motion'); + if(!latency)assert.ok(motion.frames.some(f=>f.transform!=='none'&&f.transform!=='matrix(1, 0, 0, 1, 0, 0)'),'exercise actual terminal motion'); + assert.ok(motion.sizes.length>=2&&motion.sizes[0].rowss.cols===beforeDock.size.cols),'dock motion leaves terminal width unchanged'); + assert.deepEqual(afterDock.size,motion.sizes.at(-1),'late dock shrink must not replace the restored terminal size'); + const finalFrame=motion.frames.at(-1); + assert.ok(finalFrame.hostHeight-finalFrame.screenHeight>=0&&finalFrame.hostHeight-finalFrame.screenHeight<32,'restored terminal fills the available height'); + assert.ok(afterDock.equal);await capture(`fixed-window-dock-motion-${latency}`); + } finally { + await page.evaluate(()=>{cancelAnimationFrame(window.__dockFrame);delete window.__dockFrame;delete window.__dockFrames;}); + await restoreResizeTransport(); + } + } for(const length of [240,700,1500,3500,7000]){ const text='abcdefghijklmnopqrst'.repeat(Math.ceil(length/20)).slice(0,length); await agent.call('type',{text:`printf '%s\\n' '${text}'`}); @@ -74,6 +156,26 @@ try{ assert.equal(execution.status,'executed','ordinary SSH command executes in Autopilot'); await delay(500);assert.ok((await grid(agent,`printed-${length}`)).equal);await capture(`output-${length}`); } + // A response without a trailing newline legitimately shares its last line + // with a Bash prompt. Preserve that distinction; never invent a newline. + const prompt=process.env.CONN_AUTH_FIXTURE_RUNTIME?'remote$':'fixture$'; + const cols=(await agent.call('snapshot')).size.cols; + for(const padding of [0,cols*2]){ + const body=JSON.stringify({padding:'x'.repeat(padding),agentMode:{state:'unavailable'}}); + for(const [ending,format] of [['none','%s'],['lf','%s\\n'],['crlf','%s\\r\\n']]){ + await agent.call('type',{text:`printf '${format}' '${body}'`}); + assert.equal((await agent.call('send_key',{key:'ENTER',intent:'Compare synthetic JSON output with and without a trailing newline.'})).status,'executed'); + const label=`line-ending-${ending}-${padding?'wrapped':'short'}`; + const result=await grid(agent,label);assert.ok(result.equal); + const last=result.visible[result.cursor.row]; + if(ending==='none')assert.ok(last.endsWith(`}}${prompt}`),'no newline: prompt follows the final JSON bytes'); + else { + assert.equal(last,prompt,'newline: prompt starts on a separate line'); + assert.ok(result.visible[result.cursor.row-1].endsWith('}}'),'newline preserves the preceding JSON ending'); + } + await capture(label); + } + } // The modal settings sheet must leave the underlying PTY dimensions alone. for(const text of ['long-input/'.repeat(100), '한글/경로/🙂/'.repeat(65)]){ await agent.call('type',{text:`printf '%s\\n' '${text}'`}); diff --git a/frontends/tauri/src-tauri/tauri.conf.json b/frontends/tauri/src-tauri/tauri.conf.json index 90788b3..8e59f72 100644 --- a/frontends/tauri/src-tauri/tauri.conf.json +++ b/frontends/tauri/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Conn", - "version": "0.8.5", + "version": "0.8.6", "identifier": "dev.eggp.conn", "build": { "beforeDevCommand": "npm run dev", diff --git a/frontends/tauri/src/components/Term.svelte b/frontends/tauri/src/components/Term.svelte index 6992207..8a32117 100644 --- a/frontends/tauri/src/components/Term.svelte +++ b/frontends/tauri/src/components/Term.svelte @@ -7,6 +7,8 @@ import { FitAddon } from "@xterm/addon-fit"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { observeTerminalInput } from "../lib/terminalInput"; + import { terminalResizeQueue } from "../lib/terminalResize"; + import { terminalOutputQueue } from "../lib/terminalOutput"; import { cmd, onOutput, b64ToBytes } from "../lib/bridge"; import { st, tab } from "../lib/store.svelte"; import { THEMES, applyTheme, agentColor } from "../lib/themes"; @@ -15,11 +17,12 @@ let host: HTMLDivElement; let term: Terminal; let fit: FitAddon; + let requestFit = () => {}; const t = $derived(tab(session)!); const active = $derived(st.active === session); export function focus() { term?.focus(); } export function size() { return { rows: term?.rows ?? 24, cols: term?.cols ?? 80 }; } - export function refit() { fit?.fit(); } + export function refit() { requestFit(); } /** Centre of the cursor cell in the app frame. Measured on demand: only the handoff wash needs it. */ export function cursorCenter() { @@ -42,7 +45,7 @@ term.options.theme = xt; term.options.cursorStyle = agent ? "underline" : "block"; term.options.fontSize = st.fontSize; - if (active) fit.fit(); + if (active) requestFit(); } onMount(() => { @@ -55,9 +58,16 @@ fit = new FitAddon(); term.loadAddon(fit); term.open(host); - // Register before the first fit: the PTY starts at 80x24, while xterm - // immediately adopts the pane size. Missing this event desynchronizes wrapping. - term.onResize(({ rows, cols }) => { cmd("resize", { session, rows, cols }); }); + // Request the pane size, but commit it to xterm through the backend output + // stream so shell redraws and size changes reach both parsers in one order. + const sizes = terminalResizeQueue( + ({ rows, cols }) => cmd("resize", { session, rows, cols }), + (error) => console.error("Terminal resize failed", error), + ); + requestFit = () => { + const size = fit.proposeDimensions(); + if (size && Number.isFinite(size.rows) && Number.isFinite(size.cols)) sizes.resize(size); + }; applyLook(); term.attachCustomKeyEventHandler((e) => { if (e.type !== "keydown") return true; @@ -100,16 +110,12 @@ }); let mounted = true; let previousBottom = parseFloat(getComputedStyle(host).bottom); - let previousHeight = host.clientHeight; let motion: Animation | undefined; - const ro = new ResizeObserver(() => { + const output = terminalOutputQueue(({ rows, cols }) => { + if (rows === term.rows && cols === term.cols) return; const bottom = parseFloat(getComputedStyle(host).bottom); - const height = host.clientHeight; const dockChanged = bottom !== previousBottom; - const delta = previousHeight - height; previousBottom = bottom; - previousHeight = height; - if (!active) return; // Commit the final terminal grid once. Animate its visual offset, not // its height: otherwise every frame resizes the PTY and redraws the shell. const transform = getComputedStyle(host).transform; @@ -118,29 +124,30 @@ const rowHeight = host.querySelector(".xterm-screen")!.clientHeight / term.rows; const before = term.buffer.active; const cursorBefore = before.baseY + before.cursorY - before.viewportY; - fit.fit(); + term.resize(cols, rows); const after = term.buffer.active; const cursorDelta = (cursorBefore - (after.baseY + after.cursorY - after.viewportY)) * rowHeight; - if (dockChanged && delta && cursorDelta && !reducedMotion()) { + if (active && dockChanged && cursorDelta && !reducedMotion()) { host.style.willChange = "transform"; motion = host.animate(dockOffsetFrames(cursorDelta + offset), { duration: DOCK_MOTION_MS, easing: "linear", }); motion.onfinish = () => { host.style.willChange = ""; }; } else { host.style.willChange = ""; } - }); + }, (data, done) => term.write(data, done)); + const ro = new ResizeObserver(() => { if (active) requestFit(); }); ro.observe(host); const un = onOutput((p) => { if (!mounted || p.session !== session) return; - term.write(b64ToBytes(p.data)); + output.push({ size: p.size, data: b64ToBytes(p.data) }); }); un.then(() => { if (mounted) return cmd("attach_output", { session }); }).catch(() => {}); - return () => { mounted = false; inputListener.dispose(); privateClipboard.dispose(); motion?.cancel(); ro.disconnect(); un.then((f) => f()); term.dispose(); }; + return () => { mounted = false; sizes.dispose(); output.dispose(); requestFit = () => {}; inputListener.dispose(); privateClipboard.dispose(); motion?.cancel(); ro.disconnect(); un.then((f) => f()); term.dispose(); }; }); $effect(() => { void st.theme; void st.themeRevision; void t?.controller.type; void t?.controller.agentId; void st.fontSize; void active; - if (term) { applyLook(); if (active) setTimeout(() => fit.fit(), 0); } + if (term) { applyLook(); if (active) setTimeout(() => requestFit(), 0); } }); diff --git a/frontends/tauri/src/lib/bridge.ts b/frontends/tauri/src/lib/bridge.ts index 8575699..946a619 100644 --- a/frontends/tauri/src/lib/bridge.ts +++ b/frontends/tauri/src/lib/bridge.ts @@ -32,5 +32,6 @@ export const log = (msg: string) => invoke("log", { msg }).catch(() => {}); export const onEvent = (h: (ev: Ev) => void) => listen("ss:event", (e) => h(e.payload)); export const onAdmission = (h: (p: AdmissionChange) => void) => listen("ss:admission", (e) => h(e.payload)); export const onTabOpened = (h: (p: { session: string; agentId: string; reason?: string | null; focus?: boolean }) => void) => listen<{ session: string; agentId: string; reason?: string | null; focus?: boolean }>("ss:tab_opened", (e) => h(e.payload)); -export const onOutput = (h: (p: { session: string; data: string; outputSeq: number; generation: number }) => void) => listen<{ session: string; data: string; outputSeq: number; generation: number }>("ss:output", (e) => h(e.payload)); +type OutputFrame = { session: string; data: string; outputSeq: number; generation: number; size: { rows: number; cols: number } }; +export const onOutput = (h: (p: OutputFrame) => void) => listen("ss:output", (e) => h(e.payload)); export const b64ToBytes = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); diff --git a/frontends/tauri/src/lib/terminalOutput.ts b/frontends/tauri/src/lib/terminalOutput.ts new file mode 100644 index 0000000..7020b41 --- /dev/null +++ b/frontends/tauri/src/lib/terminalOutput.ts @@ -0,0 +1,24 @@ +type Size = { rows: number; cols: number }; +type Frame = { size: Size; data: Uint8Array }; + +/** xterm parses writes asynchronously. Apply the next frame's size only after + * earlier bytes have been parsed, preserving the backend's resize/output order. + */ +export function terminalOutputQueue(resize: (size: Size) => void, write: (data: Uint8Array, done: () => void) => void) { + const pending: Frame[] = []; + let writing = false; + let disposed = false; + function drain() { + while (!disposed && !writing && pending.length) { + const frame = pending.shift()!; + resize(frame.size); + if (!frame.data.length) continue; + writing = true; + write(frame.data, () => { writing = false; drain(); }); + } + } + return { + push(frame: Frame) { if (!disposed) { pending.push(frame); drain(); } }, + dispose() { disposed = true; pending.length = 0; }, + }; +} diff --git a/frontends/tauri/src/lib/terminalResize.ts b/frontends/tauri/src/lib/terminalResize.ts new file mode 100644 index 0000000..8075a67 --- /dev/null +++ b/frontends/tauri/src/lib/terminalResize.ts @@ -0,0 +1,35 @@ +type Size = { rows: number; cols: number }; + +/** One resize in flight per terminal, retaining the newest pending dimensions. + * Native IPC workers can finish in a different order from invocation. Sending + * the next size only after acknowledgement keeps an older size from winning. + * Intermediate window/dock sizes do not need their own PTY redraw. + */ +export function terminalResizeQueue(send: (size: Size) => Promise, onError: (error: unknown) => void) { + let pending: Size | undefined; + let applied: Size | undefined; + let running = false; + let disposed = false; + + async function drain() { + running = true; + try { + while (!disposed && pending) { + const size = pending; + pending = undefined; + if (applied?.rows === size.rows && applied.cols === size.cols) continue; + try { await send(size); applied = size; } + catch (error) { if (!disposed) onError(error); } + } + } finally { running = false; } + } + + return { + resize(size: Size) { + if (disposed) return; + pending = { ...size }; + if (!running) void drain(); + }, + dispose() { disposed = true; pending = undefined; }, + }; +} diff --git a/frontends/tauri/tests/browser/transport.ts b/frontends/tauri/tests/browser/transport.ts index 9653789..dfb2848 100644 --- a/frontends/tauri/tests/browser/transport.ts +++ b/frontends/tauri/tests/browser/transport.ts @@ -1,6 +1,6 @@ type Handler = (event: {payload:any})=>void; const listeners = new Map>(); -type OutputPacket = {session:string;data:string;outputSeq:number;generation:number}; +type OutputPacket = {session:string;data:string;outputSeq:number;generation:number;size:{rows:number;cols:number}}; const history = new Map(); const historyBytes = new Map(); const pending = new Mapvoid;reject:(e:Error)=>void;timer:ReturnType}>(); diff --git a/frontends/tauri/tests/terminal/output.test.ts b/frontends/tauri/tests/terminal/output.test.ts new file mode 100644 index 0000000..ddaf64b --- /dev/null +++ b/frontends/tauri/tests/terminal/output.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { terminalOutputQueue } from '../../src/lib/terminalOutput.ts'; + +test('resize markers wait for old bytes and precede the new shell redraw', () => { + const events: string[] = []; + const completions: (() => void)[] = []; + const queue = terminalOutputQueue( + size => events.push(`size ${size.rows}x${size.cols}`), + (data, done) => { events.push(new TextDecoder().decode(data)); completions.push(done); }, + ); + const frame = (rows: number, text = '') => ({size:{rows,cols:104}, data:new TextEncoder().encode(text)}); + queue.push(frame(38, 'old output')); + queue.push(frame(27)); + queue.push(frame(27, 'small redraw')); + queue.push(frame(38)); + queue.push(frame(38, 'restored redraw')); + assert.deepEqual(events, ['size 38x104', 'old output']); + completions.shift()!(); + assert.deepEqual(events.slice(2), ['size 27x104', 'size 27x104', 'small redraw']); + completions.shift()!(); + assert.deepEqual(events.slice(5), ['size 38x104', 'size 38x104', 'restored redraw']); + completions.shift()!();queue.dispose(); +}); + +test('disposing a renderer discards frames waiting for an asynchronous write', () => { + const applied: number[] = [];let complete!: () => void; + const queue=terminalOutputQueue(size=>applied.push(size.rows),(_data,done)=>{complete=done;}); + queue.push({size:{rows:38,cols:104},data:new Uint8Array([65])}); + queue.push({size:{rows:27,cols:104},data:new Uint8Array()}); + queue.dispose();complete(); + queue.push({size:{rows:20,cols:80},data:new Uint8Array()}); + assert.deepEqual(applied,[38]); +}); diff --git a/frontends/tauri/tests/terminal/resize.test.ts b/frontends/tauri/tests/terminal/resize.test.ts new file mode 100644 index 0000000..45bd9b4 --- /dev/null +++ b/frontends/tauri/tests/terminal/resize.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { terminalResizeQueue } from '../../src/lib/terminalResize.ts'; + +const turn = () => new Promise(resolve => setImmediate(resolve)); +const deferred = () => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; + +test('a slow old resize cannot overtake the latest terminal size', async () => { + const first = deferred(); + const calls: {rows:number;cols:number}[] = []; + let pty = { rows:24, cols:80 }; + const queue = terminalResizeQueue(async size => { + calls.push(size); + if (calls.length === 1) await first.promise; + pty = size; + }, error => { throw error; }); + queue.resize({ rows:30, cols:83 }); + queue.resize({ rows:29, cols:100 }); + queue.resize({ rows:28, cols:137 }); + await turn(); + assert.deepEqual(calls, [{ rows:30, cols:83 }]); + first.resolve(); await turn(); + assert.deepEqual(calls, [{ rows:30, cols:83 }, { rows:28, cols:137 }]); + assert.deepEqual(pty, { rows:28, cols:137 }); + queue.dispose(); +}); + +test('returning to the acknowledged size does not signal a redundant resize', async () => { + const first = deferred();let calls=0; + const queue = terminalResizeQueue(async () => { calls++;await first.promise; }, error => { throw error; }); + queue.resize({ rows:24, cols:80 }); + queue.resize({ rows:24, cols:100 }); + queue.resize({ rows:24, cols:80 }); + first.resolve();await turn(); + assert.equal(calls,1);queue.dispose(); +}); + +test('a rejected resize does not strand the newest size or count as applied', async () => { + const first=deferred();const errors:unknown[]=[];let calls=0; + const queue=terminalResizeQueue(async () => {if(++calls===1)await first.promise;}, error=>errors.push(error)); + const size={rows:30,cols:104};queue.resize(size);queue.resize(size); + const error=new Error('synthetic transport failure');first.reject(error);await turn(); + assert.deepEqual(errors,[error]);assert.equal(calls,2);queue.dispose(); +}); + +test('closing a terminal discards pending sizes and late errors', async () => { + const first=deferred();let calls=0;const errors:unknown[]=[]; + const queue=terminalResizeQueue(async () => {calls++;await first.promise;}, error=>errors.push(error)); + queue.resize({rows:30,cols:100});queue.resize({rows:20,cols:90});queue.dispose(); + first.reject(new Error('closed'));await turn();queue.resize({rows:40,cols:120}); + assert.equal(calls,1);assert.deepEqual(errors,[]); +}); + +test('one terminal never blocks another terminal resize', async () => { + const first=deferred();let secondApplied=false; + const one=terminalResizeQueue(()=>first.promise,error=>{throw error;}); + const two=terminalResizeQueue(async()=>{secondApplied=true;},error=>{throw error;}); + one.resize({rows:24,cols:80});two.resize({rows:30,cols:100});await turn(); + assert.equal(secondApplied,true);one.dispose();two.dispose();first.resolve(); +}); diff --git a/package-lock.json b/package-lock.json index 290cdb1..030fa4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "frontends/tauri": { "name": "conn-desktop", - "version": "0.8.5", + "version": "0.8.6", "dependencies": { "@conn/brand": "*", "@conn/themes": "*", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 55c7c2c..a2a99dc 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "conn", "description": "Conn — one shell, one hand on it. Registers the conn MCP server and a skill that teaches the agent how to work in a shell a human is watching.", - "version": "0.8.5", + "version": "0.8.6", "author": { "name": "eggp" }, diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index 0c2a6f3..ed4607e 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "conn", - "version": "0.8.5", + "version": "0.8.6", "description": "Conn — one shell, one hand on it. Registers the conn MCP server and a skill that teaches the agent how to work in a shell a human is watching.", "author": { "name": "eggp"