Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/commandhistory"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/db"
Expand All @@ -26,10 +27,11 @@ import (
)

type App struct {
Sessions session.Service
Messages message.Service
History history.Service
Permissions permission.Service
Sessions session.Service
Messages message.Service
History history.Service
CommandHistory commandhistory.Service
Permissions permission.Service

CoderAgent agent.Service

Expand All @@ -53,18 +55,20 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
sessions := session.NewService(q)
messages := message.NewService(q)
files := history.NewService(q, conn)
commandHistory := commandhistory.NewService(q, conn)
skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
allowedTools := []string{}
if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
allowedTools = cfg.Permissions.AllowedTools
}

app := &App{
Sessions: sessions,
Messages: messages,
History: files,
Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
LSPClients: csync.NewMap[string, *lsp.Client](),
Sessions: sessions,
Messages: messages,
History: files,
CommandHistory: commandHistory,
Permissions: permission.NewPermissionService(cfg.WorkingDir(), skipPermissionsRequests, allowedTools),
LSPClients: csync.NewMap[string, *lsp.Client](),

globalCtx: ctx,

Expand Down
136 changes: 136 additions & 0 deletions internal/commandhistory/service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package commandhistory

import (
"context"
"database/sql"
"strings"

"github.com/charmbracelet/crush/internal/db"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/google/uuid"
)

type CommandHistory struct {
ID string
SessionID string
Command string
CreatedAt int64
UpdatedAt int64
}

type Service interface {
pubsub.Suscriber[CommandHistory]
Add(ctx context.Context, sessionID, command string) (CommandHistory, error)
ListBySession(ctx context.Context, sessionID string, limit int) ([]CommandHistory, error)
DeleteSessionHistory(ctx context.Context, sessionID string) error
}

type service struct {
*pubsub.Broker[CommandHistory]
db *sql.DB
q *db.Queries
}

const MaxHistorySize = 1000

func NewService(q *db.Queries, db *sql.DB) Service {
return &service{
Broker: pubsub.NewBroker[CommandHistory](),
q: q,
db: db,
}
}

func (s *service) Add(ctx context.Context, sessionID, command string) (CommandHistory, error) {
command = strings.TrimSpace(command)
if command == "" {
return CommandHistory{}, nil
}

// Get current count for this session
countRow, err := s.q.GetCommandHistoryCount(ctx, db.GetCommandHistoryCountParams{
SessionID: sessionID,
})
if err != nil {
return CommandHistory{}, err
}

// If we're at the limit, remove oldest entries
if int(countRow.Count) >= MaxHistorySize {
history, err := s.q.ListCommandHistoryBySession(ctx, db.ListCommandHistoryBySessionParams{
SessionID: sessionID,
})
if err != nil {
return CommandHistory{}, err
}

// Remove oldest entries to make room
toRemove := int(countRow.Count) - MaxHistorySize + 1
for i := 0; i < toRemove && i < len(history); i++ {
// Simple deletion - in a more sophisticated implementation,
// we might want to batch delete
if _, err := s.db.ExecContext(ctx, "DELETE FROM command_history WHERE id = ?", history[i].ID); err != nil {
return CommandHistory{}, err
}
}
Comment on lines +60 to +75
Copy link

Copilot AI Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting oldest entries one-by-one after loading the full history is inefficient. Prefer a single statement that deletes the oldest N rows, e.g., DELETE by id IN a subquery limited by created_at ASC, to avoid loading and N round-trips.

Suggested change
history, err := s.q.ListCommandHistoryBySession(ctx, db.ListCommandHistoryBySessionParams{
SessionID: sessionID,
})
if err != nil {
return CommandHistory{}, err
}
// Remove oldest entries to make room
toRemove := int(countRow.Count) - MaxHistorySize + 1
for i := 0; i < toRemove && i < len(history); i++ {
// Simple deletion - in a more sophisticated implementation,
// we might want to batch delete
if _, err := s.db.ExecContext(ctx, "DELETE FROM command_history WHERE id = ?", history[i].ID); err != nil {
return CommandHistory{}, err
}
}
toRemove := int(countRow.Count) - MaxHistorySize + 1
// Batch delete oldest entries in a single statement
_, err := s.db.ExecContext(ctx, `
DELETE FROM command_history
WHERE id IN (
SELECT id FROM command_history
WHERE session_id = ?
ORDER BY created_at ASC
LIMIT ?
)
`, sessionID, toRemove)
if err != nil {
return CommandHistory{}, err
}

Copilot uses AI. Check for mistakes.
}

dbHistory, err := s.q.CreateCommandHistory(ctx, db.CreateCommandHistoryParams{
ID: uuid.New().String(),
SessionID: sessionID,
Command: command,
})
if err != nil {
return CommandHistory{}, err
}

history := CommandHistory{
ID: dbHistory.ID,
SessionID: dbHistory.SessionID,
Command: dbHistory.Command,
CreatedAt: dbHistory.CreatedAt,
UpdatedAt: dbHistory.UpdatedAt,
}

s.Publish(pubsub.CreatedEvent, history)
return history, nil
}

func (s *service) ListBySession(ctx context.Context, sessionID string, limit int) ([]CommandHistory, error) {
if limit <= 0 {
limit = MaxHistorySize
}

dbHistory, err := s.q.ListLatestCommandHistoryBySession(ctx, db.ListLatestCommandHistoryBySessionParams{
SessionID: sessionID,
Limit: int64(limit),
})
Comment on lines +100 to +107
Copy link

Copilot AI Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method signature suggests 'limit' is caller-controlled, but treating limit <= 0 as MaxHistorySize contradicts the caller comment that 0 = no limit. Consider: if limit <= 0, call the unbounded list (ListCommandHistoryBySession) or document that 0 means 'use default cap'.

Suggested change
if limit <= 0 {
limit = MaxHistorySize
}
dbHistory, err := s.q.ListLatestCommandHistoryBySession(ctx, db.ListLatestCommandHistoryBySessionParams{
SessionID: sessionID,
Limit: int64(limit),
})
var dbHistory []db.CommandHistory
var err error
if limit <= 0 {
// Unbounded: get all history for the session
dbHistory, err = s.q.ListCommandHistoryBySession(ctx, sessionID)
} else {
dbHistory, err = s.q.ListLatestCommandHistoryBySession(ctx, db.ListLatestCommandHistoryBySessionParams{
SessionID: sessionID,
Limit: int64(limit),
})
}

Copilot uses AI. Check for mistakes.
if err != nil {
return nil, err
}

history := make([]CommandHistory, len(dbHistory))
for i, dbItem := range dbHistory {
// Reverse the slice so callers see commands in chronological order.
history[len(dbHistory)-1-i] = CommandHistory{
ID: dbItem.ID,
SessionID: dbItem.SessionID,
Command: dbItem.Command,
CreatedAt: dbItem.CreatedAt,
UpdatedAt: dbItem.UpdatedAt,
}
}
return history, nil
}

func (s *service) DeleteSessionHistory(ctx context.Context, sessionID string) error {
err := s.q.DeleteSessionCommandHistory(ctx, db.DeleteSessionCommandHistoryParams{
SessionID: sessionID,
})
if err != nil {
return err
}
// Publish deletion event
s.Publish(pubsub.DeletedEvent, CommandHistory{SessionID: sessionID})
return nil
}
70 changes: 70 additions & 0 deletions internal/commandhistory/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package commandhistory

import (
"context"
"testing"

"github.com/charmbracelet/crush/internal/db"
"github.com/charmbracelet/crush/internal/session"
"github.com/stretchr/testify/require"
)

func setupTestService(t *testing.T) (context.Context, *service, *db.Queries) {
t.Helper()

ctx := context.Background()
conn, err := db.Connect(ctx, t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, conn.Close())
})

