Vectrum
Sign In

Concentric corners, Usage on the main sheet, model picker rows, drag-select after double click, image paste, per-device project switching, full-width terminal beside thread details

- Settings → Providers: rows round inside the card, 6px in from its corners.
- Composer pills, the strip, menus and the model picker rail round concentric
  with what holds them; the model pill sits symmetric in the card's corner.
- Usage leaves Settings: the sidebar's chart button and Ctrl+U open it on the
  main sheet, with a way back to the thread.
- Model picker rows no longer squeeze into each other in the scrolling list.
- A press right after a click (a double click) that then drags now selects
  what it drags over instead of one word, so Ctrl+C copies all of it.
- Pasting an image or copied image files into the composer attaches them.
- Run on: a device only offers the thread when the project was added there,
  matched by git origin (else name or folder name), and switches into that
  device's folder. Android's strip does the same.
- The terminal drawer runs under the thread details panel; a side panel
  still squeezes it into the chat column.
JjohannSocommitted 1 parent 1314d93commitb51a226

Showing 20 changed files with 521 additions and 103 deletions.

+5−3README.mdView File
@@ -104,7 +104,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
104104 now as a steer, remove), work left running in the background ("Running: …" with **Stop**),
105105 a usage limit, and the agent's task list with its progress.
106106 - Ctrl+S puts the prompt aside; a **Stash** tab beside the composer brings it back.
107- - Attachments: images are sent inline, other files by path. `@` lists the thread folder's
107+ - Attachments: images are sent inline, other files by path. Pasting an image (a screenshot,
108+ say) or files copied in a file manager attaches them. `@` lists the thread folder's
108109 files and folders to mention. An optional ring (Settings → General → Legacy features)
109110 shows how full the context window is, with the numbers and **Compact context** on hover.
110111 - A project panel: the machine, **Open in** your editor (Zed, VS Code, Cursor, VSCodium,
@@ -266,8 +267,9 @@ Gruvbox, Rosé Pine, Solarized and Dracula.
266267 - **Storage**: where new worktrees go, and the app's folders.
267268 - **Connections**: remote connections to and from other devices.
268269 - **Archive**: settled threads by project, with Unarchive (and Delete on right-click).
269- - **Usage**: cost, tokens and plan limits over the past day, week, month or quarter, read
270- from Claude Code's and Pi's local logs and any usage hubs, by provider, model and day.
270+ - **Usage** (the chart button at the sidebar's foot, or Ctrl+U) opens on the main sheet: cost,
271+ tokens and plan limits over the past day, week, month or quarter, read from Claude Code's and
272+ Pi's local logs and any usage hubs, by provider, model and day.
271273
272274 ## Requirements
273275
+33−1android/app/src/main/java/cc/vectrum/code/net/Protocol.ktView File
@@ -122,7 +122,39 @@ data class ProjectInfo(
122122 val git: Boolean = false,
123123 val added: Long = 0,
124124 val removed: Long = 0,
125- )
125+ /** The `origin` remote's URL, which names the repository on every machine. */
126+ val origin: String? = null,
127+ ) {
128+ /** The repository as `host/owner/repo`, so SSH and HTTPS URLs compare equal. */
129+ val repository: String?
130+ get() {
131+ var url = origin?.trim()?.trimEnd('/') ?: return null
132+ url = url.removeSuffix(".git")
133+ val rest = when {
134+ "://" in url -> url.substringAfter("://")
135+ ':' in url && '/' !in url.substringBefore(':') -> url.replaceFirst(':', '/')
136+ else -> url
137+ }
138+ val host = rest.substringBefore('/').substringAfterLast('@').substringBefore(':')
139+ val path = rest.substringAfter('/', "").trim('/')
140+ return if (host.isEmpty() || path.isEmpty()) null else "$host/$path".lowercase()
141+ }
142+
143+ /**
144+ * The same project among [candidates] (another machine's), in whatever
145+ * folder it is there: the same repository, or without git the same name
146+ * or folder name. `null` when it hasn't been added there.
147+ */
148+ fun counterpartIn(candidates: List<ProjectInfo>): ProjectInfo? {
149+ val folder = path.trimEnd('/').substringAfterLast('/')
150+ val sameName = { other: ProjectInfo -> other.name == name || other.path.trimEnd('/').substringAfterLast('/') == folder }
151+ val repo = repository
152+ if (repo != null) {
153+ return candidates.find { it.repository == repo } ?: candidates.find { it.repository == null && sameName(it) }
154+ }
155+ return candidates.find(sameName)
156+ }
157+ }
126158
127159 /** One agent among several a call started. */
128160 @Serializable
+3−2android/app/src/main/java/cc/vectrum/code/ui/composer/NewThreadScreen.ktView File
@@ -274,8 +274,9 @@ private fun Start(
274274 SendCap(if (sending) Lucide.LoaderCircle else Lucide.ArrowUp, enabled = state.hasInput && !sending, onClick = ::start)
275275 }
276276 NewThreadStrip(host, project, picks, hosts, Modifier.tuckUnder(16.dp)) { machine ->
277- // The same project on that machine, or its first one.
278- val same = machine.projects.find { it.name == project.name } ?: machine.projects.first()
277+ // The same project on that machine, in whatever folder it has
278+ // there. The strip only offers machines that have it.
279+ val same = project.counterpartIn(machine.projects) ?: return@NewThreadStrip
279280 onPick(Pick(machine.saved.id, same.path))
280281 }
281282 }
+6−2android/app/src/main/java/cc/vectrum/code/ui/composer/Strip.ktView File
@@ -225,12 +225,16 @@ fun NewThreadStrip(
225225 BottomSheet(sheet == StripSheet.Machine, close) {
226226 MenuLabel("Run on")
227227 for (machine in machines) {
228- MenuItem(selected = machine == host, onClick = {
228+ val target = if (machine == host) project else project.counterpartIn(machine.projects)
229+ MenuItem(selected = machine == host, enabled = target != null, onClick = {
229230 close()
230231 if (machine != host) onMachine(machine)
231232 }) {
232233 LucideIcon(Lucide.Server, size = 12.dp, tint = palette.ink3)
233- Txt(machine.name, Modifier.weight(1f), size = 13.5.sp, maxLines = 1)
234+ Column(Modifier.weight(1f)) {
235+ Txt(machine.name, size = 13.5.sp, maxLines = 1, color = if (target != null) palette.ink else palette.ink3)
236+ Txt(target?.path ?: "Not added on this machine", size = 11.sp, maxLines = 1, color = palette.ink3)
237+ }
234238 }
235239 }
236240 }
+42−1src/git.rsView File
@@ -39,6 +39,8 @@ pub struct GitInfo {
3939 pub branches: Vec<String>,
4040 pub added: u64,
4141 pub removed: u64,
42+ /// The `origin` remote's URL, which names the repository across devices.
43+ pub origin: Option<String>,
4244 }
4345
4446 /// Reads the branch and the size of the working-tree changes, counting the
@@ -84,7 +86,46 @@ pub fn read_info(path: &Path) -> Option<GitInfo> {
8486 added += lines + u64::from(bytes.last().is_some_and(|&byte| byte != b'\n'));
8587 }
8688 }
87- Some(GitInfo { branch, branches, added, removed })
89+ let origin = git(path, &["remote", "get-url", "origin"]).ok().map(|url| url.trim().to_string()).filter(|url| !url.is_empty());
90+ Some(GitInfo { branch, branches, added, removed, origin })
91+ }
92+
93+ /// A remote URL reduced to `host/owner/repo`, lowercased, so the SSH and
94+ /// HTTPS URLs of one repository compare equal: `git@github.com:a/b.git`,
95+ /// `https://user@github.com/a/b` and `ssh://git@github.com/a/b/` all give
96+ /// `github.com/a/b`.
97+ pub fn repository_identity(url: &str) -> Option<String> {
98+ let url = url.trim().trim_end_matches('/');
99+ let url = url.strip_suffix(".git").unwrap_or(url);
100+ let rest = match url.split_once("://") {
101+ Some((_, rest)) => rest.to_string(),
102+ // scp-like: user@host:path
103+ None => match url.split_once(':') {
104+ Some((host, path)) if !host.contains('/') => format!("{host}/{path}"),
105+ _ => url.to_string(),
106+ },
107+ };
108+ let (host, path) = rest.split_once('/')?;
109+ // Drop credentials and a port.
110+ let host = host.rsplit_once('@').map_or(host, |(_, host)| host);
111+ let host = host.split(':').next().unwrap_or(host);
112+ let path = path.trim_matches('/');
113+ (!host.is_empty() && !path.is_empty()).then(|| format!("{host}/{path}").to_lowercase())
114+ }
115+
116+ #[cfg(test)]
117+ mod identity_tests {
118+ use super::repository_identity;
119+
120+ #[test]
121+ fn ssh_and_https_urls_of_one_repository_match() {
122+ let want = Some("github.com/joni/app".to_string());
123+ assert_eq!(repository_identity("git@github.com:joni/app.git"), want);
124+ assert_eq!(repository_identity("https://github.com/joni/app"), want);
125+ assert_eq!(repository_identity("https://user:token@github.com/Joni/App.git/"), want);
126+ assert_eq!(repository_identity("ssh://git@github.com:22/joni/app.git"), want);
127+ assert_eq!(repository_identity("not a url"), None);
128+ }
88129 }
89130
90131 /// A branch the branch picker lists.
+3−0src/remote/mod.rsView File
@@ -129,6 +129,9 @@ pub struct ProjectInfo {
129129 pub git: bool,
130130 pub added: u64,
131131 pub removed: u64,
132+ /// The `origin` remote's URL, so a device can find the same repository
133+ /// among its own projects whatever folder it's in.
134+ pub origin: Option<String>,
132135 }
133136
134137 /// A file sent along with a message.
+165−2src/ui/chat.rsView File
@@ -214,6 +214,9 @@ pub struct ChatView {
214214 collapsed: bool,
215215 /// Whether the conversation was scrolled away from its end last frame.
216216 was_scrolled_up: bool,
217+ /// Where a double or triple press in the conversation went down, until
218+ /// the button is released. If it turns into a drag, the drag selects.
219+ multi_press: Option<Point<Pixels>>,
217220 _subscriptions: Vec<Subscription>,
218221 _ticker: Task<()>,
219222 }
@@ -342,6 +345,7 @@ impl ChatView {
342345 folded: Default::default(),
343346 collapsed: false,
344347 was_scrolled_up: false,
348+ multi_press: None,
345349 _subscriptions: subscriptions,
346350 _ticker: ticker,
347351 };
@@ -1188,6 +1192,29 @@ impl ChatView {
11881192 self.session.update(cx, |session, cx| session.interrupt(cx));
11891193 }
11901194
1195+ /// Attaches what the clipboard holds when it's an image, or files copied
1196+ /// in a file manager. Returns whether it did, so text pastes as usual.
1197+ fn paste_attachments(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
1198+ // Answers to Claude's questions can't carry files.
1199+ if self.session.read(cx).pending_ask().is_some() {
1200+ return false;
1201+ }
1202+ let Some(item) = cx.read_from_clipboard() else { return false };
1203+ let paths = clipboard_attachments(&item);
1204+ if paths.is_empty() {
1205+ return false;
1206+ }
1207+ for path in paths {
1208+ if !self.attachments.contains(&path) {
1209+ self.attachments.push(path);
1210+ }
1211+ }
1212+ self.expand(cx);
1213+ self.focus_composer(window, cx);
1214+ cx.notify();
1215+ true
1216+ }
1217+
11911218 fn pick_attachments(&mut self, window: &mut Window, cx: &mut Context<Self>) {
11921219 let paths = cx.prompt_for_paths(PathPromptOptions {
11931220 files: true,
@@ -1450,6 +1477,45 @@ impl ChatView {
14501477 .w_full()
14511478 .flex()
14521479 .flex_col()
1480+ // A press that lands soon after a click counts as a double click,
1481+ // which selects one word and won't grow while dragging. When such
1482+ // a press drags, start over as a plain drag from where it went
1483+ // down, so the selection (and what Ctrl+C copies) follows the mouse.
1484+ .capture_any_mouse_down(cx.listener(|this, event: &MouseDownEvent, _, _| {
1485+ this.multi_press = (event.button == MouseButton::Left && event.click_count >= 2).then_some(event.position);
1486+ }))
1487+ .capture_any_mouse_up(cx.listener(|this, _: &MouseUpEvent, _, _| this.multi_press = None))
1488+ .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, window, cx| {
1489+ let Some(start) = this.multi_press else { return };
1490+ if event.pressed_button != Some(MouseButton::Left) {
1491+ this.multi_press = None;
1492+ return;
1493+ }
1494+ let moved = event.position - start;
1495+ if moved.x.abs() < px(4.) && moved.y.abs() < px(4.) {
1496+ return;
1497+ }
1498+ this.multi_press = None;
1499+ let (position, modifiers) = (event.position, event.modifiers);
1500+ window.defer(cx, move |window, cx| {
1501+ // Drop the word (its copy text is pinned until cleared).
1502+ gpui_kit::base::TextSelection::clear(window, cx);
1503+ window.dispatch_event(
1504+ PlatformInput::MouseDown(MouseDownEvent {
1505+ button: MouseButton::Left,
1506+ position: start,
1507+ modifiers,
1508+ click_count: 1,
1509+ first_mouse: false,
1510+ }),
1511+ cx,
1512+ );
1513+ window.dispatch_event(
1514+ PlatformInput::MouseMove(MouseMoveEvent { position, pressed_button: Some(MouseButton::Left), modifiers }),
1515+ cx,
1516+ );
1517+ });
1518+ }))
14531519 // Scrolling rests the composer, when set to.
14541520 .child(
14551521 MessageScroller::new("transcript", self.scroller.clone(), move |ix, window, cx| {
@@ -1779,12 +1845,16 @@ impl ChatView {
17791845 .child(textarea),
17801846 )
17811847 .when(!approval, |this| {
1848+ // The footer's pills sit concentric in the card's 24px
1849+ // corners: the 28px model pill 10px in, the 32px send
1850+ // button 8px in.
17821851 this.child(
17831852 h_flex()
17841853 .w_full()
17851854 .justify_between()
1786- .px(px(16.))
1787- .pb(px(16.))
1855+ .pl(px(16.))
1856+ .pr(px(7.))
1857+ .pb(px(7.))
17881858 .when(asking, |this| this.pt(px(8.)))
17891859 .child(self.render_controls(right_width, cx))
17901860 .child(self.render_actions(start, cx)),
@@ -1807,6 +1877,13 @@ impl ChatView {
18071877 .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
18081878 this.composer_key(event, window, cx)
18091879 }))
1880+ // Images (and copied image files) paste in as attachments;
1881+ // text goes on to the editor.
1882+ .capture_action(cx.listener(|this, _: &gpui_kit::component::input::Paste, window, cx| {
1883+ if this.paste_attachments(window, cx) {
1884+ cx.stop_propagation();
1885+ }
1886+ }))
18101887 .children(dock)
18111888 // The strip is laid out below the card but painted first,
18121889 // so the card covers its top edge.
@@ -1973,6 +2050,92 @@ fn format_tokens(tokens: u64) -> String {
19732050 format!("{}{unit}", value.trim_end_matches(".0"))
19742051 }
19752052
2053+ /// What a paste should attach rather than type: an image on the clipboard,
2054+ /// saved as a file of its own, or files copied in a file manager (their
2055+ /// paths or `file://` URIs, one per line). Plain text gives nothing.
2056+ fn clipboard_attachments(item: &ClipboardItem) -> Vec<PathBuf> {
2057+ let mut paths = Vec::new();
2058+ for entry in item.entries() {
2059+ match entry {
2060+ ClipboardEntry::Image(image) => {
2061+ if let Some(path) = save_pasted_image(image) {
2062+ paths.push(path);
2063+ }
2064+ }
2065+ ClipboardEntry::ExternalPaths(external) => {
2066+ paths.extend(external.paths().iter().filter(|path| path.is_file()).cloned());
2067+ }
2068+ ClipboardEntry::String(_) => {}
2069+ }
2070+ }
2071+ if paths.is_empty()
2072+ && let Some(text) = item.text()
2073+ {
2074+ paths = copied_files(&text);
2075+ }
2076+ paths
2077+ }
2078+
2079+ /// The files a file manager's copy names, when every line of `text` is a
2080+ /// `file://` URI of a file that exists. Anything else, a bare path included,
2081+ /// pastes as text.
2082+ fn copied_files(text: &str) -> Vec<PathBuf> {
2083+ let lines: Vec<&str> =
2084+ text.lines().map(str::trim).filter(|line| !line.is_empty() && !line.starts_with('#') && *line != "copy" && *line != "cut").collect();
2085+ if lines.is_empty() {
2086+ return Vec::new();
2087+ }
2088+ let mut paths = Vec::new();
2089+ for line in lines {
2090+ let Some(rest) = line.strip_prefix("file://") else { return Vec::new() };
2091+ // `file://host/path` names another machine; only local ones attach.
2092+ let rest = rest.strip_prefix("localhost").unwrap_or(rest);
2093+ let path = PathBuf::from(percent_decode(rest));
2094+ if !path.is_absolute() || !path.is_file() {
2095+ return Vec::new();
2096+ }
2097+ paths.push(path);
2098+ }
2099+ paths
2100+ }
2101+
2102+ /// Decodes `%XX` escapes in a URI's path.
2103+ fn percent_decode(text: &str) -> String {
2104+ let bytes = text.as_bytes();
2105+ let mut out = Vec::with_capacity(bytes.len());
2106+ let mut ix = 0;
2107+ while ix < bytes.len() {
2108+ let hex = bytes.get(ix + 1..ix + 3).and_then(|hex| std::str::from_utf8(hex).ok());
2109+ if bytes[ix] == b'%'
2110+ && let Some(byte) = hex.and_then(|hex| u8::from_str_radix(hex, 16).ok())
2111+ {
2112+ out.push(byte);
2113+ ix += 3;
2114+ } else {
2115+ out.push(bytes[ix]);
2116+ ix += 1;
2117+ }
2118+ }
2119+ String::from_utf8_lossy(&out).into_owned()
2120+ }
2121+
2122+ /// Writes a pasted image under the data folder's `pasted/`, named for when it
2123+ /// was pasted, and returns its path.
2124+ fn save_pasted_image(image: &gpui_kit::Image) -> Option<PathBuf> {
2125+ let dir = settings::data_dir()?.join("pasted");
2126+ std::fs::create_dir_all(&dir).ok()?;
2127+ let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
2128+ let ext = image.format.extension();
2129+ let mut path = dir.join(format!("pasted-{stamp}.{ext}"));
2130+ let mut n = 2;
2131+ while path.exists() {
2132+ path = dir.join(format!("pasted-{stamp}-{n}.{ext}"));
2133+ n += 1;
2134+ }
2135+ std::fs::write(&path, &image.bytes).ok()?;
2136+ Some(path)
2137+ }
2138+
19762139 /// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from
19772140 /// the theme, which `metal` keeps in step with the theme.
19782141 pub(crate) fn markdown_style(p: &Palette) -> TextViewStyle {
+3−3src/ui/chat/footer.rsView File
@@ -38,7 +38,7 @@ impl ChatView {
3838 if width <= 0. {
3939 return FooterLayout::default();
4040 }
41- let available = width - 34. - right_width - 8.;
41+ let available = width - 25. - right_width - 8.;
4242 let overflow = 32.;
4343 let fits = |layout: FooterLayout| {
4444 let mut used = model_width;
@@ -74,7 +74,7 @@ impl ChatView {
7474 .flex_1()
7575 .child(
7676 composer::control("open-provider-settings")
77- .ml(px(-10.))
77+ .ml(px(-7.))
7878 .icon(Icon::new(IconName::CircleAlert))
7979 .label("Open provider settings")
8080 .on_click(cx.listener(|_, _, _, cx| cx.emit(super::ChatEvent::OpenProviderSettings))),
@@ -117,7 +117,7 @@ impl ChatView {
117117
118118 let model_picker = self.model_picker.clone();
119119 let model_button = composer::control("model")
120- .ml(px(-10.))
120+ .ml(px(-7.))
121121 .icon(pickers::provider_icon(provider, px(14.), p))
122122 .label(model_label.clone())
123123 .caret()
+22−13src/ui/chat/strip.rsView File
@@ -209,11 +209,11 @@ impl ChatView {
209209 let session = self.session.read(cx);
210210 let current = session.remote.as_ref().map(|remote| remote.env.clone());
211211 let Some(directory) = cx.try_global::<ProjectDirectory>() else { return div().into_any_element() };
212- let name = directory.find(&session.project, current.as_deref()).map(|project| project.name.clone());
212+ let Some(project) = directory.find(&session.project, current.as_deref()) else { return div().into_any_element() };
213213 let mut environments: Vec<(Option<String>, SharedString)> =
214214 vec![(None, if directory.hostname.is_empty() { "This device".into() } else { directory.hostname.clone() })];
215- for project in &directory.projects {
216- if let (Some(env), Some(host)) = (&project.environment, &project.host)
215+ for other in &directory.projects {
216+ if let (Some(env), Some(host)) = (&other.environment, &other.host)
217217 && !environments.iter().any(|(known, _)| known.as_deref() == Some(env.as_str()))
218218 {
219219 environments.push((Some(env.clone()), host.clone()));
@@ -221,20 +221,20 @@ impl ChatView {
221221 }
222222 let mut menu = pickers::panel(p).child(composer::menu_label("Run on", p));
223223 for (ix, (env, label)) in environments.into_iter().enumerate() {
224- // The same project on that machine, or its first one.
225- let target = directory
226- .projects
227- .iter()
228- .filter(|project| project.environment == env && !project.scratch)
229- .find(|project| Some(&project.name) == name.as_ref())
230- .or_else(|| directory.projects.iter().find(|project| project.environment == env && !project.scratch))
231- .map(|project| project.path.clone());
224+ // The same project on that device, in whatever folder it has
225+ // there. A device without it can't run the thread.
226+ let target = directory.counterpart(project, env.as_deref()).map(|project| project.path.clone());
232227 let chat = cx.entity().downgrade();
233228 let icon = if env.is_some() { Icon::new(Lucide::Server) } else { Icon::new(Lucide::Laptop) };
234229 let selected = env == current;
235230 let env_for_click = env.clone();
231+ let detail: SharedString = match &target {
232+ Some(path) => crate::ui::chat::display_path(path).into(),
233+ None => "Not added on this device".into(),
234+ };
235+ let enabled = target.is_some();
236236 menu = menu.child(
237- composer::menu_item(("environment", ix), selected, target.is_some(), p)
237+ composer::menu_item(("environment", ix), selected, enabled, p)
238238 .when_some(target, |this, path| {
239239 this.on_click(move |_, _, cx| {
240240 let (path, env) = (path.clone(), env_for_click.clone());
@@ -244,8 +244,17 @@ impl ChatView {
244244 });
245245 })
246246 })
247+ .when(!enabled, |this| {
248+ let tip: SharedString = format!("Add this project on {label} to run threads there.").into();
249+ this.cursor_not_allowed().tooltip(move |window, cx| gpui_kit::component::tooltip::Tooltip::new(tip.clone()).build(window, cx))
250+ })
247251 .child(icon.size(px(12.)).text_color(p.ink3))
248- .child(label),
252+ .child(
253+ v_flex()
254+ .min_w_0()
255+ .child(div().truncate().child(label))
256+ .child(div().truncate().max_w(px(260.)).text_size(px(11.)).text_color(p.ink3).child(detail)),
257+ ),
249258 );
250259 }
251260 menu.into_any_element()
+7−6src/ui/composer.rsView File
@@ -143,15 +143,15 @@ pub fn separator(p: &Palette) -> Div {
143143
144144 /// The strip hanging below the composer, tucked under its bottom edge. Like
145145 /// T3 Code's tray it is a solid shelf with a hairline edge, filled
146- /// a shade above the pane so it reads in every theme.
146+ /// a shade above the pane so it reads in every theme. Its 24px pills sit
147+ /// 5px in (with the 1px edge, 6px) so they round with its 18px corners.
147148 pub fn strip(p: &Palette) -> Div {
148149 h_flex()
149150 .mx(px(INSET))
150151 .mt(px(-16.))
151152 .pt(px(20.))
152- .pb(px(4.))
153- .pl(px(4.))
154- .pr(px(8.))
153+ .pb(px(5.))
154+ .px(px(5.))
155155 .gap(px(4.))
156156 .rounded_b(px(18.))
157157 .bg(mix(p.s, p.s_hi, 0.75))
@@ -206,7 +206,8 @@ pub fn menu_label(text: impl Into<SharedString>, p: &Palette) -> Div {
206206 }
207207
208208 /// One choice in a picker menu. Like T3 Code's, the current choice is shown
209- /// by a tint rather than a check.
209+ /// by a tint rather than a check. It rounds to 10px, 4px in from the
210+ /// menu's 14px corners.
210211 pub fn menu_item(id: impl Into<ElementId>, selected: bool, enabled: bool, p: &Palette) -> Stateful<Div> {
211212 h_flex()
212213 .id(id)
@@ -214,7 +215,7 @@ pub fn menu_item(id: impl Into<ElementId>, selected: bool, enabled: bool, p: &Pa
214215 .px(px(8.))
215216 .py(px(4.))
216217 .gap(px(8.))
217- .rounded(px(8.))
218+ .rounded(px(10.))
218219 .text_size(px(13.5))
219220 .text_color(p.ink)
220221 .when(selected, |this| this.bg(p.ink.opacity(0.08)))
+5−1src/ui/pickers.rsView File
@@ -444,6 +444,7 @@ impl ModelPicker {
444444 div()
445445 .relative()
446446 .w_full()
447+ .flex_none()
447448 .child(
448449 div()
449450 .id(SharedString::from(format!("rail-{id}")))
@@ -451,7 +452,7 @@ impl ModelPicker {
451452 .items_center()
452453 .justify_center()
453454 .size(px(36.))
454- .rounded(px(8.))
455+ .rounded(px(10.))
455456 .cursor_pointer()
456457 .hover(|style| style.bg(p.ink.opacity(0.1)))
457458 .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx))
@@ -497,6 +498,8 @@ impl ModelPicker {
497498 h_flex()
498499 .id(SharedString::from(format!("model-{}", model.key())))
499500 .w_full()
501+ // The list scrolls; a row never squeezes into the next one.
502+ .flex_none()
500503 .min_h(px(28.))
501504 .gap(px(6.))
502505 .px(px(8.))
@@ -725,6 +728,7 @@ impl Render for ModelPicker {
725728 h_flex()
726729 .id("legacy-models")
727730 .w_full()
731+ .flex_none()
728732 .gap(px(8.))
729733 .px(px(8.))
730734 .py(px(6.))
+7−55src/ui/settings/mod.rsView File
@@ -29,11 +29,11 @@ use gpui_kit::prelude::FluentBuilder as _;
2929 use gpui_kit::*;
3030
3131 use crate::CloseSettings;
32- use crate::models::{Catalog, Provider};
32+ use crate::models::Catalog;
3333 use crate::settings::{self, Settings};
3434 use crate::ui::icons::Lucide;
3535 use crate::ui::metal::{self, MetalButton, MetalStyled as _};
36- use crate::ui::usage::{HubSettings, UsageView};
36+ use crate::ui::usage::HubSettings;
3737 use crate::ui::workspace::{SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace};
3838
3939 pub(crate) use scope::row_titles;
@@ -51,7 +51,6 @@ pub enum Page {
5151 Storage,
5252 Connections,
5353 Archive,
54- Usage,
5554 /// Reached from General → Diagnostics.
5655 Diagnostics,
5756 /// Reached from General → Open source licenses.
@@ -59,8 +58,8 @@ pub enum Page {
5958 }
6059
6160 impl Page {
62- /// The pages in the nav, in T3 Code's order, then Usage.
63- const NAV: [Page; 10] = [
61+ /// The pages in the nav, in T3 Code's order.
62+ const NAV: [Page; 9] = [
6463 Page::General,
6564 Page::Appearance,
6665 Page::Keybindings,
@@ -70,7 +69,6 @@ impl Page {
7069 Page::Storage,
7170 Page::Connections,
7271 Page::Archive,
73- Page::Usage,
7472 ];
7573
7674 pub fn label(self) -> &'static str {
@@ -84,7 +82,6 @@ impl Page {
8482 Page::Storage => "Storage",
8583 Page::Connections => "Connections",
8684 Page::Archive => "Archive",
87- Page::Usage => "Usage",
8885 Page::Diagnostics => "Diagnostics",
8986 Page::Licenses => "Open source licenses",
9087 }
@@ -101,7 +98,6 @@ impl Page {
10198 Page::Storage => Icon::new(Lucide::HardDrive),
10299 Page::Connections => Icon::new(Lucide::Link2),
103100 Page::Archive => Icon::new(Lucide::Archive),
104- Page::Usage => Icon::new(Lucide::Gauge),
105101 }
106102 }
107103
@@ -115,7 +111,7 @@ impl Page {
115111
116112 fn width(self) -> f32 {
117113 match self {
118- Page::Providers | Page::Connections | Page::Usage => WIDE_WIDTH,
114+ Page::Providers | Page::Connections => WIDE_WIDTH,
119115 Page::Diagnostics => EXPANDED_WIDTH,
120116 _ => READABLE_WIDTH,
121117 }
@@ -145,7 +141,6 @@ pub struct SettingsView {
145141 worktrees_dir: Entity<InputState>,
146142 keybinding_search: Entity<InputState>,
147143 providers: providers::ProvidersState,
148- usage: Entity<UsageView>,
149144 hubs: Entity<HubSettings>,
150145 connections: connections::ConnectionsState,
151146 source_control: source_control::SourceControlState,
@@ -243,7 +238,6 @@ impl SettingsView {
243238 worktrees_dir,
244239 keybinding_search,
245240 providers: providers::ProvidersState::default(),
246- usage: cx.new(|cx| UsageView::new(window, cx)),
247241 hubs: cx.new(|cx| HubSettings::new(window, cx)),
248242 connections,
249243 source_control: source_control::SourceControlState::default(),
@@ -265,14 +259,11 @@ impl SettingsView {
265259 pub fn opened(&mut self, window: &mut Window, cx: &mut Context<Self>) {
266260 self.reload_inputs(window, cx);
267261 self.focus_handle.focus(window, cx);
268- if self.page == Page::Usage {
269- self.refresh_usage(cx);
270- }
271262 cx.notify();
272263 }
273264
274- /// Shows the page with this name (`Usage`, `Keybindings`), as the
275- /// command palette and the sidebar's Usage button ask.
265+ /// Shows the page with this name (`Keybindings`), as the command
266+ /// palette asks.
276267 pub fn open_page_named(&mut self, label: &str, cx: &mut Context<Self>) {
277268 let pages = Page::NAV.into_iter().chain([Page::Diagnostics, Page::Licenses]);
278269 if let Some(page) = pages.into_iter().find(|page| page.label() == label) {
@@ -309,7 +300,6 @@ impl SettingsView {
309300 }
310301 self.scroll.set_offset(point(px(0.), px(0.)));
311302 match page {
312- Page::Usage => self.refresh_usage(cx),
313303 Page::Diagnostics => self.refresh_diagnostics(cx),
314304 _ => {}
315305 }
@@ -699,48 +689,10 @@ impl SettingsView {
699689 Page::Storage => self.render_storage(window, cx),
700690 Page::Connections => self.render_connections(window, cx),
701691 Page::Archive => self.render_archive(cx),
702- Page::Usage => vec![self.usage.clone().into_any_element()],
703692 Page::Diagnostics => self.render_diagnostics(cx),
704693 Page::Licenses => self.render_licenses(cx),
705694 }
706695 }
707-
708- /// Reads usage from every provider instance's home folder and the usage
709- /// hubs, and shows each Claude instance's plan limits from its last
710- /// health check.
711- fn refresh_usage(&mut self, cx: &mut Context<Self>) {
712- let catalog = Catalog::global(cx);
713- let mut sources =
714- crate::usage::UsageSources { hubs: settings::get(|settings| settings.usage_hubs.clone()), ..Default::default() };
715- let mut limits = Vec::new();
716- for state in &catalog.instances {
717- let home = match (state.config.home.trim(), state.kind()) {
718- ("", Provider::Claude) => settings::expand_home("~/.claude"),
719- ("", Provider::Pi) => settings::expand_home("~/.pi/agent"),
720- (home, _) => settings::expand_home(home),
721- };
722- let label = match state.kind() {
723- Provider::Claude if state.label() == "Claude" => "Claude Code".to_string(),
724- _ => state.label(),
725- };
726- match state.kind() {
727- Provider::Claude => sources.claude_dirs.push((label.clone(), home)),
728- Provider::Pi => sources.pi_dirs.push((label.clone(), home)),
729- // jcode keeps no usage logs the Usage page reads.
730- Provider::Jcode => {}
731- }
732- if let Some(usage) = &state.usage {
733- limits.push((SharedString::from(label), usage.clone()));
734- }
735- }
736- // Instances sharing a home folder share their logs; read them once.
737- sources.claude_dirs.dedup_by(|a, b| a.1 == b.1);
738- sources.pi_dirs.dedup_by(|a, b| a.1 == b.1);
739- self.usage.update(cx, |usage, cx| {
740- usage.refresh(sources, cx);
741- usage.set_limits(limits, cx);
742- });
743- }
744696 }
745697
746698 /// Saves a text field when it loses focus or Enter is pressed, if it changed,
+7−2src/ui/settings/providers.rsView File
@@ -537,9 +537,12 @@ impl SettingsView {
537537
538538 // Below 768px the list sits over the editor, as in T3 Code.
539539 let two_panes = content_width(window, WIDE_WIDTH) >= 768.;
540+ // Rows sit 6px in from the card's 18px corners, so they round to 12px.
540541 let list = v_flex()
541542 .map(|this| if two_panes { this.w(px(LIST_WIDTH)).self_stretch().rounded_l(px(18.)) } else { this.w_full().rounded_t(px(18.)) })
542543 .flex_none()
544+ .p(px(6.))
545+ .gap(px(2.))
543546 .region(p)
544547 .children(instances.iter().map(|state| {
545548 let id = state.id().to_string();
@@ -551,9 +554,10 @@ impl SettingsView {
551554 h_flex()
552555 .id(SharedString::from(format!("provider-{id}")))
553556 .min_h(px(72.))
554- .px(px(16.))
557+ .px(px(10.))
555558 .py(px(12.))
556559 .gap(px(12.))
560+ .rounded(px(12.))
557561 .cursor_pointer()
558562 .when(!enabled, |this| this.opacity(0.6))
559563 .map(|this| if is_selected { this.raised(p) } else { this.hover(|style| style.bg(p.row_hover)) })
@@ -590,8 +594,9 @@ impl SettingsView {
590594 h_flex()
591595 .id("add-provider-row")
592596 .min_h(px(48.))
593- .px(px(16.))
597+ .px(px(10.))
594598 .gap(px(8.))
599+ .rounded(px(12.))
595600 .cursor_pointer()
596601 .text_size(px(13.5))
597602 .text_color(p.ink2)
+0−1src/ui/settings/search.rsView File
@@ -154,7 +154,6 @@ pub const ITEMS: &[Item] = &[
154154 ),
155155 item("remote-environments", "Environments", Page::Connections, "add pair backend host code devices remote connect"),
156156 item("archive", "Archived threads", Page::Archive, "restore reopen settled deleted history projects unarchive"),
157- item("usage", "Usage", Page::Usage, "cost tokens limits plan day week month quarter spend"),
158157 ];
159158
160159 /// Lowercases and keeps letters, digits and spaces: T3 Code's
+36−3src/ui/usage.rsView File
@@ -114,6 +114,41 @@ impl UsageView {
114114 }
115115 }
116116
117+ /// Reads usage from every provider instance's home folder and the usage
118+ /// hubs, and shows each Claude instance's plan limits from its last
119+ /// health check.
120+ pub fn refresh_all(&mut self, cx: &mut Context<Self>) {
121+ let catalog = crate::models::Catalog::global(cx);
122+ let mut sources = UsageSources { hubs: settings::get(|settings| settings.usage_hubs.clone()), ..Default::default() };
123+ let mut limits = Vec::new();
124+ for state in &catalog.instances {
125+ use crate::models::Provider;
126+ let home = match (state.config.home.trim(), state.kind()) {
127+ ("", Provider::Claude) => settings::expand_home("~/.claude"),
128+ ("", Provider::Pi) => settings::expand_home("~/.pi/agent"),
129+ (home, _) => settings::expand_home(home),
130+ };
131+ let label = match state.kind() {
132+ Provider::Claude if state.label() == "Claude" => "Claude Code".to_string(),
133+ _ => state.label(),
134+ };
135+ match state.kind() {
136+ Provider::Claude => sources.claude_dirs.push((label.clone(), home)),
137+ Provider::Pi => sources.pi_dirs.push((label.clone(), home)),
138+ // jcode keeps no usage logs this page reads.
139+ Provider::Jcode => {}
140+ }
141+ if let Some(usage) = &state.usage {
142+ limits.push((SharedString::from(label), usage.clone()));
143+ }
144+ }
145+ // Instances sharing a home folder share their logs; read them once.
146+ sources.claude_dirs.dedup_by(|a, b| a.1 == b.1);
147+ sources.pi_dirs.dedup_by(|a, b| a.1 == b.1);
148+ self.refresh(sources, cx);
149+ self.set_limits(limits, cx);
150+ }
151+
117152 fn summary(&mut self) -> Rc<Summary> {
118153 let report = self.report.clone().unwrap_or_default();
119154 self.summary
@@ -186,11 +221,9 @@ impl UsageView {
186221 let updated = self.report.as_ref().and_then(|report| report.collected_at).filter(|_| !self.loading);
187222 let breadcrumb = h_flex()
188223 .gap(px(8.))
189- .child(div().pl(px(4.)).text_size(px(14.)).font_weight(FontWeight::SEMIBOLD).child("Usage"))
190- .child(div().text_size(px(14.)).text_color(p.ink3).child("/"))
191224 .map(|this| {
192225 if self.tab == Tab::Limits {
193- this.child(div().text_size(px(14.)).text_color(p.ink2).child("Plan limits"))
226+ this.child(div().pl(px(4.)).text_size(px(14.)).text_color(p.ink2).child("Plan limits"))
194227 } else {
195228 this.child(provider_menu)
196229 }
+146−5src/ui/workspace.rsView File
@@ -106,6 +106,32 @@ impl ProjectDirectory {
106106 pub fn find(&self, path: &std::path::Path, environment: Option<&str>) -> Option<&ProjectSummary> {
107107 self.projects.iter().find(|project| project.path == path && project.environment.as_deref() == environment)
108108 }
109+
110+ /// The same project as `project` on another device (`None` for this one),
111+ /// whatever folder it's in there: the repository with the same `origin`,
112+ /// or failing that, the one with the same name or folder name. `None`
113+ /// when it hasn't been added there; a thread can't run where its project
114+ /// isn't.
115+ pub fn counterpart<'a>(&'a self, project: &'a ProjectSummary, environment: Option<&str>) -> Option<&'a ProjectSummary> {
116+ if project.environment.as_deref() == environment {
117+ return Some(project);
118+ }
119+ let candidates = || {
120+ self.projects.iter().filter(move |other| other.environment.as_deref() == environment && !other.scratch)
121+ };
122+ if let Some(repository) = &project.repository {
123+ if let Some(found) = candidates().find(|other| other.repository.as_ref() == Some(repository)) {
124+ return Some(found);
125+ }
126+ // Both are known repositories with different origins: not the same project.
127+ let folder = project.path.file_name();
128+ return candidates().find(|other| {
129+ other.repository.is_none() && (other.name == project.name || (folder.is_some() && other.path.file_name() == folder))
130+ });
131+ }
132+ let folder = project.path.file_name();
133+ candidates().find(|other| other.name == project.name || (folder.is_some() && other.path.file_name() == folder))
134+ }
109135 }
110136
111137 pub struct ProjectSummary {
@@ -120,6 +146,9 @@ pub struct ProjectSummary {
120146 /// Whether it's a git repository, so threads can start in a worktree.
121147 pub git: bool,
122148 pub branch: Option<String>,
149+ /// The repository by its `origin` remote, as `host/owner/repo`, which
150+ /// names it on every device.
151+ pub repository: Option<String>,
123152 /// The worktree the project's most recently active thread ran in, for
124153 /// the composer's "Previous worktree".
125154 pub previous_worktree: Option<Worktree>,
@@ -279,6 +308,9 @@ pub struct Workspace {
279308 hostname: SharedString,
280309 settings_view: Entity<SettingsView>,
281310 settings_open: bool,
311+ /// The Usage page, shown on the main sheet in place of the thread.
312+ usage_view: Entity<crate::ui::usage::UsageView>,
313+ usage_open: bool,
282314 /// The Add project browser, while it's open.
283315 activity: HashMap<EntityId, Activity>,
284316 /// The thread whose settle button waits for its confirming click.
@@ -423,6 +455,8 @@ impl Workspace {
423455 hostname: hostname().into(),
424456 settings_view,
425457 settings_open: false,
458+ usage_view: cx.new(|cx| crate::ui::usage::UsageView::new(window, cx)),
459+ usage_open: false,
426460 activity: HashMap::new(),
427461 settle_armed: None,
428462 archive_armed: None,
@@ -1244,6 +1278,8 @@ impl Workspace {
12441278 cx: &mut Context<Self>,
12451279 ) {
12461280 self.active_project = project_ix;
1281+ // Picking a thread leaves the Usage page.
1282+ self.usage_open = false;
12471283 session.update(cx, |session, cx| {
12481284 session.subscribe_remote();
12491285 session.mark_seen(cx);
@@ -1458,12 +1494,39 @@ impl Workspace {
14581494 .detach();
14591495 }
14601496
1461- /// Opens a settings page by its name in the settings view (`Usage`).
1497+ /// Opens a settings page by its name in the settings view (`Keybindings`).
14621498 fn open_settings_page(&mut self, page: &str, window: &mut Window, cx: &mut Context<Self>) {
14631499 self.open_settings(window, cx);
14641500 self.settings_view.update(cx, |view, cx| view.open_page_named(page, cx));
14651501 }
14661502
1503+ /// Shows the Usage page on the main sheet, reading the logs afresh.
1504+ pub(super) fn open_usage(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1505+ if self.settings_open {
1506+ self.close_settings(window, cx);
1507+ }
1508+ self.usage_open = true;
1509+ self.usage_view.update(cx, |usage, cx| usage.refresh_all(cx));
1510+ cx.notify();
1511+ }
1512+
1513+ /// The sidebar's Usage button: opens the page, or goes back to the thread.
1514+ pub(super) fn toggle_usage(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1515+ if self.usage_open {
1516+ self.close_usage(window, cx);
1517+ } else {
1518+ self.open_usage(window, cx);
1519+ }
1520+ }
1521+
1522+ fn close_usage(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1523+ self.usage_open = false;
1524+ if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())) {
1525+ chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
1526+ }
1527+ cx.notify();
1528+ }
1529+
14671530 /// The settle button on a thread card: settles at once, or arms itself
14681531 /// for a confirming second click when Settle confirmation is on.
14691532 fn press_settle(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
@@ -2052,6 +2115,15 @@ impl Workspace {
20522115 .into_any_element()
20532116 }
20542117
2118+ /// The Usage page, scrolling on the sheet below its header.
2119+ fn render_usage_sheet(&self, _cx: &mut Context<Self>) -> impl IntoElement {
2120+ div().id("usage-scroll").flex_1().min_h_0().w_full().overflow_y_scroll().child(
2121+ h_flex().w_full().justify_center().px(px(32.)).pt(px(24.)).pb(px(48.)).child(
2122+ v_flex().w_full().max_w(px(1024.)).child(self.usage_view.clone()),
2123+ ),
2124+ )
2125+ }
2126+
20552127 fn render_empty_sheet(&self, cx: &mut Context<Self>) -> impl IntoElement {
20562128 let p = metal::palette(cx);
20572129 v_flex()
@@ -2111,6 +2183,11 @@ impl Render for Workspace {
21112183 scratch: Some(&project.path) == scratch.as_ref(),
21122184 git: project.git.is_some(),
21132185 branch: project.git.as_ref().and_then(|git| git.branch.clone()),
2186+ repository: project
2187+ .git
2188+ .as_ref()
2189+ .and_then(|git| git.origin.as_deref())
2190+ .and_then(git::repository_identity),
21142191 previous_worktree: project
21152192 .sessions
21162193 .iter()
@@ -2138,6 +2215,8 @@ impl Render for Workspace {
21382215 && window.viewport_size().width >= px(PANEL_MIN_WINDOW);
21392216 // The browser gives way before the conversation gets too narrow.
21402217 let chat_min = if browser.is_some() || docked_terminal.is_some() || device_panel.is_some() { px(360.) } else { px(0.) };
2218+ let mut drawer = self.render_terminal_drawer(false, window, cx);
2219+ let drawer_full_width = panel_visible;
21412220
21422221 h_flex()
21432222 .key_context("Workspace")
@@ -2181,7 +2260,7 @@ impl Render for Workspace {
21812260 Self::copy_thread_reference(&session, window, cx);
21822261 }
21832262 }))
2184- .on_action(cx.listener(|this, _: &OpenUsage, window, cx| this.open_settings_page("Usage", window, cx)))
2263+ .on_action(cx.listener(|this, _: &OpenUsage, window, cx| this.toggle_usage(window, cx)))
21852264 .on_action(cx.listener(|this, _: &ToggleRightPanel, window, cx| this.toggle_right_panel(window, cx)))
21862265 .on_action(cx.listener(|this, _: &SelectTheme, window, cx| {
21872266 this.open_palette(palette::PaletteView::Themes, window, cx)
@@ -2267,7 +2346,8 @@ impl Render for Workspace {
22672346 .rounded(px(SHEET_RADIUS))
22682347 .plate(p)
22692348 .child(self.render_top_bar(panel_visible, cx))
2270- .child(
2349+ .when(self.usage_open, |this| this.child(self.render_usage_sheet(cx)))
2350+ .when(!self.usage_open, |this| this.child(
22712351 h_flex()
22722352 .flex_1()
22732353 .min_h_0()
@@ -2293,7 +2373,7 @@ impl Render for Workspace {
22932373 Some(chat) => this.child(chat),
22942374 None => this.child(self.render_empty_sheet(cx)),
22952375 }))
2296- .children(self.render_terminal_drawer(false, window, cx))
2376+ .when(!drawer_full_width, |this| this.children(drawer.take()))
22972377 .children(floating_device),
22982378 )
22992379 .when(panel_visible, |this| this.child(self.render_panel(cx)))
@@ -2302,7 +2382,11 @@ impl Render for Workspace {
23022382 .children(docked_terminal)
23032383 .when_some(browser, |this, browser| this.child(browser))
23042384 .when_some(device_panel, |this, panel| this.child(panel)),
2305- ),
2385+ ))
2386+ // With only the thread details beside the chat, the
2387+ // drawer runs under both; a side panel squeezes it
2388+ // into the chat's column instead.
2389+ .when(drawer_full_width && !self.usage_open, |this| this.children(drawer.take())),
23062390 ),
23072391 ))
23082392 // The command palette floats over everything; a click beside it closes it.
@@ -2455,6 +2539,63 @@ pub(crate) fn scratch_dir() -> Option<PathBuf> {
24552539 #[cfg(test)]
24562540 mod tests {
24572541 use super::initials;
2542+ use super::{ProjectDirectory, ProjectSummary};
2543+ use std::path::PathBuf;
2544+
2545+ fn project(name: &str, path: &str, env: Option<&str>, repository: Option<&str>) -> ProjectSummary {
2546+ ProjectSummary {
2547+ name: name.to_string().into(),
2548+ path: PathBuf::from(path),
2549+ environment: env.map(str::to_string),
2550+ host: env.map(|env| env.to_string().into()),
2551+ scratch: false,
2552+ git: repository.is_some(),
2553+ branch: None,
2554+ repository: repository.map(str::to_string),
2555+ previous_worktree: None,
2556+ }
2557+ }
2558+
2559+ #[::core::prelude::v1::test]
2560+ fn counterpart_follows_the_repository_into_another_folder() {
2561+ let directory = ProjectDirectory {
2562+ hostname: "XPS".into(),
2563+ projects: vec![
2564+ project("app", "/home/j/Documents/app", None, Some("github.com/j/app")),
2565+ project("other", "/home/j/Downloads/other", Some("x1"), Some("github.com/j/other")),
2566+ project("app", "/home/j/Downloads/app", Some("x1"), Some("github.com/j/app")),
2567+ ],
2568+ };
2569+ let here = &directory.projects[0];
2570+ let there = directory.counterpart(here, Some("x1")).expect("added on x1");
2571+ assert_eq!(there.path, PathBuf::from("/home/j/Downloads/app"));
2572+ // And back.
2573+ assert_eq!(directory.counterpart(there, None).unwrap().path, PathBuf::from("/home/j/Documents/app"));
2574+ }
2575+
2576+ #[::core::prelude::v1::test]
2577+ fn counterpart_is_none_where_the_project_was_not_added() {
2578+ let directory = ProjectDirectory {
2579+ hostname: "XPS".into(),
2580+ projects: vec![
2581+ project("app", "/home/j/app", None, Some("github.com/j/app")),
2582+ project("other", "/home/j/other", Some("x1"), Some("github.com/j/other")),
2583+ ],
2584+ };
2585+ assert!(directory.counterpart(&directory.projects[0], Some("x1")).is_none());
2586+ }
2587+
2588+ #[::core::prelude::v1::test]
2589+ fn counterpart_falls_back_to_the_name_without_git() {
2590+ let directory = ProjectDirectory {
2591+ hostname: "XPS".into(),
2592+ projects: vec![
2593+ project("notes", "/home/j/Documents/notes", None, None),
2594+ project("notes", "/srv/notes", Some("x1"), None),
2595+ ],
2596+ };
2597+ assert_eq!(directory.counterpart(&directory.projects[0], Some("x1")).unwrap().path, PathBuf::from("/srv/notes"));
2598+ }
24582599
24592600 #[::core::prelude::v1::test]
24602601 fn project_initials() {
+26−0src/ui/workspace/header.rsView File
@@ -206,6 +206,32 @@ impl Workspace {
206206 .into_any_element()
207207 });
208208
209+ if self.usage_open {
210+ return v_flex()
211+ .flex_none()
212+ .child(
213+ h_flex()
214+ .id("usage-header")
215+ .h(px(52.))
216+ .pl(px(if self.sidebar_open { 20. } else { 12. }))
217+ .pr(px(12.))
218+ .gap(px(8.))
219+ .when(!self.sidebar_open, |this| this.child(sidebar_toggle(false, cx)))
220+ .child(Icon::new(Lucide::ChartNoAxesColumn).size(px(15.)).text_color(p.ink3))
221+ .child(div().text_size(px(14.)).font_weight(FontWeight::SEMIBOLD).text_color(p.ink).child("Usage"))
222+ .child(div().flex_1())
223+ .child(
224+ MetalButton::new("close-usage")
225+ .ghost()
226+ .compact()
227+ .icon(IconName::ArrowLeft)
228+ .label("Back to thread")
229+ .tooltip("Back to thread (Ctrl+U)")
230+ .on_click(cx.listener(|this, _, window, cx| this.close_usage(window, cx))),
231+ ),
232+ )
233+ .child(metal::rule(p));
234+ }
209235 v_flex()
210236 .flex_none()
211237 .child(
+1−2src/ui/workspace/palette.rsView File
@@ -258,7 +258,6 @@ const SETTINGS: &[(&str, &str)] = &[
258258 ("Thread cards", "Appearance"),
259259 ("Theme", "Themes"),
260260 ("Providers", "Providers"),
261- ("Usage", "Usage"),
262261 ("Allow remote connections", "Connections"),
263262 ("Keybindings", "Keybindings"),
264263 ("Archived threads", "Archive"),
@@ -449,7 +448,7 @@ impl Workspace {
449448 PaletteItem::new("Actions", "Open usage", Lucide::ChartNoAxesColumn)
450449 .shortcut("Ctrl+U")
451450 .terms(&["cost", "tokens", "limits"])
452- .run(|this, window, cx| this.open_settings_page("Usage", window, cx)),
451+ .run(|this, window, cx| this.open_usage(window, cx)),
453452 );
454453 items.push(
455454 PaletteItem::new("Actions", "Open settings", IconName::Settings)
+2−0src/ui/workspace/remote.rsView File
@@ -422,6 +422,7 @@ impl Workspace {
422422 branches: git.as_ref().map(|git| git.branches.clone()).unwrap_or_default(),
423423 added: git.as_ref().map_or(0, |git| git.added),
424424 removed: git.as_ref().map_or(0, |git| git.removed),
425+ origin: git.as_ref().and_then(|git| git.origin.clone()),
425426 }
426427 })
427428 .collect();
@@ -1047,6 +1048,7 @@ impl Workspace {
10471048 branches: info.branches.clone(),
10481049 added: info.added,
10491050 removed: info.removed,
1051+ origin: info.origin.clone(),
10501052 });
10511053 }
10521054 let known: HashSet<&str> = threads.iter().map(|summary| summary.id.as_str()).collect();
+2−1src/ui/workspace/sidebar.rsView File
@@ -1770,7 +1770,8 @@ impl Workspace {
17701770 .ghost()
17711771 .icon(Lucide::ChartNoAxesColumn)
17721772 .tooltip("Usage")
1773- .on_click(cx.listener(|this, _, window, cx| this.open_settings_page("Usage", window, cx))),
1773+ .selected(self.usage_open)
1774+ .on_click(cx.listener(|this, _, window, cx| this.toggle_usage(window, cx))),
17741775 ),
17751776 )
17761777 }