Skip to content

Commit beb2881

Browse files
authored
Merge pull request #44 from nathfavour/master
Enhance TUI with intelligent suggestions and split-view workspace
2 parents d0072e8 + 7cdd066 commit beb2881

1 file changed

Lines changed: 279 additions & 14 deletions

File tree

cmd/vibeaura/chat.go

Lines changed: 279 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package main
33
import (
44
"context"
55
"fmt"
6+
"os"
7+
"path/filepath"
68
"runtime"
9+
"sort"
710
"strings"
811

912
"github.com/charmbracelet/bubbles/textarea"
@@ -15,14 +18,19 @@ import (
1518
)
1619

1720
type model struct {
18-
viewport viewport.Model
19-
messages []string
20-
textarea textarea.Model
21-
err error
22-
brain *brain.Brain
23-
width int
24-
height int
25-
initialized bool
21+
viewport viewport.Model
22+
messages []string
23+
textarea textarea.Model
24+
err error
25+
brain *brain.Brain
26+
width int
27+
height int
28+
initialized bool
29+
showTree bool
30+
treeView string
31+
suggestions []string
32+
suggestionIdx int
33+
triggerChar string // '/' or '#'
2634
}
2735

2836
var (
@@ -51,13 +59,36 @@ var (
5159

5260
helpStyle = lipgloss.NewStyle().
5361
Foreground(lipgloss.Color("#626262"))
62+
63+
tagStyle = lipgloss.NewStyle().
64+
Foreground(lipgloss.Color("#FFD700")).
65+
Bold(true).
66+
Italic(true)
67+
68+
suggestionStyle = lipgloss.NewStyle().
69+
Foreground(lipgloss.Color("#7D56F4")).
70+
Background(lipgloss.Color("#222222"))
71+
72+
selectedSuggestionStyle = lipgloss.NewStyle().
73+
Foreground(lipgloss.Color("#FAFAFA")).
74+
Background(lipgloss.Color("#7D56F4")).
75+
Bold(true)
76+
77+
treeStyle = lipgloss.NewStyle().
78+
Border(lipgloss.NormalBorder(), false, false, false, true).
79+
BorderForeground(lipgloss.Color("#444444")).
80+
PaddingLeft(2)
5481
)
5582

5683
type chatState struct {
5784
Messages []string `json:"messages"`
5885
Input string `json:"input"`
5986
}
6087

88+
var allCommands = []string{
89+
"/help", "/status", "/cwd", "/version", "/clear", "/exit", "/show-tree",
90+
}
91+
6192
func initialModel(b *brain.Brain) *model {
6293
ta := textarea.New()
6394
ta.Placeholder = "Send a message or type / for commands..."
@@ -128,9 +159,28 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
128159
m.width = msg.Width
129160
m.height = msg.Height
130161
m.viewport.Width = msg.Width
131-
m.textarea.SetWidth(msg.Width)
162+
if m.showTree {
163+
m.viewport.Width = msg.Width / 2
164+
}
165+
m.textarea.SetWidth(m.viewport.Width)
132166
m.viewport.Height = msg.Height - m.textarea.Height() - 6
133167
case tea.KeyMsg:
168+
// Suggestion navigation
169+
if len(m.suggestions) > 0 {
170+
switch msg.String() {
171+
case "tab", "down":
172+
m.suggestionIdx = (m.suggestionIdx + 1) % len(m.suggestions)
173+
return m, nil
174+
case "shift+tab", "up":
175+
m.suggestionIdx = (m.suggestionIdx - 1 + len(m.suggestions)) % len(m.suggestions)
176+
return m, nil
177+
case "enter":
178+
// Accept suggestion
179+
m.applySuggestion()
180+
return m, nil
181+
}
182+
}
183+
134184
switch msg.String() {
135185
case "ctrl+c", "esc":
136186
m.saveState()
@@ -149,14 +199,18 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149199
// Add user message via a response update
150200
m.messages = append(m.messages, userStyle.Render("You: ")+v)
151201
m.textarea.Reset()
202+
m.updateSuggestions("") // Clear suggestions
152203
m.viewport.SetContent(strings.Join(m.messages, "\n\n"))
153204
m.viewport.GotoBottom()
154205

155206
m.saveState()
156207

157208
// Process via brain
158209
return m, m.processRequest(v)
159-
210+
default:
211+
// After normal keypress, update suggestions
212+
m.updateSuggestions(m.textarea.Value())
213+
m.updateDynamicPreview()
160214
}
161215
case brain.Response:
162216
if msg.Error != nil {
@@ -172,6 +226,160 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172226
return m, tea.Batch(tiCmd, vpCmd)
173227
}
174228

229+
func (m *model) updateSuggestions(val string) {
230+
m.suggestions = nil
231+
m.suggestionIdx = 0
232+
m.triggerChar = ""
233+
234+
if val == "" {
235+
return
236+
}
237+
238+
// Find the word being typed
239+
words := strings.Fields(val)
240+
if len(words) == 0 {
241+
if strings.HasSuffix(val, "/") {
242+
m.triggerChar = "/"
243+
m.suggestions = append([]string{}, allCommands...)
244+
sort.Strings(m.suggestions)
245+
} else if strings.HasSuffix(val, "#") {
246+
m.triggerChar = "#"
247+
m.suggestions = m.getFileSuggestions("")
248+
}
249+
return
250+
}
251+
252+
lastWord := words[len(words)-1]
253+
if strings.HasPrefix(lastWord, "/") {
254+
m.triggerChar = "/"
255+
for _, cmd := range allCommands {
256+
if strings.HasPrefix(cmd, lastWord) {
257+
m.suggestions = append(m.suggestions, cmd)
258+
}
259+
}
260+
sort.Strings(m.suggestions)
261+
} else if strings.HasPrefix(lastWord, "#") {
262+
m.triggerChar = "#"
263+
m.suggestions = m.getFileSuggestions(lastWord[1:])
264+
}
265+
}
266+
267+
func (m *model) getFileSuggestions(prefix string) []string {
268+
var suggestions []string
269+
root, _ := os.Getwd()
270+
271+
filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
272+
if err != nil || len(suggestions) > 30 {
273+
return nil
274+
}
275+
276+
name := d.Name()
277+
if d.IsDir() {
278+
if name == ".git" || name == "node_modules" || name == "vendor" || name == "bin" || name == "dist" {
279+
return filepath.SkipDir
280+
}
281+
if prefix != "" && !strings.HasPrefix(name, prefix) && !strings.HasPrefix(path, prefix) {
282+
return nil
283+
}
284+
}
285+
286+
rel, _ := filepath.Rel(root, path)
287+
if rel == "." {
288+
return nil
289+
}
290+
291+
if prefix == "" || strings.HasPrefix(rel, prefix) || strings.HasPrefix(name, prefix) {
292+
suggestions = append(suggestions, rel)
293+
}
294+
295+
return nil
296+
})
297+
298+
sort.Strings(suggestions)
299+
return suggestions
300+
}
301+
302+
func (m *model) applySuggestion() {
303+
if len(m.suggestions) == 0 {
304+
return
305+
}
306+
307+
val := m.textarea.Value()
308+
words := strings.Fields(val)
309+
if len(words) == 0 {
310+
m.textarea.SetValue(m.suggestions[m.suggestionIdx] + " ")
311+
} else {
312+
words[len(words)-1] = m.triggerChar + m.suggestions[m.suggestionIdx]
313+
m.textarea.SetValue(strings.Join(words, " ") + " ")
314+
}
315+
m.textarea.SetCursor(len(m.textarea.Value()))
316+
m.suggestions = nil
317+
m.updateDynamicPreview()
318+
}
319+
320+
func (m *model) updateDynamicPreview() {
321+
if !m.showTree {
322+
return
323+
}
324+
325+
val := m.textarea.Value()
326+
tags := m.extractTags(val)
327+
328+
if len(tags) > 0 {
329+
// Show the last tag's content
330+
lastTag := tags[len(tags)-1]
331+
content, err := os.ReadFile(lastTag)
332+
if err == nil {
333+
m.treeView = string(content)
334+
return
335+
}
336+
337+
// If it's a directory, show tree
338+
info, err := os.Stat(lastTag)
339+
if err == nil && info.IsDir() {
340+
m.treeView = m.renderTree(lastTag)
341+
return
342+
}
343+
}
344+
345+
// Default to workspace tree
346+
m.treeView = m.renderTree(".")
347+
}
348+
349+
func (m *model) extractTags(val string) []string {
350+
var tags []string
351+
words := strings.Fields(val)
352+
for _, w := range words {
353+
if strings.HasPrefix(w, "#") {
354+
path := strings.TrimPrefix(w, "#")
355+
if _, err := os.Stat(path); err == nil {
356+
tags = append(tags, path)
357+
}
358+
}
359+
}
360+
return tags
361+
}
362+
363+
func (m *model) renderTree(root string) string {
364+
var sb strings.Builder
365+
sb.WriteString(systemStyle.Render(" EXPLORER: " + root) + "\n\n")
366+
367+
entries, _ := os.ReadDir(root)
368+
for _, entry := range entries {
369+
name := entry.Name()
370+
if strings.HasPrefix(name, ".") && name != ".env" {
371+
continue
372+
}
373+
374+
icon := "📄 "
375+
if entry.IsDir() {
376+
icon = "📁 "
377+
}
378+
sb.WriteString(icon + name + "\n")
379+
}
380+
return sb.String()
381+
}
382+
175383
func (m *model) processRequest(content string) tea.Cmd {
176384
return func() tea.Msg {
177385
ctx := context.Background()
@@ -200,6 +408,16 @@ func (m *model) handleSlashCommand(cmd string) (tea.Model, tea.Cmd) {
200408
m.messages = append(m.messages, systemStyle.Render(" CWD ") + " " + helpStyle.Render(snapshot.WorkingDir))
201409
case "/version":
202410
m.messages = append(m.messages, systemStyle.Render(" VERSION ") + "\n" + helpStyle.Render(fmt.Sprintf("App: %s\nCommit: %s\nCompiler: %s", Version, Commit, runtime.Version())))
411+
case "/show-tree":
412+
m.showTree = !m.showTree
413+
if m.showTree {
414+
m.viewport.Width = m.width / 2
415+
m.treeView = m.renderTree(".")
416+
} else {
417+
m.viewport.Width = m.width
418+
}
419+
m.textarea.SetWidth(m.viewport.Width)
420+
m.messages = append(m.messages, systemStyle.Render(" Sideview Toggled "))
203421
case "/clear":
204422
m.messages = []string{}
205423
m.viewport.SetContent(systemStyle.Render(" Session Cleared "))
@@ -216,19 +434,66 @@ func (m *model) handleSlashCommand(cmd string) (tea.Model, tea.Cmd) {
216434
}
217435

218436
func (m *model) View() string {
219-
header := titleStyle.Render(" vibeauracle ") + " " + helpStyle.Render("v" + Version)
437+
header := titleStyle.Render(" vibeauracle ") + " " + helpStyle.Render("v"+Version)
220438
border := strings.Repeat("─", m.width)
221439
if m.width > 20 {
222440
border = strings.Repeat("─", m.width-1)
223441
}
224442

225-
return fmt.Sprintf(
443+
mainContent := m.viewport.View()
444+
if m.showTree {
445+
mainContent = lipgloss.JoinHorizontal(lipgloss.Top,
446+
m.viewport.View(),
447+
treeStyle.Render(m.treeView),
448+
)
449+
}
450+
451+
suggestionView := ""
452+
if len(m.suggestions) > 0 {
453+
var sbs []string
454+
for i, s := range m.suggestions {
455+
style := suggestionStyle
456+
if i == m.suggestionIdx {
457+
style = selectedSuggestionStyle
458+
}
459+
460+
// Format: name path (truncated)
461+
name := filepath.Base(s)
462+
if m.triggerChar == "/" {
463+
name = s
464+
}
465+
dir := filepath.Dir(s)
466+
if dir == "." {
467+
dir = ""
468+
} else {
469+
dir = " " + dir
470+
if len(dir) > 20 {
471+
dir = dir[:17] + "..."
472+
}
473+
}
474+
475+
sbs = append(sbs, style.Render(fmt.Sprintf(" %-15s %s ", name, dir)))
476+
}
477+
suggestionView = lipgloss.NewStyle().
478+
Border(lipgloss.RoundedBorder()).
479+
BorderForeground(lipgloss.Color("#7D56F4")).
480+
Render(strings.Join(sbs, "\n"))
481+
}
482+
483+
view := fmt.Sprintf(
226484
"%s\n%s\n%s\n%s\n%s",
227485
header,
228486
lipgloss.NewStyle().Foreground(lipgloss.Color("#444444")).Render(border),
229-
m.viewport.View(),
487+
mainContent,
230488
lipgloss.NewStyle().Foreground(lipgloss.Color("#444444")).Render(border),
231489
m.textarea.View(),
232-
) + "\n"
490+
)
491+
492+
if suggestionView != "" {
493+
// Overlay logic: simplified for TUI
494+
view += "\n" + suggestionView
495+
}
496+
497+
return view + "\n"
233498
}
234499

0 commit comments

Comments
 (0)