@setoelkahfi / sigit / commits / 0fcb6cb

Add Repo tab: issues and pull requests for sigit.si repos

When the session's origin remote points at the sigit.si host, the TUI grows a Repo tab between History and Cloud with Issues and Pull requests sections, fetched through the official MCP server's repo workflow tools. Up and Down select, Enter opens a scrollable detail, i, p, or Left and Right switch sections, r refreshes. For any other remote the tab is hidden and the cycle skips it. Host detection derives from SIGIT_API_URL (default sigit.si), ignoring port and case so dev instances work, and parses both ssh and https remote forms. Fetches run as spawned tasks feeding the render loop; a refresh swaps the results channel, which atomically discards in-flight responses and closes any open detail, so there are no stale-data races. Tool errors render in-tab with a /login and SIGIT_MCP hint and never block rendering. The official server's read tools (list_*, get_*, search_code, web_search under the mcp__sigit__ namespace) are classified read-only by prefix so browsing never prompts, while everything else stays gated. The system prompt names the issue and PR tools verbatim through the new official_tool_name helper, and a test fails if the two drift. Stacked on the tabbed-TUI branch; rebases onto development once that merges.

paydii committed Jul 5, 2026 at 09:24 UTC 0fcb6cbf28f0e0ae1faa50b4633e5c9e35d773c4
4 files changed +949 -33
src/chat.rs
+824 -29
@@ -69,7 +69,10 @@ pub(crate) fn parse_rich_text_segments(text: &str) -> Vec<(String, bool)> {
69 // Unix-only TUI consumes it at runtime, hence the non-Unix dead-code gates
70 // (same pattern as `permissions::TUI_SESSION`).
71
72 -/// The three top-level TUI tabs, cycled with the Tab key.
72 +/// The top-level TUI tabs, cycled with the Tab key. `Repo` only exists when
73 +/// the session cwd's `origin` remote points at the sigit.si host (see
74 +/// [`parse_repo_remote`]); every method takes `repo_visible` so the hidden tab
75 +/// is skipped entirely and the bar renders exactly three tabs without it.
76 #[cfg_attr(not(unix), allow(dead_code))]
77 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
78 pub(crate) enum Tab {
@@ -77,29 +80,52 @@ pub(crate) enum Tab {
80 Session,
81 /// Saved sessions from the session store.
82 History,
83 + /// Issues and pull requests of the sigit.si-hosted repo (when detected).
84 + Repo,
85 /// siGit Code Cloud status and settings.
86 Cloud,
87 }
88
89 #[cfg_attr(not(unix), allow(dead_code))]
90 impl Tab {
86 - pub(crate) const TITLES: [&'static str; 3] = ["Session", "History", "Cloud"];
91 + /// Tab-bar titles, in cycle order.
92 + pub(crate) fn titles(repo_visible: bool) -> Vec<&'static str> {
93 + if repo_visible {
94 + vec!["Session", "History", "Repo", "Cloud"]
95 + } else {
96 + vec!["Session", "History", "Cloud"]
97 + }
98 + }
99
88 - /// Session → History → Cloud → Session.
89 - pub(crate) fn next(self) -> Self {
100 + /// Session → History → (Repo →) Cloud → Session.
101 + pub(crate) fn next(self, repo_visible: bool) -> Self {
102 match self {
103 Tab::Session => Tab::History,
92 - Tab::History => Tab::Cloud,
104 + Tab::History => {
105 + if repo_visible {
106 + Tab::Repo
107 + } else {
108 + Tab::Cloud
109 + }
110 + }
111 + Tab::Repo => Tab::Cloud,
112 Tab::Cloud => Tab::Session,
113 }
114 }
115
97 - /// Position in [`Tab::TITLES`], for the ratatui `Tabs` widget.
98 - pub(crate) fn index(self) -> usize {
116 + /// Position in [`Tab::titles`], for the ratatui `Tabs` widget.
117 + pub(crate) fn index(self, repo_visible: bool) -> usize {
118 match self {
119 Tab::Session => 0,
120 Tab::History => 1,
102 - Tab::Cloud => 2,
121 + Tab::Repo => 2,
122 + Tab::Cloud => {
123 + if repo_visible {
124 + 3
125 + } else {
126 + 2
127 + }
128 + }
129 }
130 }
131 }
@@ -133,6 +159,161 @@ pub(crate) fn history_row(
159 format!("{id} · {when} · {message_count} message(s)")
160 }
161
162 +// ── Repo tab: remote detection and data parsing ───────────────────────────────
163 +//
164 +// Pure helpers for the Repo tab (issues / pull requests of a sigit.si-hosted
165 +// repo). Kept at the top level so they compile and test on every target; only
166 +// the Unix-only TUI consumes them at runtime.
167 +
168 +/// Host of the sigit.si instance this build talks to: the host of
169 +/// `SIGIT_API_URL` when set (same variable `account.rs` uses for the account
170 +/// API), else the production default.
171 +#[cfg_attr(not(unix), allow(dead_code))]
172 +pub(crate) fn sigit_host() -> String {
173 + host_from_api_url(std::env::var("SIGIT_API_URL").ok().as_deref())
174 +}
175 +
176 +/// Pure core of [`sigit_host`]: derive the host from an optional
177 +/// `SIGIT_API_URL` value, defaulting to `sigit.si`.
178 +#[cfg_attr(not(unix), allow(dead_code))]
179 +pub(crate) fn host_from_api_url(api_url: Option<&str>) -> String {
180 + api_url
181 + .and_then(url_host)
182 + .unwrap_or_else(|| "sigit.si".to_string())
183 +}
184 +
185 +/// Extract the hostname (no scheme, userinfo, port, or path) from an http(s)
186 +/// URL. `None` for anything else.
187 +#[cfg_attr(not(unix), allow(dead_code))]
188 +pub(crate) fn url_host(url: &str) -> Option<String> {
189 + let url = url.trim();
190 + let rest = url
191 + .strip_prefix("https://")
192 + .or_else(|| url.strip_prefix("http://"))?;
193 + let authority = rest.split(['/', '?', '#']).next()?;
194 + let host = authority.rsplit('@').next()?.split(':').next()?;
195 + if host.is_empty() {
196 + None
197 + } else {
198 + Some(host.to_ascii_lowercase())
199 + }
200 +}
201 +
202 +/// Extract `owner/name` from a git remote URL *iff* it points at `host`.
203 +///
204 +/// Understands the two forms sigit.si issues:
205 +/// - ssh (scp-like): `git@sigit.si:owner/name.git` (and without `.git`)
206 +/// - https: `https://sigit.si/owner/name.git` (and without `.git`)
207 +///
208 +/// A different host, extra path segments, or anything unparsable → `None`,
209 +/// which hides the Repo tab.
210 +#[cfg_attr(not(unix), allow(dead_code))]
211 +pub(crate) fn parse_repo_remote(remote: &str, host: &str) -> Option<String> {
212 + let remote = remote.trim();
213 +
214 + // ssh, scp-like: git@<host>:<owner>/<name>(.git)
215 + if let Some(rest) = remote.strip_prefix("git@") {
216 + let (remote_host, path) = rest.split_once(':')?;
217 + if !remote_host.eq_ignore_ascii_case(host) {
218 + return None;
219 + }
220 + return repo_owner_name(path);
221 + }
222 +
223 + // http(s)://<host>(:port)/<owner>/<name>(.git)
224 + let rest = remote
225 + .strip_prefix("https://")
226 + .or_else(|| remote.strip_prefix("http://"))?;
227 + let (authority, path) = rest.split_once('/')?;
228 + let remote_host = authority.rsplit('@').next()?.split(':').next()?;
229 + if !remote_host.eq_ignore_ascii_case(host) {
230 + return None;
231 + }
232 + repo_owner_name(path)
233 +}
234 +
235 +/// Validate and normalize a remote path into `owner/name` (strips a trailing
236 +/// `.git` / `/`). Exactly two non-empty segments, or `None`.
237 +fn repo_owner_name(path: &str) -> Option<String> {
238 + let path = path.trim().trim_end_matches('/');
239 + let path = path.strip_suffix(".git").unwrap_or(path);
240 + let mut segments = path.split('/');
241 + let owner = segments.next()?;
242 + let name = segments.next()?;
243 + if owner.is_empty() || name.is_empty() || segments.next().is_some() {
244 + return None;
245 + }
246 + Some(format!("{owner}/{name}"))
247 +}
248 +
249 +/// One row of the Repo tab's Issues / Pull requests lists.
250 +#[cfg_attr(not(unix), allow(dead_code))]
251 +#[derive(Debug, Clone, PartialEq, Eq)]
252 +pub(crate) struct RepoItem {
253 + pub(crate) number: u64,
254 + pub(crate) title: String,
255 + pub(crate) state: String,
256 +}
257 +
258 +/// Parse the JSON text block a `list_issues` / `list_pull_requests` MCP tool
259 +/// returns into rows. Accepts a top-level array or an object wrapping one
260 +/// under `issues` / `pull_requests` / `items`; entries missing `number` or
261 +/// `title` are skipped. Unparsable input (e.g. an `Error: …` string from
262 +/// `mcp::call_tool`) comes back as `Err` with the original text so the tab can
263 +/// show it verbatim.
264 +#[cfg_attr(not(unix), allow(dead_code))]
265 +pub(crate) fn parse_repo_items(text: &str) -> Result<Vec<RepoItem>, String> {
266 + let trimmed = text.trim();
267 + let value: serde_json::Value =
268 + serde_json::from_str(trimmed).map_err(|_| trimmed.to_string())?;
269 + let entries = value
270 + .as_array()
271 + .cloned()
272 + .or_else(|| {
273 + value.as_object().and_then(|object| {
274 + ["issues", "pull_requests", "items"]
275 + .iter()
276 + .find_map(|key| object.get(*key).and_then(|v| v.as_array()).cloned())
277 + })
278 + })
279 + .ok_or_else(|| trimmed.to_string())?;
280 +
281 + Ok(entries
282 + .iter()
283 + .filter_map(|entry| {
284 + Some(RepoItem {
285 + number: entry.get("number")?.as_u64()?,
286 + title: entry.get("title")?.as_str()?.to_string(),
287 + state: entry
288 + .get("state")
289 + .and_then(|v| v.as_str())
290 + .unwrap_or("")
291 + .to_string(),
292 + })
293 + })
294 + .collect())
295 +}
296 +
297 +/// One list row: number, state, title.
298 +#[cfg_attr(not(unix), allow(dead_code))]
299 +pub(crate) fn repo_row(item: &RepoItem) -> String {
300 + if item.state.is_empty() {
301 + format!("#{} {}", item.number, item.title)
302 + } else {
303 + format!("#{} [{}] {}", item.number, item.state, item.title)
304 + }
305 +}
306 +
307 +/// Render a `get_issue` / `get_pull_request` result for the detail view:
308 +/// pretty-print when it's JSON, pass anything else (e.g. error text) through.
309 +#[cfg_attr(not(unix), allow(dead_code))]
310 +pub(crate) fn format_repo_detail(text: &str) -> String {
311 + match serde_json::from_str::<serde_json::Value>(text.trim()) {
312 + Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| text.to_string()),
313 + Err(_) => text.to_string(),
314 + }
315 +}
316 +
317 // ── Unix-only TUI ─────────────────────────────────────────────────────────────
318 //
319 // macOS + Linux only. Windows uses ACP mode instead.
@@ -148,7 +329,7 @@ mod tui {
329 use futures::StreamExt;
330 use onde::inference::{ChatEngine, SamplingConfig};
331
151 - use super::Tab;
332 + use super::{RepoItem, Tab};
333 use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec};
334 use crate::models::{
335 InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items,
@@ -256,6 +437,60 @@ mod tui {
437 Error(String),
438 }
439
440 + // ── Repo tab types ────────────────────────────────────────────────────────
441 +
442 + /// Which list the Repo tab is showing.
443 + #[derive(Clone, Copy, PartialEq, Eq)]
444 + enum RepoSection {
445 + Issues,
446 + PullRequests,
447 + }
448 +
449 + impl RepoSection {
450 + fn other(self) -> Self {
451 + match self {
452 + RepoSection::Issues => RepoSection::PullRequests,
453 + RepoSection::PullRequests => RepoSection::Issues,
454 + }
455 + }
456 + }
457 +
458 + /// Lifecycle of one Repo-tab list. `Failed` carries the error text returned
459 + /// by the MCP layer (signed out, MCP off, tool error, unparsable reply) so
460 + /// the tab can show it verbatim plus a hint.
461 + enum RepoData {
462 + /// Nothing fetched yet — kicked off on first entry to the tab.
463 + NotFetched,
464 + Loading,
465 + Ready(Vec<RepoItem>),
466 + Failed(String),
467 + }
468 +
469 + impl RepoData {
470 + fn len(&self) -> usize {
471 + match self {
472 + RepoData::Ready(items) => items.len(),
473 + _ => 0,
474 + }
475 + }
476 + }
477 +
478 + /// The full-tab detail view opened with Enter on a list row.
479 + struct RepoDetail {
480 + title: String,
481 + /// `None` while the `get_issue` / `get_pull_request` fetch runs.
482 + text: Option<String>,
483 + scroll: u16,
484 + }
485 +
486 + /// Results from the Repo tab's spawned fetch tasks, multiplexed into the
487 + /// event loop alongside inference updates (same pattern as the Cloud tab).
488 + enum RepoUpdate {
489 + Issues(Result<Vec<RepoItem>, String>),
490 + PullRequests(Result<Vec<RepoItem>, String>),
491 + Detail(String),
492 + }
493 +
494 // ── App state ─────────────────────────────────────────────────────────────
495
496 struct App {
@@ -324,6 +559,22 @@ mod tui {
559 /// `None` while a fetch is in flight (renders as "fetching…").
560 cloud_lines: Option<Vec<String>>,
561 cloud_rx: Option<oneshot::Receiver<Vec<String>>>,
562 +
563 + // Repo tab: issues and pull requests of a sigit.si-hosted repo.
564 + /// `owner/name` when the cwd's `origin` remote points at the sigit.si
565 + /// host; `None` hides the Repo tab entirely.
566 + repo: Option<String>,
567 + repo_section: RepoSection,
568 + repo_issues: RepoData,
569 + repo_prs: RepoData,
570 + repo_issue_index: usize,
571 + repo_pr_index: usize,
572 + /// Detail view over the list, opened with Enter, closed with Esc.
573 + repo_detail: Option<RepoDetail>,
574 + /// Sender cloned into the spawned fetch tasks; kept here so the
575 + /// receiver never reads a spurious disconnect between fetches.
576 + repo_tx: Option<mpsc::UnboundedSender<RepoUpdate>>,
577 + repo_rx: Option<mpsc::UnboundedReceiver<RepoUpdate>>,
578 }
579
580 const BANNER_ART: &str = "\
@@ -412,6 +663,36 @@ mod tui {
663 history_notice: None,
664 cloud_lines: None,
665 cloud_rx: None,
666 + repo: detect_sigit_repo(),
667 + repo_section: RepoSection::Issues,
668 + repo_issues: RepoData::NotFetched,
669 + repo_prs: RepoData::NotFetched,
670 + repo_issue_index: 0,
671 + repo_pr_index: 0,
672 + repo_detail: None,
673 + repo_tx: None,
674 + repo_rx: None,
675 + }
676 + }
677 +
678 + /// Whether the Repo tab exists for this session.
679 + fn repo_visible(&self) -> bool {
680 + self.repo.is_some()
681 + }
682 +
683 + /// The data behind the Repo tab's active section.
684 + fn repo_list(&self) -> &RepoData {
685 + match self.repo_section {
686 + RepoSection::Issues => &self.repo_issues,
687 + RepoSection::PullRequests => &self.repo_prs,
688 + }
689 + }
690 +
691 + /// Selection index of the Repo tab's active section.
692 + fn repo_index_mut(&mut self) -> &mut usize {
693 + match self.repo_section {
694 + RepoSection::Issues => &mut self.repo_issue_index,
695 + RepoSection::PullRequests => &mut self.repo_pr_index,
696 }
697 }
698
@@ -868,7 +1149,8 @@ mod tui {
1149 // ── Tab bar (Session / History / Cloud) ───────────────────────────────────
1150
1151 /// Switch to `tab`, refreshing the data it shows. Entering History rescans
871 - /// the sessions dir; entering Cloud kicks off the async status fetch.
1152 + /// the sessions dir; entering Cloud kicks off the async status fetch;
1153 + /// entering Repo fetches the issue/PR lists on first visit.
1154 fn switch_tab(app: &mut App, tab: Tab, engine: &Arc<ChatEngine>) {
1155 app.active_tab = tab;
1156 match tab {
@@ -877,6 +1159,12 @@ mod tui {
1159 app.refresh_history();
1160 app.history_notice = None;
1161 }
1162 + Tab::Repo => {
1163 + // Fetch once on first entry; `r` refreshes after that.
1164 + if matches!(app.repo_issues, RepoData::NotFetched) {
1165 + refresh_repo(app);
1166 + }
1167 + }
1168 Tab::Cloud => refresh_cloud(app, engine),
1169 }
1170 }
@@ -939,6 +1227,92 @@ mod tui {
1227 });
1228 }
1229
1230 + // ── Repo tab (issues / pull requests via the official MCP server) ─────────
1231 +
1232 + /// `owner/name` when the current directory's `origin` remote lives on the
1233 + /// sigit.si host (per `SIGIT_API_URL`). Runs `git remote get-url origin`
1234 + /// once at startup; any failure (no git, no repo, no origin) → `None`.
1235 + fn detect_sigit_repo() -> Option<String> {
1236 + let output = std::process::Command::new("git")
1237 + .args(["remote", "get-url", "origin"])
1238 + .output()
1239 + .ok()?;
1240 + if !output.status.success() {
1241 + return None;
1242 + }
1243 + let url = String::from_utf8_lossy(&output.stdout);
1244 + super::parse_repo_remote(url.trim(), &super::sigit_host())
1245 + }
1246 +
1247 + /// Fetch both Repo-tab lists on spawned tasks feeding `repo_rx` — the same
1248 + /// spawned-fetch pattern as the Cloud tab, so the render loop never blocks
1249 + /// on the network. Replacing the channel drops any in-flight fetch's
1250 + /// updates on the floor, which is exactly what a refresh wants.
1251 + fn refresh_repo(app: &mut App) {
1252 + let Some(repo) = app.repo.clone() else {
1253 + return;
1254 + };
1255 +
1256 + let (tx, rx) = mpsc::unbounded_channel();
1257 + app.repo_tx = Some(tx.clone());
1258 + app.repo_rx = Some(rx);
1259 + app.repo_issues = RepoData::Loading;
1260 + app.repo_prs = RepoData::Loading;
1261 + // A stale detail view would outlive the list it was opened from.
1262 + app.repo_detail = None;
1263 +
1264 + let args = serde_json::json!({ "repo": repo }).to_string();
1265 +
1266 + let issues_tx = tx.clone();
1267 + let issues_args = args.clone();
1268 + tokio::spawn(async move {
1269 + let tool = crate::mcp::official_tool_name("list_issues");
1270 + let text = crate::mcp::call_tool(&tool, &issues_args).await;
1271 + let _ = issues_tx.send(RepoUpdate::Issues(super::parse_repo_items(&text)));
1272 + });
1273 +
1274 + tokio::spawn(async move {
1275 + let tool = crate::mcp::official_tool_name("list_pull_requests");
1276 + let text = crate::mcp::call_tool(&tool, &args).await;
1277 + let _ = tx.send(RepoUpdate::PullRequests(super::parse_repo_items(&text)));
1278 + });
1279 + }
1280 +
1281 + /// Open the detail view for the selected row and fetch its body
1282 + /// (`get_issue` / `get_pull_request`) on a spawned task.
1283 + fn open_repo_detail(app: &mut App) {
1284 + let Some(repo) = app.repo.clone() else {
1285 + return;
1286 + };
1287 + let (tool, label, index) = match app.repo_section {
1288 + RepoSection::Issues => ("get_issue", "Issue", app.repo_issue_index),
1289 + RepoSection::PullRequests => ("get_pull_request", "Pull request", app.repo_pr_index),
1290 + };
1291 + let RepoData::Ready(items) = app.repo_list() else {
1292 + return;
1293 + };
1294 + let Some(item) = items.get(index) else {
1295 + return;
1296 + };
1297 +
1298 + let number = item.number;
1299 + app.repo_detail = Some(RepoDetail {
1300 + title: format!("{label} #{number} — {}", item.title),
1301 + text: None,
1302 + scroll: 0,
1303 + });
1304 +
1305 + let Some(tx) = app.repo_tx.clone() else {
1306 + return;
1307 + };
1308 + let tool = crate::mcp::official_tool_name(tool);
1309 + let args = serde_json::json!({ "repo": repo, "number": number }).to_string();
1310 + tokio::spawn(async move {
1311 + let text = crate::mcp::call_tool(&tool, &args).await;
1312 + let _ = tx.send(RepoUpdate::Detail(super::format_repo_detail(&text)));
1313 + });
1314 + }
1315 +
1316 /// Keys on the History and Cloud tabs (the Session tab keeps `handle_key`).
1317 /// Tab/Esc navigation is handled earlier in the event loop; this gets the
1318 /// rest.
@@ -1004,6 +1378,38 @@ mod tui {
1378 // Any other key cancels a pending delete confirmation.
1379 _ => app.history_pending_delete = None,
1380 },
1381 + Tab::Repo => {
1382 + // Detail view open: Up/Down scroll it; Esc (handled in the
1383 + // event loop) closes it back to the list.
1384 + if let Some(detail) = app.repo_detail.as_mut() {
1385 + match key.code {
1386 + KeyCode::Up => detail.scroll = detail.scroll.saturating_sub(1),
1387 + KeyCode::Down => detail.scroll = detail.scroll.saturating_add(1),
1388 + KeyCode::Char('r') => refresh_repo(app),
1389 + _ => {}
1390 + }
1391 + return;
1392 + }
1393 + match key.code {
1394 + KeyCode::Left | KeyCode::Right => {
1395 + app.repo_section = app.repo_section.other();
1396 + }
1397 + KeyCode::Char('i') => app.repo_section = RepoSection::Issues,
1398 + KeyCode::Char('p') => app.repo_section = RepoSection::PullRequests,
1399 + KeyCode::Up => {
1400 + let index = app.repo_index_mut();
1401 + *index = index.saturating_sub(1);
1402 + }
1403 + KeyCode::Down => {
1404 + let max = app.repo_list().len().saturating_sub(1);
1405 + let index = app.repo_index_mut();
1406 + *index = (*index + 1).min(max);
1407 + }
1408 + KeyCode::Enter => open_repo_detail(app),
1409 + KeyCode::Char('r') => refresh_repo(app),
1410 + _ => {}
1411 + }
1412 + }
1413 Tab::Cloud => match key.code {
1414 KeyCode::Char('l') => {
1415 let enabled = !crate::settings::local_inference_enabled();
@@ -1023,8 +1429,12 @@ mod tui {
1429 }
1430
1431 fn render_tab_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1026 - let tabs = Tabs::new(Tab::TITLES.map(Line::from).to_vec())
1027 - .select(app.active_tab.index())
1432 + let titles: Vec<Line> = Tab::titles(app.repo_visible())
1433 + .into_iter()
1434 + .map(Line::from)
1435 + .collect();
1436 + let tabs = Tabs::new(titles)
1437 + .select(app.active_tab.index(app.repo_visible()))
1438 .style(Style::default().fg(Color::DarkGray))
1439 .highlight_style(
1440 Style::default()
@@ -1093,6 +1503,147 @@ mod tui {
1503 );
1504 }
1505
1506 + fn render_repo_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1507 + let block = Block::default()
1508 + .borders(Borders::ALL)
1509 + .border_style(Style::default().fg(Color::DarkGray))
1510 + .title(format!(" {} ", app.repo.as_deref().unwrap_or("repo")));
1511 + let inner = block.inner(area);
1512 + frame.render_widget(block, area);
1513 +
1514 + // Detail view: a full-tab scrollable paragraph; Esc goes back.
1515 + if let Some(ref detail) = app.repo_detail {
1516 + let mut lines: Vec<Line> = vec![
1517 + Line::from(Span::styled(
1518 + format!(" {}", detail.title),
1519 + Style::default()
1520 + .fg(Color::Green)
1521 + .add_modifier(Modifier::BOLD),
1522 + )),
1523 + Line::from(""),
1524 + ];
1525 + match detail.text {
1526 + None => lines.push(Line::from(Span::styled(
1527 + " fetching…",
1528 + Style::default().fg(Color::DarkGray),
1529 + ))),
1530 + Some(ref text) => {
1531 + for text_line in text.lines() {
1532 + lines.push(Line::from(Span::styled(
1533 + format!(" {text_line}"),
1534 + Style::default().fg(Color::White),
1535 + )));
1536 + }
1537 + }
1538 + }
1539 + frame.render_widget(
1540 + Paragraph::new(lines)
1541 + .wrap(Wrap { trim: false })
1542 + .scroll((detail.scroll, 0)),
1543 + inner,
1544 + );
1545 + return;
1546 + }
1547 +
1548 + // Section header: Issues ←→ Pull requests.
1549 + let selected_style = Style::default()
1550 + .fg(Color::Black)
1551 + .bg(Color::Green)
1552 + .add_modifier(Modifier::BOLD);
1553 + let idle_style = Style::default().fg(Color::DarkGray);
1554 + let issues_active = app.repo_section == RepoSection::Issues;
1555 + let mut lines: Vec<Line> = vec![
1556 + Line::from(vec![
1557 + Span::raw(" "),
1558 + Span::styled(
1559 + " Issues ",
1560 + if issues_active {
1561 + selected_style
1562 + } else {
1563 + idle_style
1564 + },
1565 + ),
1566 + Span::raw(" "),
1567 + Span::styled(
1568 + " Pull requests ",
1569 + if issues_active {
1570 + idle_style
1571 + } else {
1572 + selected_style
1573 + },
1574 + ),
1575 + ]),
1576 + Line::from(""),
1577 + ];
1578 + // Rows above the first list entry, for the keep-selection-visible math.
1579 + let header_rows = lines.len();
1580 +
1581 + let (data, index) = match app.repo_section {
1582 + RepoSection::Issues => (&app.repo_issues, app.repo_issue_index),
1583 + RepoSection::PullRequests => (&app.repo_prs, app.repo_pr_index),
1584 + };
1585 +
1586 + match data {
1587 + RepoData::NotFetched | RepoData::Loading => {
1588 + lines.push(Line::from(Span::styled(
1589 + " fetching…",
1590 + Style::default().fg(Color::DarkGray),
1591 + )));
1592 + }
1593 + RepoData::Failed(error) => {
1594 + for error_line in error.lines() {
1595 + lines.push(Line::from(Span::styled(
1596 + format!(" {error_line}"),
1597 + Style::default().fg(Color::Red),
1598 + )));
1599 + }
1600 + lines.push(Line::from(""));
1601 + lines.push(Line::from(Span::styled(
1602 + " These lists come from the official sigit.si MCP server. Sign in \
1603 + with /login, and make sure SIGIT_MCP / SIGIT_MCP_OFFICIAL are not \
1604 + set to off. Press r to retry.",
1605 + Style::default().fg(Color::Yellow),
1606 + )));
1607 + }
1608 + RepoData::Ready(items) if items.is_empty() => {
1609 + lines.push(Line::from(Span::styled(
1610 + if issues_active {
1611 + " No issues."
1612 + } else {
1613 + " No pull requests."
1614 + },
1615 + Style::default().fg(Color::DarkGray),
1616 + )));
1617 + }
1618 + RepoData::Ready(items) => {
1619 + for (row_index, item) in items.iter().enumerate() {
1620 + let selected = row_index == index;
1621 + let marker = if selected { "› " } else { " " };
1622 + let style = if selected {
1623 + Style::default().fg(Color::Black).bg(Color::Green)
1624 + } else {
1625 + Style::default().fg(Color::White)
1626 + };
1627 + lines.push(Line::from(Span::styled(
1628 + format!("{marker}{}", super::repo_row(item)),
1629 + style,
1630 + )));
1631 + }
1632 + }
1633 + }
1634 +
1635 + // Keep the selection visible when the list outgrows the pane (same
1636 + // approach as the History tab, offset by the section header rows).
1637 + let inner_height = inner.height as usize;
1638 + let scroll = (index + header_rows).saturating_sub(inner_height.saturating_sub(1)) as u16;
1639 + frame.render_widget(
1640 + Paragraph::new(lines)
1641 + .wrap(Wrap { trim: false })
1642 + .scroll((scroll, 0)),
1643 + inner,
1644 + );
1645 + }
1646 +
1647 fn render_cloud_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
1648 let block = Block::default()
1649 .borders(Borders::ALL)
@@ -1234,7 +1785,7 @@ mod tui {
1785 render_footer(frame, app, zones[4]);
1786 }
1787 // No input pane on the non-chat tabs: the Tab key always cycles.
1237 - Tab::History | Tab::Cloud => {
1788 + Tab::History | Tab::Repo | Tab::Cloud => {
1789 let zones = Layout::vertical([
1790 Constraint::Length(1),
1791 Constraint::Length(1),
@@ -1245,10 +1796,10 @@ mod tui {
1796
1797 render_tab_bar(frame, app, zones[0]);
1798 render_title(frame, app, zones[1]);
1248 - if app.active_tab == Tab::History {
1249 - render_history_tab(frame, app, zones[2]);
1250 - } else {
1251 - render_cloud_tab(frame, app, zones[2]);
1799 + match app.active_tab {
1800 + Tab::History => render_history_tab(frame, app, zones[2]),
1801 + Tab::Repo => render_repo_tab(frame, app, zones[2]),
1802 + _ => render_cloud_tab(frame, app, zones[2]),
1803 }
1804 render_footer(frame, app, zones[3]);
1805 }
@@ -1575,6 +2126,20 @@ mod tui {
2126 (" Tab ", " next tab "),
2127 (" Esc ", " session"),
2128 ],
2129 + Tab::Repo if app.repo_detail.is_some() => &[
2130 + (" ↑/↓ ", " scroll "),
2131 + (" Esc ", " back "),
2132 + (" r ", " refresh "),
2133 + (" Tab ", " next tab"),
2134 + ],
2135 + Tab::Repo => &[
2136 + (" ←/→ i/p ", " section "),
2137 + (" ↑/↓ ", " select "),
2138 + (" Enter ", " open "),
2139 + (" r ", " refresh "),
2140 + (" Tab ", " next tab "),
2141 + (" Esc ", " session"),
2142 + ],
2143 _ => &[
2144 (" l ", " toggle local inference "),
2145 (" r ", " refresh "),
@@ -2535,6 +3100,44 @@ mod tui {
3100 }));
3101 }
3102
3103 + // ── Repo tab fetches resolving ────────────────────────────────
3104 + update = async {
3105 + match app.repo_rx.as_mut() {
3106 + Some(rx) => rx.recv().await,
3107 + None => pending().await,
3108 + }
3109 + } => {
3110 + match update {
3111 + Some(RepoUpdate::Issues(result)) => {
3112 + app.repo_issues = match result {
3113 + Ok(items) => RepoData::Ready(items),
3114 + Err(error) => RepoData::Failed(error),
3115 + };
3116 + app.repo_issue_index = app
3117 + .repo_issue_index
3118 + .min(app.repo_issues.len().saturating_sub(1));
3119 + }
3120 + Some(RepoUpdate::PullRequests(result)) => {
3121 + app.repo_prs = match result {
3122 + Ok(items) => RepoData::Ready(items),
3123 + Err(error) => RepoData::Failed(error),
3124 + };
3125 + app.repo_pr_index = app
3126 + .repo_pr_index
3127 + .min(app.repo_prs.len().saturating_sub(1));
3128 + }
3129 + Some(RepoUpdate::Detail(text)) => {
3130 + // Ignored if the user already closed the view.
3131 + if let Some(detail) = app.repo_detail.as_mut() {
3132 + detail.text = Some(text);
3133 + }
3134 + }
3135 + // All senders gone (can't happen while app.repo_tx is
3136 + // held) — stop polling this channel.
3137 + None => app.repo_rx = None,
3138 + }
3139 + }
3140 +
3141 // ── thinking / switching spinner tick (100ms) ────────────────
3142 _ = async {
3143 if app.thinking || app.switching_model {
@@ -2630,12 +3233,18 @@ mod tui {
3233 if key.code == KeyCode::Tab
3234 && (app.active_tab != Tab::Session || app.input.is_empty())
3235 {
2633 - let next = app.active_tab.next();
3236 + let next = app.active_tab.next(app.repo_visible());
3237 switch_tab(&mut app, next, &engine);
3238 continue;
3239 }
3240 if app.active_tab != Tab::Session && key.code == KeyCode::Esc {
2638 - app.active_tab = Tab::Session;
3241 + // On the Repo tab, Esc first closes an open
3242 + // detail view (back to the list).
3243 + if app.active_tab == Tab::Repo && app.repo_detail.is_some() {
3244 + app.repo_detail = None;
3245 + } else {
3246 + app.active_tab = Tab::Session;
3247 + }
3248 continue;
3249 }
3250 }
@@ -2766,22 +3375,208 @@ pub use tui::run_with;
3375 mod tests {
3376 use std::time::Duration;
3377
2769 - use super::{Tab, format_age, history_row, parse_rich_text_segments, strip_think_blocks};
3378 + use super::{
3379 + RepoItem, Tab, format_age, format_repo_detail, history_row, host_from_api_url,
3380 + parse_repo_items, parse_repo_remote, parse_rich_text_segments, repo_row,
3381 + strip_think_blocks, url_host,
3382 + };
3383
3384 #[test]
2772 - fn tab_next_cycles_session_history_cloud() {
2773 - assert_eq!(Tab::Session.next(), Tab::History);
2774 - assert_eq!(Tab::History.next(), Tab::Cloud);
2775 - assert_eq!(Tab::Cloud.next(), Tab::Session);
2776 - // Three hops return to the start, matching the tab bar's order.
2777 - assert_eq!(Tab::Session.next().next().next(), Tab::Session);
3385 + fn tab_next_without_repo_cycles_three_tabs() {
3386 + assert_eq!(Tab::Session.next(false), Tab::History);
3387 + assert_eq!(Tab::History.next(false), Tab::Cloud);
3388 + assert_eq!(Tab::Cloud.next(false), Tab::Session);
3389 + // Three hops return to the start — exactly the base three-tab cycle.
3390 + assert_eq!(
3391 + Tab::Session.next(false).next(false).next(false),
3392 + Tab::Session
3393 + );
3394 + }
3395 +
3396 + #[test]
3397 + fn tab_next_with_repo_cycles_four_tabs() {
3398 + assert_eq!(Tab::Session.next(true), Tab::History);
3399 + assert_eq!(Tab::History.next(true), Tab::Repo);
3400 + assert_eq!(Tab::Repo.next(true), Tab::Cloud);
3401 + assert_eq!(Tab::Cloud.next(true), Tab::Session);
3402 }
3403
3404 #[test]
3405 fn tab_index_matches_titles_order() {
2782 - assert_eq!(Tab::TITLES[Tab::Session.index()], "Session");
2783 - assert_eq!(Tab::TITLES[Tab::History.index()], "History");
2784 - assert_eq!(Tab::TITLES[Tab::Cloud.index()], "Cloud");
3406 + for repo_visible in [false, true] {
3407 + let titles = Tab::titles(repo_visible);
3408 + assert_eq!(titles[Tab::Session.index(repo_visible)], "Session");
3409 + assert_eq!(titles[Tab::History.index(repo_visible)], "History");
3410 + assert_eq!(titles[Tab::Cloud.index(repo_visible)], "Cloud");
3411 + }
3412 + assert_eq!(Tab::titles(true)[Tab::Repo.index(true)], "Repo");
3413 + }
3414 +
3415 + #[test]
3416 + fn tab_titles_hide_repo_when_undetected() {
3417 + assert_eq!(Tab::titles(false), vec!["Session", "History", "Cloud"]);
3418 + assert_eq!(
3419 + Tab::titles(true),
3420 + vec!["Session", "History", "Repo", "Cloud"]
3421 + );
3422 + }
3423 +
3424 + // ── Repo remote detection ─────────────────────────────────────────────────
3425 +
3426 + #[test]
3427 + fn parse_repo_remote_accepts_ssh_forms() {
3428 + assert_eq!(
3429 + parse_repo_remote("git@sigit.si:acme/demo.git", "sigit.si"),
3430 + Some("acme/demo".to_string())
3431 + );
3432 + // bare (no .git) works too
3433 + assert_eq!(
3434 + parse_repo_remote("git@sigit.si:acme/demo", "sigit.si"),
3435 + Some("acme/demo".to_string())
3436 + );
3437 + }
3438 +
3439 + #[test]
3440 + fn parse_repo_remote_accepts_https_forms() {
3441 + assert_eq!(
3442 + parse_repo_remote("https://sigit.si/acme/demo.git", "sigit.si"),
3443 + Some("acme/demo".to_string())
3444 + );
3445 + assert_eq!(
3446 + parse_repo_remote("https://sigit.si/acme/demo", "sigit.si"),
3447 + Some("acme/demo".to_string())
3448 + );
3449 + // trailing slash and http + port (dev instances) normalize too
3450 + assert_eq!(
3451 + parse_repo_remote("https://sigit.si/acme/demo/", "sigit.si"),
3452 + Some("acme/demo".to_string())
3453 + );
3454 + assert_eq!(
3455 + parse_repo_remote("http://127.0.0.1:8088/acme/demo.git", "127.0.0.1"),
3456 + Some("acme/demo".to_string())
3457 + );
3458 + }
3459 +
3460 + #[test]
3461 + fn parse_repo_remote_rejects_other_hosts() {
3462 + assert_eq!(
3463 + parse_repo_remote("git@github.com:acme/demo.git", "sigit.si"),
3464 + None
3465 + );
3466 + assert_eq!(
3467 + parse_repo_remote("https://github.com/acme/demo.git", "sigit.si"),
3468 + None
3469 + );
3470 + }
3471 +
3472 + #[test]
3473 + fn parse_repo_remote_rejects_garbage_and_bad_paths() {
3474 + assert_eq!(parse_repo_remote("", "sigit.si"), None);
3475 + assert_eq!(parse_repo_remote("not a url at all", "sigit.si"), None);
3476 + assert_eq!(parse_repo_remote("/local/path/repo.git", "sigit.si"), None);
3477 + // wrong number of path segments
3478 + assert_eq!(
3479 + parse_repo_remote("https://sigit.si/demo.git", "sigit.si"),
3480 + None
3481 + );
3482 + assert_eq!(
3483 + parse_repo_remote("https://sigit.si/a/b/c.git", "sigit.si"),
3484 + None
3485 + );
3486 + assert_eq!(
3487 + parse_repo_remote("git@sigit.si:/demo.git", "sigit.si"),
3488 + None
3489 + );
3490 + }
3491 +
3492 + #[test]
3493 + fn url_host_extracts_the_hostname() {
3494 + assert_eq!(url_host("https://sigit.si"), Some("sigit.si".to_string()));
3495 + assert_eq!(
3496 + url_host("http://localhost:8088/api/v1"),
3497 + Some("localhost".to_string())
3498 + );
3499 + assert_eq!(
3500 + url_host("https://SiGit.SI/path"),
3501 + Some("sigit.si".to_string())
3502 + );
3503 + assert_eq!(url_host("ftp://sigit.si"), None);
3504 + assert_eq!(url_host(""), None);
3505 + }
3506 +
3507 + #[test]
3508 + fn host_from_api_url_defaults_to_production() {
3509 + assert_eq!(host_from_api_url(None), "sigit.si");
3510 + assert_eq!(host_from_api_url(Some("nonsense")), "sigit.si");
3511 + assert_eq!(
3512 + host_from_api_url(Some("http://127.0.0.1:8088")),
3513 + "127.0.0.1"
3514 + );
3515 + }
3516 +
3517 + // ── Repo tab data parsing ─────────────────────────────────────────────────
3518 +
3519 + #[test]
3520 + fn parse_repo_items_reads_a_top_level_array() {
3521 + let text = r#"[
3522 + {"number": 12, "title": "Fix the flux capacitor", "state": "open"},
3523 + {"number": 7, "title": "Old bug", "state": "closed"}
3524 + ]"#;
3525 + let items = parse_repo_items(text).expect("items");
3526 + assert_eq!(items.len(), 2);
3527 + assert_eq!(items[0].number, 12);
3528 + assert_eq!(items[0].title, "Fix the flux capacitor");
3529 + assert_eq!(items[0].state, "open");
3530 + }
3531 +
3532 + #[test]
3533 + fn parse_repo_items_reads_wrapped_arrays_and_skips_malformed_entries() {
3534 + let text = r#"{"issues": [
3535 + {"number": 1, "title": "ok", "state": "open"},
3536 + {"title": "no number"},
3537 + {"number": 2, "title": "stateless"}
3538 + ]}"#;
3539 + let items = parse_repo_items(text).expect("items");
3540 + assert_eq!(items.len(), 2);
3541 + assert_eq!(items[1].state, "");
3542 + }
3543 +
3544 + #[test]
3545 + fn parse_repo_items_passes_error_text_through() {
3546 + // `mcp::call_tool` returns error strings, not JSON — the tab shows them.
3547 + let err = parse_repo_items("Error: MCP is not initialized.").unwrap_err();
3548 + assert_eq!(err, "Error: MCP is not initialized.");
3549 + // JSON that isn't a list shape is surfaced verbatim too.
3550 + assert!(parse_repo_items("\"unexpected\"").is_err());
3551 + }
3552 +
3553 + #[test]
3554 + fn repo_row_formats_number_state_title() {
3555 + let item = RepoItem {
3556 + number: 12,
3557 + title: "Fix the flux capacitor".to_string(),
3558 + state: "open".to_string(),
3559 + };
3560 + assert_eq!(repo_row(&item), "#12 [open] Fix the flux capacitor");
3561 + let stateless = RepoItem {
3562 + number: 3,
3563 + title: "t".to_string(),
3564 + state: String::new(),
3565 + };
3566 + assert_eq!(repo_row(&stateless), "#3 t");
3567 + }
3568 +
3569 + #[test]
3570 + fn format_repo_detail_pretty_prints_json_and_passes_text_through() {
3571 + let pretty = format_repo_detail(r#"{"number":1,"title":"x"}"#);
3572 + assert!(
3573 + pretty.contains("\n"),
3574 + "expected pretty-printed JSON: {pretty}"
3575 + );
3576 + assert_eq!(
3577 + format_repo_detail("Error: server 'sigit' returned 401"),
3578 + "Error: server 'sigit' returned 401"
3579 + );
3580 }
3581
3582 #[test]
src/main.rs
+25
@@ -148,6 +148,12 @@ Git operations — always use run_command:
148 lands without it, siGit Code amends the trailer in automatically and the tool \
149 output says so; do not amend again yourself.
150
151 +For repositories hosted on sigit.si, issue and pull request workflows go \
152 +through the official MCP tools: mcp__sigit__list_issues, mcp__sigit__get_issue, \
153 +mcp__sigit__create_issue, mcp__sigit__list_pull_requests, and \
154 +mcp__sigit__get_pull_request. Prefer these tools over shelling out to git (or \
155 +fetching web pages) for issue and PR queries on sigit.si repos.
156 +
157 Never introduce yourself unless asked. Jump straight into the answer. \
158 Keep answers short. Write idiomatic code. \
159 Fix root causes, not symptoms.
@@ -3179,6 +3185,25 @@ mod tests {
3185 );
3186 }
3187
3188 + #[test]
3189 + fn system_prompt_points_issue_and_pr_work_at_the_official_mcp_tools() {
3190 + // The guidance must reference the exact namespaced names the official
3191 + // server's tools get (see mcp::official_tool_name), or the model will
3192 + // call tools that don't exist.
3193 + for tool in [
3194 + "list_issues",
3195 + "get_issue",
3196 + "create_issue",
3197 + "list_pull_requests",
3198 + "get_pull_request",
3199 + ] {
3200 + assert!(
3201 + SYSTEM_PROMPT.contains(&mcp::official_tool_name(tool)),
3202 + "SYSTEM_PROMPT must mention mcp__sigit__{tool}"
3203 + );
3204 + }
3205 + }
3206 +
3207 #[test]
3208 fn ascii_safe_replaces_multibyte_chars() {
3209 // The exact label that crashed Zed: the cloud tier name plus the old
src/mcp.rs
+44 -2
@@ -55,6 +55,26 @@ use crate::backend::ToolSpec;
55 /// `mcp__<server>__<tool>`.
56 pub const MCP_PREFIX: &str = "mcp__";
57
58 +/// Name of the baked-in official siGit Code server; its tools are namespaced
59 +/// `mcp__sigit__<tool>`. A user-defined `mcp.toml` entry with this name
60 +/// overrides the baked-in URL/headers but keeps the namespace, so callers of
61 +/// [`official_tool_name`] reach whatever the user pointed `sigit` at.
62 +pub const OFFICIAL_SERVER_NAME: &str = "sigit";
63 +
64 +/// The full namespaced name of a tool on the official server, e.g.
65 +/// `official_tool_name("list_issues")` → `mcp__sigit__list_issues`.
66 +pub fn official_tool_name(tool: &str) -> String {
67 + format!("{MCP_PREFIX}{OFFICIAL_SERVER_NAME}__{tool}")
68 +}
69 +
70 +/// The bare tool name when `name` belongs to the official server
71 +/// (`mcp__sigit__list_issues` → `Some("list_issues")`), else `None`.
72 +pub fn official_tool_suffix(name: &str) -> Option<&str> {
73 + name.strip_prefix(MCP_PREFIX)?
74 + .strip_prefix(OFFICIAL_SERVER_NAME)?
75 + .strip_prefix("__")
76 +}
77 +
78 /// JSON-RPC / MCP protocol version we advertise in the handshake.
79 const PROTOCOL_VERSION: &str = "2025-06-18";
80
@@ -251,13 +271,13 @@ fn load_configs() -> Vec<ServerDef> {
271
272 // Add the baked-in official server, but never clobber a user-defined entry
273 // named `sigit` — an explicit config (e.g. a custom URL or headers) wins.
254 - if include_official && !defs.iter().any(|d| d.name == "sigit") {
274 + if include_official && !defs.iter().any(|d| d.name == OFFICIAL_SERVER_NAME) {
275 let mut headers = Vec::new();
276 if let Some(token) = crate::credentials::load_token() {
277 headers.push(("Authorization".to_string(), format!("Bearer {token}")));
278 }
279 defs.push(ServerDef {
260 - name: "sigit".to_string(),
280 + name: OFFICIAL_SERVER_NAME.to_string(),
281 url: official_url(),
282 headers,
283 });
@@ -828,6 +848,28 @@ mod tests {
848 assert!(!is_mcp_tool("skill"));
849 }
850
851 + #[test]
852 + fn official_tool_name_matches_the_namespacing_convention() {
853 + assert_eq!(official_tool_name("list_issues"), "mcp__sigit__list_issues");
854 + assert_eq!(
855 + official_tool_name("get_pull_request"),
856 + "mcp__sigit__get_pull_request"
857 + );
858 + }
859 +
860 + #[test]
861 + fn official_tool_suffix_strips_only_the_official_namespace() {
862 + assert_eq!(
863 + official_tool_suffix("mcp__sigit__list_issues"),
864 + Some("list_issues")
865 + );
866 + assert_eq!(official_tool_suffix("mcp__other__list_issues"), None);
867 + // `sigit` must be the whole server name, not a prefix of it.
868 + assert_eq!(official_tool_suffix("mcp__sigitx__list_issues"), None);
869 + assert_eq!(official_tool_suffix("list_issues"), None);
870 + assert_eq!(official_tool_suffix("mcp__sigit__"), Some(""));
871 + }
872 +
873 #[test]
874 fn sanitize_collapses_invalid_chars() {
875 assert_eq!(sanitize("github"), "github");
src/permissions.rs
+56 -2
@@ -22,7 +22,8 @@
22 //!
23 //! Tools discovered from MCP servers (`mcp__*`) and any unknown tool name are
24 //! treated as mutating: external tools can have arbitrary side effects, so the
25 -//! safe assumption is to gate them.
25 +//! safe assumption is to gate them. The one exception is the official
26 +//! sigit.si server's query tools (see [`classify`]), which are read-only.
27 //!
28 //! Session state (grants + plan mode) lives in a process-global keyed by
29 //! session id — the same pattern as `mcp.rs`'s server cache — so the ACP
@@ -67,11 +68,26 @@ pub enum Decision {
68 /// `task` is read-only because the subagent it launches is restricted to the
69 /// read-only toolset (see `SUBAGENT_TOOL_NAMES` in `tools.rs`), so delegated
70 /// research stays available in plan mode.
71 +///
72 +/// One MCP exception: the *official* sigit.si server (`mcp__sigit__*`) is
73 +/// first-party, so its query tools — names starting `list_` or `get_`, plus
74 +/// `search_code` and `web_search` — are read-only and never prompt (the TUI's
75 +/// Repo tab depends on this). Every other `mcp__*` tool stays mutating.
76 pub fn classify(tool_name: &str) -> ToolRisk {
77 match tool_name {
78 "read_file" | "list_directory" | "search_files" | "glob" | "read_website"
79 | "write_todos" | "skill" | "task" | "command_output" => ToolRisk::ReadOnly,
74 - _ => ToolRisk::Mutating,
80 + _ => {
81 + if let Some(bare) = crate::mcp::official_tool_suffix(tool_name)
82 + && (bare.starts_with("list_")
83 + || bare.starts_with("get_")
84 + || bare == "search_code"
85 + || bare == "web_search")
86 + {
87 + return ToolRisk::ReadOnly;
88 + }
89 + ToolRisk::Mutating
90 + }
91 }
92 }
93
@@ -276,6 +292,44 @@ mod tests {
292 }
293 }
294
295 + #[test]
296 + fn official_mcp_query_tools_are_read_only() {
297 + let _guard = env_guard();
298 + for tool in [
299 + "mcp__sigit__list_issues",
300 + "mcp__sigit__list_pull_requests",
301 + "mcp__sigit__get_issue",
302 + "mcp__sigit__get_pull_request",
303 + "mcp__sigit__list_repositories",
304 + "mcp__sigit__get_file_contents",
305 + "mcp__sigit__search_code",
306 + "mcp__sigit__web_search",
307 + ] {
308 + assert_eq!(classify(tool), ToolRisk::ReadOnly, "{tool}");
309 + // Read-only means it never prompts, whatever the policy layers say.
310 + assert_eq!(decision_for("t-official", tool), Decision::Allow, "{tool}");
311 + }
312 + }
313 +
314 + #[test]
315 + fn official_mcp_mutating_and_foreign_servers_stay_gated() {
316 + for tool in [
317 + // official server, but not a query tool
318 + "mcp__sigit__create_issue",
319 + "mcp__sigit__merge_pull_request",
320 + "mcp__sigit__delete_repository",
321 + "mcp__sigit__",
322 + // query-shaped names on other servers get no exemption
323 + "mcp__other__list_issues",
324 + "mcp__other__get_issue",
325 + "mcp__github__search_code",
326 + // `sigit` must match the whole server name
327 + "mcp__sigitx__list_issues",
328 + ] {
329 + assert_eq!(classify(tool), ToolRisk::Mutating, "{tool}");
330 + }
331 + }
332 +
333 #[test]
334 fn plan_mode_denies_mutating_and_spares_read_only() {
335 let _guard = env_guard();