diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 75b8023df6416ebfe776ce5daac758d1b7c38bef..cf4a9b535b0522833cabc9a6906dcdd7c0193124 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -30,11 +30,11 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + - uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: languages: ${{ matrix.language }} - - uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 - - uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + - uses: github/codeql-action/autobuild@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 + - uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 grype: runs-on: ubuntu-latest @@ -52,7 +52,7 @@ jobs: path: "." fail-build: true severity-cutoff: critical - - uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + - uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: ${{ steps.scan.outputs.sarif }} @@ -73,7 +73,7 @@ jobs: - name: Run govulncheck run: | govulncheck -C . -format sarif ./... > results.sarif - - uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 + - uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0 with: sarif_file: results.sarif @@ -86,7 +86,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3 + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 with: fail-on-severity: critical allow-licenses: BSD-2-Clause, BSD-3-Clause, MIT, MIT-0, Apache-2.0, MPL-2.0, ISC, LicenseRef-scancode-google-patent-license-golang, Unlicense diff --git a/Taskfile.yaml b/Taskfile.yaml index 476626fde4f0ed33d26fa20c2dc8b00ecd557af6..38e8a16313d17b9b1826ce4b6f055d39537916ec 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -184,3 +184,8 @@ tasks: - go get charm.land/fantasy - go get charm.land/catwalk - go mod tidy + + sqlc: + desc: Generate code using SQLC + cmds: + - sqlc generate diff --git a/go.mod b/go.mod index 020a189ad20b04794303efc7f00fde56e7f99cc3..95ab2ac117ac88f43b79673c64b388c4f4a4a0e6 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.1 require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.2 - charm.land/catwalk v0.30.0 + charm.land/catwalk v0.30.3 charm.land/fang/v2 v2.0.1 charm.land/fantasy v0.12.3 charm.land/glamour/v2 v2.0.0 diff --git a/go.sum b/go.sum index efb4088af550edb8231171d62520097c3358b950..694482d558aa46357e88f357a213e48c06e41d6c 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= -charm.land/catwalk v0.30.0 h1:CgWN3hM0HYYXlxMFsd1mfpBliqvx9gH3LolVi1VfMSU= -charm.land/catwalk v0.30.0/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg= +charm.land/catwalk v0.30.3 h1:eCRwVoi1znrNGYiPZoBIbWt8+Q4kDhT3zziqnPO3s2Y= +charm.land/catwalk v0.30.3/go.mod h1:+fqw/6YGNtvapvPy9vhwA/fAMxVjD2K8hVIKYov8Vhg= charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= charm.land/fantasy v0.12.3 h1:gvqRWD7vWmpNN0VcQ+rSku5QdTlLegqrlJDVCDdAh58= diff --git a/internal/app/app.go b/internal/app/app.go index 32754c6251fdd5c97d848c86d3cfbd489c9d94d2..890b1ce7765728f78adba8c47a4bbbe87c29131c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -171,9 +171,40 @@ func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] { return app.agentNotifications } +// resolveSession resolves which session to use for a non-interactive run +// If continueSessionID is set, it looks up that session by ID +// If useLast is set, it returns the most recently updated top-level session +// Otherwise, it creates a new session +func (app *App) resolveSession(ctx context.Context, continueSessionID string, useLast bool) (session.Session, error) { + switch { + case continueSessionID != "": + if app.Sessions.IsAgentToolSession(continueSessionID) { + return session.Session{}, fmt.Errorf("cannot continue an agent tool session: %s", continueSessionID) + } + sess, err := app.Sessions.Get(ctx, continueSessionID) + if err != nil { + return session.Session{}, fmt.Errorf("session not found: %s", continueSessionID) + } + if sess.ParentSessionID != "" { + return session.Session{}, fmt.Errorf("cannot continue a child session: %s", continueSessionID) + } + return sess, nil + + case useLast: + sess, err := app.Sessions.GetLast(ctx) + if err != nil { + return session.Session{}, fmt.Errorf("no sessions found to continue") + } + return sess, nil + + default: + return app.Sessions.Create(ctx, agent.DefaultSessionName) + } +} + // RunNonInteractive runs the application in non-interactive mode with the // given prompt, printing to stdout. -func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error { +func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool, continueSessionID string, useLast bool) error { slog.Info("Running in non-interactive mode") ctx, cancel := context.WithCancel(ctx) @@ -241,11 +272,16 @@ func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, defer stopSpinner() - sess, err := app.Sessions.Create(ctx, agent.DefaultSessionName) + sess, err := app.resolveSession(ctx, continueSessionID, useLast) if err != nil { return fmt.Errorf("failed to create session for non-interactive mode: %w", err) } - slog.Info("Created session for non-interactive run", "session_id", sess.ID) + + if continueSessionID != "" || useLast { + slog.Info("Continuing session for non-interactive run", "session_id", sess.ID) + } else { + slog.Info("Created session for non-interactive run", "session_id", sess.ID) + } // Automatically approve all permission requests for this non-interactive // session. diff --git a/internal/app/resolve_session_test.go b/internal/app/resolve_session_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9b0c7af736fa9637c095c7851da3460bacf737a2 --- /dev/null +++ b/internal/app/resolve_session_test.go @@ -0,0 +1,174 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "strings" + "testing" + + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/session" + "github.com/stretchr/testify/require" +) + +// mockSessionService is a minimal mock of session.Service for testing resolveSession. +type mockSessionService struct { + sessions []session.Session + created []session.Session +} + +func (m *mockSessionService) Subscribe(context.Context) <-chan pubsub.Event[session.Session] { + return make(chan pubsub.Event[session.Session]) +} + +func (m *mockSessionService) Create(_ context.Context, title string) (session.Session, error) { + s := session.Session{ID: "new-session-id", Title: title} + m.created = append(m.created, s) + return s, nil +} + +func (m *mockSessionService) CreateTitleSession(context.Context, string) (session.Session, error) { + return session.Session{}, nil +} + +func (m *mockSessionService) CreateTaskSession(context.Context, string, string, string) (session.Session, error) { + return session.Session{}, nil +} + +func (m *mockSessionService) Get(_ context.Context, id string) (session.Session, error) { + for _, s := range m.sessions { + if s.ID == id { + return s, nil + } + } + return session.Session{}, sql.ErrNoRows +} + +func (m *mockSessionService) GetLast(_ context.Context) (session.Session, error) { + if len(m.sessions) > 0 { + return m.sessions[0], nil + } + return session.Session{}, sql.ErrNoRows +} + +func (m *mockSessionService) List(context.Context) ([]session.Session, error) { + return m.sessions, nil +} + +func (m *mockSessionService) Save(_ context.Context, s session.Session) (session.Session, error) { + return s, nil +} + +func (m *mockSessionService) UpdateTitleAndUsage(context.Context, string, string, int64, int64, float64) error { + return nil +} + +func (m *mockSessionService) Rename(context.Context, string, string) error { + return nil +} + +func (m *mockSessionService) Delete(context.Context, string) error { + return nil +} + +func (m *mockSessionService) CreateAgentToolSessionID(messageID, toolCallID string) string { + return fmt.Sprintf("%s$$%s", messageID, toolCallID) +} + +func (m *mockSessionService) ParseAgentToolSessionID(sessionID string) (string, string, bool) { + parts := strings.Split(sessionID, "$$") + if len(parts) != 2 { + return "", "", false + } + return parts[0], parts[1], true +} + +func (m *mockSessionService) IsAgentToolSession(sessionID string) bool { + _, _, ok := m.ParseAgentToolSessionID(sessionID) + return ok +} + +func newTestApp(sessions session.Service) *App { + return &App{Sessions: sessions} +} + +func TestResolveSession_NewSession(t *testing.T) { + mock := &mockSessionService{} + app := newTestApp(mock) + + sess, err := app.resolveSession(t.Context(), "", false) + require.NoError(t, err) + require.Equal(t, "new-session-id", sess.ID) + require.Len(t, mock.created, 1) +} + +func TestResolveSession_ContinueByID(t *testing.T) { + mock := &mockSessionService{ + sessions: []session.Session{ + {ID: "existing-id", Title: "Old session"}, + }, + } + app := newTestApp(mock) + + sess, err := app.resolveSession(t.Context(), "existing-id", false) + require.NoError(t, err) + require.Equal(t, "existing-id", sess.ID) + require.Equal(t, "Old session", sess.Title) + require.Empty(t, mock.created) +} + +func TestResolveSession_ContinueByID_NotFound(t *testing.T) { + mock := &mockSessionService{} + app := newTestApp(mock) + + _, err := app.resolveSession(t.Context(), "nonexistent", false) + require.Error(t, err) + require.Contains(t, err.Error(), "session not found") +} + +func TestResolveSession_ContinueByID_ChildSession(t *testing.T) { + mock := &mockSessionService{ + sessions: []session.Session{ + {ID: "child-id", ParentSessionID: "parent-id", Title: "Child session"}, + }, + } + app := newTestApp(mock) + + _, err := app.resolveSession(t.Context(), "child-id", false) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot continue a child session") +} + +func TestResolveSession_ContinueByID_AgentToolSession(t *testing.T) { + mock := &mockSessionService{} + app := newTestApp(mock) + + _, err := app.resolveSession(t.Context(), "msg123$$tool456", false) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot continue an agent tool session") +} + +func TestResolveSession_Last(t *testing.T) { + mock := &mockSessionService{ + sessions: []session.Session{ + {ID: "most-recent", Title: "Latest session"}, + {ID: "older", Title: "Older session"}, + }, + } + app := newTestApp(mock) + + sess, err := app.resolveSession(t.Context(), "", true) + require.NoError(t, err) + require.Equal(t, "most-recent", sess.ID) + require.Empty(t, mock.created) +} + +func TestResolveSession_Last_NoSessions(t *testing.T) { + mock := &mockSessionService{} + app := newTestApp(mock) + + _, err := app.resolveSession(t.Context(), "", true) + require.Error(t, err) + require.Contains(t, err.Error(), "no sessions found") +} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 50264515dc7e5fd247f374e3673b306b5be196d0..6b2b18c7414da89d34c7b306525887cea07ac9ad 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -17,6 +17,7 @@ import ( "github.com/charmbracelet/crush/internal/format" "github.com/charmbracelet/crush/internal/proto" "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/anim" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" @@ -48,12 +49,23 @@ crush run --quiet "Generate a README for this project" # Run in verbose mode (show logs) crush run --verbose "Generate a README for this project" + +# Continue a previous session +crush run --session {session-id} "Follow up on your last response" + +# Continue the most recent session +crush run --continue "Follow up on your last response" + `, RunE: func(cmd *cobra.Command, args []string) error { - quiet, _ := cmd.Flags().GetBool("quiet") - verbose, _ := cmd.Flags().GetBool("verbose") - largeModel, _ := cmd.Flags().GetString("model") - smallModel, _ := cmd.Flags().GetString("small-model") + var ( + quiet, _ = cmd.Flags().GetBool("quiet") + verbose, _ = cmd.Flags().GetBool("verbose") + largeModel, _ = cmd.Flags().GetString("model") + smallModel, _ = cmd.Flags().GetString("small-model") + sessionID, _ = cmd.Flags().GetString("session") + useLast, _ = cmd.Flags().GetBool("continue") + ) // Cancel on SIGINT or SIGTERM. ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) @@ -65,6 +77,14 @@ crush run --verbose "Generate a README for this project" } defer cleanup() + if sessionID != "" { + sess, err := resolveSessionByID(ctx, c, ws.ID, sessionID) + if err != nil { + return err + } + sessionID = sess.ID + } + if !ws.Config.IsConfigured() { return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively") } @@ -88,7 +108,14 @@ crush run --verbose "Generate a README for this project" event.SetNonInteractive(true) event.AppInitialized() - return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose) + switch { + case sessionID != "": + event.SetContinueBySessionID(true) + case useLast: + event.SetContinueLastSession(true) + } + + return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast) }, } @@ -97,6 +124,9 @@ func init() { runCmd.Flags().BoolP("verbose", "v", false, "Show logs") runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers") runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider") + runCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID") + runCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session") + runCmd.MarkFlagsMutuallyExclusive("session", "continue") } // runNonInteractive executes the agent via the server and streams output @@ -107,6 +137,8 @@ func runNonInteractive( ws *proto.Workspace, prompt, largeModel, smallModel string, hideSpinner bool, + continueSessionID string, + useLast bool, ) error { slog.Info("Running in non-interactive mode") @@ -172,11 +204,15 @@ func runNonInteractive( defer stopSpinner() - sess, err := c.CreateSession(ctx, ws.ID, "non-interactive") + sess, err := resolveSession(ctx, c, ws.ID, continueSessionID, useLast) if err != nil { - return fmt.Errorf("failed to create session: %w", err) + return fmt.Errorf("failed to resolve session: %w", err) + } + if continueSessionID != "" || useLast { + slog.Info("Continuing session for non-interactive run", "session_id", sess.ID) + } else { + slog.Info("Created session for non-interactive run", "session_id", sess.ID) } - slog.Info("Created session for non-interactive run", "session_id", sess.ID) events, err := c.SubscribeEvents(ctx, ws.ID) if err != nil { @@ -407,3 +443,67 @@ func validateModelMatches(matches []modelMatch, modelID, label string) (modelMat } return matches[0], nil } + +// resolveSession returns the session to use for a non-interactive run. +// If continueSessionID is set it fetches that session; if useLast is set it +// returns the most recently updated top-level session; otherwise it creates a +// new one. +func resolveSession(ctx context.Context, c *client.Client, wsID, continueSessionID string, useLast bool) (*session.Session, error) { + switch { + case continueSessionID != "": + sess, err := c.GetSession(ctx, wsID, continueSessionID) + if err != nil { + return nil, fmt.Errorf("session not found: %s", continueSessionID) + } + if sess.ParentSessionID != "" { + return nil, fmt.Errorf("cannot continue a child session: %s", continueSessionID) + } + return sess, nil + + case useLast: + sessions, err := c.ListSessions(ctx, wsID) + if err != nil || len(sessions) == 0 { + return nil, fmt.Errorf("no sessions found to continue") + } + last := sessions[0] + for _, s := range sessions[1:] { + if s.UpdatedAt > last.UpdatedAt && s.ParentSessionID == "" { + last = s + } + } + return &last, nil + + default: + return c.CreateSession(ctx, wsID, "non-interactive") + } +} + +// resolveSessionByID resolves a session ID that may be a full UUID or a hash +// prefix returned by crush session list. +func resolveSessionByID(ctx context.Context, c *client.Client, wsID, id string) (*session.Session, error) { + if sess, err := c.GetSession(ctx, wsID, id); err == nil { + return sess, nil + } + + sessions, err := c.ListSessions(ctx, wsID) + if err != nil { + return nil, err + } + + var matches []session.Session + for _, s := range sessions { + hash := session.HashID(s.ID) + if hash == id || strings.HasPrefix(hash, id) { + matches = append(matches, s) + } + } + + switch len(matches) { + case 0: + return nil, fmt.Errorf("session %q not found", id) + case 1: + return &matches[0], nil + default: + return nil, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches)) + } +} diff --git a/internal/cmd/session.go b/internal/cmd/session.go index 67f40a0cea7442efe5d36600dc27260ab66b2320..f4267f7d57300ac91504adbc0da01ff149b71412 100644 --- a/internal/cmd/session.go +++ b/internal/cmd/session.go @@ -17,6 +17,7 @@ import ( "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" + "github.com/charmbracelet/crush/internal/event" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/chat" @@ -126,6 +127,9 @@ func sessionSetup(cmd *cobra.Command) (context.Context, *sessionServices, func() } func runSessionList(cmd *cobra.Command, _ []string) error { + event.SetNonInteractive(true) + event.SessionListed(sessionListJSON) + ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err @@ -249,6 +253,9 @@ func resolveSessionID(ctx context.Context, svc session.Service, id string) (sess } func runSessionShow(cmd *cobra.Command, args []string) error { + event.SetNonInteractive(true) + event.SessionShown(sessionShowJSON) + ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err @@ -273,6 +280,9 @@ func runSessionShow(cmd *cobra.Command, args []string) error { } func runSessionDelete(cmd *cobra.Command, args []string) error { + event.SetNonInteractive(true) + event.SessionDeletedCommand(sessionDeleteJSON) + ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err @@ -305,6 +315,9 @@ func runSessionDelete(cmd *cobra.Command, args []string) error { } func runSessionRename(cmd *cobra.Command, args []string) error { + event.SetNonInteractive(true) + event.SessionRenamed(sessionRenameJSON) + ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err @@ -338,6 +351,9 @@ func runSessionRename(cmd *cobra.Command, args []string) error { } func runSessionLast(cmd *cobra.Command, _ []string) error { + event.SetNonInteractive(true) + event.SessionLastShown(sessionLastJSON) + ctx, svc, cleanup, err := sessionSetup(cmd) if err != nil { return err diff --git a/internal/config/config.go b/internal/config/config.go index c7ca5507c981e04745321f14506550ed52467494..9a34f0fcfdaa4c5533b5e72ec5d759257f740055 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -592,6 +592,12 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error { return nil } return errors.New("not a valid bedrock api key") + case catwalk.TypeVercel: + // NOTE: Vercel does not validate API keys on the `/models` endpoint. + if strings.HasPrefix(apiKey, "vck_") { // Vercel API keys + return nil + } + return errors.New("not a valid vercel api key") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/internal/db/db.go b/internal/db/db.go index dbde2e493eea4c262aef55ef7dcadd904a1b9d65..fa3c5ac5aad27ab1929e306cd50fdb7dba493ea0 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -63,6 +63,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getHourDayHeatmapStmt, err = db.PrepareContext(ctx, getHourDayHeatmap); err != nil { return nil, fmt.Errorf("error preparing query GetHourDayHeatmap: %w", err) } + if q.getLastSessionStmt, err = db.PrepareContext(ctx, getLastSession); err != nil { + return nil, fmt.Errorf("error preparing query GetLastSession: %w", err) + } if q.getMessageStmt, err = db.PrepareContext(ctx, getMessage); err != nil { return nil, fmt.Errorf("error preparing query GetMessage: %w", err) } @@ -202,6 +205,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getHourDayHeatmapStmt: %w", cerr) } } + if q.getLastSessionStmt != nil { + if cerr := q.getLastSessionStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getLastSessionStmt: %w", cerr) + } + } if q.getMessageStmt != nil { if cerr := q.getMessageStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getMessageStmt: %w", cerr) @@ -369,6 +377,7 @@ type Queries struct { getFileByPathAndSessionStmt *sql.Stmt getFileReadStmt *sql.Stmt getHourDayHeatmapStmt *sql.Stmt + getLastSessionStmt *sql.Stmt getMessageStmt *sql.Stmt getRecentActivityStmt *sql.Stmt getSessionByIDStmt *sql.Stmt @@ -411,6 +420,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getFileByPathAndSessionStmt: q.getFileByPathAndSessionStmt, getFileReadStmt: q.getFileReadStmt, getHourDayHeatmapStmt: q.getHourDayHeatmapStmt, + getLastSessionStmt: q.getLastSessionStmt, getMessageStmt: q.getMessageStmt, getRecentActivityStmt: q.getRecentActivityStmt, getSessionByIDStmt: q.getSessionByIDStmt, diff --git a/internal/db/querier.go b/internal/db/querier.go index ae91927aedf797f84f347e7e14a93327120a847e..9031505a3db825f2c21d83e005046323bde3a6c2 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -22,6 +22,7 @@ type Querier interface { GetFileByPathAndSession(ctx context.Context, arg GetFileByPathAndSessionParams) (File, error) GetFileRead(ctx context.Context, arg GetFileReadParams) (ReadFile, error) GetHourDayHeatmap(ctx context.Context) ([]GetHourDayHeatmapRow, error) + GetLastSession(ctx context.Context) (Session, error) GetMessage(ctx context.Context, id string) (Message, error) GetRecentActivity(ctx context.Context) ([]GetRecentActivityRow, error) GetSessionByID(ctx context.Context, id string) (Session, error) diff --git a/internal/db/sessions.sql.go b/internal/db/sessions.sql.go index bdcddd01d9bdb95034a9a669e6881eed661dee10..685948e60e84ec4df66e4d5d1c9645a9ff1fb43f 100644 --- a/internal/db/sessions.sql.go +++ b/internal/db/sessions.sql.go @@ -83,6 +83,32 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error { return err } +const getLastSession = `-- name: GetLastSession :one +SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos +FROM sessions +ORDER BY updated_at DESC +LIMIT 1 +` + +func (q *Queries) GetLastSession(ctx context.Context) (Session, error) { + row := q.queryRow(ctx, q.getLastSessionStmt, getLastSession) + var i Session + err := row.Scan( + &i.ID, + &i.ParentSessionID, + &i.Title, + &i.MessageCount, + &i.PromptTokens, + &i.CompletionTokens, + &i.Cost, + &i.UpdatedAt, + &i.CreatedAt, + &i.SummaryMessageID, + &i.Todos, + ) + return i, err +} + const getSessionByID = `-- name: GetSessionByID :one SELECT id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, updated_at, created_at, summary_message_id, todos FROM sessions diff --git a/internal/db/sql/sessions.sql b/internal/db/sql/sessions.sql index 0e170fdeb270041c035c7f2ea24aaa4b571b4387..44c1609ecfbc3867bea827088fcbcff6e718427b 100644 --- a/internal/db/sql/sessions.sql +++ b/internal/db/sql/sessions.sql @@ -28,6 +28,12 @@ SELECT * FROM sessions WHERE id = ? LIMIT 1; +-- name: GetLastSession :one +SELECT * +FROM sessions +ORDER BY updated_at DESC +LIMIT 1; + -- name: ListSessions :many SELECT * FROM sessions diff --git a/internal/event/all.go b/internal/event/all.go index 713421a0186fad28137ac68fabb8d594c305d2e9..ea1e4c5ccb79a6aa70e468d03dc8e5e241909433 100644 --- a/internal/event/all.go +++ b/internal/event/all.go @@ -61,3 +61,23 @@ func TokensUsed(props ...any) { func StatsViewed() { send("stats viewed") } + +func SessionListed(json bool) { + send("session listed", "json", json) +} + +func SessionShown(json bool) { + send("session shown", "json", json) +} + +func SessionLastShown(json bool) { + send("session last shown", "json", json) +} + +func SessionDeletedCommand(json bool) { + send("session deleted", "json", json) +} + +func SessionRenamed(json bool) { + send("session renamed", "json", json) +} diff --git a/internal/event/event.go b/internal/event/event.go index 516df804fbbca1bc03212b2c3cf26a38efab6979..a10b5d82b7fdf13ccf02fd0967cab2ce65e20661 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -17,7 +17,9 @@ const ( endpoint = "https://data.charm.land" key = "phc_4zt4VgDWLqbYnJYEwLRxFoaTL2noNrQij0C6E8k3I0V" - nonInteractiveEventName = "NonInteractive" + nonInteractiveAttrName = "NonInteractive" + continueSessionByIDAttrName = "ContinueSessionByID" + continueLastSessionAttrName = "ContinueLastSession" ) var ( @@ -30,11 +32,19 @@ var ( Set("SHELL", filepath.Base(os.Getenv("SHELL"))). Set("Version", version.Version). Set("GoVersion", runtime.Version()). - Set(nonInteractiveEventName, false) + Set(nonInteractiveAttrName, false) ) func SetNonInteractive(nonInteractive bool) { - baseProps = baseProps.Set(nonInteractiveEventName, nonInteractive) + baseProps = baseProps.Set(nonInteractiveAttrName, nonInteractive) +} + +func SetContinueBySessionID(continueBySessionID bool) { + baseProps = baseProps.Set(continueSessionByIDAttrName, continueBySessionID) +} + +func SetContinueLastSession(continueLastSession bool) { + baseProps = baseProps.Set(continueLastSessionAttrName, continueLastSession) } func Init() { diff --git a/internal/session/session.go b/internal/session/session.go index 834243b62aae6266290147ca0d0270a6069e34b3..66bd9f4c9a12916d02c6d22ed7d51f81d74efdfd 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -66,6 +66,7 @@ type Service interface { CreateTitleSession(ctx context.Context, parentSessionID string) (Session, error) CreateTaskSession(ctx context.Context, toolCallID, parentSessionID, title string) (Session, error) Get(ctx context.Context, id string) (Session, error) + GetLast(ctx context.Context) (Session, error) List(ctx context.Context) ([]Session, error) Save(ctx context.Context, session Session) (Session, error) UpdateTitleAndUsage(ctx context.Context, sessionID, title string, promptTokens, completionTokens int64, cost float64) error @@ -166,6 +167,14 @@ func (s *service) Get(ctx context.Context, id string) (Session, error) { return s.fromDBItem(dbSession), nil } +func (s *service) GetLast(ctx context.Context) (Session, error) { + dbSession, err := s.q.GetLastSession(ctx) + if err != nil { + return Session{}, err + } + return s.fromDBItem(dbSession), nil +} + func (s *service) Save(ctx context.Context, session Session) (Session, error) { todosJSON, err := marshalTodos(session.Todos) if err != nil { diff --git a/internal/ui/chat/agent.go b/internal/ui/chat/agent.go index e8840cc53be358010dc006aef6961290902b5983..882971290d7af78e21add7663f993731c618a967 100644 --- a/internal/ui/chat/agent.go +++ b/internal/ui/chat/agent.go @@ -101,7 +101,7 @@ type AgentToolRenderContext struct { func (r *AgentToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.agent.nestedTools) == 0 { - return pendingTool(sty, "Agent", opts.Anim) + return pendingTool(sty, "Agent", opts.Anim, opts.Compact) } var params agent.AgentParams @@ -232,7 +232,7 @@ type agenticFetchParams struct { func (r *AgenticFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if !opts.ToolCall.Finished && !opts.IsCanceled() && len(r.fetch.nestedTools) == 0 { - return pendingTool(sty, "Agentic Fetch", opts.Anim) + return pendingTool(sty, "Agentic Fetch", opts.Anim, opts.Compact) } var params agenticFetchParams diff --git a/internal/ui/chat/bash.go b/internal/ui/chat/bash.go index 18be27ee01b4fcc21749789fc65ec0b71c2b0d4b..a6c2554768bd8dcbc986f0aa616ed4dcbc5ff046 100644 --- a/internal/ui/chat/bash.go +++ b/internal/ui/chat/bash.go @@ -41,7 +41,7 @@ type BashToolRenderContext struct{} func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Bash", opts.Anim) + return pendingTool(sty, "Bash", opts.Anim, opts.Compact) } var params tools.BashParams @@ -123,7 +123,7 @@ type JobOutputToolRenderContext struct{} func (j *JobOutputToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Job", opts.Anim) + return pendingTool(sty, "Job", opts.Anim, opts.Compact) } var params tools.JobOutputParams @@ -174,7 +174,7 @@ type JobKillToolRenderContext struct{} func (j *JobKillToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Job", opts.Anim) + return pendingTool(sty, "Job", opts.Anim, opts.Compact) } var params tools.JobKillParams diff --git a/internal/ui/chat/diagnostics.go b/internal/ui/chat/diagnostics.go index 68d2ac4a00dc880c27904468008fb8f6b2fcf9c5..e31d31832ed34ffa38226b64dd35818f7bf6cd0f 100644 --- a/internal/ui/chat/diagnostics.go +++ b/internal/ui/chat/diagnostics.go @@ -37,7 +37,7 @@ type DiagnosticsToolRenderContext struct{} func (d *DiagnosticsToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Diagnostics", opts.Anim) + return pendingTool(sty, "Diagnostics", opts.Anim, opts.Compact) } var params tools.DiagnosticsParams diff --git a/internal/ui/chat/fetch.go b/internal/ui/chat/fetch.go index e3f3a809550385dfd0ec557e98151ffc731acc93..55c730305e150b5feb3266ea1bce5a91c2013c6c 100644 --- a/internal/ui/chat/fetch.go +++ b/internal/ui/chat/fetch.go @@ -36,7 +36,7 @@ type FetchToolRenderContext struct{} func (f *FetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Fetch", opts.Anim) + return pendingTool(sty, "Fetch", opts.Anim, opts.Compact) } var params tools.FetchParams @@ -111,7 +111,7 @@ type WebFetchToolRenderContext struct{} func (w *WebFetchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Fetch", opts.Anim) + return pendingTool(sty, "Fetch", opts.Anim, opts.Compact) } var params tools.WebFetchParams @@ -165,7 +165,7 @@ type WebSearchToolRenderContext struct{} func (w *WebSearchToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Search", opts.Anim) + return pendingTool(sty, "Search", opts.Anim, opts.Compact) } var params tools.WebSearchParams diff --git a/internal/ui/chat/file.go b/internal/ui/chat/file.go index 3b1fef8530506be70e512a3cb801ed34a81e0c62..14fc5169aec1b0a238cad177a0be5bf4d6db27b0 100644 --- a/internal/ui/chat/file.go +++ b/internal/ui/chat/file.go @@ -39,7 +39,7 @@ type ViewToolRenderContext struct{} func (v *ViewToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "View", opts.Anim) + return pendingTool(sty, "View", opts.Anim, opts.Compact) } var params tools.ViewParams @@ -125,7 +125,7 @@ type WriteToolRenderContext struct{} func (w *WriteToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Write", opts.Anim) + return pendingTool(sty, "Write", opts.Anim, opts.Compact) } var params tools.WriteParams @@ -180,7 +180,7 @@ type EditToolRenderContext struct{} func (e *EditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { // Edit tool uses full width for diffs. if opts.IsPending() { - return pendingTool(sty, "Edit", opts.Anim) + return pendingTool(sty, "Edit", opts.Anim, opts.Compact) } var params tools.EditParams @@ -243,7 +243,7 @@ type MultiEditToolRenderContext struct{} func (m *MultiEditToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { // MultiEdit tool uses full width for diffs. if opts.IsPending() { - return pendingTool(sty, "Multi-Edit", opts.Anim) + return pendingTool(sty, "Multi-Edit", opts.Anim, opts.Compact) } var params tools.MultiEditParams @@ -311,7 +311,7 @@ type DownloadToolRenderContext struct{} func (d *DownloadToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Download", opts.Anim) + return pendingTool(sty, "Download", opts.Anim, opts.Compact) } var params tools.DownloadParams diff --git a/internal/ui/chat/generic.go b/internal/ui/chat/generic.go index 6b0ac433028daf7a06c57f85c7799250e9652f6f..ae4c99758cf7ea9d311019a9e46676c0f565620e 100644 --- a/internal/ui/chat/generic.go +++ b/internal/ui/chat/generic.go @@ -35,7 +35,7 @@ func (g *GenericToolRenderContext) RenderTool(sty *styles.Styles, width int, opt name := genericPrettyName(opts.ToolCall.Name) if opts.IsPending() { - return pendingTool(sty, name, opts.Anim) + return pendingTool(sty, name, opts.Anim, opts.Compact) } var params map[string]any diff --git a/internal/ui/chat/lsp_restart.go b/internal/ui/chat/lsp_restart.go index a75dce932ed5fc2da1757e4a139a93d0d7d3fab4..cd06674c335b43c25c714e79c0a9f4f7be589c2b 100644 --- a/internal/ui/chat/lsp_restart.go +++ b/internal/ui/chat/lsp_restart.go @@ -32,7 +32,7 @@ type LSPRestartToolRenderContext struct{} func (r *LSPRestartToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Restart LSP", opts.Anim) + return pendingTool(sty, "Restart LSP", opts.Anim, opts.Compact) } var params tools.LSPRestartParams diff --git a/internal/ui/chat/mcp.go b/internal/ui/chat/mcp.go index c4d124e7381a9ddaa39f56750367d3f2cf4d207f..33d72d6007f5d159d9c1983f09fb25b5e8586388 100644 --- a/internal/ui/chat/mcp.go +++ b/internal/ui/chat/mcp.go @@ -46,7 +46,7 @@ func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *T name := fmt.Sprintf("%s %s %s", mcpName, sty.Tool.MCPArrow.String(), toolName) if opts.IsPending() { - return pendingTool(sty, name, opts.Anim) + return pendingTool(sty, name, opts.Anim, opts.Compact) } var params map[string]any diff --git a/internal/ui/chat/references.go b/internal/ui/chat/references.go index 2d7efe8df3ed38bf3768d7ae13c433fc05c17418..8ae30f3d070be15cf3855eb98eee80c99a186d82 100644 --- a/internal/ui/chat/references.go +++ b/internal/ui/chat/references.go @@ -33,7 +33,7 @@ type ReferencesToolRenderContext struct{} func (r *ReferencesToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Find References", opts.Anim) + return pendingTool(sty, "Find References", opts.Anim, opts.Compact) } var params tools.ReferencesParams diff --git a/internal/ui/chat/search.go b/internal/ui/chat/search.go index 2342f671fdaed3bfdcf56619864bd3b60987d8a6..d3f8ece9c2dd4e56cb3f0fc11e2bba00f2d85421 100644 --- a/internal/ui/chat/search.go +++ b/internal/ui/chat/search.go @@ -37,7 +37,7 @@ type GlobToolRenderContext struct{} func (g *GlobToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Glob", opts.Anim) + return pendingTool(sty, "Glob", opts.Anim, opts.Compact) } var params tools.GlobParams @@ -96,7 +96,7 @@ type GrepToolRenderContext struct{} func (g *GrepToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Grep", opts.Anim) + return pendingTool(sty, "Grep", opts.Anim, opts.Compact) } var params tools.GrepParams @@ -161,7 +161,7 @@ type LSToolRenderContext struct{} func (l *LSToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "List", opts.Anim) + return pendingTool(sty, "List", opts.Anim, opts.Compact) } var params tools.LSParams @@ -221,7 +221,7 @@ type SourcegraphToolRenderContext struct{} func (s *SourcegraphToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "Sourcegraph", opts.Anim) + return pendingTool(sty, "Sourcegraph", opts.Anim, opts.Compact) } var params tools.SourcegraphParams diff --git a/internal/ui/chat/todos.go b/internal/ui/chat/todos.go index 5678d0e47f4c3a808c13c1dc6209f9194e9f9482..421964c286f0a2ae51d2bf44c8da488b92dfc6ed 100644 --- a/internal/ui/chat/todos.go +++ b/internal/ui/chat/todos.go @@ -41,7 +41,7 @@ type TodosToolRenderContext struct{} func (t *TodosToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string { cappedWidth := cappedMessageWidth(width) if opts.IsPending() { - return pendingTool(sty, "To-Do", opts.Anim) + return pendingTool(sty, "To-Do", opts.Anim, opts.Compact) } var params tools.TodosParams diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index be82a6e197b0505d83abe0c8d679b0469ed6b93a..1342cfba2f6cc1c608298e9695578ae351726160 100644 --- a/internal/ui/chat/tools.go +++ b/internal/ui/chat/tools.go @@ -424,9 +424,13 @@ func (t *baseToolMessageItem) HandleKeyEvent(key tea.KeyMsg) (bool, tea.Cmd) { } // pendingTool renders a tool that is still in progress with an animation. -func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string { +func pendingTool(sty *styles.Styles, name string, anim *anim.Anim, nested bool) string { icon := sty.Tool.IconPending.Render() - toolName := sty.Tool.NameNormal.Render(name) + nameStyle := sty.Tool.NameNormal + if nested { + nameStyle = sty.Tool.NameNested + } + toolName := nameStyle.Render(name) var animView string if anim != nil { diff --git a/internal/ui/dialog/models_list.go b/internal/ui/dialog/models_list.go index cd2f2a9570d8676ded81384a0720325c52e6e232..cec75c8e882c2db635a50ac3aa8839caa9a17601 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -216,8 +216,10 @@ func (f *ModelsList) VisibleItems() []list.Item { } matches := fuzzy.Find(query, names) + + // Sort by original index to preserve order within the group sort.SliceStable(matches, func(i, j int) bool { - return matches[i].Score > matches[j].Score + return matches[i].Index < matches[j].Index }) for _, match := range matches { diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 0870f62674f8241cd874d61532f18c3bc58dc22f..bc6d7099f66a1e4c0a9d8e7e1c05c32d6974dcec 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -254,18 +254,18 @@ type Styles struct { // Tool - styles for tool call rendering Tool struct { // Icon styles with tool status - IconPending lipgloss.Style // Pending operation icon - IconSuccess lipgloss.Style // Successful operation icon - IconError lipgloss.Style // Error operation icon - IconCancelled lipgloss.Style // Cancelled operation icon + IconPending lipgloss.Style + IconSuccess lipgloss.Style + IconError lipgloss.Style + IconCancelled lipgloss.Style // Tool name styles - NameNormal lipgloss.Style // Normal tool name - NameNested lipgloss.Style // Nested tool name + NameNormal lipgloss.Style // Top-level tool name + NameNested lipgloss.Style // Nested child tool name (inside Agent/Agentic Fetch) // Parameter list styles - ParamMain lipgloss.Style // Main parameter - ParamKey lipgloss.Style // Parameter keys + ParamMain lipgloss.Style + ParamKey lipgloss.Style // Content rendering styles ContentLine lipgloss.Style // Individual content line with background and width @@ -632,14 +632,14 @@ func DefaultStyles() Styles { StylePrimitive: ansi.StylePrimitive{ // BlockPrefix: "\n", // BlockSuffix: "\n", - Color: stringPtr(charmtone.Smoke.Hex()), + Color: new(charmtone.Smoke.Hex()), }, - // Margin: uintPtr(defaultMargin), + // Margin: new(uint(defaultMargin)), }, BlockQuote: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{}, - Indent: uintPtr(1), - IndentToken: stringPtr("│ "), + Indent: new(uint(1)), + IndentToken: new("│ "), }, List: ansi.StyleList{ LevelIndent: defaultListIndent, @@ -647,17 +647,17 @@ func DefaultStyles() Styles { Heading: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ BlockSuffix: "\n", - Color: stringPtr(charmtone.Malibu.Hex()), - Bold: boolPtr(true), + Color: new(charmtone.Malibu.Hex()), + Bold: new(true), }, }, H1: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: " ", Suffix: " ", - Color: stringPtr(charmtone.Zest.Hex()), - BackgroundColor: stringPtr(charmtone.Charple.Hex()), - Bold: boolPtr(true), + Color: new(charmtone.Zest.Hex()), + BackgroundColor: new(charmtone.Charple.Hex()), + Bold: new(true), }, }, H2: ansi.StyleBlock{ @@ -683,21 +683,21 @@ func DefaultStyles() Styles { H6: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: "###### ", - Color: stringPtr(charmtone.Guac.Hex()), - Bold: boolPtr(false), + Color: new(charmtone.Guac.Hex()), + Bold: new(false), }, }, Strikethrough: ansi.StylePrimitive{ - CrossedOut: boolPtr(true), + CrossedOut: new(true), }, Emph: ansi.StylePrimitive{ - Italic: boolPtr(true), + Italic: new(true), }, Strong: ansi.StylePrimitive{ - Bold: boolPtr(true), + Bold: new(true), }, HorizontalRule: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Charcoal.Hex()), + Color: new(charmtone.Charcoal.Hex()), Format: "\n--------\n", }, Item: ansi.StylePrimitive{ @@ -712,117 +712,117 @@ func DefaultStyles() Styles { Unticked: "[ ] ", }, Link: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Zinc.Hex()), - Underline: boolPtr(true), + Color: new(charmtone.Zinc.Hex()), + Underline: new(true), }, LinkText: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), - Bold: boolPtr(true), + Color: new(charmtone.Guac.Hex()), + Bold: new(true), }, Image: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cheeky.Hex()), - Underline: boolPtr(true), + Color: new(charmtone.Cheeky.Hex()), + Underline: new(true), }, ImageText: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Squid.Hex()), + Color: new(charmtone.Squid.Hex()), Format: "Image: {{.text}} →", }, Code: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ Prefix: " ", Suffix: " ", - Color: stringPtr(charmtone.Coral.Hex()), - BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), + Color: new(charmtone.Coral.Hex()), + BackgroundColor: new(charmtone.Charcoal.Hex()), }, }, CodeBlock: ansi.StyleCodeBlock{ StyleBlock: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Charcoal.Hex()), + Color: new(charmtone.Charcoal.Hex()), }, - Margin: uintPtr(defaultMargin), + Margin: new(uint(defaultMargin)), }, Chroma: &ansi.Chroma{ Text: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Smoke.Hex()), + Color: new(charmtone.Smoke.Hex()), }, Error: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Butter.Hex()), - BackgroundColor: stringPtr(charmtone.Sriracha.Hex()), + Color: new(charmtone.Butter.Hex()), + BackgroundColor: new(charmtone.Sriracha.Hex()), }, Comment: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Oyster.Hex()), + Color: new(charmtone.Oyster.Hex()), }, CommentPreproc: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Bengal.Hex()), + Color: new(charmtone.Bengal.Hex()), }, Keyword: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Malibu.Hex()), + Color: new(charmtone.Malibu.Hex()), }, KeywordReserved: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Pony.Hex()), + Color: new(charmtone.Pony.Hex()), }, KeywordNamespace: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Pony.Hex()), + Color: new(charmtone.Pony.Hex()), }, KeywordType: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guppy.Hex()), + Color: new(charmtone.Guppy.Hex()), }, Operator: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Salmon.Hex()), + Color: new(charmtone.Salmon.Hex()), }, Punctuation: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Zest.Hex()), + Color: new(charmtone.Zest.Hex()), }, Name: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Smoke.Hex()), + Color: new(charmtone.Smoke.Hex()), }, NameBuiltin: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cheeky.Hex()), + Color: new(charmtone.Cheeky.Hex()), }, NameTag: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Mauve.Hex()), + Color: new(charmtone.Mauve.Hex()), }, NameAttribute: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Hazy.Hex()), + Color: new(charmtone.Hazy.Hex()), }, NameClass: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Salt.Hex()), - Underline: boolPtr(true), - Bold: boolPtr(true), + Color: new(charmtone.Salt.Hex()), + Underline: new(true), + Bold: new(true), }, NameDecorator: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Citron.Hex()), + Color: new(charmtone.Citron.Hex()), }, NameFunction: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), + Color: new(charmtone.Guac.Hex()), }, LiteralNumber: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Julep.Hex()), + Color: new(charmtone.Julep.Hex()), }, LiteralString: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Cumin.Hex()), + Color: new(charmtone.Cumin.Hex()), }, LiteralStringEscape: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Bok.Hex()), + Color: new(charmtone.Bok.Hex()), }, GenericDeleted: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Coral.Hex()), + Color: new(charmtone.Coral.Hex()), }, GenericEmph: ansi.StylePrimitive{ - Italic: boolPtr(true), + Italic: new(true), }, GenericInserted: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Guac.Hex()), + Color: new(charmtone.Guac.Hex()), }, GenericStrong: ansi.StylePrimitive{ - Bold: boolPtr(true), + Bold: new(true), }, GenericSubheading: ansi.StylePrimitive{ - Color: stringPtr(charmtone.Squid.Hex()), + Color: new(charmtone.Squid.Hex()), }, Background: ansi.StylePrimitive{ - BackgroundColor: stringPtr(charmtone.Charcoal.Hex()), + BackgroundColor: new(charmtone.Charcoal.Hex()), }, }, }, @@ -837,8 +837,8 @@ func DefaultStyles() Styles { } // PlainMarkdown style - muted colors on subtle background for thinking content. - plainBg := stringPtr(bgBaseLighter.Hex()) - plainFg := stringPtr(fgMuted.Hex()) + plainBg := new(bgBaseLighter.Hex()) + plainFg := new(fgMuted.Hex()) s.PlainMarkdown = ansi.StyleConfig{ Document: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ @@ -851,8 +851,8 @@ func DefaultStyles() Styles { Color: plainFg, BackgroundColor: plainBg, }, - Indent: uintPtr(1), - IndentToken: stringPtr("│ "), + Indent: new(uint(1)), + IndentToken: new("│ "), }, List: ansi.StyleList{ LevelIndent: defaultListIndent, @@ -860,7 +860,7 @@ func DefaultStyles() Styles { Heading: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ BlockSuffix: "\n", - Bold: boolPtr(true), + Bold: new(true), Color: plainFg, BackgroundColor: plainBg, }, @@ -869,7 +869,7 @@ func DefaultStyles() Styles { StylePrimitive: ansi.StylePrimitive{ Prefix: " ", Suffix: " ", - Bold: boolPtr(true), + Bold: new(true), Color: plainFg, BackgroundColor: plainBg, }, @@ -910,17 +910,17 @@ func DefaultStyles() Styles { }, }, Strikethrough: ansi.StylePrimitive{ - CrossedOut: boolPtr(true), + CrossedOut: new(true), Color: plainFg, BackgroundColor: plainBg, }, Emph: ansi.StylePrimitive{ - Italic: boolPtr(true), + Italic: new(true), Color: plainFg, BackgroundColor: plainBg, }, Strong: ansi.StylePrimitive{ - Bold: boolPtr(true), + Bold: new(true), Color: plainFg, BackgroundColor: plainBg, }, @@ -948,17 +948,17 @@ func DefaultStyles() Styles { Unticked: "[ ] ", }, Link: ansi.StylePrimitive{ - Underline: boolPtr(true), + Underline: new(true), Color: plainFg, BackgroundColor: plainBg, }, LinkText: ansi.StylePrimitive{ - Bold: boolPtr(true), + Bold: new(true), Color: plainFg, BackgroundColor: plainBg, }, Image: ansi.StylePrimitive{ - Underline: boolPtr(true), + Underline: new(true), Color: plainFg, BackgroundColor: plainBg, }, @@ -981,7 +981,7 @@ func DefaultStyles() Styles { Color: plainFg, BackgroundColor: plainBg, }, - Margin: uintPtr(defaultMargin), + Margin: new(uint(defaultMargin)), }, }, Table: ansi.StyleTable{ @@ -1119,7 +1119,7 @@ func DefaultStyles() Styles { s.Tool.IconCancelled = s.Muted.SetString(ToolPending) s.Tool.NameNormal = base.Foreground(blue) - s.Tool.NameNested = base.Foreground(fgHalfMuted) + s.Tool.NameNested = base.Foreground(blue) s.Tool.ParamMain = s.Subtle s.Tool.ParamKey = s.Subtle @@ -1368,10 +1368,6 @@ func DefaultStyles() Styles { return s } -// Helper functions for style pointers -func boolPtr(b bool) *bool { return &b } -func stringPtr(s string) *string { return &s } -func uintPtr(u uint) *uint { return &u } func chromaStyle(style ansi.StylePrimitive) string { var s strings.Builder