queries := db.New(conn)
svc := NewService(queries, conn)
return ctx, svc.(*service), queries
}

func TestListBySessionReturnsChronologicalHistory(t *testing.T) {
ctx, svc, queries := setupTestService(t)

sessionSvc := session.NewService(queries)
sess, err := sessionSvc.Create(ctx, "test session")
require.NoError(t, err)

// Seed deterministic history with known timestamps.
rows := []struct {
id string
command string
ts int64
}{
{id: "cmd-1", command: "first", ts: 1},
{id: "cmd-2", command: "second", ts: 2},
{id: "cmd-3", command: "third", ts: 3},
}

for _, row := range rows {
_, err := svc.db.ExecContext(ctx, `
INSERT INTO command_history (id, session_id, command, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
row.id, sess.ID, row.command, row.ts, row.ts,
)
require.NoError(t, err)
}

history, err := svc.ListBySession(ctx, sess.ID, 0)
require.NoError(t, err)
require.Len(t, history, 3)
require.Equal(t, []string{"first", "second", "third"}, []string{
history[0].Command,
history[1].Command,
history[2].Command,
})

limitedHistory, err := svc.ListBySession(ctx, sess.ID, 2)
require.NoError(t, err)
require.Len(t, limitedHistory, 2)
require.Equal(t, []string{"second", "third"}, []string{
limitedHistory[0].Command,
limitedHistory[1].Command,
})
}
136 changes: 136 additions & 0 deletions internal/db/command_history.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading