- 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.
Showing 20 changed files with 521 additions and 103 deletions.
| @@ -104,7 +104,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 104 | 104 | now as a steer, remove), work left running in the background ("Running: …" with **Stop**), |
| 105 | 105 | a usage limit, and the agent's task list with its progress. |
| 106 | 106 | - 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 | |
| 108 | 109 | files and folders to mention. An optional ring (Settings → General → Legacy features) |
| 109 | 110 | shows how full the context window is, with the numbers and **Compact context** on hover. |
| 110 | 111 | - A project panel: the machine, **Open in** your editor (Zed, VS Code, Cursor, VSCodium, |
| @@ -266,8 +267,9 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 266 | 267 | - **Storage**: where new worktrees go, and the app's folders. |
| 267 | 268 | - **Connections**: remote connections to and from other devices. |
| 268 | 269 | - **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. | |
| 271 | 273 | |
| 272 | 274 | ## Requirements |
| 273 | 275 | |
| @@ -122,7 +122,39 @@ data class ProjectInfo( | ||
| 122 | 122 | val git: Boolean = false, |
| 123 | 123 | val added: Long = 0, |
| 124 | 124 | 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 | + } | |
| 126 | 158 | |
| 127 | 159 | /** One agent among several a call started. */ |
| 128 | 160 | @Serializable |
| @@ -274,8 +274,9 @@ private fun Start( | ||
| 274 | 274 | SendCap(if (sending) Lucide.LoaderCircle else Lucide.ArrowUp, enabled = state.hasInput && !sending, onClick = ::start) |
| 275 | 275 | } |
| 276 | 276 | 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 | |
| 279 | 280 | onPick(Pick(machine.saved.id, same.path)) |
| 280 | 281 | } |
| 281 | 282 | } |
| @@ -225,12 +225,16 @@ fun NewThreadStrip( | ||
| 225 | 225 | BottomSheet(sheet == StripSheet.Machine, close) { |
| 226 | 226 | MenuLabel("Run on") |
| 227 | 227 | 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 = { | |
| 229 | 230 | close() |
| 230 | 231 | if (machine != host) onMachine(machine) |
| 231 | 232 | }) { |
| 232 | 233 | 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 | + } | |
| 234 | 238 | } |
| 235 | 239 | } |
| 236 | 240 | } |
| @@ -39,6 +39,8 @@ pub struct GitInfo { | ||
| 39 | 39 | pub branches: Vec<String>, |
| 40 | 40 | pub added: u64, |
| 41 | 41 | pub removed: u64, |
| 42 | + /// The `origin` remote's URL, which names the repository across devices. | |
| 43 | + pub origin: Option<String>, | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | /// 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> { | ||
| 84 | 86 | added += lines + u64::from(bytes.last().is_some_and(|&byte| byte != b'\n')); |
| 85 | 87 | } |
| 86 | 88 | } |
| 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 | + } | |
| 88 | 129 | } |
| 89 | 130 | |
| 90 | 131 | /// A branch the branch picker lists. |
| @@ -129,6 +129,9 @@ pub struct ProjectInfo { | ||
| 129 | 129 | pub git: bool, |
| 130 | 130 | pub added: u64, |
| 131 | 131 | 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>, | |
| 132 | 135 | } |
| 133 | 136 | |
| 134 | 137 | /// A file sent along with a message. |
| @@ -214,6 +214,9 @@ pub struct ChatView { | ||
| 214 | 214 | collapsed: bool, |
| 215 | 215 | /// Whether the conversation was scrolled away from its end last frame. |
| 216 | 216 | 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>>, | |
| 217 | 220 | _subscriptions: Vec<Subscription>, |
| 218 | 221 | _ticker: Task<()>, |
| 219 | 222 | } |
| @@ -342,6 +345,7 @@ impl ChatView { | ||
| 342 | 345 | folded: Default::default(), |
| 343 | 346 | collapsed: false, |
| 344 | 347 | was_scrolled_up: false, |
| 348 | + multi_press: None, | |
| 345 | 349 | _subscriptions: subscriptions, |
| 346 | 350 | _ticker: ticker, |
| 347 | 351 | }; |
| @@ -1188,6 +1192,29 @@ impl ChatView { | ||
| 1188 | 1192 | self.session.update(cx, |session, cx| session.interrupt(cx)); |
| 1189 | 1193 | } |
| 1190 | 1194 | |
| 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 | + | |
| 1191 | 1218 | fn pick_attachments(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 1192 | 1219 | let paths = cx.prompt_for_paths(PathPromptOptions { |
| 1193 | 1220 | files: true, |
| @@ -1450,6 +1477,45 @@ impl ChatView { | ||
| 1450 | 1477 | .w_full() |
| 1451 | 1478 | .flex() |
| 1452 | 1479 | .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 | + })) | |
| 1453 | 1519 | // Scrolling rests the composer, when set to. |
| 1454 | 1520 | .child( |
| 1455 | 1521 | MessageScroller::new("transcript", self.scroller.clone(), move |ix, window, cx| { |
| @@ -1779,12 +1845,16 @@ impl ChatView { | ||
| 1779 | 1845 | .child(textarea), |
| 1780 | 1846 | ) |
| 1781 | 1847 | .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. | |
| 1782 | 1851 | this.child( |
| 1783 | 1852 | h_flex() |
| 1784 | 1853 | .w_full() |
| 1785 | 1854 | .justify_between() |
| 1786 | - .px(px(16.)) | |
| 1787 | - .pb(px(16.)) | |
| 1855 | + .pl(px(16.)) | |
| 1856 | + .pr(px(7.)) | |
| 1857 | + .pb(px(7.)) | |
| 1788 | 1858 | .when(asking, |this| this.pt(px(8.))) |
| 1789 | 1859 | .child(self.render_controls(right_width, cx)) |
| 1790 | 1860 | .child(self.render_actions(start, cx)), |
| @@ -1807,6 +1877,13 @@ impl ChatView { | ||
| 1807 | 1877 | .capture_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { |
| 1808 | 1878 | this.composer_key(event, window, cx) |
| 1809 | 1879 | })) |
| 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 | + })) | |
| 1810 | 1887 | .children(dock) |
| 1811 | 1888 | // The strip is laid out below the card but painted first, |
| 1812 | 1889 | // so the card covers its top edge. |
| @@ -1973,6 +2050,92 @@ fn format_tokens(tokens: u64) -> String { | ||
| 1973 | 2050 | format!("{}{unit}", value.trim_end_matches(".0")) |
| 1974 | 2051 | } |
| 1975 | 2052 | |
| 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 | + | |
| 1976 | 2139 | /// Markdown in MetalUI's voice: code sits in pressed wells. Colors come from |
| 1977 | 2140 | /// the theme, which `metal` keeps in step with the theme. |
| 1978 | 2141 | pub(crate) fn markdown_style(p: &Palette) -> TextViewStyle { |
| @@ -209,11 +209,11 @@ impl ChatView { | ||
| 209 | 209 | let session = self.session.read(cx); |
| 210 | 210 | let current = session.remote.as_ref().map(|remote| remote.env.clone()); |
| 211 | 211 | 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() }; | |
| 213 | 213 | let mut environments: Vec<(Option<String>, SharedString)> = |
| 214 | 214 | 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) | |
| 217 | 217 | && !environments.iter().any(|(known, _)| known.as_deref() == Some(env.as_str())) |
| 218 | 218 | { |
| 219 | 219 | environments.push((Some(env.clone()), host.clone())); |
| @@ -221,20 +221,20 @@ impl ChatView { | ||
| 221 | 221 | } |
| 222 | 222 | let mut menu = pickers::panel(p).child(composer::menu_label("Run on", p)); |
| 223 | 223 | 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()); | |
| 232 | 227 | let chat = cx.entity().downgrade(); |
| 233 | 228 | let icon = if env.is_some() { Icon::new(Lucide::Server) } else { Icon::new(Lucide::Laptop) }; |
| 234 | 229 | let selected = env == current; |
| 235 | 230 | 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(); | |
| 236 | 236 | menu = menu.child( |
| 237 | - composer::menu_item(("environment", ix), selected, target.is_some(), p) | |
| 237 | + composer::menu_item(("environment", ix), selected, enabled, p) | |
| 238 | 238 | .when_some(target, |this, path| { |
| 239 | 239 | this.on_click(move |_, _, cx| { |
| 240 | 240 | let (path, env) = (path.clone(), env_for_click.clone()); |
| @@ -244,8 +244,17 @@ impl ChatView { | ||
| 244 | 244 | }); |
| 245 | 245 | }) |
| 246 | 246 | }) |
| 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 | + }) | |
| 247 | 251 | .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 | + ), | |
| 249 | 258 | ); |
| 250 | 259 | } |
| 251 | 260 | menu.into_any_element() |
| @@ -143,15 +143,15 @@ pub fn separator(p: &Palette) -> Div { | ||
| 143 | 143 | |
| 144 | 144 | /// The strip hanging below the composer, tucked under its bottom edge. Like |
| 145 | 145 | /// 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. | |
| 147 | 148 | pub fn strip(p: &Palette) -> Div { |
| 148 | 149 | h_flex() |
| 149 | 150 | .mx(px(INSET)) |
| 150 | 151 | .mt(px(-16.)) |
| 151 | 152 | .pt(px(20.)) |
| 152 | - .pb(px(4.)) | |
| 153 | - .pl(px(4.)) | |
| 154 | - .pr(px(8.)) | |
| 153 | + .pb(px(5.)) | |
| 154 | + .px(px(5.)) | |
| 155 | 155 | .gap(px(4.)) |
| 156 | 156 | .rounded_b(px(18.)) |
| 157 | 157 | .bg(mix(p.s, p.s_hi, 0.75)) |
| @@ -206,7 +206,8 @@ pub fn menu_label(text: impl Into<SharedString>, p: &Palette) -> Div { | ||
| 206 | 206 | } |
| 207 | 207 | |
| 208 | 208 | /// 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. | |
| 210 | 211 | pub fn menu_item(id: impl Into<ElementId>, selected: bool, enabled: bool, p: &Palette) -> Stateful<Div> { |
| 211 | 212 | h_flex() |
| 212 | 213 | .id(id) |
| @@ -214,7 +215,7 @@ pub fn menu_item(id: impl Into<ElementId>, selected: bool, enabled: bool, p: &Pa | ||
| 214 | 215 | .px(px(8.)) |
| 215 | 216 | .py(px(4.)) |
| 216 | 217 | .gap(px(8.)) |
| 217 | - .rounded(px(8.)) | |
| 218 | + .rounded(px(10.)) | |
| 218 | 219 | .text_size(px(13.5)) |
| 219 | 220 | .text_color(p.ink) |
| 220 | 221 | .when(selected, |this| this.bg(p.ink.opacity(0.08))) |
| @@ -444,6 +444,7 @@ impl ModelPicker { | ||
| 444 | 444 | div() |
| 445 | 445 | .relative() |
| 446 | 446 | .w_full() |
| 447 | + .flex_none() | |
| 447 | 448 | .child( |
| 448 | 449 | div() |
| 449 | 450 | .id(SharedString::from(format!("rail-{id}"))) |
| @@ -451,7 +452,7 @@ impl ModelPicker { | ||
| 451 | 452 | .items_center() |
| 452 | 453 | .justify_center() |
| 453 | 454 | .size(px(36.)) |
| 454 | - .rounded(px(8.)) | |
| 455 | + .rounded(px(10.)) | |
| 455 | 456 | .cursor_pointer() |
| 456 | 457 | .hover(|style| style.bg(p.ink.opacity(0.1))) |
| 457 | 458 | .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) |
| @@ -497,6 +498,8 @@ impl ModelPicker { | ||
| 497 | 498 | h_flex() |
| 498 | 499 | .id(SharedString::from(format!("model-{}", model.key()))) |
| 499 | 500 | .w_full() |
| 501 | + // The list scrolls; a row never squeezes into the next one. | |
| 502 | + .flex_none() | |
| 500 | 503 | .min_h(px(28.)) |
| 501 | 504 | .gap(px(6.)) |
| 502 | 505 | .px(px(8.)) |
| @@ -725,6 +728,7 @@ impl Render for ModelPicker { | ||
| 725 | 728 | h_flex() |
| 726 | 729 | .id("legacy-models") |
| 727 | 730 | .w_full() |
| 731 | + .flex_none() | |
| 728 | 732 | .gap(px(8.)) |
| 729 | 733 | .px(px(8.)) |
| 730 | 734 | .py(px(6.)) |
| @@ -29,11 +29,11 @@ use gpui_kit::prelude::FluentBuilder as _; | ||
| 29 | 29 | use gpui_kit::*; |
| 30 | 30 | |
| 31 | 31 | use crate::CloseSettings; |
| 32 | - use crate::models::{Catalog, Provider}; | |
| 32 | + use crate::models::Catalog; | |
| 33 | 33 | use crate::settings::{self, Settings}; |
| 34 | 34 | use crate::ui::icons::Lucide; |
| 35 | 35 | use crate::ui::metal::{self, MetalButton, MetalStyled as _}; |
| 36 | - use crate::ui::usage::{HubSettings, UsageView}; | |
| 36 | + use crate::ui::usage::HubSettings; | |
| 37 | 37 | use crate::ui::workspace::{SHEET_INSET, SHEET_RADIUS, SIDEBAR_WIDTH, Workspace}; |
| 38 | 38 | |
| 39 | 39 | pub(crate) use scope::row_titles; |
| @@ -51,7 +51,6 @@ pub enum Page { | ||
| 51 | 51 | Storage, |
| 52 | 52 | Connections, |
| 53 | 53 | Archive, |
| 54 | - Usage, | |
| 55 | 54 | /// Reached from General → Diagnostics. |
| 56 | 55 | Diagnostics, |
| 57 | 56 | /// Reached from General → Open source licenses. |
| @@ -59,8 +58,8 @@ pub enum Page { | ||
| 59 | 58 | } |
| 60 | 59 | |
| 61 | 60 | 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] = [ | |
| 64 | 63 | Page::General, |
| 65 | 64 | Page::Appearance, |
| 66 | 65 | Page::Keybindings, |
| @@ -70,7 +69,6 @@ impl Page { | ||
| 70 | 69 | Page::Storage, |
| 71 | 70 | Page::Connections, |
| 72 | 71 | Page::Archive, |
| 73 | - Page::Usage, | |
| 74 | 72 | ]; |
| 75 | 73 | |
| 76 | 74 | pub fn label(self) -> &'static str { |
| @@ -84,7 +82,6 @@ impl Page { | ||
| 84 | 82 | Page::Storage => "Storage", |
| 85 | 83 | Page::Connections => "Connections", |
| 86 | 84 | Page::Archive => "Archive", |
| 87 | - Page::Usage => "Usage", | |
| 88 | 85 | Page::Diagnostics => "Diagnostics", |
| 89 | 86 | Page::Licenses => "Open source licenses", |
| 90 | 87 | } |
| @@ -101,7 +98,6 @@ impl Page { | ||
| 101 | 98 | Page::Storage => Icon::new(Lucide::HardDrive), |
| 102 | 99 | Page::Connections => Icon::new(Lucide::Link2), |
| 103 | 100 | Page::Archive => Icon::new(Lucide::Archive), |
| 104 | - Page::Usage => Icon::new(Lucide::Gauge), | |
| 105 | 101 | } |
| 106 | 102 | } |
| 107 | 103 | |
| @@ -115,7 +111,7 @@ impl Page { | ||
| 115 | 111 | |
| 116 | 112 | fn width(self) -> f32 { |
| 117 | 113 | match self { |
| 118 | - Page::Providers | Page::Connections | Page::Usage => WIDE_WIDTH, | |
| 114 | + Page::Providers | Page::Connections => WIDE_WIDTH, | |
| 119 | 115 | Page::Diagnostics => EXPANDED_WIDTH, |
| 120 | 116 | _ => READABLE_WIDTH, |
| 121 | 117 | } |
| @@ -145,7 +141,6 @@ pub struct SettingsView { | ||
| 145 | 141 | worktrees_dir: Entity<InputState>, |
| 146 | 142 | keybinding_search: Entity<InputState>, |
| 147 | 143 | providers: providers::ProvidersState, |
| 148 | - usage: Entity<UsageView>, | |
| 149 | 144 | hubs: Entity<HubSettings>, |
| 150 | 145 | connections: connections::ConnectionsState, |
| 151 | 146 | source_control: source_control::SourceControlState, |
| @@ -243,7 +238,6 @@ impl SettingsView { | ||
| 243 | 238 | worktrees_dir, |
| 244 | 239 | keybinding_search, |
| 245 | 240 | providers: providers::ProvidersState::default(), |
| 246 | - usage: cx.new(|cx| UsageView::new(window, cx)), | |
| 247 | 241 | hubs: cx.new(|cx| HubSettings::new(window, cx)), |
| 248 | 242 | connections, |
| 249 | 243 | source_control: source_control::SourceControlState::default(), |
| @@ -265,14 +259,11 @@ impl SettingsView { | ||
| 265 | 259 | pub fn opened(&mut self, window: &mut Window, cx: &mut Context<Self>) { |
| 266 | 260 | self.reload_inputs(window, cx); |
| 267 | 261 | self.focus_handle.focus(window, cx); |
| 268 | - if self.page == Page::Usage { | |
| 269 | - self.refresh_usage(cx); | |
| 270 | - } | |
| 271 | 262 | cx.notify(); |
| 272 | 263 | } |
| 273 | 264 | |
| 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. | |
| 276 | 267 | pub fn open_page_named(&mut self, label: &str, cx: &mut Context<Self>) { |
| 277 | 268 | let pages = Page::NAV.into_iter().chain([Page::Diagnostics, Page::Licenses]); |
| 278 | 269 | if let Some(page) = pages.into_iter().find(|page| page.label() == label) { |
| @@ -309,7 +300,6 @@ impl SettingsView { | ||
| 309 | 300 | } |
| 310 | 301 | self.scroll.set_offset(point(px(0.), px(0.))); |
| 311 | 302 | match page { |
| 312 | - Page::Usage => self.refresh_usage(cx), | |
| 313 | 303 | Page::Diagnostics => self.refresh_diagnostics(cx), |
| 314 | 304 | _ => {} |
| 315 | 305 | } |
| @@ -699,48 +689,10 @@ impl SettingsView { | ||
| 699 | 689 | Page::Storage => self.render_storage(window, cx), |
| 700 | 690 | Page::Connections => self.render_connections(window, cx), |
| 701 | 691 | Page::Archive => self.render_archive(cx), |
| 702 | - Page::Usage => vec![self.usage.clone().into_any_element()], | |
| 703 | 692 | Page::Diagnostics => self.render_diagnostics(cx), |
| 704 | 693 | Page::Licenses => self.render_licenses(cx), |
| 705 | 694 | } |
| 706 | 695 | } |
| 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 | - } | |
| 744 | 696 | } |
| 745 | 697 | |
| 746 | 698 | /// Saves a text field when it loses focus or Enter is pressed, if it changed, |
| @@ -537,9 +537,12 @@ impl SettingsView { | ||
| 537 | 537 | |
| 538 | 538 | // Below 768px the list sits over the editor, as in T3 Code. |
| 539 | 539 | 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. | |
| 540 | 541 | let list = v_flex() |
| 541 | 542 | .map(|this| if two_panes { this.w(px(LIST_WIDTH)).self_stretch().rounded_l(px(18.)) } else { this.w_full().rounded_t(px(18.)) }) |
| 542 | 543 | .flex_none() |
| 544 | + .p(px(6.)) | |
| 545 | + .gap(px(2.)) | |
| 543 | 546 | .region(p) |
| 544 | 547 | .children(instances.iter().map(|state| { |
| 545 | 548 | let id = state.id().to_string(); |
| @@ -551,9 +554,10 @@ impl SettingsView { | ||
| 551 | 554 | h_flex() |
| 552 | 555 | .id(SharedString::from(format!("provider-{id}"))) |
| 553 | 556 | .min_h(px(72.)) |
| 554 | - .px(px(16.)) | |
| 557 | + .px(px(10.)) | |
| 555 | 558 | .py(px(12.)) |
| 556 | 559 | .gap(px(12.)) |
| 560 | + .rounded(px(12.)) | |
| 557 | 561 | .cursor_pointer() |
| 558 | 562 | .when(!enabled, |this| this.opacity(0.6)) |
| 559 | 563 | .map(|this| if is_selected { this.raised(p) } else { this.hover(|style| style.bg(p.row_hover)) }) |
| @@ -590,8 +594,9 @@ impl SettingsView { | ||
| 590 | 594 | h_flex() |
| 591 | 595 | .id("add-provider-row") |
| 592 | 596 | .min_h(px(48.)) |
| 593 | - .px(px(16.)) | |
| 597 | + .px(px(10.)) | |
| 594 | 598 | .gap(px(8.)) |
| 599 | + .rounded(px(12.)) | |
| 595 | 600 | .cursor_pointer() |
| 596 | 601 | .text_size(px(13.5)) |
| 597 | 602 | .text_color(p.ink2) |
| @@ -154,7 +154,6 @@ pub const ITEMS: &[Item] = &[ | ||
| 154 | 154 | ), |
| 155 | 155 | item("remote-environments", "Environments", Page::Connections, "add pair backend host code devices remote connect"), |
| 156 | 156 | 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"), | |
| 158 | 157 | ]; |
| 159 | 158 | |
| 160 | 159 | /// Lowercases and keeps letters, digits and spaces: T3 Code's |
| @@ -114,6 +114,41 @@ impl UsageView { | ||
| 114 | 114 | } |
| 115 | 115 | } |
| 116 | 116 | |
| 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 | + | |
| 117 | 152 | fn summary(&mut self) -> Rc<Summary> { |
| 118 | 153 | let report = self.report.clone().unwrap_or_default(); |
| 119 | 154 | self.summary |
| @@ -186,11 +221,9 @@ impl UsageView { | ||
| 186 | 221 | let updated = self.report.as_ref().and_then(|report| report.collected_at).filter(|_| !self.loading); |
| 187 | 222 | let breadcrumb = h_flex() |
| 188 | 223 | .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("/")) | |
| 191 | 224 | .map(|this| { |
| 192 | 225 | 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")) | |
| 194 | 227 | } else { |
| 195 | 228 | this.child(provider_menu) |
| 196 | 229 | } |
| @@ -106,6 +106,32 @@ impl ProjectDirectory { | ||
| 106 | 106 | pub fn find(&self, path: &std::path::Path, environment: Option<&str>) -> Option<&ProjectSummary> { |
| 107 | 107 | self.projects.iter().find(|project| project.path == path && project.environment.as_deref() == environment) |
| 108 | 108 | } |
| 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 | + } | |
| 109 | 135 | } |
| 110 | 136 | |
| 111 | 137 | pub struct ProjectSummary { |
| @@ -120,6 +146,9 @@ pub struct ProjectSummary { | ||
| 120 | 146 | /// Whether it's a git repository, so threads can start in a worktree. |
| 121 | 147 | pub git: bool, |
| 122 | 148 | 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>, | |
| 123 | 152 | /// The worktree the project's most recently active thread ran in, for |
| 124 | 153 | /// the composer's "Previous worktree". |
| 125 | 154 | pub previous_worktree: Option<Worktree>, |
| @@ -279,6 +308,9 @@ pub struct Workspace { | ||
| 279 | 308 | hostname: SharedString, |
| 280 | 309 | settings_view: Entity<SettingsView>, |
| 281 | 310 | 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, | |
| 282 | 314 | /// The Add project browser, while it's open. |
| 283 | 315 | activity: HashMap<EntityId, Activity>, |
| 284 | 316 | /// The thread whose settle button waits for its confirming click. |
| @@ -423,6 +455,8 @@ impl Workspace { | ||
| 423 | 455 | hostname: hostname().into(), |
| 424 | 456 | settings_view, |
| 425 | 457 | settings_open: false, |
| 458 | + usage_view: cx.new(|cx| crate::ui::usage::UsageView::new(window, cx)), | |
| 459 | + usage_open: false, | |
| 426 | 460 | activity: HashMap::new(), |
| 427 | 461 | settle_armed: None, |
| 428 | 462 | archive_armed: None, |
| @@ -1244,6 +1278,8 @@ impl Workspace { | ||
| 1244 | 1278 | cx: &mut Context<Self>, |
| 1245 | 1279 | ) { |
| 1246 | 1280 | self.active_project = project_ix; |
| 1281 | + // Picking a thread leaves the Usage page. | |
| 1282 | + self.usage_open = false; | |
| 1247 | 1283 | session.update(cx, |session, cx| { |
| 1248 | 1284 | session.subscribe_remote(); |
| 1249 | 1285 | session.mark_seen(cx); |
| @@ -1458,12 +1494,39 @@ impl Workspace { | ||
| 1458 | 1494 | .detach(); |
| 1459 | 1495 | } |
| 1460 | 1496 | |
| 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`). | |
| 1462 | 1498 | fn open_settings_page(&mut self, page: &str, window: &mut Window, cx: &mut Context<Self>) { |
| 1463 | 1499 | self.open_settings(window, cx); |
| 1464 | 1500 | self.settings_view.update(cx, |view, cx| view.open_page_named(page, cx)); |
| 1465 | 1501 | } |
| 1466 | 1502 | |
| 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 | + | |
| 1467 | 1530 | /// The settle button on a thread card: settles at once, or arms itself |
| 1468 | 1531 | /// for a confirming second click when Settle confirmation is on. |
| 1469 | 1532 | fn press_settle(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) { |
| @@ -2052,6 +2115,15 @@ impl Workspace { | ||
| 2052 | 2115 | .into_any_element() |
| 2053 | 2116 | } |
| 2054 | 2117 | |
| 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 | + | |
| 2055 | 2127 | fn render_empty_sheet(&self, cx: &mut Context<Self>) -> impl IntoElement { |
| 2056 | 2128 | let p = metal::palette(cx); |
| 2057 | 2129 | v_flex() |
| @@ -2111,6 +2183,11 @@ impl Render for Workspace { | ||
| 2111 | 2183 | scratch: Some(&project.path) == scratch.as_ref(), |
| 2112 | 2184 | git: project.git.is_some(), |
| 2113 | 2185 | 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), | |
| 2114 | 2191 | previous_worktree: project |
| 2115 | 2192 | .sessions |
| 2116 | 2193 | .iter() |
| @@ -2138,6 +2215,8 @@ impl Render for Workspace { | ||
| 2138 | 2215 | && window.viewport_size().width >= px(PANEL_MIN_WINDOW); |
| 2139 | 2216 | // The browser gives way before the conversation gets too narrow. |
| 2140 | 2217 | 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; | |
| 2141 | 2220 | |
| 2142 | 2221 | h_flex() |
| 2143 | 2222 | .key_context("Workspace") |
| @@ -2181,7 +2260,7 @@ impl Render for Workspace { | ||
| 2181 | 2260 | Self::copy_thread_reference(&session, window, cx); |
| 2182 | 2261 | } |
| 2183 | 2262 | })) |
| 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))) | |
| 2185 | 2264 | .on_action(cx.listener(|this, _: &ToggleRightPanel, window, cx| this.toggle_right_panel(window, cx))) |
| 2186 | 2265 | .on_action(cx.listener(|this, _: &SelectTheme, window, cx| { |
| 2187 | 2266 | this.open_palette(palette::PaletteView::Themes, window, cx) |
| @@ -2267,7 +2346,8 @@ impl Render for Workspace { | ||
| 2267 | 2346 | .rounded(px(SHEET_RADIUS)) |
| 2268 | 2347 | .plate(p) |
| 2269 | 2348 | .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( | |
| 2271 | 2351 | h_flex() |
| 2272 | 2352 | .flex_1() |
| 2273 | 2353 | .min_h_0() |
| @@ -2293,7 +2373,7 @@ impl Render for Workspace { | ||
| 2293 | 2373 | Some(chat) => this.child(chat), |
| 2294 | 2374 | None => this.child(self.render_empty_sheet(cx)), |
| 2295 | 2375 | })) |
| 2296 | - .children(self.render_terminal_drawer(false, window, cx)) | |
| 2376 | + .when(!drawer_full_width, |this| this.children(drawer.take())) | |
| 2297 | 2377 | .children(floating_device), |
| 2298 | 2378 | ) |
| 2299 | 2379 | .when(panel_visible, |this| this.child(self.render_panel(cx))) |
| @@ -2302,7 +2382,11 @@ impl Render for Workspace { | ||
| 2302 | 2382 | .children(docked_terminal) |
| 2303 | 2383 | .when_some(browser, |this, browser| this.child(browser)) |
| 2304 | 2384 | .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())), | |
| 2306 | 2390 | ), |
| 2307 | 2391 | )) |
| 2308 | 2392 | // The command palette floats over everything; a click beside it closes it. |
| @@ -2455,6 +2539,63 @@ pub(crate) fn scratch_dir() -> Option<PathBuf> { | ||
| 2455 | 2539 | #[cfg(test)] |
| 2456 | 2540 | mod tests { |
| 2457 | 2541 | 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 | + } | |
| 2458 | 2599 | |
| 2459 | 2600 | #[::core::prelude::v1::test] |
| 2460 | 2601 | fn project_initials() { |
| @@ -206,6 +206,32 @@ impl Workspace { | ||
| 206 | 206 | .into_any_element() |
| 207 | 207 | }); |
| 208 | 208 | |
| 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 | + } | |
| 209 | 235 | v_flex() |
| 210 | 236 | .flex_none() |
| 211 | 237 | .child( |
| @@ -258,7 +258,6 @@ const SETTINGS: &[(&str, &str)] = &[ | ||
| 258 | 258 | ("Thread cards", "Appearance"), |
| 259 | 259 | ("Theme", "Themes"), |
| 260 | 260 | ("Providers", "Providers"), |
| 261 | - ("Usage", "Usage"), | |
| 262 | 261 | ("Allow remote connections", "Connections"), |
| 263 | 262 | ("Keybindings", "Keybindings"), |
| 264 | 263 | ("Archived threads", "Archive"), |
| @@ -449,7 +448,7 @@ impl Workspace { | ||
| 449 | 448 | PaletteItem::new("Actions", "Open usage", Lucide::ChartNoAxesColumn) |
| 450 | 449 | .shortcut("Ctrl+U") |
| 451 | 450 | .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)), | |
| 453 | 452 | ); |
| 454 | 453 | items.push( |
| 455 | 454 | PaletteItem::new("Actions", "Open settings", IconName::Settings) |
| @@ -422,6 +422,7 @@ impl Workspace { | ||
| 422 | 422 | branches: git.as_ref().map(|git| git.branches.clone()).unwrap_or_default(), |
| 423 | 423 | added: git.as_ref().map_or(0, |git| git.added), |
| 424 | 424 | removed: git.as_ref().map_or(0, |git| git.removed), |
| 425 | + origin: git.as_ref().and_then(|git| git.origin.clone()), | |
| 425 | 426 | } |
| 426 | 427 | }) |
| 427 | 428 | .collect(); |
| @@ -1047,6 +1048,7 @@ impl Workspace { | ||
| 1047 | 1048 | branches: info.branches.clone(), |
| 1048 | 1049 | added: info.added, |
| 1049 | 1050 | removed: info.removed, |
| 1051 | + origin: info.origin.clone(), | |
| 1050 | 1052 | }); |
| 1051 | 1053 | } |
| 1052 | 1054 | let known: HashSet<&str> = threads.iter().map(|summary| summary.id.as_str()).collect(); |