Merge pull request #25 from kujtimiihoxha/cleanup-logs-status

Kujtim Hoxha created

Cleanup Logs and Status

Change summary

cmd/root.go                              |  10 -
internal/app/services.go                 |   9 -
internal/config/config.go                |   4 
internal/llm/agent/agent.go              |  20 --
internal/llm/agent/mcp-tools.go          |  12 +
internal/llm/provider/anthropic.go       | 116 ++++++----------
internal/llm/provider/gemini.go          |   6 
internal/llm/tools/fetch.go              |   6 
internal/logging/logger.go               |  61 +++++++-
internal/logging/logging.go              |  10 +
internal/logging/message.go              |  16 +-
internal/logging/writer.go               |  27 ++-
internal/lsp/client.go                   |  20 ++
internal/lsp/handlers.go                 |  17 +-
internal/lsp/transport.go                |  70 +++++-----
internal/lsp/watcher/watcher.go          | 118 ++++++++++------
internal/tui/components/core/status.go   |  26 ++-
internal/tui/components/logs/details.go  | 176 ++++++++++++++++++++++++++
internal/tui/components/logs/table.go    |  38 +++-
internal/tui/components/repl/sessions.go |   1 
internal/tui/layout/bento.go             |  55 +++++--
internal/tui/page/logs.go                |  19 +-
internal/tui/tui.go                      |  85 +++++++++---
internal/tui/util/util.go                |   7 
main.go                                  |  12 -
25 files changed, 612 insertions(+), 329 deletions(-)

Detailed changes

cmd/root.go 🔗

@@ -107,16 +107,6 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
 			wg.Done()
 		}()
 	}
-	{
-		sub := app.Status.Subscribe(ctx)
-		wg.Add(1)
-		go func() {
-			for ev := range sub {
-				ch <- ev
-			}
-			wg.Done()
-		}()
-	}
 	return ch, func() {
 		cancel()
 		wg.Wait()

internal/app/services.go 🔗

@@ -11,9 +11,7 @@ import (
 	"github.com/kujtimiihoxha/termai/internal/lsp/watcher"
 	"github.com/kujtimiihoxha/termai/internal/message"
 	"github.com/kujtimiihoxha/termai/internal/permission"
-	"github.com/kujtimiihoxha/termai/internal/pubsub"
 	"github.com/kujtimiihoxha/termai/internal/session"
-	"github.com/kujtimiihoxha/termai/internal/tui/util"
 )
 
 type App struct {
@@ -27,16 +25,14 @@ type App struct {
 
 	Logger logging.Interface
 
-	Status  *pubsub.Broker[util.InfoMsg]
 	ceanups []func()
 }
 
 func New(ctx context.Context, conn *sql.DB) *App {
 	cfg := config.Get()
 	q := db.New(conn)
-	log := logging.NewLogger(logging.Options{
-		Level: cfg.Log.Level,
-	})
+	log := logging.Get()
+	log.SetLevel(cfg.Log.Level)
 	sessions := session.NewService(ctx, q)
 	messages := message.NewService(ctx, q)
 
@@ -46,7 +42,6 @@ func New(ctx context.Context, conn *sql.DB) *App {
 		Messages:    messages,
 		Permissions: permission.NewPermissionService(),
 		Logger:      log,
-		Status:      pubsub.NewBroker[util.InfoMsg](),
 		LSPClients:  make(map[string]*lsp.Client),
 	}
 

internal/config/config.go 🔗

@@ -70,6 +70,8 @@ type Config struct {
 	LSP map[string]LSPConfig `json:"lsp,omitempty"`
 
 	Model *Model `json:"model,omitempty"`
+
+	Debug bool `json:"debug,omitempty"`
 }
 
 var cfg *Config
@@ -95,8 +97,10 @@ func Load(debug bool) error {
 	// Add defaults
 	viper.SetDefault("data.directory", defaultDataDirectory)
 	if debug {
+		viper.SetDefault("debug", true)
 		viper.Set("log.level", "debug")
 	} else {
+		viper.SetDefault("debug", false)
 		viper.SetDefault("log.level", defaultLogLevel)
 	}
 

internal/llm/agent/agent.go 🔗

@@ -4,7 +4,6 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"log"
 	"strings"
 	"sync"
 
@@ -15,8 +14,6 @@ import (
 	"github.com/kujtimiihoxha/termai/internal/llm/provider"
 	"github.com/kujtimiihoxha/termai/internal/llm/tools"
 	"github.com/kujtimiihoxha/termai/internal/message"
-	"github.com/kujtimiihoxha/termai/internal/pubsub"
-	"github.com/kujtimiihoxha/termai/internal/tui/util"
 )
 
 type Agent interface {
@@ -94,24 +91,13 @@ func (c *agent) processEvent(
 		assistantMsg.AppendContent(event.Content)
 		return c.Messages.Update(*assistantMsg)
 	case provider.EventError:
-		// TODO: remove when realease
-		log.Println("error", event.Error)
-		c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
-			Type: util.InfoTypeError,
-			Msg:  event.Error.Error(),
-		})
+		c.App.Logger.PersistError(event.Error.Error())
 		return event.Error
 	case provider.EventWarning:
-		c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
-			Type: util.InfoTypeWarn,
-			Msg:  event.Info,
-		})
+		c.App.Logger.PersistWarn(event.Info)
 		return nil
 	case provider.EventInfo:
-		c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
-			Type: util.InfoTypeInfo,
-			Msg:  event.Info,
-		})
+		c.App.Logger.PersistInfo(event.Info)
 	case provider.EventComplete:
 		assistantMsg.SetToolCalls(event.Response.ToolCalls)
 		assistantMsg.AddFinish(event.Response.FinishReason)

internal/llm/agent/mcp-tools.go 🔗

@@ -4,10 +4,10 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
-	"log"
 
 	"github.com/kujtimiihoxha/termai/internal/config"
 	"github.com/kujtimiihoxha/termai/internal/llm/tools"
+	"github.com/kujtimiihoxha/termai/internal/logging"
 	"github.com/kujtimiihoxha/termai/internal/permission"
 	"github.com/kujtimiihoxha/termai/internal/version"
 
@@ -22,6 +22,8 @@ type mcpTool struct {
 	permissions permission.Service
 }
 
+var logger = logging.Get()
+
 type MCPClient interface {
 	Initialize(
 		ctx context.Context,
@@ -141,13 +143,13 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
 
 	_, err := c.Initialize(ctx, initRequest)
 	if err != nil {
-		log.Printf("error initializing mcp client: %s", err)
+		logger.Error("error initializing mcp client", "error", err)
 		return stdioTools
 	}
 	toolsRequest := mcp.ListToolsRequest{}
 	tools, err := c.ListTools(ctx, toolsRequest)
 	if err != nil {
-		log.Printf("error listing tools: %s", err)
+		logger.Error("error listing tools", "error", err)
 		return stdioTools
 	}
 	for _, t := range tools.Tools {
@@ -170,7 +172,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
 				m.Args...,
 			)
 			if err != nil {
-				log.Printf("error creating mcp client: %s", err)
+				logger.Error("error creating mcp client", "error", err)
 				continue
 			}
 
@@ -181,7 +183,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
 				client.WithHeaders(m.Headers),
 			)
 			if err != nil {
-				log.Printf("error creating mcp client: %s", err)
+				logger.Error("error creating mcp client", "error", err)
 				continue
 			}
 			mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)

internal/llm/provider/anthropic.go 🔗

