on main help sign in
fix logs and add cancellation
36f201dKujtim Hoxha
cmd/root.go+15−4
@@ -2,6 +2,7 @@ package cmd
2
2
3import (
3import (
4 "context"
4 "context"
5+ "log/slog"
5 "os"
6 "os"
6 "sync"
7 "sync"
7
8
@@ -10,6 +11,7 @@ import (
10 "github.com/kujtimiihoxha/termai/internal/config"
11 "github.com/kujtimiihoxha/termai/internal/config"
11 "github.com/kujtimiihoxha/termai/internal/db"
12 "github.com/kujtimiihoxha/termai/internal/db"
12 "github.com/kujtimiihoxha/termai/internal/llm/agent"
13 "github.com/kujtimiihoxha/termai/internal/llm/agent"
14+ "github.com/kujtimiihoxha/termai/internal/logging"
13 "github.com/kujtimiihoxha/termai/internal/tui"
15 "github.com/kujtimiihoxha/termai/internal/tui"
14 zone "github.com/lrstanley/bubblezone"
16 zone "github.com/lrstanley/bubblezone"
15 "github.com/spf13/cobra"
17 "github.com/spf13/cobra"
@@ -26,6 +28,16 @@ var rootCmd = &cobra.Command{
26 }
28 }
27 debug, _ := cmd.Flags().GetBool("debug")
29 debug, _ := cmd.Flags().GetBool("debug")
28 err := config.Load(debug)
30 err := config.Load(debug)
31+ cfg := config.Get()
32+ defaultLevel := slog.LevelInfo
33+ if cfg.Debug {
34+ defaultLevel = slog.LevelDebug
35+ }
36+ logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
37+ Level: defaultLevel,
38+ }))
39+ slog.SetDefault(logger)
40+
29 if err != nil {
41 if err != nil {
30 return err
42 return err
31 }
43 }
@@ -37,14 +49,14 @@ var rootCmd = &cobra.Command{
37
49
38 app := app.New(ctx, conn)
50 app := app.New(ctx, conn)
39 defer app.Close()
51 defer app.Close()
40− app.Logger.Info("Starting termai...")
52+ logging.Info("Starting termai...")
41 zone.NewGlobal()
53 zone.NewGlobal()
42 tui := tea.NewProgram(
54 tui := tea.NewProgram(
43 tui.New(app),
55 tui.New(app),
44 tea.WithAltScreen(),
56 tea.WithAltScreen(),
45 tea.WithMouseCellMotion(),
57 tea.WithMouseCellMotion(),
46 )
58 )
47− app.Logger.Info("Setting up subscriptions...")
59+ logging.Info("Setting up subscriptions...")
48 ch, unsub := setupSubscriptions(app)
60 ch, unsub := setupSubscriptions(app)
49 defer unsub()
61 defer unsub()
50
62
@@ -66,9 +78,8 @@ func setupSubscriptions(app *app.App) (chan tea.Msg, func()) {
66 ch := make(chan tea.Msg)
78 ch := make(chan tea.Msg)
67 wg := sync.WaitGroup{}
79 wg := sync.WaitGroup{}
68 ctx, cancel := context.WithCancel(app.Context)
80 ctx, cancel := context.WithCancel(app.Context)
69−
70 {
81 {
71− sub := app.Logger.Subscribe(ctx)
82+ sub := logging.Subscribe(ctx)
72 wg.Add(1)
83 wg.Add(1)
73 go func() {
84 go func() {
74 for ev := range sub {
85 for ev := range sub {
go.mod+2−2
@@ -33,7 +33,7 @@ require (
33 github.com/spf13/cobra v1.9.1
33 github.com/spf13/cobra v1.9.1
34 github.com/spf13/viper v1.20.0
34 github.com/spf13/viper v1.20.0
35 github.com/stretchr/testify v1.10.0
35 github.com/stretchr/testify v1.10.0
36− golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
36+ golang.org/x/net v0.34.0
37 google.golang.org/api v0.215.0
37 google.golang.org/api v0.215.0
38)
38)
39
39
@@ -116,10 +116,10 @@ require (
116 go.uber.org/multierr v1.9.0 // indirect
116 go.uber.org/multierr v1.9.0 // indirect
117 golang.design/x/clipboard v0.7.0 // indirect
117 golang.design/x/clipboard v0.7.0 // indirect
118 golang.org/x/crypto v0.33.0 // indirect
118 golang.org/x/crypto v0.33.0 // indirect
119+ golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
119 golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect
120 golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect
120 golang.org/x/image v0.14.0 // indirect
121 golang.org/x/image v0.14.0 // indirect
121 golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
122 golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
122− golang.org/x/net v0.34.0 // indirect
123 golang.org/x/oauth2 v0.25.0 // indirect
123 golang.org/x/oauth2 v0.25.0 // indirect
124 golang.org/x/sync v0.12.0 // indirect
124 golang.org/x/sync v0.12.0 // indirect
125 golang.org/x/sys v0.31.0 // indirect
125 golang.org/x/sys v0.31.0 // indirect
internal/app/services.go+6−8
@@ -3,6 +3,7 @@ package app
3import (
3import (
4 "context"
4 "context"
5 "database/sql"
5 "database/sql"
6+ "log/slog"
6
7
7 "github.com/kujtimiihoxha/termai/internal/config"
8 "github.com/kujtimiihoxha/termai/internal/config"
8 "github.com/kujtimiihoxha/termai/internal/db"
9 "github.com/kujtimiihoxha/termai/internal/db"
@@ -23,16 +24,14 @@ type App struct {
23
24
24 LSPClients map[string]*lsp.Client
25 LSPClients map[string]*lsp.Client
25
26
26− Logger logging.Interface
27−
28 ceanups []func()
27 ceanups []func()
29}
28}
30
29
31func New(ctx context.Context, conn *sql.DB) *App {
30func New(ctx context.Context, conn *sql.DB) *App {
32 cfg := config.Get()
31 cfg := config.Get()
32+ logging.Info("Debug mode enabled")
33+
33 q := db.New(conn)
34 q := db.New(conn)
34− log := logging.Get()
35− log.SetLevel(cfg.Log.Level)
36 sessions := session.NewService(ctx, q)
35 sessions := session.NewService(ctx, q)
37 messages := message.NewService(ctx, q)
36 messages := message.NewService(ctx, q)
38
37
@@ -41,7 +40,6 @@ func New(ctx context.Context, conn *sql.DB) *App {
41 Sessions: sessions,
40 Sessions: sessions,
42 Messages: messages,
41 Messages: messages,
43 Permissions: permission.NewPermissionService(),
42 Permissions: permission.NewPermissionService(),
44− Logger: log,
45 LSPClients: make(map[string]*lsp.Client),
43 LSPClients: make(map[string]*lsp.Client),
46 }
44 }
47
45
@@ -52,13 +50,13 @@ func New(ctx context.Context, conn *sql.DB) *App {
52 })
50 })
53 workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
51 workspaceWatcher := watcher.NewWorkspaceWatcher(lspClient)
54 if err != nil {
52 if err != nil {
55− log.Error("Failed to create LSP client for", name, err)
53+ logging.Error("Failed to create LSP client for", name, err)
56 continue
54 continue
57 }
55 }
58
56
59 _, err = lspClient.InitializeLSPClient(ctx, config.WorkingDirectory())
57 _, err = lspClient.InitializeLSPClient(ctx, config.WorkingDirectory())
60 if err != nil {
58 if err != nil {
61− log.Error("Initialize failed", "error", err)
59+ logging.Error("Initialize failed", "error", err)
62 continue
60 continue
63 }
61 }
64 go workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
62 go workspaceWatcher.WatchWorkspace(ctx, config.WorkingDirectory())
@@ -74,5 +72,5 @@ func (a *App) Close() {
74 for _, client := range a.LSPClients {
72 for _, client := range a.LSPClients {
75 client.Close()
73 client.Close()
76 }
74 }
77− a.Logger.Info("App closed")
75+ slog.Info("App closed")
78}
76}
internal/db/connect.go+8−10
@@ -16,8 +16,6 @@ import (
16 "github.com/kujtimiihoxha/termai/internal/logging"
16 "github.com/kujtimiihoxha/termai/internal/logging"
17)
17)
18
18
19−var log = logging.Get()
20−
21func Connect() (*sql.DB, error) {
19func Connect() (*sql.DB, error) {
22 dataDir := config.Get().Data.Directory
20 dataDir := config.Get().Data.Directory
23 if dataDir == "" {
21 if dataDir == "" {
@@ -50,43 +48,43 @@ func Connect() (*sql.DB, error) {
50
48
51 for _, pragma := range pragmas {
49 for _, pragma := range pragmas {
52 if _, err = db.Exec(pragma); err != nil {
50 if _, err = db.Exec(pragma); err != nil {
53− log.Warn("Failed to set pragma", pragma, err)
51+ logging.Warn("Failed to set pragma", pragma, err)
54 } else {
52 } else {
55− log.Warn("Set pragma", "pragma", pragma)
53+ logging.Warn("Set pragma", "pragma", pragma)
56 }
54 }
57 }
55 }
58
56
59 // Initialize schema from embedded file
57 // Initialize schema from embedded file
60 d, err := iofs.New(FS, "migrations")
58 d, err := iofs.New(FS, "migrations")
61 if err != nil {
59 if err != nil {
62− log.Error("Failed to open embedded migrations", "error", err)
60+ logging.Error("Failed to open embedded migrations", "error", err)
63 db.Close()
61 db.Close()
64 return nil, fmt.Errorf("failed to open embedded migrations: %w", err)
62 return nil, fmt.Errorf("failed to open embedded migrations: %w", err)
65 }
63 }
66
64
67 driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
65 driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
68 if err != nil {
66 if err != nil {
69− log.Error("Failed to create SQLite driver", "error", err)
67+ logging.Error("Failed to create SQLite driver", "error", err)
70 db.Close()
68 db.Close()
71 return nil, fmt.Errorf("failed to create SQLite driver: %w", err)
69 return nil, fmt.Errorf("failed to create SQLite driver: %w", err)
72 }
70 }
73
71
74 m, err := migrate.NewWithInstance("iofs", d, "ql", driver)
72 m, err := migrate.NewWithInstance("iofs", d, "ql", driver)
75 if err != nil {
73 if err != nil {
76− log.Error("Failed to create migration instance", "error", err)
74+ logging.Error("Failed to create migration instance", "error", err)
77 db.Close()
75 db.Close()
78 return nil, fmt.Errorf("failed to create migration instance: %w", err)
76 return nil, fmt.Errorf("failed to create migration instance: %w", err)
79 }
77 }
80
78
81 err = m.Up()
79 err = m.Up()
82 if err != nil && err != migrate.ErrNoChange {
80 if err != nil && err != migrate.ErrNoChange {
83− log.Error("Migration failed", "error", err)
81+ logging.Error("Migration failed", "error", err)
84 db.Close()
82 db.Close()
85 return nil, fmt.Errorf("failed to apply schema: %w", err)
83 return nil, fmt.Errorf("failed to apply schema: %w", err)
86 } else if err == migrate.ErrNoChange {
84 } else if err == migrate.ErrNoChange {
87− log.Info("No schema changes to apply")
85+ logging.Info("No schema changes to apply")
88 } else {
86 } else {
89− log.Info("Schema migration applied successfully")
87+ logging.Info("Schema migration applied successfully")
90 }
88 }
91
89
92 return db, nil
90 return db, nil
internal/llm/agent/agent-tool.go+1−1
@@ -56,7 +56,7 @@ func (b *agentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes
56 return tools.NewTextErrorResponse(fmt.Sprintf("error creating session: %s", err)), nil
56 return tools.NewTextErrorResponse(fmt.Sprintf("error creating session: %s", err)), nil
57 }
57 }
58
58
59− err = agent.Generate(session.ID, params.Prompt)
59+ err = agent.Generate(ctx, session.ID, params.Prompt)
60 if err != nil {
60 if err != nil {
61 return tools.NewTextErrorResponse(fmt.Sprintf("error generating agent: %s", err)), nil
61 return tools.NewTextErrorResponse(fmt.Sprintf("error generating agent: %s", err)), nil
62 }
62 }
internal/llm/agent/agent.go+135−13
@@ -13,11 +13,12 @@ import (
13 "github.com/kujtimiihoxha/termai/internal/llm/prompt"
13 "github.com/kujtimiihoxha/termai/internal/llm/prompt"
14 "github.com/kujtimiihoxha/termai/internal/llm/provider"
14 "github.com/kujtimiihoxha/termai/internal/llm/provider"
15 "github.com/kujtimiihoxha/termai/internal/llm/tools"
15 "github.com/kujtimiihoxha/termai/internal/llm/tools"
16+ "github.com/kujtimiihoxha/termai/internal/logging"
16 "github.com/kujtimiihoxha/termai/internal/message"
17 "github.com/kujtimiihoxha/termai/internal/message"
17)
18)
18
19
19type Agent interface {
20type Agent interface {
20− Generate(sessionID string, content string) error
21+ Generate(ctx context.Context, sessionID string, content string) error
21}
22}
22
23
23type agent struct {
24type agent struct {
@@ -28,9 +29,9 @@ type agent struct {
28 titleGenerator provider.Provider
29 titleGenerator provider.Provider
29}
30}
30
31
31−func (c *agent) handleTitleGeneration(sessionID, content string) {
32+func (c *agent) handleTitleGeneration(ctx context.Context, sessionID, content string) {
32 response, err := c.titleGenerator.SendMessages(
33 response, err := c.titleGenerator.SendMessages(
33− c.Context,
34+ ctx,
34 []message.Message{
35 []message.Message{
35 {
36 {
36 Role: message.User,
37 Role: message.User,
@@ -91,13 +92,16 @@ func (c *agent) processEvent(
91 assistantMsg.AppendContent(event.Content)
92 assistantMsg.AppendContent(event.Content)
92 return c.Messages.Update(*assistantMsg)
93 return c.Messages.Update(*assistantMsg)
93 case provider.EventError:
94 case provider.EventError:
95+ if errors.Is(event.Error, context.Canceled) {
96+ return nil
97+ }
94− c.App.Logger.PersistError(event.Error.Error())
98+ logging.ErrorPersist(event.Error.Error())
95 return event.Error
99 return event.Error
96 case provider.EventWarning:
100 case provider.EventWarning:
97− c.App.Logger.PersistWarn(event.Info)
101+ logging.WarnPersist(event.Info)
98 return nil
102 return nil
99 case provider.EventInfo:
103 case provider.EventInfo:
100− c.App.Logger.PersistInfo(event.Info)
104+ logging.InfoPersist(event.Info)
101 case provider.EventComplete:
105 case provider.EventComplete:
102 assistantMsg.SetToolCalls(event.Response.ToolCalls)
106 assistantMsg.SetToolCalls(event.Response.ToolCalls)
103 assistantMsg.AddFinish(event.Response.FinishReason)
107 assistantMsg.AddFinish(event.Response.FinishReason)
@@ -115,12 +119,37 @@ func (c *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall,
115 var wg sync.WaitGroup
119 var wg sync.WaitGroup
116 toolResults := make([]message.ToolResult, len(toolCalls))
120 toolResults := make([]message.ToolResult, len(toolCalls))
117 mutex := &sync.Mutex{}
121 mutex := &sync.Mutex{}
122+ errChan := make(chan error, 1)
123+
124+ // Create a child context that can be canceled
125+ ctx, cancel := context.WithCancel(ctx)
126+ defer cancel()
118
127
119 for i, tc := range toolCalls {
128 for i, tc := range toolCalls {
120 wg.Add(1)
129 wg.Add(1)
121 go func(index int, toolCall message.ToolCall) {
130 go func(index int, toolCall message.ToolCall) {
122 defer wg.Done()
131 defer wg.Done()
123
132
133+ // Check if context is already canceled
134+ select {
135+ case <-ctx.Done():
136+ mutex.Lock()
137+ toolResults[index] = message.ToolResult{
138+ ToolCallID: toolCall.ID,
139+ Content: "Tool execution canceled",
140+ IsError: true,
141+ }
142+ mutex.Unlock()
143+
144+ // Send cancellation error to error channel if it's empty
145+ select {
146+ case errChan <- ctx.Err():
147+ default:
148+ }
149+ return
150+ default:
151+ }
152+
124 response := ""
153 response := ""
125 isError := false
154 isError := false
126 found := false
155 found := false
@@ -133,8 +162,19 @@ func (c *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall,
133 Name: toolCall.Name,
162 Name: toolCall.Name,
134 Input: toolCall.Input,
163 Input: toolCall.Input,
135 })
164 })
165+
136 if toolErr != nil {
166 if toolErr != nil {
167+ if errors.Is(toolErr, context.Canceled) {
168+ response = "Tool execution canceled"
169+
170+ // Send cancellation error to error channel if it's empty
171+ select {
172+ case errChan <- ctx.Err():
173+ default:
174+ }
175+ } else {
137− response = fmt.Sprintf("error running tool: %s", toolErr)
176+ response = fmt.Sprintf("error running tool: %s", toolErr)
177+ }
138 isError = true
178 isError = true
139 } else {
179 } else {
140 response = toolResult.Content
180 response = toolResult.Content
@@ -160,7 +200,24 @@ func (c *agent) ExecuteTools(ctx context.Context, toolCalls []message.ToolCall,
160 }(i, tc)
200 }(i, tc)
161 }
201 }
162
202
203+ // Wait for all goroutines to finish or context to be canceled
204+ done := make(chan struct{})
205+ go func() {
163− wg.Wait()
206+ wg.Wait()
207+ close(done)
208+ }()
209+
210+ select {
211+ case <-done:
212+ // All tools completed successfully
213+ case err := <-errChan:
214+ // One of the tools encountered a cancellation
215+ return toolResults, err
216+ case <-ctx.Done():
217+ // Context was canceled externally
218+ return toolResults, ctx.Err()
219+ }
220+
164 return toolResults, nil
221 return toolResults, nil
165}
222}
166
223
@@ -188,14 +245,14 @@ func (c *agent) handleToolExecution(
188 return &msg, err
245 return &msg, err
189}
246}
190
247
191−func (c *agent) generate(sessionID string, content string) error {
248+func (c *agent) generate(ctx context.Context, sessionID string, content string) error {
192 messages, err := c.Messages.List(sessionID)
249 messages, err := c.Messages.List(sessionID)
193 if err != nil {
250 if err != nil {
194 return err
251 return err
195 }
252 }
196
253
197 if len(messages) == 0 {
254 if len(messages) == 0 {
198− go c.handleTitleGeneration(sessionID, content)
255+ go c.handleTitleGeneration(ctx, sessionID, content)
199 }
256 }
200
257
201 userMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
258 userMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
@@ -212,9 +269,36 @@ func (c *agent) generate(sessionID string, content string) error {
212
269
213 messages = append(messages, userMsg)
270 messages = append(messages, userMsg)
214 for {
271 for {
272+ select {
273+ case <-ctx.Done():
274+ assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
275+ Role: message.Assistant,
276+ Parts: []message.ContentPart{},
277+ })
278+ if err != nil {
279+ return err
280+ }
281+ assistantMsg.AddFinish("canceled")
282+ c.Messages.Update(assistantMsg)
283+ return context.Canceled
284+ default:
285+ // Continue processing
286+ }
215
287
216− eventChan, err := c.agent.StreamResponse(c.Context, messages, c.tools)
288+ eventChan, err := c.agent.StreamResponse(ctx, messages, c.tools)
217 if err != nil {
289 if err != nil {
290+ if errors.Is(err, context.Canceled) {
291+ assistantMsg, err := c.Messages.Create(sessionID, message.CreateMessageParams{
292+ Role: message.Assistant,
293+ Parts: []message.ContentPart{},
294+ })
295+ if err != nil {
296+ return err
297+ }
298+ assistantMsg.AddFinish("canceled")
299+ c.Messages.Update(assistantMsg)
300+ return context.Canceled
301+ }
218 return err
302 return err
219 }
303 }
220
304
@@ -228,19 +312,47 @@ func (c *agent) generate(sessionID string, content string) error {
228 for event := range eventChan {
312 for event := range eventChan {
229 err = c.processEvent(sessionID, &assistantMsg, event)
313 err = c.processEvent(sessionID, &assistantMsg, event)
230 if err != nil {
314 if err != nil {
315+ if errors.Is(err, context.Canceled) {
316+ assistantMsg.AddFinish("canceled")
317+ c.Messages.Update(assistantMsg)
318+ return context.Canceled
319+ }
231 assistantMsg.AddFinish("error:" + err.Error())
320 assistantMsg.AddFinish("error:" + err.Error())
232 c.Messages.Update(assistantMsg)
321 c.Messages.Update(assistantMsg)
233 return err
322 return err
234 }
323 }
324+
325+ select {
326+ case <-ctx.Done():
327+ assistantMsg.AddFinish("canceled")
328+ c.Messages.Update(assistantMsg)
329+ return context.Canceled
330+ default:
331+ }
235 }
332 }
236
333
237− msg, err := c.handleToolExecution(c.Context, assistantMsg)
334+ // Check for context cancellation before tool execution
335+ select {
336+ case <-ctx.Done():
337+ assistantMsg.AddFinish("canceled")
338+ c.Messages.Update(assistantMsg)
339+ return context.Canceled
340+ default:
341+ // Continue processing
342+ }
238
343
239− c.Messages.Update(assistantMsg)
344+ msg, err := c.handleToolExecution(ctx, assistantMsg)
240 if err != nil {
345 if err != nil {
346+ if errors.Is(err, context.Canceled) {
347+ assistantMsg.AddFinish("canceled")
348+ c.Messages.Update(assistantMsg)
349+ return context.Canceled
350+ }
241 return err
351 return err
242 }
352 }
243
353
354+ c.Messages.Update(assistantMsg)
355+
244 if len(assistantMsg.ToolCalls()) == 0 {
356 if len(assistantMsg.ToolCalls()) == 0 {
245 break
357 break
246 }
358 }
@@ -249,6 +361,16 @@ func (c *agent) generate(sessionID string, content string) error {
249 if msg != nil {
361 if msg != nil {
250 messages = append(messages, *msg)
362 messages = append(messages, *msg)
251 }
363 }
364+
365+ // Check for context cancellation after tool execution
366+ select {
367+ case <-ctx.Done():
368+ assistantMsg.AddFinish("canceled")
369+ c.Messages.Update(assistantMsg)
370+ return context.Canceled
371+ default:
372+ // Continue processing
373+ }
252 }
374 }
253 return nil
375 return nil
254}
376}
internal/llm/agent/coder.go+3−2
@@ -1,6 +1,7 @@
1package agent
1package agent
2
2
3import (
3import (
4+ "context"
4 "errors"
5 "errors"
5
6
6 "github.com/kujtimiihoxha/termai/internal/app"
7 "github.com/kujtimiihoxha/termai/internal/app"
@@ -28,9 +29,9 @@ func (c *coderAgent) setAgentTool(sessionID string) {
28 }
29 }
29}
30}
30
31
31−func (c *coderAgent) Generate(sessionID string, content string) error {
32+func (c *coderAgent) Generate(ctx context.Context, sessionID string, content string) error {
32 c.setAgentTool(sessionID)
33 c.setAgentTool(sessionID)
33− return c.generate(sessionID, content)
34+ return c.generate(ctx, sessionID, content)
34}
35}
35
36
36func NewCoderAgent(app *app.App) (Agent, error) {
37func NewCoderAgent(app *app.App) (Agent, error) {
internal/llm/agent/mcp-tools.go+4−6
@@ -22,8 +22,6 @@ type mcpTool struct {
22 permissions permission.Service
22 permissions permission.Service
23}
23}
24
24
25−var logger = logging.Get()
26−
27type MCPClient interface {
25type MCPClient interface {
28 Initialize(
26 Initialize(
29 ctx context.Context,
27 ctx context.Context,
@@ -143,13 +141,13 @@ func getTools(ctx context.Context, name string, m config.MCPServer, permissions
143
141
144 _, err := c.Initialize(ctx, initRequest)
142 _, err := c.Initialize(ctx, initRequest)
145 if err != nil {
143 if err != nil {
146− logger.Error("error initializing mcp client", "error", err)
144+ logging.Error("error initializing mcp client", "error", err)
147 return stdioTools
145 return stdioTools
148 }
146 }
149 toolsRequest := mcp.ListToolsRequest{}
147 toolsRequest := mcp.ListToolsRequest{}
150 tools, err := c.ListTools(ctx, toolsRequest)
148 tools, err := c.ListTools(ctx, toolsRequest)
151 if err != nil {
149 if err != nil {
152− logger.Error("error listing tools", "error", err)
150+ logging.Error("error listing tools", "error", err)
153 return stdioTools
151 return stdioTools
154 }
152 }
155 for _, t := range tools.Tools {
153 for _, t := range tools.Tools {
@@ -172,7 +170,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
172 m.Args...,
170 m.Args...,
173 )
171 )
174 if err != nil {
172 if err != nil {
175− logger.Error("error creating mcp client", "error", err)
173+ logging.Error("error creating mcp client", "error", err)
176 continue
174 continue
177 }
175 }
178
176
@@ -183,7 +181,7 @@ func GetMcpTools(ctx context.Context, permissions permission.Service) []tools.Ba
183 client.WithHeaders(m.Headers),
181 client.WithHeaders(m.Headers),
184 )
182 )
185 if err != nil {
183 if err != nil {
186− logger.Error("error creating mcp client", "error", err)
184+ logging.Error("error creating mcp client", "error", err)
187 continue
185 continue
188 }
186 }
189 mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
187 mcpTools = append(mcpTools, getTools(ctx, name, m, permissions, c)...)
internal/llm/agent/task.go+3−2
@@ -1,6 +1,7 @@
1package agent
1package agent
2
2
3import (
3import (
4+ "context"
4 "errors"
5 "errors"
5
6
6 "github.com/kujtimiihoxha/termai/internal/app"
7 "github.com/kujtimiihoxha/termai/internal/app"
@@ -13,8 +14,8 @@ type taskAgent struct {
13 *agent
14 *agent
14}
15}
15
16
16−func (c *taskAgent) Generate(sessionID string, content string) error {
17+func (c *taskAgent) Generate(ctx context.Context, sessionID string, content string) error {
17− return c.generate(sessionID, content)
18+ return c.generate(ctx, sessionID, content)
18}
19}
19
20
20func NewTaskAgent(app *app.App) (Agent, error) {
21func NewTaskAgent(app *app.App) (Agent, error) {
internal/logging/default.godeleted−12
@@ -1,12 +0,0 @@
1−package logging
2−
3−var defaultLogger Interface
4−
5−func Get() Interface {
6− if defaultLogger == nil {
7− defaultLogger = NewLogger(Options{
8− Level: "info",
9− })
10− }
11− return defaultLogger
12−}
internal/logging/logger.go+17−119
@@ -1,141 +1,39 @@
1package logging
1package logging
2
2
3−import (
4− "context"
5− "io"
6− "log/slog"
3+import "log/slog"
7− "slices"
8
4
9− "github.com/kujtimiihoxha/termai/internal/pubsub"
10− "golang.org/x/exp/maps"
11−)
12−
13−const DefaultLevel = "info"
14−
15−const (
16− persistKeyArg = "$persist"
17− PersistTimeArg = "$persist_time"
18−)
19−
20−var levels = map[string]slog.Level{
21− "debug": slog.LevelDebug,
22− DefaultLevel: slog.LevelInfo,
23− "warn": slog.LevelWarn,
24− "error": slog.LevelError,
25−}
26−
27−func ValidLevels() []string {
5+func Info(msg string, args ...any) {
28− keys := maps.Keys(levels)
6+ slog.Info(msg, args...)
29− slices.SortFunc(keys, func(a, b string) int {
30− if a == DefaultLevel {
31− return -1
32− }
33− if b == DefaultLevel {
34− return 1
35− }
36− if a < b {
37− return -1
38− }
39− return 1
40− })
41− return keys
42−}
43−
44−func NewLogger(opts Options) Interface {
45− logger := &Logger{}
46− broker := pubsub.NewBroker[LogMessage]()
47− writer := &writer{
48− messages: []LogMessage{},
49− Broker: broker,
50− }
51−
52− handler := slog.NewTextHandler(
53− io.MultiWriter(writer),
54− &slog.HandlerOptions{
55− Level: slog.Level(levels[opts.Level]),
56− },
57− )
58− logger.logger = slog.New(handler)
59− logger.writer = writer
60−
61− return logger
62}
7}
63
8
64−type Options struct {
9+func Debug(msg string, args ...any) {
65− Level string
10+ slog.Debug(msg, args...)
66}
11}
67
12
68−type Logger struct {
13+func Warn(msg string, args ...any) {
69− logger *slog.Logger
14+ slog.Warn(msg, args...)
70− writer *writer
71}
15}
72
16
73−func (l *Logger) SetLevel(level string) {
17+func Error(msg string, args ...any) {
74− if _, ok := levels[level]; !ok {
18+ slog.Error(msg, args...)
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}
19}
85
20
86−// PersistDebug implements Interface.
87−func (l *Logger) PersistDebug(msg string, args ...any) {
21+func InfoPersist(msg string, args ...any) {
88 args = append(args, persistKeyArg, true)
22 args = append(args, persistKeyArg, true)
89− l.Debug(msg, args...)
23+ slog.Info(msg, args...)
90}
24}
91
25
92−// PersistError implements Interface.
93−func (l *Logger) PersistError(msg string, args ...any) {
26+func DebugPersist(msg string, args ...any) {
94 args = append(args, persistKeyArg, true)
27 args = append(args, persistKeyArg, true)
95− l.Error(msg, args...)
28+ slog.Debug(msg, args...)
96}
29}
97
30
98−// PersistInfo implements Interface.
99−func (l *Logger) PersistInfo(msg string, args ...any) {
31+func WarnPersist(msg string, args ...any) {
100 args = append(args, persistKeyArg, true)
32 args = append(args, persistKeyArg, true)
101− l.Info(msg, args...)
33+ slog.Warn(msg, args...)
102}
34}
103
35
104−// PersistWarn implements Interface.
105−func (l *Logger) PersistWarn(msg string, args ...any) {
36+func ErrorPersist(msg string, args ...any) {
106 args = append(args, persistKeyArg, true)
37 args = append(args, persistKeyArg, true)
107− l.Warn(msg, args...)
108−}
109−
110−func (l *Logger) Debug(msg string, args ...any) {
111− l.logger.Debug(msg, args...)
112−}
113−
114−func (l *Logger) Info(msg string, args ...any) {
115− l.logger.Info(msg, args...)
116−}
117−
118−func (l *Logger) Warn(msg string, args ...any) {
119− l.logger.Warn(msg, args...)
120−}
121−
122−func (l *Logger) Error(msg string, args ...any) {
123− l.logger.Error(msg, args...)
38+ slog.Error(msg, args...)
124−}
125−
126−func (l *Logger) List() []LogMessage {
127− return l.writer.messages
128−}
129−
130−func (l *Logger) Get(id string) (LogMessage, error) {
131− for _, msg := range l.writer.messages {
132− if msg.ID == id {
133− return msg, nil
134− }
135− }
136− return LogMessage{}, io.EOF
137−}
138−
139−func (l *Logger) Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage] {
140− return l.writer.Subscribe(ctx)
141}
39}
internal/logging/logging.godeleted−23
@@ -1,23 +0,0 @@
1−package logging
2−
3−import (
4− "context"
5−
6− "github.com/kujtimiihoxha/termai/internal/pubsub"
7−)
8−
9−type Interface interface {
10− Debug(msg string, args ...any)
11− Info(msg string, args ...any)
12− Warn(msg string, args ...any)
13− Error(msg string, args ...any)
14− Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage]
15−
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)
23−}
internal/logging/writer.go+45−4
@@ -2,18 +2,47 @@ package logging
2
2
3import (
3import (
4 "bytes"
4 "bytes"
5+ "context"
5 "fmt"
6 "fmt"
7+ "strings"
8+ "sync"
6 "time"
9 "time"
7
10
8 "github.com/go-logfmt/logfmt"
11 "github.com/go-logfmt/logfmt"
9 "github.com/kujtimiihoxha/termai/internal/pubsub"
12 "github.com/kujtimiihoxha/termai/internal/pubsub"
10)
13)
11
14
15+const (
16+ persistKeyArg = "$_persist"
17+ PersistTimeArg = "$_persist_time"
18+)
19+
12−type writer struct {
20+type LogData struct {
13 messages []LogMessage
21 messages []LogMessage
14 *pubsub.Broker[LogMessage]
22 *pubsub.Broker[LogMessage]
23+ lock sync.Mutex
24+}
25+
26+func (l *LogData) Add(msg LogMessage) {
27+ l.lock.Lock()
28+ defer l.lock.Unlock()
29+ l.messages = append(l.messages, msg)
30+ l.Publish(pubsub.CreatedEvent, msg)
31+}
32+
33+func (l *LogData) List() []LogMessage {
34+ l.lock.Lock()
35+ defer l.lock.Unlock()
36+ return l.messages
37+}
38+
39+var defaultLogData = &LogData{
40+ messages: make([]LogMessage, 0),
41+ Broker: pubsub.NewBroker[LogMessage](),
15}
42}
16
43
44+type writer struct{}
45+
17func (w *writer) Write(p []byte) (int, error) {
46func (w *writer) Write(p []byte) (int, error) {
18 d := logfmt.NewDecoder(bytes.NewReader(p))
47 d := logfmt.NewDecoder(bytes.NewReader(p))
19 for d.ScanRecord() {
48 for d.ScanRecord() {
@@ -30,7 +59,7 @@ func (w *writer) Write(p []byte) (int, error) {
30 }
59 }
31 msg.Time = parsed
60 msg.Time = parsed
32 case "level":
61 case "level":
33− msg.Level = string(d.Value())
62+ msg.Level = strings.ToLower(string(d.Value()))
34 case "msg":
63 case "msg":
35 msg.Message = string(d.Value())
64 msg.Message = string(d.Value())
36 default:
65 default:
@@ -50,11 +79,23 @@ func (w *writer) Write(p []byte) (int, error) {
50 }
79 }
51 }
80 }
52 }
81 }
53− w.messages = append(w.messages, msg)
82+ defaultLogData.Add(msg)
54− w.Publish(pubsub.CreatedEvent, msg)
55 }
83 }
56 if d.Err() != nil {
84 if d.Err() != nil {
57 return 0, d.Err()
85 return 0, d.Err()
58 }
86 }
59 return len(p), nil
87 return len(p), nil
60}
88}
89+
90+func NewWriter() *writer {
91+ w := &writer{}
92+ return w
93+}
94+
95+func Subscribe(ctx context.Context) <-chan pubsub.Event[LogMessage] {
96+ return defaultLogData.Subscribe(ctx)
97+}
98+
99+func List() []LogMessage {
100+ return defaultLogData.List()
101+}
internal/lsp/client.go+3−5
@@ -18,8 +18,6 @@ import (
18 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
18 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
19)
19)
20
20
21−var logger = logging.Get()
22−
23type Client struct {
21type Client struct {
24 Cmd *exec.Cmd
22 Cmd *exec.Cmd
25 stdin io.WriteCloser
23 stdin io.WriteCloser
@@ -377,7 +375,7 @@ func (c *Client) CloseFile(ctx context.Context, filepath string) error {
377 }
375 }
378
376
379 if cnf.Debug {
377 if cnf.Debug {
380− logger.Debug("Closing file", "file", filepath)
378+ logging.Debug("Closing file", "file", filepath)
381 }
379 }
382 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
380 if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
383 return err
381 return err
@@ -416,12 +414,12 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
416 for _, filePath := range filesToClose {
414 for _, filePath := range filesToClose {
417 err := c.CloseFile(ctx, filePath)
415 err := c.CloseFile(ctx, filePath)
418 if err != nil && cnf.Debug {
416 if err != nil && cnf.Debug {
419− logger.Warn("Error closing file", "file", filePath, "error", err)
417+ logging.Warn("Error closing file", "file", filePath, "error", err)
420 }
418 }
421 }
419 }
422
420
423 if cnf.Debug {
421 if cnf.Debug {
424− logger.Debug("Closed all files", "files", filesToClose)
422+ logging.Debug("Closed all files", "files", filesToClose)
425 }
423 }
426}
424}
427
425
internal/lsp/handlers.go+7−6
@@ -4,6 +4,7 @@ import (
4 "encoding/json"
4 "encoding/json"
5
5
6 "github.com/kujtimiihoxha/termai/internal/config"
6 "github.com/kujtimiihoxha/termai/internal/config"
7+ "github.com/kujtimiihoxha/termai/internal/logging"
7 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
8 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
8 "github.com/kujtimiihoxha/termai/internal/lsp/util"
9 "github.com/kujtimiihoxha/termai/internal/lsp/util"
9)
10)
@@ -17,7 +18,7 @@ func HandleWorkspaceConfiguration(params json.RawMessage) (any, error) {
17func HandleRegisterCapability(params json.RawMessage) (any, error) {
18func HandleRegisterCapability(params json.RawMessage) (any, error) {
18 var registerParams protocol.RegistrationParams
19 var registerParams protocol.RegistrationParams
19 if err := json.Unmarshal(params, &registerParams); err != nil {
20 if err := json.Unmarshal(params, &registerParams); err != nil {
20− logger.Error("Error unmarshaling registration params", "error", err)
21+ logging.Error("Error unmarshaling registration params", "error", err)
21 return nil, err
22 return nil, err
22 }
23 }
23
24
@@ -27,13 +28,13 @@ func HandleRegisterCapability(params json.RawMessage) (any, error) {
27 // Parse the registration options
28 // Parse the registration options
28 optionsJSON, err := json.Marshal(reg.RegisterOptions)
29 optionsJSON, err := json.Marshal(reg.RegisterOptions)
29 if err != nil {
30 if err != nil {
30− logger.Error("Error marshaling registration options", "error", err)
31+ logging.Error("Error marshaling registration options", "error", err)
31 continue
32 continue
32 }
33 }
33
34
34 var options protocol.DidChangeWatchedFilesRegistrationOptions
35 var options protocol.DidChangeWatchedFilesRegistrationOptions
35 if err := json.Unmarshal(optionsJSON, &options); err != nil {
36 if err := json.Unmarshal(optionsJSON, &options); err != nil {
36− logger.Error("Error unmarshaling registration options", "error", err)
37+ logging.Error("Error unmarshaling registration options", "error", err)
37 continue
38 continue
38 }
39 }
39
40
@@ -53,7 +54,7 @@ func HandleApplyEdit(params json.RawMessage) (any, error) {
53
54
54 err := util.ApplyWorkspaceEdit(edit.Edit)
55 err := util.ApplyWorkspaceEdit(edit.Edit)
55 if err != nil {
56 if err != nil {
56− logger.Error("Error applying workspace edit", "error", err)
57+ logging.Error("Error applying workspace edit", "error", err)
57 return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
58 return protocol.ApplyWorkspaceEditResult{Applied: false, FailureReason: err.Error()}, nil
58 }
59 }
59
60
@@ -88,7 +89,7 @@ func HandleServerMessage(params json.RawMessage) {
88 }
89 }
89 if err := json.Unmarshal(params, &msg); err == nil {
90 if err := json.Unmarshal(params, &msg); err == nil {
90 if cnf.Debug {
91 if cnf.Debug {
91− logger.Debug("Server message", "type", msg.Type, "message", msg.Message)
92+ logging.Debug("Server message", "type", msg.Type, "message", msg.Message)
92 }
93 }
93 }
94 }
94}
95}
@@ -96,7 +97,7 @@ func HandleServerMessage(params json.RawMessage) {
96func HandleDiagnostics(client *Client, params json.RawMessage) {
97func HandleDiagnostics(client *Client, params json.RawMessage) {
97 var diagParams protocol.PublishDiagnosticsParams
98 var diagParams protocol.PublishDiagnosticsParams
98 if err := json.Unmarshal(params, &diagParams); err != nil {
99 if err := json.Unmarshal(params, &diagParams); err != nil {
99− logger.Error("Error unmarshaling diagnostics params", "error", err)
100+ logging.Error("Error unmarshaling diagnostics params", "error", err)
100 return
101 return
101 }
102 }
102
103
internal/lsp/transport.go+16−15
@@ -9,6 +9,7 @@ import (
9 "strings"
9 "strings"
10
10
11 "github.com/kujtimiihoxha/termai/internal/config"
11 "github.com/kujtimiihoxha/termai/internal/config"
12+ "github.com/kujtimiihoxha/termai/internal/logging"
12)
13)
13
14
14// Write writes an LSP message to the given writer
15// Write writes an LSP message to the given writer
@@ -20,7 +21,7 @@ func WriteMessage(w io.Writer, msg *Message) error {
20 cnf := config.Get()
21 cnf := config.Get()
21
22
22 if cnf.Debug {
23 if cnf.Debug {
23− logger.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
24+ logging.Debug("Sending message to server", "method", msg.Method, "id", msg.ID)
24 }
25 }
25
26
26 _, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data))
27 _, err = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(data))
@@ -49,7 +50,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
49 line = strings.TrimSpace(line)
50 line = strings.TrimSpace(line)
50
51
51 if cnf.Debug {
52 if cnf.Debug {
52− logger.Debug("Received header", "line", line)
53+ logging.Debug("Received header", "line", line)
53 }
54 }
54
55
55 if line == "" {
56 if line == "" {
@@ -65,7 +66,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
65 }
66 }
66
67
67 if cnf.Debug {
68 if cnf.Debug {
68− logger.Debug("Content-Length", "length", contentLength)
69+ logging.Debug("Content-Length", "length", contentLength)
69 }
70 }
70
71
71 // Read content
72 // Read content
@@ -76,7 +77,7 @@ func ReadMessage(r *bufio.Reader) (*Message, error) {
76 }
77 }
77
78
78 if cnf.Debug {
79 if cnf.Debug {
79− logger.Debug("Received content", "content", string(content))
80+ logging.Debug("Received content", "content", string(content))
80 }
81 }
81
82
82 // Parse message
83 // Parse message
@@ -95,7 +96,7 @@ func (c *Client) handleMessages() {
95 msg, err := ReadMessage(c.stdout)
96 msg, err := ReadMessage(c.stdout)
96 if err != nil {
97 if err != nil {
97 if cnf.Debug {
98 if cnf.Debug {
98− logger.Error("Error reading message", "error", err)
99+ logging.Error("Error reading message", "error", err)
99 }
100 }
100 return
101 return
101 }
102 }
@@ -103,7 +104,7 @@ func (c *Client) handleMessages() {
103 // Handle server->client request (has both Method and ID)
104 // Handle server->client request (has both Method and ID)
104 if msg.Method != "" && msg.ID != 0 {
105 if msg.Method != "" && msg.ID != 0 {
105 if cnf.Debug {
106 if cnf.Debug {
106− logger.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
107+ logging.Debug("Received request from server", "method", msg.Method, "id", msg.ID)
107 }
108 }
108
109
109 response := &Message{
110 response := &Message{
@@ -143,7 +144,7 @@ func (c *Client) handleMessages() {
143
144
144 // Send response back to server
145 // Send response back to server
145 if err := WriteMessage(c.stdin, response); err != nil {
146 if err := WriteMessage(c.stdin, response); err != nil {
146− logger.Error("Error sending response to server", "error", err)
147+ logging.Error("Error sending response to server", "error", err)
147 }
148 }
148
149
149 continue
150 continue
@@ -157,11 +158,11 @@ func (c *Client) handleMessages() {
157
158
158 if ok {
159 if ok {
159 if cnf.Debug {
160 if cnf.Debug {
160− logger.Debug("Handling notification", "method", msg.Method)
161+ logging.Debug("Handling notification", "method", msg.Method)
161 }
162 }
162 go handler(msg.Params)
163 go handler(msg.Params)
163 } else if cnf.Debug {
164 } else if cnf.Debug {
164− logger.Debug("No handler for notification", "method", msg.Method)
165+ logging.Debug("No handler for notification", "method", msg.Method)
165 }
166 }
166 continue
167 continue
167 }
168 }
@@ -174,12 +175,12 @@ func (c *Client) handleMessages() {
174
175
175 if ok {
176 if ok {
176 if cnf.Debug {
177 if cnf.Debug {
177− logger.Debug("Received response for request", "id", msg.ID)
178+ logging.Debug("Received response for request", "id", msg.ID)
178 }
179 }
179 ch <- msg
180 ch <- msg
180 close(ch)
181 close(ch)
181 } else if cnf.Debug {
182 } else if cnf.Debug {
182− logger.Debug("No handler for response", "id", msg.ID)
183+ logging.Debug("No handler for response", "id", msg.ID)
183 }
184 }
184 }
185 }
185 }
186 }
@@ -191,7 +192,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
191 id := c.nextID.Add(1)
192 id := c.nextID.Add(1)
192
193
193 if cnf.Debug {
194 if cnf.Debug {
194− logger.Debug("Making call", "method", method, "id", id)
195+ logging.Debug("Making call", "method", method, "id", id)
195 }
196 }
196
197
197 msg, err := NewRequest(id, method, params)
198 msg, err := NewRequest(id, method, params)
@@ -217,14 +218,14 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
217 }
218 }
218
219
219 if cnf.Debug {
220 if cnf.Debug {
220− logger.Debug("Request sent", "method", method, "id", id)
221+ logging.Debug("Request sent", "method", method, "id", id)
221 }
222 }
222
223
223 // Wait for response
224 // Wait for response
224 resp := <-ch
225 resp := <-ch
225
226
226 if cnf.Debug {
227 if cnf.Debug {
227− logger.Debug("Received response", "id", id)
228+ logging.Debug("Received response", "id", id)
228 }
229 }
229
230
230 if resp.Error != nil {
231 if resp.Error != nil {
@@ -250,7 +251,7 @@ func (c *Client) Call(ctx context.Context, method string, params any, result any
250func (c *Client) Notify(ctx context.Context, method string, params any) error {
251func (c *Client) Notify(ctx context.Context, method string, params any) error {
251 cnf := config.Get()
252 cnf := config.Get()
252 if cnf.Debug {
253 if cnf.Debug {
253− logger.Debug("Sending notification", "method", method)
254+ logging.Debug("Sending notification", "method", method)
254 }
255 }
255
256
256 msg, err := NewNotification(method, params)
257 msg, err := NewNotification(method, params)
internal/lsp/watcher/watcher.go+28−30
@@ -16,8 +16,6 @@ import (
16 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
16 "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
17)
17)
18
18
19−var logger = logging.Get()
20−
21// WorkspaceWatcher manages LSP file watching
19// WorkspaceWatcher manages LSP file watching
22type WorkspaceWatcher struct {
20type WorkspaceWatcher struct {
23 client *lsp.Client
21 client *lsp.Client
@@ -53,7 +51,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
53
51
54 // Print detailed registration information for debugging
52 // Print detailed registration information for debugging
55 if cnf.Debug {
53 if cnf.Debug {
56− logger.Debug("Adding file watcher registrations",
54+ logging.Debug("Adding file watcher registrations",
57 "id", id,
55 "id", id,
58 "watchers", len(watchers),
56 "watchers", len(watchers),
59 "total", len(w.registrations),
57 "total", len(w.registrations),
@@ -61,26 +59,26 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
61 )
59 )
62
60
63 for i, watcher := range watchers {
61 for i, watcher := range watchers {
64− logger.Debug("Registration", "index", i+1)
62+ logging.Debug("Registration", "index", i+1)
65
63
66 // Log the GlobPattern
64 // Log the GlobPattern
67 switch v := watcher.GlobPattern.Value.(type) {
65 switch v := watcher.GlobPattern.Value.(type) {
68 case string:
66 case string:
69− logger.Debug("GlobPattern", "pattern", v)
67+ logging.Debug("GlobPattern", "pattern", v)
70 case protocol.RelativePattern:
68 case protocol.RelativePattern:
71− logger.Debug("GlobPattern", "pattern", v.Pattern)
69+ logging.Debug("GlobPattern", "pattern", v.Pattern)
72
70
73 // Log BaseURI details
71 // Log BaseURI details
74 switch u := v.BaseURI.Value.(type) {
72 switch u := v.BaseURI.Value.(type) {
75 case string:
73 case string:
76− logger.Debug("BaseURI", "baseURI", u)
74+ logging.Debug("BaseURI", "baseURI", u)
77 case protocol.DocumentUri:
75 case protocol.DocumentUri:
78− logger.Debug("BaseURI", "baseURI", u)
76+ logging.Debug("BaseURI", "baseURI", u)
79 default:
77 default:
80− logger.Debug("BaseURI", "baseURI", u)
78+ logging.Debug("BaseURI", "baseURI", u)
81 }
79 }
82 default:
80 default:
83− logger.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
81+ logging.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
84 }
82 }
85
83
86 // Log WatchKind
84 // Log WatchKind
@@ -89,7 +87,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
89 watchKind = *watcher.Kind
87 watchKind = *watcher.Kind
90 }
88 }
91
89
92− logger.Debug("WatchKind", "kind", watchKind)
90+ logging.Debug("WatchKind", "kind", watchKind)
93
91
94 // Test match against some example paths
92 // Test match against some example paths
95 testPaths := []string{
93 testPaths := []string{
@@ -99,7 +97,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
99
97
100 for _, testPath := range testPaths {
98 for _, testPath := range testPaths {
101 isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
99 isMatch := w.matchesPattern(testPath, watcher.GlobPattern)
102− logger.Debug("Test path", "path", testPath, "matches", isMatch)
100+ logging.Debug("Test path", "path", testPath, "matches", isMatch)
103 }
101 }
104 }
102 }
105 }
103 }
@@ -119,7 +117,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
119 if d.IsDir() {
117 if d.IsDir() {
120 if path != w.workspacePath && shouldExcludeDir(path) {
118 if path != w.workspacePath && shouldExcludeDir(path) {
121 if cnf.Debug {
119 if cnf.Debug {
122− logger.Debug("Skipping excluded directory", "path", path)
120+ logging.Debug("Skipping excluded directory", "path", path)
123 }
121 }
124 return filepath.SkipDir
122 return filepath.SkipDir
125 }
123 }
@@ -139,7 +137,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
139
137
140 elapsedTime := time.Since(startTime)
138 elapsedTime := time.Since(startTime)
141 if cnf.Debug {
139 if cnf.Debug {
142− logger.Debug("Workspace scan complete",
140+ logging.Debug("Workspace scan complete",
143 "filesOpened", filesOpened,
141 "filesOpened", filesOpened,
144 "elapsedTime", elapsedTime.Seconds(),
142 "elapsedTime", elapsedTime.Seconds(),
145 "workspacePath", w.workspacePath,
143 "workspacePath", w.workspacePath,
@@ -147,7 +145,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
147 }
145 }
148
146
149 if err != nil && cnf.Debug {
147 if err != nil && cnf.Debug {
150− logger.Debug("Error scanning workspace for files to open", "error", err)
148+ logging.Debug("Error scanning workspace for files to open", "error", err)
151 }
149 }
152 }()
150 }()
153}
151}
@@ -164,7 +162,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
164
162
165 watcher, err := fsnotify.NewWatcher()
163 watcher, err := fsnotify.NewWatcher()
166 if err != nil {
164 if err != nil {
167− logger.Error("Error creating watcher", "error", err)
165+ logging.Error("Error creating watcher", "error", err)
168 }
166 }
169 defer watcher.Close()
167 defer watcher.Close()
170
168
@@ -178,7 +176,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
178 if d.IsDir() && path != workspacePath {
176 if d.IsDir() && path != workspacePath {
179 if shouldExcludeDir(path) {
177 if shouldExcludeDir(path) {
180 if cnf.Debug {
178 if cnf.Debug {
181− logger.Debug("Skipping excluded directory", "path", path)
179+ logging.Debug("Skipping excluded directory", "path", path)
182 }
180 }
183 return filepath.SkipDir
181 return filepath.SkipDir
184 }
182 }
@@ -188,14 +186,14 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
188 if d.IsDir() {
186 if d.IsDir() {
189 err = watcher.Add(path)
187 err = watcher.Add(path)
190 if err != nil {
188 if err != nil {
191− logger.Error("Error watching path", "path", path, "error", err)
189+ logging.Error("Error watching path", "path", path, "error", err)
192 }
190 }
193 }
191 }
194
192
195 return nil
193 return nil
196 })
194 })
197 if err != nil {
195 if err != nil {
198− logger.Error("Error walking workspace", "error", err)
196+ logging.Error("Error walking workspace", "error", err)
199 }
197 }
200
198
201 // Event loop
199 // Event loop
@@ -217,7 +215,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
217 // Skip excluded directories
215 // Skip excluded directories
218 if !shouldExcludeDir(event.Name) {
216 if !shouldExcludeDir(event.Name) {
219 if err := watcher.Add(event.Name); err != nil {
217 if err := watcher.Add(event.Name); err != nil {
220− logger.Error("Error adding directory to watcher", "path", event.Name, "error", err)
218+ logging.Error("Error adding directory to watcher", "path", event.Name, "error", err)
221 }
219 }
222 }
220 }
223 } else {
221 } else {
@@ -232,7 +230,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
232 // Debug logging
230 // Debug logging
233 if cnf.Debug {
231 if cnf.Debug {
234 matched, kind := w.isPathWatched(event.Name)
232 matched, kind := w.isPathWatched(event.Name)
235− logger.Debug("File event",
233+ logging.Debug("File event",
236 "path", event.Name,
234 "path", event.Name,
237 "operation", event.Op.String(),
235 "operation", event.Op.String(),
238 "watched", matched,
236 "watched", matched,
@@ -277,7 +275,7 @@ func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath str
277 if !ok {
275 if !ok {
278 return
276 return
279 }
277 }
280− logger.Error("Error watching file", "error", err)
278+ logging.Error("Error watching file", "error", err)
281 }
279 }
282 }
280 }
283}
281}
@@ -402,7 +400,7 @@ func matchesSimpleGlob(pattern, path string) bool {
402 // Fall back to simple matching for simpler patterns
400 // Fall back to simple matching for simpler patterns
403 matched, err := filepath.Match(pattern, path)
401 matched, err := filepath.Match(pattern, path)
404 if err != nil {
402 if err != nil {
405− logger.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
403+ logging.Error("Error matching pattern", "pattern", pattern, "path", path, "error", err)
406 return false
404 return false
407 }
405 }
408
406
@@ -413,7 +411,7 @@ func matchesSimpleGlob(pattern, path string) bool {
413func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
411func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPattern) bool {
414 patternInfo, err := pattern.AsPattern()
412 patternInfo, err := pattern.AsPattern()
415 if err != nil {
413 if err != nil {
416− logger.Error("Error parsing pattern", "pattern", pattern, "error", err)
414+ logging.Error("Error parsing pattern", "pattern", pattern, "error", err)
417 return false
415 return false
418 }
416 }
419
417
@@ -438,7 +436,7 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
438 // Make path relative to basePath for matching
436 // Make path relative to basePath for matching
439 relPath, err := filepath.Rel(basePath, path)
437 relPath, err := filepath.Rel(basePath, path)
440 if err != nil {
438 if err != nil {
441− logger.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
439+ logging.Error("Error getting relative path", "path", path, "basePath", basePath, "error", err)
442 return false
440 return false
443 }
441 }
444 relPath = filepath.ToSlash(relPath)
442 relPath = filepath.ToSlash(relPath)
@@ -479,14 +477,14 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
479 if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
477 if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
480 err := w.client.NotifyChange(ctx, filePath)
478 err := w.client.NotifyChange(ctx, filePath)
481 if err != nil {
479 if err != nil {
482− logger.Error("Error notifying change", "error", err)
480+ logging.Error("Error notifying change", "error", err)
483 }
481 }
484 return
482 return
485 }
483 }
486
484
487 // Notify LSP server about the file event using didChangeWatchedFiles
485 // Notify LSP server about the file event using didChangeWatchedFiles
488 if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
486 if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
489− logger.Error("Error notifying LSP server about file event", "error", err)
487+ logging.Error("Error notifying LSP server about file event", "error", err)
490 }
488 }
491}
489}
492
490
@@ -494,7 +492,7 @@ func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, chan
494func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
492func (w *WorkspaceWatcher) notifyFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) error {
495 cnf := config.Get()
493 cnf := config.Get()
496 if cnf.Debug {
494 if cnf.Debug {
497− logger.Debug("Notifying file event",
495+ logging.Debug("Notifying file event",
498 "uri", uri,
496 "uri", uri,
499 "changeType", changeType,
497 "changeType", changeType,
500 )
498 )
@@ -618,7 +616,7 @@ func shouldExcludeFile(filePath string) bool {
618 // Skip large files
616 // Skip large files
619 if info.Size() > maxFileSize {
617 if info.Size() > maxFileSize {
620 if cnf.Debug {
618 if cnf.Debug {
621− logger.Debug("Skipping large file",
619+ logging.Debug("Skipping large file",
622 "path", filePath,
620 "path", filePath,
623 "size", info.Size(),
621 "size", info.Size(),
624 "maxSize", maxFileSize,
622 "maxSize", maxFileSize,
@@ -651,7 +649,7 @@ func (w *WorkspaceWatcher) openMatchingFile(ctx context.Context, path string) {
651 if watched, _ := w.isPathWatched(path); watched {
649 if watched, _ := w.isPathWatched(path); watched {
652 // Don't need to check if it's already open - the client.OpenFile handles that
650 // Don't need to check if it's already open - the client.OpenFile handles that
653 if err := w.client.OpenFile(ctx, path); err != nil && cnf.Debug {
651 if err := w.client.OpenFile(ctx, path); err != nil && cnf.Debug {
654− logger.Error("Error opening file", "path", path, "error", err)
652+ logging.Error("Error opening file", "path", path, "error", err)
655 }
653 }
656 }
654 }
657}
655}
internal/tui/components/core/status.go+4−4
@@ -13,7 +13,7 @@ import (
13)
13)
14
14
15type statusCmp struct {
15type statusCmp struct {
16− info *util.InfoMsg
16+ info util.InfoMsg
17 width int
17 width int
18 messageTTL time.Duration
18 messageTTL time.Duration
19}
19}
@@ -35,14 +35,14 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
35 m.width = msg.Width
35 m.width = msg.Width
36 return m, nil
36 return m, nil
37 case util.InfoMsg:
37 case util.InfoMsg:
38− m.info = &msg
38+ m.info = msg
39 ttl := msg.TTL
39 ttl := msg.TTL
40 if ttl == 0 {
40 if ttl == 0 {
41 ttl = m.messageTTL
41 ttl = m.messageTTL
42 }
42 }
43 return m, m.clearMessageCmd(ttl)
43 return m, m.clearMessageCmd(ttl)
44 case util.ClearStatusMsg:
44 case util.ClearStatusMsg:
45− m.info = nil
45+ m.info = util.InfoMsg{}
46 }
46 }
47 return m, nil
47 return m, nil
48}
48}
@@ -54,7 +54,7 @@ var (
54
54
55func (m statusCmp) View() string {
55func (m statusCmp) View() string {
56 status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
56 status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
57− if m.info != nil {
57+ if m.info.Msg != "" {
58 infoStyle := styles.Padded.
58 infoStyle := styles.Padded.
59 Foreground(styles.Base).
59 Foreground(styles.Base).
60 Width(m.availableFooterMsgWidth())
60 Width(m.availableFooterMsgWidth())
internal/tui/components/logs/details.go+1−1
@@ -30,7 +30,7 @@ type detailCmp struct {
30}
30}
31
31
32func (i *detailCmp) Init() tea.Cmd {
32func (i *detailCmp) Init() tea.Cmd {
33− messages := logging.Get().List()
33+ messages := logging.List()
34 if len(messages) == 0 {
34 if len(messages) == 0 {
35 return nil
35 return nil
36 }
36 }
internal/tui/components/logs/table.go+2−4
@@ -22,8 +22,6 @@ type TableComponent interface {
22 layout.Bordered
22 layout.Bordered
23}
23}
24
24
25−var logger = logging.Get()
26−
27type tableCmp struct {
25type tableCmp struct {
28 table table.Model
26 table table.Model
29}
27}
@@ -57,7 +55,7 @@ func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57 if selectedRow != nil {
55 if selectedRow != nil {
58 if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
56 if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
59 var log logging.LogMessage
57 var log logging.LogMessage
60− for _, row := range logging.Get().List() {
58+ for _, row := range logging.List() {
61 if row.ID == selectedRow[0] {
59 if row.ID == selectedRow[0] {
62 log = row
60 log = row
63 break
61 break
@@ -112,7 +110,7 @@ func (i *tableCmp) BindingKeys() []key.Binding {
112func (i *tableCmp) setRows() {
110func (i *tableCmp) setRows() {
113 rows := []table.Row{}
111 rows := []table.Row{}
114
112
115− logs := logger.List()
113+ logs := logging.List()
116 slices.SortFunc(logs, func(a, b logging.LogMessage) int {
114 slices.SortFunc(logs, func(a, b logging.LogMessage) int {
117 if a.Time.Before(b.Time) {
115 if a.Time.Before(b.Time) {
118 return 1
116 return 1
internal/tui/components/repl/editor.go+33−8
@@ -12,6 +12,7 @@ import (
12 "github.com/kujtimiihoxha/termai/internal/tui/styles"
12 "github.com/kujtimiihoxha/termai/internal/tui/styles"
13 "github.com/kujtimiihoxha/termai/internal/tui/util"
13 "github.com/kujtimiihoxha/termai/internal/tui/util"
14 "github.com/kujtimiihoxha/vimtea"
14 "github.com/kujtimiihoxha/vimtea"
15+ "golang.org/x/net/context"
15)
16)
16
17
17type EditorCmp interface {
18type EditorCmp interface {
@@ -23,18 +24,20 @@ type EditorCmp interface {
23}
24}
24
25
25type editorCmp struct {
26type editorCmp struct {
26− app *app.App
27+ app *app.App
27− editor vimtea.Editor
28+ editor vimtea.Editor
28− editorMode vimtea.EditorMode
29+ editorMode vimtea.EditorMode
29− sessionID string
30+ sessionID string
30− focused bool
31+ focused bool
31− width int
32+ width int
32− height int
33+ height int
34+ cancelMessage context.CancelFunc
33}
35}
34
36
35type editorKeyMap struct {
37type editorKeyMap struct {
36 SendMessage key.Binding
38 SendMessage key.Binding
37 SendMessageI key.Binding
39 SendMessageI key.Binding
40+ CancelMessage key.Binding
38 InsertMode key.Binding
41 InsertMode key.Binding
39 NormaMode key.Binding
42 NormaMode key.Binding
40 VisualMode key.Binding
43 VisualMode key.Binding
@@ -50,6 +53,10 @@ var editorKeyMapValue = editorKeyMap{
50 key.WithKeys("ctrl+s"),
53 key.WithKeys("ctrl+s"),
51 key.WithHelp("ctrl+s", "send message insert mode"),
54 key.WithHelp("ctrl+s", "send message insert mode"),
52 ),
55 ),
56+ CancelMessage: key.NewBinding(
57+ key.WithKeys("ctrl+x"),
58+ key.WithHelp("ctrl+x", "cancel current message"),
59+ ),
53 InsertMode: key.NewBinding(
60 InsertMode: key.NewBinding(
54 key.WithKeys("i"),
61 key.WithKeys("i"),
55 key.WithHelp("i", "insert mode"),
62 key.WithHelp("i", "insert mode"),
@@ -93,6 +100,8 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
93 if m.editorMode == vimtea.ModeInsert {
100 if m.editorMode == vimtea.ModeInsert {
94 return m, m.Send()
101 return m, m.Send()
95 }
102 }
103+ case key.Matches(msg, editorKeyMapValue.CancelMessage):
104+ return m, m.Cancel()
96 }
105 }
97 }
106 }
98 u, cmd := m.editor.Update(msg)
107 u, cmd := m.editor.Update(msg)
@@ -136,6 +145,16 @@ func (m *editorCmp) SetSize(width int, height int) {
136 m.editor.SetSize(width, height)
145 m.editor.SetSize(width, height)
137}
146}
138
147
148+func (m *editorCmp) Cancel() tea.Cmd {
149+ if m.cancelMessage == nil {
150+ return util.ReportWarn("No message to cancel")
151+ }
152+
153+ m.cancelMessage()
154+ m.cancelMessage = nil
155+ return util.ReportWarn("Message cancelled")
156+}
157+
139func (m *editorCmp) Send() tea.Cmd {
158func (m *editorCmp) Send() tea.Cmd {
140 return func() tea.Msg {
159 return func() tea.Msg {
141 messages, err := m.app.Messages.List(m.sessionID)
160 messages, err := m.app.Messages.List(m.sessionID)
@@ -151,7 +170,13 @@ func (m *editorCmp) Send() tea.Cmd {
151 }
170 }
152
171
153 content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
172 content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
173+ ctx, cancel := context.WithCancel(m.app.Context)
174+ m.cancelMessage = cancel
175+ go func() {
176+ defer cancel()
154− go a.Generate(m.sessionID, content)
177+ a.Generate(ctx, m.sessionID, content)
178+ m.cancelMessage = nil
179+ }()
155
180
156 return m.editor.Reset()
181 return m.editor.Reset()
157 }
182 }
internal/tui/components/repl/messages.go+8−3
@@ -309,7 +309,7 @@ func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.
309 }
309 }
310
310
311 for _, msg := range futureMessages {
311 for _, msg := range futureMessages {
312− if msg.Content().String() != "" {
312+ if msg.Content().String() != "" || msg.FinishReason() == "canceled" {
313 break
313 break
314 }
314 }
315
315
@@ -345,13 +345,18 @@ func (m *messagesCmp) renderView() {
345 prevMessageWasUser := false
345 prevMessageWasUser := false
346 for inx, msg := range m.messages {
346 for inx, msg := range m.messages {
347 content := msg.Content().String()
347 content := msg.Content().String()
348− if content != "" || prevMessageWasUser {
348+ if content != "" || prevMessageWasUser || msg.FinishReason() == "canceled" {
349 if msg.ReasoningContent().String() != "" && content == "" {
349 if msg.ReasoningContent().String() != "" && content == "" {
350 content = msg.ReasoningContent().String()
350 content = msg.ReasoningContent().String()
351 } else if content == "" {
351 } else if content == "" {
352 content = "..."
352 content = "..."
353 }
353 }
354+ if msg.FinishReason() == "canceled" {
355+ content, _ = r.Render(content)
356+ content += lipgloss.NewStyle().Padding(1, 0, 0, 1).Foreground(styles.Error).Render(styles.ErrorIcon + " Canceled")
357+ } else {
354− content, _ = r.Render(content)
358+ content, _ = r.Render(content)
359+ }
355
360
356 isSelected := inx == m.selectedMsgIdx
361 isSelected := inx == m.selectedMsgIdx
357
362
internal/tui/tui.go+2−1
@@ -101,7 +101,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
101 // Status
101 // Status
102 case util.InfoMsg:
102 case util.InfoMsg:
103 a.status, cmd = a.status.Update(msg)
103 a.status, cmd = a.status.Update(msg)
104+ cmds = append(cmds, cmd)
104− return a, cmd
105+ return a, tea.Batch(cmds...)
105 case pubsub.Event[logging.LogMessage]:
106 case pubsub.Event[logging.LogMessage]:
106 if msg.Payload.Persist {
107 if msg.Payload.Persist {
107 switch msg.Payload.Level {
108 switch msg.Payload.Level {