diff options
Diffstat (limited to 'tables.go')
| -rw-r--r-- | tables.go | 1073 |
1 files changed, 917 insertions, 156 deletions
@@ -2,70 +2,124 @@ package main import ( "fmt" + "image" "os" "path" "strings" "time" - "elefant/models" - "elefant/pngmeta" - "elefant/rag" + "gf-lt/models" + "gf-lt/pngmeta" + "gf-lt/rag" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) func makeChatTable(chatMap map[string]models.Chat) *tview.Table { - actions := []string{"load", "rename", "delete", "update card"} + actions := []string{"load", "rename", "delete", "update card", "move sysprompt onto 1st msg", "new_chat_from_card"} chatList := make([]string, len(chatMap)) i := 0 for name := range chatMap { chatList[i] = name i++ } - rows, cols := len(chatMap), len(actions)+2 + // Sort chatList by UpdatedAt field in descending order (most recent first) + for i := 0; i < len(chatList)-1; i++ { + for j := i + 1; j < len(chatList); j++ { + if chatMap[chatList[i]].UpdatedAt.Before(chatMap[chatList[j]].UpdatedAt) { + // Swap chatList[i] and chatList[j] + chatList[i], chatList[j] = chatList[j], chatList[i] + } + } + } + // Add 1 extra row for header + rows, cols := len(chatMap)+1, len(actions)+4 // +2 for name, +2 for timestamps chatActTable := tview.NewTable(). SetBorders(true) - // for chatName, chat := range chatMap { - for r := 0; r < rows; r++ { - // r := 0 + // Add header row (row 0) + for c := 0; c < cols; c++ { + color := tcell.ColorWhite + var headerText string + switch c { + case 0: + headerText = "Chat Name" + case 1: + headerText = "Preview" + case 2: + headerText = "Created At" + case 3: + headerText = "Updated At" + default: + headerText = actions[c-4] + } + chatActTable.SetCell(0, c, + tview.NewTableCell(headerText). + SetSelectable(false). + SetTextColor(color). + SetAlign(tview.AlignCenter). + SetAttributes(tcell.AttrBold)) + } + previewLen := 100 + // Add data rows (starting from row 1) + for r := 0; r < rows-1; r++ { // rows-1 because we added a header row for c := 0; c < cols; c++ { color := tcell.ColorWhite switch c { case 0: - chatActTable.SetCell(r, c, + chatActTable.SetCell(r+1, c, // +1 to account for header row tview.NewTableCell(chatList[r]). + SetSelectable(false). SetTextColor(color). SetAlign(tview.AlignCenter)) case 1: - chatActTable.SetCell(r, c, - tview.NewTableCell(chatMap[chatList[r]].Msgs[len(chatMap[chatList[r]].Msgs)-30:]). + if len(chatMap[chatList[r]].Msgs) < 100 { + previewLen = len(chatMap[chatList[r]].Msgs) + } + chatActTable.SetCell(r+1, c, // +1 to account for header row + tview.NewTableCell(chatMap[chatList[r]].Msgs[len(chatMap[chatList[r]].Msgs)-previewLen:]). + SetSelectable(false). SetTextColor(color). SetAlign(tview.AlignCenter)) - default: - chatActTable.SetCell(r, c, - tview.NewTableCell(actions[c-2]). + case 2: + // Created At column + chatActTable.SetCell(r+1, c, // +1 to account for header row + tview.NewTableCell(chatMap[chatList[r]].CreatedAt.Format("2006-01-02 15:04")). + SetSelectable(false). + SetTextColor(color). + SetAlign(tview.AlignCenter)) + case 3: + // Updated At column + chatActTable.SetCell(r+1, c, // +1 to account for header row + tview.NewTableCell(chatMap[chatList[r]].UpdatedAt.Format("2006-01-02 15:04")). + SetSelectable(false). SetTextColor(color). SetAlign(tview.AlignCenter)) + default: + chatActTable.SetCell(r+1, c, // +1 to account for header row + tview.NewTableCell(actions[c-4]). // Adjusted offset to account for 2 new timestamp columns + SetTextColor(color). + SetAlign(tview.AlignCenter)) } } - // r++ } - chatActTable.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEsc || key == tcell.KeyF1 { + chatActTable.Select(1, 0).SetSelectable(true, true).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') { pages.RemovePage(historyPage) return } - if key == tcell.KeyEnter { - chatActTable.SetSelectable(true, true) - } }).SetSelectedFunc(func(row int, column int) { + // Skip header row (row 0) for selection + if row == 0 { + // If user clicks on header, just return without action + chatActTable.Select(1, column) // Move selection to first data row + return + } tc := chatActTable.GetCell(row, column) tc.SetTextColor(tcell.ColorRed) chatActTable.SetSelectable(false, false) - selectedChat := chatList[row] + selectedChat := chatList[row-1] // -1 to account for header row defer pages.RemovePage(historyPage) - // notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text) switch tc.Text { case "load": history, err := loadHistoryChat(selectedChat) @@ -75,7 +129,7 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table { return } chatBody.Messages = history - textView.SetText(chatToText(cfg.ShowSys)) + textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) activeChatName = selectedChat pages.RemovePage(historyPage) return @@ -93,56 +147,132 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table { if err := store.RemoveChat(sc.ID); err != nil { logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name) } - if err := notifyUser("chat deleted", selectedChat+" was deleted"); err != nil { - logger.Error("failed to send notification", "error", err) - } + showToast("chat deleted", selectedChat+" was deleted") // load last chat chatBody.Messages = loadOldChatOrGetNew() - textView.SetText(chatToText(cfg.ShowSys)) + textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) pages.RemovePage(historyPage) return case "update card": // save updated card fi := strings.Index(selectedChat, "_") agentName := selectedChat[fi+1:] - cc, ok := sysMap[agentName] - if !ok { + cc := GetCardByRole(agentName) + if cc == nil { logger.Warn("no such card", "agent", agentName) - //no:lint - if err := notifyUser("error", "no such card: "+agentName); err != nil { - logger.Warn("failed ot notify", "error", err) - } + showToast("error", "no such card: "+agentName) return } - if chatBody.Messages[0].Role != "system" || chatBody.Messages[1].Role != agentName { - if err := notifyUser("error", "unexpected chat structure; card: "+agentName); err != nil { - logger.Warn("failed ot notify", "error", err) - } - return - } - // change sys_prompt + first msg cc.SysPrompt = chatBody.Messages[0].Content cc.FirstMsg = chatBody.Messages[1].Content if err := pngmeta.WriteToPng(cc.ToSpec(cfg.UserRole), cc.FilePath, cc.FilePath); err != nil { - logger.Error("failed to write charcard", - "error", err) + logger.Error("failed to write charcard", "error", err) } return + case "move sysprompt onto 1st msg": + chatBody.Messages[1].Content = chatBody.Messages[0].Content + chatBody.Messages[1].Content + chatBody.Messages[0].Content = rpDefenitionSysMsg + textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) + activeChatName = selectedChat + pages.RemovePage(historyPage) + return + case "new_chat_from_card": + fi := strings.Index(selectedChat, "_") + agentName := selectedChat[fi+1:] + cc := GetCardByRole(agentName) + if cc == nil { + logger.Warn("no such card", "agent", agentName) + showToast("error", "no such card: "+agentName) + return + } + newCard, err := pngmeta.ReadCard(cc.FilePath, cfg.UserRole) + if err != nil { + logger.Error("failed to reload charcard", "path", cc.FilePath, "error", err) + newCard, err = pngmeta.ReadCardJson(cc.FilePath) + if err != nil { + logger.Error("failed to reload charcard", "path", cc.FilePath, "error", err) + showToast("error", "failed to reload card: "+cc.FilePath) + return + } + } + if newCard.ID == "" { + newCard.ID = models.ComputeCardID(newCard.Role, newCard.FilePath) + } + sysMap[newCard.ID] = newCard + roleToID[newCard.Role] = newCard.ID + startNewChat(false) + pages.RemovePage(historyPage) + return default: return } }) + // Add input capture to handle 'x' key for closing the table + chatActTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune && event.Rune() == 'x' { + pages.RemovePage(historyPage) + return nil + } + return event + }) return chatActTable } -// func makeRAGTable(fileList []string) *tview.Table { -func makeRAGTable(fileList []string) *tview.Flex { - actions := []string{"load", "delete"} - rows, cols := len(fileList), len(actions)+1 +// nolint:unused +func formatSize(size int64) string { + units := []string{"B", "KB", "MB", "GB", "TB"} + i := 0 + s := float64(size) + for s >= 1024 && i < len(units)-1 { + s /= 1024 + i++ + } + return fmt.Sprintf("%.1f%s", s, units[i]) +} + +type ragFileInfo struct { + name string + inRAGDir bool + isLoaded bool + fullPath string +} + +func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex { + // Build set of loaded files for quick lookup + loadedSet := make(map[string]bool) + for _, f := range loadedFiles { + loadedSet[f] = true + } + // Build merged list: files from ragdir + orphaned files from DB + ragFiles := make([]ragFileInfo, 0, len(fileList)+len(loadedFiles)) + seen := make(map[string]bool) + // Add files from ragdir + for _, f := range fileList { + ragFiles = append(ragFiles, ragFileInfo{ + name: f, + inRAGDir: true, + isLoaded: loadedSet[f], + fullPath: path.Join(cfg.RAGDir, f), + }) + seen[f] = true + } + // Add orphaned files (in DB but not in ragdir) + for _, f := range loadedFiles { + if !seen[f] { + ragFiles = append(ragFiles, ragFileInfo{ + name: f, + inRAGDir: false, + isLoaded: true, + fullPath: "", + }) + } + } + rows := len(ragFiles) + cols := 4 // File Name | Preview | Action | Delete fileTable := tview.NewTable(). SetBorders(true) longStatusView := tview.NewTextView() - longStatusView.SetText("status text") + longStatusView.SetText("press x to exit") longStatusView.SetBorder(true).SetTitle("status") longStatusView.SetChangedFunc(func() { app.Draw() @@ -150,25 +280,99 @@ func makeRAGTable(fileList []string) *tview.Flex { ragflex := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(longStatusView, 0, 10, false). AddItem(fileTable, 0, 60, true) + // Add the exit option as the first row (row 0) + fileTable.SetCell(0, 0, + tview.NewTableCell("File Name"). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + fileTable.SetCell(0, 1, + tview.NewTableCell("Preview"). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + fileTable.SetCell(0, 2, + tview.NewTableCell("Load/Unload"). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + fileTable.SetCell(0, 3, + tview.NewTableCell("Delete"). + SetTextColor(tcell.ColorWhite). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + // Add the file rows starting from row 1 for r := 0; r < rows; r++ { + f := ragFiles[r] for c := 0; c < cols; c++ { color := tcell.ColorWhite - if c < 1 { - fileTable.SetCell(r, c, - tview.NewTableCell(fileList[r]). + switch c { + case 0: + displayName := f.name + if !f.inRAGDir { + displayName = f.name + " (orphaned)" + } + fileTable.SetCell(r+1, c, + tview.NewTableCell(displayName). SetTextColor(color). - SetAlign(tview.AlignCenter)) - } else { - fileTable.SetCell(r, c, - tview.NewTableCell(actions[c-1]). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + case 1: + if !f.inRAGDir { + // Orphaned file - no preview available + fileTable.SetCell(r+1, c, + tview.NewTableCell("not in ragdir"). + SetTextColor(tcell.ColorYellow). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + } else if fi, err := os.Stat(f.fullPath); err == nil { + size := fi.Size() + modTime := fi.ModTime() + preview := fmt.Sprintf("%s | %s", formatSize(size), modTime.Format("2006-01-02 15:04")) + fileTable.SetCell(r+1, c, + tview.NewTableCell(preview). + SetTextColor(color). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + } else { + fileTable.SetCell(r+1, c, + tview.NewTableCell("error"). + SetTextColor(color). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + } + case 2: + actionText := "load" + if f.isLoaded { + actionText = "unload" + } + if !f.inRAGDir { + // Orphaned file - can only unload + actionText = "unload" + } + fileTable.SetCell(r+1, c, + tview.NewTableCell(actionText). SetTextColor(color). SetAlign(tview.AlignCenter)) + case 3: + if !f.inRAGDir { + // Orphaned file - cannot delete from ragdir (not there) + fileTable.SetCell(r+1, c, + tview.NewTableCell("-"). + SetTextColor(tcell.ColorDarkGray). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + } else { + fileTable.SetCell(r+1, c, + tview.NewTableCell("delete"). + SetTextColor(color). + SetAlign(tview.AlignCenter)) + } } } } - errCh := make(chan error, 1) + errCh := make(chan error, 1) // why? go func() { - defer pages.RemovePage(RAGPage) for { select { case err := <-errCh: @@ -192,119 +396,145 @@ func makeRAGTable(fileList []string) *tview.Flex { } } }() - fileTable.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEsc || key == tcell.KeyF1 { - pages.RemovePage(RAGPage) + fileTable.Select(0, 0). + SetFixed(1, 1). + SetSelectable(true, true). + SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)). + SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') || key == tcell.KeyCtrlX { + pages.RemovePage(RAGPage) + return + } + }).SetSelectedFunc(func(row int, column int) { + // If user selects a non-actionable column (0 or 1), move to first action column (2) + if column <= 1 { + if fileTable.GetColumnCount() > 2 { + fileTable.Select(row, 2) // Select first action column + } return } - if key == tcell.KeyEnter { - fileTable.SetSelectable(true, true) - } - }).SetSelectedFunc(func(row int, column int) { - // defer pages.RemovePage(RAGPage) tc := fileTable.GetCell(row, column) tc.SetTextColor(tcell.ColorRed) fileTable.SetSelectable(false, false) - fpath := fileList[row] - // notification := fmt.Sprintf("chat: %s; action: %s", fpath, tc.Text) + // Check if the selected row is the exit row (row 0) - do this first to avoid index issues + if row == 0 { + pages.RemovePage(RAGPage) + return + } + // For file rows, get the file info (row index - 1 because of the exit row at index 0) + f := ragFiles[row-1] + // Handle "-" case (orphaned file with no delete option) + if tc.Text == "-" { + return + } switch tc.Text { case "load": - fpath = path.Join(cfg.RAGDir, fpath) + fpath := path.Join(cfg.RAGDir, f.name) longStatusView.SetText("clicked load") go func() { if err := ragger.LoadRAG(fpath); err != nil { logger.Error("failed to embed file", "chat", fpath, "error", err) - errCh <- err - // pages.RemovePage(RAGPage) + showToast("RAG", "failed to embed file; error: "+err.Error()) return } + showToast("RAG", "file loaded successfully") + app.QueueUpdate(func() { + pages.RemovePage(RAGPage) + loadedFiles, _ := ragger.ListLoaded() + chatRAGTable := makeRAGTable(fileList, loadedFiles) + pages.AddPage(RAGPage, chatRAGTable, true, true) + }) + }() + return + case "unload": + longStatusView.SetText("clicked unload") + go func() { + if err := ragger.RemoveFile(f.name); err != nil { + logger.Error("failed to unload file from RAG", "filename", f.name, "error", err) + showToast("RAG", "failed to unload file; error: "+err.Error()) + return + } + showToast("RAG", "file unloaded successfully") + app.QueueUpdate(func() { + pages.RemovePage(RAGPage) + loadedFiles, _ := ragger.ListLoaded() + chatRAGTable := makeRAGTable(fileList, loadedFiles) + pages.AddPage(RAGPage, chatRAGTable, true, true) + }) }() return case "delete": - fpath = path.Join(cfg.RAGDir, fpath) + fpath := path.Join(cfg.RAGDir, f.name) if err := os.Remove(fpath); err != nil { logger.Error("failed to delete file", "filename", fpath, "error", err) return } - if err := notifyUser("chat deleted", fpath+" was deleted"); err != nil { - logger.Error("failed to send notification", "error", err) - } + showToast("chat deleted", fpath+" was deleted") + go func() { + app.QueueUpdate(func() { + pages.RemovePage(RAGPage) + newFileList, _ := os.ReadDir(cfg.RAGDir) + loadedFiles, _ := ragger.ListLoaded() + var newFiles []string + for _, f := range newFileList { + if !f.IsDir() { + newFiles = append(newFiles, f.Name()) + } + } + chatRAGTable := makeRAGTable(newFiles, loadedFiles) + pages.AddPage(RAGPage, chatRAGTable, true, true) + }) + }() return default: + pages.RemovePage(RAGPage) return } }) - return ragflex -} - -func makeLoadedRAGTable(fileList []string) *tview.Table { - actions := []string{"delete"} - rows, cols := len(fileList), len(actions)+1 - fileTable := tview.NewTable(). - SetBorders(true) - for r := 0; r < rows; r++ { - for c := 0; c < cols; c++ { - color := tcell.ColorWhite - if c < 1 { - fileTable.SetCell(r, c, - tview.NewTableCell(fileList[r]). - SetTextColor(color). - SetAlign(tview.AlignCenter)) - } else { - fileTable.SetCell(r, c, - tview.NewTableCell(actions[c-1]). - SetTextColor(color). - SetAlign(tview.AlignCenter)) - } - } - } - fileTable.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEsc || key == tcell.KeyF1 { + // Add input capture to the flex container to handle 'x' key for closing + ragflex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune && event.Rune() == 'x' { pages.RemovePage(RAGPage) - return - } - if key == tcell.KeyEnter { - fileTable.SetSelectable(true, true) - } - }).SetSelectedFunc(func(row int, column int) { - defer pages.RemovePage(RAGPage) - tc := fileTable.GetCell(row, column) - tc.SetTextColor(tcell.ColorRed) - fileTable.SetSelectable(false, false) - fpath := fileList[row] - // notification := fmt.Sprintf("chat: %s; action: %s", fpath, tc.Text) - switch tc.Text { - case "delete": - if err := ragger.RemoveFile(fpath); err != nil { - logger.Error("failed to delete file", "filename", fpath, "error", err) - return - } - if err := notifyUser("chat deleted", fpath+" was deleted"); err != nil { - logger.Error("failed to send notification", "error", err) - } - return - default: - // pages.RemovePage(RAGPage) - return + return nil } + return event }) - return fileTable + return ragflex } func makeAgentTable(agentList []string) *tview.Table { - actions := []string{"load"} + actions := []string{"filepath", "load"} rows, cols := len(agentList), len(actions)+1 chatActTable := tview.NewTable(). SetBorders(true) for r := 0; r < rows; r++ { for c := 0; c < cols; c++ { color := tcell.ColorWhite - if c < 1 { + switch c { + case 0: chatActTable.SetCell(r, c, tview.NewTableCell(agentList[r]). SetTextColor(color). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + case 1: + if actions[c-1] == "filepath" { + cc := GetCardByRole(agentList[r]) + if cc == nil { + continue + } + chatActTable.SetCell(r, c, + tview.NewTableCell(cc.FilePath). + SetTextColor(color). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + continue + } + chatActTable.SetCell(r, c, + tview.NewTableCell(actions[c-1]). + SetTextColor(color). SetAlign(tview.AlignCenter)) - } else { + default: chatActTable.SetCell(r, c, tview.NewTableCell(actions[c-1]). SetTextColor(color). @@ -312,15 +542,23 @@ func makeAgentTable(agentList []string) *tview.Table { } } } - chatActTable.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEsc || key == tcell.KeyF1 { - pages.RemovePage(agentPage) + chatActTable.Select(0, 0). + SetFixed(1, 1). + SetSelectable(true, true). + SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)). + SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') { + pages.RemovePage(agentPage) + return + } + }).SetSelectedFunc(func(row int, column int) { + // If user selects a non-actionable column (0 or 1), move to first action column (2) + if column <= 1 { + if chatActTable.GetColumnCount() > 2 { + chatActTable.Select(row, 2) // Select first action column + } return } - if key == tcell.KeyEnter { - chatActTable.SetSelectable(true, true) - } - }).SetSelectedFunc(func(row int, column int) { tc := chatActTable.GetCell(row, column) tc.SetTextColor(tcell.ColorRed) chatActTable.SetSelectable(false, false) @@ -328,13 +566,13 @@ func makeAgentTable(agentList []string) *tview.Table { // notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text) switch tc.Text { case "load": - if ok := charToStart(selected); !ok { + if ok := charToStart(selected, true); !ok { logger.Warn("no such sys msg", "name", selected) pages.RemovePage(agentPage) return } // replace textview - textView.SetText(chatToText(cfg.ShowSys)) + textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) colorText() updateStatusLine() // sysModal.ClearButtons() @@ -355,9 +593,7 @@ func makeAgentTable(agentList []string) *tview.Table { if err := store.RemoveChat(sc.ID); err != nil { logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name) } - if err := notifyUser("chat deleted", selected+" was deleted"); err != nil { - logger.Error("failed to send notification", "error", err) - } + showToast("chat deleted", selected+" was deleted") pages.RemovePage(agentPage) return default: @@ -365,6 +601,14 @@ func makeAgentTable(agentList []string) *tview.Table { return } }) + // Add input capture to handle 'x' key for closing the table + chatActTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune && event.Rune() == 'x' { + pages.RemovePage(agentPage) + return nil + } + return event + }) return chatActTable } @@ -380,12 +624,14 @@ func makeCodeBlockTable(codeBlocks []string) *tview.Table { if len(codeBlocks[r]) < 30 { previewLen = len(codeBlocks[r]) } - if c < 1 { + switch { + case c < 1: table.SetCell(r, c, tview.NewTableCell(codeBlocks[r][:previewLen]). SetTextColor(color). - SetAlign(tview.AlignCenter)) - } else { + SetAlign(tview.AlignCenter). + SetSelectable(false)) + default: table.SetCell(r, c, tview.NewTableCell(actions[c-1]). SetTextColor(color). @@ -393,15 +639,23 @@ func makeCodeBlockTable(codeBlocks []string) *tview.Table { } } } - table.Select(0, 0).SetFixed(1, 1).SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEsc || key == tcell.KeyF1 { - pages.RemovePage(agentPage) + table.Select(0, 0). + SetFixed(1, 1). + SetSelectable(true, true). + SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)). + SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') { + pages.RemovePage(codeBlockPage) + return + } + }).SetSelectedFunc(func(row int, column int) { + // If user selects a non-actionable column (0), move to first action column (1) + if column == 0 { + if table.GetColumnCount() > 1 { + table.Select(row, 1) // Select first action column + } return } - if key == tcell.KeyEnter { - table.SetSelectable(true, true) - } - }).SetSelectedFunc(func(row int, column int) { tc := table.GetCell(row, column) tc.SetTextColor(tcell.ColorRed) table.SetSelectable(false, false) @@ -410,13 +664,9 @@ func makeCodeBlockTable(codeBlocks []string) *tview.Table { switch tc.Text { case "copy": if err := copyToClipboard(selected); err != nil { - if err := notifyUser("error", err.Error()); err != nil { - logger.Error("failed to send notification", "error", err) - } - } - if err := notifyUser("copied", selected); err != nil { - logger.Error("failed to send notification", "error", err) + showToast("error", err.Error()) } + showToast("copied", selected) pages.RemovePage(codeBlockPage) app.SetFocus(textArea) return @@ -425,5 +675,516 @@ func makeCodeBlockTable(codeBlocks []string) *tview.Table { return } }) + // Add input capture to handle 'x' key for closing the table + table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune && event.Rune() == 'x' { + pages.RemovePage(codeBlockPage) + return nil + } + return event + }) return table } + +func makeImportChatTable(filenames []string) *tview.Table { + actions := []string{"load"} + rows, cols := len(filenames), len(actions)+1 + chatActTable := tview.NewTable(). + SetBorders(true) + for r := 0; r < rows; r++ { + for c := 0; c < cols; c++ { + color := tcell.ColorWhite + switch { + case c < 1: + chatActTable.SetCell(r, c, + tview.NewTableCell(filenames[r]). + SetTextColor(color). + SetAlign(tview.AlignCenter). + SetSelectable(false)) + default: + chatActTable.SetCell(r, c, + tview.NewTableCell(actions[c-1]). + SetTextColor(color). + SetAlign(tview.AlignCenter)) + } + } + } + chatActTable.Select(0, 0). + SetFixed(1, 1). + SetSelectable(true, true). + SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)). + SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') { + pages.RemovePage(historyPage) + return + } + }).SetSelectedFunc(func(row int, column int) { + // If user selects a non-actionable column (0), move to first action column (1) + if column == 0 { + if chatActTable.GetColumnCount() > 1 { + chatActTable.Select(row, 1) // Select first action column + } + return + } + tc := chatActTable.GetCell(row, column) + tc.SetTextColor(tcell.ColorRed) + chatActTable.SetSelectable(false, false) + selected := filenames[row] + // notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text) + switch tc.Text { + case "load": + if err := importChat(selected); err != nil { + logger.Warn("failed to import chat", "filename", selected) + pages.RemovePage(historyPage) + return + } + colorText() + updateStatusLine() + // redraw the text in text area + textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) + pages.RemovePage(historyPage) + app.SetFocus(textArea) + return + case "rename": + pages.RemovePage(historyPage) + pages.AddPage(renamePage, renameWindow, true, true) + return + case "delete": + sc, ok := chatMap[selected] + if !ok { + // no chat found + pages.RemovePage(historyPage) + return + } + if err := store.RemoveChat(sc.ID); err != nil { + logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name) + } + showToast("chat deleted", selected+" was deleted") + pages.RemovePage(historyPage) + return + default: + pages.RemovePage(historyPage) + return + } + }) + // Add input capture to handle 'x' key for closing the table + chatActTable.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune && event.Rune() == 'x' { + pages.RemovePage(historyPage) + return nil + } + return event + }) + return chatActTable +} + +func makeFilePicker() *tview.Flex { + // Initialize with directory from config or current directory + startDir := cfg.FilePickerDir + if startDir == "" { + startDir = "." + } + // If startDir is ".", resolve it to the actual current working directory + if startDir == "." { + wd, err := os.Getwd() + if err == nil { + startDir = wd + } + } + // Track navigation history + dirStack := []string{startDir} + currentStackPos := 0 + // Track selected file + var selectedFile string + // Track currently displayed directory (changes as user navigates) + currentDisplayDir := startDir + // --- NEW: search state --- + searching := false + searchQuery := "" + searchInputMode := false + // Helper function to check if a file has an allowed extension from config + hasAllowedExtension := func(filename string) bool { + if cfg.FilePickerExts == "" { + return true + } + allowedExts := strings.Split(cfg.FilePickerExts, ",") + lowerFilename := strings.ToLower(strings.TrimSpace(filename)) + for _, ext := range allowedExts { + ext = strings.TrimSpace(ext) + if ext != "" && strings.HasSuffix(lowerFilename, "."+ext) { + return true + } + } + return false + } + // Helper function to check if a file is an image + isImageFile := func(filename string) bool { + imageExtensions := []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg"} + lowerFilename := strings.ToLower(filename) + for _, ext := range imageExtensions { + if strings.HasSuffix(lowerFilename, ext) { + return true + } + } + return false + } + // Create UI elements + listView := tview.NewList() + listView.SetBorder(true). + SetTitle("Files & Directories [s: set FilePickerDir]. Current base dir: " + cfg.FilePickerDir). + SetTitleAlign(tview.AlignLeft) + // Status view for selected file information + statusView := tview.NewTextView() + statusView.SetBorder(true).SetTitle("Selected File").SetTitleAlign(tview.AlignLeft) + statusView.SetTextColor(tcell.ColorYellow) + // Image preview pane + var imgPreview *tview.Image + if cfg.ImagePreview { + imgPreview = tview.NewImage() + imgPreview.SetBorder(true).SetTitle("Preview").SetTitleAlign(tview.AlignLeft) + } + // Horizontal flex for list + preview + var hFlex *tview.Flex + if cfg.ImagePreview && imgPreview != nil { + hFlex = tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(listView, 0, 3, true). + AddItem(imgPreview, 0, 2, false) + } else { + hFlex = tview.NewFlex().SetDirection(tview.FlexColumn). + AddItem(listView, 0, 1, true) + } + // Main vertical flex + flex := tview.NewFlex().SetDirection(tview.FlexRow) + flex.AddItem(hFlex, 0, 3, true) + flex.AddItem(statusView, 3, 0, false) + // Refresh the file list – now accepts a filter string + var refreshList func(string, string) + refreshList = func(dir string, filter string) { + listView.Clear() + // Update the current display directory + currentDisplayDir = dir + // Add exit option at the top + listView.AddItem("Exit file picker [gray](Close without selecting)[-]", "", 'x', func() { + pages.RemovePage(filePickerPage) + }) + // Add parent directory (..) if not at root + if dir != "/" { + parentDir := path.Dir(dir) + // For Unix-like systems, avoid infinite loop when at root + if parentDir != dir { + listView.AddItem("../ [gray](Parent Directory)[-]", "", 'p', func() { + // Clear search on navigation + searching = false + searchQuery = "" + if cfg.ImagePreview { + imgPreview.SetImage(nil) + } + refreshList(parentDir, "") + dirStack = append(dirStack, parentDir) + currentStackPos = len(dirStack) - 1 + }) + } + } + // Read directory contents + files, err := os.ReadDir(dir) + if err != nil { + statusView.SetText("Error reading directory: " + err.Error()) + return + } + // Helper to check if an item passes the filter + matchesFilter := func(name string) bool { + if filter == "" { + return true + } + return strings.Contains(strings.ToLower(name), strings.ToLower(filter)) + } + // Add directories + for _, file := range files { + name := file.Name() + if strings.HasPrefix(name, ".") { + continue + } + if file.IsDir() && matchesFilter(name) { + dirName := name + listView.AddItem(dirName+"/ [gray](Directory)[-]", "", 0, func() { + // Clear search on navigation + searching = false + searchQuery = "" + if cfg.ImagePreview { + imgPreview.SetImage(nil) + } + newDir := path.Join(dir, dirName) + refreshList(newDir, "") + dirStack = append(dirStack, newDir) + currentStackPos = len(dirStack) - 1 + statusView.SetText("Current: " + newDir) + }) + } + } + // Add files with allowed extensions + for _, file := range files { + name := file.Name() + if strings.HasPrefix(name, ".") || file.IsDir() { + continue + } + if hasAllowedExtension(name) && matchesFilter(name) { + fileName := name + fullFilePath := path.Join(dir, fileName) + listView.AddItem(fileName+" [gray](File)[-]", "", 0, func() { + selectedFile = fullFilePath + statusView.SetText("Selected: " + selectedFile) + if isImageFile(fileName) { + statusView.SetText("Selected image: " + selectedFile) + } + }) + } + } + // Update status line based on search state + switch { + case searching: + statusView.SetText("Search: " + searchQuery + "_") + case searchQuery != "": + statusView.SetText("Current: " + dir + " (filter: " + searchQuery + ")") + default: + statusView.SetText("Current: " + dir) + } + } + // Initialize the file list + refreshList(startDir, "") + // Update image preview when selection changes (unchanged) + if cfg.ImagePreview && imgPreview != nil { + listView.SetChangedFunc(func(index int, mainText, secondaryText string, rune rune) { + itemText, _ := listView.GetItemText(index) + if strings.HasPrefix(itemText, "Exit file picker") || strings.HasPrefix(itemText, "../") { + imgPreview.SetImage(nil) + return + } + actualItemName := itemText + if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 { + actualItemName = itemText[:bracketPos] + } + if strings.HasSuffix(actualItemName, "/") { + imgPreview.SetImage(nil) + return + } + if !isImageFile(actualItemName) { + imgPreview.SetImage(nil) + return + } + filePath := path.Join(currentDisplayDir, actualItemName) + file, err := os.Open(filePath) + if err != nil { + imgPreview.SetImage(nil) + return + } + defer file.Close() + img, _, err := image.Decode(file) + if err != nil { + imgPreview.SetImage(nil) + return + } + imgPreview.SetImage(img) + }) + } + // Set up keyboard navigation + flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // --- Handle search mode --- + if searching { + switch event.Key() { + case tcell.KeyEsc: + // Exit search, clear filter + searching = false + searchInputMode = false + searchQuery = "" + refreshList(currentDisplayDir, "") + return nil + case tcell.KeyBackspace, tcell.KeyBackspace2: + if len(searchQuery) > 0 { + searchQuery = searchQuery[:len(searchQuery)-1] + refreshList(currentDisplayDir, searchQuery) + } + return nil + case tcell.KeyEnter: + // Exit search input mode and let normal processing handle selection + searchInputMode = false + // Get the currently highlighted item in the list + itemIndex := listView.GetCurrentItem() + if itemIndex >= 0 && itemIndex < listView.GetItemCount() { + itemText, _ := listView.GetItemText(itemIndex) + // Check for the exit option first + if strings.HasPrefix(itemText, "Exit file picker") { + pages.RemovePage(filePickerPage) + return nil + } + // Extract the actual filename/directory name by removing the type info + actualItemName := itemText + if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 { + actualItemName = itemText[:bracketPos] + } + // Check if it's a directory (ends with /) + if strings.HasSuffix(actualItemName, "/") { + var targetDir string + if strings.HasPrefix(actualItemName, "../") { + // Parent directory + targetDir = path.Dir(currentDisplayDir) + if targetDir == currentDisplayDir && currentDisplayDir == "/" { + return nil + } + } else { + // Regular subdirectory + dirName := strings.TrimSuffix(actualItemName, "/") + targetDir = path.Join(currentDisplayDir, dirName) + } + // Navigate – clear search + if cfg.ImagePreview && imgPreview != nil { + imgPreview.SetImage(nil) + } + searching = false + searchInputMode = false + searchQuery = "" + refreshList(targetDir, "") + dirStack = append(dirStack, targetDir) + currentStackPos = len(dirStack) - 1 + statusView.SetText("Current: " + targetDir) + return nil + } else { + // It's a file + filePath := path.Join(currentDisplayDir, actualItemName) + if info, err := os.Stat(filePath); err == nil && !info.IsDir() { + if isImageFile(actualItemName) { + SetImageAttachment(filePath) + statusView.SetText("Image attached: " + filePath + " (will be sent with next message)") + pages.RemovePage(filePickerPage) + } else { + textArea.SetText(filePath, true) + app.SetFocus(textArea) + pages.RemovePage(filePickerPage) + } + } + return nil + } + } + return nil + case tcell.KeyRune: + r := event.Rune() + if searchInputMode && r != 0 { + searchQuery += string(r) + refreshList(currentDisplayDir, searchQuery) + return nil + } + // If not in search input mode, pass through for navigation + return event + default: + // Exit search input mode but keep filter active for navigation + searchInputMode = false + // Pass all other keys (arrows, etc.) to normal processing + return event + } + } + // --- Not searching --- + switch event.Key() { + case tcell.KeyEsc: + pages.RemovePage(filePickerPage) + return nil + case tcell.KeyBackspace2: // Backspace to go to parent directory + if cfg.ImagePreview && imgPreview != nil { + imgPreview.SetImage(nil) + } + if currentStackPos > 0 { + currentStackPos-- + prevDir := dirStack[currentStackPos] + // Clear search when navigating with backspace + searching = false + searchQuery = "" + refreshList(prevDir, "") + // Trim the stack to current position + dirStack = dirStack[:currentStackPos+1] + } + return nil + case tcell.KeyRune: + if event.Rune() == '/' { + // Enter search mode + searching = true + searchInputMode = true + searchQuery = "" + refreshList(currentDisplayDir, "") + return nil + } + if event.Rune() == 's' { + // Set FilePickerDir to current directory + // Get the actual directory path + cfg.FilePickerDir = currentDisplayDir + listView.SetTitle("Files & Directories [s: set FilePickerDir]. Current base dir: " + cfg.FilePickerDir) + // pages.RemovePage(filePickerPage) + return nil + } + case tcell.KeyEnter: + // Get the currently highlighted item in the list + itemIndex := listView.GetCurrentItem() + if itemIndex >= 0 && itemIndex < listView.GetItemCount() { + itemText, _ := listView.GetItemText(itemIndex) + logger.Info("choosing dir", "itemText", itemText) + // Check for the exit option first + if strings.HasPrefix(itemText, "Exit file picker") { + pages.RemovePage(filePickerPage) + return nil + } + // Extract the actual filename/directory name by removing the type info + actualItemName := itemText + if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 { + actualItemName = itemText[:bracketPos] + } + // Check if it's a directory (ends with /) + if strings.HasSuffix(actualItemName, "/") { + var targetDir string + if strings.HasPrefix(actualItemName, "../") { + // Parent directory + targetDir = path.Dir(currentDisplayDir) + if targetDir == currentDisplayDir && currentDisplayDir == "/" { + logger.Warn("at root, cannot go up") + return nil + } + } else { + // Regular subdirectory + dirName := strings.TrimSuffix(actualItemName, "/") + targetDir = path.Join(currentDisplayDir, dirName) + } + // Navigate – clear search + logger.Info("going to dir", "dir", targetDir) + if cfg.ImagePreview && imgPreview != nil { + imgPreview.SetImage(nil) + } + searching = false + searchQuery = "" + refreshList(targetDir, "") + dirStack = append(dirStack, targetDir) + currentStackPos = len(dirStack) - 1 + statusView.SetText("Current: " + targetDir) + return nil + } else { + // It's a file + filePath := path.Join(currentDisplayDir, actualItemName) + if info, err := os.Stat(filePath); err == nil && !info.IsDir() { + if isImageFile(actualItemName) { + logger.Info("setting image", "file", actualItemName) + SetImageAttachment(filePath) + logger.Info("after setting image", "file", actualItemName) + statusView.SetText("Image attached: " + filePath + " (will be sent with next message)") + logger.Info("after setting text", "file", actualItemName) + pages.RemovePage(filePickerPage) + logger.Info("after update drawn", "file", actualItemName) + } else { + textArea.SetText(filePath, true) + app.SetFocus(textArea) + pages.RemovePage(filePickerPage) + } + } + return nil + } + } + return nil + } + return event + }) + return flex +} |
