on main help sign in
finish logs page
commitd39d52d95d6aaab67fb3a17efb9ed62cc290e72f authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 9 Apr 2025, 19:07 (2 years ago) parent0d8d324
22 files changed+564−250
cmd/root.go−10 internal/app/services.go+2−7 internal/config/config.go+4 internal/llm/agent/agent.go+3−17 internal/llm/agent/mcp-tools.go+7−5 internal/llm/provider/gemini.go−6 internal/logging/logger.go+51−10 internal/logging/logging.go+8−2 internal/logging/message.go+9−7 internal/logging/writer.go+18−8 internal/lsp/client.go+14−6 internal/lsp/handlers.go+10−7 internal/lsp/transport.go+36−34 internal/lsp/watcher/watcher.go+71−47 internal/tui/components/core/status.go+15−9 internal/tui/components/logs/details.go+176 internal/tui/components/logs/table.go+27−11 internal/tui/layout/bento.go+38−17 internal/tui/page/logs.go+7−12 internal/tui/tui.go+62−22 internal/tui/util/util.go+6−1 main.go−12
cmd/root.go−10
@@ -107,16 +107,6 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
107107 wg.Done()
108108 }()
109109 }
110− {
111− sub := app.Status.Subscribe(ctx)
112− wg.Add(1)
113− go func() {
114− for ev := range sub {
115− ch <- ev
116− }
117− wg.Done()
118− }()
119− }
120110 return ch, func() {
121111 cancel()
122112 wg.Wait()
internal/app/services.go+2−7
@@ -11,9 +11,7 @@ import (
1111 "github.com/kujtimiihoxha/termai/internal/lsp/watcher"
1212 "github.com/kujtimiihoxha/termai/internal/message"
1313 "github.com/kujtimiihoxha/termai/internal/permission"
14− "github.com/kujtimiihoxha/termai/internal/pubsub"
1514 "github.com/kujtimiihoxha/termai/internal/session"
16− "github.com/kujtimiihoxha/termai/internal/tui/util"
1715)
1816
1917type App struct {
@@ -27,16 +25,14 @@ type App struct {
2725
2826 Logger logging.Interface
2927
30− Status *pubsub.Broker[util.InfoMsg]
3128 ceanups []func()
3229}
3330
3431func New(ctx context.Context, conn *sql.DB) *App {
3532 cfg := config.Get()
3633 q := db.New(conn)
37− log := logging.NewLogger(logging.Options{
38− Level: cfg.Log.Level,
39− })
34+ log := logging.Get()
35+ log.SetLevel(cfg.Log.Level)
4036 sessions := session.NewService(ctx, q)
4137 messages := message.NewService(ctx, q)
4238
@@ -46,7 +42,6 @@ func New(ctx context.Context, conn *sql.DB) *App {
4642 Messages: messages,
4743 Permissions: permission.NewPermissionService(),
4844 Logger: log,
49− Status: pubsub.NewBroker[util.InfoMsg](),
5045 LSPClients: make(map[string]*lsp.Client),
5146 }
5247
internal/config/config.go+4
@@ -65,6 +65,8 @@ type Config struct {
6565 LSP map[string]LSPConfig `json:"lsp,omitempty"`
6666
6767 Model *Model `json:"model,omitempty"`
68+
69+ Debug bool `json:"debug,omitempty"`
6870}
6971
7072var cfg *Config
@@ -90,8 +92,10 @@ func Load(debug bool) error {
9092 // Add defaults
9193 viper.SetDefault("data.directory", defaultDataDirectory)
9294 if debug {
95+ viper.SetDefault("debug", true)
9396 viper.Set("log.level", "debug")
9497 } else {
98+ viper.SetDefault("debug", false)
9599 viper.SetDefault("log.level", defaultLogLevel)
96100 }
97101
internal/llm/agent/agent.go+3−17
@@ -4,7 +4,6 @@ import (
44 "context"
55 "errors"
66 "fmt"
7− "log"
87 "strings"
98 "sync"
109
@@ -15,8 +14,6 @@ import (
1514 "github.com/kujtimiihoxha/termai/internal/llm/provider"
1615 "github.com/kujtimiihoxha/termai/internal/llm/tools"
1716 "github.com/kujtimiihoxha/termai/internal/message"
18− "github.com/kujtimiihoxha/termai/internal/pubsub"
19− "github.com/kujtimiihoxha/termai/internal/tui/util"
2017)
2118
2219type Agent interface {
@@ -94,24 +91,13 @@ func (c *agent) processEvent(
9491 assistantMsg.AppendContent(event.Content)
9592 return c.Messages.Update(*assistantMsg)
9693 case provider.EventError:
97− // TODO: remove when realease
98− log.Println("error", event.Error)
99− c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
100− Type: util.InfoTypeError,
101− Msg: event.Error.Error(),
102− })
94+ c.App.Logger.PersistError(event.Error.Error())
10395 return event.Error
10496 case provider.EventWarning:
105− c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
106− Type: util.InfoTypeWarn,
107− Msg: event.Info,
108− })
97+ c.App.Logger.PersistWarn(event.Info)
10998 return nil
11099 case provider.EventInfo:
111− c.App.Status.Publish(pubsub.UpdatedEvent, util.InfoMsg{
112− Type: util.InfoTypeInfo,
113− Msg: event.Info,
114− })
100+ c.App.Logger.PersistInfo(event.Info)
115101 case provider.EventComplete:
116102 assistantMsg.SetToolCalls(event.Response.ToolCalls)
117103 assistantMsg.AddFinish(event.Response.FinishReason)
internal/llm/agent/mcp-tools.go+7−5
@@ -4,10 +4,10 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7− "log"
87
98 "github.com/kujtimiihoxha/termai/internal/config"
109 "github.com/kujtimiihoxha/termai/internal/llm/tools"
10+ "github.com/kujtimiihoxha/termai/internal/logging"
1111 "github.com/kujtimiihoxha/termai/internal/permission"
1212 "github.com/kujtimiihoxha/termai/internal/version"
1313
@@ -22,6 +22,8 @@ type mcpTool struct {
2222 permissions permission.Service
2323}
2424
25+var logger = logging.Get()
26+
2527type MCPClient interface {
2628 Initialize(
2729 ctx context.Context,
@@ -141,13 +143,13 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
141143
142144 _, err := c.Initialize(ctx, initRequest)
143145 if err != nil {
144− log.Printf("error initializing mcp client: %s", err)
146+ logger.Error("error initializing mcp client", "error", err)
145147 return stdioTools
146148 }
147149 toolsRequest := mcp.ListToolsRequest{}
148150 tools, err := c.ListTools(ctx, toolsRequest)
149151 if err != nil {
150− log.Printf("error listing tools: %s", err)
152+ logger.Error("error listing tools", "error", err)
151153 return stdioTools
152154 }
153155 for _, t := range tools.Tools {
@@ -170,7 +172,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
170172 m.Args...,
171173 )
172174 if err != nil {
173− log.Printf("error creating mcp client: %s", err)
175+ logger.Error("error creating mcp client", "error", err)
174176 continue
175177 }
176178
@@ -181,7 +183,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
181183 client.WithHeaders(m.Headers),
182184 )
183185 if err != nil {
184− log.Printf("error creating mcp client: %s", err)
186+ logger.Error("error creating mcp client", "error", err)
185187 continue
186188 }
187189 mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
internal/llm/provider/gemini.go−6
@@ -4,14 +4,12 @@ import (
44 "context"
55 "encoding/json"
66 "errors"
7− "log"
87
98 "github.com/google/generative-ai-go/genai"
109 "github.com/google/uuid"
1110 "github.com/kujtimiihoxha/termai/internal/llm/models"
1211 "github.com/kujtimiihoxha/termai/internal/llm/tools"
1312 "github.com/kujtimiihoxha/termai/internal/message"
14− "google.golang.org/api/googleapi"
1513 "google.golang.org/api/iterator"
1614 "google.golang.org/api/option"
1715)
@@ -242,10 +240,6 @@ func (p *geminiProvider) StreamResponse(ctx context.Context, messages []message.
242240 break
243241 }
244242 if err != nil {
245− var apiErr *googleapi.Error
246− if errors.As(err, &apiErr) {
247− log.Printf("%s", apiErr.Body)
248− }
249243 eventChan <- ProviderEvent{
250244 Type: EventError,
251245 Error: err,
internal/logging/logger.go+51−10
@@ -12,6 +12,11 @@ import (
1212
1313const DefaultLevel = "info"
1414
15+const (
16+ persistKeyArg = "$persist"
17+ PersistTimeArg = "$persist_time"
18+)
19+
1520var levels = map[string]slog.Level{
1621 "debug": slog.LevelDebug,
1722 DefaultLevel: slog.LevelInfo,
@@ -36,16 +41,16 @@ func ValidLevels() []string {
3641 return keys
3742}
3843
39−func NewLogger(opts Options) *Logger {
44+func NewLogger(opts Options) Interface {
4045 logger := &Logger{}
41− broker := pubsub.NewBroker[Message]()
46+ broker := pubsub.NewBroker[LogMessage]()
4247 writer := &writer{
43− messages: []Message{},
48+ messages: []LogMessage{},
4449 Broker: broker,
4550 }
4651
4752 handler := slog.NewTextHandler(
48− io.MultiWriter(append(opts.AdditionalWriters, writer)...),
53+ io.MultiWriter(writer),
4954 &slog.HandlerOptions{
5055 Level: slog.Level(levels[opts.Level]),
5156 },
@@ -57,8 +62,7 @@ func NewLogger(opts Options) *Logger {
5762}
5863
5964type Options struct {
60− Level string
61− AdditionalWriters []io.Writer
65+ Level string
6266}
6367
6468type Logger struct {
@@ -66,6 +70,43 @@ type Logger struct {
6670 writer *writer
6771}
6872
73+func (l *Logger) SetLevel(level string) {
74+ if _, ok := levels[level]; !ok {
75+ level = DefaultLevel
76+ }
77+ handler := slog.NewTextHandler(
78+ io.MultiWriter(l.writer),
79+ &slog.HandlerOptions{
80+ Level: levels[level],
81+ },
82+ )
83+ l.logger = slog.New(handler)
84+}
85+
86+// PersistDebug implements Interface.
87+func (l *Logger) PersistDebug(msg string, args ...any) {
88+ args = append(args, persistKeyArg, true)
89+ l.Debug(msg, args...)
90+}
91+
92+// PersistError implements Interface.
93+func (l *Logger) PersistError(msg string, args ...any) {
94+ args = append(args, persistKeyArg, true)
95+ l.Error(msg, args...)
96+}
97+
98+// PersistInfo implements Interface.
99+func (l *Logger) PersistInfo(msg string, args ...any) {
100+ args = append(args, persistKeyArg, true)
101+ l.Info(msg, args...)
102+}
103+
104+// PersistWarn implements Interface.
105+func (l *Logger) PersistWarn(msg string, args ...any) {
106+ args = append(args, persistKeyArg, true)
107+ l.Warn(msg, args...)
108+}
109+
69110func (l *Logger) Debug(msg string, args ...any) {
70111 l.logger.Debug(msg, args...)
71112}
@@ -82,19 +123,19 @@ func (l *Logger) Error(msg string, args ...any) {
82123 l.logger.Error(msg, args...)
83124}
84125
85−func (l *Logger) List() []Message {
126+func (l *Logger) List() []LogMessage {
86127 return l.writer.messages
87128}
88129
89−func (l *Logger) Get(id string) (Message, error) {
130+func (l *Logger) Get(id string) (LogMessage, error) {
90131 for _, msg := range l.writer.messages {
91132 if msg.ID == id {
92133 return msg, nil
93134 }
94135 }
95− return Message{}, io.EOF
136+ return LogMessage{}, io.EOF
96137}
97138
98−func (l *Logger) Subscribe(ctx context.Context) <-chan pubsub.Event[Message] {
139+func (l *Logger) Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage] {
99140 return l.writer.Subscribe(ctx)
100141}
internal/logging/logging.go+8−2
@@ -11,7 +11,13 @@ type Interface interface {
1111 Info(msg string, args ...any)
1212 Warn(msg string, args ...any)
1313 Error(msg string, args ...any)
14− Subscribe(ctx context.Context) <-chan pubsub.Event[Message]
14+ Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage]
1515
16− List() []Message
16+ PersistDebug(msg string, args ...any)
17+ PersistInfo(msg string, args ...any)
18+ PersistWarn(msg string, args ...any)
19+ PersistError(msg string, args ...any)
20+ List() []LogMessage
21+
22+ SetLevel(level string)
1723}
internal/logging/message.go+9−7
@@ -4,13 +4,15 @@ import (
44 "time"
55)
66
7−// Message is the event payload for a log message
8−type Message struct {
9− ID string
10− Time time.Time
11− Level string
12− Message string `json:"msg"`
13− Attributes []Attr
7+// LogMessage is the event payload for a log message
8+type LogMessage struct {
9+ ID string
10+ Time time.Time
11+ Level string
12+ Persist bool // used when we want to show the mesage in the status bar
13+ PersistTime time.Duration // used when we want to show the mesage in the status bar
14+ Message string `json:"msg"`
15+ Attributes []Attr
1416}
1517
1618type Attr struct {
internal/logging/writer.go+18−8
@@ -10,15 +10,15 @@ import (
1010)
1111
1212type writer struct {
13− messages []Message
14− *pubsub.Broker[Message]
13+ messages []LogMessage
14+ *pubsub.Broker[LogMessage]
1515}
1616
1717func (w *writer) Write(p []byte) (int, error) {
1818 d := logfmt.NewDecoder(bytes.NewReader(p))
1919 for d.ScanRecord() {
20− msg := Message{
21− ID: time.Now().Format(time.RFC3339Nano),
20+ msg := LogMessage{
21+ ID: fmt.Sprintf("%d", time.Now().UnixNano()),
2222 }
2323 for d.ScanKeyval() {
2424 switch string(d.Key()) {
@@ -33,10 +33,20 @@ func (w *writer) Write(p []byte) (int, error) {
3333 case "msg":
3434 msg.Message = string(d.Value())
3535 default:
36− msg.Attributes = append(msg.Attributes, Attr{
37− Key: string(d.Key()),
38− Value: string(d.Value()),
39− })
36+ if string(d.Key()) == persistKeyArg {
37+ msg.Persist = true
38+ } else if string(d.Key()) == PersistTimeArg {
39+ parsed, err := time.ParseDuration(string(d.Value()))
40+ if err != nil {
41+ continue
42+ }
43+ msg.PersistTime = parsed
44+ } else {
45+ msg.Attributes = append(msg.Attributes, Attr{
46+ Key: string(d.Key()),
47+ Value: string(d.Value()),
48+ })
49+ }
4050 }
4151 }
4252 w.messages = append(w.messages, msg)
internal/lsp/client.go+14−6
@@ -6,7 +6,6 @@ import (
66 "encoding/json"
77 "fmt"
88 "io"
9− "log"
109 "os"
1110 "os/exec"
1211 "strings"
@@ -14,9 +13,13 @@ import (
1413 "sync/atomic"
1514 "time"
1615
16+ "github.com/kujtimiihoxha/termai/internal/config"
17+ "github.com/kujtimiihoxha/termai/internal/logging"
1718 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
1819)
1920
21+var logger = logging.Get()
22+
2023type Client struct {
2124 Cmd *exec.Cmd
2225 stdin io.WriteCloser
@@ -357,6 +360,7 @@ func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
357360}
358361
359362func (c *Client) CloseFile(ctx context.Context, filepath string) error {
363+ cnf := config.Get()
360364 uri := fmt.Sprintf("file://%s", filepath)
361365
362366 c.openFilesMu.Lock()
@@ -371,7 +375,10 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
371375 URI: protocol.DocumentUri(uri),
372376 },
373377 }
374− log.Println("Closing", params.TextDocument.URI.Dir())
378+
379+ if cnf.Debug {
380+ logger.Debug("Closing file", "file", filepath)
381+ }
375382 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
376383 return err
377384 }
@@ -393,6 +400,7 @@ func (c *Client) IsFileOpen(filepath string) bool {
393400
394401// CloseAllFiles closes all currently open files
395402func (c *Client) CloseAllFiles(ctx context.Context) {
403+ cnf := config.Get()
396404 c.openFilesMu.Lock()
397405 filesToClose := make([]string, 0, len(c.openFiles))
398406
@@ -407,13 +415,13 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
407415 // Then close them all
408416 for _, filePath := range filesToClose {
409417 err := c.CloseFile(ctx, filePath)
410− if err != nil && debug {
411− log.Printf("Error closing file %s: %v", filePath, err)
418+ if err != nil && cnf.Debug {
419+ logger.Warn("Error closing file", "file", filePath, "error", err)
412420 }
413421 }
414422
415− if debug {
416− log.Printf("Closed %d files", len(filesToClose))
423+ if cnf.Debug {
424+ logger.Debug("Closed all files", "files", filesToClose)
417425 }
418426}
419427
internal/lsp/handlers.go+10−7
@@ -2,8 +2,8 @@ package lsp
22
33import (
44 "encoding/json"
5− "log"
65
6+ "github.com/kujtimiihoxha/termai/internal/config"
77 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
88 "github.com/kujtimiihoxha/termai/internal/lsp/util"
99)
@@ -17,7 +17,7 @@ func HandleWorkspaceConfiguration(params json.RawMessage) (any, error) {
1717func HandleRegisterCapability(params json.RawMessage) (any, error) {
1818 var registerParams protocol.RegistrationParams
1919 if err := json.Unmarshal(params, &registerParams); err != nil {
20− log.Printf("Error unmarshaling registration params: %v", err)
20+ logger.Error("Error unmarshaling registration params", "error", err)
2121 return nil, err
2222 }
2323
@@ -27,13 +27,13 @@ func HandleRegisterCapability(params json.RawMessage) (any, error) {
2727 // Parse the registration options
2828 optionsJSON, err := json.Marshal(reg.RegisterOptions)
2929 if err != nil {
30− log.Printf("Error marshaling registration options: %v", err)
30+ logger.Error("Error marshaling registration options", "error", err)
3131 continue
3232 }
3333
3434 var options protocol.DidChangeWatchedFilesRegistrationOptions
3535 if err := json.Unmarshal(optionsJSON, &options); err != nil {
36− log.Printf("Error unmarshaling registration options: %v", err)
36+ logger.Error("Error unmarshaling registration options", "error", err)
3737 continue
3838 }
3939
@@ -53,7 +53,7 @@ func HandleApplyEdit(params json.RawMessage) (any, error) {
5353
5454 err := util.ApplyWorkspaceEdit(edit.Edit)
5555 if err != nil {
56− log.Printf("Error applying workspace edit: %v", err)
56+ logger.Error("Error applying workspace edit", "error", err)
5757 return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
5858 }
5959
@@ -81,19 +81,22 @@ func notifyFileWatchRegistration(id string, watchers []protocol.FileSystemWatche
8181// Notifications
8282
8383func HandleServerMessage(params json.RawMessage) {
84+ cnf := config.Get()
8485 var msg struct {
8586 Type int `json:"type"`
8687 Message string `json:"message"`
8788 }
8889 if err := json.Unmarshal(params, &msg); err == nil {
89− log.Printf("Server message: %s\n", msg.Message)
90+ if cnf.Debug {
91+ logger.Debug("Server message", "type", msg.Type, "message", msg.Message)
92+ }
9093 }
9194}
9295
9396func HandleDiagnostics(client *Client, params json.RawMessage) {
9497 var diagParams protocol.PublishDiagnosticsParams
9598 if err := json.Unmarshal(params, &diagParams); err != nil {
96− log.Printf("Error unmarshaling diagnostic params: %v", err)
99+ logger.Error("Error unmarshaling diagnostics params", "error", err)
97100 return
98101 }
99102
internal/lsp/transport.go+36−34
@@ -6,12 +6,10 @@ import (
66 "encoding/json"
77 "fmt"
88 "io"
9− "log"
10− "os"
119 "strings"
12−)
1310
14−var debug = os.Getenv("DEBUG") != ""
11+ "github.com/kujtimiihoxha/termai/internal/config"
12+)
1513
1614// Write writes an LSP message to the given writer
1715func WriteMessage(w io.Writer, msg *Message) error {
@@ -19,10 +17,10 @@ func WriteMessage(w io.Writer, msg *Message) error {
1917 if err != nil {
2018 return fmt.Errorf("failed to marshal message: %w", err)
2119 }
20+ cnf := config.Get()
2221
23− if debug {
24− log.Printf("%v", msg.Method)
25− log.Printf("-> Sending: %s", string(data))
22+ if cnf.Debug {
23+ logger.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
2624 }
2725
2826 _, 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 {
4038
4139// ReadMessage reads a single LSP message from the given reader
4240func ReadMessage(r *bufio.Reader) (*Message, error) {
41+ cnf := config.Get()
4342 // Read headers
4443 var contentLength int
4544 for {
@@ -49,8 +48,8 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
4948 }
5049 line = strings.TrimSpace(line)
5150
52− if debug {
53− log.Printf("<- Header: %s", line)
51+ if cnf.Debug {
52+ logger.Debug("Received header", "line", line)
5453 }
5554
5655 if line == "" {
@@ -65,8 +64,8 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
6564 }
6665 }
6766
68− if debug {
69− log.Printf("<- Reading content with length: %d", contentLength)
67+ if cnf.Debug {
68+ logger.Debug("Content-Length", "length", contentLength)
7069 }
7170
7271 // Read content
@@ -76,8 +75,8 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
7675 return nil, fmt.Errorf("failed to read content: %w", err)
7776 }
7877
79− if debug {
80− log.Printf("<- Received: %s", string(content))
78+ if cnf.Debug {
79+ logger.Debug("Received content", "content", string(content))
8180 }
8281
8382 // Parse message
@@ -91,19 +90,20 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
9190
9291// handleMessages reads and dispatches messages in a loop
9392func (c *Client) handleMessages() {
93+ cnf := config.Get()
9494 for {
9595 msg, err := ReadMessage(c.stdout)
9696 if err != nil {
97− if debug {
98− log.Printf("Error reading message: %v", err)
97+ if cnf.Debug {
98+ logger.Error("Error reading message", "error", err)
9999 }
100100 return
101101 }
102102
103103 // Handle server->client request (has both Method and ID)
104104 if msg.Method != "" && msg.ID != 0 {
105− if debug {
106− log.Printf("Received request from server: method=%s id=%d", msg.Method, msg.ID)
105+ if cnf.Debug {
106+ logger.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
107107 }
108108
109109 response := &Message{
@@ -143,7 +143,7 @@ func (c *Client) handleMessages() {
143143
144144 // Send response back to server
145145 if err := WriteMessage(c.stdin, response); err != nil {
146− log.Printf("Error sending response to server: %v", err)
146+ logger.Error("Error sending response to server", "error", err)
147147 }
148148
149149 continue
@@ -156,12 +156,12 @@ func (c *Client) handleMessages() {
156156 c.notificationMu.RUnlock()
157157
158158 if ok {
159− if debug {
160− log.Printf("Handling notification: %s", msg.Method)
159+ if cnf.Debug {
160+ logger.Debug("Handling notification", "method", msg.Method)
161161 }
162162 go handler(msg.Params)
163− } else if debug {
164− log.Printf("No handler for notification: %s", msg.Method)
163+ } else if cnf.Debug {
164+ logger.Debug("No handler for notification", "method", msg.Method)
165165 }
166166 continue
167167 }
@@ -173,13 +173,13 @@ func (c *Client) handleMessages() {
173173 c.handlersMu.RUnlock()
174174
175175 if ok {
176− if debug {
177− log.Printf("Sending response for ID %d to handler", msg.ID)
176+ if cnf.Debug {
177+ logger.Debug("Received response for request", "id", msg.ID)
178178 }
179179 ch <- msg
180180 close(ch)
181− } else if debug {
182− log.Printf("No handler for response ID: %d", msg.ID)
181+ } else if cnf.Debug {
182+ logger.Debug("No handler for response", "id", msg.ID)
183183 }
184184 }
185185 }
@@ -187,10 +187,11 @@ func (c *Client) handleMessages() {
187187
188188// Call makes a request and waits for the response
189189func (c *Client) Call(ctx context.Context, method string, params any, result any) error {
190+ cnf := config.Get()
190191 id := c.nextID.Add(1)
191192
192− if debug {
193− log.Printf("Making call: method=%s id=%d", method, id)
193+ if cnf.Debug {
194+ logger.Debug("Making call", "method", method, "id", id)
194195 }
195196
196197 msg, err := NewRequest(id, method, params)
@@ -215,15 +216,15 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
215216 return fmt.Errorf("failed to send request: %w", err)
216217 }
217218
218− if debug {
219− log.Printf("Waiting for response to request ID: %d", id)
219+ if cnf.Debug {
220+ logger.Debug("Request sent", "method", method, "id", id)
220221 }
221222
222223 // Wait for response
223224 resp := <-ch
224225
225− if debug {
226− log.Printf("Received response for request ID: %d", id)
226+ if cnf.Debug {
227+ logger.Debug("Received response", "id", id)
227228 }
228229
229230 if resp.Error != nil {
@@ -247,8 +248,9 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
247248
248249// Notify sends a notification (a request without an ID that doesn't expect a response)
249250func (c *Client) Notify(ctx context.Context, method string, params any) error {
250− if debug {
251− log.Printf("Sending notification: method=%s", method)
251+ cnf := config.Get()
252+ if cnf.Debug {
253+ logger.Debug("Sending notification", "method", method)
252254 }
253255
254256 msg, err := NewNotification(method, params)
internal/lsp/watcher/watcher.go+71−47
@@ -3,7 +3,6 @@ package watcher
33import (
44 "context"
55 "fmt"
6− "log"
76 "os"
87 "path/filepath"
98 "strings"
@@ -11,11 +10,13 @@ import (
1110 "time"
1211
1312 "github.com/fsnotify/fsnotify"
13+ "github.com/kujtimiihoxha/termai/internal/config"
14+ "github.com/kujtimiihoxha/termai/internal/logging"
1415 "github.com/kujtimiihoxha/termai/internal/lsp"
1516 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
1617)
1718
18−var debug = false // Force debug logging on
19+var logger = logging.Get()
1920
2021// WorkspaceWatcher manages LSP file watching
2122type WorkspaceWatcher struct {
@@ -43,6 +44,7 @@ func NewWorkspaceWatcher(client *lsp.Client) *WorkspaceWatcher {
4344
4445// AddRegistrations adds file watchers to track
4546func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watchers []protocol.FileSystemWatcher) {
47+ cnf := config.Get()
4648 w.registrationMu.Lock()
4749 defer w.registrationMu.Unlock()
4850
@@ -50,31 +52,35 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
5052 w.registrations = append(w.registrations, watchers...)
5153
5254 // Print detailed registration information for debugging
53− if debug {
54− log.Printf("Added %d file watcher registrations (id: %s), total: %d",
55− len(watchers), id, len(w.registrations))
55+ if cnf.Debug {
56+ logger.Debug("Adding file watcher registrations",
57+ "id", id,
58+ "watchers", len(watchers),
59+ "total", len(w.registrations),
60+ "watchers", watchers,
61+ )
5662
5763 for i, watcher := range watchers {
58− log.Printf("Registration #%d raw data:", i+1)
64+ logger.Debug("Registration", "index", i+1)
5965
6066 // Log the GlobPattern
6167 switch v := watcher.GlobPattern.Value.(type) {
6268 case string:
63− log.Printf(" GlobPattern: string pattern '%s'", v)
69+ logger.Debug("GlobPattern", "pattern", v)
6470 case protocol.RelativePattern:
65− log.Printf(" GlobPattern: RelativePattern with pattern '%s'", v.Pattern)
71+ logger.Debug("GlobPattern", "pattern", v.Pattern)
6672
6773 // Log BaseURI details
6874 switch u := v.BaseURI.Value.(type) {
6975 case string:
70− log.Printf(" BaseURI: string '%s'", u)
76+ logger.Debug("BaseURI", "baseURI", u)
7177 case protocol.DocumentUri:
72− log.Printf(" BaseURI: DocumentUri '%s'", u)
78+ logger.Debug("BaseURI", "baseURI", u)
7379 default:
74− log.Printf(" BaseURI: unknown type %T", u)
80+ logger.Debug("BaseURI", "baseURI", u)
7581 }
7682 default:
77− log.Printf(" GlobPattern: unknown type %T", v)
83+ logger.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
7884 }
7985
8086 // Log WatchKind
@@ -82,11 +88,8 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
8288 if watcher.Kind != nil {
8389 watchKind = *watcher.Kind
8490 }
85− log.Printf(" WatchKind: %d (Create:%v, Change:%v, Delete:%v)",
86− watchKind,
87− watchKind&protocol.WatchCreate != 0,
88− watchKind&protocol.WatchChange != 0,
89− watchKind&protocol.WatchDelete != 0)
91+
92+ logger.Debug("WatchKind", "kind", watchKind)
9093
9194 // Test match against some example paths
9295 testPaths := []string{
@@ -96,7 +99,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
9699
97100 for _, testPath := range testPaths {
98101 isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
99− log.Printf(" Test path '%s': %v", testPath, isMatch)
102+ logger.Debug("Test path", "path", testPath, "matches", isMatch)
100103 }
101104 }
102105 }
@@ -114,10 +117,9 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
114117
115118 // Skip directories that should be excluded
116119 if d.IsDir() {
117− log.Println(path)
118120 if path != w.workspacePath && shouldExcludeDir(path) {
119− if debug {
120− log.Printf("Skipping excluded directory!!: %s", path)
121+ if cnf.Debug {
122+ logger.Debug("Skipping excluded directory", "path", path)
121123 }
122124 return filepath.SkipDir
123125 }
@@ -136,18 +138,23 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
136138 })
137139
138140 elapsedTime := time.Since(startTime)
139− if debug {
140− log.Printf("Workspace scan complete: processed %d files in %.2f seconds", filesOpened, elapsedTime.Seconds())
141+ if cnf.Debug {
142+ logger.Debug("Workspace scan complete",
143+ "filesOpened", filesOpened,
144+ "elapsedTime", elapsedTime.Seconds(),
145+ "workspacePath", w.workspacePath,
146+ )
141147 }
142148
143− if err != nil && debug {
144− log.Printf("Error scanning workspace for files to open: %v", err)
149+ if err != nil && cnf.Debug {
150+ logger.Debug("Error scanning workspace for files to open", "error", err)
145151 }
146152 }()
147153}
148154
149155// WatchWorkspace sets up file watching for a workspace
150156func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) {
157+ cnf := config.Get()
151158 w.workspacePath = workspacePath
152159
153160 // Register handler for file watcher registrations from the server
@@ -157,7 +164,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
157164
158165 watcher, err := fsnotify.NewWatcher()
159166 if err != nil {
160− log.Fatalf("Error creating watcher: %v", err)
167+ logger.Error("Error creating watcher", "error", err)
161168 }
162169 defer watcher.Close()
163170
@@ -170,8 +177,8 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
170177 // Skip excluded directories (except workspace root)
171178 if d.IsDir() && path != workspacePath {
172179 if shouldExcludeDir(path) {
173− if debug {
174− log.Printf("Skipping watching excluded directory: %s", path)
180+ if cnf.Debug {
181+ logger.Debug("Skipping excluded directory", "path", path)
175182 }
176183 return filepath.SkipDir
177184 }
@@ -181,14 +188,14 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
181188 if d.IsDir() {
182189 err = watcher.Add(path)
183190 if err != nil {
184− log.Printf("Error watching path %s: %v", path, err)
191+ logger.Error("Error watching path", "path", path, "error", err)
185192 }
186193 }
187194
188195 return nil
189196 })
190197 if err != nil {
191− log.Fatalf("Error walking workspace: %v", err)
198+ logger.Error("Error walking workspace", "error", err)
192199 }
193200
194201 // Event loop
@@ -210,7 +217,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
210217 // Skip excluded directories
211218 if !shouldExcludeDir(event.Name) {
212219 if err := watcher.Add(event.Name); err != nil {
213− log.Printf("Error watching new directory: %v", err)
220+ logger.Error("Error adding directory to watcher", "path", event.Name, "error", err)
214221 }
215222 }
216223 } else {
@@ -223,10 +230,15 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
223230 }
224231
225232 // Debug logging
226− if debug {
233+ if cnf.Debug {
227234 matched, kind := w.isPathWatched(event.Name)
228− log.Printf("Event: %s, Op: %s, Watched: %v, Kind: %d",
229− event.Name, event.Op.String(), matched, kind)
235+ logger.Debug("File event",
236+ "path", event.Name,
237+ "operation", event.Op.String(),
238+ "watched", matched,
239+ "kind", kind,
240+ )
241+
230242 }
231243
232244 // Check if this path should be watched according to server registrations
@@ -265,7 +277,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
265277 if !ok {
266278 return
267279 }
268− log.Printf("Watcher error: %v\n", err)
280+ logger.Error("Error watching file", "error", err)
269281 }
270282 }
271283}
@@ -390,7 +402,7 @@ func matchesSimpleGlob(pattern, path string) bool {
390402 // Fall back to simple matching for simpler patterns
391403 matched, err := filepath.Match(pattern, path)
392404 if err != nil {
393− log.Printf("Error matching pattern %s: %v", pattern, err)
405+ logger.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
394406 return false
395407 }
396408
@@ -401,7 +413,7 @@ func matchesSimpleGlob(pattern, path string) bool {
401413func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
402414 patternInfo, err := pattern.AsPattern()
403415 if err != nil {
404− log.Printf("Error parsing pattern: %v", err)
416+ logger.Error("Error parsing pattern", "pattern", pattern, "error", err)
405417 return false
406418 }
407419
@@ -426,7 +438,7 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
426438 // Make path relative to basePath for matching
427439 relPath, err := filepath.Rel(basePath, path)
428440 if err != nil {
429− log.Printf("Error getting relative path for %s: %v", path, err)
441+ logger.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
430442 return false
431443 }
432444 relPath = filepath.ToSlash(relPath)
@@ -467,21 +479,25 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
467479 if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
468480 err := w.client.NotifyChange(ctx, filePath)
469481 if err != nil {
470− log.Printf("Error notifying change: %v", err)
482+ logger.Error("Error notifying change", "error", err)
471483 }
472484 return
473485 }
474486
475487 // Notify LSP server about the file event using didChangeWatchedFiles
476488 if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
477− log.Printf("Error notifying LSP server about file event: %v", err)
489+ logger.Error("Error notifying LSP server about file event", "error", err)
478490 }
479491}
480492
481493// notifyFileEvent sends a didChangeWatchedFiles notification for a file event
482494func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
483− if debug {
484− log.Printf("Notifying file event: %s (type: %d)", uri, changeType)
495+ cnf := config.Get()
496+ if cnf.Debug {
497+ logger.Debug("Notifying file event",
498+ "uri", uri,
499+ "changeType", changeType,
500+ )
485501 }
486502
487503 params := protocol.DidChangeWatchedFilesParams{
@@ -575,7 +591,7 @@ func shouldExcludeDir(dirPath string) bool {
575591// shouldExcludeFile returns true if the file should be excluded from opening
576592func shouldExcludeFile(filePath string) bool {
577593 fileName := filepath.Base(filePath)
578−
594+ cnf := config.Get()
579595 // Skip dot files
580596 if strings.HasPrefix(fileName, ".") {
581597 return true
@@ -601,8 +617,15 @@ func shouldExcludeFile(filePath string) bool {
601617
602618 // Skip large files
603619 if info.Size() > maxFileSize {
604− if debug {
605− log.Printf("Skipping large file: %s (%.2f MB)", filePath, float64(info.Size())/(1024*1024))
620+ if cnf.Debug {
621+ logger.Debug("Skipping large file",
622+ "path", filePath,
623+ "size", info.Size(),
624+ "maxSize", maxFileSize,
625+ "debug", cnf.Debug,
626+ "sizeMB", float64(info.Size())/(1024*1024),
627+ "maxSizeMB", float64(maxFileSize)/(1024*1024),
628+ )
606629 }
607630 return true
608631 }
@@ -612,6 +635,7 @@ func shouldExcludeFile(filePath string) bool {
612635
613636// openMatchingFile opens a file if it matches any of the registered patterns
614637func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
638+ cnf := config.Get()
615639 // Skip directories
616640 info, err := os.Stat(path)
617641 if err != nil || info.IsDir() {
@@ -626,8 +650,8 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
626650 // Check if this path should be watched according to server registrations
627651 if watched, _ := w.isPathWatched(path); watched {
628652 // Don't need to check if it's already open - the client.OpenFile handles that
629− if err := w.client.OpenFile(ctx, path); err != nil && debug {
630− log.Printf("Error opening file %s: %v", path, err)
653+ if err := w.client.OpenFile(ctx, path); err != nil && cnf.Debug {
654+ logger.Error("Error opening file", "path", path, "error", err)
631655 }
632656 }
633657}
internal/tui/components/core/status.go+15−9
@@ -7,7 +7,6 @@ import (
77 "github.com/charmbracelet/lipgloss"
88 "github.com/kujtimiihoxha/termai/internal/config"
99 "github.com/kujtimiihoxha/termai/internal/llm/models"
10− "github.com/kujtimiihoxha/termai/internal/pubsub"
1110 "github.com/kujtimiihoxha/termai/internal/tui/styles"
1211 "github.com/kujtimiihoxha/termai/internal/tui/util"
1312 "github.com/kujtimiihoxha/termai/internal/version"
@@ -20,8 +19,8 @@ type statusCmp struct {
2019}
2120
2221// clearMessageCmd is a command that clears status messages after a timeout
23−func (m statusCmp) clearMessageCmd() tea.Cmd {
24− return tea.Tick(m.messageTTL, func(time.Time) tea.Msg {
22+func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
23+ return tea.Tick(ttl, func(time.Time) tea.Msg {
2524 return util.ClearStatusMsg{}
2625 })
2726}
@@ -34,13 +33,14 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3433 switch msg := msg.(type) {
3534 case tea.WindowSizeMsg:
3635 m.width = msg.Width
37− return m, m.clearMessageCmd()
38− case pubsub.Event[util.InfoMsg]:
39− m.info = &msg.Payload
40− return m, m.clearMessageCmd()
36+ return m, nil
4137 case util.InfoMsg:
4238 m.info = &msg
43− return m, m.clearMessageCmd()
39+ ttl := msg.TTL
40+ if ttl == 0 {
41+ ttl = m.messageTTL
42+ }
43+ return m, m.clearMessageCmd(ttl)
4444 case util.ClearStatusMsg:
4545 m.info = nil
4646 }
@@ -66,7 +66,13 @@ func (m statusCmp) View() string {
6666 case util.InfoTypeError:
6767 infoStyle = infoStyle.Background(styles.Red)
6868 }
69− status += infoStyle.Render(m.info.Msg)
69+ // Truncate message if it's longer than available width
70+ msg := m.info.Msg
71+ availWidth := m.availableFooterMsgWidth() - 3 // Account for ellipsis
72+ if len(msg) > availWidth && availWidth > 0 {
73+ msg = msg[:availWidth] + "..."
74+ }
75+ status += infoStyle.Render(msg)
7076 } else {
7177 status += styles.Padded.
7278 Foreground(styles.Base).
internal/tui/components/logs/details.gonew file+176
@@ -0,0 +1,176 @@
1+package logs
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/charmbracelet/bubbles/key"
9+ "github.com/charmbracelet/bubbles/viewport"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+ "github.com/kujtimiihoxha/termai/internal/logging"
13+ "github.com/kujtimiihoxha/termai/internal/tui/layout"
14+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
15+)
16+
17+type DetailComponent interface {
18+ tea.Model
19+ layout.Focusable
20+ layout.Sizeable
21+ layout.Bindings
22+ layout.Bordered
23+}
24+
25+type detailCmp struct {
26+ width, height int
27+ focused bool
28+ currentLog logging.LogMessage
29+ viewport viewport.Model
30+}
31+
32+func (i *detailCmp) Init() tea.Cmd {
33+ messages := logging.Get().List()
34+ if len(messages) == 0 {
35+ return nil
36+ }
37+ i.currentLog = messages[0]
38+ return nil
39+}
40+
41+func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42+ var (
43+ cmd tea.Cmd
44+ cmds []tea.Cmd
45+ )
46+
47+ switch msg := msg.(type) {
48+ case selectedLogMsg:
49+ if msg.ID != i.currentLog.ID {
50+ i.currentLog = logging.LogMessage(msg)
51+ i.updateContent()
52+ }
53+ }
54+
55+ if i.focused {
56+ i.viewport, cmd = i.viewport.Update(msg)
57+ cmds = append(cmds, cmd)
58+ }
59+
60+ return i, tea.Batch(cmds...)
61+}
62+
63+func (i *detailCmp) updateContent() {
64+ var content strings.Builder
65+
66+ // Format the header with timestamp and level
67+ timeStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
68+ levelStyle := getLevelStyle(i.currentLog.Level)
69+
70+ header := lipgloss.JoinHorizontal(
71+ lipgloss.Center,
72+ timeStyle.Render(i.currentLog.Time.Format(time.RFC3339)),
73+ " ",
74+ levelStyle.Render(i.currentLog.Level),
75+ )
76+
77+ content.WriteString(lipgloss.NewStyle().Bold(true).Render(header))
78+ content.WriteString("\n\n")
79+
80+ // Message with styling
81+ messageStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Text)
82+ content.WriteString(messageStyle.Render("Message:"))
83+ content.WriteString("\n")
84+ content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(i.currentLog.Message))
85+ content.WriteString("\n\n")
86+
87+ // Attributes section
88+ if len(i.currentLog.Attributes) > 0 {
89+ attrHeaderStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Text)
90+ content.WriteString(attrHeaderStyle.Render("Attributes:"))
91+ content.WriteString("\n")
92+
93+ // Create a table-like display for attributes
94+ keyStyle := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true)
95+ valueStyle := lipgloss.NewStyle().Foreground(styles.Text)
96+
97+ for _, attr := range i.currentLog.Attributes {
98+ attrLine := fmt.Sprintf("%s: %s",
99+ keyStyle.Render(attr.Key),
100+ valueStyle.Render(attr.Value),
101+ )
102+ content.WriteString(lipgloss.NewStyle().Padding(0, 2).Render(attrLine))
103+ content.WriteString("\n")
104+ }
105+ }
106+
107+ i.viewport.SetContent(content.String())
108+}
109+
110+func getLevelStyle(level string) lipgloss.Style {
111+ style := lipgloss.NewStyle().Bold(true)
112+
113+ switch strings.ToLower(level) {
114+ case "info":
115+ return style.Foreground(styles.Blue)
116+ case "warn", "warning":
117+ return style.Foreground(styles.Warning)
118+ case "error", "err":
119+ return style.Foreground(styles.Error)
120+ case "debug":
121+ return style.Foreground(styles.Green)
122+ default:
123+ return style.Foreground(styles.Text)
124+ }
125+}
126+
127+func (i *detailCmp) View() string {
128+ return i.viewport.View()
129+}
130+
131+func (i *detailCmp) Blur() tea.Cmd {
132+ i.focused = false
133+ return nil
134+}
135+
136+func (i *detailCmp) Focus() tea.Cmd {
137+ i.focused = true
138+ return nil
139+}
140+
141+func (i *detailCmp) IsFocused() bool {
142+ return i.focused
143+}
144+
145+func (i *detailCmp) GetSize() (int, int) {
146+ return i.width, i.height
147+}
148+
149+func (i *detailCmp) SetSize(width int, height int) {
150+ i.width = width
151+ i.height = height
152+ i.viewport.Width = i.width
153+ i.viewport.Height = i.height
154+ i.updateContent()
155+}
156+
157+func (i *detailCmp) BindingKeys() []key.Binding {
158+ return []key.Binding{
159+ i.viewport.KeyMap.PageDown,
160+ i.viewport.KeyMap.PageUp,
161+ i.viewport.KeyMap.HalfPageDown,
162+ i.viewport.KeyMap.HalfPageUp,
163+ }
164+}
165+
166+func (i *detailCmp) BorderText() map[layout.BorderPosition]string {
167+ return map[layout.BorderPosition]string{
168+ layout.TopLeftBorder: "Log Details",
169+ }
170+}
171+
172+func NewLogsDetails() DetailComponent {
173+ return &detailCmp{
174+ viewport: viewport.New(0, 0),
175+ }
176+}
internal/tui/components/logs/table.go+27−11
@@ -11,6 +11,7 @@ import (
1111 "github.com/kujtimiihoxha/termai/internal/pubsub"
1212 "github.com/kujtimiihoxha/termai/internal/tui/layout"
1313 "github.com/kujtimiihoxha/termai/internal/tui/styles"
14+ "github.com/kujtimiihoxha/termai/internal/tui/util"
1415)
1516
1617type TableComponent interface {
@@ -26,29 +27,42 @@ type tableCmp struct {
2627 table table.Model
2728}
2829
30+type selectedLogMsg logging.LogMessage
31+
2932func (i *tableCmp) Init() tea.Cmd {
3033 i.setRows()
3134 return nil
3235}
3336
3437func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
38+ var cmds []tea.Cmd
3539 if i.table.Focused() {
36− switch msg := msg.(type) {
37− case pubsub.Event[logging.Message]:
40+ switch msg.(type) {
41+ case pubsub.Event[logging.LogMessage]:
3842 i.setRows()
3943 return i, nil
40− case tea.KeyMsg:
41− if msg.String() == "ctrl+s" {
42− logger.Info("Saving logs...",
43− "rows", len(i.table.Rows()),
44− )
45− }
4644 }
45+ prevSelectedRow := i.table.SelectedRow()
4746 t, cmd := i.table.Update(msg)
47+ cmds = append(cmds, cmd)
4848 i.table = t
49− return i, cmd
49+ selectedRow := i.table.SelectedRow()
50+ if selectedRow != nil {
51+ if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
52+ var log logging.LogMessage
53+ for _, row := range logging.Get().List() {
54+ if row.ID == selectedRow[0] {
55+ log = row
56+ break
57+ }
58+ }
59+ if log.ID != "" {
60+ cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
61+ }
62+ }
63+ }
5064 }
51− return i, nil
65+ return i, tea.Batch(cmds...)
5266}
5367
5468func (i *tableCmp) View() string {
@@ -92,7 +106,7 @@ func (i *tableCmp) setRows() {
92106 rows := []table.Row{}
93107
94108 logs := logger.List()
95− slices.SortFunc(logs, func(a, b logging.Message) int {
109+ slices.SortFunc(logs, func(a, b logging.LogMessage) int {
96110 if a.Time.Before(b.Time) {
97111 return 1
98112 }
@@ -106,6 +120,7 @@ func (i *tableCmp) setRows() {
106120 bm, _ := json.Marshal(log.Attributes)
107121
108122 row := table.Row{
123+ log.ID,
109124 log.Time.Format("15:04:05"),
110125 log.Level,
111126 log.Message,
@@ -118,6 +133,7 @@ func (i *tableCmp) setRows() {
118133
119134func NewLogsTable() TableComponent {
120135 columns := []table.Column{
136+ {Title: "ID", Width: 4},
121137 {Title: "Time", Width: 4},
122138 {Title: "Level", Width: 10},
123139 {Title: "Message", Width: 10},
internal/tui/layout/bento.go+38−17
@@ -187,7 +187,6 @@ func (b *bentoLayout) SetSize(width int, height int) {
187187 b.width = width
188188 b.height = height
189189
190− // Check which panes are available
191190 leftExists := false
192191 rightTopExists := false
193192 rightBottomExists := false
@@ -218,7 +217,6 @@ func (b *bentoLayout) SetSize(width int, height int) {
218217 rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
219218 rightBottomHeight = height - rightTopHeight
220219
221− // Ensure minimum height for bottom pane
222220 if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
223221 rightBottomHeight = minRightBottomHeight
224222 rightTopHeight = height - rightBottomHeight
@@ -271,23 +269,47 @@ func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
271269}
272270
273271func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
272+ orderForward := []paneID{BentoLeftPane, BentoRightTopPane, BentoRightBottomPane}
273+ orderBackward := []paneID{BentoLeftPane, BentoRightBottomPane, BentoRightTopPane}
274+
275+ order := orderForward
274276 if back {
275− switch b.currentPane {
276− case BentoLeftPane:
277− b.currentPane = BentoRightBottomPane
278− case BentoRightTopPane:
279− b.currentPane = BentoLeftPane
280− case BentoRightBottomPane:
281− b.currentPane = BentoRightTopPane
277+ order = orderBackward
278+ }
279+
280+ currentIdx := -1
281+ for i, id := range order {
282+ if id == b.currentPane {
283+ currentIdx = i
284+ break
285+ }
286+ }
287+
288+ if currentIdx == -1 {
289+ for _, id := range order {
290+ if _, exists := b.panes[id]; exists {
291+ if _, hidden := b.hiddenPanes[id]; !hidden {
292+ b.currentPane = id
293+ break
294+ }
295+ }
282296 }
283297 } else {
284− switch b.currentPane {
285− case BentoLeftPane:
286− b.currentPane = BentoRightTopPane
287− case BentoRightTopPane:
288− b.currentPane = BentoRightBottomPane
289− case BentoRightBottomPane:
290− b.currentPane = BentoLeftPane
298+ startIdx := currentIdx
299+ for {
300+ currentIdx = (currentIdx + 1) % len(order)
301+
302+ nextID := order[currentIdx]
303+ if _, exists := b.panes[nextID]; exists {
304+ if _, hidden := b.hiddenPanes[nextID]; !hidden {
305+ b.currentPane = nextID
306+ break
307+ }
308+ }
309+
310+ if currentIdx == startIdx {
311+ break
312+ }
291313 }
292314 }
293315
@@ -319,7 +341,6 @@ type BentoLayoutOption func(*bentoLayout)
319341func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
320342 p := make(map[paneID]SinglePaneLayout, len(panes))
321343 for id, pane := range panes {
322− // Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
323344 if sp, ok := pane.(SinglePaneLayout); !ok {
324345 p[id] = NewSinglePane(
325346 pane,
internal/tui/page/logs.go+7−12
@@ -9,17 +9,12 @@ import (
99var LogsPage PageID = "logs"
1010
1111func NewLogsPage() tea.Model {
12− p := layout.NewSinglePane(
13− logs.NewLogsTable(),
14− layout.WithSinglePaneFocusable(true),
15− layout.WithSinglePaneBordered(true),
16− layout.WithSignlePaneBorderText(
17− map[layout.BorderPosition]string{
18− layout.TopMiddleBorder: "Logs",
19− },
20− ),
21− layout.WithSinglePanePadding(1),
12+ return layout.NewBentoLayout(
13+ layout.BentoPanes{
14+ layout.BentoRightTopPane: logs.NewLogsTable(),
15+ layout.BentoRightBottomPane: logs.NewLogsDetails(),
16+ },
17+ layout.WithBentoLayoutCurrentPane(layout.BentoRightTopPane),
18+ layout.WithBentoLayoutRightTopHeightRatio(0.5),
2219 )
23− p.Focus()
24− return p
2520}
internal/tui/tui.go+62−22
@@ -5,6 +5,7 @@ import (
55 tea "github.com/charmbracelet/bubbletea"
66 "github.com/charmbracelet/lipgloss"
77 "github.com/kujtimiihoxha/termai/internal/app"
8+ "github.com/kujtimiihoxha/termai/internal/logging"
89 "github.com/kujtimiihoxha/termai/internal/permission"
910 "github.com/kujtimiihoxha/termai/internal/pubsub"
1011 "github.com/kujtimiihoxha/termai/internal/tui/components/core"
@@ -74,22 +75,9 @@ func (a appModel) Init() tea.Cmd {
7475}
7576
7677func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78+ var cmds []tea.Cmd
79+ var cmd tea.Cmd
7780 switch msg := msg.(type) {
78− case pubsub.Event[permission.PermissionRequest]:
79− return a, dialog.NewPermissionDialogCmd(msg.Payload)
80− case pubsub.Event[util.InfoMsg]:
81− a.status, _ = a.status.Update(msg)
82− case dialog.PermissionResponseMsg:
83− switch msg.Action {
84− case dialog.PermissionAllow:
85− a.app.Permissions.Grant(msg.Permission)
86− case dialog.PermissionAllowForSession:
87− a.app.Permissions.GrantPersistant(msg.Permission)
88− case dialog.PermissionDeny:
89− a.app.Permissions.Deny(msg.Permission)
90− }
91− case vimtea.EditorModeMsg:
92− a.editorMode = msg.Mode
9381 case tea.WindowSizeMsg:
9482 var cmds []tea.Cmd
9583 msg.Height -= 1 // Make space for the status bar
@@ -109,6 +97,58 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
10997 a.dialog = d.(core.DialogCmp)
11098
11199 return a, tea.Batch(cmds...)
100+
101+ // Status
102+ case util.InfoMsg:
103+ a.status, cmd = a.status.Update(msg)
104+ return a, cmd
105+ case pubsub.Event[logging.LogMessage]:
106+ if msg.Payload.Persist {
107+ switch msg.Payload.Level {
108+ case "error":
109+ a.status, cmd = a.status.Update(util.InfoMsg{
110+ Type: util.InfoTypeError,
111+ Msg: msg.Payload.Message,
112+ TTL: msg.Payload.PersistTime,
113+ })
114+ case "info":
115+ a.status, cmd = a.status.Update(util.InfoMsg{
116+ Type: util.InfoTypeInfo,
117+ Msg: msg.Payload.Message,
118+ TTL: msg.Payload.PersistTime,
119+ })
120+ case "warn":
121+ a.status, cmd = a.status.Update(util.InfoMsg{
122+ Type: util.InfoTypeWarn,
123+ Msg: msg.Payload.Message,
124+ TTL: msg.Payload.PersistTime,
125+ })
126+
127+ default:
128+ a.status, cmd = a.status.Update(util.InfoMsg{
129+ Type: util.InfoTypeInfo,
130+ Msg: msg.Payload.Message,
131+ TTL: msg.Payload.PersistTime,
132+ })
133+ }
134+ }
135+ case util.ClearStatusMsg:
136+ a.status, _ = a.status.Update(msg)
137+
138+ // Permission
139+ case pubsub.Event[permission.PermissionRequest]:
140+ return a, dialog.NewPermissionDialogCmd(msg.Payload)
141+ case dialog.PermissionResponseMsg:
142+ switch msg.Action {
143+ case dialog.PermissionAllow:
144+ a.app.Permissions.Grant(msg.Permission)
145+ case dialog.PermissionAllowForSession:
146+ a.app.Permissions.GrantPersistant(msg.Permission)
147+ case dialog.PermissionDeny:
148+ a.app.Permissions.Deny(msg.Permission)
149+ }
150+
151+ // Dialog
112152 case core.DialogMsg:
113153 d, cmd := a.dialog.Update(msg)
114154 a.dialog = d.(core.DialogCmp)
@@ -119,10 +159,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119159 a.dialog = d.(core.DialogCmp)
120160 a.dialogVisible = false
121161 return a, cmd
162+
163+ // Editor
164+ case vimtea.EditorModeMsg:
165+ a.editorMode = msg.Mode
166+
122167 case page.PageChangeMsg:
123168 return a, a.moveToPage(msg.ID)
124− case util.InfoMsg:
125− a.status, _ = a.status.Update(msg)
126169 case tea.KeyMsg:
127170 if a.editorMode == vimtea.ModeNormal {
128171 switch {
@@ -162,9 +205,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
162205 }
163206 }
164207
165− var cmds []tea.Cmd
166− s, cmd := a.status.Update(msg)
167− a.status = s
208+ a.status, cmd = a.status.Update(msg)
168209 cmds = append(cmds, cmd)
169210 if a.dialogVisible {
170211 d, cmd := a.dialog.Update(msg)
@@ -172,8 +213,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172213 cmds = append(cmds, cmd)
173214 return a, tea.Batch(cmds...)
174215 }
175− p, cmd := a.pages[a.currentPage].Update(msg)
176− a.pages[a.currentPage] = p
216+ a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
177217 cmds = append(cmds, cmd)
178218 return a, tea.Batch(cmds...)
179219}
internal/tui/util/util.go+6−1
@@ -1,6 +1,10 @@
11package util
22
3−import tea "github.com/charmbracelet/bubbletea"
3+import (
4+ "time"
5+
6+ tea "github.com/charmbracelet/bubbletea"
7+)
48
59func CmdHandler(msg tea.Msg) tea.Cmd {
610 return func() tea.Msg {
@@ -41,6 +45,7 @@ type (
4145 InfoMsg struct {
4246 Type InfoType
4347 Msg string
48+ TTL time.Duration
4449 }
4550 ClearStatusMsg struct{}
4651)
main.go−12
@@ -1,21 +1,9 @@
11package main
22
33import (
4− "log"
5− "os"
6−
74 "github.com/kujtimiihoxha/termai/cmd"
85)
96
107func main() {
11− // Create a log file and make that the log output DEBUG
12− // TODO: remove this on release
13− logfile, err := os.OpenFile("debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
14− if err != nil {
15− panic(err)
16− }
17−
18− log.SetOutput(logfile)
19−
208 cmd.Execute()
219}