@@ -159,40 +159,9 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
 		attempts := 0
 
 		for {
-			// If this isn't the first attempt, we're retrying
-			if attempts > 0 {
-				if attempts > maxRetries {
-					eventChan <- ProviderEvent{
-						Type:  EventError,
-						Error: errors.New("maximum retry attempts reached for rate limit (429)"),
-					}
-					return
-				}
-
-				// Inform user we're retrying with attempt number
-				eventChan <- ProviderEvent{
-					Type: EventWarning,
-					Info: fmt.Sprintf("[Retrying due to rate limit... attempt %d of %d]", attempts, maxRetries),
-				}
-
-				// Calculate backoff with exponential backoff and jitter
-				backoffMs := 2000 * (1 << (attempts - 1)) // 2s, 4s, 8s, 16s, 32s
-				jitterMs := int(float64(backoffMs) * 0.2)
-				totalBackoffMs := backoffMs + jitterMs
-
-				// Sleep with backoff, respecting context cancellation
-				select {
-				case <-ctx.Done():
-					eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-					return
-				case <-time.After(time.Duration(totalBackoffMs) * time.Millisecond):
-					// Continue with retry
-				}
-			}
 
 			attempts++
 
-			// Create new streaming request
 			stream := a.client.Messages.NewStreaming(
 				ctx,
 				anthropic.MessageNewParams{
@@ -213,11 +182,8 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
 				},
 			)
 
-			// Process stream events
 			accumulatedMessage := anthropic.Message{}
-			streamSuccess := false
 
-			// Process the stream until completion or error
 			for stream.Next() {
 				event := stream.Current()
 				err := accumulatedMessage.Accumulate(event)
@@ -247,7 +213,6 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
 					eventChan <- ProviderEvent{Type: EventContentStop}
 
 				case anthropic.MessageStopEvent:
-					streamSuccess = true
 					content := ""
 					for _, block := range accumulatedMessage.Content {
 						if text, ok := block.AsAny().(anthropic.TextBlock); ok {
@@ -270,51 +235,59 @@ func (a *anthropicProvider) StreamResponse(ctx context.Context, messages []messa
 				}
 			}
 
-			// If the stream completed successfully, we're done
-			if streamSuccess {
+			err := stream.Err()
+			if err == nil {
 				return
 			}
 
-			// Check for stream errors
-			err := stream.Err()
-			if err != nil {
-				var apierr *anthropic.Error
-				if errors.As(err, &apierr) {
-					if apierr.StatusCode == 429 || apierr.StatusCode == 529 {
-						// Check for Retry-After header
-						if retryAfterValues := apierr.Response.Header.Values("Retry-After"); len(retryAfterValues) > 0 {
-							// Parse the retry after value (seconds)
-							var retryAfterSec int
-							if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryAfterSec); err == nil {
-								retryMs := retryAfterSec * 1000
-
-								// Inform user of retry with specific wait time
-								eventChan <- ProviderEvent{
-									Type: EventWarning,
-									Info: fmt.Sprintf("[Rate limited: waiting %d seconds as specified by API]", retryAfterSec),
-								}
-
-								// Sleep respecting context cancellation
-								select {
-								case <-ctx.Done():
-									eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
-									return
-								case <-time.After(time.Duration(retryMs) * time.Millisecond):
-									// Continue with retry after specified delay
-									continue
-								}
-							}
-						}
+			var apierr *anthropic.Error
+			if !errors.As(err, &apierr) {
+				eventChan <- ProviderEvent{Type: EventError, Error: err}
+				return
+			}
 
-						// Fall back to exponential backoff if Retry-After parsing failed
-						continue
+			if apierr.StatusCode != 429 && apierr.StatusCode != 529 {
+				eventChan <- ProviderEvent{Type: EventError, Error: err}
+				return
+			}
+
+			if attempts > maxRetries {
+				eventChan <- ProviderEvent{
+					Type:  EventError,
+					Error: errors.New("maximum retry attempts reached for rate limit (429)"),
+				}
+				return
+			}
+
+			retryMs := 0
+			retryAfterValues := apierr.Response.Header.Values("Retry-After")
+			if len(retryAfterValues) > 0 {
+				var retryAfterSec int
+				if _, err := fmt.Sscanf(retryAfterValues[0], "%d", &retryAfterSec); err == nil {
+					retryMs = retryAfterSec * 1000
+					eventChan <- ProviderEvent{
+						Type: EventWarning,
+						Info: fmt.Sprintf("[Rate limited: waiting %d seconds as specified by API]", retryAfterSec),
 					}
 				}
+			} else {
+				eventChan <- ProviderEvent{
+					Type: EventWarning,
+					Info: fmt.Sprintf("[Retrying due to rate limit... attempt %d of %d]", attempts, maxRetries),
+				}
 
-				// For non-rate limit errors, report and exit
-				eventChan <- ProviderEvent{Type: EventError, Error: err}
+				backoffMs := 2000 * (1 << (attempts - 1))
+				jitterMs := int(float64(backoffMs) * 0.2)
+				retryMs = backoffMs + jitterMs
+			}
+			select {
+			case <-ctx.Done():
+				eventChan <- ProviderEvent{Type: EventError, Error: ctx.Err()}
 				return
+			case <-time.After(time.Duration(retryMs) * time.Millisecond):
+				continue
 			}
+
 		}
 	}()
 
@@ -412,7 +385,6 @@ func (a *anthropicProvider) convertToAnthropicMessages(messages []message.Messag
 				blocks = append(blocks, anthropic.ContentBlockParamOfRequestToolUseBlock(toolCall.ID, inputMap, toolCall.Name))
 			}
 
-			// Skip empty assistant messages completely
 			if len(blocks) > 0 {
 				anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
 			}

internal/llm/provider/gemini.go 🔗

@@ -4,14 +4,12 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
-	"log"
 
 	"github.com/google/generative-ai-go/genai"
 	"github.com/google/uuid"
 	"github.com/kujtimiihoxha/termai/internal/llm/models"
 	"github.com/kujtimiihoxha/termai/internal/llm/tools"
 	"github.com/kujtimiihoxha/termai/internal/message"
-	"google.golang.org/api/googleapi"
 	"google.golang.org/api/iterator"
 	"google.golang.org/api/option"
 )
@@ -242,10 +240,6 @@ func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.
 				break
 			}
 			if err != nil {
-				var apiErr *googleapi.Error
-				if errors.As(err, &apiErr) {
-					log.Printf("%s", apiErr.Body)
-				}
 				eventChan <- ProviderEvent{
 					Type:  EventError,
 					Error: err,

internal/llm/tools/fetch.go 🔗

@@ -121,11 +121,7 @@ func (t *fetchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error
 			ToolName:    FetchToolName,
 			Action:      "fetch",
 			Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
-			Params: FetchPermissionsParams{
-				URL:     params.URL,
-				Format:  params.Format,
-				Timeout: params.Timeout,
-			},
+			Params:      FetchPermissionsParams(params),
 		},
 	)
 

internal/logging/logger.go 🔗

@@ -12,6 +12,11 @@ import (
 
 const DefaultLevel = "info"
 
+const (
+	persistKeyArg  = "$persist"
+	PersistTimeArg = "$persist_time"
+)
+
 var levels = map[string]slog.Level{
 	"debug":      slog.LevelDebug,
 	DefaultLevel: slog.LevelInfo,
@@ -36,16 +41,16 @@ func ValidLevels() []string {
 	return keys
 }
 
-func NewLogger(opts Options) *Logger {
+func NewLogger(opts Options) Interface {
 	logger := &Logger{}
-	broker := pubsub.NewBroker[Message]()
+	broker := pubsub.NewBroker[LogMessage]()
 	writer := &writer{
-		messages: []Message{},
+		messages: []LogMessage{},
 		Broker:   broker,
 	}
 
 	handler := slog.NewTextHandler(
-		io.MultiWriter(append(opts.AdditionalWriters, writer)...),
+		io.MultiWriter(writer),
 		&slog.HandlerOptions{
 			Level: slog.Level(levels[opts.Level]),
 		},
@@ -57,8 +62,7 @@ func NewLogger(opts Options) *Logger {
 }
 
 type Options struct {
-	Level             string
-	AdditionalWriters []io.Writer
+	Level string
 }
 
 type Logger struct {
@@ -66,6 +70,43 @@ type Logger struct {
 	writer *writer
 }
 
+func (l *Logger) SetLevel(level string) {
+	if _, ok := levels[level]; !ok {
+		level = DefaultLevel
+	}
+	handler := slog.NewTextHandler(
+		io.MultiWriter(l.writer),
+		&slog.HandlerOptions{
+			Level: levels[level],
+		},
+	)
+	l.logger = slog.New(handler)
+}
+
+// PersistDebug implements Interface.
+func (l *Logger) PersistDebug(msg string, args ...any) {
+	args = append(args, persistKeyArg, true)
+	l.Debug(msg, args...)
+}
+
+// PersistError implements Interface.
+func (l *Logger) PersistError(msg string, args ...any) {
+	args = append(args, persistKeyArg, true)
+	l.Error(msg, args...)
+}
+
+// PersistInfo implements Interface.
+func (l *Logger) PersistInfo(msg string, args ...any) {
+	args = append(args, persistKeyArg, true)
+	l.Info(msg, args...)
+}
+
+// PersistWarn implements Interface.
+func (l *Logger) PersistWarn(msg string, args ...any) {
+	args = append(args, persistKeyArg, true)
+	l.Warn(msg, args...)
+}
+
 func (l *Logger) Debug(msg string, args ...any) {
 	l.logger.Debug(msg, args...)
 }
@@ -82,19 +123,19 @@ func (l *Logger) Error(msg string, args ...any) {
 	l.logger.Error(msg, args...)
 }
 
-func (l *Logger) List() []Message {
+func (l *Logger) List() []LogMessage {
 	return l.writer.messages
 }
 
-func (l *Logger) Get(id string) (Message, error) {
+func (l *Logger) Get(id string) (LogMessage, error) {
 	for _, msg := range l.writer.messages {
 		if msg.ID == id {
 			return msg, nil
 		}
 	}
-	return Message{}, io.EOF
+	return LogMessage{}, io.EOF
 }
 
-func (l *Logger) Subscribe(ctx context.Context) <-chan pubsub.Event[Message] {
+func (l *Logger) Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage] {
 	return l.writer.Subscribe(ctx)
 }

internal/logging/logging.go 🔗

@@ -11,7 +11,13 @@ type Interface interface {
 	Info(msg string, args ...any)
 	Warn(msg string, args ...any)
 	Error(msg string, args ...any)
-	Subscribe(ctx context.Context) <-chan pubsub.Event[Message]
+	Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage]
 
-	List() []Message
+	PersistDebug(msg string, args ...any)
+	PersistInfo(msg string, args ...any)
+	PersistWarn(msg string, args ...any)
+	PersistError(msg string, args ...any)
+	List() []LogMessage
+
+	SetLevel(level string)
 }

internal/logging/message.go 🔗

@@ -4,13 +4,15 @@ import (
 	"time"
 )
 
-// Message is the event payload for a log message
-type Message struct {
-	ID         string
-	Time       time.Time
-	Level      string
-	Message    string `json:"msg"`
-	Attributes []Attr
+// LogMessage is the event payload for a log message
+type LogMessage struct {
+	ID          string
+	Time        time.Time
+	Level       string
+	Persist     bool          // used when we want to show the mesage in the status bar
+	PersistTime time.Duration // used when we want to show the mesage in the status bar
+	Message     string        `json:"msg"`
+	Attributes  []Attr
 }
 
 type Attr struct {

internal/logging/writer.go 🔗

@@ -10,15 +10,16 @@ import (
 )
 
 type writer struct {
-	messages []Message
-	*pubsub.Broker[Message]
+	messages []LogMessage
+	*pubsub.Broker[LogMessage]
 }
 
 func (w *writer) Write(p []byte) (int, error) {
 	d := logfmt.NewDecoder(bytes.NewReader(p))
 	for d.ScanRecord() {
-		msg := Message{
-			ID: time.Now().Format(time.RFC3339Nano),
+		msg := LogMessage{
+			ID:   fmt.Sprintf("%d", time.Now().UnixNano()),
+			Time: time.Now(),
 		}
 		for d.ScanKeyval() {
 			switch string(d.Key()) {
@@ -33,10 +34,20 @@ func (w *writer) Write(p []byte) (int, error) {
 			case "msg":
 				msg.Message = string(d.Value())
 			default:
-				msg.Attributes = append(msg.Attributes, Attr{
-					Key:   string(d.Key()),
-					Value: string(d.Value()),
-				})
+				if string(d.Key()) == persistKeyArg {
+					msg.Persist = true
+				} else if string(d.Key()) == PersistTimeArg {
+					parsed, err := time.ParseDuration(string(d.Value()))
+					if err != nil {
+						continue
+					}
+					msg.PersistTime = parsed
+				} else {
+					msg.Attributes = append(msg.Attributes, Attr{
+						Key:   string(d.Key()),
+						Value: string(d.Value()),
+					})
+				}
 			}
 		}
 		w.messages = append(w.messages, msg)

internal/lsp/client.go 🔗

@@ -6,7 +6,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
-	"log"
 	"os"
 	"os/exec"
 	"strings"
@@ -14,9 +13,13 @@ import (
 	"sync/atomic"
 	"time"
 
+	"github.com/kujtimiihoxha/termai/internal/config"
+	"github.com/kujtimiihoxha/termai/internal/logging"
 	"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
 )
 
+var logger = logging.Get()
+
 type Client struct {
 	Cmd    *exec.Cmd
 	stdin  io.WriteCloser
@@ -357,6 +360,7 @@ func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
 }
 
 func (c *Client) CloseFile(ctx context.Context, filepath string) error {
+	cnf := config.Get()
 	uri := fmt.Sprintf("file://%s", filepath)
 
 	c.openFilesMu.Lock()
@@ -371,7 +375,10 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
 			URI: protocol.DocumentUri(uri),
 		},
 	}
-	log.Println("Closing", params.TextDocument.URI.Dir())
+
+	if cnf.Debug {
+		logger.Debug("Closing file", "file", filepath)
+	}
 	if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
 		return err
 	}
@@ -393,6 +400,7 @@ func (c *Client) IsFileOpen(filepath string) bool {
 
 // CloseAllFiles closes all currently open files
 func (c *Client) CloseAllFiles(ctx context.Context) {
+	cnf := config.Get()
 	c.openFilesMu.Lock()
 	filesToClose := make([]string, 0, len(c.openFiles))
 
@@ -407,13 +415,13 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
 	// Then close them all
 	for _, filePath := range filesToClose {
 		err := c.CloseFile(ctx, filePath)
-		if err != nil && debug {
-			log.Printf("Error closing file %s: %v", filePath, err)
+		if err != nil && cnf.Debug {
+			logger.Warn("Error closing file", "file", filePath, "error", err)
 		}
 	}
 
-	if debug {
-		log.Printf("Closed %d files", len(filesToClose))
+	if cnf.Debug {
+		logger.Debug("Closed all files", "files", filesToClose)
 	}
 }
 

internal/lsp/handlers.go 🔗

@@ -2,8 +2,8 @@ package lsp
 
 import (
 	"encoding/json"
-	"log"
 
+	"github.com/kujtimiihoxha/termai/internal/config"
 	"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
 	"github.com/kujtimiihoxha/termai/internal/lsp/util"
 )
@@ -17,7 +17,7 @@ func HandleWorkspaceConfiguration(params json.RawMessage) (any, error) {
 func HandleRegisterCapability(params json.RawMessage) (any, error) {
 	var registerParams protocol.RegistrationParams
 	if err := json.Unmarshal(params, &registerParams); err != nil {
-		log.Printf("Error unmarshaling registration params: %v", err)
+		logger.Error("Error unmarshaling registration params", "error", err)
 		return nil, err
 	}
 
@@ -27,13 +27,13 @@ func HandleRegisterCapability(params json.RawMessage) (any, error) {
 			// Parse the registration options
 			optionsJSON, err := json.Marshal(reg.RegisterOptions)
 			if err != nil {
-				log.Printf("Error marshaling registration options: %v", err)
+				logger.Error("Error marshaling registration options", "error", err)
 				continue
 			}
 
 			var options protocol.DidChangeWatchedFilesRegistrationOptions
 			if err := json.Unmarshal(optionsJSON, &options); err != nil {
-				log.Printf("Error unmarshaling registration options: %v", err)
+				logger.Error("Error unmarshaling registration options", "error", err)
 				continue
 			}
 
@@ -53,7 +53,7 @@ func HandleApplyEdit(params json.RawMessage) (any, error) {
 
 	err := util.ApplyWorkspaceEdit(edit.Edit)
 	if err != nil {
-		log.Printf("Error applying workspace edit: %v", err)
+		logger.Error("Error applying workspace edit", "error", err)
 		return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
 	}
 
@@ -81,19 +81,22 @@ func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatche
 // Notifications
 
 func HandleServerMessage(params json.RawMessage) {
+	cnf := config.Get()
 	var msg struct {
 		Type    int    `json:"type"`
 		Message string `json:"message"`
 	}
 	if err := json.Unmarshal(params, &msg); err == nil {
-		log.Printf("Server message: %s\n", msg.Message)
+		if cnf.Debug {
+			logger.Debug("Server message", "type", msg.Type, "message", msg.Message)
+		}
 	}
 }
 
 func HandleDiagnostics(client *Client, params json.RawMessage) {
 	var diagParams protocol.PublishDiagnosticsParams
 	if err := json.Unmarshal(params, &diagParams); err != nil {
-		log.Printf("Error unmarshaling diagnostic params: %v", err)
+		logger.Error("Error unmarshaling diagnostics params", "error", err)
 		return
 	}
 

internal/lsp/transport.go 🔗

@@ -6,12 +6,10 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
-	"log"
-	"os"
 	"strings"
-)
 
-var debug = os.Getenv("DEBUG") != ""
+	"github.com/kujtimiihoxha/termai/internal/config"
+)
 
 // Write writes an LSP message to the given writer
 func WriteMessage(w io.Writer, msg *Message) error {
@@ -19,10 +17,10 @@ func WriteMessage(w io.Writer, msg *Message) error {
 	if err != nil {
 		return fmt.Errorf("failed to marshal message: %w", err)
 	}
+	cnf := config.Get()
 
-	if debug {
-		log.Printf("%v", msg.Method)
-		log.Printf("-> Sending: %s", string(data))
+	if cnf.Debug {
+		logger.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
 	}
 
 	_, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data))
@@ -40,6 +38,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
 
 // ReadMessage reads a single LSP message from the given reader
 func ReadMessage(r *bufio.Reader) (*Message, error) {
+	cnf := config.Get()
 	// Read headers
 	var contentLength int
 	for {
@@ -49,8 +48,8 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
 		}
 		line = strings.TrimSpace(line)
 
-		if debug {
-			log.Printf("<- Header: %s", line)
+		if cnf.Debug {
+			logger.Debug("Received header", "line", line)
 		}
 
 		if line == "" {
@@ -65,8 +64,8 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
 		}
 	}
 
-	if debug {
-		log.Printf("<- Reading content with length: %d", contentLength)
+	if cnf.Debug {
+		logger.Debug("Content-Length", "length", contentLength)
 	}
 
 	// Read content
@@ -76,8 +75,8 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
 		return nil, fmt.Errorf("failed to read content: %w", err)
 	}
 
-	if debug {
-		log.Printf("<- Received: %s", string(content))
+	if cnf.Debug {
+		logger.Debug("Received content", "content", string(content))
 	}
 
 	// Parse message
@@ -91,19 +90,20 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
 
 // handleMessages reads and dispatches messages in a loop
 func (c *Client) handleMessages() {
+	cnf := config.Get()
 	for {
 		msg, err := ReadMessage(c.stdout)
 		if err != nil {
-			if debug {
-				log.Printf("Error reading message: %v", err)
+			if cnf.Debug {
+				logger.Error("Error reading message", "error", err)
 			}
 			return
 		}
 
 		// Handle server->client request (has both Method and ID)
 		if msg.Method != "" && msg.ID != 0 {
-			if debug {
-				log.Printf("Received request from server: method=%s id=%d", msg.Method, msg.ID)
+			if cnf.Debug {
+				logger.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
 			}
 
 			response := &Message{
@@ -143,7 +143,7 @@ func (c *Client) handleMessages() {
 
 			// Send response back to server
 			if err := WriteMessage(c.stdin, response); err != nil {
-				log.Printf("Error sending response to server: %v", err)
+				logger.Error("Error sending response to server", "error", err)
 			}
 
 			continue
@@ -156,12 +156,12 @@ func (c *Client) handleMessages() {
 			c.notificationMu.RUnlock()
 
 			if ok {
-				if debug {
-					log.Printf("Handling notification: %s", msg.Method)
+				if cnf.Debug {
+					logger.Debug("Handling notification", "method", msg.Method)
 				}
 				go handler(msg.Params)
-			} else if debug {
-				log.Printf("No handler for notification: %s", msg.Method)
+			} else if cnf.Debug {
+				logger.Debug("No handler for notification", "method", msg.Method)
 			}
 			continue
 		}
@@ -173,13 +173,13 @@ func (c *Client) handleMessages() {
 			c.handlersMu.RUnlock()
 
 			if ok {
-				if debug {
-					log.Printf("Sending response for ID %d to handler", msg.ID)
+				if cnf.Debug {
+					logger.Debug("Received response for request", "id", msg.ID)
 				}
 				ch <- msg
 				close(ch)
-			} else if debug {
-				log.Printf("No handler for response ID: %d", msg.ID)
+			} else if cnf.Debug {
+				logger.Debug("No handler for response", "id", msg.ID)
 			}
 		}
 	}
@@ -187,10 +187,11 @@ func (c *Client) handleMessages() {
 
 // Call makes a request and waits for the response
 func (c *Client) Call(ctx context.Context, method string, params any, result any) error {
+	cnf := config.Get()
 	id := c.nextID.Add(1)
 
-	if debug {
-		log.Printf("Making call: method=%s id=%d", method, id)
+	if cnf.Debug {
+		logger.Debug("Making call", "method", method, "id", id)
 	}
 
 	msg, err := NewRequest(id, method, params)
@@ -215,15 +216,15 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
 		return fmt.Errorf("failed to send request: %w", err)
 	}
 
-	if debug {
-		log.Printf("Waiting for response to request ID: %d", id)
+	if cnf.Debug {
+		logger.Debug("Request sent", "method", method, "id", id)
 	}
 
 	// Wait for response
 	resp := <-ch
 
-	if debug {
-		log.Printf("Received response for request ID: %d", id)
+	if cnf.Debug {
+		logger.Debug("Received response", "id", id)
 	}
 
 	if resp.Error != nil {
@@ -247,8 +248,9 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
 
 // Notify sends a notification (a request without an ID that doesn't expect a response)
 func (c *Client) Notify(ctx context.Context, method string, params any) error {
-	if debug {
-		log.Printf("Sending notification: method=%s", method)
+	cnf := config.Get()
+	if cnf.Debug {
+		logger.Debug("Sending notification", "method", method)
 	}
 
 	msg, err := NewNotification(method, params)

internal/lsp/watcher/watcher.go 🔗

@@ -3,7 +3,6 @@ package watcher
 import (
 	"context"
 	"fmt"
-	"log"
 	"os"
 	"path/filepath"
 	"strings"
@@ -11,11 +10,13 @@ import (
 	"time"
 
 	"github.com/fsnotify/fsnotify"
+	"github.com/kujtimiihoxha/termai/internal/config"
+	"github.com/kujtimiihoxha/termai/internal/logging"
 	"github.com/kujtimiihoxha/termai/internal/lsp"
 	"github.com/kujtimiihoxha/termai/internal/lsp/protocol"
 )
 
-var debug = false // Force debug logging on
+var logger = logging.Get()
 
 // WorkspaceWatcher manages LSP file watching
 type WorkspaceWatcher struct {
@@ -43,6 +44,7 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
 
 // AddRegistrations adds file watchers to track
 func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
+	cnf := config.Get()
 	w.registrationMu.Lock()
 	defer w.registrationMu.Unlock()
 
@@ -50,31 +52,35 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
 	w.registrations = append(w.registrations, watchers...)
 
 	// Print detailed registration information for debugging
-	if debug {
-		log.Printf("Added %d file watcher registrations (id: %s), total: %d",
-			len(watchers), id, len(w.registrations))
+	if cnf.Debug {
+		logger.Debug("Adding file watcher registrations",
+			"id", id,
+			"watchers", len(watchers),
+			"total", len(w.registrations),
+			"watchers", watchers,
+		)
 
 		for i, watcher := range watchers {
-			log.Printf("Registration #%d raw data:", i+1)
+			logger.Debug("Registration", "index", i+1)
 
 			// Log the GlobPattern
 			switch v := watcher.GlobPattern.Value.(type) {
 			case string:
-				log.Printf("  GlobPattern: string pattern '%s'", v)
+				logger.Debug("GlobPattern", "pattern", v)
 			case protocol.RelativePattern:
-				log.Printf("  GlobPattern: RelativePattern with pattern '%s'", v.Pattern)
+				logger.Debug("GlobPattern", "pattern", v.Pattern)
 
 				// Log BaseURI details
 				switch u := v.BaseURI.Value.(type) {
 				case string:
-					log.Printf("    BaseURI: string '%s'", u)
+					logger.Debug("BaseURI", "baseURI", u)
 				case protocol.DocumentUri:
-					log.Printf("    BaseURI: DocumentUri '%s'", u)
+					logger.Debug("BaseURI", "baseURI", u)
 				default:
-					log.Printf("    BaseURI: unknown type %T", u)
+					logger.Debug("BaseURI", "baseURI", u)
 				}
 			default:
-				log.Printf("  GlobPattern: unknown type %T", v)
+				logger.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
 			}
 
 			// Log WatchKind
@@ -82,11 +88,8 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
 			if watcher.Kind != nil {
 				watchKind = *watcher.Kind
 			}
-			log.Printf("  WatchKind: %d (Create:%v, Change:%v, Delete:%v)",
-				watchKind,
-				watchKind&protocol.WatchCreate != 0,
-				watchKind&protocol.WatchChange != 0,
-				watchKind&protocol.WatchDelete != 0)
+
+			logger.Debug("WatchKind", "kind", watchKind)
 
 			// Test match against some example paths
 			testPaths := []string{
@@ -96,7 +99,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
 
 			for _, testPath := range testPaths {
 				isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
-				log.Printf("  Test path '%s': %v", testPath, isMatch)
+				logger.Debug("Test path", "path", testPath, "matches", isMatch)
 			}
 		}
 	}
@@ -114,10 +117,9 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
 
 			// Skip directories that should be excluded
 			if d.IsDir() {
-				log.Println(path)
 				if path != w.workspacePath && shouldExcludeDir(path) {
-					if debug {
-						log.Printf("Skipping excluded directory!!: %s", path)
+					if cnf.Debug {
+						logger.Debug("Skipping excluded directory", "path", path)
 					}
 					return filepath.SkipDir
 				}
@@ -136,18 +138,23 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
 		})
 
 		elapsedTime := time.Since(startTime)
-		if debug {
-			log.Printf("Workspace scan complete: processed %d files in %.2f seconds", filesOpened, elapsedTime.Seconds())
+		if cnf.Debug {
+			logger.Debug("Workspace scan complete",
+				"filesOpened", filesOpened,
+				"elapsedTime", elapsedTime.Seconds(),
+				"workspacePath", w.workspacePath,
+			)
 		}
 
-		if err != nil && debug {
-			log.Printf("Error scanning workspace for files to open: %v", err)
+		if err != nil && cnf.Debug {
+			logger.Debug("Error scanning workspace for files to open", "error", err)
 		}
 	}()
 }
 
 // WatchWorkspace sets up file watching for a workspace
 func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
+	cnf := config.Get()
 	w.workspacePath = workspacePath
 
 	// Register handler for file watcher registrations from the server
@@ -157,7 +164,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
 
 	watcher, err := fsnotify.NewWatcher()
 	if err != nil {
-		log.Fatalf("Error creating watcher: %v", err)
+		logger.Error("Error creating watcher", "error", err)
 	}
 	defer watcher.Close()
 
@@ -170,8 +177,8 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
 		// Skip excluded directories (except workspace root)
 		if d.IsDir() && path != workspacePath {
 			if shouldExcludeDir(path) {
-				if debug {
-					log.Printf("Skipping watching excluded directory: %s", path)
+				if cnf.Debug {
+					logger.Debug("Skipping excluded directory", "path", path)
 				}
 				return filepath.SkipDir
 			}
@@ -181,14 +188,14 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
 		if d.IsDir() {
 			err = watcher.Add(path)
 			if err != nil {
-				log.Printf("Error watching path %s: %v", path, err)
+				logger.Error("Error watching path", "path", path, "error", err)
 			}
 		}
 
 		return nil
 	})
 	if err != nil {
-		log.Fatalf("Error walking workspace: %v", err)
+		logger.Error("Error walking workspace", "error", err)
 	}
 
 	// Event loop
@@ -210,7 +217,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
 						// Skip excluded directories
 						if !shouldExcludeDir(event.Name) {
 							if err := watcher.Add(event.Name); err != nil {
-								log.Printf("Error watching new directory: %v", err)
+								logger.Error("Error adding directory to watcher", "path", event.Name, "error", err)
 							}
 						}
 					} else {
@@ -223,10 +230,15 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
 			}
 
 			// Debug logging
-			if debug {
+			if cnf.Debug {
 				matched, kind := w.isPathWatched(event.Name)
-				log.Printf("Event: %s, Op: %s, Watched: %v, Kind: %d",
-					event.Name, event.Op.String(), matched, kind)
+				logger.Debug("File event",
+					"path", event.Name,
+					"operation", event.Op.String(),
+					"watched", matched,
+					"kind", kind,
+				)
+
 			}
 
 			// Check if this path should be watched according to server registrations
@@ -265,7 +277,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
 			if !ok {
 				return
 			}
-			log.Printf("Watcher error: %v\n", err)
+			logger.Error("Error watching file", "error", err)
 		}
 	}
 }
@@ -390,7 +402,7 @@ func matchesSimpleGlob(pattern, path string) bool {
 	// Fall back to simple matching for simpler patterns
 	matched, err := filepath.Match(pattern, path)
 	if err != nil {
-		log.Printf("Error matching pattern %s: %v", pattern, err)
+		logger.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
 		return false
 	}
 
@@ -401,7 +413,7 @@ func matchesSimpleGlob(pattern, path string) bool {
 func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
 	patternInfo, err := pattern.AsPattern()
 	if err != nil {
-		log.Printf("Error parsing pattern: %v", err)
+		logger.Error("Error parsing pattern", "pattern", pattern, "error", err)
 		return false
 	}
 
@@ -426,7 +438,7 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
 	// Make path relative to basePath for matching
 	relPath, err := filepath.Rel(basePath, path)
 	if err != nil {
-		log.Printf("Error getting relative path for %s: %v", path, err)
+		logger.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
 		return false
 	}
 	relPath = filepath.ToSlash(relPath)
@@ -467,21 +479,25 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
 	if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
 		err := w.client.NotifyChange(ctx, filePath)
 		if err != nil {
-			log.Printf("Error notifying change: %v", err)
+			logger.Error("Error notifying change", "error", err)
 		}
 		return
 	}
 
 	// Notify LSP server about the file event using didChangeWatchedFiles
 	if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
-		log.Printf("Error notifying LSP server about file event: %v", err)
+		logger.Error("Error notifying LSP server about file event", "error", err)
 	}
 }
 
 // notifyFileEvent sends a didChangeWatchedFiles notification for a file event
 func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
-	if debug {
-		log.Printf("Notifying file event: %s (type: %d)", uri, changeType)
+	cnf := config.Get()
+	if cnf.Debug {
+		logger.Debug("Notifying file event",
+			"uri", uri,
+			"changeType", changeType,
+		)
 	}
 
 	params := protocol.DidChangeWatchedFilesParams{
@@ -575,7 +591,7 @@ func shouldExcludeDir(dirPath string) bool {
 // shouldExcludeFile returns true if the file should be excluded from opening
 func shouldExcludeFile(filePath string) bool {
 	fileName := filepath.Base(filePath)
-
+	cnf := config.Get()
 	// Skip dot files
 	if strings.HasPrefix(fileName, ".") {
 		return true
@@ -601,8 +617,15 @@ func shouldExcludeFile(filePath string) bool {
 
 	// Skip large files
 	if info.Size() > maxFileSize {
-		if debug {
-			log.Printf("Skipping large file: %s (%.2f MB)", filePath, float64(info.Size())/(1024*1024))
+		if cnf.Debug {
+			logger.Debug("Skipping large file",
+				"path", filePath,
+				"size", info.Size(),
+				"maxSize", maxFileSize,
+				"debug", cnf.Debug,
+				"sizeMB", float64(info.Size())/(1024*1024),
+				"maxSizeMB", float64(maxFileSize)/(1024*1024),
+			)
 		}
 		return true
 	}
@@ -612,6 +635,7 @@ func shouldExcludeFile(filePath string) bool {
 
 // openMatchingFile opens a file if it matches any of the registered patterns
 func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
+	cnf := config.Get()
 	// Skip directories
 	info, err := os.Stat(path)
 	if err != nil || info.IsDir() {
@@ -626,8 +650,8 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
 	// Check if this path should be watched according to server registrations
 	if watched, _ := w.isPathWatched(path); watched {
 		// Don't need to check if it's already open - the client.OpenFile handles that
-		if err := w.client.OpenFile(ctx, path); err != nil && debug {
-			log.Printf("Error opening file %s: %v", path, err)
+		if err := w.client.OpenFile(ctx, path); err != nil && cnf.Debug {
+			logger.Error("Error opening file", "path", path, "error", err)
 		}
 	}
 }

internal/tui/components/core/status.go 🔗

@@ -7,7 +7,6 @@ import (
 	"github.com/charmbracelet/lipgloss"
 	"github.com/kujtimiihoxha/termai/internal/config"
 	"github.com/kujtimiihoxha/termai/internal/llm/models"
-	"github.com/kujtimiihoxha/termai/internal/pubsub"
 	"github.com/kujtimiihoxha/termai/internal/tui/styles"
 	"github.com/kujtimiihoxha/termai/internal/tui/util"
 	"github.com/kujtimiihoxha/termai/internal/version"
@@ -20,8 +19,8 @@ type statusCmp struct {
 }
 
 // clearMessageCmd is a command that clears status messages after a timeout
-func (m statusCmp) clearMessageCmd() tea.Cmd {
-	return tea.Tick(m.messageTTL, func(time.Time) tea.Msg {
+func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
+	return tea.Tick(ttl, func(time.Time) tea.Msg {
 		return util.ClearStatusMsg{}
 	})
 }
@@ -34,13 +33,14 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
-		return m, m.clearMessageCmd()
-	case pubsub.Event[util.InfoMsg]:
-		m.info = &msg.Payload
-		return m, m.clearMessageCmd()
+		return m, nil
 	case util.InfoMsg:
 		m.info = &msg
-		return m, m.clearMessageCmd()
+		ttl := msg.TTL
+		if ttl == 0 {
+			ttl = m.messageTTL
+		}
+		return m, m.clearMessageCmd(ttl)
 	case util.ClearStatusMsg:
 		m.info = nil
 	}
@@ -66,7 +66,13 @@ func (m statusCmp) View() string {
 		case util.InfoTypeError:
 			infoStyle = infoStyle.Background(styles.Red)
 		}
-		status += infoStyle.Render(m.info.Msg)
+		// Truncate message if it's longer than available width
+		msg := m.info.Msg
+		availWidth := m.availableFooterMsgWidth() - 3 // Account for ellipsis
+		if len(msg) > availWidth && availWidth > 0 {
+			msg = msg[:availWidth] + "..."
+		}
+		status += infoStyle.Render(msg)
 	} else {
 		status += styles.Padded.
 			Foreground(styles.Base).
@@ -91,6 +97,6 @@ func (m statusCmp) model() string {
 
 func NewStatusCmp() tea.Model {
 	return &statusCmp{
-		messageTTL: 15 * time.Second,
+		messageTTL: 10 * time.Second,
 	}
 }

internal/tui/components/logs/details.go 🔗

@@ -0,0 +1,176 @@
+package logs
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/kujtimiihoxha/termai/internal/logging"
+	"github.com/kujtimiihoxha/termai/internal/tui/layout"
+	"github.com/kujtimiihoxha/termai/internal/tui/styles"
+)
+
+type DetailComponent interface {
+	tea.Model
+	layout.Focusable
+	layout.Sizeable
+	layout.Bindings
+	layout.Bordered
+}
+
+type detailCmp struct {
+	width, height int
+	focused       bool
+	currentLog    logging.LogMessage
+	viewport      viewport.Model
+}
+
+func (i *detailCmp) Init() tea.Cmd {
+	messages := logging.Get().List()
+	if len(messages) == 0 {
+		return nil
+	}
+	i.currentLog = messages[0]
+	return nil
+}
+
+func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var (
+		cmd  tea.Cmd
+		cmds []tea.Cmd
+	)
+
+	switch msg := msg.(type) {
+	case selectedLogMsg:
+		if msg.ID != i.currentLog.ID {
+			i.currentLog = logging.LogMessage(msg)
+			i.updateContent()
+		}
+	}
+
+	if i.focused {
+		i.viewport, cmd = i.viewport.Update(msg)
+		cmds = append(cmds, cmd)
+	}
+
+	return i, tea.Batch(cmds...)
+}
+
+func (i *detailCmp) updateContent() {
+	var content strings.Builder
+
+	// Format the header with timestamp and level
+	timeStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
+	levelStyle := getLevelStyle(i.currentLog.Level)
+
+	header := lipgloss.JoinHorizontal(
+		lipgloss.Center,
+		timeStyle.Render(i.currentLog.Time.Format(time.RFC3339)),
+		"  ",
+		levelStyle.Render(i.currentLog.Level),
+	)
+
+	content.WriteString(lipgloss.NewStyle().Bold(true).Render(header))
+	content.WriteString("\n\n")
+
+	// Message with styling
+	messageStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Text)
+	content.WriteString(messageStyle.Render("Message:"))
+	content.WriteString("\n")
+	content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
+	content.WriteString("\n\n")
+
+	// Attributes section
+	if len(i.currentLog.Attributes) > 0 {
+		attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Text)
+		content.WriteString(attrHeaderStyle.Render("Attributes:"))
+		content.WriteString("\n")
+
+		// Create a table-like display for attributes
+		keyStyle := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true)
+		valueStyle := lipgloss.NewStyle().Foreground(styles.Text)
+
+		for _, attr := range i.currentLog.Attributes {
+			attrLine := fmt.Sprintf("%s: %s",
+				keyStyle.Render(attr.Key),
+				valueStyle.Render(attr.Value),
+			)
+			content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine))
+			content.WriteString("\n")
+		}
+	}
+
+	i.viewport.SetContent(content.String())
+}
+
+func getLevelStyle(level string) lipgloss.Style {
+	style := lipgloss.NewStyle().Bold(true)
+
+	switch strings.ToLower(level) {
+	case "info":
+		return style.Foreground(styles.Blue)
+	case "warn", "warning":
+		return style.Foreground(styles.Warning)
+	case "error", "err":
+		return style.Foreground(styles.Error)
+	case "debug":
+		return style.Foreground(styles.Green)
+	default:
+		return style.Foreground(styles.Text)
+	}
+}
+
+func (i *detailCmp) View() string {
+	return i.viewport.View()
+}
+
+func (i *detailCmp) Blur() tea.Cmd {
+	i.focused = false
+	return nil
+}
+
+func (i *detailCmp) Focus() tea.Cmd {
+	i.focused = true
+	return nil
+}
+
+func (i *detailCmp) IsFocused() bool {
+	return i.focused
+}
+
+func (i *detailCmp) GetSize() (int, int) {
+	return i.width, i.height
+}
+
+func (i *detailCmp) SetSize(width int, height int) {
+	i.width = width
+	i.height = height
+	i.viewport.Width = i.width
+	i.viewport.Height = i.height
+	i.updateContent()
+}
+
+func (i *detailCmp) BindingKeys() []key.Binding {
+	return []key.Binding{
+		i.viewport.KeyMap.PageDown,
+		i.viewport.KeyMap.PageUp,
+		i.viewport.KeyMap.HalfPageDown,
+		i.viewport.KeyMap.HalfPageUp,
+	}
+}
+
+func (i *detailCmp) BorderText() map[layout.BorderPosition]string {
+	return map[layout.BorderPosition]string{
+		layout.TopLeftBorder: "Log Details",
+	}
+}
+
+func NewLogsDetails() DetailComponent {
+	return &detailCmp{
+		viewport: viewport.New(0, 0),
+	}
+}

internal/tui/components/logs/table.go 🔗

@@ -11,6 +11,7 @@ import (
 	"github.com/kujtimiihoxha/termai/internal/pubsub"
 	"github.com/kujtimiihoxha/termai/internal/tui/layout"
 	"github.com/kujtimiihoxha/termai/internal/tui/styles"
+	"github.com/kujtimiihoxha/termai/internal/tui/util"
 )
 
 type TableComponent interface {
@@ -26,29 +27,42 @@ type tableCmp struct {
 	table table.Model
 }
 
+type selectedLogMsg logging.LogMessage
+
 func (i *tableCmp) Init() tea.Cmd {
 	i.setRows()
 	return nil
 }
 
 func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
 	if i.table.Focused() {
-		switch msg := msg.(type) {
-		case pubsub.Event[logging.Message]:
+		switch msg.(type) {
+		case pubsub.Event[logging.LogMessage]:
 			i.setRows()
 			return i, nil
-		case tea.KeyMsg:
-			if msg.String() == "ctrl+s" {
-				logger.Info("Saving logs...",
-					"rows", len(i.table.Rows()),
-				)
-			}
 		}
+		prevSelectedRow := i.table.SelectedRow()
 		t, cmd := i.table.Update(msg)
+		cmds = append(cmds, cmd)
 		i.table = t
-		return i, cmd
+		selectedRow := i.table.SelectedRow()
+		if selectedRow != nil {
+			if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
+				var log logging.LogMessage
+				for _, row := range logging.Get().List() {
+					if row.ID == selectedRow[0] {
+						log = row
+						break
+					}
+				}
+				if log.ID != "" {
+					cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
+				}
+			}
+		}
 	}
-	return i, nil
+	return i, tea.Batch(cmds...)
 }
 
 func (i *tableCmp) View() string {
@@ -92,7 +106,7 @@ func (i *tableCmp) setRows() {
 	rows := []table.Row{}
 
 	logs := logger.List()
-	slices.SortFunc(logs, func(a, b logging.Message) int {
+	slices.SortFunc(logs, func(a, b logging.LogMessage) int {
 		if a.Time.Before(b.Time) {
 			return 1
 		}
@@ -106,6 +120,7 @@ func (i *tableCmp) setRows() {
 		bm, _ := json.Marshal(log.Attributes)
 
 		row := table.Row{
+			log.ID,
 			log.Time.Format("15:04:05"),
 			log.Level,
 			log.Message,
@@ -118,6 +133,7 @@ func (i *tableCmp) setRows() {
 
 func NewLogsTable() TableComponent {
 	columns := []table.Column{
+		{Title: "ID", Width: 4},
 		{Title: "Time", Width: 4},
 		{Title: "Level", Width: 10},
 		{Title: "Message", Width: 10},

internal/tui/components/repl/sessions.go 🔗

@@ -123,6 +123,7 @@ func (i *sessionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, sessionKeyMapValue.Select):
+			i.app.Logger.PersistInfo("Session selected")
 			selected := i.list.SelectedItem()
 			if selected == nil {
 				return i, nil

internal/tui/layout/bento.go 🔗

@@ -187,7 +187,6 @@ func (b *bentoLayout) SetSize(width int, height int) {
 	b.width = width
 	b.height = height
 
-	// Check which panes are available
 	leftExists := false
 	rightTopExists := false
 	rightBottomExists := false
@@ -218,7 +217,6 @@ func (b *bentoLayout) SetSize(width int, height int) {
 			rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
 			rightBottomHeight = height - rightTopHeight
 
-			// Ensure minimum height for bottom pane
 			if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
 				rightBottomHeight = minRightBottomHeight
 				rightTopHeight = height - rightBottomHeight
@@ -271,23 +269,47 @@ func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
 }
 
 func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
+	orderForward := []paneID{BentoLeftPane, BentoRightTopPane, BentoRightBottomPane}
+	orderBackward := []paneID{BentoLeftPane, BentoRightBottomPane, BentoRightTopPane}
+
+	order := orderForward
 	if back {
-		switch b.currentPane {
-		case BentoLeftPane:
-			b.currentPane = BentoRightBottomPane
-		case BentoRightTopPane:
-			b.currentPane = BentoLeftPane
-		case BentoRightBottomPane:
-			b.currentPane = BentoRightTopPane
+		order = orderBackward
+	}
+
+	currentIdx := -1
+	for i, id := range order {
+		if id == b.currentPane {
+			currentIdx = i
+			break
+		}
+	}
+
+	if currentIdx == -1 {
+		for _, id := range order {
+			if _, exists := b.panes[id]; exists {
+				if _, hidden := b.hiddenPanes[id]; !hidden {
+					b.currentPane = id
+					break
+				}
+			}
 		}
 	} else {
-		switch b.currentPane {
-		case BentoLeftPane:
-			b.currentPane = BentoRightTopPane
-		case BentoRightTopPane:
-			b.currentPane = BentoRightBottomPane
-		case BentoRightBottomPane:
-			b.currentPane = BentoLeftPane
+		startIdx := currentIdx
+		for {
+			currentIdx = (currentIdx + 1) % len(order)
+
+			nextID := order[currentIdx]
+			if _, exists := b.panes[nextID]; exists {
+				if _, hidden := b.hiddenPanes[nextID]; !hidden {
+					b.currentPane = nextID
+					break
+				}
+			}
+
+			if currentIdx == startIdx {
+				break
+			}
 		}
 	}
 
@@ -319,7 +341,6 @@ type BentoLayoutOption func(*bentoLayout)
 func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
 	p := make(map[paneID]SinglePaneLayout, len(panes))
 	for id, pane := range panes {
-		// Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
 		if sp, ok := pane.(SinglePaneLayout); !ok {
 			p[id] = NewSinglePane(
 				pane,

internal/tui/page/logs.go 🔗

@@ -9,17 +9,12 @@ import (
 var LogsPage PageID = "logs"
 
 func NewLogsPage() tea.Model {
-	p := layout.NewSinglePane(
-		logs.NewLogsTable(),
-		layout.WithSinglePaneFocusable(true),
-		layout.WithSinglePaneBordered(true),
-		layout.WithSignlePaneBorderText(
-			map[layout.BorderPosition]string{
-				layout.TopMiddleBorder: "Logs",
-			},
-		),
-		layout.WithSinglePanePadding(1),
+	return layout.NewBentoLayout(
+		layout.BentoPanes{
+			layout.BentoRightTopPane:    logs.NewLogsTable(),
+			layout.BentoRightBottomPane: logs.NewLogsDetails(),
+		},
+		layout.WithBentoLayoutCurrentPane(layout.BentoRightTopPane),
+		layout.WithBentoLayoutRightTopHeightRatio(0.5),
 	)
-	p.Focus()
-	return p
 }

internal/tui/tui.go 🔗

@@ -5,6 +5,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/kujtimiihoxha/termai/internal/app"
+	"github.com/kujtimiihoxha/termai/internal/logging"
 	"github.com/kujtimiihoxha/termai/internal/permission"
 	"github.com/kujtimiihoxha/termai/internal/pubsub"
 	"github.com/kujtimiihoxha/termai/internal/tui/components/core"
@@ -74,22 +75,9 @@ func (a appModel) Init() tea.Cmd {
 }
 
 func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	var cmd tea.Cmd
 	switch msg := msg.(type) {
-	case pubsub.Event[permission.PermissionRequest]:
-		return a, dialog.NewPermissionDialogCmd(msg.Payload)
-	case pubsub.Event[util.InfoMsg]:
-		a.status, _ = a.status.Update(msg)
-	case dialog.PermissionResponseMsg:
-		switch msg.Action {
-		case dialog.PermissionAllow:
-			a.app.Permissions.Grant(msg.Permission)
-		case dialog.PermissionAllowForSession:
-			a.app.Permissions.GrantPersistant(msg.Permission)
-		case dialog.PermissionDeny:
-			a.app.Permissions.Deny(msg.Permission)
-		}
-	case vimtea.EditorModeMsg:
-		a.editorMode = msg.Mode
 	case tea.WindowSizeMsg:
 		var cmds []tea.Cmd
 		msg.Height -= 1 // Make space for the status bar
@@ -109,6 +97,59 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.dialog = d.(core.DialogCmp)
 
 		return a, tea.Batch(cmds...)
+
+	// Status
+	case util.InfoMsg:
+		a.status, cmd = a.status.Update(msg)
+		return a, cmd
+	case pubsub.Event[logging.LogMessage]:
+		if msg.Payload.Persist {
+			switch msg.Payload.Level {
+			case "error":
+				a.status, cmd = a.status.Update(util.InfoMsg{
+					Type: util.InfoTypeError,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				})
+			case "info":
+				a.status, cmd = a.status.Update(util.InfoMsg{
+					Type: util.InfoTypeInfo,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				})
+			case "warn":
+				a.status, cmd = a.status.Update(util.InfoMsg{
+					Type: util.InfoTypeWarn,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				})
+
+			default:
+				a.status, cmd = a.status.Update(util.InfoMsg{
+					Type: util.InfoTypeInfo,
+					Msg:  msg.Payload.Message,
+					TTL:  msg.Payload.PersistTime,
+				})
+			}
+			cmds = append(cmds, cmd)
+		}
+	case util.ClearStatusMsg:
+		a.status, _ = a.status.Update(msg)
+
+	// Permission
+	case pubsub.Event[permission.PermissionRequest]:
+		return a, dialog.NewPermissionDialogCmd(msg.Payload)
+	case dialog.PermissionResponseMsg:
+		switch msg.Action {
+		case dialog.PermissionAllow:
+			a.app.Permissions.Grant(msg.Permission)
+		case dialog.PermissionAllowForSession:
+			a.app.Permissions.GrantPersistant(msg.Permission)
+		case dialog.PermissionDeny:
+			a.app.Permissions.Deny(msg.Permission)
+		}
+
+	// Dialog
 	case core.DialogMsg:
 		d, cmd := a.dialog.Update(msg)
 		a.dialog = d.(core.DialogCmp)
@@ -119,10 +160,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		a.dialog = d.(core.DialogCmp)
 		a.dialogVisible = false
 		return a, cmd
+
+	// Editor
+	case vimtea.EditorModeMsg:
+		a.editorMode = msg.Mode
+
 	case page.PageChangeMsg:
 		return a, a.moveToPage(msg.ID)
-	case util.InfoMsg:
-		a.status, _ = a.status.Update(msg)
 	case tea.KeyMsg:
 		if a.editorMode == vimtea.ModeNormal {
 			switch {
@@ -162,18 +206,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	}
 
-	var cmds []tea.Cmd
-	s, cmd := a.status.Update(msg)
-	a.status = s
-	cmds = append(cmds, cmd)
 	if a.dialogVisible {
 		d, cmd := a.dialog.Update(msg)
 		a.dialog = d.(core.DialogCmp)
 		cmds = append(cmds, cmd)
 		return a, tea.Batch(cmds...)
 	}
-	p, cmd := a.pages[a.currentPage].Update(msg)
-	a.pages[a.currentPage] = p
+	a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
 	cmds = append(cmds, cmd)
 	return a, tea.Batch(cmds...)
 }

internal/tui/util/util.go 🔗

@@ -1,6 +1,10 @@
 package util
 
-import tea "github.com/charmbracelet/bubbletea"
+import (
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
 
 func CmdHandler(msg tea.Msg) tea.Cmd {
 	return func() tea.Msg {
@@ -41,6 +45,7 @@ type (
 	InfoMsg struct {
 		Type InfoType
 		Msg  string
+		TTL  time.Duration
 	}
 	ClearStatusMsg struct{}
 )

main.go 🔗

@@ -1,21 +1,9 @@
 package main
 
 import (
-	"log"
-	"os"
-
 	"github.com/kujtimiihoxha/termai/cmd"
 )
 
 func main() {
-	// Create a log file and make that the log output DEBUG
-	// TODO: remove this on release
-	logfile, err := os.OpenFile("debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
-	if err != nil {
-		panic(err)
-	}
-
-	log.SetOutput(logfile)
-
 	cmd.Execute()
 }