From 3b1c847e76f49caaf17df8a2d6fb8f42e53544ee Mon Sep 17 00:00:00 2001
From: lianqq <2088486180@qq.com>
Date: Sun, 15 Mar 2026 19:26:12 +0800
Subject: [PATCH 01/33] docs: add Simplified Chinese README translation
---
README.md | 4 +-
README.zh-CN.md | 130 ++++++++++++++++++++++++++++++++++++++++++++++++
README.zh-TW.md | 3 +-
3 files changed, 135 insertions(+), 2 deletions(-)
create mode 100644 README.zh-CN.md
diff --git a/README.md b/README.md
index 549532e9..37ab6720 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,9 @@
Website ·
Download ·
Report a Bug ·
- 繁體中文
+ 繁體中文 ·
+ 简体中文
+
---
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 00000000..73e90bfb
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,130 @@
+
+
+
+
+Codirigent
+
+
+ 一个可并行运行多个 AI Coding CLI 的终端工作空间
+
+
+
+
+
+
+
+
+
+
+ 官网 ·
+ 下载 ·
+ 报告问题 ·
+ English ·
+ 繁體中文
+
+
+---
+
+https://github.com/user-attachments/assets/51b821fd-dfc3-40f0-b1f3-e8727045f474
+
+---
+
+如果你同时在多个项目中运行 Claude Code、Codex 或 Gemini,你一定知道那种痛苦:打开终端、在仓库之间 `cd` 来回切换、摆放窗口,还要记住哪个 agent 正在做什么。
+
+Codirigent 是一个为这种工作流打造的 Tmux 风格工作空间。只要打开一次,你的会话就会保持在上次离开时的状态,目录正确、布局正确、agent 也正确。
+
+## 功能特性
+
+**多个会话,一个视图** — 并排运行 Claude Code、Codex 和 Gemini。每个会话都会显示实时状态指示器:
+
+| 状态 | | 含义 |
+|--------|---|---------|
+| 空闲 |  | Shell 空闲,没有 agent 活动 |
+| 工作中 |  | Agent 正在生成回复 |
+| 需要关注 |  | Agent 正在等待用户输入或授权 |
+| 就绪 |  | Agent 已完成,回复正在未聚焦的会话中等待 |
+
+---
+
+**自定义布局** — 以任意网格方式排列会话并保存。拖放会话标题即可随时重新安排位置。
+
+---
+
+**同步文件树** — 文件浏览器始终反映当前聚焦的会话,因此你总能知道自己当前位于哪里。
+
+---
+
+**Git worktree 支持** — 在隔离分支上同时运行多个 agent,互不冲突。
+
+---
+
+**会话恢复** — Codirigent 会自动检测并恢复之前的 Claude Code 和 Codex 会话,让你可以从上次中断的地方继续。
+
+---
+
+**智能剪贴板** — 可将文本、文件或图片粘贴到任意会话中。文件路径会自动转换为目标 CLI 可直接使用的 shell 友好格式。
+
+## 下载
+
+> **早期 Alpha 版本** — 预计还会有不少粗糙之处。[欢迎反馈。](https://github.com/oso95/Codirigent/issues)
+
+### Windows
+
+从[最新版本](https://github.com/oso95/Codirigent/releases/latest)下载 `.msi` 安装程序。
+
+> **SmartScreen 警告:** 由于应用尚未进行代码签名,Windows 可能会显示“Windows 已保护你的电脑”。点击 **更多信息 → 仍要运行** 继续。
+
+### macOS
+
+从[最新版本](https://github.com/oso95/Codirigent/releases/latest)下载 `.dmg`。
+
+## Hook 设置(推荐)
+
+Codirigent 使用轻量级 hook 来实时跟踪 agent 状态,显示每个会话当前是 Working、Needs Attention 还是 Response Ready。如果 hook 不可用,Codirigent 会回退到 reader/detector 路径,但精度会稍差一些。
+
+**支持的 CLI 会在首次启动时自动安装 Hooks。** Codirigent 会将它的 `codirigent-hook` 二进制注册到各个 CLI 的配置中:
+
+| CLI | 配置文件 | 自动安装 |
+|-----|-------------|----------------|
+| Claude Code | `~/.claude/settings.json` | 是 |
+| Codex CLI | `~/.codex/config.toml` | 是 |
+| Gemini CLI | `~/.gemini/settings.json` | 是 |
+
+如需确认 hook 已安装,请检查你的 CLI 配置文件中是否出现了 `codirigent-hook`。如果你移动或重新安装了 Codirigent,只需重新启动一次,即可用更新后的二进制路径重新注册 hooks。
+
+## 从源码构建
+
+**前置要求:** Rust 1.75+、Windows 或 macOS
+
+```bash
+git clone https://github.com/oso95/Codirigent.git
+cd Codirigent
+cargo install --path . --all-features
+cargo install --path crates/codirigent-hook
+```
+
+这会将 `codirigent` 和 `codirigent-hook` 一起安装到 `~/.cargo/bin/`。hook 二进制是实时跟踪 agent 状态所必需的组件(见[Hook 设置](#hook-设置推荐))。
+
+如需在不安装的情况下运行:
+
+```bash
+cargo run --all-features
+```
+
+> Linux 支持尚未完成。
+
+## 开发
+
+```bash
+cargo test --all --all-targets # 运行测试
+cargo fmt --all # 格式化
+cargo clippy --all -- -D warnings # lint 检查
+```
+
+## 贡献
+
+进行重大更改前请先开 issue 讨论。欢迎提交 PR。
+
+## 许可证
+
+GPL-3.0 — 详见 [LICENSE](LICENSE)。
diff --git a/README.zh-TW.md b/README.zh-TW.md
index dca4bfda..e2683f55 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -19,7 +19,8 @@
官方網站 ·
下載 ·
回報問題 ·
- English
+ English ·
+ 简体中文
---
From 0d95c08faa4a5a90dbf1aa860a2c96e41aa82c80 Mon Sep 17 00:00:00 2001
From: oso95
Date: Sat, 21 Mar 2026 01:50:03 -0500
Subject: [PATCH 02/33] Fix flaky test_on_exit_called_on_eof race condition in
CI
---
crates/codirigent-session/src/pty.rs | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/crates/codirigent-session/src/pty.rs b/crates/codirigent-session/src/pty.rs
index 45ec93b1..f47db97f 100644
--- a/crates/codirigent-session/src/pty.rs
+++ b/crates/codirigent-session/src/pty.rs
@@ -1033,13 +1033,17 @@ mod tests {
.expect("timeout")
.expect("channel closed");
assert_eq!(&received, data);
- assert!(chunk_called.load(Ordering::SeqCst), "on_chunk should fire");
// Wait for channel to close (reader thread hit EOF and exited)
while rx.recv().await.is_some() {}
// Give the thread a moment to run on_exit after the loop
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+ // Check both flags after the thread has fully exited, since on_chunk
+ // is called *after* the channel send and may not have run yet when
+ // rx.recv() returns.
+ assert!(chunk_called.load(Ordering::SeqCst), "on_chunk should fire");
assert!(
exit_called.load(Ordering::SeqCst),
"on_exit should fire on EOF"
From 7d34182876d886a18da84c3cf69ac090a1759f42 Mon Sep 17 00:00:00 2001
From: oso95
Date: Sat, 21 Mar 2026 12:37:54 -0500
Subject: [PATCH 03/33] Add shutdown guard to prevent system logout/shutdown
while sessions are active
On macOS, adds applicationShouldTerminate: to GPUI's delegate via ObjC
runtime, returning NSTerminateCancel when sessions exist (triggers the
system "failed to quit" dialog). On Windows, subclasses the GPUI window
to intercept WM_QUERYENDSESSION with ShutdownBlockReasonCreate.
User-initiated quit (Cmd+Q / Ctrl+Q) always proceeds immediately.
---
crates/codirigent-ui/src/app.rs | 35 ++++++-
crates/codirigent-ui/src/platform/mod.rs | 9 ++
.../src/platform/shutdown_guard.rs | 88 +++++++++++++++++
.../src/platform/shutdown_guard_macos.rs | 98 +++++++++++++++++++
.../src/platform/shutdown_guard_windows.rs | 84 ++++++++++++++++
.../src/workspace/gpui/derived_state.rs | 5 +
6 files changed, 317 insertions(+), 2 deletions(-)
create mode 100644 crates/codirigent-ui/src/platform/shutdown_guard.rs
create mode 100644 crates/codirigent-ui/src/platform/shutdown_guard_macos.rs
create mode 100644 crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
diff --git a/crates/codirigent-ui/src/app.rs b/crates/codirigent-ui/src/app.rs
index 61c6eeb9..a4c7a36c 100644
--- a/crates/codirigent-ui/src/app.rs
+++ b/crates/codirigent-ui/src/app.rs
@@ -445,6 +445,10 @@ impl CodirigentApp {
let splash_duration = self.splash_duration;
Application::new().run(move |cx: &mut App| {
+ // Install platform-specific shutdown guard (macOS: adds
+ // applicationShouldTerminate: to GPUI's delegate class).
+ crate::platform::shutdown_guard::install();
+
// Register global actions
Self::register_actions(cx);
@@ -516,7 +520,8 @@ impl CodirigentApp {
}),
..Default::default()
},
- move |_window, cx| {
+ move |window, cx| {
+ Self::install_window_shutdown_guard(window);
cx.new(|cx| {
AppView::new_with_splash(
session_manager,
@@ -543,7 +548,8 @@ impl CodirigentApp {
}),
..Default::default()
},
- move |_window, cx| {
+ move |window, cx| {
+ Self::install_window_shutdown_guard(window);
cx.new(|cx| {
AppView::new_workspace(session_manager, detector, event_bus, theme, cx)
})
@@ -554,10 +560,35 @@ impl CodirigentApp {
});
}
+ /// Install the window-level shutdown guard (Windows only).
+ ///
+ /// On Windows, subclasses the HWND to intercept `WM_QUERYENDSESSION`.
+ /// On other platforms, this is a no-op.
+ #[allow(unused_variables)]
+ fn install_window_shutdown_guard(window: &Window) {
+ #[cfg(target_os = "windows")]
+ {
+ use raw_window_handle::HasWindowHandle;
+ if let Ok(handle) = window.window_handle() {
+ if let raw_window_handle::RawWindowHandle::Win32(win32) = handle.as_raw() {
+ crate::platform::shutdown_guard::install_for_window(win32.hwnd.get());
+ }
+ }
+ }
+ }
+
/// Register global application actions.
fn register_actions(cx: &mut App) {
cx.on_action(|_: &Quit, cx| {
info!("Quit action triggered");
+ // On macOS, mark this as a user-initiated quit so the shutdown
+ // guard allows it through (vs. system shutdown/logout which
+ // should be blocked when sessions are active).
+ #[cfg(target_os = "macos")]
+ {
+ crate::platform::shutdown_guard::USER_QUIT_REQUESTED
+ .store(true, std::sync::atomic::Ordering::SeqCst);
+ }
cx.quit();
});
diff --git a/crates/codirigent-ui/src/platform/mod.rs b/crates/codirigent-ui/src/platform/mod.rs
index edf8e4ef..123f2883 100644
--- a/crates/codirigent-ui/src/platform/mod.rs
+++ b/crates/codirigent-ui/src/platform/mod.rs
@@ -28,6 +28,15 @@
//! let clipboard = platform::StubSmartClipboard::new();
//! ```
+// Shutdown guard — prevents system shutdown/logout while sessions are active
+pub mod shutdown_guard;
+
+#[cfg(target_os = "macos")]
+mod shutdown_guard_macos;
+
+#[cfg(target_os = "windows")]
+mod shutdown_guard_windows;
+
#[cfg(target_os = "macos")]
mod clipboard_macos;
diff --git a/crates/codirigent-ui/src/platform/shutdown_guard.rs b/crates/codirigent-ui/src/platform/shutdown_guard.rs
new file mode 100644
index 00000000..244288dd
--- /dev/null
+++ b/crates/codirigent-ui/src/platform/shutdown_guard.rs
@@ -0,0 +1,88 @@
+//! Cross-platform shutdown guard.
+//!
+//! Prevents the operating system from terminating the application during
+//! logout or shutdown when sessions are actively running.
+//!
+//! ## Platform behavior
+//!
+//! - **macOS**: Adds `applicationShouldTerminate:` to GPUI's delegate,
+//! returning `NSTerminateCancel` when sessions are active. macOS shows
+//! "Your Mac hasn't logged out because Codirigent failed to quit."
+//!
+//! - **Windows**: Subclasses the GPUI window to intercept `WM_QUERYENDSESSION`,
+//! returning `FALSE` and setting a `ShutdownBlockReason` when sessions are active.
+
+use std::sync::atomic::{AtomicBool, Ordering};
+
+/// Whether the application should block system shutdown/logout.
+///
+/// When `true`, the OS will be prevented from terminating the app during
+/// shutdown/logout, showing a system dialog to the user.
+static SHUTDOWN_BLOCKED: AtomicBool = AtomicBool::new(false);
+
+/// Whether the current quit was initiated by the user (Cmd+Q / Ctrl+Q).
+///
+/// On macOS, `[NSApp terminate:]` triggers `applicationShouldTerminate:` for
+/// both user-initiated and system-initiated quits. This flag distinguishes
+/// the two so that Cmd+Q always quits immediately.
+///
+/// Not needed on Windows — `WM_QUERYENDSESSION` is only sent during
+/// system shutdown, never during user-initiated quit.
+#[cfg(target_os = "macos")]
+pub(crate) static USER_QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
+
+/// Update whether the app should block system shutdown.
+///
+/// Call this whenever the session count changes:
+/// - `true` when there are active sessions
+/// - `false` when all sessions are closed
+pub fn set_shutdown_blocked(blocked: bool) {
+ SHUTDOWN_BLOCKED.store(blocked, Ordering::SeqCst);
+}
+
+/// Check if shutdown is currently blocked.
+pub fn is_shutdown_blocked() -> bool {
+ SHUTDOWN_BLOCKED.load(Ordering::SeqCst)
+}
+
+/// Install the platform-specific shutdown guard.
+///
+/// On macOS, this adds `applicationShouldTerminate:` to GPUI's delegate class.
+/// Must be called inside `Application::new().run()` after GPUI has initialized.
+#[cfg(target_os = "macos")]
+pub fn install() {
+ super::shutdown_guard_macos::install_shutdown_guard();
+}
+
+/// No-op on non-macOS platforms. Windows uses `install_for_window` instead.
+#[cfg(not(target_os = "macos"))]
+pub fn install() {}
+
+/// Install the Windows shutdown guard by subclassing the given window.
+///
+/// Must be called after the GPUI window has been created, from within the
+/// `open_window` callback where the raw window handle is available.
+#[cfg(target_os = "windows")]
+pub fn install_for_window(hwnd: isize) {
+ super::shutdown_guard_windows::install_shutdown_guard(hwnd);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_shutdown_blocked_default_false() {
+ // Reset to known state (other tests may have changed it)
+ set_shutdown_blocked(false);
+ assert!(!is_shutdown_blocked());
+ }
+
+ #[test]
+ fn test_set_shutdown_blocked() {
+ set_shutdown_blocked(true);
+ assert!(is_shutdown_blocked());
+ set_shutdown_blocked(false);
+ assert!(!is_shutdown_blocked());
+ }
+}
diff --git a/crates/codirigent-ui/src/platform/shutdown_guard_macos.rs b/crates/codirigent-ui/src/platform/shutdown_guard_macos.rs
new file mode 100644
index 00000000..2d8bf1cf
--- /dev/null
+++ b/crates/codirigent-ui/src/platform/shutdown_guard_macos.rs
@@ -0,0 +1,98 @@
+//! macOS shutdown guard implementation.
+//!
+//! Adds `applicationShouldTerminate:` to GPUI's `GPUIApplicationDelegate`
+//! class via the ObjC runtime. When sessions are active and the system
+//! tries to shut down or log out, this returns `NSTerminateCancel`,
+//! causing macOS to show the "Your Mac hasn't logged out because
+//! Codirigent failed to quit" dialog.
+//!
+//! User-initiated quits (Cmd+Q) are always allowed through, distinguished
+//! by the `USER_QUIT_REQUESTED` flag set in the Quit action handler.
+
+use super::shutdown_guard::{is_shutdown_blocked, USER_QUIT_REQUESTED};
+use std::ffi::c_char;
+use std::sync::atomic::Ordering;
+use tracing::{info, warn};
+
+/// `NSTerminateCancel` — The app should not be terminated.
+const NS_TERMINATE_CANCEL: usize = 0;
+/// `NSTerminateNow` — It is OK to proceed with termination.
+const NS_TERMINATE_NOW: usize = 1;
+
+// ObjC runtime functions for adding a method to an existing class.
+extern "C" {
+ fn objc_getClass(name: *const c_char) -> *mut std::ffi::c_void;
+ fn sel_registerName(name: *const c_char) -> *const std::ffi::c_void;
+ fn class_addMethod(
+ cls: *mut std::ffi::c_void,
+ name: *const std::ffi::c_void,
+ imp: extern "C" fn(
+ *mut std::ffi::c_void,
+ *const std::ffi::c_void,
+ *mut std::ffi::c_void,
+ ) -> usize,
+ types: *const c_char,
+ ) -> i8;
+}
+
+/// Install the macOS shutdown guard.
+///
+/// Adds `applicationShouldTerminate:` to GPUI's `GPUIApplicationDelegate`
+/// class. This must be called after GPUI has initialized (inside
+/// `Application::new().run()`), but before the app enters the event loop.
+///
+/// # Safety
+///
+/// Uses the ObjC runtime to modify the `GPUIApplicationDelegate` class.
+/// The class must exist (GPUI creates it via `#[ctor]` before `main()`).
+pub fn install_shutdown_guard() {
+ unsafe {
+ let cls = objc_getClass(b"GPUIApplicationDelegate\0".as_ptr().cast::());
+ if cls.is_null() {
+ warn!("Could not find GPUIApplicationDelegate class; shutdown guard not installed");
+ return;
+ }
+
+ let sel = sel_registerName(b"applicationShouldTerminate:\0".as_ptr().cast::());
+
+ // Type encoding: return NSUInteger (Q), self (@), _cmd (:), sender (@)
+ let types = b"Q@:@\0".as_ptr().cast::();
+
+ let added = class_addMethod(cls, sel, application_should_terminate, types);
+
+ if added != 0 {
+ info!("Shutdown guard installed on GPUIApplicationDelegate");
+ } else {
+ warn!(
+ "applicationShouldTerminate: already exists on GPUIApplicationDelegate; \
+ shutdown guard not installed"
+ );
+ }
+ }
+}
+
+/// ObjC method implementation for `applicationShouldTerminate:`.
+///
+/// Called by macOS when the system attempts to terminate the app (shutdown,
+/// logout, or `[NSApp terminate:]`).
+extern "C" fn application_should_terminate(
+ _this: *mut std::ffi::c_void,
+ _sel: *const std::ffi::c_void,
+ _sender: *mut std::ffi::c_void,
+) -> usize {
+ // If the user explicitly requested quit (Cmd+Q), allow unconditionally.
+ // Reset the flag so subsequent system-initiated attempts are not affected.
+ if USER_QUIT_REQUESTED.swap(false, Ordering::SeqCst) {
+ info!("User-initiated quit: allowing termination");
+ return NS_TERMINATE_NOW;
+ }
+
+ // System-initiated (shutdown/logout): block if sessions are active.
+ if is_shutdown_blocked() {
+ info!("Blocking system shutdown: active sessions exist");
+ NS_TERMINATE_CANCEL
+ } else {
+ info!("No active sessions: allowing system termination");
+ NS_TERMINATE_NOW
+ }
+}
diff --git a/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs b/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
new file mode 100644
index 00000000..3c285662
--- /dev/null
+++ b/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
@@ -0,0 +1,84 @@
+//! Windows shutdown guard implementation.
+//!
+//! Subclasses the GPUI window to intercept `WM_QUERYENDSESSION`.
+//! When sessions are active and the system tries to shut down or
+//! log out, this returns `FALSE` and sets a `ShutdownBlockReason`,
+//! causing Windows to show a dialog telling the user that
+//! Codirigent is preventing shutdown.
+
+use super::shutdown_guard::is_shutdown_blocked;
+use std::sync::atomic::{AtomicIsize, Ordering};
+use tracing::{info, warn};
+
+const GWLP_WNDPROC: i32 = -4;
+const WM_QUERYENDSESSION: u32 = 0x0011;
+
+/// The original window procedure, saved when subclassing.
+static ORIGINAL_WNDPROC: AtomicIsize = AtomicIsize::new(0);
+
+// Win32 functions from user32.dll (linked automatically on Windows).
+extern "system" {
+ fn SetWindowLongPtrW(hwnd: isize, index: i32, new_long: isize) -> isize;
+ fn CallWindowProcW(prev: isize, hwnd: isize, msg: u32, wparam: usize, lparam: isize) -> isize;
+ fn ShutdownBlockReasonCreate(hwnd: isize, reason: *const u16) -> i32;
+ fn ShutdownBlockReasonDestroy(hwnd: isize) -> i32;
+}
+
+/// Install the Windows shutdown guard by subclassing the given window.
+///
+/// Replaces the window procedure with one that intercepts `WM_QUERYENDSESSION`
+/// and blocks shutdown when sessions are active.
+///
+/// Must be called after the GPUI window has been created, from the main thread.
+pub fn install_shutdown_guard(hwnd: isize) {
+ if hwnd == 0 {
+ warn!("Invalid HWND (null); shutdown guard not installed");
+ return;
+ }
+
+ let prev = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, shutdown_guard_wndproc as isize) };
+ if prev == 0 {
+ warn!("Failed to subclass window for shutdown guard");
+ return;
+ }
+
+ ORIGINAL_WNDPROC.store(prev, Ordering::SeqCst);
+ info!("Shutdown guard installed on window");
+}
+
+/// Subclassed window procedure that intercepts `WM_QUERYENDSESSION`.
+///
+/// When the system tries to shut down or log out:
+/// - If sessions are active: sets a `ShutdownBlockReason` and returns `FALSE` (0)
+/// to block shutdown. Windows shows the reason to the user.
+/// - If no sessions: cleans up any previous block reason and forwards to
+/// the original GPUI window procedure.
+///
+/// All other messages are forwarded to the original window procedure unchanged.
+unsafe extern "system" fn shutdown_guard_wndproc(
+ hwnd: isize,
+ msg: u32,
+ wparam: usize,
+ lparam: isize,
+) -> isize {
+ if msg == WM_QUERYENDSESSION {
+ if is_shutdown_blocked() {
+ info!("Blocking system shutdown: active sessions exist");
+
+ // Encode reason as null-terminated UTF-16 for the Windows API.
+ let reason: Vec = "Codirigent has active AI sessions"
+ .encode_utf16()
+ .chain(std::iter::once(0))
+ .collect();
+ unsafe { ShutdownBlockReasonCreate(hwnd, reason.as_ptr()) };
+
+ return 0; // FALSE — block shutdown
+ }
+
+ // Not blocking: clean up any stale block reason from a previous attempt.
+ unsafe { ShutdownBlockReasonDestroy(hwnd) };
+ }
+
+ let prev = ORIGINAL_WNDPROC.load(Ordering::SeqCst);
+ unsafe { CallWindowProcW(prev, hwnd, msg, wparam, lparam) }
+}
diff --git a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs
index 5ecb78ec..c8028405 100644
--- a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs
+++ b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs
@@ -228,6 +228,11 @@ impl WorkspaceView {
let task_titles = self.sync_task_board_state();
self.sync_all_session_headers(Some(&task_titles));
self.sync_empty_cells_state();
+
+ // Update shutdown guard: block system shutdown/logout while sessions exist.
+ crate::platform::shutdown_guard::set_shutdown_blocked(
+ !self.workspace.sessions().is_empty(),
+ );
}
/// Sync a single session's terminal header from workspace state.
From af2dd457c591213cbccfad8c5a2ab0829760e4b2 Mon Sep 17 00:00:00 2001
From: oso95
Date: Sat, 21 Mar 2026 12:38:08 -0500
Subject: [PATCH 04/33] Fix extra newlines when copying wrapped terminal lines
Check the WRAPLINE flag on each grid row before inserting a newline
during copy. Wrapped rows are continuations of the same logical line
and should be joined without a newline, matching standard terminal
emulator behavior.
---
crates/codirigent-ui/src/clipboard.rs | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/crates/codirigent-ui/src/clipboard.rs b/crates/codirigent-ui/src/clipboard.rs
index 3772b229..4741ec08 100644
--- a/crates/codirigent-ui/src/clipboard.rs
+++ b/crates/codirigent-ui/src/clipboard.rs
@@ -5,6 +5,7 @@
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::index::{Column, Line};
+use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::Term;
/// Copy selected text from terminal grid.
@@ -65,12 +66,16 @@ pub fn copy_selection(term: &Term, start: (i32, usize), end: (i32, usize))
let trimmed = line_text.trim_end();
text.push_str(trimmed);
- // Add newline between lines (but not after the last line)
- if line_idx < end_line && !trimmed.is_empty() {
- text.push('\n');
- } else if line_idx < end_line && trimmed.is_empty() {
- // Preserve empty lines in multi-line selections
- text.push('\n');
+ // Only add newline between lines if the current line does NOT wrap
+ // to the next row. Wrapped lines are continuations of the same
+ // logical line and should be joined without a newline, matching
+ // the behavior of standard terminal emulators.
+ if line_idx < end_line {
+ let last_col = Column(total_cols.saturating_sub(1));
+ let is_wrapped = line[last_col].flags.contains(Flags::WRAPLINE);
+ if !is_wrapped {
+ text.push('\n');
+ }
}
}
From 0cc9372ee2133a7a75bd234309687851268d7a2c Mon Sep 17 00:00:00 2001
From: oso95
Date: Sat, 21 Mar 2026 13:34:55 -0500
Subject: [PATCH 05/33] Fix Windows clippy lint for function pointer cast
Cast through *const () before isize to satisfy
fn-to-numeric-cast and function-casts-as-integer lints.
---
crates/codirigent-ui/src/platform/shutdown_guard_windows.rs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs b/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
index 3c285662..9063aba6 100644
--- a/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
+++ b/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
@@ -36,7 +36,9 @@ pub fn install_shutdown_guard(hwnd: isize) {
return;
}
- let prev = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, shutdown_guard_wndproc as isize) };
+ let prev = unsafe {
+ SetWindowLongPtrW(hwnd, GWLP_WNDPROC, shutdown_guard_wndproc as *const () as isize)
+ };
if prev == 0 {
warn!("Failed to subclass window for shutdown guard");
return;
From 78ac244f6ada3a3ab8e55917ffbb825ba1efe21d Mon Sep 17 00:00:00 2001
From: oso95
Date: Sat, 21 Mar 2026 14:04:38 -0500
Subject: [PATCH 06/33] Derive app version from git tag in release builds
- Add CI step to stamp workspace version from the release tag before
building, so env!("CARGO_PKG_VERSION") reflects the actual release
- Replace hardcoded "v0.1.0" in settings panel with dynamic version
- Fix Windows rustfmt for shutdown_guard_windows.rs
---
.github/workflows/release.yml | 19 +++++++++++++++++++
.../src/platform/shutdown_guard_windows.rs | 6 +++++-
.../src/workspace/settings_panels.rs | 2 +-
3 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8019c650..fb8b4354 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -52,6 +52,25 @@ jobs:
cache-targets: "false"
save-if: ${{ github.ref == 'refs/heads/main' }}
+ - name: Set workspace version from tag
+ shell: bash
+ run: |
+ export VERSION="${GITHUB_REF_NAME#v}"
+ python3 -c "
+ import os, re, pathlib
+ v = os.environ['VERSION']
+ p = pathlib.Path('Cargo.toml')
+ txt = p.read_text()
+ txt = re.sub(
+ r'(\[workspace\.package\]\s*\nversion\s*=\s*)\"[^\"]*\"',
+ rf'\1\"{v}\"',
+ txt,
+ count=1,
+ )
+ p.write_text(txt)
+ "
+ cargo generate-lockfile
+
- name: Build binaries
run: |
cargo build --profile dist --features gpui-full --target ${{ matrix.target }} -p codirigent
diff --git a/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs b/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
index 9063aba6..361256e9 100644
--- a/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
+++ b/crates/codirigent-ui/src/platform/shutdown_guard_windows.rs
@@ -37,7 +37,11 @@ pub fn install_shutdown_guard(hwnd: isize) {
}
let prev = unsafe {
- SetWindowLongPtrW(hwnd, GWLP_WNDPROC, shutdown_guard_wndproc as *const () as isize)
+ SetWindowLongPtrW(
+ hwnd,
+ GWLP_WNDPROC,
+ shutdown_guard_wndproc as *const () as isize,
+ )
};
if prev == 0 {
warn!("Failed to subclass window for shutdown guard");
diff --git a/crates/codirigent-ui/src/workspace/settings_panels.rs b/crates/codirigent-ui/src/workspace/settings_panels.rs
index f20adb7c..6e23594a 100644
--- a/crates/codirigent-ui/src/workspace/settings_panels.rs
+++ b/crates/codirigent-ui/src/workspace/settings_panels.rs
@@ -1241,7 +1241,7 @@ impl super::gpui::WorkspaceView {
.text_color(ansi_hsla(12))
.child("Compiling ")
.child(div().text_color(fg).child("codirigent-ui"))
- .child(" v0.1.0"),
+ .child(format!(" v{}", env!("CARGO_PKG_VERSION"))),
)
.child(
div()
From 522d883f9101698536b27e026dfb0d9d993afe72 Mon Sep 17 00:00:00 2001
From: oso95
Date: Sat, 21 Mar 2026 14:17:52 -0500
Subject: [PATCH 07/33] Fix Windows build: use fully qualified HasWindowHandle
trait call
GPUI's Window has an inherent window_handle() returning AnyWindowHandle
that shadows the HasWindowHandle trait method returning Result. Use
HasWindowHandle::window_handle(window) to call the trait method.
---
crates/codirigent-ui/src/app.rs | 2 +-
crates/codirigent-ui/src/workspace/render.rs | 11 +++++++----
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/crates/codirigent-ui/src/app.rs b/crates/codirigent-ui/src/app.rs
index a4c7a36c..234b3c2e 100644
--- a/crates/codirigent-ui/src/app.rs
+++ b/crates/codirigent-ui/src/app.rs
@@ -569,7 +569,7 @@ impl CodirigentApp {
#[cfg(target_os = "windows")]
{
use raw_window_handle::HasWindowHandle;
- if let Ok(handle) = window.window_handle() {
+ if let Ok(handle) = HasWindowHandle::window_handle(window) {
if let raw_window_handle::RawWindowHandle::Win32(win32) = handle.as_raw() {
crate::platform::shutdown_guard::install_for_window(win32.hwnd.get());
}
diff --git a/crates/codirigent-ui/src/workspace/render.rs b/crates/codirigent-ui/src/workspace/render.rs
index 016594a5..cb96ad32 100644
--- a/crates/codirigent-ui/src/workspace/render.rs
+++ b/crates/codirigent-ui/src/workspace/render.rs
@@ -81,10 +81,13 @@ impl WorkspaceView {
#[cfg(target_os = "windows")]
{
use raw_window_handle::HasWindowHandle;
- let raw_handle = window.window_handle().ok().map(|h| match h.as_raw() {
- raw_window_handle::RawWindowHandle::Win32(win32) => win32.hwnd.get(),
- _ => 0,
- });
+ let raw_handle =
+ HasWindowHandle::window_handle(window)
+ .ok()
+ .map(|h| match h.as_raw() {
+ raw_window_handle::RawWindowHandle::Win32(win32) => win32.hwnd.get(),
+ _ => 0,
+ });
if let Some(hwnd) = raw_handle {
bar = bar.on_mouse_down(gpui::MouseButton::Left, move |_event, _window, _cx| {
crate::platform_drag::begin_title_bar_drag(hwnd);
From 62374ee898386e26f5af8a8e7c63ed3fba074556 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Sat, 21 Mar 2026 17:22:46 -0500
Subject: [PATCH 08/33] fix: fill terminal canvas background to prevent display
cut-off after resize
Empty terminal rows use the default background color and produce no
bg_rects_hsla entries, so they showed the panel_bg from the outer div
rather than the terminal background color. This was visible as a visual
cut-off when dragging a session from a smaller pane into a larger one.
Fix by painting the full canvas bounds with terminal_bg at the start of
the paint closure, before any cell-specific backgrounds. Non-default
background cells still paint their own color on top, as before.
---
crates/codirigent-ui/src/workspace/terminal_render.rs | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/crates/codirigent-ui/src/workspace/terminal_render.rs b/crates/codirigent-ui/src/workspace/terminal_render.rs
index 2870aa75..0d1e427a 100644
--- a/crates/codirigent-ui/src/workspace/terminal_render.rs
+++ b/crates/codirigent-ui/src/workspace/terminal_render.rs
@@ -201,6 +201,11 @@ impl WorkspaceView {
}
}
+ // 0. Fill canvas with terminal background so empty rows (default-color cells
+ // that produce no bg_rects_hsla entries) don't show panel_bg below them.
+ // This fixes the visual "cut-off" after dragging to a larger pane.
+ window.paint_quad(gpui::fill(bounds, terminal_bg));
+
// 1. Paint background rectangles
for row in &cached_rows {
for (rect_row, start_col, end_col, bg_color) in row.bg_rects_hsla.iter() {
From d1801341f508c6194a0bb3c781debbc59ee33e63 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Sat, 21 Mar 2026 17:58:49 -0500
Subject: [PATCH 09/33] fix: force full row rebuild when scrolled back to
prevent display cycling
When new output arrives while the viewport is scrolled into scrollback,
alacritty's partial damage tracking only reports rows changed in the
active area. But new output can push lines into scrollback, shifting all
content visible in the scrolled viewport. Using partial damage leaves
most rows stale, causing a cycling/repeating display artifact.
Skip the partial-damage optimization when display_offset > 0 so all
viewport rows are rebuilt from the current grid state.
---
crates/codirigent-ui/src/terminal_runtime.rs | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/crates/codirigent-ui/src/terminal_runtime.rs b/crates/codirigent-ui/src/terminal_runtime.rs
index 6e349c80..01090bcd 100644
--- a/crates/codirigent-ui/src/terminal_runtime.rs
+++ b/crates/codirigent-ui/src/terminal_runtime.rs
@@ -199,10 +199,12 @@ impl TerminalRuntime {
fn snapshot_from_damage(&mut self) -> TerminalRenderSnapshot {
let rows = self.terminal.rows() as usize;
let cols = self.terminal.cols() as usize;
- let damage = if self
- .cached_rows
- .as_ref()
- .is_some_and(|cached_rows| cached_rows.len() == rows)
+ let scrolled_back = self.terminal.term().grid().display_offset() > 0;
+ let damage = if !scrolled_back
+ && self
+ .cached_rows
+ .as_ref()
+ .is_some_and(|cached_rows| cached_rows.len() == rows)
{
let term = self.terminal.term_mut();
let damage = match term.damage() {
From c9cfdc5dc278e8234c018d83c06242daceb45950 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Sat, 21 Mar 2026 18:08:35 -0500
Subject: [PATCH 10/33] fix: sort hook signals by timestamp to prevent stale
status overwrites
When multiple signal files target the same session (e.g., parent + subagent),
non-deterministic read_dir order could let a stale "idle" from the parent
overwrite a newer "working" from the subagent, keeping the status stuck on idle.
---
.../src/workspace/impl_output_polling/hook_signals.rs | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs
index 1c26ec0e..5cf336b8 100644
--- a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs
+++ b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs
@@ -366,6 +366,14 @@ impl WorkspaceView {
let _ = this.update(cx, |this, cx| {
this.polling.hook_signal_check_in_flight = false;
+ // Sort by timestamp so that the newest signal for each
+ // session is processed last and wins the cached status.
+ // Without this, non-deterministic read_dir order can let
+ // a stale "idle" signal overwrite a newer "working" signal
+ // when multiple signal files target the same session
+ // (e.g., parent + subagent Claude Code sessions).
+ let mut updates = updates;
+ updates.sort_by_key(|u| u.ts);
for update in updates {
this.apply_hook_signal_update(update, cx);
}
From 8d746d3e8893f86609b62d21d8100b3546a0cf89 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Sun, 22 Mar 2026 12:03:48 -0500
Subject: [PATCH 11/33] fix: stop overwriting session UUID after PTY spawn on
restore
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On app restart, finalize_restored_session_bootstrap overwrote the workspace
session's session_uuid with the old UUID from state.json. But the PTY was
already spawned with the new UUID from Session::new() as the
CODIRIGENT_SESSION_UUID env var. This mismatch caused hook signal routing
to fail silently — signals carried the PTY's UUID but the workspace had the
old one — keeping status stuck on Idle.
---
.../src/workspace/impl_session_lifecycle.rs | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
index 83c177fa..7c0039b5 100644
--- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
+++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
@@ -1443,19 +1443,19 @@ impl WorkspaceView {
let codex_started_at = plan.codex_started_at;
if let Ok(manager) = self.session_manager.lock() {
manager.with_session_state_mut(bootstrapped.session_id, |state| {
- // session_uuid is set only inside this restore_cli-gated block;
- // the local session struct receives the same guard at the assignment below.
- state.session.session_uuid = plan.session_uuid.clone();
state.session.codex_execution_mode = codex_execution_mode;
state.session.codex_started_at = codex_started_at;
});
}
}
+ // NOTE: Do NOT overwrite session.session_uuid with plan.session_uuid here.
+ // The PTY was already spawned with the UUID from Session::new() as the
+ // CODIRIGENT_SESSION_UUID env var. Overwriting with the saved UUID from a
+ // previous app instance creates a mismatch: hook signals carry the PTY's
+ // UUID but the workspace session has the old one, causing routing to fail
+ // and status to stay stuck on Idle.
let mut session = bootstrapped.session;
- if restore_cli {
- session.session_uuid = plan.session_uuid.clone();
- }
session.shell = bootstrapped.request.requested_shell.clone();
session.group = plan.group.clone();
session.color = plan.color.clone();
From 80a43329f69c495662b3d409ff00a35f34d79cd8 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Sun, 22 Mar 2026 14:48:55 -0500
Subject: [PATCH 12/33] chore: add Windows MSI signing workflow and reorganize
docs
- Add sign-and-upload.ps1 script for local MSI signing with Certum cert
- Add windows-release-signing.md documenting the full signing workflow
- Exclude MSI from CI checksums (replaced by signed version post-build)
- Reorganize docs into features/, release/, workflows/ subdirectories
- Remove obsolete plan files and gitignore specs/
---
.github/workflows/release.yml | 3 +-
.gitignore | 4 +
docs/{ => features}/hook-and-status-system.md | 0
docs/{ => features}/session-resume.md | 0
docs/ghostty-theme-registry-plan.md | 546 ------------------
docs/{ => release}/local-dmg-build.md | 0
docs/release/windows-release-signing.md | 189 ++++++
...-03-18-terminal-scrollbar-search-design.md | 188 ------
.../task-verification-workflow.md | 0
scripts/sign-and-upload.ps1 | 77 +++
10 files changed, 272 insertions(+), 735 deletions(-)
rename docs/{ => features}/hook-and-status-system.md (100%)
rename docs/{ => features}/session-resume.md (100%)
delete mode 100644 docs/ghostty-theme-registry-plan.md
rename docs/{ => release}/local-dmg-build.md (100%)
create mode 100644 docs/release/windows-release-signing.md
delete mode 100644 docs/specs/2026-03-18-terminal-scrollbar-search-design.md
rename docs/{ => workflows}/task-verification-workflow.md (100%)
create mode 100644 scripts/sign-and-upload.ps1
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fb8b4354..536da9b1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -267,7 +267,8 @@ jobs:
- name: Generate checksums
run: |
cd artifacts
- sha256sum * > checksums-sha256.txt
+ # Exclude MSI — it will be replaced by a locally signed version
+ sha256sum $(ls | grep -v '\.msi$') > checksums-sha256.txt
cat checksums-sha256.txt
- name: Create GitHub Release
diff --git a/.gitignore b/.gitignore
index 4baa4c10..9552362b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,9 +7,13 @@
# -----------------------------------------------------------------------------
+# Claude Code project instructions (local only)
+CLAUDE.md
+
# Development plans
plans/
docs/superpowers/
+docs/specs/
# Superpowers skill state
.superpowers/
diff --git a/docs/hook-and-status-system.md b/docs/features/hook-and-status-system.md
similarity index 100%
rename from docs/hook-and-status-system.md
rename to docs/features/hook-and-status-system.md
diff --git a/docs/session-resume.md b/docs/features/session-resume.md
similarity index 100%
rename from docs/session-resume.md
rename to docs/features/session-resume.md
diff --git a/docs/ghostty-theme-registry-plan.md b/docs/ghostty-theme-registry-plan.md
deleted file mode 100644
index ab40bc2d..00000000
--- a/docs/ghostty-theme-registry-plan.md
+++ /dev/null
@@ -1,546 +0,0 @@
-# Ghostty-Style Theme Registry Plan
-
-Implementation plan for expanding Codirigent's theme system from a built-in
-`dark/light` toggle into a registry-backed theme model with custom theme files,
-runtime theme IDs, and terminal palette behavior that can scale toward a
-Ghostty-style theme experience.
-
-This document is intentionally written before code changes. It is the working
-plan for the branch `feat/ghostty-theme-registry`.
-
----
-
-## Execution Summary
-
-This branch is now complete. The implementation landed as the planned task
-series with small reviewable commits:
-
-1. `ced6530` `Add theme registry conversion backbone`
-2. `d2c0be0` `Apply saved theme IDs during settings load`
-3. `03d208f` `Make settings theme picker registry-driven`
-4. `f632676` `Load custom themes during settings startup`
-5. `4df2b36` `Apply selection text color in terminal rendering`
-6. `eb020b1` `Update lockfile for theme rendering dependency`
-
-Final outcome:
-
-- theme selection is registry-based instead of hardcoded `dark/light`
-- `appearance.theme` now round-trips as a durable theme ID
-- startup/settings load resolves and applies saved theme IDs through one path
-- custom themes load from the user config `themes/` directory off the UI thread
-- terminal fg/bg/cursor/selection/ANSI palette are all on the active theme path
-- terminal selection foreground is now actually rendered, not just stored in the schema
-
----
-
-## Purpose
-
-Codirigent already has the terminal rendering primitives needed for richer
-themes, but the application still behaves like a two-theme product:
-
-- the runtime UI uses `CodirigentTheme`
-- the settings UI only exposes `dark` and `light`
-- the saved setting is treated like a boolean mode rather than a durable
- registry theme ID
-- custom theme loading infrastructure exists separately but is not wired into
- app startup or live theme application
-
-The goal of this task series is to make themes a first-class product feature
-instead of a hardcoded toggle.
-
----
-
-## Problem Statement
-
-The current implementation has four structural gaps:
-
-1. **Two parallel theme models**
- - `crates/codirigent-ui/src/theme.rs` defines the runtime theme actually used
- by UI and terminal rendering.
- - `crates/codirigent-ui/src/theme_config.rs` and
- `crates/codirigent-ui/src/theme_manager.rs` define a serializable theme
- model and registry, but they are not the active runtime path.
-
-2. **Theme selection is hardcoded**
- - Settings only present `dark` and `light`.
- - Theme switching constructs `CodirigentTheme::dark()` or
- `CodirigentTheme::light()` directly.
-
-3. **Saved theme identity is not durable**
- - `appearance.theme` is stored as a `String`, but the settings page rebuild
- currently infers the value from current background lightness instead of
- preserving the active theme ID.
-
-4. **Load/apply path is incomplete**
- - User settings are loaded and cached, but the selected theme is not treated
- as a registry-resolved startup input.
-
----
-
-## Current Architecture Inventory
-
-### Runtime Theme Path
-
-- `crates/codirigent-ui/src/theme.rs`
- - owns `CodirigentTheme`
- - contains UI colors, terminal colors, typography, spacing
- - contains ANSI 16-color palette and 256-color indexed conversion
-
-- `crates/codirigent-ui/src/terminal_colors.rs`
- - maps terminal named/indexed/spec colors into runtime theme colors
-
-- `crates/codirigent-ui/src/terminal_view.rs`
- - caches terminal bg/fg from `CodirigentTheme`
- - updates terminal runtime when theme changes
-
-- `crates/codirigent-ui/src/workspace/core.rs`
- - stores the active `CodirigentTheme`
-
-### Settings and Persistence Path
-
-- `crates/codirigent-core/src/config.rs`
- - `AppearanceSettings.theme: String`
- - `TerminalSettings` stores font/cursor/line-height preferences
-
-- `crates/codirigent-ui/src/workspace/settings_panels.rs`
- - theme dropdown is currently `["dark", "light"]`
- - directly constructs built-in runtime themes
-
-- `crates/codirigent-ui/src/workspace/impl_settings.rs`
- - settings page rebuild overwrites `appearance.theme` based on background
- lightness
- - settings load path updates cached settings but does not appear to resolve
- and apply an arbitrary theme ID through a registry
-
-### Unused or Underused Theme Registry Path
-
-- `crates/codirigent-ui/src/theme_config.rs`
- - serializable `Theme`
- - `ThemeColors`, `TerminalColors`, typography, spacing
-
-- `crates/codirigent-ui/src/theme_manager.rs`
- - registry for built-in and JSON-loaded themes
- - theme loading from a directory or file
- - active theme switching by ID
-
----
-
-## Target End State
-
-After this work series:
-
-- Codirigent loads a theme registry on startup.
-- The active theme is identified by a durable theme ID.
-- `appearance.theme` means "selected theme ID", not "dark mode boolean".
-- Settings list all available themes, not just `dark/light`.
-- Built-in themes and custom JSON themes use the same application path.
-- Runtime theme application updates both UI and terminal state consistently.
-- The terminal palette model is structured so it can grow toward a
- Ghostty-style theme schema without another large refactor.
-
----
-
-## Non-Goals For The First Pass
-
-The first implementation pass should not try to do all theme features at once.
-These are explicitly out of scope unless they fall out naturally:
-
-- importing Ghostty theme files verbatim with full syntax compatibility
-- automatic OS appearance switching
-- a theme editor UI
-- remote theme downloads
-- dynamic generation of 256-color cube replacements on the first pass
-
-The first pass is about establishing the correct architecture and durable
-runtime behavior.
-
----
-
-## Design Principles
-
-1. **One runtime source of truth**
- - The app should resolve every selected theme into one runtime
- `CodirigentTheme`.
-
-2. **Theme ID is stable**
- - Any active theme must have a durable identifier that round-trips through
- settings and app restart.
-
-3. **Custom themes should not be a side path**
- - Built-in and file-loaded themes should use the same selection and apply
- flow.
-
-4. **Terminal fidelity matters**
- - ANSI palette, foreground/background, cursor, and selection colors must all
- switch with the active theme.
-
-5. **Incremental delivery**
- - The work should land as small, reviewable tasks following
- `docs/task-verification-workflow.md`.
-
----
-
-## Implementation Constraints
-
-These constraints apply to every task in this branch:
-
-1. **Do not load theme files on the UI thread**
- - theme discovery, directory scans, and file reads must happen on a
- background executor
- - the UI thread may receive resolved theme data and apply it, but must not
- block on filesystem traversal or JSON file IO
-
-2. **Keep files at manageable length**
- - do not keep expanding already-large files with unrelated theme logic
- - when a change starts to push a file into "grab bag" territory, extract a
- focused helper/module instead
- - prefer small, reviewable modules over one large integration file
-
-3. **Prefer reusable components over duplicated wiring**
- - shared theme resolution, fallback, conversion, and apply behavior should be
- centralized
- - avoid copy-pasting theme selection logic across startup, settings, and
- terminal update paths
-
-4. **Avoid magic numbers unless they are inherent to the domain**
- - filesystem polling delays, cache TTLs, directory limits, and fallback
- constants must be named
- - if a number is part of a terminal standard or palette definition, document
- why it is fixed
-
-5. **Separate IO, state, and presentation concerns**
- - file loading belongs in a theme loading/service layer
- - theme registry state belongs in app/workspace state
- - settings UI should only render options and trigger actions
-
----
-
-## Proposed Implementation Shape
-
-### 1. Introduce a Registry-to-Runtime Conversion Layer
-
-Create a conversion path from the serializable registry theme model into
-`CodirigentTheme`.
-
-Options:
-
-- add `impl TryFrom for CodirigentTheme`
-- or add `Theme::to_runtime_theme() -> Result`
-
-Expected result:
-
-- the registry model becomes useful without replacing the runtime renderer
-- theme parsing and theme application stop being separate systems
-
-### 2. Make Theme Selection Registry-Driven
-
-Replace direct `dark/light` branching with:
-
-1. resolve selected theme ID from settings
-2. look it up in the theme registry
-3. convert it into `CodirigentTheme`
-4. apply it to workspace and terminals
-
-Fallback behavior:
-
-- if the theme ID is missing or invalid, fall back to built-in `dark`
-- log the failure with enough detail to diagnose bad custom themes
-
-### 3. Preserve Theme IDs In Settings
-
-Remove the current behavior that reconstructs `appearance.theme` by inspecting
-background lightness.
-
-Instead:
-
-- track the current active theme ID in workspace settings state
-- persist and rebuild the settings page using that actual ID
-
-### 4. Load Custom Themes From A Well-Defined Directory
-
-Decide and document the custom theme directory. Likely candidate:
-
-- `%APPDATA%/codirigent/themes/` on Windows
-- `~/.config/codirigent/themes/` on Linux/macOS
-
-The initial implementation should:
-
-- load built-in themes first
-- then overlay custom themes from disk
-- allow custom themes to coexist with built-ins under unique IDs
-- perform file discovery and JSON loading off the UI thread
-
-### 5. Keep Runtime Theme Mutations Compatible
-
-Today the code mutates parts of the active runtime theme after applying a base
-theme, for example:
-
-- grid gap
-- UI font size
-- terminal font size
-- terminal font family
-- terminal line height
-
-The new registry-driven apply path must preserve those user overrides rather
-than resetting them when a theme changes.
-
-### 6. Prepare For Ghostty-Style Theme Growth
-
-The first pass does not need full Ghostty config syntax, but the schema should
-be able to expand toward these terminal concepts cleanly:
-
-- background
-- foreground
-- cursor color
-- cursor text color
-- selection background
-- selection foreground
-- ANSI 16 palette
-- optional split between light and dark variants
-
-If a schema change is needed, prefer a backward-compatible addition over a
-throwaway one-off field.
-
----
-
-## Task Series
-
-This branch should be executed as a small task series, not one large patch.
-
-### Task 1. Document and Wire The Runtime Registry Backbone
-
-Deliverables:
-
-- conversion path from serializable theme model to runtime `CodirigentTheme`
-- built-in themes exposed through the registry path
-- unit tests for conversion and fallback behavior
-
-Done when:
-
-- a theme ID can produce a runtime theme without `if theme == "light"`
-
-Status:
-
-- complete in `ced6530`
-
-### Task 2. Apply Saved Theme IDs During Settings Load / Startup
-
-Deliverables:
-
-- startup or settings load path resolves `appearance.theme`
-- invalid IDs fall back safely
-- active theme ID is retained in workspace state
-
-Done when:
-
-- restarting the app with a non-default theme keeps the same theme selected
-
-Status:
-
-- complete in `d2c0be0`
-
-### Task 3. Make The Settings Theme Picker Dynamic
-
-Deliverables:
-
-- settings theme dropdown is populated from the registry
-- selection applies by theme ID
-- settings rebuild preserves the active theme ID
-
-Done when:
-
-- custom or built-in registry themes are selectable from settings without
- hardcoded branching
-
-Status:
-
-- complete in `03d208f`
-
-### Task 4. Load Custom Theme Files From Disk
-
-Deliverables:
-
-- custom theme directory resolution
-- file loading on startup
-- invalid file handling with non-fatal logging
-- tests for loading valid and invalid theme files
-
-Done when:
-
-- dropping a valid theme JSON file into the theme directory makes it selectable
-
-Status:
-
-- complete in `f632676`
-
-### Task 5. Expand Terminal Theme Fidelity Where Needed
-
-Deliverables:
-
-- review the serializable theme schema against runtime terminal needs
-- add missing fields only if required for correct runtime parity
-- verify terminal fg/bg/cursor/selection/ANSI palette switch correctly
-
-Done when:
-
-- terminal behavior remains visually consistent after switching among themes
-
-Status:
-
-- complete in `4df2b36`
-
----
-
-## Risks And Review Focus
-
-### Risk 1. Theme Drift Between Models
-
-If `theme_config::Theme` cannot fully represent runtime needs, conversion logic
-may silently drop behavior.
-
-Review focus:
-
-- terminal fields
-- status colors
-- typography/spacings that are currently mutated at runtime
-
-### Risk 2. Settings Page Regressions
-
-The current settings page rebuild flow reconstructs display state from runtime
-theme values. That can easily wipe out the selected theme ID.
-
-Review focus:
-
-- open settings after switching themes
-- close and reopen settings
-- restart app and reopen settings
-
-### Risk 3. Startup Ordering
-
-If theme loading happens after UI creation or after terminal views are
-constructed, the app may flash the wrong theme or only partially update.
-
-Review focus:
-
-- initial workspace creation
-- settings background load path
-- terminal creation after theme application
-- background theme loading handoff back to UI state application
-
-### Risk 4. Overwriting User Overrides
-
-Applying a new base theme must not discard user font size, terminal font
-preferences, or grid gap choices.
-
-Review focus:
-
-- theme switch after changing font sizes
-- theme switch after changing terminal line height
-- theme switch after changing terminal font family
-
----
-
-## Verification Strategy
-
-This task series follows `docs/task-verification-workflow.md`.
-
-Per task, after implementation:
-
-```bash
-cargo clean
-cargo build --all-features
-cargo test --all --all-targets --all-features
-cargo test -p codirigent-ui --lib --features gpui-full
-cargo clippy --all --all-targets --all-features -- -D warnings
-cargo fmt --all --check
-bash scripts/audit-unwraps.sh
-```
-
-Required review pass after verification:
-
-- inspect the diff for dead theme paths and duplicate logic
-- review fallback behavior for invalid theme IDs and broken JSON files
-- review startup ordering and settings rebuild behavior
-- review terminal palette behavior, not just UI chrome colors
-- confirm file IO and theme discovery do not happen on the UI thread
-- confirm new constants are named and justified
-- confirm touched files remain at maintainable size
-
-Final verification executed on the completed branch:
-
-```bash
-cargo build --all-features
-cargo test --all --all-targets --all-features
-cargo test -p codirigent-ui --lib --features gpui-full
-cargo clippy --all --all-targets --all-features -- -D warnings
-cargo fmt --all --check
-bash scripts/audit-unwraps.sh
-```
-
-Result:
-
-- build passed
-- full test suite passed
-- `gpui-full` UI tests passed
-- clippy passed with `-D warnings`
-- formatting check passed
-- unwrap audit reported only the existing repository-wide baseline
-
----
-
-## Suggested File Touch Order
-
-To keep the series reviewable, prefer this order:
-
-1. `crates/codirigent-ui/src/theme.rs`
-2. `crates/codirigent-ui/src/theme_config.rs`
-3. `crates/codirigent-ui/src/theme_manager.rs`
-4. `crates/codirigent-ui/src/workspace/impl_settings.rs`
-5. `crates/codirigent-ui/src/workspace/settings_panels.rs`
-6. any startup/bootstrap files that need registry initialization
-7. tests
-8. follow-up docs updates if behavior changes materially
-
-This order keeps model changes ahead of UI wiring.
-
----
-
-## Open Questions Before Implementation
-
-1. Where should the registry live at runtime?
- - central app state
- - workspace state
- - settings state
-
-2. Should built-in themes remain defined in `theme.rs`, or should they be
- generated from `theme_config.rs` and then converted into runtime themes?
-
-3. Do we want the first pass to add richer terminal fields to
- `theme_config.rs`, or keep schema changes minimal and only fill the missing
- runtime wiring?
-
-4. Should custom theme discovery be automatic on every startup, or only when
- the settings panel opens?
-
-Recommended answers for the first pass:
-
-- keep the registry in app/workspace state
-- preserve `theme.rs` as the runtime authority initially
-- add only the schema fields required for parity
-- load custom themes on startup so the selected theme is valid before settings
- open
-
----
-
-## Completion Standard
-
-This plan is complete only when all of the following are true:
-
-- theme selection is registry-based
-- `appearance.theme` stores and preserves a real theme ID
-- startup and settings load paths apply the saved theme
-- custom themes can be loaded from disk
-- terminal colors switch consistently with the active theme
-- each task is verified and reviewed per `docs/task-verification-workflow.md`
-
-This completion standard is now satisfied for `feat/ghostty-theme-registry`.
diff --git a/docs/local-dmg-build.md b/docs/release/local-dmg-build.md
similarity index 100%
rename from docs/local-dmg-build.md
rename to docs/release/local-dmg-build.md
diff --git a/docs/release/windows-release-signing.md b/docs/release/windows-release-signing.md
new file mode 100644
index 00000000..bbd915e4
--- /dev/null
+++ b/docs/release/windows-release-signing.md
@@ -0,0 +1,189 @@
+# Windows Release Signing Workflow
+
+Required procedure for signing the Windows MSI installer after CI builds a
+release.
+
+---
+
+## Goal
+
+The Certum Open Source Code Signing certificate private key is non-exportable
+(SimplySign protected). CI cannot sign the MSI. After CI completes, the MSI
+must be signed locally on the machine where SimplySign Desktop is installed,
+then uploaded back to the GitHub Release with updated checksums.
+
+---
+
+## Prerequisites
+
+- SimplySign Desktop installed and logged in (system tray icon visible)
+- Windows SDK installed (`signtool.exe` available)
+- WiX Toolset available at `tools/wix/` in the repo
+- `gh` CLI authenticated with push access to the repo
+- Certum certificate visible in `certmgr.msc` under Personal > Certificates
+
+---
+
+## Automated Flow (CI Release)
+
+When a tag is pushed, CI handles everything except Windows signing:
+
+### 1. Push the Tag
+
+```bash
+git tag -a v0.X.Y -m "Release v0.X.Y"
+git push origin v0.X.Y
+```
+
+### 2. Wait for CI
+
+The GitHub Actions `Release` workflow builds:
+
+- Windows x64: `.msi` (unsigned) + `.zip`
+- macOS Apple Silicon: `.dmg` (signed + notarized) + `.tar.gz`
+
+`checksums-sha256.txt` intentionally excludes the MSI.
+
+### 3. Sign and Upload
+
+Run the signing script after CI completes:
+
+```powershell
+.\scripts\sign-and-upload.ps1 -Tag v0.X.Y
+```
+
+The script:
+
+1. downloads the unsigned MSI from the release
+2. finds the Certum certificate by issuer in the Windows cert store
+3. signs the MSI with SHA-256 + Certum timestamp server
+4. verifies the signature
+5. computes SHA-256 hash and appends to `checksums-sha256.txt`
+6. uploads signed MSI and updated checksums to the release
+
+### 4. Verify
+
+After the script completes, confirm:
+
+- [ ] MSI on the release page has a newer timestamp than the original
+- [ ] `checksums-sha256.txt` includes a line for the `.msi` file
+- [ ] Download the signed MSI and check Properties > Digital Signatures
+
+---
+
+## Local Build Flow (Testing)
+
+For testing installer changes before pushing a tag.
+
+### 1. Set the Version
+
+The workspace version in `Cargo.toml` must match the installer version.
+CI does this automatically from the tag. Locally, do it manually:
+
+```bash
+# Check current version
+grep '^version' Cargo.toml | head -1
+
+# If it needs updating (revert after testing):
+# Edit Cargo.toml line 16: version = "0.X.Y"
+```
+
+### 2. Build Binaries
+
+```bash
+export PATH="$PATH:/c/Program Files (x86)/Windows Kits/10/bin/10.0.19041.0/x64"
+cargo build --profile dist --features gpui-full -p codirigent -p codirigent-hook
+```
+
+Binaries output to `target/dist/`.
+
+### 3. Build MSI
+
+```bash
+WIX_BIN="tools/wix"
+"$WIX_BIN/candle.exe" \
+ -dBinaryPath="target/dist" \
+ -dVersion=0.X.Y.0 \
+ -dLicensePath=wix/License.rtf \
+ -arch x64 wix/main.wxs -o wix/main.wixobj
+
+"$WIX_BIN/light.exe" \
+ -ext "$WIX_BIN/WixUIExtension.dll" \
+ wix/main.wixobj -o dist/codirigent.msi
+```
+
+Note: WiX version format requires `x.x.x.x` (four integers, no prerelease
+suffix). Use `0.1.3.0` not `0.1.3-alpha.1`.
+
+### 4. Sign MSI
+
+Ensure SimplySign Desktop is running and logged in, then:
+
+```powershell
+$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" |
+ Sort-Object FullName -Descending | Select-Object -First 1).FullName
+
+$cert = Get-ChildItem Cert:\CurrentUser\My |
+ Where-Object { $_.Issuer -like "*Certum*" } |
+ Select-Object -First 1
+
+& $signtool sign `
+ /sha1 $cert.Thumbprint `
+ /fd sha256 `
+ /tr http://time.certum.pl `
+ /td sha256 `
+ /v dist\codirigent.msi
+```
+
+### 5. Verify Signature
+
+```powershell
+& $signtool verify /pa /v dist\codirigent.msi
+```
+
+Or: right-click `dist\codirigent.msi` > Properties > Digital Signatures.
+
+### 6. Test Install
+
+Double-click `dist\codirigent.msi` and verify:
+
+- [ ] Installer shows correct version
+- [ ] `codirigent.exe` runs and reports the correct version
+- [ ] `codirigent-hook.exe` is installed alongside
+- [ ] PATH is updated (new terminal session required)
+
+### 7. Revert Version (if changed)
+
+If you modified `Cargo.toml` for testing, revert it:
+
+```bash
+git checkout Cargo.toml Cargo.lock
+```
+
+---
+
+## Failure Handling
+
+- **SimplySign not running:** signtool returns "No certificates were found".
+ Launch SimplySign Desktop and log in.
+- **Certificate not found:** Run `certmgr.msc` and confirm the Certum cert is
+ under Personal > Certificates with the key icon.
+- **Timestamp server unreachable:** Retry. If `time.certum.pl` is down, try
+ `http://timestamp.digicert.com` as a fallback.
+- **WiX candle/light fails:** Ensure `tools/wix/` exists. If missing, download
+ WiX Toolset v3.14 binaries and extract to `tools/wix/`.
+- **Version mismatch (MSI vs binary):** The `-dVersion` passed to WiX controls
+ what Windows shows in Add/Remove Programs. The binary version comes from
+ `Cargo.toml`. Both must be updated for a consistent release.
+
+---
+
+## Completion Standard
+
+A Windows release is only complete when:
+
+- [ ] CI workflow finished successfully
+- [ ] MSI is signed with the Certum certificate
+- [ ] `checksums-sha256.txt` includes the signed MSI hash
+- [ ] Both files are uploaded to the GitHub Release
+- [ ] The release page shows the correct file sizes (signed MSI is larger)
diff --git a/docs/specs/2026-03-18-terminal-scrollbar-search-design.md b/docs/specs/2026-03-18-terminal-scrollbar-search-design.md
deleted file mode 100644
index b454b24b..00000000
--- a/docs/specs/2026-03-18-terminal-scrollbar-search-design.md
+++ /dev/null
@@ -1,188 +0,0 @@
-# Terminal Scrollbar & Search Design
-
-**Date:** 2026-03-18
-**Status:** Approved
-
-## Overview
-
-Add two features to the terminal pane:
-1. An interactive scrollbar with drag-to-scroll, click-to-jump, and auto-hide
-2. A find-in-terminal overlay (Cmd+F / Ctrl+F) with match highlighting, navigation, and scrollbar match markers
-
-## Prerequisite APIs
-
-### Total Scrollback Lines
-
-The scrollbar and search both need to know the total scrollback size. Alacritty's `Term` provides this via `grid().total_lines() - grid().screen_lines()` (history size) and `topmost_line().0.unsigned_abs()` (max scroll offset). Currently `TerminalSize::total_lines()` in `terminal.rs` returns only the visible row count.
-
-**Changes required:**
-- Add a `history_size: usize` field to `TerminalRenderSnapshot` in `terminal_runtime.rs`, populated from `term.topmost_line().0.unsigned_abs()` during snapshot generation
-- Add `total_scrollback_lines() -> usize` to `TerminalView`, reading from the snapshot
-- The scrollbar uses this value for thumb sizing and position math
-
-### Scroll-to-Absolute-Position
-
-The scrollbar drag and track-click need to set the viewport to an arbitrary position. Only relative scroll APIs exist today (`scroll_up`, `scroll_down`, `scroll_to_bottom`).
-
-**Approach:** Compute a delta from the current `display_offset` to the target offset using `i32` arithmetic to avoid underflow: `Scroll::Delta((target as i32) - (current_display_offset as i32))`. Alacritty clamps the result to `[0, history_size]`. Add a `scroll_to_offset(target: usize)` method on `TerminalRuntimeHandle` that performs this computation internally.
-
-### Search Grid Access
-
-The search engine needs to iterate the `Term` grid cells. The `Term` is owned by `TerminalRuntime` behind `Arc>`.
-
-**Approach:** Add a `search(query: &str) -> Vec` method on `TerminalRuntimeHandle` that acquires the mutex lock and runs the scan, consistent with how `get_selected_text()` already works. The debounce timer resets on each keystroke so only the final query triggers a scan. If large-scrollback performance becomes an issue, this can be moved to a background task later.
-
-## Feature 1: Interactive Scrollbar
-
-### Rendering
-
-Overlay div on the right edge of the terminal pane, rendered as a sibling of the terminal canvas inside `grid_render.rs`. Positioned absolute, sits on top of terminal content.
-
-- **Track:** Full-height div, transparent by default, semi-transparent on hover
-- **Thumb:** Colored div inside the track
- - Height: `max(30px, (visible_rows / total_lines) * track_height)`
- - Position: proportional to `display_offset / total_scrollback_lines`
-- **Width:** 8px default, expands to 12px on hover
-
-### Interaction
-
-- **Drag thumb:** `on_mouse_down` on thumb captures drag start offset. `on_mouse_move` on track converts pixel Y delta to proportional scrollback position. `on_mouse_up` releases.
-- **Click track:** Jump to proportional position — `(click_y / track_height) * total_scrollback_lines`
-- **Mouse wheel:** Existing handler unchanged. Thumb position updates reactively from `display_offset`.
-
-### Auto-Hide
-
-- Default opacity: 0 (hidden)
-- Fade in on: mouse enters terminal area, scroll wheel activity, scrollback position changes
-- Fade out after: 1.5s of no scroll activity AND mouse not hovering the scrollbar
-- While mouse hovers the scrollbar: stay visible, expand width
-- Timer: use `cx.spawn()` with `Timer::after(Duration::from_millis(1500))` to schedule fade-out; cancel and restart on any scroll activity or hover. Update opacity via `cx.notify()`.
-- The scrollbar track height accounts for `TERMINAL_CONTENT_PADDING` so the thumb range matches the visible content area.
-
-### State
-
-```rust
-struct ScrollbarState {
- /// Current opacity (0.0 = hidden, 1.0 = fully visible).
- opacity: f32,
- /// Mouse is hovering the scrollbar track or thumb.
- hovered: bool,
- /// Active drag: stores Y offset from thumb top at drag start.
- dragging: Option,
- /// Timestamp of last scroll activity (for auto-hide timer).
- last_scroll_activity: Instant,
-}
-```
-
-## Feature 2: Terminal Search
-
-### Search Overlay
-
-Floating bar at the top-right of the terminal pane, approximately 300px wide. Contains:
-- Text input field (focused on open)
-- Match count label: "3 of 47"
-- Prev/Next buttons (up/down arrow icons, or keyboard Enter/Shift+Enter)
-- Close button (X) or Escape to dismiss
-
-Rendered as an absolute-positioned div inside the terminal pane container.
-
-### Activation
-
-- **Open:** Cmd+F (macOS) / Ctrl+F (Windows/Linux) — registers a `SearchTerminal` GPUI action
-- **Close:** Escape key or click X — clears highlights and returns focus to terminal
-- **Input routing:** While search bar is open, keystrokes go to the search input, not the terminal PTY
-
-### Search Engine
-
-Location: `crates/codirigent-ui/src/terminal_search.rs`
-
-- Scans alacritty `Term` grid from bottom of scrollback to top (most recent content first)
-- Iterates cells row by row, concatenating characters into line strings
-- Handles wrapped lines as a single logical line
-- Case-insensitive matching
-- Returns match positions:
-
-```rust
-struct SearchMatch {
- /// Absolute grid line coordinate (matches alacritty's Line(i32) convention).
- /// Negative = scrollback history, 0 = top of visible screen, positive = below.
- /// This is independent of display_offset — the viewport maps absolute lines
- /// to screen rows. Scrollbar marker positions are computed from these absolute
- /// coordinates relative to the total scrollback range.
- grid_line: i32,
- /// Start column (inclusive).
- start_col: usize,
- /// End column (exclusive).
- end_col: usize,
-}
-```
-
-- Wrapped lines detected via alacritty's `WRAPLINE` cell flag — consecutive flagged rows are concatenated into a single logical line for matching, with column offsets adjusted accordingly
-- Debounce: 150ms, timer resets on each keystroke so only the final query triggers a scan; search runs synchronously under the `TerminalRuntimeHandle` mutex (consistent with `get_selected_text()`)
-- **Output during search:** When new terminal output arrives while search is active, matches are kept as-is (stale) until the user modifies the query. Match indices may shift due to new output; if the user navigates to a match whose text no longer matches the query at that position, skip to the next valid match. This avoids re-scanning on every output event.
-
-### Match Highlighting
-
-- All matches: colored background rects rendered during terminal paint phase (same layer as selection rects in `terminal_render.rs`)
-- Active/current match: brighter highlight color to distinguish from other matches
-- Only matches within the current viewport need rects computed — filter by visible row range during render
-
-### Navigation
-
-- Enter or Down arrow: jump to next match
-- Shift+Enter or Up arrow: jump to previous match
-- Jumping scrolls the viewport to center the match on screen
-- Match index wraps around (last match → first match)
-
-### Scrollbar Match Markers
-
-- While search is active, render small horizontal ticks on the scrollbar track
-- Each tick: 2px tall, full scrollbar width, positioned at proportional Y for the match's grid line
-- Uses the search highlight color
-- Only visible while search overlay is open
-- Marker Y position formula: `marker_y_fraction = (history_size + grid_line) / (history_size + screen_lines)` — this maps absolute grid line coordinates to the same proportional space used by the scrollbar thumb
-
-### Search State
-
-```rust
-struct SearchState {
- /// Whether the search overlay is open.
- active: bool,
- /// Current search query.
- query: String,
- /// All matches found in the terminal grid.
- matches: Vec,
- /// Index of the currently focused match (for navigation).
- current_match: Option,
-}
-```
-
-## File Organization
-
-### New Files
-
-| File | Purpose |
-|------|---------|
-| `crates/codirigent-ui/src/workspace/scrollbar_render.rs` | Scrollbar rendering helper called from within the session cell render path in `grid_render.rs`; not a standalone workspace component |
-| `crates/codirigent-ui/src/workspace/search_render.rs` | Search overlay: text input, match count, prev/next buttons |
-| `crates/codirigent-ui/src/terminal_search.rs` | Search engine: grid scanning, match collection, result types |
-
-### Modified Files
-
-| File | Change |
-|------|--------|
-| `terminal_view.rs` | Add `ScrollbarState`, `SearchState` fields; expose `total_scrollback_lines()` |
-| `grid_render.rs` | Compose scrollbar and search overlay into terminal pane div; wire Cmd+F |
-| `terminal_render.rs` | Render search match highlight rects during paint phase |
-| `app.rs` | Define `SearchTerminal` action struct and register keybinding (Cmd+F / Ctrl+F), alongside existing actions like `Copy`, `Paste` |
-| `workspace/mod.rs` | Declare new modules |
-
-### Modified (minimal)
-
-- `terminal_runtime.rs` — add `history_size` to snapshot, add `scroll_to_offset()` and `search()` methods on handle
-
-### Unchanged
-
-- `terminal.rs` — alacritty wrapper stays untouched
-- Session management, persistence, layout systems
-- Existing mouse scroll and text selection behavior (selection continues to work normally beneath the search overlay; search matches are purely visual and do not interact with the selection system)
diff --git a/docs/task-verification-workflow.md b/docs/workflows/task-verification-workflow.md
similarity index 100%
rename from docs/task-verification-workflow.md
rename to docs/workflows/task-verification-workflow.md
diff --git a/scripts/sign-and-upload.ps1 b/scripts/sign-and-upload.ps1
new file mode 100644
index 00000000..ac10c555
--- /dev/null
+++ b/scripts/sign-and-upload.ps1
@@ -0,0 +1,77 @@
+# Usage: .\scripts\sign-and-upload.ps1 -Tag v0.1.3-alpha3
+param(
+ [Parameter(Mandatory)]
+ [string]$Tag
+)
+
+$ErrorActionPreference = "Stop"
+
+$archiveName = "codirigent-$Tag-x86_64-pc-windows-msvc"
+$msiName = "$archiveName.msi"
+$tmpDir = Join-Path $env:TEMP "codirigent-sign-$Tag"
+
+New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null
+
+# ── 1. Download unsigned MSI from the release ─────────────────────────────────
+Write-Host "Downloading $msiName from release $Tag..."
+gh release download $Tag --pattern $msiName --output "$tmpDir\$msiName" --clobber
+
+# ── 2. Find signtool ──────────────────────────────────────────────────────────
+$signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" |
+ Sort-Object FullName -Descending | Select-Object -First 1).FullName
+
+if (-not $signtool) {
+ Write-Error "signtool.exe not found. Install Windows SDK."
+}
+
+# ── 3. Find the Certum certificate ───────────────────────────────────────────
+$cert = Get-ChildItem Cert:\CurrentUser\My |
+ Where-Object { $_.Issuer -like "*Certum*" -and $_.EnhancedKeyUsageList.FriendlyName -contains "Code Signing" } |
+ Select-Object -First 1
+
+if (-not $cert) {
+ Write-Error "Certum code signing certificate not found in CurrentUser\My store."
+}
+
+Write-Host "Using certificate: $($cert.Subject)"
+
+# ── 4. Sign the MSI ───────────────────────────────────────────────────────────
+Write-Host "Signing $msiName..."
+& $signtool sign `
+ /sha1 $cert.Thumbprint `
+ /fd sha256 `
+ /tr http://time.certum.pl `
+ /td sha256 `
+ /v "$tmpDir\$msiName"
+
+if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+# ── 5. Verify signature ───────────────────────────────────────────────────────
+Write-Host "Verifying signature..."
+& $signtool verify /pa /v "$tmpDir\$msiName"
+if ($LASTEXITCODE -ne 0) { Write-Warning "Signature verification failed!" }
+
+# ── 6. Compute new checksum ───────────────────────────────────────────────────
+Write-Host "Computing checksum..."
+$hash = (Get-FileHash "$tmpDir\$msiName" -Algorithm SHA256).Hash.ToLower()
+$checksumLine = "$hash $msiName"
+Write-Host "Checksum: $checksumLine"
+
+# ── 7. Download existing checksums-sha256.txt and append MSI checksum ─────────
+$checksumFile = "$tmpDir\checksums-sha256.txt"
+gh release download $Tag --pattern "checksums-sha256.txt" --output $checksumFile --clobber
+
+# Remove any stale MSI line (in case of re-run) then append new one
+$lines = Get-Content $checksumFile | Where-Object { $_ -notmatch '\.msi' }
+$lines + $checksumLine | Set-Content $checksumFile
+
+Write-Host "`nUpdated checksums-sha256.txt:"
+Get-Content $checksumFile
+
+# ── 8. Upload signed MSI and updated checksums ────────────────────────────────
+Write-Host "`nUploading signed MSI and updated checksums to release $Tag..."
+gh release upload $Tag "$tmpDir\$msiName" --clobber
+gh release upload $Tag $checksumFile --clobber
+
+Write-Host "`nDone! Signed MSI and checksums uploaded to release $Tag."
+Write-Host "Temp files at: $tmpDir"
From 418a5d82e0133de6ac364acef4db403a6d2699ed Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Sun, 22 Mar 2026 16:24:03 -0500
Subject: [PATCH 13/33] chore: set release to draft mode and update README
- Release workflow now creates draft releases for manual review
- Remove author attribution from README
- Update SmartScreen warning to reflect code-signed MSI
---
.github/workflows/release.yml | 2 +-
README.md | 4 +---
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 536da9b1..ce268ade 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -276,7 +276,7 @@ jobs:
with:
artifacts: "artifacts/*"
token: ${{ secrets.GITHUB_TOKEN }}
- draft: false
+ draft: true
generateReleaseNotes: true
prerelease: ${{ contains(github.ref, 'alpha') || contains(github.ref, 'beta') || contains(github.ref, 'rc') }}
diff --git a/README.md b/README.md
index 34ff739b..8b65bd02 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,7 @@ Codirigent is a Tmux-style workspace built for this workflow. Open it once, and
Download the `.msi` installer from the [latest release](https://github.com/oso95/Codirigent/releases/latest).
-> **SmartScreen warning:** Windows may show "Windows protected your PC" since the app is not yet code-signed. Click **More info → Run anyway** to proceed.
+> **SmartScreen warning:** Windows may show "Windows protected your PC" on first install. The MSI is code-signed but SmartScreen requires reputation history. Click **More info → Run anyway** to proceed.
### macOS
@@ -129,5 +129,3 @@ Open an issue before major changes. PRs welcome.
## License
GPL-3.0 — see [LICENSE](LICENSE).
-
-Authored by ChengYuanWang
From 965cf571535f849728950371e47074f853355f33 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Tue, 24 Mar 2026 12:55:08 -0500
Subject: [PATCH 14/33] fix: avoid GPUI RefCell reentrancy crash on Windows
window drag
On Windows, GPUI 0.2.x's WindowControlArea::Drag causes WM_NCHITTEST
to return HTCAPTION, which makes Windows enter a modal drag loop inside
DefWindowProc. This re-enters the message pump while GPUI still holds
RefCell borrows, causing a panic/freeze (issue #31).
Fix: on Windows, do NOT use WindowControlArea::Drag at all. Instead,
post WM_NCLBUTTONDOWN(HTCAPTION) asynchronously via PostMessageW from
an on_mouse_down handler, so the modal drag loop starts on the next
message-pump iteration after GPUI's borrows are released.
Also moves logo/title into a dedicated drag_region child div (cleaner
separation from caption buttons) and adds double-click-to-maximize
support on both platforms.
Closes #31
---
crates/codirigent-ui/src/platform_drag.rs | 11 ++-
crates/codirigent-ui/src/workspace/render.rs | 79 ++++++++++++++------
2 files changed, 64 insertions(+), 26 deletions(-)
diff --git a/crates/codirigent-ui/src/platform_drag.rs b/crates/codirigent-ui/src/platform_drag.rs
index 4aa9eb06..ff81a90b 100644
--- a/crates/codirigent-ui/src/platform_drag.rs
+++ b/crates/codirigent-ui/src/platform_drag.rs
@@ -2,9 +2,14 @@
//!
//! On Windows, GPUI 0.2.x has a timing issue where `WindowControlArea::Drag`
//! doesn't reliably initiate window moves (stale `mouse_hit_test` in
-//! WM_NCHITTEST). This module provides a direct Win32 workaround that sends
-//! `WM_NCLBUTTONDOWN` with `HTCAPTION` to the window, telling Windows to
-//! start a native title-bar drag.
+//! WM_NCHITTEST). Worse, when WM_NCHITTEST *does* return HTCAPTION, Windows
+//! enters a modal drag loop inside `DefWindowProc` that re-enters the message
+//! pump while GPUI still holds `RefCell` borrows — causing a panic / freeze.
+//!
+//! This module provides a direct Win32 workaround: on mouse-down we post
+//! `WM_NCLBUTTONDOWN(HTCAPTION)` **asynchronously** via `PostMessageW`, so the
+//! modal drag loop begins on the *next* message-pump iteration, after GPUI's
+//! borrows are released.
//!
//! Remove this module after upgrading GPUI to a version that fixes the issue.
diff --git a/crates/codirigent-ui/src/workspace/render.rs b/crates/codirigent-ui/src/workspace/render.rs
index cb96ad32..e3d58e8f 100644
--- a/crates/codirigent-ui/src/workspace/render.rs
+++ b/crates/codirigent-ui/src/workspace/render.rs
@@ -28,8 +28,8 @@ use tracing::info;
impl WorkspaceView {
/// Render the title bar with window controls (minimize, maximize, close).
///
- /// This is a 32px bar with the logo on the left and native window controls
- /// on the right. The entire bar is a drag region for moving the window.
+ /// This is a 32px bar with a dedicated drag region on the left and native
+ /// window controls on the right.
pub(super) fn render_title_bar(
&mut self,
window: &mut Window,
@@ -40,8 +40,7 @@ impl WorkspaceView {
let border_color: gpui::Hsla = theme.border.into();
let fg: gpui::Hsla = theme.foreground.into();
- // The entire bar is a drag region. Caption buttons use .occlude() +
- // their own WindowControlArea to carve out non-drag zones.
+ // The bar hosts a dedicated drag region plus caption buttons.
let mut bar = div()
.id("title-bar")
.h(px(self.title_bar.height()))
@@ -52,8 +51,7 @@ impl WorkspaceView {
.flex()
.items_center()
.px_3()
- .gap_2()
- .window_control_area(WindowControlArea::Drag);
+ .gap_2();
// macOS: Native traffic lights are rendered by the OS.
// Reserve left padding so content doesn't overlap them, and handle
@@ -67,17 +65,33 @@ impl WorkspaceView {
} else {
bar.pl(px(TRAFFIC_LIGHT_PADDING))
};
+ }
- bar = bar.on_click(|event: &ClickEvent, window, _cx| {
- if event.click_count() == 2 {
- window.titlebar_double_click();
- }
- });
+ // Drag region: how the user moves the window by clicking the title bar.
+ //
+ // macOS: Use GPUI's `WindowControlArea::Drag` — it returns HTCAPTION via
+ // the native hit-test and the OS handles drag + double-click-to-zoom.
+ //
+ // Windows: Do NOT use `WindowControlArea::Drag`. GPUI 0.2.x has a timing
+ // issue where WM_NCHITTEST returns HTCAPTION while GPUI still holds
+ // `RefCell` borrows. Windows then enters a modal drag loop inside
+ // `DefWindowProc` that re-enters the message pump — panic / freeze.
+ // Instead, we post `WM_NCLBUTTONDOWN(HTCAPTION)` asynchronously from
+ // an `on_mouse_down` handler so the modal loop starts *after* GPUI's
+ // borrows are released. See `platform_drag.rs`.
+ let mut drag_region = div().flex().items_center().gap_2().flex_1().h_full();
+
+ #[cfg(target_os = "macos")]
+ {
+ drag_region = drag_region
+ .window_control_area(WindowControlArea::Drag)
+ .on_mouse_down(MouseButton::Left, |event: &MouseDownEvent, window, _cx| {
+ if event.click_count == 2 {
+ window.titlebar_double_click();
+ }
+ });
}
- // Windows: GPUI 0.2.1 has a stale mouse_hit_test issue in WM_NCHITTEST,
- // so WindowControlArea::Drag alone doesn't reliably initiate drags.
- // Work around by sending WM_NCLBUTTONDOWN(HTCAPTION) on mouse-down.
#[cfg(target_os = "windows")]
{
use raw_window_handle::HasWindowHandle;
@@ -89,15 +103,23 @@ impl WorkspaceView {
_ => 0,
});
if let Some(hwnd) = raw_handle {
- bar = bar.on_mouse_down(gpui::MouseButton::Left, move |_event, _window, _cx| {
- crate::platform_drag::begin_title_bar_drag(hwnd);
- });
+ drag_region = drag_region.on_mouse_down(
+ MouseButton::Left,
+ move |event: &MouseDownEvent, window, _cx| {
+ if event.click_count == 2 {
+ window.titlebar_double_click();
+ } else {
+ crate::platform_drag::begin_title_bar_drag(hwnd);
+ }
+ },
+ );
}
}
// Logo (3x3 grid matching logo-primary-dark.svg)
- bar = bar.child(div().flex_shrink_0().ml_2().child(self.render_logo_small()));
- bar = bar.child(
+ drag_region =
+ drag_region.child(div().flex_shrink_0().ml_2().child(self.render_logo_small()));
+ drag_region = drag_region.child(
div()
.text_sm()
.font_weight(FontWeight::BOLD)
@@ -106,8 +128,9 @@ impl WorkspaceView {
.child(TitleBar::LOGO_TEXT),
);
- // Spacer — fills remaining space so window controls stay on the right
- bar = bar.child(div().flex_1());
+ // Spacer - fills remaining space so window controls stay on the right.
+ drag_region = drag_region.child(div().flex_1());
+ bar = bar.child(drag_region);
// Window controls (Windows/Linux)
// Uses native Segoe icon fonts and WindowControlArea for OS-level handling.
@@ -332,6 +355,13 @@ impl WorkspaceView {
this.close_session_menu(cx);
cx.stop_propagation();
}),
+ )
+ .on_mouse_down(
+ MouseButton::Right,
+ cx.listener(|this, _: &MouseDownEvent, _window, cx| {
+ this.close_session_menu(cx);
+ cx.stop_propagation();
+ }),
);
// Build dropdown menu
@@ -463,8 +493,11 @@ impl WorkspaceView {
cx,
));
- // Position dropdown to the right of the drawer, aligned with the row
- let left_offset = crate::icon_rail::IconRail::WIDTH + self.drawer.width() - 8.0;
+ // Position dropdown: at click position (tab right-click) or next to the drawer (default).
+ let left_offset = self
+ .selection
+ .session_menu_anchor_x
+ .unwrap_or_else(|| crate::icon_rail::IconRail::WIDTH + self.drawer.width() - 8.0);
Some(
div()
From f82c96435223ca69780a61a8da16da84f3091261 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Tue, 24 Mar 2026 12:59:39 -0500
Subject: [PATCH 15/33] feat: add right-click context menu on session tabs
Reuses the existing session dropdown menu (rename, groups, end session)
when right-clicking a tab, positioned at the cursor location.
---
.../codirigent-ui/src/workspace/drawer_render.rs | 7 ++++++-
.../src/workspace/impl_ui_operations.rs | 6 ++++++
.../src/workspace/pane_header_render.rs | 14 +++++++++++++-
crates/codirigent-ui/src/workspace/types.rs | 5 +++++
4 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/crates/codirigent-ui/src/workspace/drawer_render.rs b/crates/codirigent-ui/src/workspace/drawer_render.rs
index 17e1e43d..a9207bd9 100644
--- a/crates/codirigent-ui/src/workspace/drawer_render.rs
+++ b/crates/codirigent-ui/src/workspace/drawer_render.rs
@@ -1300,7 +1300,12 @@ impl WorkspaceView {
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
- this.open_session_menu(session_id, Some(event.position.y.into()), cx);
+ this.open_session_menu(
+ session_id,
+ Some(event.position.y.into()),
+ None,
+ cx,
+ );
cx.stop_propagation();
}),
)
diff --git a/crates/codirigent-ui/src/workspace/impl_ui_operations.rs b/crates/codirigent-ui/src/workspace/impl_ui_operations.rs
index 39ad2498..0d8c558a 100644
--- a/crates/codirigent-ui/src/workspace/impl_ui_operations.rs
+++ b/crates/codirigent-ui/src/workspace/impl_ui_operations.rs
@@ -100,15 +100,20 @@ impl WorkspaceView {
}
/// Open the session context menu for a specific session.
+ ///
+ /// When `anchor_x` is `Some`, the menu is positioned at that X coordinate
+ /// (e.g. for tab right-click). When `None`, default drawer-relative positioning is used.
pub fn open_session_menu(
&mut self,
session_id: SessionId,
anchor_y: Option,
+ anchor_x: Option,
cx: &mut Context,
) {
info!(?session_id, "Opening session menu");
self.selection.session_menu_open = Some(session_id);
self.selection.session_menu_anchor_y = anchor_y;
+ self.selection.session_menu_anchor_x = anchor_x;
cx.notify();
}
@@ -117,6 +122,7 @@ impl WorkspaceView {
info!("Closing session menu");
self.selection.session_menu_open = None;
self.selection.session_menu_anchor_y = None;
+ self.selection.session_menu_anchor_x = None;
cx.notify();
}
diff --git a/crates/codirigent-ui/src/workspace/pane_header_render.rs b/crates/codirigent-ui/src/workspace/pane_header_render.rs
index 2b9de338..276aff73 100644
--- a/crates/codirigent-ui/src/workspace/pane_header_render.rs
+++ b/crates/codirigent-ui/src/workspace/pane_header_render.rs
@@ -239,7 +239,19 @@ impl WorkspaceView {
cx.notify();
}
}
- }));
+ }))
+ .on_mouse_down(
+ MouseButton::Right,
+ cx.listener(move |this, event: &MouseDownEvent, _window, cx| {
+ this.open_session_menu(
+ tab_session_id,
+ Some(event.position.y.into()),
+ Some(event.position.x.into()),
+ cx,
+ );
+ cx.stop_propagation();
+ }),
+ );
// Apply glow border if present
if let Some(glow_border) = decoration.tab_border {
diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs
index 07059b73..c9497764 100644
--- a/crates/codirigent-ui/src/workspace/types.rs
+++ b/crates/codirigent-ui/src/workspace/types.rs
@@ -317,6 +317,10 @@ pub(super) struct SelectionState {
pub session_menu_open: Option,
/// Vertical anchor position for the session menu overlay, in window pixels.
pub session_menu_anchor_y: Option,
+ /// Horizontal anchor position for the session menu overlay, in window pixels.
+ /// When `Some`, the menu is positioned at this X coordinate (e.g. tab right-click).
+ /// When `None`, the menu uses the default drawer-relative positioning.
+ pub session_menu_anchor_x: Option,
/// Whether the user is actively dragging a text selection in a terminal.
pub is_selecting: bool,
/// Session ID that is currently being selected in (for mouse move events).
@@ -456,6 +460,7 @@ impl SelectionState {
selected_session_id: None,
session_menu_open: None,
session_menu_anchor_y: None,
+ session_menu_anchor_x: None,
is_selecting: false,
selecting_session_id: None,
file_tree_context_menu: None,
From 825cf8d0415785350aefb14a8b35a4a77483b24a Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Tue, 24 Mar 2026 18:03:29 -0500
Subject: [PATCH 16/33] chore: remove dead session_uuid field from
RestoreSessionPlan
The field was intentionally kept unused after the UUID overwrite fix
(8d746d3) but never cleaned up. Removing it along with the stale
NOTE comment and all construction sites.
---
.../src/workspace/impl_session_lifecycle.rs | 11 -----------
1 file changed, 11 deletions(-)
diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
index 7c0039b5..05bd2ff2 100644
--- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
+++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
@@ -34,7 +34,6 @@ use tracing::{info, warn};
#[derive(Debug, Clone)]
struct RestoreSessionPlan {
original_session_id: SessionId,
- session_uuid: String,
session_name: String,
working_dir: PathBuf,
shell: Option,
@@ -656,7 +655,6 @@ mod tests {
fn restore_resume_commands_preserve_cli_order() {
let plan = RestoreSessionPlan {
original_session_id: SessionId(1),
- session_uuid: "session-uuid-1".to_string(),
session_name: "Session 1".to_string(),
working_dir: sample_working_dir(),
shell: None,
@@ -965,7 +963,6 @@ mod tests {
fn restore_plan_cli_type_prefers_known_resume_metadata() {
let base = RestoreSessionPlan {
original_session_id: SessionId(1),
- session_uuid: "session-uuid-1".to_string(),
session_name: "Session 1".to_string(),
working_dir: PathBuf::from("/tmp"),
shell: None,
@@ -997,7 +994,6 @@ mod tests {
fn restore_resume_commands_empty_for_plan_with_no_cli_fields() {
let plan = RestoreSessionPlan {
original_session_id: SessionId(1),
- session_uuid: "uuid".to_string(),
session_name: "Session 1".to_string(),
working_dir: sample_working_dir(),
shell: None,
@@ -1449,12 +1445,6 @@ impl WorkspaceView {
}
}
- // NOTE: Do NOT overwrite session.session_uuid with plan.session_uuid here.
- // The PTY was already spawned with the UUID from Session::new() as the
- // CODIRIGENT_SESSION_UUID env var. Overwriting with the saved UUID from a
- // previous app instance creates a mismatch: hook signals carry the PTY's
- // UUID but the workspace session has the old one, causing routing to fail
- // and status to stay stuck on Idle.
let mut session = bootstrapped.session;
session.shell = bootstrapped.request.requested_shell.clone();
session.group = plan.group.clone();
@@ -1648,7 +1638,6 @@ impl WorkspaceView {
sessions.push(RestoreSessionPlan {
original_session_id: saved.id,
- session_uuid: saved.session_uuid.clone(),
session_name,
working_dir,
shell: saved.shell,
From 188de7cd32c5905ebcb3fc710c795d51c401edc0 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Tue, 24 Mar 2026 18:03:38 -0500
Subject: [PATCH 17/33] feat: add cursor navigation to session rename/group
modal
The rename and group assignment modals used append-only text input
with no cursor positioning. Add cursor_position tracking to
SessionActionModal, reusing the existing cursor helper methods from
the task creation modal.
Supports: left/right arrows, Home/End, Delete, backspace and insert
at cursor position, and Ctrl+V paste.
---
.../src/workspace/impl_modals.rs | 59 ++++++++++++++++---
.../src/workspace/modal_render.rs | 17 ++++--
crates/codirigent-ui/src/workspace/types.rs | 2 +
3 files changed, 66 insertions(+), 12 deletions(-)
diff --git a/crates/codirigent-ui/src/workspace/impl_modals.rs b/crates/codirigent-ui/src/workspace/impl_modals.rs
index fdf6826e..c6309e7c 100644
--- a/crates/codirigent-ui/src/workspace/impl_modals.rs
+++ b/crates/codirigent-ui/src/workspace/impl_modals.rs
@@ -34,10 +34,12 @@ impl WorkspaceView {
.unwrap_or_default(),
};
+ let cursor_position = input.chars().count();
self.modals.session_action = Some(SessionActionModal {
session_id,
kind,
input,
+ cursor_position,
error: None,
});
}
@@ -326,6 +328,7 @@ impl WorkspaceView {
let Some(modal) = self.modals.session_action.as_mut() else {
return false;
};
+ modal.cursor_position = modal.cursor_position.min(Self::char_count(&modal.input));
let key = event.keystroke.key.to_lowercase();
match key.as_str() {
@@ -339,13 +342,41 @@ impl WorkspaceView {
return true;
}
"backspace" => {
- modal.input.pop();
+ Self::backspace_at_cursor(&mut modal.input, &mut modal.cursor_position);
+ modal.error = None;
+ cx.notify();
+ return true;
+ }
+ "delete" => {
+ Self::delete_at_cursor(&mut modal.input, &mut modal.cursor_position);
+ modal.error = None;
+ cx.notify();
+ return true;
+ }
+ "left" | "arrowleft" => {
+ Self::move_cursor_left(&modal.input, &mut modal.cursor_position);
+ cx.notify();
+ return true;
+ }
+ "right" | "arrowright" => {
+ Self::move_cursor_right(&modal.input, &mut modal.cursor_position);
+ cx.notify();
+ return true;
+ }
+ "home" => {
+ Self::move_cursor_home(&mut modal.cursor_position);
+ cx.notify();
+ return true;
+ }
+ "end" => {
+ Self::move_cursor_end(&modal.input, &mut modal.cursor_position);
cx.notify();
return true;
}
"space" => {
// GPUI on Windows reports space as key="space" with key_char=None
- modal.input.push(' ');
+ Self::insert_at_cursor(&mut modal.input, &mut modal.cursor_position, " ");
+ modal.error = None;
cx.notify();
return true;
}
@@ -355,10 +386,25 @@ impl WorkspaceView {
// Ctrl+A selects all (clears input for easy replacement)
if (event.keystroke.modifiers.control || event.keystroke.modifiers.platform) && key == "a" {
modal.input.clear();
+ modal.cursor_position = 0;
cx.notify();
return true;
}
+ // Ctrl+V / Cmd+V — paste from system clipboard
+ if (event.keystroke.modifiers.control || event.keystroke.modifiers.platform) && key == "v" {
+ if let Ok(codirigent_core::ClipboardContent::Text(text)) =
+ self.clipboard.smart_clipboard.read_content()
+ {
+ if let Some(modal) = self.modals.session_action.as_mut() {
+ Self::insert_at_cursor(&mut modal.input, &mut modal.cursor_position, &text);
+ modal.error = None;
+ cx.notify();
+ }
+ }
+ return true;
+ }
+
// Ignore other modifier-based shortcuts inside the modal.
if event.keystroke.modifiers.control
|| event.keystroke.modifiers.alt
@@ -368,11 +414,10 @@ impl WorkspaceView {
}
if let Some(ref key_char) = event.keystroke.key_char {
- if let Some(ch) = key_char.chars().next() {
- if ch.is_ascii_graphic() || ch == ' ' {
- modal.input.push(ch);
- cx.notify();
- }
+ if !key_char.is_empty() {
+ Self::insert_at_cursor(&mut modal.input, &mut modal.cursor_position, key_char);
+ modal.error = None;
+ cx.notify();
}
}
diff --git a/crates/codirigent-ui/src/workspace/modal_render.rs b/crates/codirigent-ui/src/workspace/modal_render.rs
index 3660d050..768a3805 100644
--- a/crates/codirigent-ui/src/workspace/modal_render.rs
+++ b/crates/codirigent-ui/src/workspace/modal_render.rs
@@ -761,11 +761,18 @@ impl WorkspaceView {
};
let input_value = if self.modals.cursor_blink_on {
- if modal.input.is_empty() {
- "|".to_string()
- } else {
- format!("{}|", modal.input)
- }
+ let cursor = modal.cursor_position.min(modal.input.chars().count());
+ let cursor_byte = modal
+ .input
+ .char_indices()
+ .nth(cursor)
+ .map(|(i, _)| i)
+ .unwrap_or(modal.input.len());
+ let mut out = String::with_capacity(modal.input.len() + 1);
+ out.push_str(&modal.input[..cursor_byte]);
+ out.push('|');
+ out.push_str(&modal.input[cursor_byte..]);
+ out
} else {
modal.input.clone()
};
diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs
index c9497764..2bc5550c 100644
--- a/crates/codirigent-ui/src/workspace/types.rs
+++ b/crates/codirigent-ui/src/workspace/types.rs
@@ -196,6 +196,8 @@ pub(super) struct SessionActionModal {
pub(super) kind: SessionActionKind,
/// User input value.
pub(super) input: String,
+ /// Cursor position (char index) within `input`.
+ pub(super) cursor_position: usize,
/// Optional error message if validation fails.
pub(super) error: Option,
}
From e919e5df36c4c2f5f14984593278ef738941a4ab Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Tue, 24 Mar 2026 23:36:28 -0500
Subject: [PATCH 18/33] Update .gitignore
---
.gitignore | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 9552362b..5828ef7d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ CLAUDE.md
plans/
docs/superpowers/
docs/specs/
+Tasks.md
# Superpowers skill state
.superpowers/
@@ -143,4 +144,4 @@ coverage/
.worktrees/
worktrees/
vibehq-hub/
-.docx
\ No newline at end of file
+.docx
From c3809403e1e3762ca7273aee6bd0c5790b8be114 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Tue, 24 Mar 2026 23:51:18 -0500
Subject: [PATCH 19/33] docs: add release workflow documentation
Covers the full release process: tagging, CI draft creation, local
MSI code signing with SimplySign/Certum, checksum updates, and
publishing. Includes troubleshooting and recovery steps.
---
docs/workflows/release-workflow.md | 196 +++++++++++++++++++++++++++++
1 file changed, 196 insertions(+)
create mode 100644 docs/workflows/release-workflow.md
diff --git a/docs/workflows/release-workflow.md b/docs/workflows/release-workflow.md
new file mode 100644
index 00000000..7b597e2f
--- /dev/null
+++ b/docs/workflows/release-workflow.md
@@ -0,0 +1,196 @@
+# Release Workflow
+
+Step-by-step procedure for creating a new Codirigent release with
+code-signed installers.
+
+---
+
+## Overview
+
+Releases use a two-phase process:
+
+1. **CI phase** — Push a git tag. GitHub Actions builds binaries, creates
+ unsigned installers (MSI + DMG), and publishes a **draft** release.
+2. **Local phase** — Sign the Windows MSI locally with the Certum
+ certificate via SimplySign, update checksums, upload, then publish.
+
+macOS DMG signing and notarization happen automatically in CI (Apple
+Developer certificate is stored in GitHub Secrets).
+
+---
+
+## Prerequisites
+
+- GitHub CLI (`gh`) authenticated
+- SimplySign Desktop running and authenticated (provides the Certum
+ code-signing certificate to the Windows certificate store)
+- Windows SDK installed (provides `signtool.exe`)
+- All changes committed and pushed to `main`
+
+---
+
+## Step 1: Create and Push the Tag
+
+```bash
+git tag -a v0.1.X -m "v0.1.X"
+git push origin v0.1.X
+```
+
+This triggers the `Release` workflow (`.github/workflows/release.yml`),
+which:
+
+- Builds release binaries for Windows x64 and macOS ARM64
+- Packages Windows `.zip` and `.msi` (unsigned)
+- Packages macOS `.tar.gz` and `.dmg` (signed + notarized in CI)
+- Generates `checksums-sha256.txt` (excludes `.msi` — it will be replaced)
+- Creates a **draft** GitHub Release with all artifacts attached
+
+### Monitor the workflow
+
+```bash
+gh run list --limit 3
+gh run watch
+```
+
+Wait for the workflow to complete successfully before proceeding.
+
+---
+
+## Step 2: Sign the Windows MSI
+
+Run the signing script from the repo root:
+
+```bash
+powershell -File scripts/sign-and-upload.ps1 -Tag v0.1.X
+```
+
+The script performs these steps automatically:
+
+1. Downloads the unsigned MSI from the draft release
+2. Locates `signtool.exe` from the Windows SDK
+3. Finds the Certum code-signing certificate (OID `1.3.6.1.5.5.7.3.3`)
+ in `Cert:\CurrentUser\My` (provided by SimplySign)
+4. Signs the MSI with SHA-256 and timestamps via `http://time.certum.pl`
+5. Verifies the signature
+6. Computes the SHA-256 checksum of the signed MSI
+7. Downloads `checksums-sha256.txt`, replaces the MSI line, re-uploads
+8. Uploads the signed MSI (overwrites the unsigned one)
+
+### Troubleshooting
+
+| Error | Cause | Fix |
+|-------|-------|-----|
+| "Certum code signing certificate not found" | SimplySign not running or not authenticated | Launch SimplySign Desktop and sign in |
+| "signtool.exe not found" | Windows SDK not installed | Install Windows 10/11 SDK |
+| "Failed to send Windows toast notification" | SimplySign session expired | Re-authenticate in SimplySign |
+
+---
+
+## Step 3: Verify the Draft Release
+
+Check the draft release before publishing:
+
+```bash
+gh release view v0.1.X
+```
+
+Confirm:
+
+- [ ] All expected assets are present (`.msi`, `.dmg`, `.zip`, `.tar.gz`,
+ `.wixpdb`, `checksums-sha256.txt`)
+- [ ] The MSI checksum in `checksums-sha256.txt` matches the signed file
+- [ ] The release is still in **draft** status
+
+---
+
+## Step 4: Edit Release Notes
+
+Update the draft release body with the changelog. Follow the format from
+previous releases:
+
+```
+## What's New
+
+### Feature Name
+- Description of changes
+
+---
+
+## Bug Fixes
+- Fix description (closes #N)
+
+---
+
+**Full Changelog**: https://github.com/oso95/Codirigent/compare/vPREV...vCURR
+```
+
+You can edit via the GitHub web UI or:
+
+```bash
+gh release edit v0.1.X --notes "$(cat release-notes.md)"
+```
+
+---
+
+## Step 5: Publish
+
+Once everything is verified, publish the draft:
+
+```bash
+gh release edit v0.1.X --draft=false
+```
+
+Or use the "Publish release" button in the GitHub web UI.
+
+---
+
+## Pre-release Tags
+
+Tags containing `alpha`, `beta`, or `rc` are automatically marked as
+pre-releases by the CI workflow. Use semantic naming:
+
+```
+v0.1.X-alpha.1 # early testing
+v0.1.X-beta.1 # feature complete, testing
+v0.1.X-rc.1 # release candidate
+v0.1.X # stable release
+```
+
+---
+
+## Recovery
+
+### If the workflow fails
+
+```bash
+# Delete the failed release (if created)
+gh release delete v0.1.X --yes
+
+# Delete the tag
+git tag -d v0.1.X
+git push origin :refs/tags/v0.1.X
+
+# Fix the issue, then re-tag and push
+git tag -a v0.1.X -m "v0.1.X"
+git push origin v0.1.X
+```
+
+### If you need to re-sign the MSI
+
+Just re-run the signing script — it uses `--clobber` to overwrite:
+
+```bash
+powershell -File scripts/sign-and-upload.ps1 -Tag v0.1.X
+```
+
+---
+
+## File Reference
+
+| File | Purpose |
+|------|---------|
+| `.github/workflows/release.yml` | CI workflow (build, package, draft release) |
+| `scripts/sign-and-upload.ps1` | Local MSI signing and upload |
+| `scripts/gen-license-rtf.ps1` | Generate `License.rtf` for WiX (used by CI) |
+| `wix/main.wxs` | WiX installer definition |
+| `tools/wix/` | Local WiX toolset (candle, light) |
From 4813586f2a118b51385faf079160b11195be091b Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Thu, 26 Mar 2026 08:07:46 -0500
Subject: [PATCH 20/33] fix: pass real cursor coordinates in PostMessageW drag
workaround
PostMessageW(WM_NCLBUTTONDOWN, HTCAPTION, 0) passed lparam=0 meaning
cursor position (0,0). Some Windows builds silently ignore the message
when the position falls outside the window bounds. Now calls
GetCursorPos and packs screen coordinates into lparam.
---
crates/codirigent-ui/src/platform_drag.rs | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/crates/codirigent-ui/src/platform_drag.rs b/crates/codirigent-ui/src/platform_drag.rs
index ff81a90b..ef52caf4 100644
--- a/crates/codirigent-ui/src/platform_drag.rs
+++ b/crates/codirigent-ui/src/platform_drag.rs
@@ -20,6 +20,10 @@
/// `SendMessageW` (sync) to avoid reentrancy — `SendMessageW` starts a
/// modal drag loop that pumps messages while GPUI's `RefCell` is still
/// borrowed by the event callback, causing a panic.
+///
+/// The `lparam` carries the current cursor screen coordinates packed as
+/// `MAKELPARAM(x, y)`. Passing 0 caused some Windows builds to silently
+/// ignore the message when the (0,0) position fell outside the window.
#[cfg(target_os = "windows")]
pub fn begin_title_bar_drag(hwnd: isize) {
use std::ffi::c_int;
@@ -33,19 +37,31 @@ pub fn begin_title_bar_drag(hwnd: isize) {
#[allow(clippy::upper_case_acronyms)]
type BOOL = c_int;
+ #[repr(C)]
+ #[allow(clippy::upper_case_acronyms)]
+ struct POINT {
+ x: i32,
+ y: i32,
+ }
+
const WM_NCLBUTTONDOWN: u32 = 0x00A1;
const HTCAPTION: WPARAM = 2;
extern "system" {
fn ReleaseCapture() -> BOOL;
fn PostMessageW(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> BOOL;
+ fn GetCursorPos(point: *mut POINT) -> BOOL;
}
// Safety: hwnd is obtained from raw_window_handle and the window is alive
// during the mouse-down handler that calls this function.
unsafe {
+ let mut pt = POINT { x: 0, y: 0 };
+ GetCursorPos(&mut pt);
+ // Pack screen coordinates as MAKELPARAM(x, y) = (y << 16) | (x & 0xFFFF)
+ let lparam = (((pt.y & 0xFFFF) as LPARAM) << 16) | ((pt.x & 0xFFFF) as LPARAM);
ReleaseCapture();
- PostMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0);
+ PostMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, lparam);
}
}
From fb1c341f1e7429b5397950af37b8831292e254ff Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Thu, 26 Mar 2026 08:07:54 -0500
Subject: [PATCH 21/33] fix: use grid dimensions for scrollbar track height at
render time
canvas_metrics Rc> is only populated during prepaint, so
content_height is always 0 when render_terminal_scrollbar runs.
Use rows * cell_height as the render-time estimate. Mouse handlers
still prefer the real content_height from prepaint when available.
---
.../src/workspace/scrollbar_render.rs | 30 +++++++++++++++----
1 file changed, 24 insertions(+), 6 deletions(-)
diff --git a/crates/codirigent-ui/src/workspace/scrollbar_render.rs b/crates/codirigent-ui/src/workspace/scrollbar_render.rs
index 7a9271db..4ed26f03 100644
--- a/crates/codirigent-ui/src/workspace/scrollbar_render.rs
+++ b/crates/codirigent-ui/src/workspace/scrollbar_render.rs
@@ -20,7 +20,13 @@ impl WorkspaceView {
cx: &mut Context,
) -> Option {
let terminal_view = self.terminals.get(&session_id)?;
- let track_height = canvas_metrics.get().content_height;
+
+ // Approximate track height from terminal grid dimensions at render
+ // time. The Rc| > canvas_metrics is only populated during
+ // prepaint (after the element tree is built), so reading it here
+ // would always yield 0. Mouse handlers read the Rc lazily (after
+ // prepaint) and prefer the exact content_height when available.
+ let track_height = terminal_view.rows() as f32 * terminal_view.cell_height();
if track_height <= 0.0 {
return None;
}
@@ -74,13 +80,19 @@ impl WorkspaceView {
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, event: &MouseDownEvent, window, cx| {
- let origin_y = metrics_for_track.get().origin_y;
+ let metrics = metrics_for_track.get();
+ let effective_track = if metrics.content_height > 0.0 {
+ metrics.content_height
+ } else {
+ track_height
+ };
+ let origin_y = metrics.origin_y;
let pointer_y: f32 = event.position.y.into();
let relative_y = pointer_y - origin_y;
if let Some(terminal_view) = this.terminals.get_mut(&session_id) {
let target = terminal_view.scrollbar_offset_for_pointer(
relative_y,
- track_height,
+ effective_track,
None,
);
if target != terminal_view.display_offset() {
@@ -126,20 +138,26 @@ impl WorkspaceView {
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, event: &MouseDownEvent, window, cx| {
- let origin_y = metrics_for_thumb.get().origin_y;
+ let metrics = metrics_for_thumb.get();
+ let effective_track = if metrics.content_height > 0.0 {
+ metrics.content_height
+ } else {
+ track_height
+ };
+ let origin_y = metrics.origin_y;
let pointer_y: f32 = event.position.y.into();
let relative_y = pointer_y - origin_y;
if let Some(terminal_view) = this.terminals.get_mut(&session_id) {
let (_, current_thumb_top) =
- terminal_view.scrollbar_thumb_metrics(track_height);
+ terminal_view.scrollbar_thumb_metrics(effective_track);
let thumb_offset = (relative_y - current_thumb_top).max(0.0);
terminal_view.start_scrollbar_drag(thumb_offset);
this.selection.terminal_scrollbar_drag =
Some(super::types::TerminalScrollbarDragState {
session_id,
track_top: origin_y,
- track_height,
+ track_height: effective_track,
});
this.select_session_with_cx(session_id, cx);
window.focus(&this.focus_handle(cx));
From db13a5733e696ae427a2722ac9dc686c5d2c826f Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Thu, 26 Mar 2026 08:08:02 -0500
Subject: [PATCH 22/33] fix: cap scroll lines per event and tighten
snap-to-bottom threshold
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
High-resolution touchpad momentum scrolling produced delta_y values of
500+ pixels, translating to 30+ lines per event — causing page-sized
jumps. Cap to half the viewport height.
Also reduce snap-to-bottom threshold from rows+lines (~30 rows) to
3+lines so it only triggers when genuinely near the bottom, fixing the
infinite scroll feel.
---
.../src/workspace/grid_render.rs | 22 ++++++++++++-------
1 file changed, 14 insertions(+), 8 deletions(-)
diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs
index e2b7e256..2b838c70 100644
--- a/crates/codirigent-ui/src/workspace/grid_render.rs
+++ b/crates/codirigent-ui/src/workspace/grid_render.rs
@@ -316,18 +316,24 @@ impl WorkspaceView {
if let Some(tv) = this.terminals_mut().get_mut(&session_id) {
let cell_h: f32 = tv.cell_height();
let delta_y: f32 = event.delta.pixel_delta(px(cell_h)).y.into();
+ // Cap lines per event to avoid page-sized jumps from
+ // high-resolution touchpad momentum scrolling.
+ let max_lines: usize = tv.rows().max(1) as usize / 2;
+ let lines = (delta_y.abs() / cell_h)
+ .ceil()
+ .max(1.0)
+ .min(max_lines as f32)
+ as usize;
// Positive delta_y = scroll up = show older content (scrollback)
if delta_y > 0.0 {
- let lines = (delta_y / cell_h).ceil().max(1.0) as usize;
tv.scroll_up(lines);
} else if delta_y < 0.0 {
- let lines = (-delta_y / cell_h).ceil().max(1.0) as usize;
- // Snap to bottom when scrolling down within one viewport
- // of the live view. Without this, accidentally scrolling
- // up by even 1 line causes every new output line to push
- // the viewport further from the bottom, making it feel
- // like there is no bottom wall.
- if tv.display_offset() <= tv.rows() as usize + lines {
+ // Snap to bottom when scrolling down within a small
+ // margin of the live view. Without this, accidentally
+ // scrolling up by even 1 line causes every new output
+ // line to push the viewport further from the bottom.
+ let snap_margin = 3;
+ if tv.display_offset() <= snap_margin + lines {
tv.scroll_to_bottom();
} else {
tv.scroll_down(lines);
From 0607a3b65c8d3883ac9a7d86c53ef588433481f0 Mon Sep 17 00:00:00 2001
From: oso95
Date: Fri, 27 Mar 2026 22:32:28 -0500
Subject: [PATCH 23/33] fix: use window subclass to fix title bar drag and
top-edge resize on Windows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
GPUI 0.2.x re-dispatches WM_NCLBUTTONDOWN through its element tree before
DefWindowProc can process it. When the title bar's on_mouse_down handler
catches the event, DefWindowProc never runs — breaking both window drag
(PostMessageW loop) and top-edge resize (drag region overlaps resize zone).
Replace the direct PostMessageW(WM_NCLBUTTONDOWN) approach with a Win32
window subclass (SetWindowSubclass) that intercepts NC messages before
GPUI's WndProc:
- Drag: on_mouse_down posts a custom WM_APP message; the subclass catches
it and calls DefWindowProc(WM_NCLBUTTONDOWN, HTCAPTION) directly, starting
the OS drag loop when no GPUI borrows are held.
- Resize: WM_NCLBUTTONDOWN for resize edges (HTTOP, HTLEFT, …) routes
straight to DefWindowProc, bypassing GPUI's element dispatch.
- Buttons: WM_NCLBUTTONDOWN for HTMINBUTTON/HTMAXBUTTON/HTCLOSE passes
through to GPUI for normal handling.
Fixes window drag not working on some Windows machines and top-edge resize
being eaten by the title bar's mouse handler.
---
crates/codirigent-ui/src/platform_drag.rs | 233 +++++++++++++++----
crates/codirigent-ui/src/workspace/render.rs | 24 +-
2 files changed, 200 insertions(+), 57 deletions(-)
diff --git a/crates/codirigent-ui/src/platform_drag.rs b/crates/codirigent-ui/src/platform_drag.rs
index ef52caf4..b90f7d69 100644
--- a/crates/codirigent-ui/src/platform_drag.rs
+++ b/crates/codirigent-ui/src/platform_drag.rs
@@ -1,70 +1,203 @@
-//! Platform-specific window drag helpers.
+//! Platform-specific window drag and resize helpers.
//!
-//! On Windows, GPUI 0.2.x has a timing issue where `WindowControlArea::Drag`
-//! doesn't reliably initiate window moves (stale `mouse_hit_test` in
-//! WM_NCHITTEST). Worse, when WM_NCHITTEST *does* return HTCAPTION, Windows
-//! enters a modal drag loop inside `DefWindowProc` that re-enters the message
-//! pump while GPUI still holds `RefCell` borrows — causing a panic / freeze.
+//! On Windows, GPUI 0.2.x has two issues with custom-chrome windows:
//!
-//! This module provides a direct Win32 workaround: on mouse-down we post
-//! `WM_NCLBUTTONDOWN(HTCAPTION)` **asynchronously** via `PostMessageW`, so the
-//! modal drag loop begins on the *next* message-pump iteration, after GPUI's
-//! borrows are released.
+//! 1. **Drag freeze**: `WindowControlArea::Drag` causes GPUI's `WM_NCHITTEST`
+//! to return `HTCAPTION`. When Windows enters the modal drag loop via
+//! `DefWindowProc`, it re-enters the message pump while GPUI's `RefCell`
+//! borrows are still held — causing a panic / freeze.
//!
-//! Remove this module after upgrading GPUI to a version that fixes the issue.
+//! 2. **NC event interception**: GPUI's `handle_nc_mouse_down_msg` re-dispatches
+//! `WM_NCLBUTTONDOWN` as a regular `MouseDownEvent` through the element tree.
+//! If an element handles it (e.g., the title bar's `on_mouse_down`),
+//! `DefWindowProc` never runs — breaking both title-bar drag and top-edge
+//! resize (because the drag region overlaps the top resize zone).
+//!
+//! This module installs a Win32 window subclass that intercepts these messages
+//! **before** GPUI's WndProc:
+//!
+//! - **Drag**: The `on_mouse_down` handler posts a custom `WM_APP` message.
+//! The subclass catches it and calls `DefWindowProc(WM_NCLBUTTONDOWN,
+//! HTCAPTION)` directly, starting the OS drag loop when no GPUI borrows
+//! are held.
+//!
+//! - **Resize**: `WM_NCLBUTTONDOWN` for resize hit-test areas (`HTTOP`,
+//! `HTLEFT`, …) is routed straight to `DefWindowProc`, bypassing GPUI's
+//! element dispatch that would otherwise eat the event.
+//!
+//! - **Buttons**: `WM_NCLBUTTONDOWN` for `HTMINBUTTON`/`HTMAXBUTTON`/`HTCLOSE`
+//! passes through to GPUI for normal button handling.
+//!
+//! Remove this module after upgrading GPUI to a version that fixes the issues.
-/// Begin a native title-bar drag on Windows.
-///
-/// Posts `ReleaseCapture` + `WM_NCLBUTTONDOWN(HTCAPTION)` so the OS
-/// takes over the drag loop. Uses `PostMessageW` (async) instead of
-/// `SendMessageW` (sync) to avoid reentrancy — `SendMessageW` starts a
-/// modal drag loop that pumps messages while GPUI's `RefCell` is still
-/// borrowed by the event callback, causing a panic.
+/// Install the window subclass for drag/resize handling (Windows only).
///
-/// The `lparam` carries the current cursor screen coordinates packed as
-/// `MAKELPARAM(x, y)`. Passing 0 caused some Windows builds to silently
-/// ignore the message when the (0,0) position fell outside the window.
+/// Must be called once per window. Safe to call multiple times — only the
+/// first call installs the subclass.
#[cfg(target_os = "windows")]
-pub fn begin_title_bar_drag(hwnd: isize) {
- use std::ffi::c_int;
-
- #[allow(clippy::upper_case_acronyms)]
- type HWND = isize;
- #[allow(clippy::upper_case_acronyms)]
- type WPARAM = usize;
- #[allow(clippy::upper_case_acronyms)]
- type LPARAM = isize;
- #[allow(clippy::upper_case_acronyms)]
- type BOOL = c_int;
+pub fn install_drag_subclass(hwnd: isize) {
+ use std::sync::atomic::{AtomicBool, Ordering};
- #[repr(C)]
- #[allow(clippy::upper_case_acronyms)]
- struct POINT {
- x: i32,
- y: i32,
+ static INSTALLED: AtomicBool = AtomicBool::new(false);
+ if INSTALLED
+ .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
+ .is_err()
+ {
+ return;
}
- const WM_NCLBUTTONDOWN: u32 = 0x00A1;
- const HTCAPTION: WPARAM = 2;
-
- extern "system" {
- fn ReleaseCapture() -> BOOL;
- fn PostMessageW(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> BOOL;
- fn GetCursorPos(point: *mut POINT) -> BOOL;
+ // Safety: hwnd is obtained from raw_window_handle and the window is alive.
+ // SetWindowSubclass must be called from the thread that owns the window,
+ // which is the UI thread where render_title_bar runs.
+ unsafe {
+ SetWindowSubclass(hwnd, drag_subclass_proc, SUBCLASS_ID, 0);
}
+}
- // Safety: hwnd is obtained from raw_window_handle and the window is alive
- // during the mouse-down handler that calls this function.
+/// Begin a native title-bar drag on Windows.
+///
+/// Posts a custom `WM_APP` message with the cursor's screen coordinates.
+/// The window subclass intercepts this and calls
+/// `DefWindowProc(WM_NCLBUTTONDOWN, HTCAPTION)` to start the OS drag loop.
+#[cfg(target_os = "windows")]
+pub fn begin_title_bar_drag(hwnd: isize) {
+ // Safety: GetCursorPos and PostMessageW are safe to call from any thread,
+ // and hwnd is valid during the mouse-down handler that calls this.
unsafe {
let mut pt = POINT { x: 0, y: 0 };
GetCursorPos(&mut pt);
- // Pack screen coordinates as MAKELPARAM(x, y) = (y << 16) | (x & 0xFFFF)
- let lparam = (((pt.y & 0xFFFF) as LPARAM) << 16) | ((pt.x & 0xFFFF) as LPARAM);
- ReleaseCapture();
- PostMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, lparam);
+ let lparam = pack_point(pt.x, pt.y);
+ PostMessageW(hwnd, WM_APP_DRAG_WINDOW, 0, lparam);
}
}
/// No-op on non-Windows platforms (drag is handled by GPUI natively).
#[cfg(not(target_os = "windows"))]
pub fn begin_title_bar_drag(_hwnd: isize) {}
+
+/// No-op on non-Windows platforms.
+#[cfg(not(target_os = "windows"))]
+pub fn install_drag_subclass(_hwnd: isize) {}
+
+// ---------------------------------------------------------------------------
+// Windows implementation
+// ---------------------------------------------------------------------------
+
+// Win32 type aliases (avoid pulling in the full `windows` crate).
+#[cfg(target_os = "windows")]
+#[allow(clippy::upper_case_acronyms, non_camel_case_types)]
+mod win32 {
+ pub type HWND = isize;
+ pub type WPARAM = usize;
+ pub type LPARAM = isize;
+ pub type LRESULT = isize;
+ pub type BOOL = std::ffi::c_int;
+
+ #[repr(C)]
+ pub struct POINT {
+ pub x: i32,
+ pub y: i32,
+ }
+
+ /// Signature expected by `SetWindowSubclass` / `RemoveWindowSubclass`.
+ pub type SUBCLASSPROC =
+ unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM, usize, usize) -> LRESULT;
+}
+
+#[cfg(target_os = "windows")]
+use win32::*;
+
+// Message and hit-test constants.
+#[cfg(target_os = "windows")]
+const WM_NCLBUTTONDOWN: u32 = 0x00A1;
+#[cfg(target_os = "windows")]
+const WM_NCLBUTTONDBLCLK: u32 = 0x00A3;
+#[cfg(target_os = "windows")]
+const WM_NCDESTROY: u32 = 0x0082;
+#[cfg(target_os = "windows")]
+const WM_APP_DRAG_WINDOW: u32 = 0x8000; // WM_APP
+#[cfg(target_os = "windows")]
+const HTMINBUTTON: u32 = 8;
+#[cfg(target_os = "windows")]
+const HTMAXBUTTON: u32 = 9;
+#[cfg(target_os = "windows")]
+const HTCLOSE: u32 = 20;
+#[cfg(target_os = "windows")]
+const HTCAPTION: usize = 2;
+#[cfg(target_os = "windows")]
+const SUBCLASS_ID: usize = 0xC0D1; // Memorable constant for our subclass.
+
+// Imports from comctl32.dll (window subclass API).
+#[cfg(target_os = "windows")]
+#[link(name = "comctl32")]
+extern "system" {
+ fn SetWindowSubclass(
+ hwnd: HWND,
+ pfn_subclass: SUBCLASSPROC,
+ uid_subclass: usize,
+ dw_ref_data: usize,
+ ) -> BOOL;
+ fn RemoveWindowSubclass(hwnd: HWND, pfn_subclass: SUBCLASSPROC, uid_subclass: usize) -> BOOL;
+ fn DefSubclassProc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT;
+}
+
+// Imports from user32.dll.
+#[cfg(target_os = "windows")]
+extern "system" {
+ fn DefWindowProcW(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT;
+ fn ReleaseCapture() -> BOOL;
+ fn PostMessageW(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> BOOL;
+ fn GetCursorPos(point: *mut POINT) -> BOOL;
+}
+
+/// Pack screen coordinates as `MAKELPARAM(x, y)`.
+#[cfg(target_os = "windows")]
+fn pack_point(x: i32, y: i32) -> LPARAM {
+ (((y & 0xFFFF) as LPARAM) << 16) | ((x & 0xFFFF) as LPARAM)
+}
+
+/// Window subclass procedure — runs **before** GPUI's WndProc.
+///
+/// Routes NC mouse messages so the OS handles drag/resize directly,
+/// bypassing GPUI's element dispatch that would otherwise eat them.
+#[cfg(target_os = "windows")]
+unsafe extern "system" fn drag_subclass_proc(
+ hwnd: HWND,
+ msg: u32,
+ wparam: WPARAM,
+ lparam: LPARAM,
+ _uid_subclass: usize,
+ _ref_data: usize,
+) -> LRESULT {
+ match msg {
+ // Our custom drag message, posted by begin_title_bar_drag().
+ // At this point no GPUI RefCell borrows are held, so the modal
+ // drag loop started by DefWindowProc is safe.
+ WM_APP_DRAG_WINDOW => {
+ ReleaseCapture();
+ DefWindowProcW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, lparam)
+ }
+
+ // NC mouse-down (single or double click).
+ WM_NCLBUTTONDOWN | WM_NCLBUTTONDBLCLK => match wparam as u32 {
+ // Window control buttons — let GPUI handle them via
+ // WindowControlArea::Min/Max/Close.
+ HTMINBUTTON | HTMAXBUTTON | HTCLOSE => DefSubclassProc(hwnd, msg, wparam, lparam),
+ // Everything else: resize edges (HTTOP, HTLEFT, …) or a
+ // stale HTCAPTION from GPUI's hit-test callback.
+ // Send straight to DefWindowProc so the OS starts the
+ // resize/drag loop — bypassing GPUI's element dispatch
+ // which would route it to on_mouse_down and eat the event.
+ _ => DefWindowProcW(hwnd, msg, wparam, lparam),
+ },
+
+ // Clean up the subclass when the window is destroyed.
+ WM_NCDESTROY => {
+ RemoveWindowSubclass(hwnd, drag_subclass_proc, SUBCLASS_ID);
+ DefSubclassProc(hwnd, msg, wparam, lparam)
+ }
+
+ // Everything else passes through to GPUI.
+ _ => DefSubclassProc(hwnd, msg, wparam, lparam),
+ }
+}
diff --git a/crates/codirigent-ui/src/workspace/render.rs b/crates/codirigent-ui/src/workspace/render.rs
index e3d58e8f..8796b53e 100644
--- a/crates/codirigent-ui/src/workspace/render.rs
+++ b/crates/codirigent-ui/src/workspace/render.rs
@@ -72,13 +72,17 @@ impl WorkspaceView {
// macOS: Use GPUI's `WindowControlArea::Drag` — it returns HTCAPTION via
// the native hit-test and the OS handles drag + double-click-to-zoom.
//
- // Windows: Do NOT use `WindowControlArea::Drag`. GPUI 0.2.x has a timing
- // issue where WM_NCHITTEST returns HTCAPTION while GPUI still holds
- // `RefCell` borrows. Windows then enters a modal drag loop inside
- // `DefWindowProc` that re-enters the message pump — panic / freeze.
- // Instead, we post `WM_NCLBUTTONDOWN(HTCAPTION)` asynchronously from
- // an `on_mouse_down` handler so the modal loop starts *after* GPUI's
- // borrows are released. See `platform_drag.rs`.
+ // Windows: Do NOT use `WindowControlArea::Drag`. GPUI 0.2.x has two
+ // issues: (1) WM_NCHITTEST returns HTCAPTION while GPUI holds RefCell
+ // borrows, causing a freeze when DefWindowProc enters a modal drag
+ // loop; (2) GPUI re-dispatches WM_NCLBUTTONDOWN through its element
+ // tree, so on_mouse_down handlers eat resize events for the top edge
+ // (which overlaps the title bar).
+ //
+ // Fix: a Win32 window subclass (see `platform_drag.rs`) intercepts
+ // NC messages before GPUI. Drag uses a custom WM_APP message that the
+ // subclass routes to DefWindowProc(WM_NCLBUTTONDOWN, HTCAPTION).
+ // Resize edges go straight to DefWindowProc, bypassing GPUI.
let mut drag_region = div().flex().items_center().gap_2().flex_1().h_full();
#[cfg(target_os = "macos")]
@@ -103,6 +107,12 @@ impl WorkspaceView {
_ => 0,
});
if let Some(hwnd) = raw_handle {
+ // Install the window subclass that routes WM_NCLBUTTONDOWN
+ // for resize edges directly to DefWindowProc (bypassing
+ // GPUI's element dispatch) and handles our custom drag
+ // message. Safe to call every frame — only installs once.
+ crate::platform_drag::install_drag_subclass(hwnd);
+
drag_region = drag_region.on_mouse_down(
MouseButton::Left,
move |event: &MouseDownEvent, window, _cx| {
From 2c88d1567798ba0d5f73f57923898c080b29a12e Mon Sep 17 00:00:00 2001
From: oso95
Date: Fri, 27 Mar 2026 22:36:01 -0500
Subject: [PATCH 24/33] fix: guard against stale drag message on double-click
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After a double-click, the WM_APP_DRAG_WINDOW from the first click may
still be in the message queue when titlebar_double_click() has already
maximized/restored the window. Check GetAsyncKeyState(VK_LBUTTON)
before starting the drag — if the button is released, skip the stale
message to avoid a spurious drag on the now-toggled window.
---
crates/codirigent-ui/src/platform_drag.rs | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/crates/codirigent-ui/src/platform_drag.rs b/crates/codirigent-ui/src/platform_drag.rs
index b90f7d69..812ae201 100644
--- a/crates/codirigent-ui/src/platform_drag.rs
+++ b/crates/codirigent-ui/src/platform_drag.rs
@@ -125,6 +125,8 @@ const HTCLOSE: u32 = 20;
#[cfg(target_os = "windows")]
const HTCAPTION: usize = 2;
#[cfg(target_os = "windows")]
+const VK_LBUTTON: i32 = 0x01;
+#[cfg(target_os = "windows")]
const SUBCLASS_ID: usize = 0xC0D1; // Memorable constant for our subclass.
// Imports from comctl32.dll (window subclass API).
@@ -148,6 +150,7 @@ extern "system" {
fn ReleaseCapture() -> BOOL;
fn PostMessageW(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> BOOL;
fn GetCursorPos(point: *mut POINT) -> BOOL;
+ fn GetAsyncKeyState(vkey: i32) -> i16;
}
/// Pack screen coordinates as `MAKELPARAM(x, y)`.
@@ -173,9 +176,20 @@ unsafe extern "system" fn drag_subclass_proc(
// Our custom drag message, posted by begin_title_bar_drag().
// At this point no GPUI RefCell borrows are held, so the modal
// drag loop started by DefWindowProc is safe.
+ //
+ // Guard: only start the drag if the left mouse button is still
+ // held. On a double-click the second click fires
+ // titlebar_double_click() synchronously, and by the time this
+ // stale message from the first click is dequeued the button may
+ // already be released — skip it to avoid a spurious drag on the
+ // now-maximised window.
WM_APP_DRAG_WINDOW => {
- ReleaseCapture();
- DefWindowProcW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, lparam)
+ if GetAsyncKeyState(VK_LBUTTON) < 0 {
+ ReleaseCapture();
+ DefWindowProcW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, lparam)
+ } else {
+ 0
+ }
}
// NC mouse-down (single or double click).
From e22d8c1e7d97ed8cfafb7bfac8edaa1e868f09a7 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Mon, 30 Mar 2026 15:49:05 -0500
Subject: [PATCH 25/33] fix: correct keybinding doctest to use Ctrl+Shift+N
---
crates/codirigent-ui/src/keybindings.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/crates/codirigent-ui/src/keybindings.rs b/crates/codirigent-ui/src/keybindings.rs
index 5570e43d..20761924 100644
--- a/crates/codirigent-ui/src/keybindings.rs
+++ b/crates/codirigent-ui/src/keybindings.rs
@@ -14,9 +14,9 @@
//! let manager = KeybindingManager::with_defaults();
//! // Platform modifier: Cmd on macOS, Ctrl elsewhere.
//! #[cfg(target_os = "macos")]
-//! let binding = KeybindingManager::parse_binding("Cmd+N").unwrap();
+//! let binding = KeybindingManager::parse_binding("Cmd+Shift+N").unwrap();
//! #[cfg(not(target_os = "macos"))]
-//! let binding = KeybindingManager::parse_binding("Ctrl+N").unwrap();
+//! let binding = KeybindingManager::parse_binding("Ctrl+Shift+N").unwrap();
//! assert_eq!(manager.get_action(&binding), Some(&Action::NewSession));
//! ```
From 4f3f6ef0b4cd803e6d0afa3437947b9621422cf4 Mon Sep 17 00:00:00 2001
From: cyw <86410452+oso95@users.noreply.github.com>
Date: Fri, 3 Apr 2026 16:14:42 -0500
Subject: [PATCH 26/33] fix: prompt-aware resume dispatch to prevent blank
Claude terminals on restart
The resume command (claude --resume) was sent to the PTY immediately after
bootstrapping, before the shell had completed its DSR handshake. The buffered
command bytes corrupted PowerShell's cursor position query, leaving terminals
blank. Now resume commands are enqueued and dispatched only after the shell
produces its first output, with a 3-second fallback timeout.
---
.../impl_output_polling/output_runtime.rs | 14 +++
.../src/workspace/impl_session_lifecycle.rs | 103 ++++++++++++++++--
crates/codirigent-ui/src/workspace/types.rs | 6 +
3 files changed, 111 insertions(+), 12 deletions(-)
diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
index 9d44df50..e70d83ef 100644
--- a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
+++ b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
@@ -60,6 +60,12 @@ impl WorkspaceView {
let had_output_activity = self.schedule_output_preparation(cx);
+ // Safety-net: dispatch resume commands that have been waiting longer
+ // than the fallback timeout (shell never produced output).
+ if !self.polling.pending_resume_commands.is_empty() {
+ self.dispatch_timed_out_resume_commands();
+ }
+
// Track output activity for adaptive polling
//
// Sessions that actually produced output are synchronized in
@@ -361,6 +367,14 @@ impl WorkspaceView {
has_more,
"apply_prepared_session_output"
);
+
+ // Prompt-aware resume dispatch: the shell has produced real output,
+ // so it is alive and can accept input. Flush any queued resume
+ // commands for this session now.
+ if bytes_drained > 0 {
+ self.dispatch_pending_resume_commands_for_session(session_id);
+ }
+
let mut any_dirty = false;
if let Some(snapshot) = render_snapshot {
diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
index 05bd2ff2..56bdc029 100644
--- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
+++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
@@ -76,6 +76,11 @@ struct CompletedRestoreBootstrap {
result: Result,
}
+/// Safety-net timeout for prompt-aware resume dispatch. If a session's shell
+/// has not produced any PTY output within this window, the resume command is
+/// sent anyway (the shell may not emit OSC 133 or may be very slow to start).
+const RESUME_COMMAND_FALLBACK_TIMEOUT: Duration = Duration::from_secs(3);
+
fn legacy_pane_stacks_from_groups(
saved_sessions: &[Session],
pane_tab_groups: &[PaneTabGroup],
@@ -1479,21 +1484,77 @@ impl WorkspaceView {
);
});
}
+ }
- if restore_cli {
- for command in restore_resume_commands(&plan) {
- if let Ok(manager) = self.session_manager.lock() {
- if let Err(error) =
- manager.send_input(bootstrapped.session_id, command.as_bytes())
- {
- warn!(
- ?bootstrapped.session_id,
- %error,
- "Failed to send resume command"
- );
- }
+ /// Enqueue resume commands to be dispatched when each session's shell
+ /// produces its first output (prompt-aware dispatch). The commands are
+ /// stored in `polling.pending_resume_commands` and flushed by
+ /// `dispatch_pending_resume_commands()` — either when the output pipeline
+ /// delivers the first real bytes for a session, or after a fallback timeout.
+ fn enqueue_restored_resume_commands(
+ &mut self,
+ pending_commands: Vec<(SessionId, Vec)>,
+ ) {
+ let now = std::time::Instant::now();
+ for (session_id, commands) in pending_commands {
+ if !commands.is_empty() {
+ info!(
+ ?session_id,
+ command_count = commands.len(),
+ "Enqueued resume commands (prompt-aware dispatch)"
+ );
+ self.polling
+ .pending_resume_commands
+ .insert(session_id, (now, commands));
+ }
+ }
+ }
+
+ /// Dispatch pending resume commands for a specific session.
+ ///
+ /// Called when the output pipeline delivers real bytes for this session
+ /// (the shell is alive and producing output) or when the fallback timeout
+ /// fires.
+ pub(super) fn dispatch_pending_resume_commands_for_session(&mut self, session_id: SessionId) {
+ let Some((_, commands)) = self.polling.pending_resume_commands.remove(&session_id) else {
+ return;
+ };
+ info!(
+ ?session_id,
+ command_count = commands.len(),
+ "Dispatching resume commands (shell produced output)"
+ );
+ if let Ok(manager) = self.session_manager.lock() {
+ for command in commands {
+ if let Err(error) = manager.send_input(session_id, command.as_bytes()) {
+ warn!(?session_id, %error, "Failed to send resume command");
}
}
+ manager.mark_output_pending(session_id);
+ }
+ }
+
+ /// Check for timed-out pending resume commands and dispatch them.
+ ///
+ /// This is the safety-net path: if a shell does not produce any PTY output
+ /// within `RESUME_COMMAND_FALLBACK_TIMEOUT`, the resume command is sent
+ /// anyway so the session does not remain stuck.
+ pub(super) fn dispatch_timed_out_resume_commands(&mut self) {
+ let expired: Vec = self
+ .polling
+ .pending_resume_commands
+ .iter()
+ .filter(|(_, (enqueued_at, _))| {
+ enqueued_at.elapsed() >= RESUME_COMMAND_FALLBACK_TIMEOUT
+ })
+ .map(|(session_id, _)| *session_id)
+ .collect();
+ for session_id in expired {
+ info!(
+ ?session_id,
+ "Resume command fallback timeout — dispatching without prompt"
+ );
+ self.dispatch_pending_resume_commands_for_session(session_id);
}
}
@@ -1703,6 +1764,7 @@ impl WorkspaceView {
let session_manager = self.session_manager.clone();
cx.spawn(async move |this: gpui::WeakEntity, cx| {
let mut restored_session_ids = std::collections::HashMap::new();
+ let mut pending_resume_commands: Vec<(SessionId, Vec)> = Vec::new();
let total_batches = restore_batches.len();
for (batch_index, batch) in restore_batches.into_iter().enumerate() {
let is_last_batch = batch_index + 1 == total_batches;
@@ -1728,6 +1790,20 @@ impl WorkspaceView {
Ok(bootstrapped) => {
let restored_session_id = bootstrapped.session_id;
let original_session_id = completion.plan.original_session_id;
+ let restore_cli = this
+ .effective_user_settings()
+ .general
+ .restore_cli_on_startup;
+ if restore_cli {
+ let commands = restore_resume_commands(&completion.plan)
+ .into_iter()
+ .map(str::to_owned)
+ .collect::>();
+ if !commands.is_empty() {
+ pending_resume_commands
+ .push((restored_session_id, commands));
+ }
+ }
this.finalize_restored_session_bootstrap(
bootstrapped,
completion.plan,
@@ -1757,6 +1833,9 @@ impl WorkspaceView {
this.sync_layout_derived_state();
this.sync_file_tree_to_focused_session(cx);
}
+ this.enqueue_restored_resume_commands(std::mem::take(
+ &mut pending_resume_commands,
+ ));
this.polling.restore_in_flight = false;
info!("Session restoration complete");
// Persist immediately so any session_uuids generated for
diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs
index 2bc5550c..ba33e45b 100644
--- a/crates/codirigent-ui/src/workspace/types.rs
+++ b/crates/codirigent-ui/src/workspace/types.rs
@@ -532,6 +532,11 @@ pub(super) struct PollingState {
pub last_legacy_fallback: Instant,
/// Best-effort shell command line capture per session while the shell is idle.
pub shell_input_buffers: HashMap,
+ /// CLI resume commands waiting for the shell to produce output before
+ /// being dispatched. Keyed by session ID; value is (enqueued_at, commands).
+ /// Commands are sent as soon as the first PTY output is received (the shell
+ /// is alive) or after `RESUME_COMMAND_FALLBACK_TIMEOUT` as a safety net.
+ pub pending_resume_commands: HashMap)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -568,6 +573,7 @@ impl PollingState {
pending_session_bootstrap_slots: HashSet::new(),
last_legacy_fallback: Instant::now(),
shell_input_buffers: HashMap::new(),
+ pending_resume_commands: HashMap::new(),
}
}
}
From c8b4f4a10f3a8a11aeee5f7feb274184667226bf Mon Sep 17 00:00:00 2001
From: looluo
Date: Mon, 27 Apr 2026 20:46:48 +0800
Subject: [PATCH 27/33] fix: include Nerd Font family fonts in Windows terminal
font detection
---
.../src/workspace/editor_detection.rs | 30 ++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)
diff --git a/crates/codirigent-ui/src/workspace/editor_detection.rs b/crates/codirigent-ui/src/workspace/editor_detection.rs
index a860b010..55e12dbd 100644
--- a/crates/codirigent-ui/src/workspace/editor_detection.rs
+++ b/crates/codirigent-ui/src/workspace/editor_detection.rs
@@ -184,6 +184,23 @@ pub(super) fn detect_monospace_fonts(text_system: &gpui::TextSystem) -> Vec Vec = all_names
+ .iter()
+ .filter(|name| {
+ let lower = name.to_lowercase();
+ lower.contains("nerd font") && !is_symbol_font(name)
+ })
+ .cloned()
+ .collect();
+ monospace.extend(nerd_fonts);
+
+ // Fallback heuristic when none of the above are available.
if monospace.is_empty() {
monospace = all_names
.iter()
From 05ed14baeb0567d604d4fa9fd17a227be1825403 Mon Sep 17 00:00:00 2001
From: linshitao <936633856@qq.com>
Date: Sun, 30 Aug 2026 16:20:37 +0800
Subject: [PATCH 28/33] fix(terminal): stabilize agent status and dynamic
rendering
1. Normalize and validate Windows hook executable paths before registration.
2. Ignore PowerShell shell prompts in generic attention detection.
3. Rebuild terminal row caches when switching alternate screens.
4. Accumulate pending dirty rows across consecutive snapshots.
---
crates/codirigent-core/src/hook_installer.rs | 85 ++++++++++++++++++--
crates/codirigent-detector/src/patterns.rs | 32 ++++++++
crates/codirigent-ui/src/terminal_runtime.rs | 33 +++++++-
crates/codirigent-ui/src/terminal_view.rs | 77 +++++++++++++++++-
4 files changed, 219 insertions(+), 8 deletions(-)
diff --git a/crates/codirigent-core/src/hook_installer.rs b/crates/codirigent-core/src/hook_installer.rs
index 08e19a88..8027ac7d 100644
--- a/crates/codirigent-core/src/hook_installer.rs
+++ b/crates/codirigent-core/src/hook_installer.rs
@@ -39,6 +39,7 @@ const HOOK_MARKER: &str = "codirigent-hook";
/// Safe to call on every launch - the function is idempotent.
/// Returns `Ok(true)` if the file was modified, `Ok(false)` if already up to date.
pub fn ensure_hooks_installed(hook_binary: &Path) -> Result {
+ validate_hook_binary(hook_binary)?;
let settings_path =
claude_settings_path().context("Could not determine ~/.claude/settings.json path")?;
@@ -61,6 +62,7 @@ pub fn ensure_hooks_installed(hook_binary: &Path) -> Result {
/// Gemini CLI uses the same JSON hook structure as Claude Code, so the merge is
/// additive and idempotent. Existing hooks from other tools are preserved.
pub fn ensure_gemini_hooks_installed(hook_binary: &Path) -> Result {
+ validate_hook_binary(hook_binary)?;
let settings_path =
gemini_settings_path().context("Could not determine ~/.gemini/settings.json path")?;
@@ -90,6 +92,7 @@ pub fn ensure_gemini_hooks_installed(hook_binary: &Path) -> Result {
/// Safe to call on every launch -- the function is idempotent.
/// Returns `Ok(true)` if the file was modified, `Ok(false)` if already up to date.
pub fn ensure_codex_hooks_installed(hook_binary: &Path) -> Result {
+ validate_hook_binary(hook_binary)?;
let config_path =
codex_config_path().context("Could not determine ~/.codex/config.toml path")?;
@@ -140,12 +143,35 @@ fn codex_config_path() -> Option {
home_dir().map(|home| home.join(".codex").join("config.toml"))
}
+fn validate_hook_binary(hook_binary: &Path) -> Result<()> {
+ if hook_binary.is_absolute() && !hook_binary.is_file() {
+ anyhow::bail!(
+ "Codirigent hook binary does not exist: {}",
+ hook_binary.display()
+ );
+ }
+ Ok(())
+}
+
fn shell_escaped_hook_command(hook_binary: &Path) -> String {
let raw = hook_binary.to_string_lossy().into_owned();
- if raw.contains(' ') {
- format!("\"{raw}\"")
- } else {
- raw
+
+ #[cfg(windows)]
+ {
+ // Claude and Gemini execute hooks through a POSIX-compatible shell on
+ // Windows. Unquoted backslashes are treated as escape characters, so
+ // normalize to the forward-slash form accepted by Windows executables.
+ let normalized = raw.replace('\\', "/");
+ format!("\"{normalized}\"")
+ }
+
+ #[cfg(not(windows))]
+ {
+ if raw.contains(' ') {
+ format!("\"{raw}\"")
+ } else {
+ raw
+ }
}
}
@@ -446,7 +472,56 @@ mod tests {
const CMD: &str = "/usr/local/bin/codirigent-hook";
const CMD2: &str = "/opt/codirigent/codirigent-hook";
- const CMD_SPACES: &str = r#""C:\Program Files\Codirigent\codirigent-hook.exe""#;
+ const CMD_SPACES: &str = r#""C:/Program Files/Codirigent/codirigent-hook.exe""#;
+
+ #[test]
+ fn missing_absolute_hook_binary_is_rejected() {
+ let temp = tempfile::tempdir().expect("hook installer test should create temp dir");
+ let missing = temp.path().join("codirigent-hook-missing");
+ let error = validate_hook_binary(&missing)
+ .expect_err("an absolute path to a missing hook must be rejected");
+ assert!(error.to_string().contains("does not exist"));
+ }
+
+ #[test]
+ fn bare_hook_binary_name_is_allowed() {
+ validate_hook_binary(Path::new("codirigent-hook"))
+ .expect("a bare hook command may rely on PATH");
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn windows_hook_path_without_spaces_is_bash_safe() {
+ let command = shell_escaped_hook_command(Path::new(
+ r"D:\study\codirigent\target\debug\codirigent-hook.exe",
+ ));
+ assert_eq!(
+ command,
+ r#""D:/study/codirigent/target/debug/codirigent-hook.exe""#
+ );
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn windows_hook_path_with_spaces_is_bash_safe() {
+ let command = shell_escaped_hook_command(Path::new(
+ r"C:\Program Files\Codirigent\codirigent-hook.exe",
+ ));
+ assert_eq!(command, CMD_SPACES);
+ }
+
+ #[cfg(not(windows))]
+ #[test]
+ fn unix_hook_path_escaping_is_unchanged() {
+ assert_eq!(
+ shell_escaped_hook_command(Path::new(CMD)),
+ "/usr/local/bin/codirigent-hook"
+ );
+ assert_eq!(
+ shell_escaped_hook_command(Path::new("/opt/Codirigent App/codirigent-hook")),
+ r#""/opt/Codirigent App/codirigent-hook""#
+ );
+ }
#[test]
fn fresh_install_adds_three_hooks() {
diff --git a/crates/codirigent-detector/src/patterns.rs b/crates/codirigent-detector/src/patterns.rs
index 73a92af8..c198c539 100644
--- a/crates/codirigent-detector/src/patterns.rs
+++ b/crates/codirigent-detector/src/patterns.rs
@@ -18,6 +18,7 @@
//! }
//! ```
+use codirigent_core::context::strip_ansi_codes;
use regex::Regex;
use tracing::warn;
@@ -161,6 +162,14 @@ pub fn find_matching_pattern_with_limit(
.join("\n");
for pattern in patterns {
+ // The generic `> ` pattern is useful for REPLs and interactive tools,
+ // but it also matches PowerShell's normal `PS > ` shell prompt.
+ // A shell prompt means the session is idle, not waiting for an agent
+ // response. Ignore only this well-known prompt shape so other angle
+ // prompts continue to work.
+ if pattern.as_str() == r"> $" && ends_with_powershell_prompt(&recent_output) {
+ continue;
+ }
if pattern.is_match(&recent_output) {
return Some(pattern.as_str().to_string());
}
@@ -168,6 +177,15 @@ pub fn find_matching_pattern_with_limit(
None
}
+fn ends_with_powershell_prompt(output: &str) -> bool {
+ let plain_output = strip_ansi_codes(output);
+ plain_output
+ .lines()
+ .next_back()
+ .map(str::trim_end)
+ .is_some_and(|line| line.starts_with("PS ") && line.ends_with('>'))
+}
+
/// Check if any pattern matches the output (boolean version).
///
/// This is a convenience function when you only need to know if there's a match,
@@ -296,6 +314,20 @@ mod tests {
assert!(result.is_some());
}
+ #[test]
+ fn test_powershell_prompt_is_not_an_attention_prompt() {
+ let patterns = compile_patterns(&[r"> $".to_string()]);
+
+ assert!(find_matching_pattern(&patterns, r"PS D:\repo> ").is_none());
+ }
+
+ #[test]
+ fn test_powershell_prompt_with_osc133_is_not_an_attention_prompt() {
+ let patterns = compile_patterns(&[r"> $".to_string()]);
+
+ assert!(find_matching_pattern(&patterns, "\x1b]133;A\x07PS D:\\repo> ").is_none());
+ }
+
#[test]
fn test_find_matching_pattern_press_enter() {
let patterns = compile_patterns(&[r"Press Enter".to_string()]);
diff --git a/crates/codirigent-ui/src/terminal_runtime.rs b/crates/codirigent-ui/src/terminal_runtime.rs
index 01090bcd..e00b3463 100644
--- a/crates/codirigent-ui/src/terminal_runtime.rs
+++ b/crates/codirigent-ui/src/terminal_runtime.rs
@@ -28,6 +28,7 @@ struct TerminalRuntime {
terminal: Terminal,
theme: CodirigentTheme,
generation: u64,
+ last_snapshot_mode: TermMode,
cached_rows: Option>,
cached_search_snapshot: Option>,
}
@@ -44,10 +45,12 @@ impl TerminalRuntimeHandle {
initial_size: TerminalSize,
) -> (Self, TerminalRenderSnapshot) {
terminal.resize_with_cells(initial_size);
+ let last_snapshot_mode = terminal.mode();
let mut runtime = TerminalRuntime {
terminal,
theme,
generation: 0,
+ last_snapshot_mode,
cached_rows: None,
cached_search_snapshot: None,
};
@@ -200,7 +203,10 @@ impl TerminalRuntime {
let rows = self.terminal.rows() as usize;
let cols = self.terminal.cols() as usize;
let scrolled_back = self.terminal.term().grid().display_offset() > 0;
- let damage = if !scrolled_back
+ let alternate_screen_changed = self.last_snapshot_mode.contains(TermMode::ALT_SCREEN)
+ != self.terminal.mode().contains(TermMode::ALT_SCREEN);
+ let damage = if !alternate_screen_changed
+ && !scrolled_back
&& self
.cached_rows
.as_ref()
@@ -277,6 +283,7 @@ impl TerminalRuntime {
None
};
self.terminal.mark_clean();
+ self.last_snapshot_mode = mode;
TerminalRenderSnapshot {
generation: self.generation,
@@ -444,6 +451,30 @@ mod tests {
assert!(next.generation > initial.generation);
assert_eq!(next.rows, 4);
assert_eq!(next.cols, 8);
+ assert!(next.dirty_rows.is_some());
+ }
+
+ #[test]
+ fn runtime_alt_screen_exit_rebuilds_all_cached_rows() {
+ let runtime = create_runtime();
+ let entered = runtime
+ .apply_output(b"\x1b[?1049h\x1b[2J\x1b[4;1HKIMI")
+ .expect("alternate-screen snapshot");
+ assert!(entered.mode.contains(TermMode::ALT_SCREEN));
+
+ let exited = runtime
+ .apply_output(b"\x1b[?1049lPS> ")
+ .expect("primary-screen snapshot");
+ let visible_text = exited
+ .cached_rows
+ .iter()
+ .flat_map(|row| row.text_runs_hsla.iter())
+ .map(|(run, _)| run.text.as_str())
+ .collect::();
+
+ assert!(!exited.mode.contains(TermMode::ALT_SCREEN));
+ assert_eq!(exited.dirty_rows, None);
+ assert!(!visible_text.contains("KIMI"));
}
#[test]
diff --git a/crates/codirigent-ui/src/terminal_view.rs b/crates/codirigent-ui/src/terminal_view.rs
index d61e7ae5..24af5394 100644
--- a/crates/codirigent-ui/src/terminal_view.rs
+++ b/crates/codirigent-ui/src/terminal_view.rs
@@ -426,8 +426,18 @@ impl TerminalView {
self.cached_shaped_font_size = None;
self.cached_shaped_rows = None;
self.dirty_rows = None;
- } else {
- self.dirty_rows = snapshot.dirty_rows;
+ } else if self.cached_shaped_rows.is_none() {
+ // A full shaped rebuild is already pending, so partial row damage
+ // cannot narrow the work that still needs to be done.
+ self.dirty_rows = None;
+ } else if let Some(mut dirty_rows) = snapshot.dirty_rows {
+ if let Some(pending_rows) = self.dirty_rows.as_mut() {
+ pending_rows.append(&mut dirty_rows);
+ pending_rows.sort_unstable();
+ pending_rows.dedup();
+ } else {
+ self.dirty_rows = Some(dirty_rows);
+ }
}
true
@@ -2113,6 +2123,69 @@ mod tests {
assert_eq!(view.rows(), current);
}
+ #[test]
+ fn test_apply_snapshot_accumulates_dirty_rows_until_rendered() {
+ let mut view = create_test_view();
+ view.cached_shaped_rows = Some(
+ (0..view.cached_rows.len())
+ .map(|_| Arc::new(Vec::new()))
+ .collect(),
+ );
+ let first = TerminalRenderSnapshot {
+ generation: view.snapshot_generation + 1,
+ rows: view.rows,
+ cols: view.cols,
+ mode: view.mode,
+ history_size: view.history_size,
+ display_offset: view.display_offset,
+ cached_rows: view.cached_rows.clone(),
+ dirty_rows: Some(vec![3]),
+ cursor_viewport_cell: None,
+ };
+ assert!(view.apply_snapshot(first));
+
+ let second = TerminalRenderSnapshot {
+ generation: view.snapshot_generation + 1,
+ rows: view.rows,
+ cols: view.cols,
+ mode: view.mode,
+ history_size: view.history_size,
+ display_offset: view.display_offset,
+ cached_rows: view.cached_rows.clone(),
+ dirty_rows: Some(vec![7, 3]),
+ cursor_viewport_cell: None,
+ };
+ assert!(view.apply_snapshot(second));
+
+ assert_eq!(view.dirty_rows, Some(vec![3, 7]));
+ }
+
+ #[test]
+ fn test_apply_snapshot_full_rebuild_supersedes_pending_dirty_rows() {
+ let mut view = create_test_view();
+ view.cached_shaped_rows = Some(
+ (0..view.cached_rows.len())
+ .map(|_| Arc::new(Vec::new()))
+ .collect(),
+ );
+ view.dirty_rows = Some(vec![3, 7]);
+ let full = TerminalRenderSnapshot {
+ generation: view.snapshot_generation + 1,
+ rows: view.rows,
+ cols: view.cols,
+ mode: view.mode,
+ history_size: view.history_size,
+ display_offset: view.display_offset,
+ cached_rows: view.cached_rows.clone(),
+ dirty_rows: None,
+ cursor_viewport_cell: None,
+ };
+
+ assert!(view.apply_snapshot(full));
+ assert!(view.cached_shaped_rows.is_none());
+ assert_eq!(view.dirty_rows, None);
+ }
+
#[test]
fn test_selection_rects_follow_scrollback() {
let mut view = create_test_view();
From cd15527c6f3187991b1f6c1c0716f09b9421f036 Mon Sep 17 00:00:00 2001
From: linshitao <936633856@qq.com>
Date: Mon, 31 Aug 2026 00:15:18 +0800
Subject: [PATCH 29/33] fix(status): generalize agent state detection
1. Classify visible terminal semantics for approval and response-ready states
2. Preserve legacy prompt patterns and support extensible status rules
3. Map Claude idle notifications to response ready
4. Add detector, UI, hook regressions and documentation
---
crates/codirigent-core/src/hook_installer.rs | 8 +-
crates/codirigent-core/src/types/status.rs | 3 +-
crates/codirigent-detector/src/detector.rs | 274 +++++++++++++++++-
crates/codirigent-detector/src/lib.rs | 9 +-
crates/codirigent-detector/src/patterns.rs | 187 +++++++++++-
crates/codirigent-hook/src/main.rs | 9 +-
crates/codirigent-ui/src/terminal_runtime.rs | 37 +++
.../impl_output_polling/output_runtime.rs | 6 +
docs/architecture/workspace/output-polling.md | 9 +
docs/features/hook-and-status-system.md | 22 ++
10 files changed, 547 insertions(+), 17 deletions(-)
diff --git a/crates/codirigent-core/src/hook_installer.rs b/crates/codirigent-core/src/hook_installer.rs
index 8027ac7d..91402f46 100644
--- a/crates/codirigent-core/src/hook_installer.rs
+++ b/crates/codirigent-core/src/hook_installer.rs
@@ -10,8 +10,8 @@
//! | Event | Matcher | Purpose |
//! |---|---|---|
//! | `UserPromptSubmit` | (all) | Mark session as "working" |
-//! | `Notification` | `idle_prompt\|permission_prompt` | Mark as "idle" or "needs_attention" |
-//! | `Stop` | (all) | Mark session as "idle" |
+//! | `Notification` | `idle_prompt\|permission_prompt` | Mark as "response_ready" or "needs_attention" |
+//! | `Stop` | (all) | Mark session as "response_ready" |
//!
//! # Signal files
//!
@@ -408,9 +408,9 @@ fn hook_definitions() -> &'static [(&'static str, &'static str, &'static str)] {
(
"Notification",
"idle_prompt|permission_prompt",
- "mark session as idle or needs_attention",
+ "mark session as response_ready or needs_attention",
),
- ("Stop", "", "mark session as idle on exit"),
+ ("Stop", "", "mark session as response_ready"),
]
}
diff --git a/crates/codirigent-core/src/types/status.rs b/crates/codirigent-core/src/types/status.rs
index e77560b0..226dcef1 100644
--- a/crates/codirigent-core/src/types/status.rs
+++ b/crates/codirigent-core/src/types/status.rs
@@ -15,7 +15,8 @@ pub enum SessionStatus {
Working,
/// Session needs user attention (input required or permission prompt).
NeedsAttention,
- /// Claude just finished responding (Stop hook) and the session is not focused.
+ /// An agent has finished responding and is waiting for the next user prompt.
+ /// Produced by hooks, structured logs, or terminal-screen semantic rules.
/// Cleared to Idle when the user switches to this session.
ResponseReady,
/// Error detected in output.
diff --git a/crates/codirigent-detector/src/detector.rs b/crates/codirigent-detector/src/detector.rs
index 2f8d0653..83d3cdef 100644
--- a/crates/codirigent-detector/src/detector.rs
+++ b/crates/codirigent-detector/src/detector.rs
@@ -34,7 +34,11 @@
//! let status = detector.get_status(SessionId(1));
//! ```
-use crate::patterns::{compile_patterns, find_matching_pattern_with_limit, get_default_patterns};
+use crate::patterns::{
+ compile_patterns, compile_status_rules, find_matching_pattern_with_limit,
+ find_matching_status_rule, get_default_patterns, get_default_status_rules, CompiledStatusRule,
+ StatusRule,
+};
use crate::platform::{NativeMonitor, PlatformMonitor, ProcessState};
use anyhow::Result;
use codirigent_core::{
@@ -71,6 +75,12 @@ pub struct DetectorConfig {
/// the default pattern set.
pub custom_patterns: Vec,
+ /// Additional semantic screen rules for unsupported agents.
+ ///
+ /// Unlike legacy `custom_patterns`, each rule declares the status it
+ /// produces and may require multiple visible-screen features.
+ pub status_rules: Vec,
+
/// Whether to send desktop notifications when input is required.
///
/// Default: true
@@ -95,6 +105,7 @@ impl Default for DetectorConfig {
poll_interval: Duration::from_millis(250),
idle_threshold: Duration::from_secs(2),
custom_patterns: Vec::new(),
+ status_rules: Vec::new(),
notifications_enabled: true,
max_buffer_size: 4096,
recent_lines_to_check: 5,
@@ -152,6 +163,8 @@ struct MonitoredSession {
current_status: SessionStatus,
/// Pattern that matched, if any.
pattern_matched: Option,
+ /// Status inferred from the latest raw prompt or visible terminal screen.
+ detected_status: Option,
/// Last known shell state from OSC 133 markers.
shell_state: Option,
}
@@ -166,6 +179,7 @@ impl MonitoredSession {
output_buffer: String::new(),
current_status: SessionStatus::Idle,
pattern_matched: None,
+ detected_status: None,
shell_state: None,
}
}
@@ -174,6 +188,7 @@ impl MonitoredSession {
fn clear_buffer(&mut self) {
self.output_buffer.clear();
self.pattern_matched = None;
+ self.detected_status = None;
}
}
@@ -190,6 +205,8 @@ pub struct InputDetector {
sessions: HashMap,
/// Compiled regex patterns.
compiled_patterns: Vec,
+ /// Compiled semantic rules for visible terminal screens.
+ compiled_status_rules: Vec,
/// Event bus for publishing status changes.
event_bus: Arc,
}
@@ -218,6 +235,9 @@ impl InputDetector {
all_patterns.extend(config.custom_patterns.clone());
let compiled_patterns = compile_patterns(&all_patterns);
+ let mut status_rules = get_default_status_rules();
+ status_rules.extend(config.status_rules.clone());
+ let compiled_status_rules = compile_status_rules(&status_rules);
debug!(
pattern_count = compiled_patterns.len(),
@@ -229,6 +249,7 @@ impl InputDetector {
platform_monitor: NativeMonitor::new(),
sessions: HashMap::new(),
compiled_patterns,
+ compiled_status_rules,
event_bus,
}
}
@@ -245,6 +266,10 @@ impl InputDetector {
pub fn process_output(&mut self, session_id: SessionId, data: &[u8]) {
let new_status = if let Some(session) = self.sessions.get_mut(&session_id) {
session.last_output_time = Instant::now();
+ // Any new output invalidates a resting state inferred from the
+ // previous screen. The fresh terminal snapshot will immediately
+ // establish a new semantic state when one is still visible.
+ session.detected_status = None;
// Append to buffer (keep limited size)
let text = String::from_utf8_lossy(data);
@@ -269,6 +294,10 @@ impl InputDetector {
&session.output_buffer,
self.config.recent_lines_to_check,
);
+ session.detected_status = session
+ .pattern_matched
+ .as_ref()
+ .map(|_| SessionStatus::NeedsAttention);
// Active output is enough to treat the session as working unless a
// prompt pattern or OSC 133 shell state says otherwise.
@@ -282,6 +311,61 @@ impl InputDetector {
}
}
+ /// Reclassify a session from its current visible terminal screen.
+ ///
+ /// The visible screen is authoritative for interactive TUI state: it
+ /// replaces the rolling raw-output buffer so a dismissed permission menu
+ /// cannot remain matched after the agent redraws the terminal.
+ pub fn process_visible_screen(&mut self, session_id: SessionId, screen: &str) {
+ let semantic_match = find_matching_status_rule(&self.compiled_status_rules, screen);
+ let legacy_match = if semantic_match.is_none() {
+ find_matching_pattern_with_limit(
+ &self.compiled_patterns,
+ screen,
+ self.config.recent_lines_to_check,
+ )
+ } else {
+ None
+ };
+
+ let new_status = if let Some(session) = self.sessions.get_mut(&session_id) {
+ session.output_buffer.clear();
+ if screen.len() <= self.config.max_buffer_size {
+ session.output_buffer.push_str(screen);
+ } else {
+ let target_start = screen.len() - self.config.max_buffer_size;
+ let start = screen
+ .char_indices()
+ .find(|(index, _)| *index >= target_start)
+ .map(|(index, _)| index)
+ .unwrap_or(screen.len());
+ session.output_buffer.push_str(&screen[start..]);
+ }
+
+ let (detected_status, detail) = match semantic_match {
+ Some((status, rule_name)) => (Some(status), Some(rule_name)),
+ None => match legacy_match {
+ Some(pattern) => (Some(SessionStatus::NeedsAttention), Some(pattern)),
+ None => (None, None),
+ },
+ };
+ session.detected_status = detected_status;
+ session.pattern_matched = if detected_status == Some(SessionStatus::NeedsAttention) {
+ detail
+ } else {
+ None
+ };
+
+ Some(Self::status_while_processing_output(session))
+ } else {
+ None
+ };
+
+ if let Some(new_status) = new_status {
+ self.apply_session_status(session_id, new_status);
+ }
+ }
+
/// Update status for a session.
///
/// Determines the new status based on process state and patterns,
@@ -306,8 +390,8 @@ impl InputDetector {
/// 3. Process state heuristic — fallback for shells without OSC 133
fn determine_status(&self, session: &MonitoredSession) -> SessionStatus {
// 1. Pattern match takes highest priority
- if session.pattern_matched.is_some() {
- return SessionStatus::NeedsAttention;
+ if let Some(status) = session.detected_status {
+ return status;
}
// 2. OSC 133 shell state — reliable, if available
@@ -388,8 +472,8 @@ impl InputDetector {
}
fn status_while_processing_output(session: &MonitoredSession) -> SessionStatus {
- if session.pattern_matched.is_some() {
- SessionStatus::NeedsAttention
+ if let Some(status) = session.detected_status {
+ status
} else if let Some(ref shell_state) = session.shell_state {
Self::status_from_shell_state(shell_state)
} else {
@@ -434,7 +518,7 @@ impl InputDetector {
let needs_tick = self
.sessions
.get(session_id)
- .map(|s| s.shell_state.is_none() || s.pattern_matched.is_some())
+ .map(|s| s.shell_state.is_none() || s.detected_status.is_some())
.unwrap_or(false);
if needs_tick {
let old_status = self.sessions.get(session_id).map(|s| s.current_status);
@@ -542,6 +626,7 @@ impl ProcessMonitor for InputDetector {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::patterns::StatusRule;
use codirigent_core::DefaultEventBus;
fn create_test_detector() -> InputDetector {
@@ -561,6 +646,7 @@ mod tests {
assert_eq!(config.poll_interval, Duration::from_millis(250));
assert_eq!(config.idle_threshold, Duration::from_secs(2));
assert!(config.custom_patterns.is_empty());
+ assert!(config.status_rules.is_empty());
assert!(config.notifications_enabled);
assert_eq!(config.max_buffer_size, 4096);
assert_eq!(config.recent_lines_to_check, 5);
@@ -1073,6 +1159,7 @@ mod tests {
assert!(session.output_buffer.is_empty());
assert_eq!(session.current_status, SessionStatus::Idle);
assert!(session.pattern_matched.is_none());
+ assert!(session.detected_status.is_none());
}
#[test]
@@ -1080,11 +1167,13 @@ mod tests {
let mut session = MonitoredSession::new(SessionId(1), 1234);
session.output_buffer = "some output".to_string();
session.pattern_matched = Some("pattern".to_string());
+ session.detected_status = Some(SessionStatus::NeedsAttention);
session.clear_buffer();
assert!(session.output_buffer.is_empty());
assert!(session.pattern_matched.is_none());
+ assert!(session.detected_status.is_none());
}
// Pattern detection edge cases
@@ -1256,6 +1345,179 @@ mod tests {
);
}
+ #[test]
+ fn test_kimi_command_approval_menu_sets_needs_attention() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+
+ detector.process_output(
+ SessionId(1),
+ concat!(
+ "Run this command?\n",
+ "cwd: D:\\study\\codirigent\\test\\gomoku\n",
+ "$ ls -R src && cat src/App.tsx\n",
+ "1. Approve once\n",
+ "2. Approve for this session\n",
+ "3. Reject\n",
+ "4. Reject with feedback\n",
+ "1/2/3/4 choose · confirm",
+ )
+ .as_bytes(),
+ );
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::NeedsAttention)
+ );
+ }
+
+ #[test]
+ fn test_unknown_agent_approval_menu_sets_needs_attention_from_visible_screen() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+
+ detector.process_visible_screen(
+ SessionId(1),
+ concat!(
+ "Tool wants to execute a command\n",
+ "1. Allow once\n",
+ "2. Allow for this workspace\n",
+ "3. Deny\n",
+ "Use arrows to select, then confirm",
+ ),
+ );
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::NeedsAttention)
+ );
+ }
+
+ #[test]
+ fn test_agent_prompt_and_chrome_sets_response_ready() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+
+ detector.process_visible_screen(
+ SessionId(1),
+ concat!(
+ "Implemented the requested change.\n",
+ ">\n",
+ "manual mode on · ? for shortcuts · + for agents",
+ ),
+ );
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::ResponseReady)
+ );
+ }
+
+ #[test]
+ fn test_explicit_ready_message_sets_response_ready_for_unknown_agent() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+
+ detector.process_visible_screen(SessionId(1), "Idle - Ready for next task");
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::ResponseReady)
+ );
+ }
+
+ #[test]
+ fn test_powershell_prompt_is_idle_not_response_ready() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::PromptStart);
+
+ detector.process_visible_screen(SessionId(1), r"PS D:\repo> ");
+
+ assert_eq!(detector.get_status(SessionId(1)), Some(SessionStatus::Idle));
+ }
+
+ #[test]
+ fn test_new_output_clears_stale_response_ready_screen_hint() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+ detector.process_visible_screen(SessionId(1), "Idle - Ready for next task");
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::ResponseReady)
+ );
+
+ detector.process_output(SessionId(1), b"Starting the next task...");
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::Working)
+ );
+ }
+
+ #[test]
+ fn test_visible_screen_replacement_clears_stale_approval_menu() {
+ let mut detector = create_test_detector();
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+ detector.process_visible_screen(
+ SessionId(1),
+ "Allow this action?\n1. Allow\n2. Deny\nSelect an option to confirm",
+ );
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::NeedsAttention)
+ );
+
+ detector.process_visible_screen(SessionId(1), "Running tests...\nworking");
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::Working)
+ );
+ }
+
+ #[test]
+ fn test_custom_status_rule_supports_unrecognized_agent() {
+ let mut config = DetectorConfig::default();
+ config.status_rules.push(StatusRule {
+ name: "acme-agent-ready".to_string(),
+ status: SessionStatus::ResponseReady,
+ required_patterns: vec![r"ACME_AGENT_AWAITING_INPUT".to_string()],
+ excluded_patterns: Vec::new(),
+ });
+ let mut detector = create_detector_with_config(config);
+ detector
+ .start_monitoring(SessionId(1), std::process::id())
+ .unwrap();
+ detector.set_shell_state(SessionId(1), ShellState::CommandExecuted);
+
+ detector.process_visible_screen(SessionId(1), "ACME_AGENT_AWAITING_INPUT");
+
+ assert_eq!(
+ detector.get_status(SessionId(1)),
+ Some(SessionStatus::ResponseReady)
+ );
+ }
+
#[test]
fn test_set_shell_state_nonexistent_session() {
let mut detector = create_test_detector();
diff --git a/crates/codirigent-detector/src/lib.rs b/crates/codirigent-detector/src/lib.rs
index f3384f0e..f8efa05d 100644
--- a/crates/codirigent-detector/src/lib.rs
+++ b/crates/codirigent-detector/src/lib.rs
@@ -3,8 +3,8 @@
//! Process monitoring, input detection, and status tracking for sessions.
//!
//! This crate provides the core detection functionality for Codirigent,
-//! determining when AI CLI sessions (Claude Code, Codex CLI, Gemini CLI)
-//! are waiting for user input.
+//! determining when known and previously unseen AI CLI sessions are working,
+//! waiting for approval, or ready for the next prompt.
//!
//! # Overview
//!
@@ -13,6 +13,7 @@
//! - **Platform-specific process monitoring** via `libproc` on macOS
//! and Win32 APIs on Windows
//! - **Output pattern matching** to detect common input prompts
+//! - **Visible-screen semantic rules** to classify interactive agent TUIs
//! - **Timing heuristics** to identify idle processes
//!
//! # Modules
@@ -66,6 +67,8 @@
//!
//! Custom patterns can be added via [`InputDetector::add_pattern`] or
//! through [`DetectorConfig::custom_patterns`].
+//! Unsupported agents can map terminal features to any status with
+//! [`StatusRule`] entries in [`DetectorConfig::status_rules`].
//!
//! # Notifications
//!
@@ -92,7 +95,7 @@ pub use notification::{
notify_error, notify_input_required, notify_task_completed, send_notification,
NotificationHandle, NotificationManager, NotificationType,
};
-pub use patterns::{DEFAULT_PATTERNS, DEFAULT_RECENT_LINES_TO_CHECK};
+pub use patterns::{StatusRule, DEFAULT_PATTERNS, DEFAULT_RECENT_LINES_TO_CHECK};
pub use platform::{NativeMonitor, PlatformMonitor, ProcessInfo, ProcessState};
// Re-export the factory function
diff --git a/crates/codirigent-detector/src/patterns.rs b/crates/codirigent-detector/src/patterns.rs
index c198c539..5acda718 100644
--- a/crates/codirigent-detector/src/patterns.rs
+++ b/crates/codirigent-detector/src/patterns.rs
@@ -19,9 +19,49 @@
//! ```
use codirigent_core::context::strip_ansi_codes;
+use codirigent_core::SessionStatus;
use regex::Regex;
use tracing::warn;
+/// A semantic terminal-screen rule that maps visible features to a session status.
+///
+/// Every `required_patterns` regex must match and every `excluded_patterns`
+/// regex must not match. This lets integrations describe an unsupported agent
+/// without adding another hard-coded CLI enum variant.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct StatusRule {
+ /// Stable diagnostic name for the rule.
+ pub name: String,
+ /// Status produced when the rule matches.
+ pub status: SessionStatus,
+ /// Regex features that must all occur in the visible terminal screen.
+ pub required_patterns: Vec,
+ /// Regex features that prevent the rule from matching.
+ pub excluded_patterns: Vec,
+}
+
+#[derive(Debug)]
+pub(crate) struct CompiledStatusRule {
+ name: String,
+ status: SessionStatus,
+ required_patterns: Vec,
+ excluded_patterns: Vec,
+}
+
+impl CompiledStatusRule {
+ fn matches(&self, screen: &str) -> bool {
+ !self.required_patterns.is_empty()
+ && self
+ .required_patterns
+ .iter()
+ .all(|pattern| pattern.is_match(screen))
+ && self
+ .excluded_patterns
+ .iter()
+ .all(|pattern| !pattern.is_match(screen))
+ }
+}
+
/// Default patterns for detecting input prompts.
///
/// These patterns cover common interactive prompts from CLI tools:
@@ -42,8 +82,91 @@ pub const DEFAULT_PATTERNS: &[&str] = &[
r"\(y/N\)",
r"password:",
r"Password:",
+ r"(?is)(approve|allow|permit|authorize|批准|允许).{0,512}(reject|deny|decline|cancel|拒绝|取消).{0,256}(choose|select|confirm|选择|确认)",
];
+/// Return the built-in brand-independent terminal semantic rules.
+pub(crate) fn get_default_status_rules() -> Vec {
+ vec![
+ StatusRule {
+ name: "interactive-approval-menu".to_string(),
+ status: SessionStatus::NeedsAttention,
+ required_patterns: vec![
+ r"(?i)(\bapprove\b|\ballow\b|\bpermit\b|\bauthorize\b|批准|允许)".to_string(),
+ r"(?i)(\breject\b|\bdeny\b|\bdecline\b|\bcancel\b|拒绝|取消)".to_string(),
+ r"(?i)(\bchoose\b|\bselect\b|\bconfirm\b|选择|确认)".to_string(),
+ ],
+ excluded_patterns: Vec::new(),
+ },
+ StatusRule {
+ name: "explicit-ready-for-next-input".to_string(),
+ status: SessionStatus::ResponseReady,
+ required_patterns: vec![
+ r"(?im)^\s*(?:idle\s*[-—:]\s*)?(?:ready|waiting)\s+for\s+(?:your\s+|the\s+)?(?:next\s+)?(?:task|prompt|request|instruction|input)\s*[.!]?\s*$"
+ .to_string(),
+ ],
+ excluded_patterns: Vec::new(),
+ },
+ StatusRule {
+ name: "agent-input-prompt".to_string(),
+ status: SessionStatus::ResponseReady,
+ required_patterns: vec![
+ r"(?i)(manual mode|for shortcuts|for agents|context\s*:?\s*\d+%|\bcontext\s+(?:left|remaining)\b|\btokens?\s*:|\bmodel\s*:|ctrl\+c to (?:stop|interrupt))"
+ .to_string(),
+ r"(?m)^\s*(?:>|›|❯|➜)\s*$".to_string(),
+ ],
+ excluded_patterns: vec![
+ r"(?i)(\bapprove\b|\ballow\b|\bpermit\b|\bauthorize\b|批准|允许).{0,512}(\breject\b|\bdeny\b|\bdecline\b|\bcancel\b|拒绝|取消)"
+ .to_string(),
+ ],
+ },
+ ]
+}
+
+pub(crate) fn compile_status_rules(rules: &[StatusRule]) -> Vec {
+ rules
+ .iter()
+ .filter_map(|rule| {
+ let required_patterns = compile_rule_patterns(&rule.name, &rule.required_patterns)?;
+ let excluded_patterns = compile_rule_patterns(&rule.name, &rule.excluded_patterns)?;
+ if required_patterns.is_empty() {
+ warn!(rule = %rule.name, "Ignoring status rule without required patterns");
+ return None;
+ }
+ Some(CompiledStatusRule {
+ name: rule.name.clone(),
+ status: rule.status,
+ required_patterns,
+ excluded_patterns,
+ })
+ })
+ .collect()
+}
+
+fn compile_rule_patterns(rule_name: &str, patterns: &[String]) -> Option> {
+ patterns
+ .iter()
+ .map(|pattern| match Regex::new(pattern) {
+ Ok(regex) => Some(regex),
+ Err(error) => {
+ warn!(rule = %rule_name, %pattern, %error, "Failed to compile status rule");
+ None
+ }
+ })
+ .collect()
+}
+
+pub(crate) fn find_matching_status_rule(
+ rules: &[CompiledStatusRule],
+ screen: &str,
+) -> Option<(SessionStatus, String)> {
+ let plain_screen = strip_ansi_codes(screen);
+ rules
+ .iter()
+ .find(|rule| rule.matches(&plain_screen))
+ .map(|rule| (rule.status, rule.name.clone()))
+}
+
/// Compile a list of pattern strings into regex objects.
///
/// Invalid patterns are skipped with a warning log message.
@@ -244,7 +367,7 @@ mod tests {
#[test]
fn test_default_patterns_count() {
- assert_eq!(DEFAULT_PATTERNS.len(), 10);
+ assert_eq!(DEFAULT_PATTERNS.len(), 11);
}
#[test]
@@ -328,6 +451,31 @@ mod tests {
assert!(find_matching_pattern(&patterns, "\x1b]133;A\x07PS D:\\repo> ").is_none());
}
+ #[test]
+ fn test_kimi_command_approval_menu_is_an_attention_prompt() {
+ let patterns = compile_patterns(&get_default_patterns());
+ let output = concat!(
+ "Run this command?\n",
+ "cwd: D:\\study\\codirigent\\test\\gomoku\n",
+ "$ ls -R src && cat src/App.tsx\n",
+ "\x1b[36m1. Approve once\x1b[0m\n",
+ "2. Approve for this session\n",
+ "3. Reject\n",
+ "4. Reject with feedback\n",
+ "1/2/3/4 choose · confirm",
+ );
+
+ assert!(find_matching_pattern(&patterns, output).is_some());
+ }
+
+ #[test]
+ fn test_approval_wording_in_prose_is_not_an_attention_prompt() {
+ let patterns = compile_patterns(&get_default_patterns());
+ let output = "The documentation explains when to choose Approve for this session.";
+
+ assert!(find_matching_pattern(&patterns, output).is_none());
+ }
+
#[test]
fn test_find_matching_pattern_press_enter() {
let patterns = compile_patterns(&[r"Press Enter".to_string()]);
@@ -445,6 +593,43 @@ mod tests {
}
}
+ #[test]
+ fn test_default_status_rules_compile() {
+ let rules = get_default_status_rules();
+ let compiled = compile_status_rules(&rules);
+
+ assert_eq!(compiled.len(), rules.len());
+ }
+
+ #[test]
+ fn test_status_rule_requires_all_features_in_any_order() {
+ let rules = compile_status_rules(&[StatusRule {
+ name: "approval".to_string(),
+ status: SessionStatus::NeedsAttention,
+ required_patterns: vec!["Allow".to_string(), "Deny".to_string()],
+ excluded_patterns: Vec::new(),
+ }]);
+
+ let matched = find_matching_status_rule(&rules, "2. Deny\n1. Allow");
+
+ assert_eq!(
+ matched,
+ Some((SessionStatus::NeedsAttention, "approval".to_string()))
+ );
+ }
+
+ #[test]
+ fn test_status_rule_exclusion_prevents_match() {
+ let rules = compile_status_rules(&[StatusRule {
+ name: "ready".to_string(),
+ status: SessionStatus::ResponseReady,
+ required_patterns: vec!["Ready".to_string()],
+ excluded_patterns: vec!["approval pending".to_string()],
+ }]);
+
+ assert!(find_matching_status_rule(&rules, "Ready - approval pending").is_none());
+ }
+
#[test]
fn test_compile_patterns_preserves_order() {
let patterns = vec![
diff --git a/crates/codirigent-hook/src/main.rs b/crates/codirigent-hook/src/main.rs
index 6618e0cc..ca8850d2 100644
--- a/crates/codirigent-hook/src/main.rs
+++ b/crates/codirigent-hook/src/main.rs
@@ -223,6 +223,7 @@ fn map_claude_status(hook_event: Option<&str>, notification_type: Option<&str>)
Some("Stop") => "response_ready",
Some("Notification") => match notification_type {
Some("permission_prompt") => "needs_attention",
+ Some("idle_prompt") => "response_ready",
_ => "idle",
},
_ => "idle",
@@ -380,7 +381,7 @@ mod tests {
}
#[test]
- fn map_status_notification_other_is_idle() {
+ fn map_status_notification_idle_prompt_is_response_ready() {
assert_eq!(
map_status(
Some("Notification"),
@@ -388,8 +389,12 @@ mod tests {
None,
CLI_TYPE_CLAUDE
),
- "idle"
+ "response_ready"
);
+ }
+
+ #[test]
+ fn map_status_notification_without_type_is_idle() {
assert_eq!(
map_status(Some("Notification"), None, None, CLI_TYPE_CLAUDE),
"idle"
diff --git a/crates/codirigent-ui/src/terminal_runtime.rs b/crates/codirigent-ui/src/terminal_runtime.rs
index e00b3463..127d2027 100644
--- a/crates/codirigent-ui/src/terminal_runtime.rs
+++ b/crates/codirigent-ui/src/terminal_runtime.rs
@@ -24,6 +24,31 @@ pub(crate) struct TerminalRenderSnapshot {
pub(crate) cursor_viewport_cell: Option<(usize, usize)>,
}
+impl TerminalRenderSnapshot {
+ /// Reconstruct the current visible terminal viewport as plain text.
+ pub(crate) fn visible_text(&self) -> String {
+ let mut lines = Vec::with_capacity(self.cached_rows.len());
+ for row in &self.cached_rows {
+ let mut runs = row.text_runs_hsla.iter().collect::>();
+ runs.sort_by_key(|(run, _)| run.start_col);
+ let mut line = String::new();
+ let mut cell_cursor = 0usize;
+ for (run, _) in runs {
+ if run.start_col > cell_cursor {
+ line.push_str(&" ".repeat(run.start_col - cell_cursor));
+ }
+ line.push_str(&run.text);
+ cell_cursor = run.start_col + run.cell_count;
+ }
+ lines.push(line.trim_end().to_string());
+ }
+ while lines.last().is_some_and(String::is_empty) {
+ lines.pop();
+ }
+ lines.join("\n")
+ }
+}
+
struct TerminalRuntime {
terminal: Terminal,
theme: CodirigentTheme,
@@ -477,6 +502,18 @@ mod tests {
assert!(!visible_text.contains("KIMI"));
}
+ #[test]
+ fn snapshot_visible_text_preserves_row_and_column_spacing() {
+ let runtime = create_runtime();
+ let snapshot = runtime
+ .apply_output(b"left ok\r\nnext")
+ .expect("runtime output snapshot");
+
+ let visible_text = snapshot.visible_text();
+
+ assert!(visible_text.contains("left ok\nnext"));
+ }
+
#[test]
fn runtime_resize_updates_dimensions() {
let runtime = create_runtime();
diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
index e70d83ef..e5665069 100644
--- a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
+++ b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
@@ -260,6 +260,9 @@ impl WorkspaceView {
let data = drained.data;
let bytes_drained = data.len();
let render_snapshot = runtime.apply_output(&data);
+ let visible_screen = render_snapshot
+ .as_ref()
+ .map(TerminalRenderSnapshot::visible_text);
let detected_cli_type = detect_cli_from_output(&data);
let shell_events = codirigent_session::extract_osc133_events(&data);
@@ -268,6 +271,9 @@ impl WorkspaceView {
{
let mut detector = detector.lock().ok()?;
detector.process_output(session_id, &data);
+ if let Some(screen) = visible_screen.as_deref() {
+ detector.process_visible_screen(session_id, screen);
+ }
for event in shell_events {
// DUAL-PATH: Emitted to channel for phase-2 event routing.
// Also applied directly below via set_shell_state() for correctness now.
diff --git a/docs/architecture/workspace/output-polling.md b/docs/architecture/workspace/output-polling.md
index 0847a555..e5025f69 100644
--- a/docs/architecture/workspace/output-polling.md
+++ b/docs/architecture/workspace/output-polling.md
@@ -160,10 +160,19 @@ response or when post-command follow-up input timing is wrong.
Status is not driven by one source. The system combines multiple hints:
- detector state from `InputDetector`
+- semantic classification of the current visible terminal viewport
- hook-derived status for Claude Code
- JSONL-derived status for Codex/Gemini
- stale-cache handling rules
+The terminal viewport is reconstructed from `TerminalRenderSnapshot` after
+each PTY output batch and passed to `InputDetector::process_visible_screen`.
+This is important for full-screen Agent TUIs: screen redraws replace old
+permission menus, while raw PTY history would keep stale menu text. Semantic
+rules are status-bearing (`NeedsAttention`, `ResponseReady`, and so on) and
+match interaction concepts instead of requiring a known `CliType`. Legacy
+custom input regexes remain compatible and still mean `NeedsAttention`.
+
The actual arbitration happens in:
- `status_engine.rs`
diff --git a/docs/features/hook-and-status-system.md b/docs/features/hook-and-status-system.md
index c40acfc7..a1fb8e3a 100644
--- a/docs/features/hook-and-status-system.md
+++ b/docs/features/hook-and-status-system.md
@@ -95,10 +95,32 @@ hook_event_name notification_type → signal status
UserPromptSubmit (any) → "working"
Stop (any) → "response_ready"
Notification "permission_prompt" → "needs_attention"
+Notification "idle_prompt" → "response_ready"
Notification anything else → "idle"
(unknown event) (any) → "idle"
```
+Claude Code can emit `idle_prompt` immediately after `Stop`. Both events mean
+that Claude has finished and is waiting for the next user prompt, so they map
+to the same `response_ready` signal. This also prevents a later notification
+from overwriting an unread completion with `idle` before the next UI poll.
+
+### Generic Agent fallback
+
+Agents without a dedicated hook or JSONL reader use the terminal detector's
+visible-screen semantic rules. The detector reconstructs the current viewport
+after each PTY output batch and classifies interaction semantics such as:
+
+- a menu containing allow/approve, deny/reject, and select/confirm actions →
+ `needs_attention`
+- an Agent-style empty input prompt plus interaction chrome, or an explicit
+ “ready for next task” state → `response_ready`
+
+These rules do not require the Agent to have a `CliType` enum variant. A new
+integration with unique terminal wording can add a `StatusRule` declaring its
+target status and required/excluded regex features; old custom prompt regexes
+continue to map to `needs_attention`.
+
### Signal File
The hook writes a small JSON file to the signals directory:
From 861712eb0cb1389868909231f9f73d8606f46ef1 Mon Sep 17 00:00:00 2001
From: linshitao <936633856@qq.com>
Date: Mon, 31 Aug 2026 00:27:02 +0800
Subject: [PATCH 30/33] fix(task-modal): support IME and direct text input
1. Register input handlers for all task creation fields.
2. Route IME composition and committed Unicode text correctly.
3. Preserve direct English, digit, and space input with regression tests.
---
crates/codirigent-ui/src/workspace/gpui.rs | 28 ++++-
.../codirigent-ui/src/workspace/gpui/tests.rs | 19 +++
.../src/workspace/impl_modals.rs | 114 +++++++++++++++---
.../src/workspace/task_board_render.rs | 55 +++++++--
4 files changed, 189 insertions(+), 27 deletions(-)
diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs
index 1c43546a..5a6aeeee 100644
--- a/crates/codirigent-ui/src/workspace/gpui.rs
+++ b/crates/codirigent-ui/src/workspace/gpui.rs
@@ -1545,6 +1545,16 @@ impl EntityInputHandler for WorkspaceView {
_window: &mut Window,
cx: &mut Context,
) {
+ let had_ime_overlay = self.ime_marked_range.is_some() || self.ime_preedit_text.is_some();
+ if self.modals.task_creation.is_some() {
+ self.ime_marked_range = None;
+ self.ime_preedit_text = None;
+ let changed = self.insert_task_creation_text(text);
+ if changed || had_ime_overlay {
+ cx.notify();
+ }
+ return;
+ }
if self.has_blocking_modal() {
// Modal text fields are handled via key events; do not leak input to PTY.
return;
@@ -1564,7 +1574,6 @@ impl EntityInputHandler for WorkspaceView {
return;
}
- let had_ime_overlay = self.ime_marked_range.is_some() || self.ime_preedit_text.is_some();
self.ime_marked_range = None;
self.ime_preedit_text = None;
let mut scrolled_to_bottom = false;
@@ -1595,6 +1604,23 @@ impl EntityInputHandler for WorkspaceView {
_window: &mut Window,
cx: &mut Context,
) {
+ if self.modals.task_creation.is_some() {
+ let previous_text = self.ime_preedit_text.clone();
+ let previous_range = self.ime_marked_range.clone();
+ let len = text.encode_utf16().count();
+ if len == 0 {
+ self.ime_marked_range = None;
+ self.ime_preedit_text = None;
+ } else {
+ self.ime_marked_range = Some(0..len);
+ self.ime_preedit_text = Some(text.to_string());
+ }
+
+ if self.ime_preedit_text != previous_text || self.ime_marked_range != previous_range {
+ cx.notify();
+ }
+ return;
+ }
if self.focused_search_session_id().is_some() {
self.ime_marked_range = None;
self.ime_preedit_text = None;
diff --git a/crates/codirigent-ui/src/workspace/gpui/tests.rs b/crates/codirigent-ui/src/workspace/gpui/tests.rs
index 44c14847..570ccfca 100644
--- a/crates/codirigent-ui/src/workspace/gpui/tests.rs
+++ b/crates/codirigent-ui/src/workspace/gpui/tests.rs
@@ -91,3 +91,22 @@ fn test_keystroke_is_not_text_input_for_named_terminal_key() {
assert!(!super::WorkspaceView::keystroke_is_text_input(&event));
}
+
+#[test]
+fn test_keystroke_is_text_input_for_task_modal_ascii_and_digits() {
+ for key in ["a", "7", "space"] {
+ let event = gpui::KeyDownEvent {
+ keystroke: gpui::Keystroke {
+ modifiers: gpui::Modifiers::default(),
+ key: key.to_string(),
+ key_char: None,
+ },
+ is_held: false,
+ };
+
+ assert!(
+ super::WorkspaceView::keystroke_is_text_input(&event),
+ "{key} should continue to the platform text-input handler"
+ );
+ }
+}
diff --git a/crates/codirigent-ui/src/workspace/impl_modals.rs b/crates/codirigent-ui/src/workspace/impl_modals.rs
index c6309e7c..193c4ead 100644
--- a/crates/codirigent-ui/src/workspace/impl_modals.rs
+++ b/crates/codirigent-ui/src/workspace/impl_modals.rs
@@ -556,6 +556,28 @@ impl WorkspaceView {
*cursor += text.chars().count();
}
+ fn insert_text_into_task_modal(modal: &mut TaskCreationModal, text: &str) -> bool {
+ if text.is_empty() {
+ return false;
+ }
+
+ Self::clamp_task_modal_cursor(modal);
+ if let Some((field, cursor)) = Self::focused_field_and_cursor_mut(modal) {
+ Self::insert_at_cursor(field, cursor, text);
+ modal.error = None;
+ true
+ } else {
+ false
+ }
+ }
+
+ pub(super) fn insert_task_creation_text(&mut self, text: &str) -> bool {
+ let Some(modal) = self.modals.task_creation.as_mut() else {
+ return false;
+ };
+ Self::insert_text_into_task_modal(modal, text)
+ }
+
fn backspace_at_cursor(field: &mut String, cursor: &mut usize) {
if *cursor == 0 {
return;
@@ -604,12 +626,20 @@ impl WorkspaceView {
event: &KeyDownEvent,
cx: &mut Context,
) -> bool {
+ let ime_composing = self.ime_marked_range.is_some() || self.ime_preedit_text.is_some();
let Some(modal) = self.modals.task_creation.as_mut() else {
return false;
};
Self::clamp_task_modal_cursor(modal);
let key = event.keystroke.key.to_lowercase();
+ // While an IME composition is active, keys such as Space, Enter, arrows,
+ // and digits belong to the IME candidate UI. The committed text arrives
+ // through EntityInputHandler::replace_text_in_range().
+ if ime_composing {
+ return true;
+ }
+
match key.as_str() {
"escape" => {
self.close_task_creation_modal();
@@ -678,14 +708,6 @@ impl WorkspaceView {
cx.notify();
return true;
}
- "space" => {
- if let Some((field, cursor)) = Self::focused_field_and_cursor_mut(modal) {
- Self::insert_at_cursor(field, cursor, " ");
- }
- modal.error = None;
- cx.notify();
- return true;
- }
_ => {}
}
@@ -722,16 +744,74 @@ impl WorkspaceView {
return true;
}
- if let Some(ref key_char) = event.keystroke.key_char {
- if !key_char.is_empty() {
- if let Some((field, cursor)) = Self::focused_field_and_cursor_mut(modal) {
- Self::insert_at_cursor(field, cursor, key_char);
- modal.error = None;
- cx.notify();
- }
- }
+ // Printable characters (including plain Space) must keep propagating so
+ // the platform can deliver them through EntityInputHandler. The root
+ // keyboard handler already prevents these keys from reaching the PTY.
+ // During IME composition we returned early above, so candidate-selection
+ // digits and Space remain owned by the IME instead.
+ !Self::keystroke_is_text_input(event)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn task_modal(focused_field: usize) -> TaskCreationModal {
+ TaskCreationModal {
+ title: String::new(),
+ description: String::new(),
+ priority: codirigent_core::TaskPriority::Medium,
+ focused_field,
+ cursor_positions: [0, 0, 0],
+ error: Some("stale error".to_string()),
+ project_dir: None,
+ plan_file: String::new(),
+ editing_task_id: None,
}
+ }
- true
+ #[test]
+ fn committed_chinese_text_is_inserted_into_every_task_field() {
+ for focused_field in 0..3 {
+ let mut modal = task_modal(focused_field);
+
+ assert!(WorkspaceView::insert_text_into_task_modal(
+ &mut modal,
+ "中文输入"
+ ));
+
+ let values = [&modal.title, &modal.description, &modal.plan_file];
+ assert_eq!(values[focused_field], "中文输入");
+ assert_eq!(modal.cursor_positions[focused_field], 4);
+ assert!(modal.error.is_none());
+ }
+ }
+
+ #[test]
+ fn committed_text_uses_character_cursor_without_splitting_unicode() {
+ let mut modal = task_modal(0);
+ modal.title = "甲乙".to_string();
+ modal.cursor_positions[0] = 1;
+
+ assert!(WorkspaceView::insert_text_into_task_modal(
+ &mut modal, "任务"
+ ));
+
+ assert_eq!(modal.title, "甲任务乙");
+ assert_eq!(modal.cursor_positions[0], 3);
+ }
+
+ #[test]
+ fn empty_ime_commit_does_not_change_task_field() {
+ let mut modal = task_modal(1);
+ modal.description = "已有内容".to_string();
+ modal.cursor_positions[1] = 4;
+
+ assert!(!WorkspaceView::insert_text_into_task_modal(&mut modal, ""));
+
+ assert_eq!(modal.description, "已有内容");
+ assert_eq!(modal.cursor_positions[1], 4);
+ assert!(modal.error.is_some());
}
}
diff --git a/crates/codirigent-ui/src/workspace/task_board_render.rs b/crates/codirigent-ui/src/workspace/task_board_render.rs
index f5f1f3f0..e5add42f 100644
--- a/crates/codirigent-ui/src/workspace/task_board_render.rs
+++ b/crates/codirigent-ui/src/workspace/task_board_render.rs
@@ -10,9 +10,9 @@ use crate::workspace::gpui::WorkspaceView;
use crate::workspace::render::SessionMenuAction;
use codirigent_core::SessionId;
use gpui::{
- div, prelude::FluentBuilder, px, ClickEvent, Context, FontWeight, InteractiveElement,
- IntoElement, MouseButton, MouseDownEvent, ParentElement, SharedString,
- StatefulInteractiveElement, Styled,
+ canvas, div, prelude::FluentBuilder, px, ClickEvent, Context, ElementInputHandler, Focusable,
+ FontWeight, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement,
+ SharedString, StatefulInteractiveElement, Styled,
};
/// Priority indicator colors in Hsla (consistent with amber/green constants below).
@@ -81,6 +81,29 @@ const GREEN_BG_HOVER: gpui::Hsla = gpui::Hsla {
};
impl WorkspaceView {
+ fn task_modal_input_registration(
+ &self,
+ focused: bool,
+ cx: &mut Context,
+ ) -> impl IntoElement {
+ let entity = cx.entity();
+ let focus_handle = self.focus_handle(cx);
+ canvas(
+ |bounds, _window, _cx| bounds,
+ move |bounds, _prepaint, window, cx| {
+ if focused {
+ window.handle_input(
+ &focus_handle,
+ ElementInputHandler::new(bounds, entity),
+ cx,
+ );
+ }
+ },
+ )
+ .absolute()
+ .inset_0()
+ }
+
/// Convert core Task to UI TaskItem with status mapping.
pub(super) fn core_task_to_ui_item(
&self,
@@ -229,6 +252,7 @@ impl WorkspaceView {
let desc_focused = modal.focused_field == 1;
let plan_focused = modal.focused_field == 2;
let cursor_visible = self.modals.cursor_blink_on;
+ let ime_preedit_text = self.ime_preedit_text.as_deref().unwrap_or("");
let with_cursor =
|value: &str, focused: bool, cursor: usize, placeholder: &str| -> String {
@@ -240,7 +264,7 @@ impl WorkspaceView {
};
}
- if !cursor_visible {
+ if !cursor_visible && ime_preedit_text.is_empty() {
return value.to_string();
}
@@ -250,9 +274,12 @@ impl WorkspaceView {
.nth(cursor)
.map(|(i, _)| i)
.unwrap_or(value.len());
- let mut out = String::with_capacity(value.len() + 1);
+ let mut out = String::with_capacity(value.len() + ime_preedit_text.len() + 1);
out.push_str(&value[..cursor_byte]);
- out.push('|');
+ out.push_str(ime_preedit_text);
+ if cursor_visible {
+ out.push('|');
+ }
out.push_str(&value[cursor_byte..]);
out
};
@@ -282,6 +309,10 @@ impl WorkspaceView {
.map(|p| format!("Project: {}", p.display()))
.unwrap_or_else(|| "Project: (none)".to_string());
+ let title_input_registration = self.task_modal_input_registration(title_focused, cx);
+ let description_input_registration = self.task_modal_input_registration(desc_focused, cx);
+ let plan_input_registration = self.task_modal_input_registration(plan_focused, cx);
+
Some(
div()
.id("task-creation-overlay")
@@ -372,6 +403,7 @@ impl WorkspaceView {
modal.error.is_some(),
&input_style,
)
+ .relative()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _event, _window, cx| {
@@ -384,7 +416,8 @@ impl WorkspaceView {
}
cx.notify();
}),
- ),
+ )
+ .child(title_input_registration),
),
)
.child(
@@ -402,6 +435,7 @@ impl WorkspaceView {
)))
.child(
div()
+ .relative()
.h(px(120.0))
.w_full()
.p_3()
@@ -436,7 +470,8 @@ impl WorkspaceView {
cx.notify();
}),
)
- .child(description_value),
+ .child(description_value)
+ .child(description_input_registration),
),
)
// Priority selector
@@ -512,6 +547,7 @@ impl WorkspaceView {
false,
&input_style,
)
+ .relative()
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _event, _window, cx| {
@@ -524,7 +560,8 @@ impl WorkspaceView {
}
cx.notify();
}),
- ),
+ )
+ .child(plan_input_registration),
),
)
.when_some(modal.error.clone(), |this, error| {
From 130981e728e0dabe329d43c736b6c3802b29a4f5 Mon Sep 17 00:00:00 2001
From: linshitao <936633856@qq.com>
Date: Tue, 1 Sep 2026 19:57:34 +0800
Subject: [PATCH 31/33] fix(terminal): render reverse-video blank cells as
caret blocks
1. Exclude INVERSE-flagged blank cells from the default-background skip path so TUIs that draw their caret as a reverse-video blank (e.g. claude's Ink `\e[7m \e[27m`) render it as a background rect instead of being discarded.
2. Add a regression test asserting a reverse-video blank produces a caret background rect.
---
crates/codirigent-ui/src/terminal_runtime.rs | 31 +++++++++++++++++++-
1 file changed, 30 insertions(+), 1 deletion(-)
diff --git a/crates/codirigent-ui/src/terminal_runtime.rs b/crates/codirigent-ui/src/terminal_runtime.rs
index 127d2027..39e880c0 100644
--- a/crates/codirigent-ui/src/terminal_runtime.rs
+++ b/crates/codirigent-ui/src/terminal_runtime.rs
@@ -362,7 +362,14 @@ fn build_row_cache(
continue;
}
- if c == ' ' && cell.bg == TermColor::Named(NamedColor::Background) {
+ // Skip blank cells with the default background for efficiency. But a
+ // reverse-video blank (`\e[7m `) is how TUIs like claude's Ink draw their
+ // own caret; INVERSE swaps its background to the foreground color, so it
+ // must be kept and rendered as a background rect, not discarded.
+ if c == ' '
+ && cell.bg == TermColor::Named(NamedColor::Background)
+ && !cell.flags.contains(CellFlags::INVERSE)
+ {
continue;
}
@@ -579,4 +586,26 @@ mod tests {
assert_eq!(snapshot.display_offset, snapshot.history_size);
}
+
+ #[test]
+ fn runtime_renders_reverse_video_blank_as_background_rect() {
+ let runtime = create_runtime();
+
+ // claude's Ink draws its caret as a reverse-video blank: \e[7m \e[27m.
+ // The blank cell keeps the default background but gains the INVERSE
+ // flag, so skipping it (the old behaviour) made claude's caret vanish.
+ let snapshot = runtime
+ .apply_output(b"\x1b[7m \x1b[27m")
+ .expect("runtime output snapshot");
+
+ let row = &snapshot.cached_rows[0];
+ let has_caret_rect = row
+ .bg_rects_hsla
+ .iter()
+ .any(|(r, start, end, _)| *r == 0 && *start == 0 && *end == 1);
+ assert!(
+ has_caret_rect,
+ "reverse-video blank must produce a background rect (the caret block)"
+ );
+ }
}
From 57daf00f573cf4e0fb70496db74b970caccf7fb0 Mon Sep 17 00:00:00 2001
From: linshitao <936633856@qq.com>
Date: Tue, 1 Sep 2026 20:12:28 +0800
Subject: [PATCH 32/33] fix(updater): prevent toast click-through
1. Occlude the update toast from terminal mouse input.\n2. Add GPUI hit-testing regression coverage.
---
Cargo.lock | 1071 ++++++++++++++++-
crates/codirigent-ui/Cargo.toml | 1 +
.../src/workspace/toast_render.rs | 110 +-
3 files changed, 1146 insertions(+), 36 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 886b7e59..582fedf1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,15 @@
# It is not intended for manual editing.
version = 3
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -20,6 +29,19 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "const-random",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -137,6 +159,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+[[package]]
+name = "as-raw-xcb-connection"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+
[[package]]
name = "as-slice"
version = "0.2.1"
@@ -146,6 +174,47 @@ dependencies = [
"stable_deref_trait",
]
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "ash-window"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82"
+dependencies = [
+ "ash",
+ "raw-window-handle",
+ "raw-window-metal",
+]
+
+[[package]]
+name = "ashpd"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
+dependencies = [
+ "async-fs",
+ "async-net",
+ "enumflags2",
+ "futures-channel",
+ "futures-util",
+ "rand 0.9.2",
+ "serde",
+ "serde_repr",
+ "url",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols 0.32.10",
+ "zbus",
+]
+
[[package]]
name = "ashpd"
version = "0.12.1"
@@ -456,6 +525,21 @@ dependencies = [
"arrayvec",
]
+[[package]]
+name = "backtrace"
+version = "0.3.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-link 0.2.1",
+]
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -547,6 +631,64 @@ dependencies = [
"core2",
]
+[[package]]
+name = "blade-graphics"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e71cfb73b98eb9f58ee84048aa1bdf4e7497fd20c141b57523499fa066b48fed"
+dependencies = [
+ "ash",
+ "ash-window",
+ "bitflags 2.10.0",
+ "bytemuck",
+ "codespan-reporting",
+ "glow",
+ "gpu-alloc",
+ "gpu-alloc-ash",
+ "hidden-trait",
+ "js-sys",
+ "khronos-egl",
+ "libloading",
+ "log",
+ "mint",
+ "naga",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-metal",
+ "objc2-quartz-core",
+ "objc2-ui-kit",
+ "once_cell",
+ "raw-window-handle",
+ "slab",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "blade-macros"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27142319e2f4c264581067eaccb9f80acccdde60d8b4bf57cc50cd3152f109ca"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
+[[package]]
+name = "blade-util"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a6be3a82c001ba7a17b6f8e413ede5d1004e6047213f8efaf0ffc15b5c4904c"
+dependencies = [
+ "blade-graphics",
+ "bytemuck",
+ "log",
+ "profiling",
+]
+
[[package]]
name = "block"
version = "0.1.6"
@@ -620,6 +762,20 @@ name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.4",
+]
[[package]]
name = "byteorder"
@@ -653,6 +809,18 @@ dependencies = [
"thiserror 1.0.69",
]
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
[[package]]
name = "cast"
version = "0.3.0"
@@ -831,6 +999,22 @@ dependencies = [
"error-code",
]
+[[package]]
+name = "cocoa"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c"
+dependencies = [
+ "bitflags 1.3.2",
+ "block",
+ "cocoa-foundation 0.1.2",
+ "core-foundation 0.9.4",
+ "core-graphics 0.23.2",
+ "foreign-types",
+ "libc",
+ "objc",
+]
+
[[package]]
name = "cocoa"
version = "0.26.0"
@@ -839,14 +1023,28 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2"
dependencies = [
"bitflags 2.10.0",
"block",
- "cocoa-foundation",
+ "cocoa-foundation 0.2.0",
"core-foundation 0.10.1",
- "core-graphics",
+ "core-graphics 0.24.0",
"foreign-types",
"libc",
"objc",
]
+[[package]]
+name = "cocoa-foundation"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
+dependencies = [
+ "bitflags 1.3.2",
+ "block",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "libc",
+ "objc",
+]
+
[[package]]
name = "cocoa-foundation"
version = "0.2.0"
@@ -1099,6 +1297,26 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -1131,6 +1349,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "foreign-types",
+ "libc",
+]
+
[[package]]
name = "core-graphics"
version = "0.24.0"
@@ -1144,6 +1375,19 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core-graphics-helmer-fork"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5"
+dependencies = [
+ "bitflags 2.10.0",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "foreign-types",
+ "libc",
+]
+
[[package]]
name = "core-graphics-types"
version = "0.1.3"
@@ -1186,7 +1430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130"
dependencies = [
"core-foundation 0.10.1",
- "core-graphics",
+ "core-graphics 0.24.0",
"foreign-types",
"libc",
]
@@ -1223,6 +1467,29 @@ dependencies = [
"libm",
]
+[[package]]
+name = "cosmic-text"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8"
+dependencies = [
+ "bitflags 2.10.0",
+ "fontdb 0.16.2",
+ "log",
+ "rangemap",
+ "rustc-hash 1.1.0",
+ "rustybuzz 0.14.1",
+ "self_cell",
+ "smol_str",
+ "swash",
+ "sys-locale",
+ "ttf-parser 0.21.1",
+ "unicode-bidi",
+ "unicode-linebreak",
+ "unicode-script",
+ "unicode-segmentation",
+]
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1802,6 +2069,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "font-types"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23"
+dependencies = [
+ "bytemuck",
+]
+
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
@@ -1811,6 +2087,20 @@ dependencies = [
"roxmltree",
]
+[[package]]
+name = "fontdb"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3"
+dependencies = [
+ "fontconfig-parser",
+ "log",
+ "memmap2",
+ "slotmap",
+ "tinyvec",
+ "ttf-parser 0.20.0",
+]
+
[[package]]
name = "fontdb"
version = "0.23.0"
@@ -1822,7 +2112,7 @@ dependencies = [
"memmap2",
"slotmap",
"tinyvec",
- "ttf-parser",
+ "ttf-parser 0.25.1",
]
[[package]]
@@ -2009,6 +2299,16 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.3",
+ "windows-link 0.2.1",
+]
+
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -2046,6 +2346,12 @@ dependencies = [
"weezl",
]
+[[package]]
+name = "gimli"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+
[[package]]
name = "git2"
version = "0.20.4"
@@ -2092,6 +2398,48 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "glow"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "gpu-alloc"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9"
+dependencies = [
+ "bitflags 2.10.0",
+ "gpu-alloc-types",
+]
+
+[[package]]
+name = "gpu-alloc-ash"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "643756f08ef6def813c776199e4766596395a4c6530373c9a4374a64de2f53a1"
+dependencies = [
+ "ash",
+ "gpu-alloc-types",
+ "tinyvec",
+]
+
+[[package]]
+name = "gpu-alloc-types"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
[[package]]
name = "gpui"
version = "0.2.1"
@@ -2099,21 +2447,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d6cc8eeb8b75347d602fb1919e2c42f26c43c2a75d71b46200a8c24e8ba4c4c"
dependencies = [
"anyhow",
+ "as-raw-xcb-connection",
+ "ashpd 0.11.1",
"async-task",
+ "backtrace",
"bindgen 0.71.1",
+ "blade-graphics",
+ "blade-macros",
+ "blade-util",
"block",
+ "bytemuck",
"calloop",
+ "calloop-wayland-source",
"cbindgen",
- "cocoa",
+ "cocoa 0.26.0",
"core-foundation 0.10.1",
"core-foundation-sys",
- "core-graphics",
+ "core-graphics 0.24.0",
"core-text",
"core-video",
+ "cosmic-text",
"ctor",
"derive_more",
"embed-resource",
"etagere",
+ "filedescriptor",
"flume",
"foreign-types",
"futures",
@@ -2137,6 +2495,7 @@ dependencies = [
"num_cpus",
"objc",
"oo7",
+ "open",
"parking",
"parking_lot",
"pathfinder_geometry",
@@ -2159,12 +2518,22 @@ dependencies = [
"usvg",
"uuid",
"waker-fn",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-cursor",
+ "wayland-protocols 0.31.2",
+ "wayland-protocols-plasma",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-numerics 0.2.0",
"windows-registry 0.5.3",
"workspace-hack",
+ "x11-clipboard",
+ "x11rb",
+ "xkbcommon",
"zed-font-kit",
+ "zed-scap",
+ "zed-xim",
]
[[package]]
@@ -2298,12 +2667,15 @@ dependencies = [
"dunce",
"futures",
"futures-lite 1.13.0",
+ "git2",
"globset",
"gpui_collections",
+ "gpui_util_macros",
"itertools 0.14.0",
"libc",
"log",
"nix 0.29.0",
+ "rand 0.9.2",
"regex",
"rust-embed",
"schemars",
@@ -2370,10 +2742,16 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.15.5"
+version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
-dependencies = [
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
"foldhash",
]
@@ -2422,6 +2800,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+[[package]]
+name = "hidden-trait"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ed9e850438ac849bec07e7d09fbe9309cbd396a5988c30b010580ce08860df"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
[[package]]
name = "hkdf"
version = "0.12.4"
@@ -2819,6 +3208,15 @@ dependencies = [
"serde",
]
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
[[package]]
name = "is-terminal"
version = "0.4.17"
@@ -2830,6 +3228,16 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
[[package]]
name = "itertools"
version = "0.10.5"
@@ -2892,6 +3300,16 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+]
+
[[package]]
name = "kurbo"
version = "0.11.3"
@@ -3267,6 +3685,12 @@ dependencies = [
"simd-adler32",
]
+[[package]]
+name = "mint"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff"
+
[[package]]
name = "mio"
version = "1.1.1"
@@ -3316,6 +3740,7 @@ dependencies = [
"num-traits",
"once_cell",
"rustc-hash 1.1.0",
+ "spirv",
"strum 0.26.3",
"thiserror 2.0.18",
"unicode-ident",
@@ -3397,6 +3822,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
+[[package]]
+name = "ntapi"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -3525,6 +3959,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
+ "objc_exception",
+]
+
+[[package]]
+name = "objc-foundation"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
+dependencies = [
+ "block",
+ "objc",
+ "objc_id",
]
[[package]]
@@ -3668,6 +4114,18 @@ dependencies = [
"objc2-core-foundation",
]
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.10.0",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -3676,7 +4134,40 @@ checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.10.0",
"objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-metal",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.10.0",
+ "objc2",
+ "objc2-core-foundation",
"objc2-foundation",
+ "objc2-quartz-core",
+]
+
+[[package]]
+name = "objc_exception"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc_id"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
+dependencies = [
+ "objc",
]
[[package]]
@@ -3701,7 +4192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d"
dependencies = [
"aes",
- "ashpd",
+ "ashpd 0.12.1",
"async-fs",
"async-io",
"async-lock",
@@ -3735,6 +4226,16 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
+[[package]]
+name = "open"
+version = "5.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a"
+dependencies = [
+ "is-wsl",
+ "libc",
+]
+
[[package]]
name = "openssl-probe"
version = "0.1.6"
@@ -4149,6 +4650,24 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+[[package]]
+name = "quick-xml"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -4278,6 +4797,12 @@ dependencies = [
"getrandom 0.3.4",
]
+[[package]]
+name = "rangemap"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
+
[[package]]
name = "rav1e"
version = "0.8.1"
@@ -4334,6 +4859,18 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+[[package]]
+name = "raw-window-metal"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1"
+dependencies = [
+ "cocoa 0.25.0",
+ "core-graphics 0.23.2",
+ "objc",
+ "raw-window-handle",
+]
+
[[package]]
name = "rayon"
version = "1.11.0"
@@ -4354,6 +4891,16 @@ dependencies = [
"crossbeam-utils",
]
+[[package]]
+name = "read-fonts"
+version = "0.40.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e47cf772000d7aea67a6ea51de8a21b77674aaa538f3b523d89ae6514bf45bb4"
+dependencies = [
+ "bytemuck",
+ "font-types",
+]
+
[[package]]
name = "redox_syscall"
version = "0.2.16"
@@ -4560,6 +5107,12 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "rustc-demangle"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
+
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -4680,6 +5233,23 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+[[package]]
+name = "rustybuzz"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c"
+dependencies = [
+ "bitflags 2.10.0",
+ "bytemuck",
+ "libm",
+ "smallvec",
+ "ttf-parser 0.21.1",
+ "unicode-bidi-mirroring 0.2.0",
+ "unicode-ccc 0.2.0",
+ "unicode-properties",
+ "unicode-script",
+]
+
[[package]]
name = "rustybuzz"
version = "0.20.1"
@@ -4691,9 +5261,9 @@ dependencies = [
"core_maths",
"log",
"smallvec",
- "ttf-parser",
- "unicode-bidi-mirroring",
- "unicode-ccc",
+ "ttf-parser 0.25.1",
+ "unicode-bidi-mirroring 0.4.0",
+ "unicode-ccc 0.4.0",
"unicode-properties",
"unicode-script",
]
@@ -4757,12 +5327,41 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "screencapturekit"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e"
+dependencies = [
+ "screencapturekit-sys",
+]
+
+[[package]]
+name = "screencapturekit-sys"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60"
+dependencies = [
+ "block",
+ "dispatch",
+ "objc",
+ "objc-foundation",
+ "objc_id",
+ "once_cell",
+]
+
[[package]]
name = "sdd"
version = "3.0.10"
@@ -4798,6 +5397,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "self_cell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
+
[[package]]
name = "semver"
version = "1.0.27"
@@ -5061,6 +5666,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
+[[package]]
+name = "skrifa"
+version = "0.43.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cbe997d0f2480442d727488fbe2150779114cbe480ecdbadef58b33e0318ffb"
+dependencies = [
+ "bytemuck",
+ "read-fonts",
+]
+
[[package]]
name = "slab"
version = "0.4.12"
@@ -5099,6 +5714,12 @@ dependencies = [
"futures-lite 2.6.1",
]
+[[package]]
+name = "smol_str"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+
[[package]]
name = "socket2"
version = "0.6.2"
@@ -5118,6 +5739,15 @@ dependencies = [
"lock_api",
]
+[[package]]
+name = "spirv"
+version = "0.3.0+sdk-1.3.268.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -5337,6 +5967,17 @@ dependencies = [
"siphasher",
]
+[[package]]
+name = "swash"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e"
+dependencies = [
+ "skrifa",
+ "yazi",
+ "zeno",
+]
+
[[package]]
name = "syn"
version = "1.0.109"
@@ -5359,6 +6000,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -5379,6 +6031,29 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "sysinfo"
+version = "0.31.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+ "memchr",
+ "ntapi",
+ "rayon",
+ "windows 0.57.0",
+]
+
[[package]]
name = "system-configuration"
version = "0.6.1"
@@ -5418,6 +6093,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb"
+[[package]]
+name = "tao-core-video-sys"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6"
+dependencies = [
+ "cfg-if",
+ "core-foundation-sys",
+ "libc",
+ "objc",
+]
+
[[package]]
name = "tempfile"
version = "3.24.0"
@@ -5514,6 +6201,15 @@ dependencies = [
"zune-jpeg 0.4.21",
]
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -5865,6 +6561,18 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+[[package]]
+name = "ttf-parser"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4"
+
+[[package]]
+name = "ttf-parser"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
+
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -5909,12 +6617,24 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+[[package]]
+name = "unicode-bidi-mirroring"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86"
+
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
+[[package]]
+name = "unicode-ccc"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656"
+
[[package]]
name = "unicode-ccc"
version = "0.4.0"
@@ -5927,6 +6647,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -5985,13 +6711,13 @@ dependencies = [
"base64",
"data-url",
"flate2",
- "fontdb",
+ "fontdb 0.23.0",
"imagesize",
"kurbo",
"log",
"pico-args",
"roxmltree",
- "rustybuzz",
+ "rustybuzz 0.20.1",
"simplecss",
"siphasher",
"strict-num",
@@ -6239,6 +6965,103 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wayland-backend"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.3",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
+dependencies = [
+ "bitflags 2.10.0",
+ "rustix 1.1.3",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078"
+dependencies = [
+ "rustix 1.1.3",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols 0.31.2",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml 0.41.0",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
[[package]]
name = "web-sys"
version = "0.3.85"
@@ -6329,6 +7152,16 @@ dependencies = [
"windows_x86_64_msvc 0.24.0",
]
+[[package]]
+name = "windows"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
+dependencies = [
+ "windows-core 0.57.0",
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows"
version = "0.61.3"
@@ -6354,6 +7187,19 @@ dependencies = [
"windows-numerics 0.3.1",
]
+[[package]]
+name = "windows-capture"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24"
+dependencies = [
+ "parking_lot",
+ "rayon",
+ "thiserror 2.0.18",
+ "windows 0.61.3",
+ "windows-future 0.2.1",
+]
+
[[package]]
name = "windows-collections"
version = "0.2.0"
@@ -6372,14 +7218,26 @@ dependencies = [
"windows-core 0.62.2",
]
+[[package]]
+name = "windows-core"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
+dependencies = [
+ "windows-implement 0.57.0",
+ "windows-interface 0.57.0",
+ "windows-result 0.1.2",
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -6391,8 +7249,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
- "windows-implement",
- "windows-interface",
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -6420,6 +7278,17 @@ dependencies = [
"windows-threading 0.2.1",
]
+[[package]]
+name = "windows-implement"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -6431,6 +7300,17 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "windows-interface"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.114",
+]
+
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -6496,6 +7376,15 @@ dependencies = [
"windows-strings 0.4.2",
]
+[[package]]
+name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -6896,6 +7785,46 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-clipboard"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3"
+dependencies = [
+ "libc",
+ "x11rb",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
+dependencies = [
+ "as-raw-xcb-connection",
+ "gethostname",
+ "libc",
+ "rustix 1.1.3",
+ "x11rb-protocol",
+ "xcursor",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
+
[[package]]
name = "xattr"
version = "0.2.3"
@@ -6905,6 +7834,60 @@ dependencies = [
"libc",
]
+[[package]]
+name = "xcb"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6"
+dependencies = [
+ "bitflags 1.3.2",
+ "libc",
+ "quick-xml 0.30.0",
+ "x11",
+]
+
+[[package]]
+name = "xcursor"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
+
+[[package]]
+name = "xim-ctext"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "xim-parser"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
+[[package]]
+name = "xkbcommon"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
+dependencies = [
+ "as-raw-xcb-connection",
+ "libc",
+ "memmap2",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+
[[package]]
name = "xml-rs"
version = "0.8.28"
@@ -6923,6 +7906,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
+[[package]]
+name = "yazi"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
+
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.0"
@@ -7041,7 +8030,7 @@ dependencies = [
"bitflags 2.10.0",
"byteorder",
"core-foundation 0.10.1",
- "core-graphics",
+ "core-graphics 0.24.0",
"core-text",
"dirs 5.0.1",
"dwrote",
@@ -7107,6 +8096,48 @@ dependencies = [
"windows-registry 0.4.0",
]
+[[package]]
+name = "zed-scap"
+version = "0.0.8-zed"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8"
+dependencies = [
+ "anyhow",
+ "cocoa 0.25.0",
+ "core-graphics-helmer-fork",
+ "log",
+ "objc",
+ "rand 0.8.5",
+ "screencapturekit",
+ "screencapturekit-sys",
+ "sysinfo",
+ "tao-core-video-sys",
+ "windows 0.61.3",
+ "windows-capture",
+ "x11",
+ "xcb",
+]
+
+[[package]]
+name = "zed-xim"
+version = "0.4.0-zed"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642"
+dependencies = [
+ "ahash",
+ "hashbrown 0.14.5",
+ "log",
+ "x11rb",
+ "xim-ctext",
+ "xim-parser",
+]
+
+[[package]]
+name = "zeno"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524"
+
[[package]]
name = "zerocopy"
version = "0.8.37"
diff --git a/crates/codirigent-ui/Cargo.toml b/crates/codirigent-ui/Cargo.toml
index 20d5e72e..95306c5d 100644
--- a/crates/codirigent-ui/Cargo.toml
+++ b/crates/codirigent-ui/Cargo.toml
@@ -61,3 +61,4 @@ clipboard-win = "5.4"
[dev-dependencies]
tempfile.workspace = true
serial_test = "3.2"
+gpui = { workspace = true, features = ["test-support"] }
diff --git a/crates/codirigent-ui/src/workspace/toast_render.rs b/crates/codirigent-ui/src/workspace/toast_render.rs
index eed26133..722030f6 100644
--- a/crates/codirigent-ui/src/workspace/toast_render.rs
+++ b/crates/codirigent-ui/src/workspace/toast_render.rs
@@ -10,6 +10,11 @@ use gpui::{
SharedString, StatefulInteractiveElement, Styled,
};
+/// Keep pointer input inside an update toast from reaching the workspace underneath it.
+fn prevent_update_toast_pointer_passthrough(element: E) -> E {
+ element.occlude()
+}
+
impl WorkspaceView {
/// Render the auto-update toast notification.
///
@@ -41,22 +46,24 @@ impl WorkspaceView {
let muted: gpui::Hsla = theme.muted.into();
let primary: gpui::Hsla = theme.primary.into();
- let mut toast = div()
- .id("update-toast")
- .absolute()
- .bottom(px(16.0))
- .right(px(16.0))
- .bg(panel_bg)
- .border_1()
- .border_color(border_color)
- .rounded_lg()
- .shadow_lg()
- .p_3()
- .flex()
- .flex_col()
- .gap_2()
- .max_w(px(320.0))
- .min_w(px(240.0));
+ let mut toast = prevent_update_toast_pointer_passthrough(
+ div()
+ .id("update-toast")
+ .absolute()
+ .bottom(px(16.0))
+ .right(px(16.0))
+ .bg(panel_bg)
+ .border_1()
+ .border_color(border_color)
+ .rounded_lg()
+ .shadow_lg()
+ .p_3()
+ .flex()
+ .flex_col()
+ .gap_2()
+ .max_w(px(320.0))
+ .min_w(px(240.0)),
+ );
match variant {
ToastVariant::UpdateAvailable { version } => {
@@ -294,3 +301,74 @@ enum ToastVariant {
Downloading { percent: u8 },
ReadyToApply { version: String },
}
+
+#[cfg(test)]
+mod tests {
+ use super::prevent_update_toast_pointer_passthrough;
+ use gpui::{
+ div, point, px, size, InteractiveElement, Modifiers, MouseButton, ParentElement, Styled,
+ TestAppContext,
+ };
+ use std::{cell::Cell, rc::Rc};
+
+ #[gpui::test]
+ fn update_toast_click_does_not_reach_content_behind_it(cx: &mut TestAppContext) {
+ let behind_mouse_downs = Rc::new(Cell::new(0));
+ let toast_mouse_downs = Rc::new(Cell::new(0));
+ let visual_cx = cx.add_empty_window();
+
+ visual_cx.draw(point(px(0.0), px(0.0)), size(px(100.0), px(100.0)), {
+ let behind_mouse_downs = behind_mouse_downs.clone();
+ let toast_mouse_downs = toast_mouse_downs.clone();
+
+ move |_, _| {
+ let behind_mouse_downs = behind_mouse_downs.clone();
+ let toast_mouse_downs = toast_mouse_downs.clone();
+
+ div()
+ .relative()
+ .size_full()
+ .child(div().absolute().inset_0().on_mouse_down(
+ MouseButton::Left,
+ move |_, _, _| {
+ behind_mouse_downs.set(behind_mouse_downs.get() + 1);
+ },
+ ))
+ .child(
+ prevent_update_toast_pointer_passthrough(
+ div()
+ .id("test-update-toast")
+ .debug_selector(|| "test-update-toast".to_string())
+ .absolute()
+ .left(px(20.0))
+ .top(px(20.0))
+ .w(px(60.0))
+ .h(px(60.0)),
+ )
+ .on_mouse_down(
+ MouseButton::Left,
+ move |_, _, _| {
+ toast_mouse_downs.set(toast_mouse_downs.get() + 1);
+ },
+ ),
+ )
+ }
+ });
+
+ let toast_bounds = visual_cx
+ .debug_bounds("test-update-toast")
+ .expect("test update toast should be rendered");
+ visual_cx.simulate_mouse_down(toast_bounds.center(), MouseButton::Left, Modifiers::none());
+
+ assert_eq!(
+ toast_mouse_downs.get(),
+ 1,
+ "toast should receive its own mouse input"
+ );
+ assert_eq!(
+ behind_mouse_downs.get(),
+ 0,
+ "toast click must not reach the content behind it"
+ );
+ }
+}
From a51ac45cc53180c6b6566ecf8e88db8908952ad5 Mon Sep 17 00:00:00 2001
From: linshitao <936633856@qq.com>
Date: Tue, 1 Sep 2026 21:10:01 +0800
Subject: [PATCH 33/33] fix(terminal): improve agent CLI input rendering
1. Anchor IME preedit to hidden-cursor software carets.\n2. Synchronize ConPTY size before restored agents start.\n3. Forward terminal editing control keys while preserving app shortcuts.\n4. Add cursor, resize, resume, focus, and key regression coverage.
---
crates/codirigent-ui/src/input/keyboard.rs | 21 +++++
crates/codirigent-ui/src/terminal_runtime.rs | 80 +++++++++++++++++++
crates/codirigent-ui/src/terminal_view.rs | 71 ++++++++++++----
crates/codirigent-ui/src/workspace/gpui.rs | 45 ++++++++++-
.../src/workspace/gpui/layout_sync.rs | 53 ++++++++----
.../codirigent-ui/src/workspace/gpui/tests.rs | 40 ++++++++++
.../src/workspace/grid_render.rs | 3 +
.../impl_output_polling/output_runtime.rs | 1 +
.../src/workspace/impl_session_lifecycle.rs | 73 ++++++++++++++++-
crates/codirigent-ui/src/workspace/types.rs | 4 +
10 files changed, 355 insertions(+), 36 deletions(-)
diff --git a/crates/codirigent-ui/src/input/keyboard.rs b/crates/codirigent-ui/src/input/keyboard.rs
index 297f17c4..78575635 100644
--- a/crates/codirigent-ui/src/input/keyboard.rs
+++ b/crates/codirigent-ui/src/input/keyboard.rs
@@ -513,6 +513,27 @@ mod tests {
);
}
+ #[test]
+ fn test_terminal_editing_control_sequences() {
+ let mode = TermMode::empty();
+ assert_eq!(
+ key_to_bytes(&make_keystroke("backspace", true, false, false), mode),
+ Some(vec![0x08])
+ );
+ assert_eq!(
+ key_to_bytes(&make_keystroke("w", true, false, false), mode),
+ Some(vec![0x17])
+ );
+ assert_eq!(
+ key_to_bytes(&make_keystroke("left", true, false, false), mode),
+ Some(b"\x1b[1;5D".to_vec())
+ );
+ assert_eq!(
+ key_to_bytes(&make_keystroke("delete", true, false, false), mode),
+ Some(b"\x1b[3;5~".to_vec())
+ );
+ }
+
#[test]
fn test_shift_tab() {
let mode = TermMode::empty();
diff --git a/crates/codirigent-ui/src/terminal_runtime.rs b/crates/codirigent-ui/src/terminal_runtime.rs
index 39e880c0..bbd650f6 100644
--- a/crates/codirigent-ui/src/terminal_runtime.rs
+++ b/crates/codirigent-ui/src/terminal_runtime.rs
@@ -22,6 +22,7 @@ pub(crate) struct TerminalRenderSnapshot {
pub(crate) cached_rows: Vec,
pub(crate) dirty_rows: Option>,
pub(crate) cursor_viewport_cell: Option<(usize, usize)>,
+ pub(crate) software_cursor_viewport_cell: Option<(usize, usize)>,
}
impl TerminalRenderSnapshot {
@@ -307,6 +308,14 @@ impl TerminalRuntime {
} else {
None
};
+ let software_cursor_viewport_cell = if mode.contains(TermMode::SHOW_CURSOR) {
+ None
+ } else {
+ closest_software_cursor_cell(
+ self.cached_rows.as_deref().unwrap_or_default(),
+ cursor_viewport_cell,
+ )
+ };
self.terminal.mark_clean();
self.last_snapshot_mode = mode;
@@ -320,10 +329,37 @@ impl TerminalRuntime {
cached_rows: self.cached_rows.clone().unwrap_or_default(),
dirty_rows,
cursor_viewport_cell,
+ software_cursor_viewport_cell,
}
}
}
+fn closest_software_cursor_cell(
+ cached_rows: &[CachedTerminalRow],
+ hardware_cursor: Option<(usize, usize)>,
+) -> Option<(usize, usize)> {
+ let distance = |candidate: (usize, usize)| {
+ hardware_cursor.map_or((0, 0), |hardware| {
+ (
+ candidate.0.abs_diff(hardware.0),
+ candidate.1.abs_diff(hardware.1),
+ )
+ })
+ };
+
+ cached_rows
+ .iter()
+ .enumerate()
+ .flat_map(|(row, cached)| {
+ cached
+ .software_cursor_cols
+ .iter()
+ .copied()
+ .map(move |col| (row, col))
+ })
+ .min_by_key(|candidate| distance(*candidate))
+}
+
fn build_row_cache(
terminal: &Terminal,
theme: &CodirigentTheme,
@@ -336,12 +372,30 @@ fn build_row_cache(
let mut text_runs: Vec = Vec::new();
let mut background_rects: Vec<(usize, usize, usize, Rgba)> = Vec::new();
+ let mut software_cursor_cols = Vec::new();
let mut current_run: Option = None;
for col in 0..cols {
let cell = &grid[grid_line][Column(col)];
let c = cell.c;
+ if c == ' '
+ && cell.flags.contains(CellFlags::INVERSE)
+ && !cell.flags.contains(CellFlags::WIDE_CHAR_SPACER)
+ {
+ let inverse_to_left = col > 0
+ && grid[grid_line][Column(col - 1)]
+ .flags
+ .contains(CellFlags::INVERSE);
+ let inverse_to_right = col + 1 < cols
+ && grid[grid_line][Column(col + 1)]
+ .flags
+ .contains(CellFlags::INVERSE);
+ if !inverse_to_left && !inverse_to_right {
+ software_cursor_cols.push(col);
+ }
+ }
+
if cell.flags.contains(CellFlags::WIDE_CHAR_SPACER) {
let bg = convert_color(cell.bg, theme);
if bg != theme.terminal_background {
@@ -453,6 +507,7 @@ fn build_row_cache(
})
.collect(),
),
+ software_cursor_cols: Arc::new(software_cursor_cols),
}
}
@@ -607,5 +662,30 @@ mod tests {
has_caret_rect,
"reverse-video blank must produce a background rect (the caret block)"
);
+ assert_eq!(snapshot.software_cursor_viewport_cell, None);
+ }
+
+ #[test]
+ fn runtime_finds_isolated_reverse_video_caret_when_hardware_cursor_is_hidden() {
+ let runtime = create_runtime();
+
+ let snapshot = runtime
+ .apply_output(b"\x1b[?25l> \x1b[7m \x1b[27m\x1b[4;8H")
+ .expect("runtime output snapshot");
+
+ assert!(!snapshot.mode.contains(TermMode::SHOW_CURSOR));
+ assert_eq!(snapshot.cursor_viewport_cell, Some((3, 7)));
+ assert_eq!(snapshot.software_cursor_viewport_cell, Some((0, 2)));
+ }
+
+ #[test]
+ fn runtime_does_not_treat_reverse_video_regions_as_software_carets() {
+ let runtime = create_runtime();
+
+ let snapshot = runtime
+ .apply_output(b"\x1b[?25l\x1b[7m \x1b[27m\x1b[4;8H")
+ .expect("runtime output snapshot");
+
+ assert_eq!(snapshot.software_cursor_viewport_cell, None);
}
}
diff --git a/crates/codirigent-ui/src/terminal_view.rs b/crates/codirigent-ui/src/terminal_view.rs
index 24af5394..96be369a 100644
--- a/crates/codirigent-ui/src/terminal_view.rs
+++ b/crates/codirigent-ui/src/terminal_view.rs
@@ -100,6 +100,10 @@ pub struct CachedTerminalContent {
pub(crate) struct CachedTerminalRow {
pub(crate) bg_rects_hsla: Arc>,
pub(crate) text_runs_hsla: Arc>,
+ /// Columns containing an isolated reverse-video blank. Terminal UIs built
+ /// on Ink and similar renderers use this cell pattern as a software caret
+ /// while the hardware cursor is hidden.
+ pub(crate) software_cursor_cols: Arc>,
}
type ShapedTerminalRow = Arc>;
@@ -326,6 +330,8 @@ pub struct TerminalView {
/// Updated alongside row caches in `ensure_row_caches()` so the render
/// pass never calls `renderable_content()` for cursor/IME positioning.
cached_cursor_viewport_pos: Option<(f32, f32)>,
+ /// Cached viewport-relative software cursor position in pixels.
+ cached_software_cursor_viewport_pos: Option<(f32, f32)>,
/// Scrollbar interaction state.
scrollbar: ScrollbarState,
/// Search overlay state.
@@ -379,6 +385,7 @@ impl TerminalView {
cached_terminal_bg,
cached_terminal_fg,
cached_cursor_viewport_pos: None,
+ cached_software_cursor_viewport_pos: None,
scrollbar: ScrollbarState::default(),
search: SearchState::default(),
};
@@ -415,7 +422,10 @@ impl TerminalView {
self.snapshot_generation = snapshot.generation;
self.cached_rows = snapshot.cached_rows;
self.cached_content = None;
- self.refresh_cursor_cache(snapshot.cursor_viewport_cell);
+ self.refresh_cursor_cache(
+ snapshot.cursor_viewport_cell,
+ snapshot.software_cursor_viewport_cell,
+ );
if display_offset_changed {
self.note_scroll_activity();
@@ -485,7 +495,7 @@ impl TerminalView {
let _ = self.apply_snapshot(snapshot);
} else {
self.mark_dirty();
- self.refresh_cursor_cache(None);
+ self.refresh_cursor_cache(None, None);
}
}
@@ -696,11 +706,17 @@ impl TerminalView {
/// Returns the cached cursor (x, y) for IME preedit anchoring.
///
- /// Unlike `cursor_rect`, this ignores `\e[?25l` visibility so the
- /// preedit overlay tracks the real cursor location even during
- /// Claude Code / Ink redraw cycles.
+ /// When a TUI hides and parks the hardware cursor, prefer the isolated
+ /// reverse-video blank it uses as a software caret. Falling back to the
+ /// hardware position preserves compatibility with hidden-cursor programs
+ /// that do not expose a recognizable software caret.
pub fn ime_anchor_pos(&self) -> Option<(f32, f32)> {
- self.cached_cursor_viewport_pos
+ if self.mode.contains(TermMode::SHOW_CURSOR) {
+ self.cached_cursor_viewport_pos
+ } else {
+ self.cached_software_cursor_viewport_pos
+ .or(self.cached_cursor_viewport_pos)
+ }
}
/// Calculate pixel dimensions for the current terminal size.
@@ -1300,14 +1316,17 @@ impl TerminalView {
(start < end).then_some((start, end))
}
- /// Snapshot the cursor viewport position into `cached_cursor_viewport_pos`.
- fn refresh_cursor_cache(&mut self, cursor_viewport_cell: Option<(usize, usize)>) {
- if let Some((row, col)) = cursor_viewport_cell {
- self.cached_cursor_viewport_pos =
- Some((col as f32 * self.cell_width, row as f32 * self.cell_height));
- } else {
- self.cached_cursor_viewport_pos = None;
- }
+ /// Snapshot hardware and software cursor viewport positions into pixels.
+ fn refresh_cursor_cache(
+ &mut self,
+ cursor_viewport_cell: Option<(usize, usize)>,
+ software_cursor_viewport_cell: Option<(usize, usize)>,
+ ) {
+ let to_pixels = |(row, col): (usize, usize)| {
+ (col as f32 * self.cell_width, row as f32 * self.cell_height)
+ };
+ self.cached_cursor_viewport_pos = cursor_viewport_cell.map(to_pixels);
+ self.cached_software_cursor_viewport_pos = software_cursor_viewport_cell.map(to_pixels);
}
#[cfg(test)]
@@ -1855,6 +1874,26 @@ mod tests {
assert_eq!(cursor.unwrap().shape, CursorShape::Block);
}
+ #[test]
+ fn test_ime_anchor_uses_software_caret_when_hardware_cursor_is_hidden() {
+ let mut view = create_test_view();
+ view.apply_output_for_test(b"\x1b[?25l> \x1b[7m \x1b[27m\x1b[4;8H");
+
+ assert!(view.cursor_rect().is_none());
+ assert_eq!(view.ime_anchor_pos(), Some((2.0 * view.cell_width(), 0.0)));
+ }
+
+ #[test]
+ fn test_ime_anchor_uses_hardware_cursor_when_it_is_visible() {
+ let mut view = create_test_view();
+ view.apply_output_for_test(b"\x1b[2;4H");
+
+ assert_eq!(
+ view.ime_anchor_pos(),
+ Some((3.0 * view.cell_width(), view.cell_height()))
+ );
+ }
+
#[test]
fn test_cached_content_empty() {
let mut view = create_test_view();
@@ -2117,6 +2156,7 @@ mod tests {
cached_rows: Vec::new(),
dirty_rows: None,
cursor_viewport_cell: None,
+ software_cursor_viewport_cell: None,
};
assert!(!view.apply_snapshot(stale));
@@ -2141,6 +2181,7 @@ mod tests {
cached_rows: view.cached_rows.clone(),
dirty_rows: Some(vec![3]),
cursor_viewport_cell: None,
+ software_cursor_viewport_cell: None,
};
assert!(view.apply_snapshot(first));
@@ -2154,6 +2195,7 @@ mod tests {
cached_rows: view.cached_rows.clone(),
dirty_rows: Some(vec![7, 3]),
cursor_viewport_cell: None,
+ software_cursor_viewport_cell: None,
};
assert!(view.apply_snapshot(second));
@@ -2179,6 +2221,7 @@ mod tests {
cached_rows: view.cached_rows.clone(),
dirty_rows: None,
cursor_viewport_cell: None,
+ software_cursor_viewport_cell: None,
};
assert!(view.apply_snapshot(full));
diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs
index 5a6aeeee..ec543a63 100644
--- a/crates/codirigent-ui/src/workspace/gpui.rs
+++ b/crates/codirigent-ui/src/workspace/gpui.rs
@@ -283,6 +283,44 @@ impl WorkspaceView {
key.chars().count() == 1
}
+ /// Whether a Control-modified key is a terminal line-editing operation.
+ ///
+ /// Codirigent reserves a small set of unshifted Control shortcuts on
+ /// Windows/Linux. Other readline/TUI editing keys must reach the PTY.
+ pub(super) fn control_keystroke_is_terminal_editing(event: &KeyDownEvent) -> bool {
+ let modifiers = &event.keystroke.modifiers;
+ if !modifiers.control || modifiers.platform || modifiers.alt {
+ return false;
+ }
+
+ let key = event.keystroke.key.to_ascii_lowercase();
+ if matches!(
+ key.as_str(),
+ "backspace"
+ | "delete"
+ | "left"
+ | "right"
+ | "up"
+ | "down"
+ | "home"
+ | "end"
+ | "pageup"
+ | "pagedown"
+ ) {
+ return true;
+ }
+
+ // Shifted Control letters are application shortcuts (for example
+ // Ctrl+Shift+N/E/T/K/F/L). Unshifted terminal editing controls remain
+ // available except for Codirigent's explicit Ctrl+C/V/Q and pane keys.
+ !modifiers.shift
+ && key.chars().count() == 1
+ && !matches!(
+ key.as_str(),
+ "c" | "v" | "q" | "," | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
+ )
+ }
+
pub(super) fn set_session_codex_execution_mode(
&mut self,
session_id: SessionId,
@@ -1116,10 +1154,11 @@ impl WorkspaceView {
}
// On Windows/Linux, Ctrl sets modifiers.control (not modifiers.platform).
- // Guard here so Ctrl+ never reaches the PTY even if the GPUI action
- // system fails to match a secondary-* binding.
+ // Keep Codirigent shortcuts out of the PTY, but allow terminal-native
+ // editing controls such as Ctrl+Backspace, Ctrl+W and Ctrl+A/Ctrl+E.
#[cfg(not(target_os = "macos"))]
- if event.keystroke.modifiers.control {
+ if event.keystroke.modifiers.control && !Self::control_keystroke_is_terminal_editing(event)
+ {
return;
}
diff --git a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs
index f4f4c139..be3c3d24 100644
--- a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs
+++ b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs
@@ -11,6 +11,10 @@ use std::hash::{Hash, Hasher};
use std::time::{Duration, Instant};
use tracing::warn;
+fn pty_resize_required(last: Option<(u16, u16)>, target: (u16, u16)) -> bool {
+ last != Some(target)
+}
+
impl WorkspaceView {
fn current_layout_mode_for_shortcuts(&self) -> LayoutMode {
if let Some(split_state) = self.workspace.layout_state().as_split_tree() {
@@ -179,6 +183,7 @@ impl WorkspaceView {
fn resize_terminals_to_grid(&mut self) -> bool {
// Layout constants from types.rs: HEADER_HEIGHT, TERMINAL_CONTENT_PADDING, CELL_BORDER_WIDTH
let mut resized_any = false;
+ let mut synchronized_pty_sessions = Vec::new();
for info in &self.cache.render_cell_info {
if let Some(terminal_view) = self.terminals.get_mut(&info.session_id) {
@@ -217,26 +222,37 @@ impl WorkspaceView {
if did_resize {
resized_any = true;
+ }
- // Propagate resize to actual PTY (ConPTY) so the shell
- // knows the correct terminal dimensions
- let rows = terminal_view.rows();
- let cols = terminal_view.cols();
- let last = self.cache.pty_sizes.get(&info.session_id);
- if last != Some(&(rows, cols)) {
- self.with_session_manager(|manager| {
- if let Err(e) = manager.resize(info.session_id, rows, cols) {
- warn!(
- "Failed to resize PTY for session {}: {}",
- info.session_id, e
- );
- }
- });
- self.cache.pty_sizes.insert(info.session_id, (rows, cols));
+ // Synchronize ConPTY independently of the emulator resize.
+ // A newly created PTY starts at 80x24, while a TerminalView may
+ // already have the target dimensions from a cached layout.
+ let rows = terminal_view.rows();
+ let cols = terminal_view.cols();
+ let last = self.cache.pty_sizes.get(&info.session_id).copied();
+ if pty_resize_required(last, (rows, cols)) {
+ let resize_result = self.with_session_manager(|manager| {
+ manager.resize(info.session_id, rows, cols)
+ });
+ match resize_result {
+ Ok(()) => {
+ self.cache.pty_sizes.insert(info.session_id, (rows, cols));
+ synchronized_pty_sessions.push(info.session_id);
+ }
+ Err(e) => warn!(
+ "Failed to resize PTY for session {}: {}",
+ info.session_id, e
+ ),
}
}
}
}
+
+ // If the shell became ready before the first layout pass, the queued
+ // Agent CLI command can now start at the real pane width.
+ for session_id in synchronized_pty_sessions {
+ self.dispatch_pending_resume_commands_for_session(session_id);
+ }
resized_any
}
@@ -361,6 +377,13 @@ mod tests {
));
}
+ #[test]
+ fn test_new_pty_is_synchronized_even_when_terminal_grid_already_matches() {
+ assert!(super::pty_resize_required(None, (40, 160)));
+ assert!(super::pty_resize_required(Some((24, 80)), (40, 160)));
+ assert!(!super::pty_resize_required(Some((40, 160)), (40, 160)));
+ }
+
#[test]
fn test_rendered_session_signature_changes_when_visible_sessions_change() {
let a = super::WorkspaceView::rendered_session_signature_for_ids(&[
diff --git a/crates/codirigent-ui/src/workspace/gpui/tests.rs b/crates/codirigent-ui/src/workspace/gpui/tests.rs
index 570ccfca..cb072619 100644
--- a/crates/codirigent-ui/src/workspace/gpui/tests.rs
+++ b/crates/codirigent-ui/src/workspace/gpui/tests.rs
@@ -110,3 +110,43 @@ fn test_keystroke_is_text_input_for_task_modal_ascii_and_digits() {
);
}
}
+
+fn control_event(key: &str, shift: bool) -> gpui::KeyDownEvent {
+ gpui::KeyDownEvent {
+ keystroke: gpui::Keystroke {
+ modifiers: gpui::Modifiers {
+ control: true,
+ shift,
+ ..gpui::Modifiers::default()
+ },
+ key: key.to_string(),
+ key_char: None,
+ },
+ is_held: false,
+ }
+}
+
+#[test]
+fn test_terminal_editing_control_keys_are_forwarded() {
+ for key in ["backspace", "delete", "left", "right", "a", "e", "w"] {
+ assert!(
+ super::WorkspaceView::control_keystroke_is_terminal_editing(&control_event(key, false)),
+ "Ctrl+{key} should reach the PTY"
+ );
+ }
+}
+
+#[test]
+fn test_workspace_control_shortcuts_remain_reserved() {
+ for key in ["c", "v", "q", ",", "1", "9"] {
+ assert!(
+ !super::WorkspaceView::control_keystroke_is_terminal_editing(&control_event(
+ key, false
+ )),
+ "Ctrl+{key} should remain a workspace shortcut"
+ );
+ }
+ assert!(
+ !super::WorkspaceView::control_keystroke_is_terminal_editing(&control_event("n", true))
+ );
+}
diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs
index 2b838c70..797361f3 100644
--- a/crates/codirigent-ui/src/workspace/grid_render.rs
+++ b/crates/codirigent-ui/src/workspace/grid_render.rs
@@ -225,6 +225,9 @@ impl WorkspaceView {
let fh = self.focus_handle(cx);
let is_focused = self.workspace.focused_session_id() == Some(session_id);
let input_enabled = !self.has_blocking_modal();
+ if let Some(terminal_view) = self.terminals.get_mut(&session_id) {
+ terminal_view.set_focused(is_focused && input_enabled);
+ }
let (terminal_content, canvas_origin) = self.render_terminal_content(
session_id,
theme,
diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
index e5665069..e9a16e7b 100644
--- a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
+++ b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs
@@ -378,6 +378,7 @@ impl WorkspaceView {
// so it is alive and can accept input. Flush any queued resume
// commands for this session now.
if bytes_drained > 0 {
+ self.polling.resume_shell_ready.insert(session_id);
self.dispatch_pending_resume_commands_for_session(session_id);
}
diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
index 56bdc029..bfb67926 100644
--- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
+++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs
@@ -81,6 +81,14 @@ struct CompletedRestoreBootstrap {
/// sent anyway (the shell may not emit OSC 133 or may be very slow to start).
const RESUME_COMMAND_FALLBACK_TIMEOUT: Duration = Duration::from_secs(3);
+fn resume_command_is_ready(shell_output_seen: bool, pty_size_synced: bool) -> bool {
+ shell_output_seen && pty_size_synced
+}
+
+fn timed_out_resume_can_dispatch(visible: bool, pty_size_synced: bool) -> bool {
+ pty_size_synced || !visible
+}
+
fn legacy_pane_stacks_from_groups(
saved_sessions: &[Session],
pane_tab_groups: &[PaneTabGroup],
@@ -682,6 +690,21 @@ mod tests {
);
}
+ #[test]
+ fn restored_agent_waits_for_shell_output_and_real_pty_size() {
+ assert!(!resume_command_is_ready(false, false));
+ assert!(!resume_command_is_ready(true, false));
+ assert!(!resume_command_is_ready(false, true));
+ assert!(resume_command_is_ready(true, true));
+ }
+
+ #[test]
+ fn visible_timed_out_agent_still_waits_for_real_pty_size() {
+ assert!(!timed_out_resume_can_dispatch(true, false));
+ assert!(timed_out_resume_can_dispatch(true, true));
+ assert!(timed_out_resume_can_dispatch(false, false));
+ }
+
#[test]
fn bootstrap_session_returns_session_metadata() {
let session_manager = create_test_session_manager();
@@ -1256,6 +1279,9 @@ impl WorkspaceView {
}
fn create_terminal_view_for_session(&mut self, session_id: SessionId) {
+ // Session IDs can be reused after closing a pane. A new ConPTY always
+ // starts at its default 80x24 size and must be synchronized again.
+ self.cache.pty_sizes.remove(&session_id);
let (pty_tx, pty_rx) = tokio::sync::mpsc::unbounded_channel();
let terminal = Terminal::new(24, 80, session_id, pty_tx);
let theme = self.workspace.theme();
@@ -1496,6 +1522,7 @@ impl WorkspaceView {
pending_commands: Vec<(SessionId, Vec)>,
) {
let now = std::time::Instant::now();
+ let mut enqueued_sessions = Vec::new();
for (session_id, commands) in pending_commands {
if !commands.is_empty() {
info!(
@@ -1506,19 +1533,38 @@ impl WorkspaceView {
self.polling
.pending_resume_commands
.insert(session_id, (now, commands));
+ enqueued_sessions.push(session_id);
}
}
+
+ // Output or the first layout pass may have completed while the restore
+ // batch was still being assembled. Dispatch immediately when both
+ // readiness signals are already present.
+ for session_id in enqueued_sessions {
+ self.dispatch_pending_resume_commands_for_session(session_id);
+ }
}
/// Dispatch pending resume commands for a specific session.
///
- /// Called when the output pipeline delivers real bytes for this session
- /// (the shell is alive and producing output) or when the fallback timeout
- /// fires.
+ /// Called when the output pipeline delivers real bytes for this session or
+ /// after PTY sizing completes. Both readiness signals must be present.
pub(super) fn dispatch_pending_resume_commands_for_session(&mut self, session_id: SessionId) {
+ if !resume_command_is_ready(
+ self.polling.resume_shell_ready.contains(&session_id),
+ self.cache.pty_sizes.contains_key(&session_id),
+ ) {
+ return;
+ }
+
+ self.dispatch_pending_resume_commands_now(session_id);
+ }
+
+ fn dispatch_pending_resume_commands_now(&mut self, session_id: SessionId) {
let Some((_, commands)) = self.polling.pending_resume_commands.remove(&session_id) else {
return;
};
+ self.polling.resume_shell_ready.remove(&session_id);
info!(
?session_id,
command_count = commands.len(),
@@ -1550,11 +1596,27 @@ impl WorkspaceView {
.map(|(session_id, _)| *session_id)
.collect();
for session_id in expired {
+ let visible = self.workspace.visible_session_ids().contains(&session_id);
+ if !timed_out_resume_can_dispatch(
+ visible,
+ self.cache.pty_sizes.contains_key(&session_id),
+ ) {
+ info!(
+ ?session_id,
+ "Resume timeout reached, waiting for visible pane PTY size"
+ );
+ if let Some((enqueued_at, _)) =
+ self.polling.pending_resume_commands.get_mut(&session_id)
+ {
+ *enqueued_at = std::time::Instant::now();
+ }
+ continue;
+ }
info!(
?session_id,
"Resume command fallback timeout — dispatching without prompt"
);
- self.dispatch_pending_resume_commands_for_session(session_id);
+ self.dispatch_pending_resume_commands_now(session_id);
}
}
@@ -2022,8 +2084,11 @@ impl WorkspaceView {
readers.cached_status.remove(&id);
}
self.polling.shell_input_buffers.remove(&id);
+ self.polling.pending_resume_commands.remove(&id);
+ self.polling.resume_shell_ready.remove(&id);
self.cache.effective_shell_labels.remove(&id);
self.cache.restore_shell_fallbacks.remove(&id);
+ self.cache.pty_sizes.remove(&id);
// Remove from output dispatcher tracking (ready/in-flight sets)
self.output_dispatcher.remove_session(id);
diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs
index ba33e45b..77943ca4 100644
--- a/crates/codirigent-ui/src/workspace/types.rs
+++ b/crates/codirigent-ui/src/workspace/types.rs
@@ -537,6 +537,9 @@ pub(super) struct PollingState {
/// Commands are sent as soon as the first PTY output is received (the shell
/// is alive) or after `RESUME_COMMAND_FALLBACK_TIMEOUT` as a safety net.
pub pending_resume_commands: HashMap)>,
+ /// Restored sessions whose shell has produced output and is ready to
+ /// receive its resume command once the PTY has its rendered pane size.
+ pub resume_shell_ready: HashSet,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -574,6 +577,7 @@ impl PollingState {
last_legacy_fallback: Instant::now(),
shell_input_buffers: HashMap::new(),
pending_resume_commands: HashMap::new(),
+ resume_shell_ready: HashSet::new(),
}
}
}
| |