23 tea "charm.land/bubbletea/v2"
24 fang "charm.land/fang/v2"
25 "charm.land/lipgloss/v2"
26 "github.com/charmbracelet/colorprofile"
27 "github.com/charmbracelet/crush/internal/app"
28 "github.com/charmbracelet/crush/internal/client"
29 "github.com/charmbracelet/crush/internal/config"
30 "github.com/charmbracelet/crush/internal/db"
31 "github.com/charmbracelet/crush/internal/event"
32 crushlog "github.com/charmbracelet/crush/internal/log"
33 "github.com/charmbracelet/crush/internal/projects"
34 "github.com/charmbracelet/crush/internal/proto"
35 "github.com/charmbracelet/crush/internal/server"
36 "github.com/charmbracelet/crush/internal/session"
37 "github.com/charmbracelet/crush/internal/skills"
38 "github.com/charmbracelet/crush/internal/ui/common"
39 ui "github.com/charmbracelet/crush/internal/ui/model"
40 "github.com/charmbracelet/crush/internal/version"
41 "github.com/charmbracelet/crush/internal/workspace"
42 uv "github.com/charmbracelet/ultraviolet"
43 "github.com/charmbracelet/x/ansi"
44 "github.com/charmbracelet/x/exp/charmtone"
45 xstrings "github.com/charmbracelet/x/exp/strings"
46 "github.com/charmbracelet/x/term"
47 "github.com/spf13/cobra"
53 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
54 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
55 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
56 rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
57 rootCmd.Flags().BoolP("help", "h", false, "Help")
58 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
59 rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
60 rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
61 rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
77var rootCmd = &cobra.Command{
79 Short: "A terminal-first AI assistant for software development",
80 Long: "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
82# Run in interactive mode
85# Run non-interactively
86crush run "Guess my 5 favorite Pokémon"
88# Run a non-interactively with pipes and redirection
89cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
91# Run with debug logging in a specific directory
92crush --debug --cwd /path/to/project
94# Run in yolo mode (auto-accept all permissions; use with care)
97# Run with custom data directory
98crush --data-dir /path/to/custom/.crush
100# Continue a previous session
101crush --session {session-id}
103# Continue the most recent session
106 RunE: func(cmd *cobra.Command, args []string) error {
107 sessionID, _ := cmd.Flags().GetString("session")
108 continueLast, _ := cmd.Flags().GetBool("continue")
110 ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
117 sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
124 event.AppInitialized()
126 com := common.DefaultCommon(ws)
127 model := ui.New(com, sessionID, continueLast)
129 var env uv.Environ = os.Environ()
130 program := tea.NewProgram(
132 tea.WithEnvironment(env),
133 tea.WithContext(cmd.Context()),
134 tea.WithFilter(ui.MouseEventFilter),
136 go ws.Subscribe(program)
138 if _, err := program.Run(); err != nil {
140 slog.Error("TUI run error", "error", err)
141 return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") //nolint:staticcheck
147var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
149 ███████████ ███████████
150████████████████████████████
151████████████████████████████
152██████████▀██████▀██████████
153██████████ ██████ ██████████
154▀▀██████▄████▄▄████▄██████▀▀
155 ████████████████████████
162const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
166 // FIXME: config.Load uses slog internally during provider resolution,
167 // but the file-based logger isn't set up until after config is loaded
168 // (because the log path depends on the data directory from config).
169 // This creates a window where slog calls in config.Load leak to
170 // stderr. We discard early logs here as a workaround. The proper
171 // fix is to remove slog calls from config.Load and have it return
172 // warnings/diagnostics instead of logging them as a side effect.
173 slog.SetDefault(slog.New(slog.DiscardHandler))
175 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
176 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
177 // finally prepend it in the version template.
178 // Unfortunately cobra doesn't give us a way to set a function to handle
179 // printing the version, and PreRunE runs after the version is already
180 // handled, so that doesn't work either.
181 // This is the only way I could find that works relatively well.
182 if term.IsTerminal(os.Stdout.Fd()) {
184 w := colorprofile.NewWriter(os.Stdout, os.Environ())
186 _, _ = w.WriteString(heartbit.String())
187 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
189 if err := fang.Execute(
190 context.Background(),
192 fang.WithVersion(version.Version),
193 fang.WithNotifySignal(os.Interrupt),
199// supportsProgressBar tries to determine whether the current terminal supports
200// progress bars by looking into environment variables.
201func supportsProgressBar() bool {
202 if !term.IsTerminal(os.Stderr.Fd()) {
205 termProg := os.Getenv("TERM_PROGRAM")
206 _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
208 return isWindowsTerminal || xstrings.ContainsAnyOf(strings.ToLower(termProg), "ghostty", "iterm2", "rio")
211// useClientServer returns true when the client/server architecture is
212// enabled via the CRUSH_CLIENT_SERVER environment variable.
213func useClientServer() bool {
214 v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
218// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
219// terminal progress bar shown during initialization.
220func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
221 showProgress := supportsProgressBar()
223 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
226 ws, cleanup, err := setupWorkspace(cmd)
229 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
232 return ws, cleanup, err
235// setupWorkspace returns a Workspace and cleanup function. When
236// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
237// ClientWorkspace. Otherwise it creates an in-process app.App and
238// returns an AppWorkspace.
239func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
240 if useClientServer() {
241 return setupClientServerWorkspace(cmd)
243 return setupLocalWorkspace(cmd)
246// setupLocalWorkspace creates an in-process app.App and wraps it in an
248func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
249 debug, _ := cmd.Flags().GetBool("debug")
250 yolo, _ := cmd.Flags().GetBool("yolo")
251 dataDir, _ := cmd.Flags().GetString("data-dir")
254 cwd, err := ResolveCwd(cmd)
259 store, err := config.Init(cwd, dataDir, debug)
264 cfg := store.Config()
265 store.Overrides().SkipPermissionRequests = yolo
267 if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
268 return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
271 gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
272 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
273 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
274 return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
278 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
279 slog.Warn("Failed to register project", "error", err)
282 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
287 logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
288 crushlog.Setup(logFile, debug)
290 // Discover skills once before app.New. Local mode hosts a single
291 // workspace per process, so WithGlobalMirror keeps the package
292 // globals (which the TUI reads via skills.GetLatestStates) in sync
294 allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(localSkillsDiscoveryConfig(store))
295 skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates, skills.WithGlobalMirror())
297 appInstance, err := app.New(ctx, conn, store, skillsMgr)
300 slog.Error("Failed to create app instance", "error", err)
304 if shouldEnableMetrics(cfg) {
308 ws := workspace.NewAppWorkspace(appInstance, store)
309 cleanup := func() { appInstance.Shutdown() }
310 return ws, cleanup, nil
313// localSkillsDiscoveryConfig adapts a *config.ConfigStore to the inputs
314// skills.DiscoverFromConfig expects.
315func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfig {
316 opts := store.Config().Options
317 var paths, disabled []string
319 paths = opts.SkillsPaths
320 disabled = opts.DisabledSkills
322 var resolver func(string) (string, error)
323 if r := store.Resolver(); r != nil {
324 resolver = r.ResolveValue
326 return skills.DiscoveryConfig{
328 DisabledSkills: disabled,
333// setupClientServerWorkspace connects to a server process and wraps the
334// result in a ClientWorkspace.
335func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
336 c, protoWs, cleanupServer, err := connectToServer(cmd)
341 clientWs := workspace.NewClientWorkspace(c, *protoWs)
343 if protoWs.Config.IsConfigured() {
344 if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
345 slog.Error("Failed to initialize coder agent", "error", err)
349 return clientWs, cleanupServer, nil
352// connectToServer ensures the server is running, creates a client and
353// workspace, and returns a cleanup function that deletes the workspace.
354func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
355 hostURL, err := server.ParseHostURL(clientHost)
357 return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
360 if err := ensureServer(cmd, hostURL); err != nil {
361 return nil, nil, nil, err
364 debug, _ := cmd.Flags().GetBool("debug")
365 yolo, _ := cmd.Flags().GetBool("yolo")
366 dataDir, _ := cmd.Flags().GetString("data-dir")
369 cwd, err := ResolveCwd(cmd)
371 return nil, nil, nil, err
374 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
376 return nil, nil, nil, err
379 wsReq := proto.Workspace{
384 Version: version.Version,
388 ws, err := c.CreateWorkspace(ctx, wsReq)
390 return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
393 if shouldEnableMetrics(ws.Config) {
397 if ws.Config != nil {
398 logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
399 crushlog.Setup(logFile, debug)
402 cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
403 return c, ws, cleanup, nil
406// ensureServer auto-starts a detached server if the socket file does not
407// exist. When the socket exists, it verifies that the running server
408// version matches the client; on mismatch it shuts down the old server
409// and starts a fresh one.
410func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
411 switch hostURL.Scheme {
412 case "unix", "npipe":
414 _, statErr := os.Stat(hostURL.Host)
417 restarted, err := restartIfStale(cmd, hostURL)
419 slog.Warn("Failed to check server version", "error", err)
421 needsStart = restarted || err != nil
422 case errors.Is(statErr, fs.ErrNotExist):
425 slog.Warn("Unexpected error stat'ing server socket, attempting cleanup",
426 "path", hostURL.Host, "error", statErr)
427 if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
428 return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
434 if err := spawnAndWaitReady(cmd, hostURL); err != nil {
435 return fmt.Errorf("failed to initialize crush server: %v", err)
440 if err := waitForServerReady(cmd.Context(), hostURL); err != nil {
441 return fmt.Errorf("failed to initialize crush server: %v", err)
448// spawnAndWaitReady serializes the spawn-and-wait-for-readiness sequence
449// across concurrent clients via an exclusive flock on
450// $XDG_CACHE_HOME/crush/server-<safeHost>/start.lock.
452// After acquiring the lock it re-probes readiness so that a client that
453// blocked while another client was spawning can skip its own spawn and
454// just use the now-running server. The lock is held only for the
455// duration of "spawn + readiness probe" and released before the caller
456// resumes its normal lifetime.
457func spawnAndWaitReady(cmd *cobra.Command, hostURL *url.URL) error {
458 chDir, err := perHostServerDir(hostURL)
462 release, err := acquireSpawnLock(filepath.Join(chDir, "start.lock"))
464 // If the lock itself is unavailable, fall back to the
465 // unsynchronized path rather than blocking the user.
466 slog.Warn("Failed to acquire spawn lock, proceeding without single-flight", "error", err)
467 if err := startDetachedServer(cmd, hostURL); err != nil {
470 return waitForServerReady(cmd.Context(), hostURL)
474 // Another client may have just finished spawning while we were
475 // waiting on the lock; if the server is already responsive, skip
476 // the spawn entirely.
477 probeCtx, cancel := context.WithTimeout(cmd.Context(), 200*time.Millisecond)
478 probeErr := quickHealthProbe(probeCtx, hostURL)
484 if err := startDetachedServer(cmd, hostURL); err != nil {
487 return waitForServerReady(cmd.Context(), hostURL)
490// quickHealthProbe issues a single readiness request with the caller's
491// context and returns nil iff the server is responsive right now.
492func quickHealthProbe(ctx context.Context, hostURL *url.URL) error {
493 httpClient, reqURL, err := readinessHTTPClient(hostURL)
497 return probeHealth(ctx, httpClient, reqURL, hostURL)
500// perHostServerDir returns (and creates) the cache directory used for
501// per-host server state (logs, start.lock, etc.). The path is derived
502// from the parsed host URL rather than the global flag so the same key
503// is computed regardless of where the host came from.
504func perHostServerDir(hostURL *url.URL) (string, error) {
505 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeHostName(hostURL))
506 if err := os.MkdirAll(chDir, 0o700); err != nil {
507 return "", fmt.Errorf("failed to create server working directory: %v", err)
512// safeHostName returns a filesystem-safe identifier for hostURL,
513// suitable for use as a directory name. It mirrors the input shape of
514// the --host flag so client and server compute the same key.
515func safeHostName(hostURL *url.URL) string {
516 return safeNameRegexp.ReplaceAllString(
517 hostURL.Scheme+"://"+hostURL.Host+hostURL.Path, "_",
521// serverReadyTimeout returns the total budget for the readiness probe.
522// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
523func serverReadyTimeout() time.Duration {
524 const def = 10 * time.Second
525 v := os.Getenv("CRUSH_SERVER_READY_TIMEOUT")
529 d, err := time.ParseDuration(v)
530 if err != nil || d <= 0 {
536// waitForServerReady polls GET /v1/health until the server responds with
537// any 2xx status or the total timeout elapses. Each attempt uses a short
538// per-attempt timeout so a hung listener doesn't burn the whole budget.
540// The HTTP transport is built to mirror how *client.Client dials so the
541// same unix socket / npipe / tcp setups all work uniformly here.
542func waitForServerReady(ctx context.Context, hostURL *url.URL) error {
543 httpClient, reqURL, err := readinessHTTPClient(hostURL)
548 const perAttempt = 100 * time.Millisecond
549 deadline := time.Now().Add(serverReadyTimeout())
553 if err := ctx.Err(); err != nil {
556 if time.Now().After(deadline) {
560 return fmt.Errorf("timed out waiting for server readiness")
563 attemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
564 err := probeHealth(attemptCtx, httpClient, reqURL, hostURL)
574 case <-time.After(perAttempt):
579// readinessHTTPClient builds an *http.Client whose transport dials the
580// server using the same scheme-aware logic as *client.Client (unix
581// socket, named pipe, or tcp).
582func readinessHTTPClient(hostURL *url.URL) (*http.Client, string, error) {
583 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
588 tr := http.DefaultTransport.(*http.Transport).Clone()
589 tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
590 return c.Dial(ctx, network, addr)
592 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
593 tr.DisableCompression = true
596 httpClient := &http.Client{Transport: tr}
598 // For unix sockets / named pipes we still need a syntactically valid
599 // HTTP URL; the actual address is resolved by the dialer.
601 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
602 host = client.DummyHost
604 reqURL := (&url.URL{Scheme: "http", Host: host, Path: "/v1/health"}).String()
605 return httpClient, reqURL, nil
608// probeHealth issues a single GET to the readiness endpoint and treats
609// any 2xx response as success.
610func probeHealth(ctx context.Context, h *http.Client, reqURL string, hostURL *url.URL) error {
611 req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
615 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
616 req.Host = client.DummyHost
618 rsp, err := h.Do(req)
622 defer rsp.Body.Close()
623 _, _ = io.Copy(io.Discard, rsp.Body)
624 if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
625 return fmt.Errorf("server health check failed: %s", rsp.Status)
630// restartIfStale checks whether the running server matches the current
631// client version. When they differ, it sends a shutdown command and
632// removes the stale socket so the caller can start a fresh server.
634// It returns restarted=true when it has shut down a stale server and the
635// caller must spawn a new one. When the server matches the client version
636// (or the check itself fails), restarted is false.
637func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err error) {
638 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
642 vi, err := c.VersionInfo(cmd.Context())
646 if vi.Version == version.Version && vi.BuildID == version.BuildID {
650 "Server version mismatch, restarting",
651 "server_version", vi.Version,
652 "client_version", version.Version,
653 "server_build_id", vi.BuildID,
654 "client_build_id", version.BuildID,
656 _ = c.ShutdownServer(cmd.Context())
657 // Give the old process a moment to release the socket.
659 if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
663 case <-cmd.Context().Done():
664 return true, cmd.Context().Err()
665 case <-time.After(100 * time.Millisecond):
668 // Force-remove if the socket is still lingering.
669 _ = os.Remove(hostURL.Host)
673var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
675func startDetachedServer(cmd *cobra.Command, hostURL *url.URL) error {
676 exe, err := os.Executable()
678 return fmt.Errorf("failed to get executable path: %v", err)
681 chDir, err := perHostServerDir(hostURL)
686 cmdArgs := []string{"server"}
687 if clientHost != server.DefaultHost() {
688 cmdArgs = append(cmdArgs, "--host", clientHost)
691 // Use context.Background() so the parent's context cancellation does not
692 // kill the spawned server. detachProcess (Setsid on !windows,
693 // DETACHED_PROCESS on windows) is what truly detaches the child from
694 // this process's lifetime.
695 c := exec.CommandContext(context.Background(), exe, cmdArgs...)
696 stdoutPath := filepath.Join(chDir, "stdout.log")
697 stderrPath := filepath.Join(chDir, "stderr.log")
700 stdout, err := os.Create(stdoutPath)
702 return fmt.Errorf("failed to create stdout log file: %v", err)
707 stderr, err := os.Create(stderrPath)
709 return fmt.Errorf("failed to create stderr log file: %v", err)
714 if err := c.Start(); err != nil {
715 return fmt.Errorf("failed to start crush server: %v", err)
718 if err := c.Process.Release(); err != nil {
719 return fmt.Errorf("failed to detach crush server process: %v", err)
725func shouldEnableMetrics(cfg *config.Config) bool {
726 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
729 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
732 if cfg.Options.DisableMetrics {
738func MaybePrependStdin(prompt string) (string, error) {
739 if term.IsTerminal(os.Stdin.Fd()) {
742 fi, err := os.Stdin.Stat()
746 // Check if stdin is a named pipe ( | ) or regular file ( < ).
747 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
750 bts, err := io.ReadAll(os.Stdin)
754 return string(bts) + "\n\n" + prompt, nil
757// resolveWorkspaceSessionID resolves a session ID that may be a full
758// UUID, full hash, or hash prefix. Works against the Workspace
759// interface so both local and client/server paths get hash prefix
761func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
762 if sess, err := ws.GetSession(ctx, id); err == nil {
766 sessions, err := ws.ListSessions(ctx)
768 return session.Session{}, err
771 var matches []session.Session
772 for _, s := range sessions {
773 hash := session.HashID(s.ID)
774 if hash == id || strings.HasPrefix(hash, id) {
775 matches = append(matches, s)
779 switch len(matches) {
781 return session.Session{}, fmt.Errorf("session not found: %s", id)
783 return matches[0], nil
785 return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
789func ResolveCwd(cmd *cobra.Command) (string, error) {
790 cwd, _ := cmd.Flags().GetString("cwd")
794 return "", fmt.Errorf("failed to change directory: %v", err)
798 cwd, err := os.Getwd()
800 return "", fmt.Errorf("failed to get current working directory: %v", err)
805func createDotCrushDir(dir string) error {
806 if err := os.MkdirAll(dir, 0o700); err != nil {
807 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
810 gitIgnorePath := filepath.Join(dir, ".gitignore")
811 content, err := os.ReadFile(gitIgnorePath)
813 // create or update if old version
814 if os.IsNotExist(err) || string(content) == oldGitIgnore {
815 if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
816 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
823//go:embed gitignore/old
824var oldGitIgnore string
826//go:embed gitignore/default
827var defaultGitIgnore string
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list