From 01a4a2fcb6b71eb8f579f8537770398bb9e1fc49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 17:03:54 +0000 Subject: [PATCH] feat(tui): generate InputCore via fluessig single_threaded handle (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the hand-written `InputCore` napi class (packages/tui/src/components/ input.ts) for a fluessig-generated one, proving the #80 single_threaded (thread-confined `!Send`) handle path at the bumped pin (e056bb9): - Author the ctor + five sync ops + the `InputEvent` model in schema/ api.json, marking the interface `single_threaded: true`. fluessig lowers it to a THREAD-CONFINED handle: `pub struct InputCore { core: RefCell }` — no `Arc`, no `Send`/`Sync` — and the `InputCoreCore` trait sheds its `Send + Sync` supertraits (`Sized + 'static`) with `&mut self` ops reached through `borrow_mut()`. - This is exactly what lets a `!Send` core compile: `InputCoreImpl` (core_impl.rs) owns pi's `Input` (whose `on_submit`/`on_escape` are non-`Send` boxed closures) plus an `Rc>` event cell captured by those closures — the same wiring the deleted hand-written class used. - regen src/generated.rs (fluessig e056bb9); delete the hand-written `#[napi]` struct/impl + `InputEvent`/`InputEventState` from lib.rs; keep `pub mod generated;`. Self-mutability is invisible to the TS type, so the RefCell-vs-`&mut self` handle is byte-transparent: the generated `InputCore` class `.d.ts` block, the `InputEvent` interface, the `.js` export, and the nativeBinding entry are all byte-identical to the pre-swap build. pi's own test/input.test.ts oracle passes 35/35 against the locally-built addon. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017xL2W7dkhPsTSRuXQNCDeY --- crates/pidgin-napi/schema/api.json | 124 ++++++++++++++++++++++++++++ crates/pidgin-napi/src/core_impl.rs | 72 ++++++++++++++++ crates/pidgin-napi/src/generated.rs | 71 ++++++++++++++++ crates/pidgin-napi/src/lib.rs | 103 +++-------------------- 4 files changed, 277 insertions(+), 93 deletions(-) diff --git a/crates/pidgin-napi/schema/api.json b/crates/pidgin-napi/schema/api.json index f534d1a..e7d7c84 100644 --- a/crates/pidgin-napi/schema/api.json +++ b/crates/pidgin-napi/schema/api.json @@ -100,6 +100,22 @@ "nullable": false } ] + }, + { + "name": "InputEvent", + "doc": "Event surfaced by [`InputCore::handle_input`] so the JS shim can fire pi's\n`onSubmit`/`onEscape` callbacks. `submit` is the submitted value (pi passes\nthe current value) or `null` when no submit fired; `escape` is `true` when a\ncancel/escape fired.", + "fields": [ + { + "name": "submit", + "type": "string", + "nullable": true + }, + { + "name": "escape", + "type": "boolean", + "nullable": false + } + ] } ], "unions": [], @@ -1379,6 +1395,114 @@ } } ] + }, + { + "name": "InputCore", + "doc": "The Rust-backed single-line input core, exposed to JavaScript as\n`InputCore`.", + "single_threaded": true, + "ops": [ + { + "name": "new", + "doc": "Create an empty input core, wiring pi's `onSubmit`/`onEscape` seams to a\nshared event cell that `handle_input` drains after each call.", + "shape": "ctor", + "params": [], + "returns": "void" + }, + { + "name": "getValue", + "doc": "pi's `getValue()`: the current value.", + "shape": "unary", + "infallible": true, + "readonly": true, + "params": [], + "returns": "string", + "bindings": { + "node": { + "name": "getValue" + } + } + }, + { + "name": "setValue", + "doc": "pi's `setValue(value)`: set the value, clamping the cursor.", + "shape": "unary", + "infallible": true, + "readonly": false, + "params": [ + { + "name": "value", + "type": "string" + } + ], + "returns": "void", + "bindings": { + "node": { + "name": "setValue" + } + } + }, + { + "name": "setFocused", + "doc": "pi's `focused` field setter — routed here because render reads it.", + "shape": "unary", + "infallible": true, + "readonly": false, + "params": [ + { + "name": "focused", + "type": "boolean" + } + ], + "returns": "void", + "bindings": { + "node": { + "name": "setFocused" + } + } + }, + { + "name": "handleInput", + "doc": "pi's `handleInput(data)`: process a chunk of terminal input, returning any\n`onSubmit`/`onEscape` that fired so the shim can replay it onto the JS\ncallbacks.", + "shape": "unary", + "infallible": true, + "readonly": false, + "params": [ + { + "name": "data", + "type": "string" + } + ], + "returns": { + "model": "InputEvent" + }, + "bindings": { + "node": { + "name": "handleInput" + } + } + }, + { + "name": "render", + "doc": "pi's `render(width)`: render the input to a single line.", + "shape": "unary", + "infallible": true, + "readonly": true, + "params": [ + { + "name": "width", + "type": "uint32" + } + ], + "returns": { + "list": "string" + }, + "bindings": { + "node": { + "name": "render" + } + } + } + ] } ] } diff --git a/crates/pidgin-napi/src/core_impl.rs b/crates/pidgin-napi/src/core_impl.rs index 5cdb389..d62d932 100644 --- a/crates/pidgin-napi/src/core_impl.rs +++ b/crates/pidgin-napi/src/core_impl.rs @@ -931,3 +931,75 @@ impl crate::generated::StdinBufferCoreCore for StdinBufferCoreImpl { self.inner.lock().unwrap().clear(); } } + +use crate::generated::InputEvent; + +/// Internal per-call event cell for [`InputCoreImpl`]. pi's `Input` fires +/// `onSubmit`/`onEscape` synchronously during `handleInput`; the core cannot +/// call JS closures, so the wired seams record any submit/escape that fired into +/// this cell and `handle_input` drains it into an [`InputEvent`] for the shim to +/// replay. +#[derive(Default)] +struct InputEventState { + submit: Option, + escape: bool, +} + +/// The engine-backed implementation of the generated `InputCore` contract. +/// +/// Authored `#[fluessig(single_threaded)]`, so the generated handle holds this +/// core THREAD-CONFINED in a `RefCell` — no `Arc`, no +/// `Send`/`Sync` — which is exactly what lets a `!Send` core compile: it owns +/// pi's `Input` (whose `on_submit`/`on_escape` are non-`Send` boxed closures) +/// plus an `Rc>` event cell captured by those closures. Every op is +/// `&mut self`, reached from the handle through `RefCell::borrow_mut()`, so the +/// JS-visible behavior is byte-for-byte unchanged from the pre-swap hand-written +/// class. +pub struct InputCoreImpl { + inner: pidgin_tui::Input, + events: std::rc::Rc>, +} + +impl crate::generated::InputCoreCore for InputCoreImpl { + fn new() -> anyhow::Result { + let events = std::rc::Rc::new(std::cell::RefCell::new(InputEventState::default())); + let mut inner = pidgin_tui::Input::new(); + { + let ev = events.clone(); + inner.on_submit = Some(Box::new(move |value| { + ev.borrow_mut().submit = Some(value); + })); + let ev = events.clone(); + inner.on_escape = Some(Box::new(move || { + ev.borrow_mut().escape = true; + })); + } + Ok(Self { inner, events }) + } + + fn get_value(&mut self) -> String { + self.inner.get_value() + } + + fn set_value(&mut self, value: String) { + self.inner.set_value(&value); + } + + fn set_focused(&mut self, focused: bool) { + self.inner.focused = focused; + } + + fn handle_input(&mut self, data: String) -> InputEvent { + *self.events.borrow_mut() = InputEventState::default(); + self.inner.handle_input_str(&data); + let ev = self.events.borrow(); + InputEvent { + submit: ev.submit.clone(), + escape: ev.escape, + } + } + + fn render(&mut self, width: u32) -> Vec { + self.inner.render_lines(width as usize) + } +} diff --git a/crates/pidgin-napi/src/generated.rs b/crates/pidgin-napi/src/generated.rs index 0f4aa7f..3081c88 100644 --- a/crates/pidgin-napi/src/generated.rs +++ b/crates/pidgin-napi/src/generated.rs @@ -6,6 +6,7 @@ // The fixed prelude — generated code uses fully-qualified paths elsewhere. use napi::bindgen_prelude::Result; use napi_derive::napi; +use std::cell::RefCell; use std::sync::Arc; fn err(e: impl std::fmt::Display) -> napi::Error { @@ -64,6 +65,17 @@ pub struct FuzzyMatchResult { pub score: f64, } +/// Event surfaced by [`InputCore::handle_input`] so the JS shim can fire pi's +/// `onSubmit`/`onEscape` callbacks. `submit` is the submitted value (pi passes +/// the current value) or `null` when no submit fired; `escape` is `true` when a +/// cancel/escape fired. +#[napi(object)] +#[derive(Clone)] +pub struct InputEvent { + pub submit: Option, + pub escape: bool, +} + /// The `Pidgin` contract — implement over the engine in `crate::core_impl`. pub trait PidginCore: Sized + Send + Sync + 'static { fn version() -> String; @@ -168,6 +180,16 @@ pub trait StdinBufferCoreCore: Sized + Send + Sync + 'static { fn clear(&self) -> (); } +/// The `InputCore` contract — implement over the engine in `crate::core_impl`. +pub trait InputCoreCore: Sized + 'static { + fn new() -> anyhow::Result; + fn get_value(&mut self) -> String; + fn set_value(&mut self, value: String) -> (); + fn set_focused(&mut self, focused: bool) -> (); + fn handle_input(&mut self, data: String) -> InputEvent; + fn render(&mut self, width: u32) -> Vec; +} + /// Returns the crate version. Proves the native addon builds and loads. /// /// Exported to JavaScript as `pidginNativeVersion`. @@ -701,3 +723,52 @@ impl StdinBufferCore { self.core.clear() } } + +/// The Rust-backed single-line input core, exposed to JavaScript as +/// `InputCore`. +#[napi] +pub struct InputCore { + // pub(crate): the @manual ops in lib.rs extend this class and need the core + pub(crate) core: RefCell, +} + +#[napi] +impl InputCore { + /// Create an empty input core, wiring pi's `onSubmit`/`onEscape` seams to a + /// shared event cell that `handle_input` drains after each call. + #[napi(constructor)] + pub fn new() -> Result { + Ok(Self { + core: RefCell::new( + ::new().map_err(err)?, + ), + }) + } + /// pi's `getValue()`: the current value. + #[napi(js_name = "getValue")] + pub fn get_value(&self) -> String { + self.core.borrow_mut().get_value() + } + /// pi's `setValue(value)`: set the value, clamping the cursor. + #[napi(js_name = "setValue")] + pub fn set_value(&self, value: String) -> () { + self.core.borrow_mut().set_value(value) + } + /// pi's `focused` field setter — routed here because render reads it. + #[napi(js_name = "setFocused")] + pub fn set_focused(&self, focused: bool) -> () { + self.core.borrow_mut().set_focused(focused) + } + /// pi's `handleInput(data)`: process a chunk of terminal input, returning any + /// `onSubmit`/`onEscape` that fired so the shim can replay it onto the JS + /// callbacks. + #[napi(js_name = "handleInput")] + pub fn handle_input(&self, data: String) -> InputEvent { + self.core.borrow_mut().handle_input(data) + } + /// pi's `render(width)`: render the input to a single line. + #[napi(js_name = "render")] + pub fn render(&self, width: u32) -> Vec { + self.core.borrow_mut().render(width) + } +} diff --git a/crates/pidgin-napi/src/lib.rs b/crates/pidgin-napi/src/lib.rs index a4c3c2b..dbb1ce9 100644 --- a/crates/pidgin-napi/src/lib.rs +++ b/crates/pidgin-napi/src/lib.rs @@ -866,99 +866,16 @@ pub fn markdown_render(source: String, width: u32) -> Vec { // --- tui input layer (packages/tui/src/components/input.ts) ----------------- // -// A stateful `#[napi]` class wrapping `pidgin_tui::Input`. The hand-written -// `input.ts` shim re-implements pi's `Input` class, keeping `onSubmit`/ -// `onEscape` as JS callbacks and the `focused` accessor as JS, and routing the -// editing/render logic through this core. pi's `handleInput` fires `onSubmit`/ -// `onEscape` synchronously; the core cannot call JS closures, so instead it -// records any submit/escape that fired during a `handleInput` call and returns -// it as an [`InputEvent`], which the shim replays onto the JS callbacks. Value -// and cursor arithmetic is UTF-16 on both sides (as in pi). - -/// Event surfaced by [`InputCore::handle_input`] so the JS shim can fire pi's -/// `onSubmit`/`onEscape` callbacks. `submit` is the submitted value (pi passes -/// the current value) or `null` when no submit fired; `escape` is `true` when a -/// cancel/escape fired. -#[napi(object)] -pub struct InputEvent { - pub submit: Option, - pub escape: bool, -} - -#[derive(Default)] -struct InputEventState { - submit: Option, - escape: bool, -} - -/// The Rust-backed single-line input core, exposed to JavaScript as -/// `InputCore`. -#[napi(js_name = "InputCore")] -pub struct InputCore { - inner: pidgin_tui::Input, - events: std::rc::Rc>, -} - -#[napi] -impl InputCore { - /// Create an empty input core, wiring pi's `onSubmit`/`onEscape` seams to a - /// shared event cell that `handle_input` drains after each call. - #[napi(constructor)] - #[allow(clippy::new_without_default)] - pub fn new() -> Self { - let events = std::rc::Rc::new(std::cell::RefCell::new(InputEventState::default())); - let mut inner = pidgin_tui::Input::new(); - { - let ev = events.clone(); - inner.on_submit = Some(Box::new(move |value| { - ev.borrow_mut().submit = Some(value); - })); - let ev = events.clone(); - inner.on_escape = Some(Box::new(move || { - ev.borrow_mut().escape = true; - })); - } - Self { inner, events } - } - - /// pi's `getValue()`: the current value. - #[napi(js_name = "getValue")] - pub fn get_value(&self) -> String { - self.inner.get_value() - } - - /// pi's `setValue(value)`: set the value, clamping the cursor. - #[napi(js_name = "setValue")] - pub fn set_value(&mut self, value: String) { - self.inner.set_value(&value); - } - - /// pi's `focused` field setter — routed here because render reads it. - #[napi(js_name = "setFocused")] - pub fn set_focused(&mut self, focused: bool) { - self.inner.focused = focused; - } - - /// pi's `handleInput(data)`: process a chunk of terminal input, returning any - /// `onSubmit`/`onEscape` that fired so the shim can replay it onto the JS - /// callbacks. - #[napi(js_name = "handleInput")] - pub fn handle_input(&mut self, data: String) -> InputEvent { - *self.events.borrow_mut() = InputEventState::default(); - self.inner.handle_input_str(&data); - let ev = self.events.borrow(); - InputEvent { - submit: ev.submit.clone(), - escape: ev.escape, - } - } - - /// pi's `render(width)`: render the input to a single line. - #[napi(js_name = "render")] - pub fn render(&self, width: u32) -> Vec { - self.inner.render_lines(width as usize) - } -} +// The single-line `Input` component (`InputCore`) now GENERATES from the +// fluessig api schema through `crate::generated` + `crate::core_impl` (the +// `InputCoreImpl` engine seam). It is authored `#[fluessig(single_threaded)]`, +// so the generated handle holds the `!Send` core (pi's `Rc>` event +// cell plus non-`Send` `onSubmit`/`onEscape` closures) THREAD-CONFINED in a +// `RefCell` — no `Arc`, no `Send`/`Sync` bound — rather than the default +// `Arc` handle a `Send` core would use. The JS `input.ts` shim keeps +// `onSubmit`/`onEscape` as JS callbacks and the `focused` accessor as JS, and +// replays the [`InputEvent`] the core returns from `handleInput` onto them — +// unchanged by the swap. See src/generated.rs and schema/api.json. Additive. // --- tui select-list layer (packages/tui/src/components/select-list.ts) ----- //