Vectrum
Sign In
280 lines · 11.4 KB · Rust
//! Vectrum Code: a minimal T3 Code-style desktop client for Claude Code.

// GPUI's derive macros expand to `gpui::` paths; route them through the Kit.
extern crate gpui_kit as gpui;

mod browser;
mod claude;
mod clock;
mod device;
mod git;
mod http;
mod models;
mod orchestrator;
mod projects;
mod remote;
mod session;
mod settings;
mod store;
mod terminal;
mod textgen;
mod ui;
mod usage;
mod worktree;

use std::{
    path::PathBuf,
    sync::{
        Mutex,
        atomic::{AtomicBool, Ordering},
    },
    time::{Duration, Instant},
};

use gpui_kit::component::{WindowExt as _, button::ButtonVariant, notification::Notification};
use gpui_kit::*;

use crate::ui::{icons::AppAssets, metal, workspace::Workspace};

gpui_kit::actions!(
    vectrum_code,
    [
        NewThread,
        OpenProject,
        OpenInEditor,
        Interrupt,
        ToggleSidebar,
        ToggleProjectPanel,
        ToggleSubagents,
        ToggleBrowser,
        FocusSearch,
        OpenSettings,
        CloseSettings,
        Quit,
        // T3 Code's commands, by its default keymap.
        ToggleCommandPalette,
        NewThreadLocal,
        NewThreadWithoutProject,
        PreviousThread,
        NextThread,
        SettleThread,
        PinThread,
        UndoThreadAction,
        CopyThreadReference,
        OpenUsage,
        ToggleRightPanel,
        SelectTheme,
        CycleAppearance,
        // The composer's, in the open thread.
        ToggleModelPicker,
        ComposerEffort,
        ComposerMode,
        ComposerBranch,
        ComposerWorkspace,
        ComposerHost,
        ComposerPreviousWorktree,
        ComposerStash,
        FindInThread,
        // T3 Code's terminal drawer commands.
        ToggleTerminal,
        SplitTerminal,
        SplitTerminalVertical,
        NewTerminal,
        CloseTerminal,
        TerminalCopy,
        TerminalPaste,
        // Keys for lists with a text field: the palette, sidebar search, the
        // project filter, and inline renames.
        SelectPrevious,
        SelectNext,
        Dismiss,
        PaletteBack
    ]
);

/// With the Hold quit shortcut, a second Ctrl+Q (or the key repeat of a
/// held one) within this long quits.
const QUIT_WINDOW: Duration = Duration::from_millis(1200);

