From e803578c020c0eb5e5876a96adf09965ebea12b8 Mon Sep 17 00:00:00 2001 From: Prateek Singh Date: Mon, 20 Jul 2026 21:43:05 -0400 Subject: [PATCH 1/3] fix(mcp): close 5 design issues found by live-driving all 48 verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by driving the real editor with the real sidecar on v0.1.120 — gaps the fake-half unit suites (C++ fakes the sidecar, Rust fakes the bridge) structurally cannot see. Opus-architected, implemented in dependency order, each with a red-state test that FAILS without the fix. #1 Welcome tab (index 0) was a silent trap: it appeared in list_open_tabs like any tab but insert_text/compare_tabs/save_tab failed on it with generic errors, and read_tab returned empty text (worse — looked like an empty document). Now every tab carries "editable"; the five tab-context write/read verbs gate on it. #3 git_* were unusable with no repo file open. runGit now appends the editor's STARTUP working dir (QDir::currentPath captured once at construction, never live — a live cwd would make git_status depend on the last file dialog) as the final candidate root, so `notepatra ~/repo && git_status` works. #4 save_tab could not persist AI-generated content: it refused untitled tabs and had no path. It now takes an optional `path` (Save-As). It STAYS WRITE-tier and approval-gated: the path is validated (absolute + parent exists) BEFORE the card, the card SHOWS the destination (OVERWRITE-flagged if it exists), and the write executes only inside the approved closure. No headless bypass. #5 replace_selection was unusable by an AI — it needed a selection but nothing could set one. New ACT-tier verb select_range (1-based, like goto_line) sets the selection with no card (it mutates only the selection, like goto_line); replace_selection still carries its own approval. Tool count 48 -> 49. #6 generic errors ("could not insert", "could not open compare view", "needs Save As") replaced with specific, actionable messages so an AI can self-correct. Verified independently, not just by the implementer: full ctest 71/71, Rust default + --features remote green, fmt + clippy --all-features clean, Windows cross-check (x86_64-pc-windows-msvc) clean. Red-state spot-checked by hand — forcing tabIsEditable true fails exactly the 3 gate tests. Approval-gate audit: save_tab validates before the card and executes only in the approval closure; select_range enqueues zero cards. Deliberate deviation from spec: the Rust mock does NOT prepend a Welcome tab at index 0 — that would shift ~55 index-dependent assertions and break every write test targeting tab_index 0. The C++ bridge (source of truth) fully implements and tests Welcome-tab behavior; the mock exercises editable:false via diagram tabs. #2 (export_chart-to-file "TypeError: e.isString") is a separate WebEngine runtime bug, investigated separately — not in this change. Co-Authored-By: Claude Opus 4.8 --- notepatra-mcp/src/lib.rs | 2 +- notepatra-mcp/src/remote/scope.rs | 6 +- notepatra-mcp/src/tools.rs | 82 ++++++- notepatra-mcp/src/transport/mock.rs | 70 +++++- notepatra-mcp/src/transport/mod.rs | 32 ++- notepatra-mcp/src/transport/socket.rs | 41 +++- notepatra-mcp/tests/protocol.rs | 69 +++++- notepatra-mcp/tests/socket_bridge.rs | 4 +- src/mainwindow.cpp | 61 ++++++ src/mainwindow.h | 5 + src/mcp_bridge.cpp | 203 ++++++++++++++++-- src/mcp_bridge.h | 24 +++ test_mcp_bridge.cpp | 293 +++++++++++++++++++++++++- 13 files changed, 840 insertions(+), 52 deletions(-) diff --git a/notepatra-mcp/src/lib.rs b/notepatra-mcp/src/lib.rs index 33e03e5..d90f2c1 100644 --- a/notepatra-mcp/src/lib.rs +++ b/notepatra-mcp/src/lib.rs @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// The tools::definitions() json! array is large enough (48 tools) to exceed +// The tools::definitions() json! array is large enough (49 tools) to exceed // the default macro recursion limit. #![recursion_limit = "512"] // UNGATED on purpose: the default std-only build needs the per-user config dir diff --git a/notepatra-mcp/src/remote/scope.rs b/notepatra-mcp/src/remote/scope.rs index d86c6f8..f4598e5 100644 --- a/notepatra-mcp/src/remote/scope.rs +++ b/notepatra-mcp/src/remote/scope.rs @@ -128,7 +128,7 @@ mod tests { fn filter_sizes_are_cumulative() { let full = serde_json::json!({ "result": { "tools": definitions() } }); let total = definitions().as_array().unwrap().len(); - assert_eq!(total, 48); + assert_eq!(total, 49); for (scope, want) in [ (Scope::ReadOnly, READ_TOOLS.len()), @@ -145,9 +145,9 @@ mod tests { scope.as_str() ); } - // Concretely: 24 / 37 / 48. + // Concretely: 24 / 38 / 49. assert_eq!(READ_TOOLS.len(), 24); - assert_eq!(READ_TOOLS.len() + ACT_TOOLS.len(), 37); + assert_eq!(READ_TOOLS.len() + ACT_TOOLS.len(), 38); } #[test] diff --git a/notepatra-mcp/src/tools.rs b/notepatra-mcp/src/tools.rs index 20545e5..1dc3506 100644 --- a/notepatra-mcp/src/tools.rs +++ b/notepatra-mcp/src/tools.rs @@ -43,6 +43,7 @@ pub const ACT_TOOLS: &[&str] = &[ "open_file", "new_tab", "goto_line", + "select_range", "set_language", "compare_tabs", "format_json", @@ -220,6 +221,22 @@ pub fn definitions() -> Value { "additionalProperties": false } }, + { + "name": "select_range", + "description": "Set the editor's text selection to a 1-based line/column range in a tab (default: the active tab). Columns are clamped to each line's length. Use to highlight a span for the user, e.g. before replace_selection.", + "inputSchema": { + "type": "object", + "properties": { + "start_line": { "type": "integer", "minimum": 1, "description": "1-based line where the selection starts" }, + "start_col": { "type": "integer", "minimum": 1, "description": "1-based column where the selection starts" }, + "end_line": { "type": "integer", "minimum": 1, "description": "1-based line where the selection ends" }, + "end_col": { "type": "integer", "minimum": 1, "description": "1-based column where the selection ends (exclusive)" }, + "tab_index": { "type": "integer", "minimum": 0, "description": "Zero-based tab index (default: active tab)" } + }, + "required": ["start_line", "start_col", "end_line", "end_col"], + "additionalProperties": false + } + }, { "name": "set_language", "description": "Set the syntax-highlighting language of a tab (defaults to the active tab). Use after new_tab, or when a file is highlighted with the wrong language.", @@ -322,13 +339,16 @@ pub fn definitions() -> Value { { "name": "save_tab", "description": format!( - "Save an open tab (default: the active tab) to its file on disk. \ - {APPROVAL_NOTE}" + "Save an open tab (default: the active tab) to disk. Pass path \ + to \"Save As\" to a new absolute file (its parent folder must \ + already exist); an untitled tab that has never been saved \ + requires path. {APPROVAL_NOTE}" ), "inputSchema": { "type": "object", "properties": { - "tab_index": { "type": "integer", "minimum": 0, "description": "Zero-based tab index (default: active tab)" } + "tab_index": { "type": "integer", "minimum": 0, "description": "Zero-based tab index (default: active tab)" }, + "path": { "type": "string", "description": "Absolute path to save the tab to (\"Save As\"); its parent folder must exist. Omit to save to the tab's existing file." } }, "required": [], "additionalProperties": false @@ -660,6 +680,7 @@ pub fn call( "find_in_tab" => find_in_tab(transport, args), "new_tab" => new_tab(transport, args), "goto_line" => goto_line(transport, args), + "select_range" => select_range(transport, args), "set_language" => set_language(transport, args), "compare_tabs" => compare_tabs(transport, args), "format_json" => format_text(transport, args, "json"), @@ -768,6 +789,16 @@ fn optional_one_based(args: &Map, key: &str) -> Result, key: &str) -> Result { + match required_index(args, key)? { + 0 => Err(CallOutcome::InvalidParams(format!( + "{key} must be an integer >= 1" + ))), + n => Ok(n), + } +} + fn optional_bool(args: &Map, key: &str, default: bool) -> Result { match args.get(key) { None => Ok(default), @@ -946,6 +977,43 @@ fn goto_line(transport: &mut dyn EditorTransport, args: &Map) -> to_outcome(transport.goto_line(line, tab_index)) } +// v0.1.121 (issue #5): ACT tier — moves the selection, no approval card. +fn select_range(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome { + if let Err(e) = reject_extras( + args, + &[ + "tab_index", + "start_line", + "start_col", + "end_line", + "end_col", + ], + ) { + return e; + } + let tab_index = match optional_index(args, "tab_index") { + Ok(i) => i, + Err(e) => return e, + }; + let start_line = match required_one_based(args, "start_line") { + Ok(n) => n, + Err(e) => return e, + }; + let start_col = match required_one_based(args, "start_col") { + Ok(n) => n, + Err(e) => return e, + }; + let end_line = match required_one_based(args, "end_line") { + Ok(n) => n, + Err(e) => return e, + }; + let end_col = match required_one_based(args, "end_col") { + Ok(n) => n, + Err(e) => return e, + }; + to_outcome(transport.select_range(tab_index, start_line, start_col, end_line, end_col)) +} + fn set_language(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome { if let Err(e) = reject_extras(args, &["language", "tab_index"]) { return e; @@ -1080,14 +1148,18 @@ fn apply_edit(transport: &mut dyn EditorTransport, args: &Map) -> } fn save_tab(transport: &mut dyn EditorTransport, args: &Map) -> CallOutcome { - if let Err(e) = reject_extras(args, &["tab_index"]) { + if let Err(e) = reject_extras(args, &["tab_index", "path"]) { return e; } let tab_index = match optional_index(args, "tab_index") { Ok(i) => i, Err(e) => return e, }; - to_outcome(transport.save_tab(tab_index)) + let path = match optional_str(args, "path") { + Ok(p) => p, + Err(e) => return e, + }; + to_outcome(transport.save_tab(tab_index, path)) } // ── v0.1.119 read-tier handlers ───────────────────────────────────────────── diff --git a/notepatra-mcp/src/transport/mock.rs b/notepatra-mcp/src/transport/mock.rs index cc37e2d..44809f8 100644 --- a/notepatra-mcp/src/transport/mock.rs +++ b/notepatra-mcp/src/transport/mock.rs @@ -71,6 +71,9 @@ struct MockTab { language: String, truncated: bool, is_diagram: bool, + // v0.1.121 (issue #1): false for tabs that are not editable text buffers + // (a Diagram canvas here); surfaced as `editable` in list_open_tabs. + editable: bool, } struct MockNote { @@ -143,6 +146,7 @@ impl Default for MockEditor { language: "Rust".into(), truncated: false, is_diagram: false, + editable: true, }, MockTab { title: "NOTES.md".into(), @@ -153,6 +157,7 @@ impl Default for MockEditor { language: "Markdown".into(), truncated: false, is_diagram: false, + editable: true, }, MockTab { title: "Untitled 1".into(), @@ -162,6 +167,7 @@ impl Default for MockEditor { language: "Plain Text".into(), truncated: false, is_diagram: false, + editable: true, }, ], selection: (0, "println!(\"hello from notepatra\");".into()), @@ -241,6 +247,7 @@ impl MockEditor { title: t.title.clone(), path: t.path.clone(), modified: t.modified, + editable: t.editable, } } @@ -324,6 +331,7 @@ impl EditorTransport for MockEditor { language: language_for(path), truncated: false, is_diagram: false, + editable: true, }); Ok(self.tabs.len() - 1) } @@ -463,6 +471,7 @@ impl EditorTransport for MockEditor { language: "Plain Text".into(), truncated: false, is_diagram: false, + editable: true, }); Ok(json!({ "tab_index": self.tabs.len() - 1 })) } @@ -480,6 +489,42 @@ impl EditorTransport for MockEditor { Ok(json!({ "ok": true, "tab_index": i, "line": line })) } + fn select_range( + &mut self, + tab_index: Option, + start_line: usize, + start_col: usize, + end_line: usize, + end_col: usize, + ) -> Result { + let i = self.resolve(tab_index)?; + let lines: Vec<&str> = self.tabs[i].content.split('\n').collect(); + if start_line < 1 || end_line < 1 || start_line > lines.len() || end_line > lines.len() { + return Err(TransportError(format!( + "could not select range in tab {i} (line out of range?)" + ))); + } + // Flatten (line,col) to a char offset (col clamped to the line, EOL + // counted as one char between lines) so the selection mirrors the span. + let offset = |line: usize, col: usize| -> usize { + let mut off = 0; + for l in &lines[..line - 1] { + off += l.chars().count() + 1; // + newline + } + off + (col - 1).min(lines[line - 1].chars().count()) + }; + let a = offset(start_line, start_col); + let b = offset(end_line, end_col); + if b < a { + return Err(TransportError(format!( + "could not select range in tab {i} (end before start?)" + ))); + } + let sel: String = self.tabs[i].content.chars().skip(a).take(b - a).collect(); + self.selection = (i, sel); + Ok(json!({ "ok": true, "tab_index": i })) + } + fn set_language( &mut self, language: &str, @@ -631,9 +676,29 @@ impl EditorTransport for MockEditor { Ok(json!({ "ok": true, "count": count })) } - fn save_tab(&mut self, tab_index: Option) -> Result { + fn save_tab( + &mut self, + tab_index: Option, + path: Option<&str>, + ) -> Result { self.check_approval()?; let i = self.resolve(tab_index)?; + // v0.1.121 (issue #4): a path is a "Save As" — the editor validates it + // (absolute + parent exists) before this runs; the mock adopts it and + // renames the tab from the basename. + if let Some(p) = path { + if !p.starts_with('/') { + return Err(TransportError(format!( + "save_tab path must be absolute: {p}" + ))); + } + self.tabs[i].path = Some(p.to_string()); + self.tabs[i].title = p.rsplit('/').next().unwrap_or(p).to_string(); + } else if self.tabs[i].path.is_none() { + return Err(TransportError( + "save_tab needs a path: this tab has never been saved (pass \"path\")".into(), + )); + } self.tabs[i].modified = false; Ok(json!({ "ok": true })) } @@ -798,6 +863,7 @@ impl EditorTransport for MockEditor { language: "HTML".into(), truncated: false, is_diagram: false, + editable: true, }); // Bridge result shape: {opened, title}. Ok(json!({ "opened": true, "title": title })) @@ -893,6 +959,8 @@ impl EditorTransport for MockEditor { language: "Plain Text".into(), truncated: false, is_diagram: true, + // A Diagram canvas is not an editable text buffer (issue #1). + editable: false, }); Ok(json!({ "tab_index": self.tabs.len() - 1, diff --git a/notepatra-mcp/src/transport/mod.rs b/notepatra-mcp/src/transport/mod.rs index b9f3eb5..fe44c6a 100644 --- a/notepatra-mcp/src/transport/mod.rs +++ b/notepatra-mcp/src/transport/mod.rs @@ -7,9 +7,12 @@ pub mod endpoint; pub mod mock; pub mod socket; -/// One entry of `list_open_tabs` — wire shape `{index,title,path,modified}`. -/// The editor always sends `path` as a string; `""` (untitled tab) maps to -/// `None` here so tool output can omit it. +/// One entry of `list_open_tabs` — wire shape +/// `{index,title,path,modified,editable}`. The editor always sends `path` as a +/// string; `""` (untitled tab) maps to `None` here so tool output can omit it. +/// `editable` (v0.1.121) is `false` for tabs that are not editable text +/// buffers (Welcome page, Diagram canvas, Noter panel); older editors omit it, +/// so it defaults to `true` on the wire (see socket `tab_info_from`). #[derive(Debug, Clone, serde::Serialize)] pub struct TabInfo { pub index: usize, @@ -17,6 +20,7 @@ pub struct TabInfo { #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, pub modified: bool, + pub editable: bool, } /// `read_tab` result — wire shape `{title,path,text}` plus `truncated:true` @@ -120,7 +124,8 @@ impl std::error::Error for TransportError {} /// * `insert_text` → `{ok:true,tab_index:N}` /// * `replace_selection` → `{ok:true}` /// * `apply_edit` → `{ok:true,count:N}` -/// * `save_tab` → `{ok:true}` +/// * `save_tab` → `{saved:true,tab_index:N,path:str}` (the editor bridge; the +/// mock returns `{ok:true}`). An optional `path` "Save As"es to a new file. /// /// v0.1.119 ("MCP depth") adds 13 verbs. All pass the editor's JSON result /// through verbatim. Shapes below are BYTE-EXACT from the C++ bridge doc @@ -209,6 +214,16 @@ pub trait EditorTransport { fn new_tab(&mut self, text: Option<&str>) -> Result; fn goto_line(&mut self, line: usize, tab_index: Option) -> Result; + /// v0.1.121 (issue #5): move the selection to a 1-based line/column range + /// (ACT — no approval card). Result `{ok:true,tab_index}`. + fn select_range( + &mut self, + tab_index: Option, + start_line: usize, + start_col: usize, + end_line: usize, + end_col: usize, + ) -> Result; fn set_language( &mut self, language: &str, @@ -239,7 +254,14 @@ pub trait EditorTransport { tab_index: Option, all: bool, ) -> Result; - fn save_tab(&mut self, tab_index: Option) -> Result; + /// `path` (v0.1.121) turns this into a "Save As" to a NEW absolute path; + /// the editor validates it (absolute, parent folder exists) before showing + /// the approval card. `None` saves the tab to its existing file. + fn save_tab( + &mut self, + tab_index: Option, + path: Option<&str>, + ) -> Result; // v0.1.119 read verbs — verbatim JSON passthrough (shapes documented on // the trait doc comment above; the C++ bridge is the source of truth). diff --git a/notepatra-mcp/src/transport/socket.rs b/notepatra-mcp/src/transport/socket.rs index 29e0ea1..a6a704b 100644 --- a/notepatra-mcp/src/transport/socket.rs +++ b/notepatra-mcp/src/transport/socket.rs @@ -723,6 +723,9 @@ fn tab_info_from(v: &Value) -> Result { .get("modified") .and_then(Value::as_bool) .ok_or_else(|| malformed("modified"))?, + // v0.1.121: absent on editors older than the field, so default to + // editable rather than failing the whole list. + editable: v.get("editable").and_then(Value::as_bool).unwrap_or(true), }) } @@ -868,6 +871,26 @@ impl EditorTransport for SocketEditor { self.call("goto_line", args) } + fn select_range( + &mut self, + tab_index: Option, + start_line: usize, + start_col: usize, + end_line: usize, + end_col: usize, + ) -> Result { + let mut args = json!({ + "start_line": start_line, + "start_col": start_col, + "end_line": end_line, + "end_col": end_col, + }); + if let Some(i) = tab_index { + args["tab_index"] = json!(i); + } + self.call("select_range", args) + } + fn set_language( &mut self, language: &str, @@ -951,11 +974,19 @@ impl EditorTransport for SocketEditor { self.call("apply_edit", args) } - fn save_tab(&mut self, tab_index: Option) -> Result { - let args = match tab_index { - Some(i) => json!({ "tab_index": i }), - None => json!({}), - }; + fn save_tab( + &mut self, + tab_index: Option, + path: Option<&str>, + ) -> Result { + // Optional keys are sent only when present so the wire stays minimal. + let mut args = json!({}); + if let Some(i) = tab_index { + args["tab_index"] = json!(i); + } + if let Some(p) = path { + args["path"] = json!(p); + } self.call("save_tab", args) } diff --git a/notepatra-mcp/tests/protocol.rs b/notepatra-mcp/tests/protocol.rs index 8092d09..ae62685 100644 --- a/notepatra-mcp/tests/protocol.rs +++ b/notepatra-mcp/tests/protocol.rs @@ -115,6 +115,7 @@ fn tools_list_shape() { "find_in_tab", "new_tab", "goto_line", + "select_range", "set_language", "compare_tabs", "format_json", @@ -158,7 +159,7 @@ fn tools_list_shape() { "export_chart" ] ); - assert_eq!(names.len(), 48); + assert_eq!(names.len(), 49); for tool in tools { assert!(tool["description"].as_str().is_some_and(|d| !d.is_empty())); let schema = &tool["inputSchema"]; @@ -197,6 +198,10 @@ fn tools_call_happy_paths() { .unwrap(); assert_eq!(tabs.as_array().unwrap().len(), 3); assert_eq!(tabs[1]["title"], "NOTES.md"); + // v0.1.121 (issue #1): every entry carries the editable flag; the mock's + // text tabs are all editable. + assert_eq!(tabs[0]["editable"], true); + assert_eq!(tabs[1]["editable"], true); // read_tab by index returns the raw content. assert!(responses[2]["result"]["content"][0]["text"] .as_str() @@ -609,7 +614,7 @@ fn write_tool_descriptions_state_the_approval_gate() { let responses = run_lines(&[json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }).to_string()]); let tools = responses[0]["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 48); + assert_eq!(tools.len(), 49); let write_tools = [ "insert_text", "replace_selection", @@ -708,6 +713,62 @@ fn write_tools_happy_paths() { assert_eq!(tabs[1]["modified"], false); } +// v0.1.121 (issue #4): save_tab with a path is a "Save As" — the tab adopts +// the new absolute path and basename title; a relative path is rejected. +#[test] +fn save_tab_with_path_is_a_save_as() { + let responses = run_lines(&[ + call_line( + 1, + "save_tab", + json!({ "tab_index": 2, "path": "/tmp/exported.txt" }), + ), + call_line(2, "list_open_tabs", json!({})), + call_line( + 3, + "save_tab", + json!({ "tab_index": 2, "path": "relative/x.txt" }), + ), + ]); + assert_eq!(responses[0]["result"]["isError"], false); + let tabs = json_of(&responses[1]); + assert_eq!(tabs[2]["path"], "/tmp/exported.txt"); + assert_eq!(tabs[2]["title"], "exported.txt"); + // A relative path fails with the verbatim editor error. + assert_eq!(responses[2]["result"]["isError"], true); + assert!(text_of(&responses[2]).contains("must be absolute")); +} + +// v0.1.121 (issue #5): select_range moves the selection (ACT, no card); a +// following get_selection reflects the spanned text, and an out-of-range line +// errors. +#[test] +fn select_range_moves_the_selection() { + let responses = run_lines(&[ + call_line( + 1, + "select_range", + json!({ "tab_index": 0, "start_line": 1, "start_col": 1, + "end_line": 1, "end_col": 3 }), + ), + call_line(2, "get_selection", json!({})), + call_line( + 3, + "select_range", + json!({ "tab_index": 0, "start_line": 99, "start_col": 1, + "end_line": 99, "end_col": 2 }), + ), + ]); + assert_eq!(responses[0]["result"]["isError"], false); + // tab 0 content starts "fn main() {" — cols 1..3 (exclusive) select "fn". + let sel = json_of(&responses[1]); + assert_eq!(sel["tab_index"], 0); + assert_eq!(sel["text"], "fn"); + // A line past the buffer end is a verbatim editor error. + assert_eq!(responses[2]["result"]["isError"], true); + assert!(text_of(&responses[2]).contains("could not select range")); +} + #[test] fn denied_write_tools_return_the_verbatim_error() { let mut editor = MockEditor::default(); @@ -1255,9 +1316,9 @@ fn p0a_list_languages_and_get_capabilities() { .unwrap() .len(); assert_eq!(caps["tool_count"], expected as u64); - assert_eq!(caps["tool_count"], 48); + assert_eq!(caps["tool_count"], 49); assert_eq!(caps["tiers"]["read"], 24); - assert_eq!(caps["tiers"]["act"], 13); + assert_eq!(caps["tiers"]["act"], 14); // +select_range (v0.1.121) assert_eq!(caps["tiers"]["write"], 11); assert!(caps["features"]["duckdb"].is_boolean()); assert!(caps["features"]["webengine"].is_boolean()); diff --git a/notepatra-mcp/tests/socket_bridge.rs b/notepatra-mcp/tests/socket_bridge.rs index 72b8020..81eceb0 100644 --- a/notepatra-mcp/tests/socket_bridge.rs +++ b/notepatra-mcp/tests/socket_bridge.rs @@ -334,7 +334,7 @@ fn write_verbs_wait_out_the_approval_window() { assert_eq!(v, json!({ "ok": true })); let v = ed.apply_edit("a", "b", Some(0), false).expect("apply_edit"); assert_eq!(v["count"], 2); - let v = ed.save_tab(None).expect("save_tab"); + let v = ed.save_tab(None, None).expect("save_tab"); assert_eq!(v, json!({ "ok": true })); cleanup(path, bridge, "write_verbs_wait_out_the_approval_window"); } @@ -360,7 +360,7 @@ fn approval_denial_and_timeout_errors_pass_through_verbatim() { writeln!(stream, "{resp}").unwrap(); }); let mut ed = editor_for(&path); - let err = ed.save_tab(None).unwrap_err(); + let err = ed.save_tab(None, None).unwrap_err(); assert_eq!(err.0, expected); cleanup( path, diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 799b4b3..a1dcb4a 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1728,6 +1728,11 @@ MainWindow::MainWindow(bool standaloneNoSession) // answer directly; write verbs run only after the human approves the // bridge's in-window card. Primary only; standalone windows never serve it. if (!m_standaloneNoSession) { + // Capture the launch directory now (before anything can chdir): the + // git verbs use it as a last-resort repo root so an agent that starts + // Notepatra from inside a checkout still gets git even with no folder + // open and only untitled tabs (issue #3). + m_startupCwd = QDir::currentPath(); McpEditorHost host; host.tabCount = [this] { return m_tabs->count(); }; host.tabTitle = [this](int i) { return m_tabs->tabText(i); }; @@ -1743,6 +1748,13 @@ MainWindow::MainWindow(bool standaloneNoSession) auto *ed = m_tabs->editorAt(i); return ed ? ed->text() : QString(); }; + // v0.1.121 (issue #1): only tabs backed by a real Editor are editable + // text buffers. editorAt() is nullptr for the Welcome page, a Diagram + // canvas, and the Noter panel — exactly the tabs an agent must not + // read_tab / insert_text / save_tab against. + host.tabEditable = [this](int i) { + return m_tabs->editorAt(i) != nullptr; + }; host.openFile = [this](const QString &p) { openFile(p); // same internal path the single-instance handoff uses const QString abs = QFileInfo(p).absoluteFilePath(); @@ -1808,6 +1820,34 @@ MainWindow::MainWindow(bool standaloneNoSession) ed->setFocus(); return true; }; + // v0.1.121 (issue #5): move the selection to a 1-based range. Cols are + // clamped to each line's text length (EOL excluded) so an over-long + // col lands at line end, never on the next line. + host.selectRange = [this](int tabIndex, int startLine, int startCol, + int endLine, int endCol) { + auto *ed = m_tabs->editorAt(tabIndex); + if (!ed) return false; + const int lineCount = ed->lines(); + const int lineFrom = startLine - 1; + const int lineTo = endLine - 1; + if (lineFrom < 0 || lineFrom >= lineCount || lineTo < 0 || + lineTo >= lineCount) + return false; + auto clampCol = [ed](int line, int col1based) { + const QString t = ed->text(line); + int len = t.size(); + while (len > 0 && + (t.at(len - 1) == QLatin1Char('\n') || + t.at(len - 1) == QLatin1Char('\r'))) + --len; + return qBound(0, col1based - 1, len); + }; + m_tabs->setCurrentIndex(tabIndex); + ed->setSelection(lineFrom, clampCol(lineFrom, startCol), lineTo, + clampCol(lineTo, endCol)); + ed->setFocus(); + return true; + }; host.setLanguage = [this](int tabIndex, const QString &lang) { auto *ed = m_tabs->editorAt(tabIndex); if (!ed) return false; @@ -1945,6 +1985,21 @@ MainWindow::MainWindow(bool standaloneNoSession) } return true; }; + // v0.1.121 (issue #4): "Save As" to an explicit path the bridge has + // already validated (absolute, parent folder exists). Same headless + // path as host.saveTab — Editor::saveFile(path) — never a dialog. + host.saveTabAs = [this](int i, const QString &path) { + auto *ed = m_tabs->editorAt(i); + if (!ed || path.isEmpty()) return false; + if (!ed->saveFile(path)) return false; + updateTabTitle(i); + ed->updateGitGutter(); + if (m_fileWatcher) { + const QString saved = ed->filePath(); + m_fileTimestamps[saved] = QFileInfo(saved).lastModified(); + } + return true; + }; // ── v0.1.119 depth wave — each lambda reuses the SAME real code path // the equivalent feature uses (git_tools, DbConnections classifier // + runQuery, NotesStorage/NotesTodos, DiagramView export). ── @@ -1985,6 +2040,12 @@ MainWindow::MainWindow(bool standaloneNoSession) QFileInfo(ed->filePath()).absolutePath(); if (!roots.contains(fdir)) roots << fdir; } + // Last resort (issue #3): the process launch directory, so git + // still resolves when no folder is open and every tab is untitled + // (an agent launched Notepatra from inside the repo). Appended + // LAST — the workspace and the file's own directory always win. + if (!m_startupCwd.isEmpty() && !roots.contains(m_startupCwd)) + roots << m_startupCwd; if (roots.isEmpty()) { if (err) *err = QStringLiteral("no workspace folder open"); return QString(); diff --git a/src/mainwindow.h b/src/mainwindow.h index f722387..42a7c68 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -210,6 +210,11 @@ class MainWindow : public QMainWindow { bool m_startupDone = false; QStringList m_startupFiles; int m_startupGotoLine = -1; + // The working directory the process was launched from, captured once at + // construction. The MCP git verbs fall back to it as a last-resort repo + // root so `git_status` works when an agent launches Notepatra from inside + // a checkout with no folder open and only untitled tabs (issue #3). + QString m_startupCwd; // D2 remote-open: raise/activate happens synchronously in // handleRemoteOpen; file loads queue here and run one event-loop turn // later so a paint lands between raise and load. While startup is diff --git a/src/mcp_bridge.cpp b/src/mcp_bridge.cpp index 6254312..ef4065d 100644 --- a/src/mcp_bridge.cpp +++ b/src/mcp_bridge.cpp @@ -12,7 +12,8 @@ // tab (when present) owns index 0. // // READ tier — answered immediately. -// list_open_tabs {} → {tabs:[{index,title,path,modified}]} +// list_open_tabs {} → {tabs:[{index,title,path,modified, +// editable}]} (editable: v0.1.121) // read_tab {index|title} → {title,path,text,truncated?} // get_selection {} → {text,tab_index} // get_status {} → {tab_index,title,path,language, @@ -55,6 +56,9 @@ // ACT tier — visible, non-destructive, NO approval card. // new_tab {text?} → {tab_index} // goto_line {line,tab_index?} → {ok,tab_index,line} +// select_range{start_line,start_col,end_line,end_col,tab_index?} +// → {ok,tab_index} (1-based line +// +col; v0.1.121) // set_language{language,tab_index?} → {ok,tab_index,language} // (language echoes the RESOLVED // canonical token since p0a) @@ -71,7 +75,9 @@ // insert_text {text,tab_index?,line?,col?} → {tab_index} // replace_selection {text,tab_index?} → {tab_index} // apply_edit {find,replace,all?,tab_index?} → {count} -// save_tab {tab_index?} → {saved,tab_index} +// save_tab {tab_index?,path?} → {saved,tab_index,path} +// (path = "Save As" to a NEW +// absolute path; v0.1.121) // create_note {title,body} → {file,title} (v0.1.119) // append_note {file,text} → {file} (v0.1.119) // set_reminder{file,due_iso} → {file,due_iso} (v0.1.119) @@ -563,6 +569,8 @@ void McpBridge::handleLine(QLocalSocket *client, const QByteArray &line) { verbNewTab(client, id, args); else if (verb == QLatin1String("goto_line")) verbGotoLine(client, id, args); + else if (verb == QLatin1String("select_range")) + verbSelectRange(client, id, args); else if (verb == QLatin1String("set_language")) verbSetLanguage(client, id, args); else if (verb == QLatin1String("compare_tabs")) @@ -701,6 +709,10 @@ void McpBridge::verbListOpenTabs(QLocalSocket *client, int id) { : QString(); t[QStringLiteral("modified")] = m_host.tabModified ? m_host.tabModified(i) : false; + // v0.1.121 (issue #1): mark tabs that are not editable text buffers + // (Welcome page, Diagram canvas, Noter panel …) so an agent knows not + // to read_tab / insert_text / save_tab against them. + t[QStringLiteral("editable")] = tabIsEditable(i); tabs.append(t); } QJsonObject result; @@ -736,6 +748,10 @@ void McpBridge::verbReadTab(QLocalSocket *client, int id, QStringLiteral("tab index out of range: %1").arg(idx)); return; } + if (!tabIsEditable(idx)) { // v0.1.121: Welcome/diagram/… have no text buffer + sendError(client, id, nonEditableReason(idx)); + return; + } QString text = m_host.tabText ? m_host.tabText(idx) : QString(); QJsonObject result; result[QStringLiteral("title")] = m_host.tabTitle ? m_host.tabTitle(idx) @@ -1006,6 +1022,49 @@ void McpBridge::verbGotoLine(QLocalSocket *client, int id, sendResult(client, id, result); } +// v0.1.121 (issue #5): move the selection to an explicit 1-based range. ACT +// tier — it changes only what is selected, so there is NO approval card. The +// host clamps columns and re-focuses the editor. +void McpBridge::verbSelectRange(QLocalSocket *client, int id, + const QJsonObject &args) { + if (!m_host.selectRange) { + sendError(client, id, + QStringLiteral("select_range not supported by host")); + return; + } + QString err; + const int idx = resolveWriteTab(args, &err); // resolves tab_index/current + if (idx < 0) { + sendError(client, id, err); + return; + } + if (!tabIsEditable(idx)) { + sendError(client, id, nonEditableReason(idx)); + return; + } + const int startLine = args.value(QLatin1String("start_line")).toInt(0); + const int startCol = args.value(QLatin1String("start_col")).toInt(0); + const int endLine = args.value(QLatin1String("end_line")).toInt(0); + const int endCol = args.value(QLatin1String("end_col")).toInt(0); + if (startLine < 1 || startCol < 1 || endLine < 1 || endCol < 1) { + sendError(client, id, + QStringLiteral("start_line, start_col, end_line and end_col " + "must all be >= 1")); + return; + } + if (!m_host.selectRange(idx, startLine, startCol, endLine, endCol)) { + sendError(client, id, + QStringLiteral("could not select range in tab %1 (line out " + "of range?)") + .arg(idx)); + return; + } + QJsonObject result; + result[QStringLiteral("ok")] = true; + result[QStringLiteral("tab_index")] = idx; + sendResult(client, id, result); +} + void McpBridge::verbSetLanguage(QLocalSocket *client, int id, const QJsonObject &args) { if (!m_host.setLanguage) { @@ -1071,8 +1130,28 @@ void McpBridge::verbCompareTabs(QLocalSocket *client, int id, QStringLiteral("cannot compare a tab with itself")); return; } + // v0.1.121 (issue #6): both sides must be editable text tabs — the + // Compare view has nothing to diff for a Welcome/diagram tab. + if (!tabIsEditable(a)) { + sendError(client, id, + QStringLiteral("compare_tabs requires two editable text " + "tabs; tab %1 is not editable") + .arg(a)); + return; + } + if (!tabIsEditable(b)) { + sendError(client, id, + QStringLiteral("compare_tabs requires two editable text " + "tabs; tab %1 is not editable") + .arg(b)); + return; + } if (!m_host.compareTabs(a, b)) { - sendError(client, id, QStringLiteral("could not open compare view")); + sendError(client, id, + QStringLiteral("could not open compare view for tabs %1 " + "and %2") + .arg(a) + .arg(b)); return; } QJsonObject result; @@ -1233,6 +1312,28 @@ QString McpBridge::tabLabel(int idx) const { return t.isEmpty() ? QStringLiteral("tab %1").arg(idx) : t; } +// A host that cannot classify tabs (older editor / test fake with no +// tabEditable lambda) is trusted to have only editable text tabs, so every +// existing wire contract keeps working unchanged. (v0.1.121, issue #1) +bool McpBridge::tabIsEditable(int idx) const { + return !m_host.tabEditable || m_host.tabEditable(idx); +} + +// v0.1.121 (issue #6): a specific, consistent reason string. The Welcome tab +// (title "Welcome", the app's index-0 page) is named outright; any other +// non-editable tab reports its title so the agent knows what it hit. +QString McpBridge::nonEditableReason(int idx) const { + const QString title = m_host.tabTitle ? m_host.tabTitle(idx) : QString(); + if (title == QLatin1String("Welcome")) + return QStringLiteral( + "tab %1 is the Welcome tab and is not an editable buffer") + .arg(idx); + return QStringLiteral( + "tab %1 is not an editable text buffer (it is \"%2\")") + .arg(idx) + .arg(title); +} + void McpBridge::verbInsertText(QLocalSocket *client, int id, const QJsonObject &args) { if (!m_host.insertText) { @@ -1251,6 +1352,10 @@ void McpBridge::verbInsertText(QLocalSocket *client, int id, sendError(client, id, err); return; } + if (!tabIsEditable(idx)) { // v0.1.121: never a card for a non-editable tab + sendError(client, id, nonEditableReason(idx)); + return; + } int line = -1, col = -1; if (args.contains(QLatin1String("line"))) { line = args.value(QLatin1String("line")).toInt(0); @@ -1272,7 +1377,11 @@ void McpBridge::verbInsertText(QLocalSocket *client, int id, elideForCard(text, kApprovalPreviewChars), [this, idx, line, col, text](QString *execErr) { if (!m_host.insertText(idx, line, col, text)) { - *execErr = QStringLiteral("could not insert"); + // v0.1.121 (issue #6): name the tab and the likely cause. + *execErr = QStringLiteral( + "could not insert into tab %1 (line/col out of " + "range?)") + .arg(idx); return QJsonObject(); } QJsonObject r; @@ -1299,6 +1408,10 @@ void McpBridge::verbReplaceSelection(QLocalSocket *client, int id, sendError(client, id, err); return; } + if (!tabIsEditable(idx)) { // v0.1.121 + sendError(client, id, nonEditableReason(idx)); + return; + } // Fail fast: never ask the human to approve a no-op. if (m_host.hasSelection && !m_host.hasSelection(idx)) { sendError(client, id, QStringLiteral("no selection")); @@ -1393,30 +1506,84 @@ void McpBridge::verbSaveTab(QLocalSocket *client, int id, sendError(client, id, err); return; } - const QString path = m_host.tabPath ? m_host.tabPath(idx) : QString(); - if (path.isEmpty()) { - // Untitled tab: this verb NEVER opens a Save As dialog. - sendError(client, id, QStringLiteral("needs Save As")); + if (!tabIsEditable(idx)) { // v0.1.121 + sendError(client, id, nonEditableReason(idx)); return; } - const QString desc = - QStringLiteral("save '%1' to disk").arg(tabLabel(idx)); - enqueueApproval(client, id, desc, QDir::toNativeSeparators(path), - [this, idx](QString *execErr) { - const QString nowPath = m_host.tabPath ? m_host.tabPath(idx) + const QString currentPath = m_host.tabPath ? m_host.tabPath(idx) : QString(); - if (nowPath.isEmpty()) { // became untitled while pending - *execErr = QStringLiteral("needs Save As"); - return QJsonObject(); + // v0.1.121 (issue #4): an optional "path" turns this into a Save As. The + // path is VALIDATED here, before any card is shown, so the human always + // sees the exact destination the write would hit. + const QString explicitPath = args.value(QLatin1String("path")).toString(); + const bool saveAs = !explicitPath.isEmpty(); + QString destPath; + if (saveAs) { + if (!m_host.saveTabAs) { + sendError(client, id, + QStringLiteral( + "save_tab with a path is not supported by host")); + return; + } + if (!QDir::isAbsolutePath(explicitPath)) { + sendError(client, id, + QStringLiteral("save_tab path must be absolute: %1") + .arg(QDir::toNativeSeparators(explicitPath))); + return; + } + const QFileInfo fi(explicitPath); + // The parent folder must already exist — save_tab writes ONE file, it + // never creates directories. + if (!QDir(fi.absolutePath()).exists()) { + sendError(client, id, + QStringLiteral( + "save_tab path's folder does not exist: %1") + .arg(QDir::toNativeSeparators(fi.absolutePath()))); + return; + } + destPath = fi.absoluteFilePath(); + } else if (!currentPath.isEmpty()) { + destPath = currentPath; + } else { + // Untitled tab and no path: this verb NEVER opens a Save As dialog. + sendError(client, id, + QStringLiteral("save_tab needs a path: this tab has never " + "been saved (pass \"path\")")); + return; + } + // Card shows the destination; a pre-existing file is flagged OVERWRITE. + const bool overwrite = QFileInfo::exists(destPath); + const QString shownDest = + sanitizeForCard(QDir::toNativeSeparators(destPath)); + const QString desc = + QStringLiteral("%1save '%2' to %3") + .arg(overwrite ? QStringLiteral("OVERWRITE — ") : QString(), + tabLabel(idx), shownDest); + enqueueApproval(client, id, desc, shownDest, + [this, idx, saveAs, destPath](QString *execErr) { + bool ok; + if (saveAs) { + ok = m_host.saveTabAs(idx, destPath); + } else { + // Re-check: the tab may have become untitled while the card waited. + const QString nowPath = m_host.tabPath ? m_host.tabPath(idx) + : QString(); + if (nowPath.isEmpty()) { + *execErr = QStringLiteral("save_tab needs a path: this tab has " + "never been saved (pass \"path\")"); + return QJsonObject(); + } + ok = m_host.saveTab(idx); } - if (!m_host.saveTab(idx)) { + if (!ok) { *execErr = QStringLiteral("could not save: %1") - .arg(QDir::toNativeSeparators(nowPath)); + .arg(QDir::toNativeSeparators(destPath)); return QJsonObject(); } QJsonObject r; r[QStringLiteral("saved")] = true; r[QStringLiteral("tab_index")] = idx; + r[QStringLiteral("path")] = QDir::toNativeSeparators(destPath); return r; }); } diff --git a/src/mcp_bridge.h b/src/mcp_bridge.h index 644f227..6734edb 100644 --- a/src/mcp_bridge.h +++ b/src/mcp_bridge.h @@ -29,6 +29,11 @@ struct McpEditorHost { std::function tabPath; // "" for non-file tabs std::function tabModified; std::function tabText; + // True when tab i is a real editable text buffer; false for the Welcome + // page, a Diagram canvas, the Noter panel, etc. An unset field means the + // host cannot distinguish, so the bridge treats every tab as editable + // (backward-compatible with older hosts / test fakes). (v0.1.121, issue #1) + std::function tabEditable; std::function openFile; // → tab index, -1 on failure std::function selection; // text; *out = current tab index std::function workspaceRoot; // "" when no folder root is set @@ -61,6 +66,10 @@ struct McpEditorHost { // Literal (non-regex) find/replace; → occurrence count, -1 on failure. std::function applyEdit; std::function saveTab; // path-ful tabs only + // v0.1.121 (issue #4): "Save As" — write tab i to a NEW absolute path + // (the bridge has already validated it). Reuses Editor::saveFile(path); + // unset ⇒ save_tab with a "path" arg is refused as unsupported. + std::function saveTabAs; // ── v0.1.119 depth wave. Same rule as above: every field is optional; // an unset field makes the verb answer with a clear "not supported by @@ -94,6 +103,13 @@ struct McpEditorHost { runSql; // ACT tier — visible, non-destructive, NO approval card. + // v0.1.121 (issue #5): move the selection in tab i to the 1-based + // [startLine,startCol]..[endLine,endCol] range (cols clamped to each + // line). Returns false when the tab is not an editor or the range is out + // of bounds. No card — it only changes what is selected. + std::function + selectRange; // Open a Noter note (already confirmed inside the Noter root by the // bridge) in the Noter tab UI. Returns the note's display title; *err // on failure. @@ -213,6 +229,7 @@ private slots: void verbFindInTab(QLocalSocket *client, int id, const QJsonObject &args); void verbNewTab(QLocalSocket *client, int id, const QJsonObject &args); void verbGotoLine(QLocalSocket *client, int id, const QJsonObject &args); + void verbSelectRange(QLocalSocket *client, int id, const QJsonObject &args); void verbSetLanguage(QLocalSocket *client, int id, const QJsonObject &args); void verbCompareTabs(QLocalSocket *client, int id, const QJsonObject &args); void verbFormatText(QLocalSocket *client, int id, const QJsonObject &args); @@ -270,6 +287,13 @@ private slots: int resolveWriteTab(const QJsonObject &args, QString *err) const; QString tabLabel(int idx) const; + // True when the host reports tab idx as an editable text buffer (or the + // host cannot tell — see McpEditorHost::tabEditable). Verbs that read or + // mutate buffer text gate on this. (v0.1.121, issue #1) + bool tabIsEditable(int idx) const; + // Human-readable reason a tab is not editable, for verb error messages + // (v0.1.121, issue #6). Names the Welcome tab specially. + QString nonEditableReason(int idx) const; // Resolve args["file"] to a canonical .html path that PROVABLY sits // inside the Noter root (../ escapes and out-of-root absolutes are // rejected). Returns "" + *err on any failure. Shared by read_note and diff --git a/test_mcp_bridge.cpp b/test_mcp_bridge.cpp index de1d85e..ee9b728 100644 --- a/test_mcp_bridge.cpp +++ b/test_mcp_bridge.cpp @@ -47,6 +47,7 @@ struct FakeTab { bool modified = false; QString language = QStringLiteral("Plain Text"); bool isDiagram = false; // v0.1.119 — .npd diagram tab + bool editable = true; // v0.1.121 — false for Welcome/diagram/… tabs }; // Crash-proof progress tracker. ctest DISCARDS a crashed test's captured stdout @@ -85,6 +86,7 @@ class TestMcpBridge : public QObject { // v0.1.118 write-tier fake state. QWidget *m_hostWindow = nullptr; QVector m_savedTabs; + QString m_savedAsPath; // v0.1.121 — last save_tab "Save As" path // v0.1.119 depth-wave fake state. QJsonArray m_reminders; // raw reminders the host would return bool m_gitFail = false; // runGit returns an error @@ -120,6 +122,14 @@ class TestMcpBridge : public QObject { h.tabPath = [this](int i) { return m_fakeTabs.value(i).path; }; h.tabModified = [this](int i) { return m_fakeTabs.value(i).modified; }; h.tabText = [this](int i) { return m_fakeTabs.value(i).text; }; + // v0.1.121 (issue #1): a tab is editable unless the fixture says + // otherwise. value(i) default-constructs (editable=true) for an + // out-of-range index, matching the real host's nullptr→false only for + // in-range non-editor tabs. + h.tabEditable = [this](int i) { + return i >= 0 && i < m_fakeTabs.size() ? m_fakeTabs[i].editable + : true; + }; h.openFile = [this](const QString &p) -> int { QFile f(p); if (!f.open(QIODevice::ReadOnly)) return -1; @@ -158,6 +168,31 @@ class TestMcpBridge : public QObject { m_lastGotoLine = line; return true; }; + // v0.1.121 (issue #5): flatten the 1-based range to char offsets, set + // m_selection to the spanned text so a following replace_selection + // works, and focus the tab. + h.selectRange = [this](int idx, int sl, int sc, int el, int ec) { + if (idx < 0 || idx >= m_fakeTabs.size()) return false; + const QString &buf = m_fakeTabs[idx].text; + auto offsetOf = [&buf](int line, int col) -> int { + int cur = 1, off = 0; + while (cur < line) { + const int nl = buf.indexOf(QLatin1Char('\n'), off); + if (nl < 0) return -1; // line out of range + off = nl + 1; + ++cur; + } + int lineEnd = buf.indexOf(QLatin1Char('\n'), off); + if (lineEnd < 0) lineEnd = buf.size(); + return qMin(off + (col - 1), lineEnd); + }; + const int a = offsetOf(sl, sc); + const int b = offsetOf(el, ec); + if (a < 0 || b < 0 || b < a) return false; + m_currentIndex = idx; + m_selection = buf.mid(a, b - a); + return true; + }; h.setLanguage = [this](int idx, const QString &lang) { static const QStringList known = { QStringLiteral("Plain Text"), QStringLiteral("Python"), @@ -261,6 +296,16 @@ class TestMcpBridge : public QObject { m_fakeTabs[idx].modified = false; return true; }; + // v0.1.121 (issue #4): "Save As" — adopt the new path (the bridge has + // already validated it) and clear modified. + h.saveTabAs = [this](int idx, const QString &path) { + if (idx < 0 || idx >= m_fakeTabs.size()) return false; + m_fakeTabs[idx].path = path; + m_fakeTabs[idx].modified = false; + m_savedAsPath = path; + m_savedTabs.append(idx); + return true; + }; // ── v0.1.119 depth wave ── h.reminders = [this] { return m_reminders; }; h.runGit = [this](const QString &sub, const QJsonObject &args, @@ -667,6 +712,7 @@ private slots: m_compareCount = 0; m_lastGotoLine = -1; m_savedTabs.clear(); + m_savedAsPath.clear(); m_reminders = QJsonArray(); m_gitFail = false; m_gitHuge = false; @@ -762,6 +808,40 @@ private slots: QCOMPARE(t1.value(QLatin1String("modified")).toBool(), true); } + // v0.1.121 (issue #1): a non-editable tab (Welcome page) is flagged in + // list_open_tabs and rejected by the buffer-reading verbs. + void welcome_tab_marked_non_editable() { + // Prepend a Welcome tab at index 0, as the real app does. + m_fakeTabs.prepend({QStringLiteral("Welcome"), QString(), QString(), + false, QStringLiteral("Plain Text"), + /*isDiagram=*/false, /*editable=*/false}); + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + const QJsonObject resp = call(s, 200, QStringLiteral("list_open_tabs")); + QVERIFY(resp.value(QLatin1String("ok")).toBool()); + const QJsonArray tabs = resp.value(QLatin1String("result")) + .toObject() + .value(QLatin1String("tabs")) + .toArray(); + QCOMPARE(tabs.size(), 3); + const QJsonObject welcome = tabs.at(0).toObject(); + QCOMPARE(welcome.value(QLatin1String("title")).toString(), + QStringLiteral("Welcome")); + QCOMPARE(welcome.value(QLatin1String("editable")).toBool(), false); + // The editable text tabs are still marked editable. + QCOMPARE(tabs.at(1).toObject().value(QLatin1String("editable")).toBool(), + true); + // read_tab against the Welcome tab is refused (no text buffer). + QJsonObject args; + args[QStringLiteral("index")] = 0; + const QJsonObject bad = call(s, 201, QStringLiteral("read_tab"), args); + QCOMPARE(bad.value(QLatin1String("ok")).toBool(), false); + QVERIFY(bad.value(QLatin1String("error")) + .toString() + .contains(QLatin1String("not an editable"))); + } + void read_tab_by_index() { QLocalSocket s; QVERIFY(connectClient(s)); @@ -1214,6 +1294,83 @@ private slots: QCOMPARE(bad.value(QLatin1String("ok")).toBool(), false); } + // v0.1.121 (issue #5): select_range moves the selection (ACT, no card) and + // a following replace_selection acts on exactly that span. + void select_range_then_replace() { + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + // tab 0 text starts "hello world\n..."; select the first word. + QJsonObject range; + range[QStringLiteral("tab_index")] = 0; + range[QStringLiteral("start_line")] = 1; + range[QStringLiteral("start_col")] = 1; + range[QStringLiteral("end_line")] = 1; + range[QStringLiteral("end_col")] = 6; // exclusive of col 6 → "hello" + const QJsonObject resp = + call(s, 230, QStringLiteral("select_range"), range); + QVERIFY(resp.value(QLatin1String("ok")).toBool()); + QCOMPARE(resp.value(QLatin1String("result")) + .toObject() + .value(QLatin1String("ok")) + .toBool(), + true); + QCOMPARE(m_selection, QStringLiteral("hello")); + // No approval card for an ACT verb. + QTest::qWait(80); + QVERIFY(!findCard()); + // Now replace exactly that selection (write tier, approved). + QJsonObject repl; + repl[QStringLiteral("text")] = QStringLiteral("HELLO"); + repl[QStringLiteral("tab_index")] = 0; + sendRequest(s, 231, QStringLiteral("replace_selection"), repl); + QFrame *card = waitForCard(); + QVERIFY(card); + auto *approve = card->findChild( + QStringLiteral("mcpApproveBtn")); + QVERIFY(approve); + approve->click(); + const QJsonObject r2 = readObj(s); + QVERIFY(r2.value(QLatin1String("ok")).toBool()); + QVERIFY(m_fakeTabs[0].text.startsWith(QLatin1String("HELLO world"))); + QVERIFY(waitForCardGone()); + } + + // v0.1.121 (issue #5): a range past the end of the buffer, and a <1 arg, + // both fail — and never a card. + void select_range_out_of_range() { + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + QJsonObject oob; + oob[QStringLiteral("tab_index")] = 0; + oob[QStringLiteral("start_line")] = 99; + oob[QStringLiteral("start_col")] = 1; + oob[QStringLiteral("end_line")] = 99; + oob[QStringLiteral("end_col")] = 2; + const QJsonObject r1 = + call(s, 232, QStringLiteral("select_range"), oob); + QCOMPARE(r1.value(QLatin1String("ok")).toBool(), false); + QVERIFY(r1.value(QLatin1String("error")) + .toString() + .contains(QLatin1String("could not select range"))); + // A zero column is a validation error. + QJsonObject zero; + zero[QStringLiteral("tab_index")] = 0; + zero[QStringLiteral("start_line")] = 1; + zero[QStringLiteral("start_col")] = 0; + zero[QStringLiteral("end_line")] = 1; + zero[QStringLiteral("end_col")] = 1; + const QJsonObject r2 = + call(s, 233, QStringLiteral("select_range"), zero); + QCOMPARE(r2.value(QLatin1String("ok")).toBool(), false); + QVERIFY(r2.value(QLatin1String("error")) + .toString() + .contains(QLatin1String(">= 1"))); + QTest::qWait(80); + QVERIFY(!findCard()); + } + void set_language_routes_and_rejects() { QLocalSocket s; QVERIFY(connectClient(s)); @@ -1332,6 +1489,63 @@ private slots: QCOMPARE(m_compareCount, 1); // no extra dialog was opened } + // v0.1.121 (issue #6): inserting into the Welcome tab is refused up front + // with a Welcome-specific message and NEVER shows an approval card. + void insert_into_welcome_gives_specific_error() { + m_fakeTabs.prepend({QStringLiteral("Welcome"), QString(), QString(), + false, QStringLiteral("Plain Text"), + /*isDiagram=*/false, /*editable=*/false}); + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + QJsonObject args; + args[QStringLiteral("text")] = QStringLiteral("hi"); + args[QStringLiteral("tab_index")] = 0; + const QJsonObject resp = + call(s, 210, QStringLiteral("insert_text"), args); + QCOMPARE(resp.value(QLatin1String("ok")).toBool(), false); + QCOMPARE(resp.value(QLatin1String("error")).toString(), + QStringLiteral( + "tab 0 is the Welcome tab and is not an editable buffer")); + // Refused before the write tier — no card, ever. + QTest::qWait(100); + QVERIFY(!findCard()); + } + + // v0.1.121 (issue #6): compare_tabs distinguishes self-compare from a + // non-editable side, each with its own message. + void compare_self_and_noneditable() { + // Index 0 becomes a non-editable Welcome tab; the two text tabs shift + // to indices 1 and 2. + m_fakeTabs.prepend({QStringLiteral("Welcome"), QString(), QString(), + false, QStringLiteral("Plain Text"), + /*isDiagram=*/false, /*editable=*/false}); + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + // Self-compare keeps its dedicated message. + QJsonObject self; + self[QStringLiteral("index_a")] = 1; + self[QStringLiteral("index_b")] = 1; + const QJsonObject r1 = + call(s, 211, QStringLiteral("compare_tabs"), self); + QCOMPARE(r1.value(QLatin1String("ok")).toBool(), false); + QCOMPARE(r1.value(QLatin1String("error")).toString(), + QStringLiteral("cannot compare a tab with itself")); + // A non-editable side is rejected with the compare-specific message. + QJsonObject bad; + bad[QStringLiteral("index_a")] = 0; // Welcome + bad[QStringLiteral("index_b")] = 1; + const QJsonObject r2 = + call(s, 212, QStringLiteral("compare_tabs"), bad); + QCOMPARE(r2.value(QLatin1String("ok")).toBool(), false); + QVERIFY(r2.value(QLatin1String("error")) + .toString() + .contains(QLatin1String( + "requires two editable text tabs"))); + QCOMPARE(m_compareCount, 0); // no dialog ever opened + } + void format_text_pure_function() { const QString before0 = m_fakeTabs[0].text; QLocalSocket s; @@ -1718,8 +1932,11 @@ private slots: auto *desc = card->findChild( QStringLiteral("mcpApprovalDesc")); QVERIFY(desc); + // v0.1.121 (issue #4): the card names the exact destination path. QVERIFY(desc->text().contains( - QLatin1String("save 'notes.txt' to disk"))); + QStringLiteral("save 'notes.txt' to %1") + .arg(QDir::toNativeSeparators( + QStringLiteral("/fake/notes.txt"))))); auto *prev = card->findChild( QStringLiteral("mcpApprovalPreview")); QVERIFY(prev); @@ -1732,26 +1949,86 @@ private slots: const QJsonObject resp = readObj(s); QCOMPARE(resp.value(QLatin1String("id")).toInt(), 68); QVERIFY(resp.value(QLatin1String("ok")).toBool()); - QCOMPARE(resp.value(QLatin1String("result")) - .toObject() - .value(QLatin1String("saved")) - .toBool(), - true); + const QJsonObject saveResult = + resp.value(QLatin1String("result")).toObject(); + QCOMPARE(saveResult.value(QLatin1String("saved")).toBool(), true); + QCOMPARE(saveResult.value(QLatin1String("path")).toString(), + QDir::toNativeSeparators(QStringLiteral("/fake/notes.txt"))); QCOMPARE(m_savedTabs, QVector{0}); QVERIFY(waitForCardGone()); - // Untitled tab: refused up front — never a Save As dialog, no card. + // Untitled tab, no path: refused up front — no card. QJsonObject untitled; untitled[QStringLiteral("tab_index")] = 1; const QJsonObject bad = call(s, 69, QStringLiteral("save_tab"), untitled); QCOMPARE(bad.value(QLatin1String("ok")).toBool(), false); QCOMPARE(bad.value(QLatin1String("error")).toString(), - QStringLiteral("needs Save As")); + QStringLiteral("save_tab needs a path: this tab has never " + "been saved (pass \"path\")")); QTest::qWait(100); QVERIFY(!findCard()); QCOMPARE(m_savedTabs.size(), 1); } + // v0.1.121 (issue #4): save_tab with an explicit absolute path is a + // "Save As": the card shows the destination and, on Approve, the tab + // adopts the new path. + void save_tab_with_path_persists() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString dest = dir.path() + QStringLiteral("/exported.txt"); + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + QJsonObject args; + args[QStringLiteral("tab_index")] = 1; // the untitled tab + args[QStringLiteral("path")] = dest; + sendRequest(s, 220, QStringLiteral("save_tab"), args); + QFrame *card = waitForCard(); + QVERIFY(card); + auto *desc = card->findChild( + QStringLiteral("mcpApprovalDesc")); + QVERIFY(desc); + // Fresh path (does not exist yet) → no OVERWRITE flag, but the exact + // destination is shown. + QVERIFY(!desc->text().contains(QLatin1String("OVERWRITE"))); + QVERIFY(desc->text().contains(QDir::toNativeSeparators(dest))); + auto *approve = card->findChild( + QStringLiteral("mcpApproveBtn")); + QVERIFY(approve); + approve->click(); + const QJsonObject resp = readObj(s); + QCOMPARE(resp.value(QLatin1String("id")).toInt(), 220); + QVERIFY(resp.value(QLatin1String("ok")).toBool()); + QCOMPARE(resp.value(QLatin1String("result")) + .toObject() + .value(QLatin1String("path")) + .toString(), + QDir::toNativeSeparators(dest)); + QCOMPARE(m_savedAsPath, dest); + QCOMPARE(m_fakeTabs[1].path, dest); // the tab adopted the new path + QVERIFY(waitForCardGone()); + } + + // v0.1.121 (issue #4): a relative path is rejected BEFORE any card. + void save_tab_rejects_relative_path() { + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + QJsonObject args; + args[QStringLiteral("tab_index")] = 0; + args[QStringLiteral("path")] = QStringLiteral("relative/out.txt"); + const QJsonObject resp = + call(s, 221, QStringLiteral("save_tab"), args); + QCOMPARE(resp.value(QLatin1String("ok")).toBool(), false); + QVERIFY(resp.value(QLatin1String("error")) + .toString() + .contains(QLatin1String("must be absolute"))); + QTest::qWait(100); + QVERIFY(!findCard()); + QVERIFY(m_savedAsPath.isEmpty()); + } + void approval_times_out_to_deny() { m_bridge->setApprovalTimeoutMs(200); QLocalSocket s; From 65d4f4d96a758983b8f2ec8420767f3e549d6ef7 Mon Sep 17 00:00:00 2001 From: Prateek Singh Date: Tue, 21 Jul 2026 00:47:51 -0400 Subject: [PATCH 2/3] fix(charts): polyfill Object.hasOwn + structuredClone for Qt 5.15 WebEngine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOT CAUSE (confirmed, hard evidence): the charts feature never rendered on Qt 5.15. Its WebEngine is Chromium ~87, but the bundled vega libraries call Object.hasOwn (needs Chromium 93) and structuredClone (needs Chromium 98). The real-display editor log showed, on every chart render: Uncaught TypeError: Object.hasOwn is not a function Uncaught ReferenceError: structuredClone is not defined vega core then fails to initialise, leaving vega.isString undefined, and vega-embed dies synchronously with "TypeError: e.isString is not a function". This broke BOTH paths: export_chart surfaced the error, while render_chart returned rendered:true and drew NOTHING — a headline v0.1.120 feature that was silently dead on any Qt 5.15 box. FIX: define both APIs (JSON round-trip is a sufficient structuredClone for vega's JSON-serialisable specs) in a From 07b49414aef86b7f7479622a154ce97e088f402e Mon Sep 17 00:00:00 2001 From: Prateek Singh Date: Tue, 21 Jul 2026 01:10:18 -0400 Subject: [PATCH 3/3] =?UTF-8?q?release:=20v0.1.121=20=E2=80=94=206=20MCP?= =?UTF-8?q?=20design-issue=20fixes=20+=20version/docs=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version SSOT 0.1.120 -> 0.1.121 (CMakeLists, Cargo.toml/lock, mcpb manifest). MCP tool count 48 -> 49 (the new select_range verb; tiers now Read 24 / Act 14 / Write 11) across MCP_TOOL_COUNT and every current-tense docs surface; v0.1.120 historical references left at 48/v0.1.120. Docs swept to v0.1.121 (JSON-LD, sticky-CTA, hero badge — tagline byte-identical except the version — download buttons/sizes, README release row + installer filenames, docs.html/mcp.html/llms.txt tool counts + a new select_range Act-table row). New v0.1.121 version card added above v0.1.120 with the LATEST pill; no gaps in the card history. release_notes/v0.1.121.md + CHANGELOG [0.1.121] added. Test fix: protocol.rs asserted a hardcoded "0.1.120" serverInfo.version; now derives from env!("CARGO_PKG_VERSION") so a version bump never leaves it stale again. The 6 fixes themselves are in the prior commits on this branch (e803578 API fixes #1/#3/#4/#5/#6; 65d4f4d charts Qt5.15 polyfill #2). Local gates: ctest 71/71, Rust default + --features remote green, fmt + clippy --all-features clean, stale-text-check 62/62, release-check green (only tree-clean pending, resolved here). PII clean. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 17 ++++++++ CMakeLists.txt | 2 +- README.md | 27 ++++++------ docs/docs.html | 10 ++--- docs/index.html | 71 +++++++++++++++++++++++++------- docs/llms.txt | 2 +- docs/mcp.html | 17 ++++---- notepatra-mcp/Cargo.lock | 2 +- notepatra-mcp/Cargo.toml | 2 +- notepatra-mcp/mcpb/manifest.json | 2 +- notepatra-mcp/tests/protocol.rs | 5 ++- release_notes/v0.1.121.md | 35 ++++++++++++++++ scripts/stale-text-check.sh | 2 +- 13 files changed, 145 insertions(+), 49 deletions(-) create mode 100644 release_notes/v0.1.121.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a9e3705..2fdfd56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver --- +## [0.1.121] — 2026-07-21 + +**MCP design hardening — 6 fixes found by live-driving all 48 verbs against a running v0.1.120 editor. The read-only Welcome tab (index 0) is no longer a silent trap: `list_open_tabs` now reports an `editable` flag on every tab and the read/insert/compare/save verbs gate on it. `save_tab` gains an optional Save-As `path` so an AI can persist a `new_tab`; a new `select_range` verb (48 → 49 tools; Read 24 / Act 14 / Write 11) makes `replace_selection` usable; `git_*` falls back to the editor's startup directory; generic errors become specific and actionable; and inline/exported charts now render on Qt 5.15 WebEngine.** + +### Added +- **`select_range`** (Act tier) — select a range of text in a tab by line/column or character offset, so the existing `replace_selection` write verb has a selection to act on. Tool count 48 → 49; tiers now **Read 24 / Act 14 / Write 11**. +- **`save_tab` gains an optional `path`** (Save-As) so an AI can persist a `new_tab`'s content to disk. It stays a Write-tier, approval-gated verb — the path is validated before the approval card, and the destination is shown on the card. + +### Fixed +- **The Welcome tab (index 0) was a silent trap.** `list_open_tabs` now marks every tab with an `editable` (bool) flag, and the read / insert / compare / save verbs gate on it — an AI that lands on the read-only Welcome tab now gets a specific error instead of a silent no-op. +- **`git_*` verbs fall back to the editor's startup working directory**, so Git works when Notepatra was launched from inside a repository with no workspace open (previously they reported "not a git repository"). +- **Specific, actionable error messages replace generic ones** — "could not insert", "could not open compare view", and "needs Save As" now explain what went wrong and what to do. +- **Charts render on Qt 5.15 WebEngine.** The chart bundle now polyfills `Object.hasOwn` and `structuredClone` before the Vega scripts load, because Qt 5.15's WebEngine (Chromium ~87) lacks them — this had been breaking all chart rendering there (`render_chart` returned `rendered: true` while drawing nothing; `export_chart` surfaced `e.isString`). + +### Security +- The Save-As `save_tab` and every other write verb still go through the same in-editor Approve/Deny card (120 s auto-deny, one card at a time, no headless bypass); the gate lives in the editor process. `select_range` is Act-tier and non-destructive. + ## [0.1.120] — 2026-07-20 **MCP full control — 48 tools + a remote gateway, and the sidecar now actually works on Windows and macOS. The `notepatra-mcp` sidecar grows from 35 to 48 tools (Read 24 / Act 13 / Write 11), making every sub-app AI-drivable (Diagram, Noter, Data-analyst, Charts) with capability discovery. A new opt-in, loopback-only remote gateway pairs a client over an authenticated channel with fail-closed scopes; writes still require a physical Approve click in the editor. Windows gets a prebuilt signed sidecar zip and a one-click `.mcpb` bundle.** diff --git a/CMakeLists.txt b/CMakeLists.txt index 6277cca..c589b1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(Notepatra VERSION 0.1.120 LANGUAGES CXX) +project(Notepatra VERSION 0.1.121 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index 9e385e1..75c4c01 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ C++ + Rust · ~12 MB bare native executable · Zero Electron · 238 file types · 82 language lexers · Local AI formatters · MCP server

