main
1package main
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "github.com/charmbracelet/bubbles/filepicker"
12 "github.com/charmbracelet/bubbles/help"
13 "github.com/charmbracelet/bubbles/key"
14 "github.com/charmbracelet/bubbles/textarea"
15 "github.com/charmbracelet/bubbles/viewport"
16 tea "github.com/charmbracelet/bubbletea"
17 "github.com/charmbracelet/glamour"
18 "github.com/charmbracelet/lipgloss"
19 "github.com/ollama/ollama/api"
20)
21
22// Styles for the TUI
23var (
24 // Color scheme inspired by modern dev tools
25 primaryColor = lipgloss.Color("#7C3AED") // Purple
26 secondaryColor = lipgloss.Color("#10B981") // Green
27 accentColor = lipgloss.Color("#F59E0B") // Orange
28 textColor = lipgloss.Color("#F9FAFB") // Light gray
29 mutedColor = lipgloss.Color("#6B7280") // Muted gray
30 backgroundColor = lipgloss.Color("#111827") // Dark gray
31 borderColor = lipgloss.Color("#374151") // Border gray
32
33 // Style definitions
34 titleStyle = lipgloss.NewStyle().
35 Foreground(primaryColor).
36 Bold(true).
37 Padding(0, 1)
38
39 subtitleStyle = lipgloss.NewStyle().
40 Foreground(mutedColor).
41 Italic(true)
42
43 borderStyle = lipgloss.NewStyle().
44 Border(lipgloss.RoundedBorder()).
45 BorderForeground(borderColor).
46 Padding(1)
47
48 chatMessageStyle = lipgloss.NewStyle().
49 Foreground(textColor).
50 Padding(0, 1)
51
52 userMessageStyle = lipgloss.NewStyle().
53 Foreground(primaryColor).
54 Bold(true).
55 Padding(0, 1)
56
57 assistantMessageStyle = lipgloss.NewStyle().
58 Foreground(secondaryColor).
59 Padding(0, 1)
60
61 statusBarStyle = lipgloss.NewStyle().
62 Background(primaryColor).
63 Foreground(textColor).
64 Bold(true).
65 Padding(0, 1)
66
67 helpStyle = lipgloss.NewStyle().
68 Foreground(mutedColor).
69 Padding(0, 1)
70)
71
72// Key bindings
73type keyMap struct {
74 Up key.Binding
75 Down key.Binding
76 Left key.Binding
77 Right key.Binding
78 Enter key.Binding
79 Tab key.Binding
80 Escape key.Binding
81 Quit key.Binding
82 Help key.Binding
83 Files key.Binding
84 Chat key.Binding
85 Memory key.Binding
86 Send key.Binding
87}
88
89var keys = keyMap{
90 Up: key.NewBinding(
91 key.WithKeys("up", "k"),
92 key.WithHelp("↑/k", "up"),
93 ),
94 Down: key.NewBinding(
95 key.WithKeys("down", "j"),
96 key.WithHelp("↓/j", "down"),
97 ),
98 Left: key.NewBinding(
99 key.WithKeys("left", "h"),
100 key.WithHelp("←/h", "left"),
101 ),
102 Right: key.NewBinding(
103 key.WithKeys("right", "l"),
104 key.WithHelp("→/l", "right"),
105 ),
106 Enter: key.NewBinding(
107 key.WithKeys("enter"),
108 key.WithHelp("enter", "select"),
109 ),
110 Tab: key.NewBinding(
111 key.WithKeys("tab"),
112 key.WithHelp("tab", "switch panel"),
113 ),
114 Escape: key.NewBinding(
115 key.WithKeys("esc"),
116 key.WithHelp("esc", "back"),
117 ),
118 Quit: key.NewBinding(
119 key.WithKeys("q", "ctrl+c"),
120 key.WithHelp("q", "quit"),
121 ),
122 Help: key.NewBinding(
123 key.WithKeys("?"),
124 key.WithHelp("?", "help"),
125 ),
126 Files: key.NewBinding(
127 key.WithKeys("f"),
128 key.WithHelp("f", "files"),
129 ),
130 Chat: key.NewBinding(
131 key.WithKeys("c"),
132 key.WithHelp("c", "chat"),
133 ),
134 Memory: key.NewBinding(
135 key.WithKeys("m"),
136 key.WithHelp("m", "memory"),
137 ),
138 Send: key.NewBinding(
139 key.WithKeys("ctrl+enter"),
140 key.WithHelp("ctrl+enter", "send"),
141 ),
142}
143
144func (k keyMap) ShortHelp() []key.Binding {
145 return []key.Binding{k.Help, k.Files, k.Chat, k.Memory, k.Quit}
146}
147
148func (k keyMap) FullHelp() [][]key.Binding {
149 return [][]key.Binding{
150 {k.Up, k.Down, k.Left, k.Right},
151 {k.Enter, k.Tab, k.Escape},
152 {k.Files, k.Chat, k.Memory},
153 {k.Send, k.Help, k.Quit},
154 }
155}
156
157// Application state
158type View int
159
160const (
161 FilesView View = iota
162 ChatView
163 MemoryView
164)
165
166type ChatMessage struct {
167 Role string
168 Content string
169 Timestamp time.Time
170}
171
172// Main application model
173type Model struct {
174 // Core state
175 currentView View
176 width int
177 height int
178 ready bool
179
180 // Components
181 filepicker filepicker.Model
182 viewport viewport.Model
183 textarea textarea.Model
184 help help.Model
185
186 // Data
187 chatHistory []ChatMessage
188 currentFile string
189 workingDir string
190
191 // AI client
192 client *api.Client
193
194 // Status
195 status string
196 isThinking bool
197
198 // Markdown renderer
199 glamour *glamour.TermRenderer
200}
201
202// Initialize the application
203func initialModel() Model {
204 // Get working directory
205 wd, _ := os.Getwd()
206
207 // Initialize file picker
208 fp := filepicker.New()
209 fp.AllowedTypes = []string{".go", ".py", ".js", ".ts", ".md", ".txt", ".json", ".yml", ".yaml"}
210 fp.CurrentDirectory = wd
211
212 // Initialize textarea for chat input
213 ta := textarea.New()
214 ta.Placeholder = "Ask Del anything... (Ctrl+Enter to send)"
215 ta.Focus()
216 ta.CharLimit = 2000
217 ta.SetWidth(50)
218 ta.SetHeight(3)
219
220 // Initialize viewport for chat display
221 vp := viewport.New(50, 20)
222
223 // Initialize help
224 h := help.New()
225
226 // Initialize Ollama client
227 client, _ := api.ClientFromEnvironment()
228
229 // Initialize glamour for markdown rendering
230 glamourRenderer, _ := glamour.NewTermRenderer(
231 glamour.WithAutoStyle(),
232 glamour.WithWordWrap(80),
233 )
234
235 m := Model{
236 currentView: FilesView,
237 filepicker: fp,
238 viewport: vp,
239 textarea: ta,
240 help: h,
241 workingDir: wd,
242 client: client,
243 status: "Welcome to Del - Your AI Coding Assistant",
244 glamour: glamourRenderer,
245 chatHistory: []ChatMessage{
246 {
247 Role: "assistant",
248 Content: "# Welcome to Del! 🤖\n\nI'm your AI coding assistant, redesigned with a beautiful TUI interface.\n\n**Quick Start:**\n- Press `f` to explore files\n- Press `c` to chat with me \n- Press `m` to view memory\n- Press `?` for help\n\nI can help you with:\n- 📁 File operations and navigation\n- 💬 Code explanations and debugging\n- 🧠 Persistent memory across sessions\n- ⚡ Fast tool execution\n\nLet's build something amazing together!",
249 Timestamp: time.Now(),
250 },
251 },
252 }
253
254 return m
255}
256
257// Tea framework methods
258func (m Model) Init() tea.Cmd {
259 return tea.Batch(
260 m.filepicker.Init(),
261 tea.EnterAltScreen,
262 func() tea.Msg {
263 return tea.WindowSizeMsg{Width: 120, Height: 40}
264 },
265 )
266}
267
268func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
269 var cmd tea.Cmd
270 var cmds []tea.Cmd
271
272 switch msg := msg.(type) {
273 case tea.WindowSizeMsg:
274 m.width = msg.Width
275 m.height = msg.Height
276 m.ready = true
277
278 // Update component sizes
279 m.updateSizes()
280
281 // Update filepicker size
282 m.filepicker.Height = m.height - 10
283
284 case tea.KeyMsg:
285 switch {
286 case key.Matches(msg, keys.Quit):
287 return m, tea.Quit
288
289 case key.Matches(msg, keys.Files):
290 m.currentView = FilesView
291 m.status = "File Explorer - Navigate with vim keys"
292
293 case key.Matches(msg, keys.Chat):
294 m.currentView = ChatView
295 m.status = "AI Chat - Ask questions, get help"
296 m.textarea.Focus()
297
298 case key.Matches(msg, keys.Memory):
299 m.currentView = MemoryView
300 m.status = "Memory System - Persistent knowledge"
301
302 case key.Matches(msg, keys.Help):
303 m.help.ShowAll = !m.help.ShowAll
304
305 case key.Matches(msg, keys.Send) && m.currentView == ChatView:
306 if strings.TrimSpace(m.textarea.Value()) != "" {
307 return m, m.sendMessage()
308 }
309
310 case key.Matches(msg, keys.Enter) && m.currentView == FilesView:
311 // Handle file selection
312 if selected, selectedPath := m.filepicker.DidSelectFile(msg); selected {
313 m.currentFile = selectedPath
314 m.status = fmt.Sprintf("Selected: %s", filepath.Base(selectedPath))
315 }
316 }
317
318 case chatResponseMsg:
319 m.chatHistory = append(m.chatHistory, ChatMessage{
320 Role: "assistant",
321 Content: string(msg),
322 Timestamp: time.Now(),
323 })
324 m.isThinking = false
325 m.status = "Response received"
326 m.updateChatView()
327
328 case errorMsg:
329 m.status = fmt.Sprintf("Error: %s", string(msg))
330 m.isThinking = false
331 }
332
333 // Update active component
334 switch m.currentView {
335 case FilesView:
336 m.filepicker, cmd = m.filepicker.Update(msg)
337 cmds = append(cmds, cmd)
338
339 case ChatView:
340 m.textarea, cmd = m.textarea.Update(msg)
341 cmds = append(cmds, cmd)
342 m.viewport, cmd = m.viewport.Update(msg)
343 cmds = append(cmds, cmd)
344 }
345
346 return m, tea.Batch(cmds...)
347}
348
349func (m Model) View() string {
350 if !m.ready {
351 return "Initializing Del..."
352 }
353
354 // Header
355 header := titleStyle.Render("🤖 Del") + " " +
356 subtitleStyle.Render("AI Coding Assistant")
357
358 // Status bar
359 statusBar := statusBarStyle.Width(m.width).Render(m.status)
360
361 // Main content based on current view
362 var content string
363 switch m.currentView {
364 case FilesView:
365 content = m.renderFilesView()
366 case ChatView:
367 content = m.renderChatView()
368 case MemoryView:
369 content = m.renderMemoryView()
370 }
371
372 // Help
373 helpView := helpStyle.Render(m.help.View(keys))
374
375 // Layout
376 return lipgloss.JoinVertical(
377 lipgloss.Left,
378 header,
379 "",
380 content,
381 "",
382 helpView,
383 statusBar,
384 )
385}
386
387// Helper methods
388func (m *Model) updateSizes() {
389 contentHeight := m.height - 8 // Reserve space for header, help, status
390 contentWidth := m.width - 4 // Padding
391
392 // Update component sizes
393 m.viewport.Width = contentWidth - 4
394 m.viewport.Height = contentHeight - 6
395 m.textarea.SetWidth(contentWidth - 4)
396 m.filepicker.Height = contentHeight
397}
398
399func (m *Model) renderFilesView() string {
400 content := borderStyle.Width(m.width-2).Render(
401 lipgloss.JoinVertical(
402 lipgloss.Left,
403 chatMessageStyle.Render("📁 File Explorer"),
404 "",
405 m.filepicker.View(),
406 ),
407 )
408 return content
409}
410
411func (m *Model) renderChatView() string {
412 chatContent := m.viewport.View()
413 inputContent := borderStyle.Width(m.width-2).Render(
414 lipgloss.JoinVertical(
415 lipgloss.Left,
416 chatMessageStyle.Render("💬 Chat Input"),
417 m.textarea.View(),
418 ),
419 )
420
421 content := lipgloss.JoinVertical(
422 lipgloss.Left,
423 borderStyle.Width(m.width-2).Render(chatContent),
424 "",
425 inputContent,
426 )
427
428 return content
429}
430
431func (m *Model) renderMemoryView() string {
432 memoryContent := "🧠 Memory System\n\nPersistent memory will be displayed here.\nComing soon: view and search your conversation history!"
433
434 content := borderStyle.Width(m.width-2).Render(
435 chatMessageStyle.Render(memoryContent),
436 )
437 return content
438}
439
440func (m *Model) updateChatView() {
441 var chatLines []string
442
443 for _, msg := range m.chatHistory {
444 timestamp := msg.Timestamp.Format("15:04")
445
446 var styledMsg string
447 if msg.Role == "user" {
448 styledMsg = userMessageStyle.Render(fmt.Sprintf("[%s] You:", timestamp)) + "\n" +
449 chatMessageStyle.Render(msg.Content)
450 } else {
451 // Render markdown for assistant messages
452 rendered, err := m.glamour.Render(msg.Content)
453 if err != nil {
454 rendered = msg.Content
455 }
456 styledMsg = assistantMessageStyle.Render(fmt.Sprintf("[%s] Del:", timestamp)) + "\n" +
457 chatMessageStyle.Render(rendered)
458 }
459
460 chatLines = append(chatLines, styledMsg)
461 }
462
463 m.viewport.SetContent(strings.Join(chatLines, "\n\n"))
464 m.viewport.GotoBottom()
465}
466
467// Message types for async operations
468type chatResponseMsg string
469type errorMsg string
470
471func (m *Model) sendMessage() tea.Cmd {
472 userMessage := strings.TrimSpace(m.textarea.Value())
473 if userMessage == "" {
474 return nil
475 }
476
477 // Add user message to history
478 m.chatHistory = append(m.chatHistory, ChatMessage{
479 Role: "user",
480 Content: userMessage,
481 Timestamp: time.Now(),
482 })
483
484 // Clear textarea
485 m.textarea.Reset()
486 m.isThinking = true
487 m.status = "Del is thinking..."
488 m.updateChatView()
489
490 // Send to AI in background
491 return func() tea.Msg {
492 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
493 defer cancel()
494
495 // Simple chat without tools for now - focus on beautiful UX first
496 req := &api.ChatRequest{
497 Model: "qwen2.5:latest",
498 Messages: []api.Message{
499 {Role: "user", Content: userMessage},
500 },
501 }
502
503 var response strings.Builder
504 err := m.client.Chat(ctx, req, func(resp api.ChatResponse) error {
505 response.WriteString(resp.Message.Content)
506 return nil
507 })
508
509 if err != nil {
510 return errorMsg(err.Error())
511 }
512
513 return chatResponseMsg(response.String())
514 }
515}
516
517func main() {
518 // Set up proper terminal handling
519 if len(os.Getenv("DEBUG")) > 0 {
520 f, err := tea.LogToFile("debug.log", "debug")
521 if err != nil {
522 fmt.Println("fatal:", err)
523 os.Exit(1)
524 }
525 defer f.Close()
526 }
527
528 // Check if we're in an interactive terminal
529 if !isInteractiveTerminal() {
530 fmt.Println("🤖 Del - AI Coding Assistant")
531 fmt.Println("✨ Beautiful TUI interface built with Charm/Bubbletea")
532 fmt.Println("📁 File explorer with vim-like navigation")
533 fmt.Println("💬 AI chat with markdown rendering")
534 fmt.Println("🧠 Persistent memory system")
535 fmt.Println("")
536 fmt.Println("⚠️ Del requires an interactive terminal to run.")
537 fmt.Println(" Please run Del from a proper terminal (not through CI/automation)")
538 return
539 }
540
541 // Initialize and run the program
542 p := tea.NewProgram(
543 initialModel(),
544 tea.WithAltScreen(),
545 tea.WithMouseCellMotion(),
546 )
547
548 if _, err := p.Run(); err != nil {
549 fmt.Printf("Error running Del: %v", err)
550 os.Exit(1)
551 }
552}
553
554func isInteractiveTerminal() bool {
555 // Check if stdin is a terminal
556 if fileInfo, _ := os.Stdin.Stat(); (fileInfo.Mode() & os.ModeCharDevice) == 0 {
557 return false
558 }
559
560 // Check if stdout is a terminal
561 if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) == 0 {
562 return false
563 }
564
565 return true
566}