fn main() {
    let project = std::env::args_os()
        .nth(1)
        .map(PathBuf::from)
        .map(|project| project.canonicalize().unwrap_or(project));

    gpui_kit::application()
        .with_assets(AppAssets)
        .run(move |cx| {
            gpui_kit::init(cx);
            metal::load_fonts(cx);
            models::Catalog::init(cx);
            ui::browser::BrowserWatch::init(cx);
            ui::device::DeviceWatch::init(cx);

            cx.bind_keys([
                // T3 Code's default keymap, for the commands the app has.
                KeyBinding::new("ctrl-b", ToggleSidebar, None),
                KeyBinding::new("ctrl-k", ToggleCommandPalette, None),
                KeyBinding::new("ctrl-n", NewThread, None),
                KeyBinding::new("ctrl-shift-o", NewThread, None),
                KeyBinding::new("ctrl-shift-n", NewThreadLocal, None),
                KeyBinding::new("ctrl-alt-n", NewThreadWithoutProject, None),
                KeyBinding::new("ctrl-shift-[", PreviousThread, None),
                KeyBinding::new("ctrl-shift-]", NextThread, None),
                KeyBinding::new("ctrl-{", PreviousThread, None),
                KeyBinding::new("ctrl-}", NextThread, None),
                KeyBinding::new("ctrl-shift-s", SettleThread, None),
                KeyBinding::new("ctrl-shift-p", PinThread, None),
                KeyBinding::new("ctrl-z", UndoThreadAction, Some("Workspace")),
                KeyBinding::new("ctrl-z", UndoThreadAction, Some("Workspace > Input")),
                KeyBinding::new("ctrl-shift-c", CopyThreadReference, None),
                KeyBinding::new("ctrl-u", OpenUsage, None),
                KeyBinding::new("ctrl-alt-b", ToggleRightPanel, None),
                KeyBinding::new("ctrl-alt-a", SelectTheme, None),
                KeyBinding::new("ctrl-alt-shift-a", CycleAppearance, None),
                // The app's own.
                KeyBinding::new("ctrl-o", OpenInEditor, None),
                KeyBinding::new("ctrl-shift-b", ToggleProjectPanel, None),
                KeyBinding::new("ctrl-alt-s", ToggleSubagents, None),
                KeyBinding::new("ctrl-shift-j", ToggleBrowser, None),
                KeyBinding::new("ctrl-,", OpenSettings, None),
                KeyBinding::new("ctrl-q", Quit, None),
                KeyBinding::new("escape", Interrupt, Some("ChatView")),
                KeyBinding::new("ctrl-shift-m", ToggleModelPicker, Some("ChatView")),
                KeyBinding::new("ctrl-shift-e", ComposerEffort, Some("ChatView")),
                KeyBinding::new("ctrl-shift-a", ComposerMode, Some("ChatView")),
                KeyBinding::new("ctrl-shift-g", ComposerBranch, Some("ChatView")),
                KeyBinding::new("ctrl-shift-x", ComposerWorkspace, Some("ChatView")),
                KeyBinding::new("ctrl-shift-h", ComposerHost, Some("ChatView")),
                KeyBinding::new("ctrl-shift-l", ComposerPreviousWorktree, Some("ChatView")),
                KeyBinding::new("ctrl-s", ComposerStash, Some("ChatView")),
                KeyBinding::new("ctrl-f", FindInThread, Some("ChatView")),
                // The composer's text field binds Ctrl+F to its own search.
                KeyBinding::new("ctrl-f", FindInThread, Some("ChatView > Input")),
                KeyBinding::new("escape", CloseSettings, Some("SettingsView")),
                // Lists with a text field.
                KeyBinding::new("up", SelectPrevious, Some("CommandPalette > Input")),
                KeyBinding::new("down", SelectNext, Some("CommandPalette > Input")),
                KeyBinding::new("escape", Dismiss, Some("CommandPalette > Input")),
                KeyBinding::new("backspace", PaletteBack, Some("CommandPalette > Input")),
                KeyBinding::new("up", SelectPrevious, Some("SidebarSearch > Input")),
                KeyBinding::new("down", SelectNext, Some("SidebarSearch > Input")),
                KeyBinding::new("escape", Dismiss, Some("SidebarSearch > Input")),
                KeyBinding::new("up", SelectPrevious, Some("ScopePicker > Input")),
                KeyBinding::new("down", SelectNext, Some("ScopePicker > Input")),
                KeyBinding::new("escape", Dismiss, Some("ScopePicker > Input")),
                KeyBinding::new("escape", Dismiss, Some("InlineRename > Input")),
                // The terminal drawer. A focused terminal's keys come last so
                // they win over the app's (Ctrl+N is a new terminal there).
                KeyBinding::new("ctrl-j", ToggleTerminal, None),
                KeyBinding::new("ctrl-d", SplitTerminal, Some("Terminal")),
                KeyBinding::new("ctrl-shift-d", SplitTerminalVertical, Some("Terminal")),
                KeyBinding::new("ctrl-n", NewTerminal, Some("Terminal")),
                KeyBinding::new("ctrl-w", CloseTerminal, Some("Terminal")),
                KeyBinding::new("ctrl-shift-c", TerminalCopy, Some("Terminal")),
                KeyBinding::new("ctrl-shift-v", TerminalPaste, Some("Terminal")),
                KeyBinding::new("shift-insert", TerminalPaste, Some("Terminal")),
            ]);
            // Keys T3 Code keeps from the app while a terminal has focus
            // (its "!terminalFocus"), so they reach the shell.
            cx.bind_keys(
                [
                    "ctrl-k", "ctrl-u", "ctrl-z", "ctrl-shift-o", "ctrl-shift-n", "ctrl-alt-n", "ctrl-shift-s",
                    "ctrl-shift-p", "ctrl-alt-a", "ctrl-alt-shift-a", "tab", "shift-tab",
                ]
                .map(|key| KeyBinding::new(key, NoAction, Some("Terminal"))),
            );
            cx.on_action(quit);
            cx.on_window_closed(|cx, _| cx.quit()).detach();

            let bounds = Bounds::centered(None, size(px(1280.), px(840.)), cx);
            let options = WindowOptions {
                window_bounds: Some(WindowBounds::Windowed(bounds)),
                titlebar: Some(TitlebarOptions {
                    title: Some("Vectrum Code".into()),
                    ..Default::default()
                }),
                app_id: Some("vectrum-code".into()),
                window_min_size: Some(size(px(820.), px(520.))),
                ..Default::default()
            };
            gpui_kit::open_window(options, cx, |window, cx| {
                metal::sync_appearance(window, cx);
                // Alt+F4 and the window manager's close button.
                window.on_window_should_close(cx, |window, cx| !ask_to_close(window, cx));
                cx.new(|cx| Workspace::new(project, window, cx))
            })
            .expect("failed to open the main window");
            cx.activate(true);
        });
}

