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}