//! The window root, laid out like T3 Code: a searchable list of threads on
//! the left, the conversation on a raised sheet, and a project panel (editor,
//! branch, commit, changes) on the right.
use std::{
collections::HashMap,
hash::{Hash as _, Hasher as _},
path::PathBuf,
process::{Command, Stdio},
rc::Rc,
time::{Duration, Instant},
};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, WindowExt as _, h_flex,
input::{Input, InputEvent, InputState},
menu::{DropdownMenu as _, PopupMenuItem},
notification::Notification,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::claude::PermissionMode;
use crate::git::{self, GitInfo};
use crate::models::{Catalog, ModelEntry, Provider};
use crate::session::{Reasoning, Session, SessionStatus};
use crate::ui::chat::{ChatEvent, ChatView, display_path};
use crate::ui::pickers;
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::settings::{SettingsEvent, SettingsView};
use crate::{FocusSearch, NewThread, OpenProject, OpenSettings, ToggleProjectPanel, ToggleSidebar};
pub(crate) const SIDEBAR_WIDTH: f32 = 280.;
const PANEL_WIDTH: f32 = 292.;
/// Below this window width the project panel stays out of the conversation's way.
const PANEL_MIN_WINDOW: f32 = 1120.;
/// The gap between the sheet and the window edge, and the sheet's rounding.
pub(crate) const SHEET_INSET: f32 = 8.;
pub(crate) const SHEET_RADIUS: f32 = 24.;
const GIT_POLL: Duration = Duration::from_secs(4);
/// The projects, as the conversation's start screen needs them: to name its
/// project, offer the others, and show where and on which branch it starts.
#[derive(Default)]
pub struct ProjectDirectory {
pub hostname: SharedString,
pub projects: Vec<ProjectSummary>,
}
pub struct ProjectSummary {
pub name: SharedString,
pub path: PathBuf,
/// The folder threads "without a project" run in.
pub scratch: bool,
pub branch: Option<String>,
pub branches: Vec<String>,
}
impl Global for ProjectDirectory {}
/// What a new thread starts with: the provider, model and settings the user
/// picked last.
#[derive(Clone, Default)]
struct ThreadSettings {
provider: Provider,
model: Option<String>,
mode: crate::claude::PermissionMode,
reasoning: Reasoning,
fast_mode: bool,
}
struct Project {
path: PathBuf,
name: SharedString,
initials: SharedString,
/// Index into the palette's tints.
tint: usize,
sessions: Vec<Entity<Session>>,
/// `None` until read, or when the folder isn't a git repository.
git: Option<GitInfo>,
}
/// A thread in the sidebar: its project's index and the session.
type ThreadEntry = (usize, Entity<Session>);
/// A settled thread, as the settings' archive lists it.
pub struct SettledThread {
pub session: Entity<Session>,
pub project: SharedString,
pub initials: SharedString,
pub tint: usize,
pub title: SharedString,
pub provider: Provider,
pub age: String,
}
/// A GUI program that can open a folder.
struct EditorApp {
name: &'static str,
program: PathBuf,
}
pub struct Workspace {
projects: Vec<Project>,
chats: HashMap<EntityId, Entity<ChatView>>,
active: Option<Entity<Session>>,
active_project: usize,
search: Entity<InputState>,
sidebar_open: bool,
panel_open: bool,
settled_open: bool,
editors: Rc<Vec<EditorApp>>,
editor: usize,
hostname: SharedString,
settings_view: Entity<SettingsView>,
settings_open: bool,
/// Whether each thread was working and waiting for the user when last
/// seen, to notice when it finishes or starts waiting.
activity: HashMap<EntityId, (bool, bool)>,
focus_handle: FocusHandle,
observers: HashMap<EntityId, Vec<Subscription>>,
_subscriptions: Vec<Subscription>,
_git_poll: Task<()>,
}
impl Workspace {
pub fn new(initial_project: PathBuf, window: &mut Window, cx: &mut Context<Self>) -> Self {
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search"));
let workspace = cx.weak_entity();
let settings_view = cx.new(|cx| SettingsView::new(workspace, window, cx));
let subscriptions = vec![
cx.subscribe_in(&settings_view, window, |this, _, event: &SettingsEvent, window, cx| match event {
SettingsEvent::Close => this.close_settings(window, cx),
}),
cx.on_app_quit(|this: &mut Self, cx| {
for project in &this.projects {
for session in &project.sessions {
session.update(cx, |session, _| session.shutdown());
}
}
async {}
}),
// The Auto colorway follows the desktop's light/dark setting.
cx.observe_window_appearance(window, |_, window, cx| metal::sync_appearance(window, cx)),
cx.subscribe(&search, |_, _, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
cx.notify();
}
}),
];
let git_poll = cx.spawn(async move |this, cx| {
let mut polls: u32 = 0;
loop {
polls = polls.wrapping_add(1);
let Ok(paths) = this.update(cx, |this, _| {
this.projects.iter().map(|project| project.path.clone()).collect::<Vec<_>>()
}) else {
break;
};
let infos = cx
.background_executor()
.spawn(async move {
paths.into_iter().map(|path| {
let info = git::read_info(&path);
(path, info)
})
.collect::<Vec<_>>()
})
.await;
let applied = this.update(cx, |this, cx| {
let mut changed = false;
for (path, info) in infos {
if let Some(project) = this.projects.iter_mut().find(|project| project.path == path)
&& project.git != info
{
project.git = info;
changed = true;
}
}
// Also repaint about once a minute so thread ages stay current.
if changed || polls.is_multiple_of(15) {
cx.notify();
}
});
if applied.is_err() {
break;
}
cx.background_executor().timer(GIT_POLL).await;
}
});
let editors = detect_editors();
let saved_editor = crate::settings::get(|settings| settings.editor.clone());
let editor = editors.iter().position(|app| Some(app.name) == saved_editor.as_deref()).unwrap_or(0);
let mut workspace = Self {
projects: Vec::new(),
chats: HashMap::new(),
active: None,
active_project: 0,
search,
sidebar_open: true,
panel_open: true,
settled_open: false,
editors: Rc::new(editors),
editor,
hostname: hostname().into(),
settings_view,
settings_open: false,
activity: HashMap::new(),
focus_handle: cx.focus_handle(),
observers: HashMap::new(),
_subscriptions: subscriptions,
_git_poll: git_poll,
};
let ix = workspace.ensure_project(initial_project, None);
let settings = workspace.new_thread_settings(cx);
workspace.replace_thread(ix, settings, window, cx);
workspace
}
fn add_project(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let ix = self.ensure_project(path, None);
let settings = self.current_settings(cx);
self.replace_thread(ix, settings, window, cx);
}
/// The settings of the open thread, which a new thread inherits.
fn current_settings(&self, cx: &App) -> ThreadSettings {
self.active
.as_ref()
.map(|active| {
let active = active.read(cx);
ThreadSettings {
provider: active.provider,
model: active.model.clone(),
mode: active.permission_mode,
reasoning: active.reasoning,
fast_mode: active.fast_mode,
}
})
.unwrap_or_default()
}
/// What a fresh thread starts with: the open thread's picks, overridden
/// by the defaults chosen in the settings.
fn new_thread_settings(&self, cx: &App) -> ThreadSettings {
let (model, reasoning, access) = crate::settings::get(|settings| {
(settings.default_model.clone(), settings.default_reasoning.clone(), settings.default_access.clone())
});
let mut settings = self.current_settings(cx);
if let Some(key) = model {
match Catalog::global(cx).find_key(&key) {
Some(model) => settings = self.settings_for(model, cx),
// Claude's models are only known once a Claude session started.
None => {
if let Some((provider, id)) = key.split_once(':')
&& let Some(provider) = Provider::from_key(provider)
{
settings.provider = provider;
settings.model = Some(id.to_string());
}
}
}
}
if let Some(reasoning) = reasoning.as_deref().and_then(Reasoning::from_key) {
let catalog = Catalog::global(cx);
let model = catalog.find(settings.provider, settings.model.as_deref());
settings.reasoning = match reasoning {
Reasoning::Level(effort) if model.is_none_or(|model| model.efforts.contains(&effort)) => reasoning,
Reasoning::Ultracode | Reasoning::Ultrathink if settings.provider == Provider::Claude => reasoning,
_ => Reasoning::Default,
};
}
if let Some(mode) = access.as_deref().and_then(PermissionMode::from_cli) {
settings.mode = mode;
}
settings
}
fn new_thread(&mut self, project_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
let settings = self.new_thread_settings(cx);
self.new_thread_with(project_ix, settings, window, cx);
}
fn new_thread_with(
&mut self,
project_ix: usize,
settings: ThreadSettings,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Entity<ChatView>> {
let project = self.projects.get(project_ix)?;
let cwd = project.path.clone();
let session = cx.new(|cx| {
Session::new(
settings.provider,
cwd,
settings.model,
settings.mode,
settings.reasoning,
settings.fast_mode,
cx,
)
});
let chat = cx.new(|cx| ChatView::new(session.clone(), window, cx));
self.observers.insert(
session.entity_id(),
vec![
cx.observe_in(&session, window, Self::on_session_changed),
cx.subscribe_in(&chat, window, Self::on_chat_event),
],
);
self.chats.insert(session.entity_id(), chat.clone());
self.projects[project_ix].sessions.push(session.clone());
self.select(project_ix, session, window, cx);
Some(chat)
}
/// Starts a thread that replaces the open one if nothing was sent in it
/// yet, carrying over what was typed.
fn replace_thread(
&mut self,
project_ix: usize,
settings: ThreadSettings,
window: &mut Window,
cx: &mut Context<Self>,
) {
let previous = self.active.clone().filter(|session| session.read(cx).items.is_empty());
let previous_chat = previous.as_ref().and_then(|session| self.chats.get(&session.entity_id()).cloned());
let Some(chat) = self.new_thread_with(project_ix, settings, window, cx) else { return };
if let (Some(previous), Some(previous_chat)) = (previous, previous_chat) {
chat.update(cx, |chat, cx| chat.take_draft_from(&previous_chat, window, cx));
if let Some(ix) = self.project_of(&previous) {
self.remove_thread(ix, &previous, cx);
}
}
}
/// Repaints, and posts a desktop notification when a thread finishes or
/// starts waiting for the user while the window is in the background.
fn on_session_changed(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
cx.notify();
if !crate::settings::get(|settings| settings.notify) {
self.activity.clear();
return;
}
let state = session.read(cx);
let now = (state.is_working(), state.needs_attention());
let before = self.activity.insert(session.entity_id(), now).unwrap_or_default();
if window.is_window_active() || state.settled {
return;
}
if now.1 && !before.1 {
desktop_notification(&state.title, "Waiting for you");
} else if before.0 && !now.0 {
desktop_notification(&state.title, "Finished");
}
}
fn project_of(&self, session: &Entity<Session>) -> Option<usize> {
self.projects
.iter()
.position(|project| project.sessions.iter().any(|candidate| candidate == session))
}
fn on_chat_event(
&mut self,
chat: &Entity<ChatView>,
event: &ChatEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
let session = chat.read(cx).session().clone();
let Some(project_ix) = self.project_of(&session) else { return };
match event {
ChatEvent::NewThreadWithModel(model) => {
let settings = self.settings_for(model, cx);
self.replace_thread(project_ix, settings, window, cx);
}
ChatEvent::SwitchProject(path) => {
if let Some(target) = self.projects.iter().position(|project| &project.path == path) {
let settings = self.current_settings(cx);
self.replace_thread(target, settings, window, cx);
}
}
ChatEvent::AddProject => self.prompt_for_project(window, cx),
ChatEvent::StartWithoutProject => {
let Some(path) = scratch_dir() else { return };
let target = self.ensure_project(path, Some("No project"));
let settings = self.current_settings(cx);
self.replace_thread(target, settings, window, cx);
}
ChatEvent::SwitchBranch(branch) => self.switch_branch(branch.clone(), window, cx),
}
}
/// The settings for a thread on `model`, keeping the user's other picks
/// where the provider supports them.
fn settings_for(&self, model: &ModelEntry, cx: &App) -> ThreadSettings {
let current = self.current_settings(cx);
let reasoning = match current.reasoning {
Reasoning::Level(effort) if model.efforts.contains(&effort) => current.reasoning,
Reasoning::Ultracode | Reasoning::Ultrathink if model.provider == Provider::Claude => current.reasoning,
_ => Reasoning::Default,
};
ThreadSettings {
provider: model.provider,
model: Some(model.id.clone()),
mode: current.mode,
reasoning,
fast_mode: current.fast_mode && model.fast_mode,
}
}
/// The index of the project at `path`, adding it if needed.
fn ensure_project(&mut self, path: PathBuf, name: Option<&str>) -> usize {
if let Some(ix) = self.projects.iter().position(|project| project.path == path) {
return ix;
}
let name = name.map(str::to_string).unwrap_or_else(|| {
path.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string())
});
let git = git::read_info(&path);
self.projects.push(Project {
path,
initials: initials(&name).into(),
tint: tint_for(&name),
name: name.into(),
sessions: Vec::new(),
git,
});
self.projects.len() - 1
}
fn select(
&mut self,
project_ix: usize,
session: Entity<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.active_project = project_ix;
if let Some(chat) = self.chats.get(&session.entity_id()).cloned() {
chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
}
self.active = Some(session);
cx.notify();
}
fn settle_thread(&mut self, session: Entity<Session>, cx: &mut Context<Self>) {
session.update(cx, |session, cx| session.settle(cx));
cx.notify();
}
fn delete_thread(
&mut self,
project_ix: usize,
session: Entity<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.remove_thread(project_ix, &session, cx);
let id = session.entity_id();
if self.active.as_ref().is_some_and(|active| active.entity_id() == id) {
self.active = None;
if let Some((ix, next)) = self.threads(cx).0.into_iter().next() {
self.select(ix, next, window, cx);
}
}
cx.notify();
}
/// Stops a thread's process and forgets it.
fn remove_thread(&mut self, project_ix: usize, session: &Entity<Session>, cx: &mut Context<Self>) {
session.update(cx, |session, _| session.shutdown());
let id = session.entity_id();
self.chats.remove(&id);
self.observers.remove(&id);
self.activity.remove(&id);
if let Some(project) = self.projects.get_mut(project_ix) {
project.sessions.retain(|candidate| candidate.entity_id() != id);
}
cx.notify();
}
fn prompt_for_project(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let paths = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Open project".into()),
});
cx.spawn_in(window, async move |this, cx| {
let Ok(Ok(Some(paths))) = paths.await else { return };
let Some(path) = paths.into_iter().next() else { return };
let _ = this.update_in(cx, |this, window, cx| this.add_project(path, window, cx));
})
.detach();
}
/// The threads matching the search, split into open and settled, most
/// recently active first.
fn threads(&self, cx: &App) -> (Vec<ThreadEntry>, Vec<ThreadEntry>) {
let query = self.search.read(cx).value().trim().to_lowercase();
let mut threads: Vec<_> = self
.projects
.iter()
.enumerate()
.flat_map(|(ix, project)| project.sessions.iter().map(move |session| (ix, session.clone())))
.filter(|(ix, session)| {
query.is_empty()
|| session.read(cx).title.to_lowercase().contains(&query)
|| self.projects[*ix].name.to_lowercase().contains(&query)
})
.collect();
threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
threads.into_iter().partition(|(_, session)| !session.read(cx).settled)
}
fn active_project(&self) -> Option<&Project> {
self.projects.get(self.active_project)
}
fn open_in_editor(&mut self, editor: usize, window: &mut Window, cx: &mut Context<Self>) {
let (Some(app), Some(project)) = (self.editors.get(editor), self.active_project()) else {
return;
};
let result = Command::new(&app.program)
.arg(&project.path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
match result {
Ok(mut child) => {
// Reap it so it doesn't linger as a zombie.
std::thread::spawn(move || child.wait());
self.set_editor(editor, cx);
}
Err(err) => window.push_notification(
Notification::error(format!("Couldn't start {}: {err}", app.name)),
cx,
),
}
cx.notify();
}
fn switch_branch(&mut self, branch: String, window: &mut Window, cx: &mut Context<Self>) {
let Some(path) = self.active_project().map(|project| project.path.clone()) else { return };
let task = cx.background_executor().spawn(async move {
let output = git::git(&path, &["switch", &branch]);
let info = git::read_info(&path);
(path, output, info)
});
cx.spawn_in(window, async move |this, cx| {
let (path, output, info) = task.await;
let _ = this.update_in(cx, |this, window, cx| {
if let Err(err) = output {
window.push_notification(Notification::error(format!("git switch failed: {err}")), cx);
}
if let Some(project) = this.projects.iter_mut().find(|project| project.path == path) {
project.git = info;
}
cx.notify();
});
})
.detach();
}
/// Hands a git chore to Claude in the open thread.
fn ask_claude(&mut self, prompt: &str, cx: &mut Context<Self>) {
if let Some(session) = &self.active {
session.update(cx, |session, cx| session.send_message(prompt.to_string(), Vec::new(), cx));
}
}
// ---- what the settings view reads and changes ----------------------
fn open_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.settings_open = true;
self.settings_view.update(cx, |view, cx| view.opened(window, cx));
cx.notify();
}
fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.settings_open = false;
if let Some(chat) = self.active.as_ref().and_then(|session| self.chats.get(&session.entity_id())) {
chat.update(cx, |chat, cx| chat.focus_composer(window, cx));
}
cx.notify();
}
/// Lets every open conversation pick up changed settings.
pub fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
for chat in self.chats.values() {
chat.update(cx, |chat, cx| chat.settings_changed(window, cx));
}
cx.notify();
}
pub fn editor_names(&self) -> Vec<&'static str> {
self.editors.iter().map(|app| app.name).collect()
}
pub fn editor(&self) -> usize {
self.editor
}
pub fn set_editor(&mut self, editor: usize, cx: &mut Context<Self>) {
let Some(app) = self.editors.get(editor) else { return };
self.editor = editor;
let name = app.name.to_string();
crate::settings::update(|settings| settings.editor = Some(name));
cx.notify();
}
/// Every settled thread, most recently active first.
pub fn settled_threads(&self, cx: &App) -> Vec<SettledThread> {
let mut threads: Vec<_> = self
.projects
.iter()
.flat_map(|project| project.sessions.iter().map(move |session| (project, session)))
.filter(|(_, session)| session.read(cx).settled)
.collect();
threads.sort_by_key(|(_, session)| std::cmp::Reverse(session.read(cx).updated));
threads
.into_iter()
.map(|(project, session)| {
let state = session.read(cx);
SettledThread {
session: session.clone(),
project: project.name.clone(),
initials: project.initials.clone(),
tint: project.tint,
title: state.title.clone(),
provider: state.provider,
age: relative_time(state.updated),
}
})
.collect()
}
/// Brings a settled thread back and opens it.
pub fn restore_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
let Some(ix) = self.project_of(&session) else { return };
session.update(cx, |session, cx| session.unsettle(cx));
self.select(ix, session, window, cx);
self.close_settings(window, cx);
}
pub fn forget_thread(&mut self, session: Entity<Session>, window: &mut Window, cx: &mut Context<Self>) {
if let Some(ix) = self.project_of(&session) {
self.delete_thread(ix, session, window, cx);
}
}
fn render_sidebar_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
h_flex()
.flex_none()
.h(px(52.))
.pl(px(10.))
.pr(px(12.))
.gap(px(8.))
.child(
MetalButton::new("hide-sidebar")
.ghost()
.mini()
.icon(Lucide::PanelLeftClose)
.tooltip("Hide sidebar (Ctrl+B)")
.on_click(cx.listener(|this, _, _, cx| {
this.sidebar_open = false;
cx.notify();
})),
)
.child(Icon::default().data(metal::LOGO_SVG).size(px(18.)).text_color(p.ink))
.child(div().text_size(px(15.)).font_weight(FontWeight::SEMIBOLD).child("Vectrum"))
.child(metal::tag("Code", p))
}
fn render_sidebar_toolbar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let projects: Vec<(usize, SharedString)> =
self.projects.iter().enumerate().map(|(ix, project)| (ix, project.name.clone())).collect();
let workspace = cx.entity().downgrade();
h_flex()
.flex_none()
.px(px(12.))
.gap(px(4.))
.child(
h_flex()
.flex_1()
.min_w_0()
.h(px(36.))
.pl(px(11.))
.pr(px(8.))
.gap(px(8.))
.rounded(px(14.))
.well(p)
.text_size(px(13.))
.text_color(p.ink)
.child(Icon::new(IconName::Search).size(px(14.)).text_color(p.field_ink))
.child(div().flex_1().min_w_0().child(Input::new(&self.search).appearance(false))),
)
.child(
MetalButton::new("project-menu")
.ghost()
.mini()
.icon(Lucide::FolderPlus)
.dropdown_menu(move |mut menu, _, _| {
for (ix, name) in projects.clone() {
let workspace = workspace.clone();
menu = menu.item(PopupMenuItem::new(format!("New thread in {name}")).on_click(
move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.new_thread(ix, window, cx));
},
));
}
let workspace = workspace.clone();
menu.separator().item(PopupMenuItem::new("Add project folder…").on_click(
move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.prompt_for_project(window, cx));
},
))
}),
)
.child(
MetalButton::new("new-thread")
.ghost()
.mini()
.icon(Lucide::SquarePen)
.tooltip("New thread (Ctrl+N)")
.on_click(cx.listener(|this, _, window, cx| {
if this.projects.is_empty() {
this.prompt_for_project(window, cx);
} else {
this.new_thread(this.active_project, window, cx);
}
})),
)
}
fn render_thread_card(
&self,
project_ix: usize,
session: &Entity<Session>,
cx: &mut Context<Self>,
) -> AnyElement {
let p = metal::palette(cx);
let project = &self.projects[project_ix];
let state = session.read(cx);
let id = session.entity_id();
let selected = self.active.as_ref().is_some_and(|active| active.entity_id() == id);
let (led, pulse) = if state.settled {
(Led::Off, false)
} else if state.needs_attention() {
(Led::Waiting, true)
} else {
match state.status {
SessionStatus::Working => (Led::Live, true),
SessionStatus::Idle => (Led::Live, false),
SessionStatus::Starting => (Led::Off, false),
SessionStatus::Exited => (Led::Failed, false),
}
};
let branch = project.git.as_ref().and_then(|git| git.branch.clone());
let settled = state.settled;
let group = SharedString::from(format!("thread-{id}"));
let select_session = session.clone();
let action_session = session.clone();
let compact = crate::settings::get(|settings| settings.compact_threads);
let action = div().invisible().group_hover(group.clone(), |this| this.visible()).child(if settled {
MetalButton::new(("delete-thread", id.as_u64() as usize))
.ghost()
.mini()
.icon(IconName::Close)
.tooltip("Delete thread")
.on_click(cx.listener(move |this, _, window, cx| {
cx.stop_propagation();
this.delete_thread(project_ix, action_session.clone(), window, cx)
}))
} else {
MetalButton::new(("settle-thread", id.as_u64() as usize))
.ghost()
.mini()
.icon(Lucide::Archive)
.tooltip("Settle: stop Claude and file the thread away")
.on_click(cx.listener(move |this, _, _, cx| {
cx.stop_propagation();
this.settle_thread(action_session.clone(), cx)
}))
});
let led = metal::led(("thread-led", id.as_u64() as usize), led, pulse, p);
let title = div()
.truncate()
.text_size(px(13.5))
.font_weight(FontWeight::SEMIBOLD)
.text_color(if selected || !settled { p.ink } else { p.ink2 })
.child(state.title.clone());
let age = div()
.flex_none()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(relative_time(state.updated));
let card = v_flex()
.id(("thread", id.as_u64() as usize))
.group(group)
.px(px(12.))
.py(px(if compact { 5. } else { 9. }))
.gap(px(4.))
.rounded(px(if compact { 12. } else { 14. }))
.cursor_pointer()
.map(|this| {
if selected {
this.raised(p)
} else {
this.hover(|style| style.bg(p.row_hover))
}
})
.on_click(cx.listener(move |this, _, window, cx| {
this.select(project_ix, select_session.clone(), window, cx)
}));
if compact {
return card
.child(
h_flex()
.h(px(22.))
.gap(px(7.))
.child(metal::badge(project.initials.clone(), project.tint, p))
.child(title.flex_1().min_w_0().text_size(px(13.)).font_weight(FontWeight::MEDIUM))
.child(action)
.child(age)
.child(led),
)
.into_any_element();
}
card.child(
h_flex()
.gap(px(7.))
.child(metal::badge(project.initials.clone(), project.tint, p))
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(12.))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink3)
.child(project.name.clone()),
)
.child(age),
)
.child(title)
.child(
h_flex()
.h(px(18.))
.gap(px(6.))
.text_color(p.ink3)
.when_some(branch, |this, branch| {
this.child(Icon::new(Lucide::GitBranch).size(px(11.)))
.child(
div()
.min_w_0()
.truncate()
.font_family(metal::MONO)
.text_size(px(10.5))
.child(branch),
)
})
.child(div().flex_1())
.child(pickers::provider_icon(state.provider, px(11.), p))
.child(action)
.child(led),
)
.into_any_element()
}
fn render_thread_list(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let (open, settled) = self.threads(cx);
let searching = !self.search.read(cx).value().trim().is_empty();
let settled_open = self.settled_open || searching;
let settled_count = settled.len();
let open_cards: Vec<_> =
open.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)).collect();
let settled_cards: Vec<_> = if settled_open {
settled.iter().map(|(ix, session)| self.render_thread_card(*ix, session, cx)).collect()
} else {
Vec::new()
};
v_flex()
.id("threads")
.flex_1()
.min_h_0()
.overflow_y_scroll()
.px(px(8.))
.pt(px(12.))
.pb(px(12.))
.gap(px(3.))
.when(open.is_empty() && settled.is_empty(), |this| {
this.child(
div()
.px(px(12.))
.py(px(8.))
.text_size(px(12.5))
.text_color(p.ink3)
.child(if searching { "No threads match." } else { "No threads yet." }),
)
})
.children(open_cards)
.when(settled_count > 0, |this| {
this.child(
h_flex()
.id("settled-toggle")
.mt(px(10.))
.h(px(30.))
.px(px(12.))
.gap(px(8.))
.rounded(px(10.))
.cursor_pointer()
.text_size(px(12.5))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink3)
.hover(|style| style.bg(p.row_hover).text_color(p.ink))
.on_click(cx.listener(|this, _, _, cx| {
this.settled_open = !this.settled_open;
cx.notify();
}))
.child(format!("Settled ({settled_count})"))
.child(div().flex_1().h(px(1.)).bg(p.rule))
.child(
Icon::new(if settled_open { IconName::ChevronDown } else { IconName::ChevronRight })
.size(px(12.)),
),
)
.when(settled_open, |this| {
this.children(settled_cards)
})
})
}
fn render_sidebar_footer(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let account = crate::models::Catalog::global(cx).claude_account.clone();
let workspace = cx.entity().downgrade();
h_flex()
.flex_none()
.h(px(48.))
.px(px(12.))
.gap(px(8.))
.child(
MetalButton::new("settings")
.ghost()
.compact()
.icon(IconName::Settings2)
.label("Settings")
.tooltip("Settings (Ctrl+,)")
.on_click(move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.open_settings(window, cx));
}),
)
.child(div().flex_1())
.child(
div()
.min_w_0()
.truncate()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(account.clone().unwrap_or_else(|| "Connecting…".into())),
)
.child(metal::led("account-led", if account.is_some() { Led::Live } else { Led::Off }, false, p))
}
fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w(px(SIDEBAR_WIDTH))
.h_full()
.flex_none()
.child(self.render_sidebar_header(cx))
.child(self.render_sidebar_toolbar(cx))
.child(self.render_thread_list(cx))
.child(self.render_sidebar_footer(cx))
}
fn render_top_bar(&self, panel_visible: bool, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let project = self.active_project();
let title = self.active.as_ref().map(|session| session.read(cx).title.clone());
v_flex()
.flex_none()
.child(
h_flex()
.h(px(52.))
.pl(px(if self.sidebar_open { 20. } else { 12. }))
.pr(px(12.))
.gap(px(10.))
.when(!self.sidebar_open, |this| {
this.child(
MetalButton::new("show-sidebar")
.ghost()
.mini()
.icon(Lucide::PanelLeftOpen)
.tooltip("Show sidebar (Ctrl+B)")
.on_click(cx.listener(|this, _, _, cx| {
this.sidebar_open = true;
cx.notify();
})),
)
})
.when_some(project, |this, project| {
this.child(metal::badge(project.initials.clone(), project.tint, p)).child(
div()
.flex_none()
.text_size(px(14.))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink2)
.child(project.name.clone()),
)
})
.when_some(title, |this, title| {
this.child(div().text_size(px(14.)).text_color(p.ink3).child("/")).child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_size(px(14.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(p.ink)
.child(title),
)
})
.child(div().flex_1())
.child(
MetalButton::new("toggle-panel")
.ghost()
.mini()
.icon(if panel_visible { Lucide::PanelRightClose } else { Lucide::PanelRightOpen })
.tooltip("Project panel (Ctrl+Shift+B)")
.on_click(cx.listener(|this, _, _, cx| {
this.panel_open = !this.panel_open;
cx.notify();
})),
),
)
.child(metal::rule(p))
}
/// One row of the project panel: an icon, a label and an optional trailer.
fn panel_row(icon: impl Into<Icon>, label: impl Into<SharedString>, p: &Palette) -> Div {
h_flex()
.h(px(36.))
.px(px(10.))
.gap(px(10.))
.rounded(px(12.))
.text_size(px(13.5))
.font_weight(FontWeight::MEDIUM)
.text_color(p.ink)
.child(icon.into().size(px(15.)).text_color(p.ink2))
.child(div().flex_1().min_w_0().truncate().child(label.into()))
}
fn render_panel(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
let Some(project) = self.active_project() else {
return div().into_any_element();
};
let git = project.git.clone();
let has_thread = self.active.is_some();
let cost = self
.active
.as_ref()
.map(|session| session.read(cx).total_cost)
.filter(|cost| *cost > 0. && crate::settings::get(|settings| settings.show_costs));
let workspace = cx.entity().downgrade();
let editors = self.editors.clone();
let editor = self.editors.get(self.editor);
let hover = |row: Stateful<Div>| row.cursor_pointer().hover(|style| style.bg(p.row_hover));
let editor_row = match editor {
Some(app) => h_flex()
.gap(px(2.))
.child(
hover(
Self::panel_row(Lucide::Code, format!("Open in {}", app.name), p)
.flex_1()
.id("open-editor"),
)
.on_click(cx.listener(|this, _, window, cx| this.open_in_editor(this.editor, window, cx))),
)
.child(
MetalButton::new("editor-menu")
.ghost()
.mini()
.icon(IconName::ChevronDown)
.dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| {
for (ix, app) in editors.iter().enumerate() {
let workspace = workspace.clone();
menu = menu.item(PopupMenuItem::new(app.name).on_click(move |_, window, cx| {
let _ = workspace.update(cx, |this, cx| this.open_in_editor(ix, window, cx));
}));
}
menu
}),
)
.into_any_element(),
None => Self::panel_row(Lucide::Code, "No editor found", p)
.text_color(p.ink3)
.into_any_element(),
};
let git_rows: AnyElement = match git {
Some(git) => {
let branches = git.branches.clone();
let current = git.branch.clone();
let workspace = cx.entity().downgrade();
let branch_label = git.branch.clone().unwrap_or_else(|| "Detached HEAD".into());
let has_changes = git.added + git.removed > 0;
v_flex()
.gap(px(2.))
.child(
MetalButton::new("branch-menu")
.ghost()
.w_full()
.h(px(36.))
.px(px(10.))
.rounded(px(12.))
.justify_start()
.gap(px(10.))
.text_size(px(13.5))
.text_color(p.ink)
.icon(Lucide::GitBranch)
.label(branch_label)
.stretch()
.caret()
.dropdown_menu(move |mut menu, _, _| {
menu = menu.min_w(px(220.)).label("Switch branch");
for branch in branches.clone() {
let workspace = workspace.clone();
let checked = current.as_deref() == Some(branch.as_str());
menu = menu.item(PopupMenuItem::new(branch.clone()).checked(checked).on_click(
move |_, window, cx| {
let branch = branch.clone();
let _ = workspace
.update(cx, |this, cx| this.switch_branch(branch, window, cx));
},
));
}
menu
}),
)
.child(self.render_commit_row(has_thread && has_changes, cx))
.child(
Self::panel_row(Lucide::FileDiff, "Changes", p)
.child(
div()
.font_family(metal::MONO)
.text_size(px(11.5))
.text_color(p.green_ink)
.child(format!("+{}", git.added)),
)
.child(
div()
.font_family(metal::MONO)
.text_size(px(11.5))
.text_color(p.red)
.child(format!("−{}", git.removed)),
),
)
.into_any_element()
}
None => Self::panel_row(Lucide::GitBranch, "Not a git repository", p)
.text_color(p.ink3)
.into_any_element(),
};
v_flex()
.w(px(PANEL_WIDTH))
.flex_none()
.p(px(12.))
.pl(px(0.))
.child(
v_flex()
.p(px(6.))
.gap(px(2.))
.rounded(px(18.))
.raised(p)
.child(
Self::panel_row(Lucide::Laptop, self.hostname.clone(), p)
.child(metal::tag("Local", p)),
)
.child(
div()
.px(px(10.))
.pb(px(4.))
.truncate()
.font_family(metal::MONO)
.text_size(px(10.5))
.text_color(p.ink3)
.child(display_path(&project.path)),
)
.child(editor_row)
.child(div().px(px(8.)).py(px(4.)).child(metal::rule(p)))
.child(git_rows)
.when_some(cost, |this, cost| {
this.child(div().px(px(8.)).py(px(4.)).child(metal::rule(p))).child(
Self::panel_row(IconName::ChartPie, "Thread cost", p).child(
div()
.font_family(metal::MONO)
.text_size(px(11.5))
.text_color(p.ink2)
.child(format!("${cost:.4}")),
),
)
}),
)
.into_any_element()
}
fn render_commit_row(&self, enabled: bool, cx: &mut Context<Self>) -> impl IntoElement {
const COMMIT: &str = "Commit all current changes in this repository with a concise, descriptive commit message.";
const COMMIT_AND_PUSH: &str = "Commit all current changes in this repository with a concise, descriptive commit message, then push the branch to its remote.";
let p = metal::palette(cx);
let workspace = cx.entity().downgrade();
h_flex()
.gap(px(2.))
.child(
Self::panel_row(Lucide::GitCommitHorizontal, "Commit", p)
.flex_1()
.id("commit")
.map(|this| {
if enabled {
this.cursor_pointer()
.hover(|style| style.bg(p.row_hover))
.on_click(cx.listener(|this, _, _, cx| this.ask_claude(COMMIT, cx)))
} else {
this.opacity(0.45)
}
})
.tooltip(|window, cx| {
gpui_kit::component::tooltip::Tooltip::new("Ask Claude to commit the changes")
.build(window, cx)
}),
)
.child(
MetalButton::new("commit-menu")
.ghost()
.mini()
.icon(IconName::ChevronDown)
.disabled(!enabled)
.dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
let commit = workspace.clone();
let push = workspace.clone();
menu.min_w(px(220.))
.item(PopupMenuItem::new("Ask Claude to commit").on_click(move |_, _, cx| {
let _ = commit.update(cx, |this, cx| this.ask_claude(COMMIT, cx));
}))
.item(PopupMenuItem::new("Ask Claude to commit and push").on_click(move |_, _, cx| {
let _ = push.update(cx, |this, cx| this.ask_claude(COMMIT_AND_PUSH, cx));
}))
}),
)
}
fn render_empty_sheet(&self, cx: &mut Context<Self>) -> impl IntoElement {
let p = metal::palette(cx);
v_flex()
.size_full()
.items_center()
.justify_center()
.gap(px(14.))
.child(metal::engraved("No thread open", p))
.child(
div()
.text_size(px(22.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(p.ink)
.child("Pick a thread, or start a new one."),
)
.child(
MetalButton::new("empty-new-thread")
.primary()
.icon(Lucide::SquarePen)
.label("New thread")
.on_click(cx.listener(|this, _, window, cx| {
if this.projects.is_empty() {
this.prompt_for_project(window, cx);
} else {
this.new_thread(this.active_project, window, cx);
}
})),
)
}
}
impl Render for Workspace {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let chat = self
.active
.as_ref()
.and_then(|session| self.chats.get(&session.entity_id()))
.cloned();
let title = match &self.active {
Some(session) => format!("{} — Vectrum Code", session.read(cx).title),
None => "Vectrum Code".to_string(),
};
window.set_window_title(&title);
// The conversation's start screen reads the projects from here.
let scratch = scratch_dir();
let directory = ProjectDirectory {
hostname: self.hostname.clone(),
projects: self
.projects
.iter()
.map(|project| ProjectSummary {
name: project.name.clone(),
path: project.path.clone(),
scratch: Some(&project.path) == scratch.as_ref(),
branch: project.git.as_ref().and_then(|git| git.branch.clone()),
branches: project.git.as_ref().map(|git| git.branches.clone()).unwrap_or_default(),
})
.collect(),
};
cx.set_global(directory);
let p = metal::palette(cx);
let panel_visible = self.panel_open
&& self.active_project().is_some()
&& window.viewport_size().width >= px(PANEL_MIN_WINDOW);
h_flex()
.key_context("Workspace")
.track_focus(&self.focus_handle)
.size_full()
.bg(p.page)
.text_color(p.ink)
.font_family(cx.theme().font_family.clone())
.on_action(cx.listener(|this, _: &NewThread, window, cx| {
if this.projects.is_empty() {
this.prompt_for_project(window, cx);
} else {
this.new_thread(this.active_project, window, cx);
}
}))
.on_action(cx.listener(|this, _: &OpenProject, window, cx| {
this.prompt_for_project(window, cx)
}))
.on_action(cx.listener(|this, _: &ToggleSidebar, _, cx| {
this.sidebar_open = !this.sidebar_open;
cx.notify();
}))
.on_action(cx.listener(|this, _: &ToggleProjectPanel, _, cx| {
this.panel_open = !this.panel_open;
cx.notify();
}))
.on_action(cx.listener(|this, _: &FocusSearch, window, cx| {
this.settings_open = false;
this.sidebar_open = true;
this.search.update(cx, |search, cx| search.focus(window, cx));
cx.notify();
}))
.on_action(cx.listener(|this, _: &OpenSettings, window, cx| {
if this.settings_open {
this.close_settings(window, cx);
} else {
this.open_settings(window, cx);
}
}))
.when(self.settings_open, |this| this.child(self.settings_view.clone()))
.when(!self.settings_open && self.sidebar_open, |this| this.child(self.render_sidebar(cx)))
.when(!self.settings_open, |this| this.child(
div()
.flex_1()
.min_w_0()
.h_full()
.py(px(SHEET_INSET))
.pr(px(SHEET_INSET))
.when(!self.sidebar_open, |this| this.pl(px(SHEET_INSET)))
.child(
v_flex()
.size_full()
.rounded(px(SHEET_RADIUS))
.plate(p)
.child(self.render_top_bar(panel_visible, cx))
.child(
h_flex()
.flex_1()
.min_h_0()
.items_start()
.child(div().flex_1().min_w_0().h_full().map(|this| match chat {
Some(chat) => this.child(chat),
None => this.child(self.render_empty_sheet(cx)),
}))
.when(panel_visible, |this| this.child(self.render_panel(cx))),
),
),
))
}
}
/// Two letters for a project's badge: the first letters of its first two
/// words (`vectrum.git` → `VG`), or of its name (`T3-2` → `T3`).
fn initials(name: &str) -> String {
let words: Vec<&str> = name.split(|c: char| !c.is_alphanumeric()).filter(|word| !word.is_empty()).collect();
let letters: String = match words.as_slice() {
[first, second, ..] if second.starts_with(|c: char| c.is_alphabetic()) => {
first.chars().take(1).chain(second.chars().take(1)).collect()
}
[first, ..] => first.chars().take(2).collect(),
[] => "?".into(),
};
letters.to_uppercase()
}
fn tint_for(name: &str) -> usize {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
name.hash(&mut hasher);
hasher.finish() as usize
}
fn relative_time(instant: Instant) -> String {
let secs = instant.elapsed().as_secs();
match secs {
0..60 => "now".into(),
60..3_600 => format!("{}m", secs / 60),
3_600..86_400 => format!("{}h", secs / 3_600),
_ => format!("{}d", secs / 86_400),
}
}
fn hostname() -> String {
std::fs::read_to_string("/proc/sys/kernel/hostname")
.or_else(|_| std::fs::read_to_string("/etc/hostname"))
.map(|name| name.trim().to_string())
.ok()
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "This computer".into())
}
pub(crate) fn find_program(names: &[&str]) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.flat_map(|dir| names.iter().map(move |name| dir.join(name)))
.find(|path| path.is_file())
}
fn detect_editors() -> Vec<EditorApp> {
const CANDIDATES: &[(&str, &[&str])] = &[
("Zed", &["zeditor", "zed"]),
("VS Code", &["code"]),
("Cursor", &["cursor"]),
("VSCodium", &["codium"]),
("Sublime Text", &["subl"]),
("Kate", &["kate"]),
("File manager", &["xdg-open"]),
];
CANDIDATES
.iter()
.filter_map(|(name, programs)| Some(EditorApp { name, program: find_program(programs)? }))
.collect()
}
/// Posts a desktop notification through `notify-send`, if it's installed.
fn desktop_notification(title: &str, body: &str) {
let Some(program) = find_program(&["notify-send"]) else { return };
let spawned = Command::new(program)
.args(["--app-name=Vectrum Code", title, body])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
if let Ok(mut child) = spawned {
std::thread::spawn(move || child.wait());
}
}
/// Where threads started without a project run.
pub(crate) fn scratch_dir() -> Option<PathBuf> {
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))?;
let dir = base.join("vectrum-code").join("scratch");
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
#[cfg(test)]
mod tests {
use super::initials;
#[::core::prelude::v1::test]
fn project_initials() {
assert_eq!(initials("T3-2"), "T3");
assert_eq!(initials("vectrum.git"), "VG");
assert_eq!(initials("my-pi-config"), "MP");
assert_eq!(initials("codey"), "CO");
}
}