/// Quits (through the close confirmation), or with the Hold shortcut, asks
/// for the keys to be held or pressed again.
fn quit(_: &Quit, cx: &mut App) {
    static FIRST_PRESS: Mutex<Option<Instant>> = Mutex::new(None);
    if settings::get(|settings| settings.quit_shortcut) == settings::QuitShortcut::Press {
        quit_or_ask(cx);
        return;
    }
    let mut first = FIRST_PRESS.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
    if first.is_some_and(|at| at.elapsed() < QUIT_WINDOW) {
        quit_or_ask(cx);
        return;
    }
    *first = Some(Instant::now());
    if let Some(window) = cx.active_window() {
        let _ = window.update(cx, |_, window, cx| {
            let message = match settings::get(|settings| settings.quit_shortcut) {
                settings::QuitShortcut::Double => "Press Ctrl+Q again to quit.",
                _ => "Hold Ctrl+Q, or press it again, to quit.",
            };
            window.push_notification(Notification::info(message), cx);
        });
    }
}

/// Quits, or asks first when closing needs a confirmation. Deferred, since a
/// key's action runs while its window is mid-update.
fn quit_or_ask(cx: &mut App) {
    cx.defer(|cx| {
        let Some(window) = cx.active_window().or_else(|| cx.windows().first().copied()) else {
            cx.quit();
            return;
        };
        if let Ok(false) = window.update(cx, |_, window, cx| ask_to_close(window, cx)) {
            cx.quit();
        }
    });
}

/// With Close confirmation on, opens the "Quit Vectrum Code?" dialog (Enter
/// or a click on Quit quits, Escape or Cancel stays) and returns true; the
/// caller then must not close. Returns false when closing needs no asking.
fn ask_to_close(window: &mut Window, cx: &mut App) -> bool {
    /// The dialog is up: a held Ctrl+Q's key repeat or a second Alt+F4
    /// leaves it be rather than stacking another.
    static ASKING: AtomicBool = AtomicBool::new(false);
    if !settings::get(|settings| settings.confirm_close) {
        return false;
    }
    if ASKING.load(Ordering::Relaxed) && window.has_active_dialog(cx) {
        return true;
    }
    ASKING.store(true, Ordering::Relaxed);
    window.open_alert_dialog(cx, |alert, _, _| {
        alert
            .title("Quit Vectrum Code?")
            .description("Running agents and terminals stop. Press Enter to quit, or Escape to stay.")
            .ok_text("Quit")
            .ok_variant(ButtonVariant::Danger)
            .cancel_text("Cancel")
            .show_cancel(true)
            .on_ok(|_, _, cx| {
                cx.quit();
                true
            })
            .on_close(|_, _, _| ASKING.store(false, Ordering::Relaxed))
    });
    true
}