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 {
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
}
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.
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,
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 {
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 = "\
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
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 {
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
}
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.
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();
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()
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)
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),
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
}
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 "),
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 {
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
}
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]