Files
gf-lt/tables.go
2026-02-18 08:42:05 +03:00

1105 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"image"
"os"
"path"
"strings"
"time"
"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", "move sysprompt onto 1st msg", "new_chat_from_card"}
chatList := make([]string, len(chatMap))
i := 0
for name := range chatMap {
chatList[i] = name
i++
}
// 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)
// 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+1, c, // +1 to account for header row
tview.NewTableCell(chatList[r]).
SetSelectable(false).
SetTextColor(color).
SetAlign(tview.AlignCenter))
case 1:
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))
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))
}
}
}
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
}
}).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-1] // -1 to account for header row
defer pages.RemovePage(historyPage)
switch tc.Text {
case "load":
history, err := loadHistoryChat(selectedChat)
if err != nil {
logger.Error("failed to read history file", "chat", selectedChat)
pages.RemovePage(historyPage)
return
}
chatBody.Messages = history
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
activeChatName = selectedChat
pages.RemovePage(historyPage)
return
case "rename":
pages.RemovePage(historyPage)
pages.AddPage(renamePage, renameWindow, true, true)
return
case "delete":
sc, ok := chatMap[selectedChat]
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)
}
if err := notifyUser("chat deleted", selectedChat+" was deleted"); err != nil {
logger.Error("failed to send notification", "error", err)
}
// load last chat
chatBody.Messages = loadOldChatOrGetNew()
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 {
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)
}
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)
}
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":
// Reread card from file and start fresh chat
fi := strings.Index(selectedChat, "_")
agentName := selectedChat[fi+1:]
cc, ok := sysMap[agentName]
if !ok {
logger.Warn("no such card", "agent", agentName)
if err := notifyUser("error", "no such card: "+agentName); err != nil {
logger.Warn("failed to notify", "error", err)
}
return
}
// Reload card from disk
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)
if err := notifyUser("error", "failed to reload card: "+cc.FilePath); err != nil {
logger.Warn("failed to notify", "error", err)
}
return
}
}
// Update sysMap with fresh card data
sysMap[agentName] = newCard
// fetching sysprompt and first message anew from the card
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
}
// nolint:unused
func makeRAGTable(fileList []string) *tview.Flex {
actions := []string{"load", "delete"}
rows, cols := len(fileList), len(actions)+1
fileTable := tview.NewTable().
SetBorders(true)
longStatusView := tview.NewTextView()
longStatusView.SetText("status text")
longStatusView.SetBorder(true).SetTitle("status")
longStatusView.SetChangedFunc(func() {
app.Draw()
})
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("Exit RAG manager").
SetTextColor(tcell.ColorWhite).
SetAlign(tview.AlignCenter).
SetSelectable(false))
fileTable.SetCell(0, 1,
tview.NewTableCell("(Close without action)").
SetTextColor(tcell.ColorGray).
SetAlign(tview.AlignCenter).
SetSelectable(false))
fileTable.SetCell(0, 2,
tview.NewTableCell("exit").
SetTextColor(tcell.ColorGray).
SetAlign(tview.AlignCenter))
// Add the file rows starting from row 1
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
color := tcell.ColorWhite
switch {
case c < 1:
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
tview.NewTableCell(fileList[r]).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case c == 1: // Action description column - not selectable
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
tview.NewTableCell("(Action)").
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
default: // Action button column - selectable
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
tview.NewTableCell(actions[c-1]).
SetTextColor(color).
SetAlign(tview.AlignCenter))
}
}
}
errCh := make(chan error, 1) // why?
go func() {
defer pages.RemovePage(RAGPage)
for {
select {
case err := <-errCh:
if err == nil {
logger.Error("somehow got a nil err", "error", err)
continue
}
logger.Error("got an err in rag status", "error", err, "textview", longStatusView)
longStatusView.SetText(fmt.Sprintf("%v", err))
close(errCh)
return
case status := <-rag.LongJobStatusCh:
longStatusView.SetText(status)
// fmt.Fprintln(longStatusView, status)
// app.Sync()
if status == rag.FinishedRAGStatus {
close(errCh)
time.Sleep(2 * time.Second)
return
}
}
}
}()
fileTable.Select(0, 0).
SetFixed(1, 1).
SetSelectable(true, false).
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
}
// defer pages.RemovePage(RAGPage)
tc := fileTable.GetCell(row, column)
// 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 filename (row index - 1 because of the exit row at index 0)
fpath := fileList[row-1] // -1 to account for the exit row at index 0
// notification := fmt.Sprintf("chat: %s; action: %s", fpath, tc.Text)
switch tc.Text {
case "load":
fpath = path.Join(cfg.RAGDir, fpath)
longStatusView.SetText("clicked load")
go func() {
if err := ragger.LoadRAG(fpath); err != nil {
logger.Error("failed to embed file", "chat", fpath, "error", err)
_ = notifyUser("RAG", "failed to embed file; error: "+err.Error())
errCh <- err
// pages.RemovePage(RAGPage)
return
}
}()
return
case "delete":
fpath = path.Join(cfg.RAGDir, fpath)
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)
}
return
default:
pages.RemovePage(RAGPage)
return
}
})
// 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 nil
}
return event
})
return ragflex
}
func makeLoadedRAGTable(fileList []string) *tview.Flex {
actions := []string{"delete"}
rows, cols := len(fileList), len(actions)+1
// Add 1 extra row for the "exit" option at the top
fileTable := tview.NewTable().
SetBorders(true)
longStatusView := tview.NewTextView()
longStatusView.SetText("Loaded RAG files list")
longStatusView.SetBorder(true).SetTitle("status")
longStatusView.SetChangedFunc(func() {
app.Draw()
})
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("Exit Loaded Files manager").
SetTextColor(tcell.ColorWhite).
SetAlign(tview.AlignCenter).
SetSelectable(false))
fileTable.SetCell(0, 1,
tview.NewTableCell("(Close without action)").
SetTextColor(tcell.ColorGray).
SetAlign(tview.AlignCenter).
SetSelectable(false))
fileTable.SetCell(0, 2,
tview.NewTableCell("exit").
SetTextColor(tcell.ColorGray).
SetAlign(tview.AlignCenter))
// Add the file rows starting from row 1
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
color := tcell.ColorWhite
switch {
case c < 1:
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
tview.NewTableCell(fileList[r]).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case c == 1: // Action description column - not selectable
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
tview.NewTableCell("(Action)").
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
default: // Action button column - selectable
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
tview.NewTableCell(actions[c-1]).
SetTextColor(color).
SetAlign(tview.AlignCenter))
}
}
}
fileTable.Select(0, 0).
SetFixed(1, 1).
SetSelectable(true, false).
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(RAGLoadedPage)
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
}
tc := fileTable.GetCell(row, column)
// Check if the selected row is the exit row (row 0) - do this first to avoid index issues
if row == 0 {
pages.RemovePage(RAGLoadedPage)
return
}
// For file rows, get the filename (row index - 1 because of the exit row at index 0)
fpath := fileList[row-1] // -1 to account for the exit row at index 0
switch tc.Text {
case "delete":
if err := ragger.RemoveFile(fpath); err != nil {
logger.Error("failed to delete file from RAG", "filename", fpath, "error", err)
longStatusView.SetText(fmt.Sprintf("Error deleting file: %v", err))
return
}
if err := notifyUser("RAG file deleted", fpath+" was deleted from RAG system"); err != nil {
logger.Error("failed to send notification", "error", err)
}
longStatusView.SetText(fpath + " was deleted from RAG system")
return
default:
pages.RemovePage(RAGLoadedPage)
return
}
})
// 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(RAGLoadedPage)
return nil
}
return event
})
return ragflex
}
func makeAgentTable(agentList []string) *tview.Table {
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
switch {
case c < 1:
chatActTable.SetCell(r, c,
tview.NewTableCell(agentList[r]).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
case c == 1:
if actions[c-1] == "filepath" {
cc, ok := sysMap[agentList[r]]
if !ok {
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))
default:
chatActTable.SetCell(r, c,
tview.NewTableCell(actions[c-1]).
SetTextColor(color).
SetAlign(tview.AlignCenter))
}
}
}
chatActTable.Select(0, 0).
SetFixed(1, 1).
SetSelectable(true, false).
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
}
tc := chatActTable.GetCell(row, column)
selected := agentList[row]
// notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
switch tc.Text {
case "load":
if ok := charToStart(selected, true); !ok {
logger.Warn("no such sys msg", "name", selected)
pages.RemovePage(agentPage)
return
}
// replace textview
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
colorText()
updateStatusLine()
// sysModal.ClearButtons()
pages.RemovePage(agentPage)
app.SetFocus(textArea)
return
case "rename":
pages.RemovePage(agentPage)
pages.AddPage(renamePage, renameWindow, true, true)
return
case "delete":
sc, ok := chatMap[selected]
if !ok {
// no chat found
pages.RemovePage(agentPage)
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)
}
if err := notifyUser("chat deleted", selected+" was deleted"); err != nil {
logger.Error("failed to send notification", "error", err)
}
pages.RemovePage(agentPage)
return
default:
pages.RemovePage(agentPage)
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
}
func makeCodeBlockTable(codeBlocks []string) *tview.Table {
actions := []string{"copy"}
rows, cols := len(codeBlocks), len(actions)+1
table := tview.NewTable().
SetBorders(true)
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
color := tcell.ColorWhite
previewLen := 30
if len(codeBlocks[r]) < 30 {
previewLen = len(codeBlocks[r])
}
switch {
case c < 1:
table.SetCell(r, c,
tview.NewTableCell(codeBlocks[r][:previewLen]).
SetTextColor(color).
SetAlign(tview.AlignCenter).
SetSelectable(false))
default:
table.SetCell(r, c,
tview.NewTableCell(actions[c-1]).
SetTextColor(color).
SetAlign(tview.AlignCenter))
}
}
}
table.Select(0, 0).
SetFixed(1, 1).
SetSelectable(true, false).
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
}
tc := table.GetCell(row, column)
selected := codeBlocks[row]
// notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
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)
}
pages.RemovePage(codeBlockPage)
app.SetFocus(textArea)
return
default:
pages.RemovePage(codeBlockPage)
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, false).
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)
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)
}
if err := notifyUser("chat deleted", selected+" was deleted"); err != nil {
logger.Error("failed to send notification", "error", err)
}
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 := ""
// 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").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
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.KeyRune:
r := event.Rune()
if r != 0 {
searchQuery += string(r)
refreshList(currentDisplayDir, searchQuery)
}
return nil
default:
// Pass all other keys (arrows, Enter, etc.) to normal processing
// This allows selecting items while still in search mode
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
searchQuery = ""
refreshList(currentDisplayDir, "")
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
}