@@ -21,17 +21,22 @@ import (
21 fang "charm.land/fang/v2"
21 fang "charm.land/fang/v2"
22 "charm.land/lipgloss/v2"
22 "charm.land/lipgloss/v2"
23 "github.com/charmbracelet/colorprofile"
23 "github.com/charmbracelet/colorprofile"
24+ "github.com/charmbracelet/crush/internal/app"
24 "github.com/charmbracelet/crush/internal/client"
25 "github.com/charmbracelet/crush/internal/client"
25 "github.com/charmbracelet/crush/internal/config"
26 "github.com/charmbracelet/crush/internal/config"
27+ "github.com/charmbracelet/crush/internal/db"
26 "github.com/charmbracelet/crush/internal/event"
28 "github.com/charmbracelet/crush/internal/event"
27 crushlog "github.com/charmbracelet/crush/internal/log"
29 crushlog "github.com/charmbracelet/crush/internal/log"
30+ "github.com/charmbracelet/crush/internal/projects"
28 "github.com/charmbracelet/crush/internal/proto"
31 "github.com/charmbracelet/crush/internal/proto"
29 "github.com/charmbracelet/crush/internal/server"
32 "github.com/charmbracelet/crush/internal/server"
33+ "github.com/charmbracelet/crush/internal/session"
30 "github.com/charmbracelet/crush/internal/ui/common"
34 "github.com/charmbracelet/crush/internal/ui/common"
31 ui "github.com/charmbracelet/crush/internal/ui/model"
35 ui "github.com/charmbracelet/crush/internal/ui/model"
32 "github.com/charmbracelet/crush/internal/version"
36 "github.com/charmbracelet/crush/internal/version"
33 "github.com/charmbracelet/crush/internal/workspace"
37 "github.com/charmbracelet/crush/internal/workspace"
34 uv "github.com/charmbracelet/ultraviolet"
38 uv "github.com/charmbracelet/ultraviolet"
39+ "github.com/charmbracelet/x/ansi"
35 "github.com/charmbracelet/x/exp/charmtone"
40 "github.com/charmbracelet/x/exp/charmtone"
36 "github.com/charmbracelet/x/term"
41 "github.com/charmbracelet/x/term"
37 "github.com/spf13/cobra"
42 "github.com/spf13/cobra"
@@ -96,15 +101,14 @@ crush --continue
96 sessionID, _ := cmd.Flags().GetString("session")
101 sessionID, _ := cmd.Flags().GetString("session")
97 continueLast, _ := cmd.Flags().GetBool("continue")
102 continueLast, _ := cmd.Flags().GetBool("continue")
99− c, ws, cleanup, err := connectToServer(cmd)
104+ ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
100 if err != nil {
105 if err != nil {
101 return err
106 return err
103 defer cleanup()
108 defer cleanup()
105− // Resolve session ID if provided.
106 if sessionID != "" {
110 if sessionID != "" {
107− sess, err := resolveSessionByID(cmd.Context(), c, ws.ID, sessionID)
111+ sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
108 if err != nil {
112 if err != nil {
109 return err
113 return err
@@ -113,15 +117,7 @@ crush --continue
114 event.AppInitialized()
118 event.AppInitialized()
116− clientWs := workspace.NewClientWorkspace(c, *ws)
118− if ws.Config.IsConfigured() {
119− if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
120− slog.Error("Failed to initialize coder agent", "error", err)
124− com := common.DefaultCommon(clientWs)
120+ com := common.DefaultCommon(ws)
125 model := ui.New(com, sessionID, continueLast)
121 model := ui.New(com, sessionID, continueLast)
127 var env uv.Environ = os.Environ()
123 var env uv.Environ = os.Environ()
@@ -131,7 +127,7 @@ crush --continue
131 tea.WithContext(cmd.Context()),
127 tea.WithContext(cmd.Context()),
132 tea.WithFilter(ui.MouseEventFilter),
128 tea.WithFilter(ui.MouseEventFilter),
134− go clientWs.Subscribe(program)
130+ go ws.Subscribe(program)
136 if _, err := program.Run(); err != nil {
132 if _, err := program.Run(); err != nil {
137 event.Error(err)
133 event.Error(err)
@@ -197,6 +193,120 @@ func supportsProgressBar() bool {
197 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
193 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
196+// useClientServer returns true when the client/server architecture is
197+// enabled via the CRUSH_CLIENT_SERVER environment variable.
198+func useClientServer() bool {
199+ v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
203+// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
204+// terminal progress bar shown during initialization.
205+func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
206+ showProgress := supportsProgressBar()
208+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
211+ ws, cleanup, err := setupWorkspace(cmd)
214+ _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
217+ return ws, cleanup, err
220+// setupWorkspace returns a Workspace and cleanup function. When
221+// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
222+// ClientWorkspace. Otherwise it creates an in-process app.App and
223+// returns an AppWorkspace.
224+func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
225+ if useClientServer() {
226+ return setupClientServerWorkspace(cmd)
228+ return setupLocalWorkspace(cmd)
231+// setupLocalWorkspace creates an in-process app.App and wraps it in an
233+func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
234+ debug, _ := cmd.Flags().GetBool("debug")
235+ yolo, _ := cmd.Flags().GetBool("yolo")
236+ dataDir, _ := cmd.Flags().GetString("data-dir")
237+ ctx := cmd.Context()
239+ cwd, err := ResolveCwd(cmd)
241+ return nil, nil, err
244+ store, err := config.Init(cwd, dataDir, debug)
246+ return nil, nil, err
249+ cfg := store.Config()
250+ store.Overrides().SkipPermissionRequests = yolo
252+ if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
253+ return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
256+ gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
257+ if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
258+ if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
259+ return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
263+ if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
264+ slog.Warn("Failed to register project", "error", err)
267+ conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
269+ return nil, nil, err
272+ logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
273+ crushlog.Setup(logFile, debug)
275+ appInstance, err := app.New(ctx, conn, store)
278+ slog.Error("Failed to create app instance", "error", err)
279+ return nil, nil, err
282+ if shouldEnableMetrics(cfg) {
286+ ws := workspace.NewAppWorkspace(appInstance, store)
287+ cleanup := func() { appInstance.Shutdown() }
288+ return ws, cleanup, nil
291+// setupClientServerWorkspace connects to a server process and wraps the
292+// result in a ClientWorkspace.
293+func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
294+ c, protoWs, cleanupServer, err := connectToServer(cmd)
296+ return nil, nil, err
299+ clientWs := workspace.NewClientWorkspace(c, *protoWs)
301+ if protoWs.Config.IsConfigured() {
302+ if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
303+ slog.Error("Failed to initialize coder agent", "error", err)
307+ return clientWs, cleanupServer, nil
200// connectToServer ensures the server is running, creates a client and
310// connectToServer ensures the server is running, creates a client and
201// workspace, and returns a cleanup function that deletes the workspace.
311// workspace, and returns a cleanup function that deletes the workspace.
202func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
312func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
@@ -426,6 +536,38 @@ func MaybePrependStdin(prompt string) (string, error) {
426 return string(bts) + "\n\n" + prompt, nil
536 return string(bts) + "\n\n" + prompt, nil
539+// resolveWorkspaceSessionID resolves a session ID that may be a full
540+// UUID, full hash, or hash prefix. Works against the Workspace
541+// interface so both local and client/server paths get hash prefix
543+func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
544+ if sess, err := ws.GetSession(ctx, id); err == nil {
548+ sessions, err := ws.ListSessions(ctx)
550+ return session.Session{}, err
553+ var matches []session.Session
554+ for _, s := range sessions {
555+ hash := session.HashID(s.ID)
556+ if hash == id || strings.HasPrefix(hash, id) {
557+ matches = append(matches, s)
561+ switch len(matches) {
563+ return session.Session{}, fmt.Errorf("session not found: %s", id)
565+ return matches[0], nil
567+ return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
429func ResolveCwd(cmd *cobra.Command) (string, error) {
571func ResolveCwd(cmd *cobra.Command) (string, error) {
430 cwd, _ := cmd.Flags().GetString("cwd")
572 cwd, _ := cmd.Flags().GetString("cwd")
431 if cwd != "" {
573 if cwd != "" {