From 553c73357533b3fe3ed91b4ff5f47b7a09de164e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:58:13 -0700 Subject: [PATCH 1/3] fix(shell): re-assert a module tile's pending keyboard focus after it draws A focus that cannot land at launch (the tile is not drawn yet) is kept in `pending_focus` and re-asserted when the client's first frame arrives, on the PresentableDraw path. A module tile never sends a frame, so its pending focus was never retried: ModuleView stayed unfocused, its gate dropped every key, and a module opened from the dock or launcher took no typing until the person clicked into it. After the UI handles a Draw, retry a pending focus that belongs to a module client. On success it clears; otherwise nothing forces a redraw, so a tile that still cannot take it just tries again on the next natural draw. Found with the Terminal module: typed keys never reached its root (a probe in its key path logged nothing) until the tile was clicked; with this change `echo` typed right after opening it from the dock ran. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 49d4f62a..6e31769e 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -5114,6 +5114,23 @@ impl App { } else { self.ui.handle_event(cx, event, &mut Scope::empty()); } + // A focus that couldn't land at launch (tile not yet drawn) is + // re-asserted for a process tile when its first frame arrives + // (PresentableDraw). A module tile sends no frame, so retry after the + // draw that may have created it; nothing forces a redraw, so a tile + // that still cannot take it just tries again on the next draw. + if let (Event::Draw(_), Some(client)) = (event, self.pending_focus) { + if self.module_host.is_module(client) { + let focused = self + .desk(cx) + .borrow_mut::() + .and_then(|mut d| d.with_tile(cx, client, |cx, v| v.focus_keyboard(cx))) + .unwrap_or(false); + if focused { + self.pending_focus = None; + } + } + } self.sync_phone_keyboard(cx); // Style reloads and phone capture teardown may retire draw lists during // this event. Remove their pass roots before upstream scans GPU demand. From e742fe985ee5ca0037a01c2b4fc71cc012f9da0e Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:58:13 -0700 Subject: [PATCH 2/3] feat(desktop): ship Terminal as a system app (app-terminal) Terminal was a developer program: built on first launch from the Makepad checkout, then run as a child process. It now links in-process like Rinx: the new `app-terminal` feature (on in the desktop's default set) registers makepad-terminal's TERMINAL_MODULE, and `terminal` joins robrix and finance as a module hosted in-process by default unless the person switched it (`terminal: Process` in wm/apps.splash keeps the process form, whose config/apps.json row stays). - Opens instantly: no compile on first launch, no swapchain round trip. - On macOS its PTY helper is `octosense` itself (Cx::pre_start answers `--exec-pty`), so nothing ships beside the binary. - It declares `process` and `clipboard` and no `octos.*` service. The assistant gets read_screen and read_scrollback only; `run` (an unsandboxed shell, rated Risk::Act) is not offered by a default system app. - Phone builds are unchanged: `mobile-apps` does not include it. Builds on the current makepad pin (75e3e24d), which already carries TERMINAL_MODULE, the PTY-helper fallback and the macOS signal-wake fix (OctoSense-org/makepad #40, #41); Cargo.toml pins makepad-terminal there. Tabs, panes, settings and terminal-ctl (makepad #47) arrive with a later pin bump. Verified locally with those makepad commits applied under .sources/makepad: - cargo check --locked -p octosense (and --features mobile-apps) - bash tools/check-shell-graph.sh -p octosense - cargo test --locked -p octosense-shell --features app-terminal: 333 passed - from phone/: cargo test --locked --features mobile-apps -p octosense-shell: 342 passed - hidden desktop over MAKEPAD_REMOTE: Terminal opens from the dock as "in-process, terminal"; the shell's parent is octosense; octoscode's composer stayed clean on 3 of 3 launches; closing the tile ended zsh and octoscode and freed the isolate; two Terminals ran two shells. Rebased onto main: cargo build --locked -p octosense and tools/check-shell-graph.sh pass against the pinned makepad with the reviewed runtime patches; cargo test -p octosense-shell --features app-terminal: 345 passed. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 14 ++++++++++++++ Cargo.toml | 2 ++ crates/shell/Cargo.toml | 4 ++++ crates/shell/src/apps.rs | 27 +++++++++++++++++++++++++-- desktop/Cargo.toml | 7 +++++-- desktop/README.md | 3 ++- desktop/README.zh-CN.md | 3 ++- 7 files changed, 54 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c92151a..44bf3dd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5548,6 +5548,19 @@ dependencies = [ "makepad-live-id", ] +[[package]] +name = "makepad-terminal" +version = "0.1.0" +dependencies = [ + "makepad-ai-services", + "makepad-app-module", + "makepad-strict-json", + "makepad-widgets", + "makepad-wm-api", + "makepad-wm-theme", + "windows 0.62.2", +] + [[package]] name = "makepad-tsdf" version = "0.1.0" @@ -7515,6 +7528,7 @@ dependencies = [ "makepad-sheets", "makepad-strict-json", "makepad-studio-protocol", + "makepad-terminal", "makepad-widgets", "makepad-wm-api", "makepad-wm-theme", diff --git a/Cargo.toml b/Cargo.toml index df882a08..84ddc826 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ makepad-wm-api = { git = "https://github.com/OctoSense-org/makepad.git", rev = " makepad-ai-services = { git = "https://github.com/OctoSense-org/makepad.git", rev = "75e3e24dbfb37381edb2745f62eba3615b335897" } makepad-strict-json = { git = "https://github.com/OctoSense-org/makepad.git", rev = "75e3e24dbfb37381edb2745f62eba3615b335897" } makepad-app-module = { git = "https://github.com/OctoSense-org/makepad.git", rev = "75e3e24dbfb37381edb2745f62eba3615b335897" } +makepad-terminal = { git = "https://github.com/OctoSense-org/makepad.git", rev = "75e3e24dbfb37381edb2745f62eba3615b335897" } makepad-wm-theme = { git = "https://github.com/OctoSense-org/makepad.git", rev = "75e3e24dbfb37381edb2745f62eba3615b335897" } makepad-map-nav = { git = "https://github.com/OctoSense-org/makepad.git", rev = "75e3e24dbfb37381edb2745f62eba3615b335897" } makepad-sheets = { git = "https://github.com/OctoSense-org/makepad.git", rev = "75e3e24dbfb37381edb2745f62eba3615b335897", default-features = false } @@ -196,6 +197,7 @@ makepad-wm-api = { path = ".sources/makepad/libs/wm_api" } makepad-ai-services = { path = ".sources/makepad/libs/ai/services" } makepad-strict-json = { path = ".sources/makepad/libs/strict_json" } makepad-app-module = { path = ".sources/makepad/libs/app_module" } +makepad-terminal = { path = ".sources/makepad/apps/terminal" } makepad-wm-theme = { path = ".sources/makepad/libs/wm_theme" } makepad-map-nav = { path = ".sources/makepad/libs/map_nav" } makepad-sheets = { path = ".sources/makepad/apps/sheets" } diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index e5ee0798..19f41d03 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -39,6 +39,7 @@ makepad-strict-json = { workspace = true } makepad-app-module = { workspace = true } makepad-wm-theme = { workspace = true } makepad-sheets = { workspace = true, optional = true } +makepad-terminal = { workspace = true, optional = true } octoscript-makepad = { workspace = true } # The L0 checker the glance service admits published cards with (glance.rs). octoscript-ui-l0 = { workspace = true } @@ -88,6 +89,9 @@ app-rinx = ["dep:rinx", "octos-core"] app-hub = ["dep:octosense-app-hub-app", "dep:octosense-appstore", "dep:octosense-mail-service", "dep:octosense-news-service", "octosense-ai-host/llm"] app-reference = ["dep:octosense-reference"] app-sheets = ["dep:makepad-sheets"] +# The terminal as a system app: linked in-process and module-hosted by +# default on the desktop (apps.rs), unlike the other desktop modules. +app-terminal = ["dep:makepad-terminal"] # Native Photos, News and OctosMap modules; without them these are the system # script apps (`os.photos`, `os.news`, `os.maps`, ADR 0004). app-photos = ["dep:octosense-photos"] diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 18c7831f..d3a40d00 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -60,6 +60,11 @@ fn linked_modules() -> Vec<&'static dyn AppModule> { out.push(&octosense_reference::REFERENCE_MODULE); #[cfg(any(feature = "app-sheets", native_mobile))] out.push(&makepad_sheets::SHEETS_MODULE); + // Terminal is a system app on the desktop: a login shell in a tile. Its + // PTY helper on macOS is this executable (`--exec-pty`, handled in + // `Cx::pre_start`), so it needs no second binary shipped beside it. + #[cfg(feature = "app-terminal")] + out.push(&makepad_terminal::TERMINAL_MODULE); // The native Photos library, albums and Memories. Without it Photos is // the system app (`os.photos`); a linked module of the same id wins. #[cfg(feature = "app-photos")] @@ -391,7 +396,7 @@ impl AppRegistry { // the person switched them. // (Rinx is Robrix renamed: under the old id the desktop fell through // to a process launch and failed with "binary not found: rinx".) - if matches!(id, "rinx" | "finance") && self.module(id).is_some() && !self.overrides.contains_key(id) { + if matches!(id, "rinx" | "finance" | "terminal") && self.module(id).is_some() && !self.overrides.contains_key(id) { return Hosting::Module; } // A system or installed app has no process form anywhere: the `card` @@ -596,7 +601,8 @@ mod tests { let registry = AppRegistry::load(Path::new("/nonexistent/apps.splash"), &["--module".to_string(), "sheets".to_string(), "--module".to_string(), "files".to_string()]); // files has no linked module: the flag cannot make it one. assert_eq!(registry.hosting("files"), Hosting::Process); - assert_eq!(registry.hosting("terminal"), Hosting::Process); + #[cfg(not(feature = "app-terminal"))] + assert_eq!(registry.hosting("terminal"), Hosting::Process, "no linked terminal: a process"); #[cfg(feature = "app-sheets")] { assert_eq!(registry.hosting("sheets"), Hosting::Module); @@ -606,6 +612,23 @@ mod tests { } } + /// Terminal is a system app: linked, it is in-process by default on every + /// target, and a person who switched it to a process keeps that. + #[cfg(feature = "app-terminal")] + #[test] + fn the_linked_terminal_is_a_module_unless_switched() { + let plain = AppRegistry::default(); + assert!(plain.linked_ids().contains(&"terminal")); + assert_eq!(plain.hosting("terminal"), Hosting::Module, "a system app: in-process by default"); + let mut switched = AppRegistry::default(); + switched.overrides.insert("terminal".into(), Hosting::Process); + if crate::host::processes_available() { + assert_eq!(switched.hosting("terminal"), Hosting::Process, "the person's switch wins"); + } + let term: &'static dyn AppModule = &makepad_terminal::TERMINAL_MODULE; + assert!(module_open(term, &card_row("terminal".into(), "Terminal".into(), Vec::new())).is_ok()); + } + #[test] fn rinx_is_module_hosted_on_the_desktop_by_default() { let plain = AppRegistry::default(); diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 35cacbce..1202c351 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -45,8 +45,9 @@ octosense-shell = { workspace = true } [features] # App Hub is part of the standard launcher: the store, the Card runner and # the system apps (News, Photos, Maps, Mail, AI providers; Camera is phone-only) it runs; -# the octos kernel is a shell service in every standard build. -default = ["app-hub", "octos-core", "app-rinx"] +# the octos kernel is a shell service in every standard build. Terminal is a +# system app too, linked in-process. +default = ["app-hub", "octos-core", "app-rinx", "app-terminal"] # The embedded apps, also usable on the desktop with --module. AppCard is not # among them: it ships only with `--features app-appcard`. mobile-apps = ["octosense-shell/mobile-apps"] @@ -58,6 +59,8 @@ octos-core = ["octosense-shell/octos-core"] app-hub = ["octosense-shell/app-hub"] app-reference = ["octosense-shell/app-reference"] app-sheets = ["octosense-shell/app-sheets"] +# The Terminal system app: a login shell in a tile, in-process by default. +app-terminal = ["octosense-shell/app-terminal"] # Native Photos, News and OctosMap modules instead of the system script apps. app-photos = ["octosense-shell/app-photos"] app-news = ["octosense-shell/app-news"] diff --git a/desktop/README.md b/desktop/README.md index 4fd0f2f2..31b1ba85 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -82,6 +82,7 @@ A relocatable `.app`, installers and a Linux session compositor are not provided | `app-rinx` | on | Links [Rinx](https://github.com/hagency-org/Rinx), the Matrix client, as a module; implies `octos-core` (its assistant is the shell's). | | `app-reference` | off | Links Reference (`../apps/reference`) as a module. | | `app-sheets` | off | Links Makepad's Sheets as a module. | +| `app-terminal` | on | Links Makepad's Terminal as a system app: a login shell in a tile, in-process by default (a `terminal: Process` line in `wm/apps.splash` under the state directory switches it to a process). The assistant gets its read tools (`read_screen`, `read_scrollback`); `run` is not offered. On macOS the shell's PTY helper is `octosense` itself, so no second binary ships beside it. | | `app-photos` | off | Links Makepad's native Photos module; it replaces the Photos system app of the same id (for comparison). | | `app-appcard` | off | Links the AppCard assistant module (`../apps/appcard/module`); implies `octos-core`. Opt-in on every target, phones included; not shipped for now. | | `app-aichat` | off | Links Makepad's AI chat as a module, without its model engine. | @@ -187,7 +188,7 @@ Open **App Hub**, choose the app, **Get**, scroll to **Install**, then **Open**: ### Developer programs and the catalog -`config/apps.json` lists Reference and Makepad's own apps (Browser, Files, Terminal, Sheets, Notes, Calendar, Director under the id `studio`, and more). The Image, PDF and AI helpers also appear in the launcher unless their ids (`image`, `pdf`, `aichat`) are listed in `wm/launcher.hides` under the state directory. +`config/apps.json` lists Reference and Makepad's own apps (Browser, Files, Terminal, Sheets, Notes, Calendar, Director under the id `studio`, and more). Terminal is also linked (`app-terminal`, on by default) and opens in-process; its `config/apps.json` row is the process form a person can switch to. The Image, PDF and AI helpers also appear in the launcher unless their ids (`image`, `pdf`, `aichat`) are listed in `wm/launcher.hides` under the state directory. Catalog lookup: `--apps ` if given, else `~/.octosense/apps.json` if it exists, else `config/apps.json`. A catalog is a JSON array; each entry picks one launch target: diff --git a/desktop/README.zh-CN.md b/desktop/README.zh-CN.md index da74748f..ae1f67d3 100644 --- a/desktop/README.zh-CN.md +++ b/desktop/README.zh-CN.md @@ -82,6 +82,7 @@ cargo run --release -p octosense | `app-rinx` | 开 | 以模块形式链接 Matrix 客户端 [Rinx](https://github.com/hagency-org/Rinx);隐含 `octos-core`(它的助手就是 Shell 的助手)。 | | `app-reference` | 关 | 以模块形式链接 Reference(`../apps/reference`)。 | | `app-sheets` | 关 | 以模块形式链接 Makepad 的 Sheets。 | +| `app-terminal` | 开 | 以系统应用形式链接 Makepad 的 Terminal:在磁贴中运行的登录 Shell,默认在进程内运行(在状态目录下的 `wm/apps.splash` 中写一行 `terminal: Process` 可改为独立进程)。助手只获得读取工具(`read_screen`、`read_scrollback`),不提供 `run`。在 macOS 上,Shell 的 PTY 辅助程序就是 `octosense` 本身,因此无需在旁边附带第二个二进制文件。 | | `app-photos` | 关 | 链接 Makepad 的原生 Photos 模块;它会替换同 id 的相册系统应用(用于对比)。 | | `app-appcard` | 关 | 链接 AppCard 助手模块(`../apps/appcard/module`);隐含 `octos-core`。在所有目标平台(包括手机)上都需显式启用;目前不随产品发布。 | | `app-aichat` | 关 | 以模块形式链接 Makepad 的 AI chat,不含其模型引擎。 | @@ -187,7 +188,7 @@ OCTOSENSE_HUB= OCTOSENSE_HUB_ANCHOR= \ ### 开发者程序与目录 -`config/apps.json` 列出 Reference 和 Makepad 自带的应用(Browser、Files、Terminal、Sheets、Notes、Calendar、id 为 `studio` 的 Director 等)。Image、PDF 和 AI 辅助应用也会出现在 launcher 中,除非它们的 id(`image`、`pdf`、`aichat`)写在状态目录下的 `wm/launcher.hides` 中。 +`config/apps.json` 列出 Reference 和 Makepad 自带的应用(Browser、Files、Terminal、Sheets、Notes、Calendar、id 为 `studio` 的 Director 等)。Terminal 同时以链接方式提供(`app-terminal`,默认开启),在进程内打开;它在 `config/apps.json` 中的条目是可以切换成的独立进程形式。Image、PDF 和 AI 辅助应用也会出现在 launcher 中,除非它们的 id(`image`、`pdf`、`aichat`)写在状态目录下的 `wm/launcher.hides` 中。 目录查找顺序:给了 `--apps ` 就用它;否则若存在 `~/.octosense/apps.json` 就用它;否则用 `config/apps.json`。目录是一个 JSON 数组,每个条目选择一种启动目标: From 5b458a82488e25dfa5206ec7bc67b144ef9320bf Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:17:48 -0700 Subject: [PATCH 3/3] fix(shell): the tile that last took the keyboard releases it on a focus change Focusing a process tile moves key focus to it (its forwarding needs that), but a module tile deliberately never moves key focus: its own widgets claim it. Nothing told the previously focused tile to let go, so after switching between the tabs of a window group the hidden tab kept the keyboard and every key typed into the visible one was dropped. Track the client whose tile last took the keyboard (`keyboard_holder`); in `focus_client`, release that tile (TileHost::release_keyboard) before focusing another. The layout already reports the new client as focused by then, so the previous one is tracked here rather than read from it. Needs the matching makepad-terminal change (claim the keyboard on a key that arrives while nobody holds it, OctoSense-org/makepad#40). Verified: Omarchy style, a two-tab Terminal group; after Cmd+Alt+Tab text typed runs in the tab now shown, both directions. cargo test --locked -p octosense-shell --features app-terminal: 333 passed; from phone/: --features mobile-apps: 342 passed; cargo check --locked -p octosense. Co-Authored-By: Claude Opus 5.5 --- crates/shell/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 6e31769e..669ace35 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -311,6 +311,12 @@ pub struct App { /// re-asserted when that client's first frame arrives. #[rust] pub pending_focus: Option, + /// The client whose tile last took the keyboard. It lets go before + /// another tile takes it: a module tile never moves key focus itself + /// (its widgets claim it), so without this the hidden tab of a group + /// kept holding the keyboard and every key was dropped. + #[rust] + pub keyboard_holder: Option, /// `--gallery`: the shell-surface gallery instead of a desktop. #[rust] pub gallery: bool, @@ -1702,12 +1708,22 @@ impl App { // Area only after its first draw — a focus at launch time lands on // nothing. Keep it pending and re-assert when the child's first // frame arrives (the PresentableDraw path below). + if let Some(previous) = self.keyboard_holder.filter(|previous| *previous != client) { + let _ = self + .desk(cx) + .borrow_mut::() + .and_then(|mut d| d.with_tile(cx, previous, |cx, v| v.release_keyboard(cx))); + self.keyboard_holder = None; + } let focused = self .desk(cx) .borrow_mut::() .and_then(|mut d| d.with_tile(cx, client, |cx, v| v.focus_keyboard(cx))) .unwrap_or(false); self.pending_focus = if focused { None } else { Some(client) }; + if focused { + self.keyboard_holder = Some(client); + } self.update_bar(cx); self.redraw_all(cx); } @@ -5128,6 +5144,7 @@ impl App { .unwrap_or(false); if focused { self.pending_focus = None; + self.keyboard_holder = Some(client); } } }