- New in v0.1.120: notepatra-mcp now ships a prebuilt signed Windows sidecar and a one-click Claude Desktop bundle — connect Claude Desktop, Claude Code, OpenAI Codex, and any MCP client to the running editor. 48 tools (Git, read-only SQL, saved-connection queries, charts, Noter notes); every write human-approved in-window; nothing leaves your machine. + New in v0.1.120: notepatra-mcp now ships a prebuilt signed Windows sidecar and a one-click Claude Desktop bundle — connect Claude Desktop, Claude Code, OpenAI Codex, and any MCP client to the running editor. 49 tools (Git, read-only SQL, saved-connection queries, charts, Noter notes); every write human-approved in-window; nothing leaves your machine.

Website · @@ -51,7 +51,7 @@ Not a port. Not a wrapper. Something new — **for everyone**. I asked: **what would a small native code editor look like if it was built today, in 2026, when AI is part of every developer's workflow, and ran natively on Linux + macOS + Windows from one codebase?** -The answer: a tiny native executable — ~12 MB bare (~12.4 MB on Linux x64) on every platform — with a Rust-powered core, Scintilla editing engine, and local-first AI integration (cloud backends optional). v0.1.120 downloads: 4.4 MB Linux x64 (tarball, Qt from the system), 27.7 MB on macOS (DMG with bundled Qt), 32.8–42.7 MB on Windows (MSI/zip/setup.exe with bundled Qt DLLs). An editor that can fix your broken JSON with regex in milliseconds — and when regex isn't enough, it asks your AI to figure it out — local by default, six cloud backends one click away when you want a frontier model. No telemetry. No subscription. No mandatory API key. +The answer: a tiny native executable — ~12 MB bare (~12.4 MB on Linux x64) on every platform — with a Rust-powered core, Scintilla editing engine, and local-first AI integration (cloud backends optional). v0.1.121 downloads: 4.4 MB Linux x64 (tarball, Qt from the system), 27.7 MB on macOS (DMG with bundled Qt), 32.8–42.7 MB on Windows (MSI/zip/setup.exe with bundled Qt DLLs). An editor that can fix your broken JSON with regex in milliseconds — and when regex isn't enough, it asks your AI to figure it out — local by default, six cloud backends one click away when you want a frontier model. No telemetry. No subscription. No mandatory API key. Notepatra started on Linux — because that's where the gap was. But great tools shouldn't have borders. **Notepatra runs on Linux, Windows, and macOS.** Same codebase. Same features. No one gets left behind. @@ -239,7 +239,7 @@ A first-class diagramming surface that **renders in the default binary on every From v0.1.118, Notepatra ships **`notepatra-mcp`** — a stdio JSON-RPC 2.0 [Model Context Protocol](https://modelcontextprotocol.io) server that connects external AI assistants (Claude Desktop, Claude Code, OpenAI Codex, the OpenAI Agents SDK, and any spec-compliant MCP client) to the running editor over a local socket. Nothing leaves your machine: stdio to the client, local socket to the editor, no network connections. -**48 tools in three tiers**: +**49 tools in three tiers**: | Tier | Tools | Gate | |---|---|---| @@ -287,7 +287,7 @@ Full tool reference, Claude Desktop / Agents SDK snippets, security model, and h **Why this hybrid?** - **C++** because Qt and QScintilla are C++ — zero friction for UI - **Rust** because file I/O, text processing, and parsing must never crash — Rust's ownership system guarantees memory safety -- **Result**: the speed of C++, the safety of Rust. The bare executable is **~12 MB** on every platform (~12.4 MB Linux x64, similar on macOS / Windows). Latest v0.1.120 download sizes: **4.4 MB** Linux x64 tar.gz · **4.1 MB** Linux ARM64 tar.gz · **27.7 MB** macOS DMG (with bundled Qt) · **42.7 MB** Windows MSI · **32.8 MB** Windows NSIS · **37.4 MB** Windows portable zip. _Installed footprint on Windows is ~75-85 MB after the MSI extracts bundled Qt + QScintilla DLLs — normal for any Qt-based installer._ +- **Result**: the speed of C++, the safety of Rust. The bare executable is **~12 MB** on every platform (~12.4 MB Linux x64, similar on macOS / Windows). Latest v0.1.121 download sizes: **4.4 MB** Linux x64 tar.gz · **4.1 MB** Linux ARM64 tar.gz · **27.7 MB** macOS DMG (with bundled Qt) · **42.7 MB** Windows MSI · **32.8 MB** Windows NSIS · **37.4 MB** Windows portable zip. _Installed footprint on Windows is ~75-85 MB after the MSI extracts bundled Qt + QScintilla DLLs — normal for any Qt-based installer._ --- @@ -307,7 +307,7 @@ irm https://notepatra.org/install.ps1 | iex That's it. Auto-detects your OS, downloads the right binary, installs it, adds to PATH, creates shortcuts. -### Or download manually — [Latest release: v0.1.120](https://github.com/singhpratech/notepatra/releases/latest) +### Or download manually — [Latest release: v0.1.121](https://github.com/singhpratech/notepatra/releases/latest) | Platform | Download | Size | What's inside | |---|---|---|---| @@ -332,11 +332,11 @@ For one-time-install-then-every-user-sees-it on a shared machine, or silent push | OS | Artefact | Silent admin install | |---|---|---| -| 🪟 **Windows** | [`notepatra-x.x.x.msi`](https://github.com/singhpratech/notepatra/releases/latest) | `msiexec /i notepatra-0.1.120.msi /quiet` — installs to `C:\Program Files\Notepatra\`, adds system PATH, registers HKCR file associations, all-users Start Menu. WiX-built, MajorUpgrade-aware, SCCM-friendly. | +| 🪟 **Windows** | [`notepatra-x.x.x.msi`](https://github.com/singhpratech/notepatra/releases/latest) | `msiexec /i notepatra-0.1.121.msi /quiet` — installs to `C:\Program Files\Notepatra\`, adds system PATH, registers HKCR file associations, all-users Start Menu. WiX-built, MajorUpgrade-aware, SCCM-friendly. | | 🍎 **macOS** | [`Notepatra.dmg`](https://github.com/singhpratech/notepatra/releases/latest) | Mount + `sudo cp -R "/Volumes/Notepatra/Notepatra.app" /Applications/` from a deployment script. Or open the DMG manually and drag to `/Applications` (admin password). Notarised + stapled. | -| 🐧 **Debian / Ubuntu / Mint / Pop!_OS** (x64 + ARM64) | [`notepatra_0.1.120_amd64.deb`](https://github.com/singhpratech/notepatra/releases/latest) | `sudo apt install ./notepatra_0.1.120_amd64.deb` — installs to `/opt/notepatra/` + symlink at `/usr/bin/notepatra`, hicolor icons, `.desktop` registration. ARM64: replace `amd64` → `arm64`. | -| 🐧 **Fedora / RHEL / CentOS Stream / Rocky / Alma** (x64 + ARM64) | [`notepatra-0.1.120-1.x86_64.rpm`](https://github.com/singhpratech/notepatra/releases/latest) | `sudo dnf install ./notepatra-0.1.120-1.x86_64.rpm` — same layout as the .deb. ARM64: replace `x86_64` → `aarch64`. Bundles QScintilla 2.14.1 alongside the binary because Fedora ships an incompatible packaging. | -| 🐧 **Arch / openSUSE Tumbleweed / Manjaro / EndeavourOS / other glibc 2.38+** | [`Notepatra-0.1.120-x86_64.AppImage`](https://github.com/singhpratech/notepatra/releases/latest) | `chmod +x Notepatra-0.1.120-x86_64.AppImage && sudo cp Notepatra-0.1.120-x86_64.AppImage /opt/notepatra.AppImage && sudo ln -s /opt/notepatra.AppImage /usr/local/bin/notepatra`. Requires glibc 2.38+ (Ubuntu 24.04+, Fedora 40+, Arch, Tumbleweed). Older distros: use the .deb / .rpm. | +| 🐧 **Debian / Ubuntu / Mint / Pop!_OS** (x64 + ARM64) | [`notepatra_0.1.121_amd64.deb`](https://github.com/singhpratech/notepatra/releases/latest) | `sudo apt install ./notepatra_0.1.121_amd64.deb` — installs to `/opt/notepatra/` + symlink at `/usr/bin/notepatra`, hicolor icons, `.desktop` registration. ARM64: replace `amd64` → `arm64`. | +| 🐧 **Fedora / RHEL / CentOS Stream / Rocky / Alma** (x64 + ARM64) | [`notepatra-0.1.121-1.x86_64.rpm`](https://github.com/singhpratech/notepatra/releases/latest) | `sudo dnf install ./notepatra-0.1.121-1.x86_64.rpm` — same layout as the .deb. ARM64: replace `x86_64` → `aarch64`. Bundles QScintilla 2.14.1 alongside the binary because Fedora ships an incompatible packaging. | +| 🐧 **Arch / openSUSE Tumbleweed / Manjaro / EndeavourOS / other glibc 2.38+** | [`Notepatra-0.1.121-x86_64.AppImage`](https://github.com/singhpratech/notepatra/releases/latest) | `chmod +x Notepatra-0.1.121-x86_64.AppImage && sudo cp Notepatra-0.1.121-x86_64.AppImage /opt/notepatra.AppImage && sudo ln -s /opt/notepatra.AppImage /usr/local/bin/notepatra`. Requires glibc 2.38+ (Ubuntu 24.04+, Fedora 40+, Arch, Tumbleweed). Older distros: use the .deb / .rpm. | > All artefacts ship with cosign `.sig` + `.pem` for keyless Sigstore verification and SLSA build provenance. See **[Verify your download](#verify-your-download)** below. @@ -346,9 +346,9 @@ For teams that **can't or won't send code to public LLM endpoints** — regulate | OS | Artefact | Silent admin install | |---|---|---| -| 🪟 **Windows** | [`notepatra-local-ai-0.1.120.msi`](https://github.com/singhpratech/notepatra/releases/latest) | `msiexec /i notepatra-local-ai-0.1.120.msi /quiet` — installs to `C:\Program Files\Notepatra Local AI\`, distinct UpgradeCode so SCCM treats it as its own product. Add/Remove Programs shows "Notepatra Local AI". | -| 🐧 **Debian/Ubuntu x64** | [`notepatra-local-ai_0.1.120_amd64.deb`](https://github.com/singhpratech/notepatra/releases/latest) | `sudo apt install ./notepatra-local-ai_0.1.120_amd64.deb` | -| 🐧 **Debian/Ubuntu ARM64** | [`notepatra-local-ai_0.1.120_arm64.deb`](https://github.com/singhpratech/notepatra/releases/latest) | `sudo apt install ./notepatra-local-ai_0.1.120_arm64.deb` | +| 🪟 **Windows** | [`notepatra-local-ai-0.1.121.msi`](https://github.com/singhpratech/notepatra/releases/latest) | `msiexec /i notepatra-local-ai-0.1.121.msi /quiet` — installs to `C:\Program Files\Notepatra Local AI\`, distinct UpgradeCode so SCCM treats it as its own product. Add/Remove Programs shows "Notepatra Local AI". | +| 🐧 **Debian/Ubuntu x64** | [`notepatra-local-ai_0.1.121_amd64.deb`](https://github.com/singhpratech/notepatra/releases/latest) | `sudo apt install ./notepatra-local-ai_0.1.121_amd64.deb` | +| 🐧 **Debian/Ubuntu ARM64** | [`notepatra-local-ai_0.1.121_arm64.deb`](https://github.com/singhpratech/notepatra/releases/latest) | `sudo apt install ./notepatra-local-ai_0.1.121_arm64.deb` | **The binary physically cannot reach `api.openai.com`, `api.anthropic.com`, `openrouter.ai`, `api.mistral.ai`, `generativelanguage.googleapis.com`, or any other public LLM endpoint.** Every `QNetworkAccessManager` request goes through an allowlist that only accepts: @@ -360,7 +360,7 @@ For teams that **can't or won't send code to public LLM endpoints** — regulate Local Ollama, local llama.cpp, self-hosted Ollama on the LAN, and any other OpenAI-compatible server you have installed locally or on your private network — **all continue to work** in the cloud-free build. Only public-cloud LLM endpoints are blocked. The cloud-URL paste box is stripped from the UI as well, so users can't even type a public host. Auditors can confirm by running `strings notepatra | grep -c openai.com` — zero hits. -On Linux the two flavors share the same `notepatra` binary name on disk; `apt` Conflicts ensures only one of `notepatra` / `notepatra-local-ai` is installed at a time, swap transactionally with `sudo apt install ./notepatra-local-ai_0.1.120_amd64.deb`. On Windows the two MSIs are independent products (different UpgradeCode + ProductName + install dir) so they can coexist if needed; admins typically push one or the other based on policy. `notepatra --version` self-identifies the build by name — only the bare lite build carries an edition suffix: `Notepatra Lite v0.1.120` for the lite build and `Notepatra v0.1.120` for the full build (DuckDB bundled), plus `Notepatra Local AI Lite v0.1.120` / `Notepatra Local AI v0.1.120` for the cloud-free (local-ai) builds; the same name shows in the window title bar and the About dialog. +On Linux the two flavors share the same `notepatra` binary name on disk; `apt` Conflicts ensures only one of `notepatra` / `notepatra-local-ai` is installed at a time, swap transactionally with `sudo apt install ./notepatra-local-ai_0.1.121_amd64.deb`. On Windows the two MSIs are independent products (different UpgradeCode + ProductName + install dir) so they can coexist if needed; admins typically push one or the other based on policy. `notepatra --version` self-identifies the build by name — only the bare lite build carries an edition suffix: `Notepatra Lite v0.1.121` for the lite build and `Notepatra v0.1.121` for the full build (DuckDB bundled), plus `Notepatra Local AI Lite v0.1.121` / `Notepatra Local AI v0.1.121` for the cloud-free (local-ai) builds; the same name shows in the window title bar and the About dialog. ### Verify your download @@ -622,6 +622,7 @@ Notepatra follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic | Version | Date | Highlights | |---|---|---| +| [**v0.1.121**](https://github.com/singhpratech/notepatra/releases/tag/v0.1.121) | 2026-07-21 | **MCP design hardening — 6 fixes from live-driving all 48 verbs against a running editor (48 → 49 tools; Read 24 / Act 14 / Write 11).** `list_open_tabs` now reports an **`editable`** flag on every tab, and the read / insert / compare / save verbs gate on it — so the read-only **Welcome tab (index 0)**, previously a silent trap, returns a specific error instead of a no-op. **`save_tab` gains an optional Save-As `path`** so an AI can persist a `new_tab`'s content to disk — still Write-tier and approval-gated, path validated before the card, destination shown on it. New **`select_range`** verb (Act) finally makes `replace_selection` usable by an AI. `git_*` now falls back to the editor's **startup working directory**, so Git works when Notepatra was launched from inside a repo with no workspace open. Generic errors ("could not insert", "could not open compare view", "needs Save As") are replaced with **specific, actionable** ones. **Charts render on Qt 5.15 WebEngine** — the chart bundle now polyfills `Object.hasOwn` and `structuredClone` before the Vega scripts, which Qt 5.15's WebEngine (Chromium ~87) lacks; this had been breaking all chart rendering there (`render_chart` drew nothing, `export_chart` surfaced `e.isString`). Approval gate unchanged: every write needs an in-editor click, 120 s auto-deny, no headless bypass. | | [**v0.1.120**](https://github.com/singhpratech/notepatra/releases/tag/v0.1.120) | 2026-07-20 | **MCP full control — 48 tools, a remote gateway, and the sidecar finally works on Windows and macOS.** `notepatra-mcp` grows from 35 to 48 tools (Read 24 / Act 13 / Write 11), making every sub-app AI-drivable: **Diagram** (`create_diagram`, `get_diagram_source`, `set_diagram_source`), **Data-analyst** (`list_connections`, `run_query`, `list_tables`, `open_data_analyst`, `export_query_results` — reaches your saved PostgreSQL / MySQL / SQL Server / SQLite / DuckDB connections, SELECT-only and row-capped), **Charts** (`render_chart`, `export_chart`), **Noter** (`open_noter`), and **discovery** (`list_languages`, `get_capabilities`). **Two defects that made the sidecar unusable outside Linux are fixed:** the Windows named-pipe transport deadlocked on every verb — the pipe was opened without `FILE_FLAG_OVERLAPPED` and a parked reader thread blocked every write forever, so no tool call ever completed (only the first request *after* the greeting hung, which is why it looked healthy) — and on macOS the sidecar could not locate a running editor at all, because it guessed `$TMPDIR` while Qt binds under `NSTemporaryDirectory()`; the editor now publishes its real bound endpoint. New opt-in **loopback-only remote gateway** (`serve` / `pair` / `connect`, 8-digit HMAC pairing, fail-closed scopes) — the default build stays **crypto-free**, asserted in every CI job, and a remote write still raises the same local Approve card. Windows gains a prebuilt **cosign-signed sidecar zip** and a one-click **`.mcpb`** Claude Desktop bundle. `.mcpb` also fixed to stop handing Intel Macs an arm64 binary and ARM64 Linux an x64 one. Approval gate unchanged: every write needs an in-editor click, 120 s auto-deny, no headless bypass. Full offscreen ctest 71/71, sidecar 83 tests (117 with `--features remote`). | | [**v0.1.119**](https://github.com/singhpratech/notepatra/releases/tag/v0.1.119) | 2026-07-18 | **MCP depth — 35 tools.** The `notepatra-mcp` sidecar grows from 22 to **35 tools in three tiers** — **Read 18 / Act 9 / Write 8**. New read tools: `list_reminders`, read-only Git (`git_status` / `git_diff` / `git_log` / `git_show` / `git_branch`), `validate_npd`, and **`run_sql`** (SELECT-only). New act tool: `open_note`. New **human-approved** write verbs: `create_note`, `append_note`, `set_reminder`, `export_diagram`. `find_in_tab` / `search_project` gained an optional `regex` flag. **Windows named-pipe transport is now supported** (Linux/macOS/Windows all connect via `--socket`; prebuilt Windows binaries not yet in the signed bundle — build from source / `cargo install`). **`run_sql` security:** SELECT-only via the SQL classifier and, on the Full/DuckDB edition, an engine sandbox — the file is materialized in-memory, then `enable_external_access=false` is set before the untrusted query runs, so it cannot read host files; an adversarial security pass hardened it before ship. Bare Lite binary unchanged at 12.4 MB (the new tools live in the sidecar). Full offscreen ctest 71/71, Lite 68/68, 60 sidecar cargo tests, live end-to-end across all 35 tools. | | [**v0.1.118**](https://github.com/singhpratech/notepatra/releases/tag/v0.1.118) | 2026-07-17 | **MCP support — your editor, readable by your AI.** New standalone **`notepatra-mcp`** sidecar: a spec-compliant stdio JSON-RPC 2.0 [Model Context Protocol](https://modelcontextprotocol.io) server exposing **22 tools in three tiers** — read (10), act (8), and write (4) — plus open tabs / Noter notes as MCP resources and 3 ready-made prompts. Works with **Claude Desktop, Claude Code, OpenAI Codex CLI, the OpenAI Agents SDK**, and any spec-compliant stdio MCP client. **Every write is human-gated:** an Approve/Deny card inside the editor window, 120 s auto-deny, FIFO one-at-a-time, no headless bypass — the gate lives in the editor process, so no MCP client can write without a human click. Local socket + stdio only, no network connections; Linux/macOS first (Windows named-pipe transport in a future release). Prebuilt `notepatra-mcp` binaries ship as cosign-signed release artifacts; new docs page [notepatra.org/mcp.html](https://notepatra.org/mcp.html). Bare-binary size claim corrected 12.4 → 12.4 MB. Full ctest 68/68 + 47 sidecar cargo tests + live E2E 17/17. | diff --git a/docs/docs.html b/docs/docs.html index 6221aaf..3c4f421 100644 --- a/docs/docs.html +++ b/docs/docs.html @@ -299,7 +299,7 @@

Notepatra Notepatra - v0.1.120 docs + v0.1.121 docs