package main

import (
	"fmt"
	"gf-lt/models"
	"gf-lt/tools"
	"image"
	_ "image/jpeg"
	_ "image/png"
	"os"
	"path"
	"strconv"
	"strings"
	"time"

	"github.com/gdamore/tcell/v2"
	"github.com/rivo/tview"
)

func isFullScreenPageActive() bool {
	name, _ := pages.GetFrontPage()
	return name != "main"
}

var (
	pages              *tview.Pages
	textArea           *tview.TextArea
	editArea           *tview.TextArea
	textView           *tview.TextView
	statusLineWidget   *tview.TextView
	helpView           *tview.TextView
	flex               *tview.Flex
	bottomFlex         *tview.Flex
	notificationWidget *tview.TextView
	imgView            *tview.Image
	defaultImage       = "sysprompts/llama.png"
	indexPickWindow    *tview.InputField
	renameWindow       *tview.InputField
	roleEditWindow     *tview.InputField
	shellInput         *tview.InputField
	confirmModal       *tview.Modal
	toastTimer         *time.Timer
	confirmPageName    = "confirm"
	fullscreenMode     bool
	positionVisible    bool = true
	// pages
	historyPage    = "historyPage"
	agentPage      = "agentPage"
	editMsgPage    = "editMsgPage"
	roleEditPage   = "roleEditPage"
	helpPage       = "helpPage"
	renamePage     = "renamePage"
	RAGPage        = "RAGPage"
	dbTablesPage   = "dbTables"
	propsPage      = "propsPage"
	codeBlockPage  = "codeBlockPage"
	imgPage        = "imgPage"
	filePickerPage = "filePicker"
	exportDir      = "chat_exports"
	// For overlay search functionality
	searchField    *tview.InputField
	searchPageName = "searchOverlay"
	// help text
	helpText = `
[yellow]Esc[white]: send msg
[yellow]PgUp/Down[white]: switch focus between input and chat widgets
[yellow]F1[white]: manage chats
[yellow]F2[white]: regen last
[yellow]F3[white]: delete last msg
[yellow]F4[white]: edit msg
[yellow]F5[white]: toggle fullscreen for input/chat window
[yellow]F6[white]: interrupt bot resp
[yellow]F7[white]: copy last msg to clipboard (linux xclip or wl-copy)
[yellow]F8[white]: copy n msg to clipboard (linux xclip or wl-copy)
[yellow]F9[white]: table to copy from; with all code blocks
[yellow]F10[white]: switch if LLM will respond on this message (for user to write multiple messages in a row)
[yellow]F11[white]: import json chat file
[yellow]F12[white]: show this help page
[yellow]Ctrl+w[white]: resume generation on the last msg
[yellow]Ctrl+s[white]: load new char/agent
[yellow]Ctrl+e[white]: export chat to json file
[yellow]Ctrl+c[white]: close programm
[yellow]Ctrl+n[white]: start a new chat
[yellow]Ctrl+o[white]: open image file picker
[yellow]Ctrl+p[white]: props edit form (min-p, dry, etc.)
[yellow]Ctrl+v[white]: show API link selection popup to choose current API
[yellow]Ctrl+r[white]: start/stop recording from your microphone (needs stt server or whisper binary)
[yellow]Ctrl+t[white]: (un)collapse tool messages
[yellow]Ctrl+l[white]: show model selection popup to choose current model
[yellow]Ctrl+k[white]: switch tool use (recommend tool use to llm after user msg)
[yellow]Ctrl+a[white]: interrupt tts (needs tts server)
[yellow]Alt+0[white]: replay last message via tts (needs tts server)
[yellow]Ctrl+g[white]: open RAG file manager (load files for context retrieval)
[yellow]Ctrl+y[white]: list loaded RAG files (view and manage loaded files)
[yellow]Ctrl+q[white]: show user role selection popup to choose who sends next msg as
[yellow]Ctrl+x[white]: show bot role selection popup to choose which agent responds next
[yellow]Alt+1[white]: toggle shell mode (execute commands locally)
[yellow]Alt+2[white]: toggle auto-scrolling (for reading while LLM types)
[yellow]Alt+3[white]: summarize chat history and start new chat with summary as tool response
[yellow]Alt+4[white]: edit msg role
[yellow]Alt+5[white]: toggle system and tool messages display
[yellow]Alt+6[white]: toggle status line visibility
[yellow]Alt+7[white]: toggle role injection (inject role in messages)
[yellow]Alt+8[white]: show char img or last picked img
[yellow]Alt+9[white]: warm up (load) selected llama.cpp model
[yellow]Alt+t[white]: toggle thinking blocks visibility (collapse/expand <think> blocks)
[yellow]Ctrl+t[white]: toggle tool call/response visibility (collapse/expand tool calls and non-shell tool responses)
[yellow]Alt+i[white]: show colorscheme selection popup
[yellow]Insert[white]: paste from clipboard to the text area (use it instead shift+insert)

=== scrolling chat window (some keys similar to vim) ===
[yellow]arrows up/down and j/k[white]: scroll up and down
[yellow]gg/G[white]: jump to the begging / end of the chat
[yellow]/[white]: start searching for text
[yellow]n[white]: go to next search result
[yellow]N[white]: go to previous search result

=== tables (chat history, agent pick, file pick, properties) ===
[yellow]x[white]: to exit the table page

=== filepicker ===
[yellow]s[white]: (in file picker) set current dir as FilePickerDir
[yellow]x[white]: to exit

=== shell mode ===
	[yellow]@match->Tab[white]: file completion with relative paths (recursive, depth 3, max 50 files)

=== status line ===
%s

Press <Enter> or 'x' to return
`
)

func setShellMode(enabled bool) {
	shellMode = enabled
	go func() {
		app.QueueUpdateDraw(func() {
			updateFlexLayout()
		})
	}()
}

// showToast displays a temporary notification in the bottom-right corner.
// It auto-hides after 3 seconds.
func showToast(title, message string) {
	if cfg.UseNotifySend {
		notifySend(title, message)
		return
	}
	if cfg.CLIMode {
		return
	}
	sanitize := func(s string, maxLen int) string {
		sanitized := strings.Map(func(r rune) rune {
			if r < 32 && r != '\t' {
				return -1
			}
			return r
		}, s)
		if len(sanitized) > maxLen {
			sanitized = sanitized[:maxLen-3] + "..."
		}
		return sanitized
	}
	title = sanitize(title, 50)
	message = sanitize(message, 197)
	if toastTimer != nil {
		toastTimer.Stop()
	}
	// show blocking notification to not mess up flex
	if fullscreenMode {
		notification := tview.NewTextView().
			SetTextAlign(tview.AlignCenter).
			SetDynamicColors(true).
			SetRegions(true).
			SetText(fmt.Sprintf("[yellow]%s[-]\n", message)).
			SetChangedFunc(func() {
				app.Draw()
			})
		notification.SetTitleAlign(tview.AlignLeft).
			SetBorder(true).
			SetTitle(title)
		// Wrap it in a full‑screen Flex to position it in the top‑right corner.
		// Outer Flex (row) pushes content to the top; inner Flex (column) pushes to the right.
		background := tview.NewFlex().SetDirection(tview.FlexRow).
			AddItem(nil, 0, 1, false). // top spacer
			AddItem(tview.NewFlex().SetDirection(tview.FlexColumn).
				AddItem(nil, 0, 1, false).          // left spacer
				AddItem(notification, 40, 1, true), // notification width 40
				5, 1, false) // notification height 5
		// Generate a unique page name (e.g., using timestamp) to allow multiple toasts.
		pageName := fmt.Sprintf("toast-%d", time.Now().UnixNano())
		pages.AddPage(pageName, background, true, true)
		// Auto‑dismiss after 2 seconds, since blocking is more annoying
		time.AfterFunc(2*time.Second, func() {
			app.QueueUpdateDraw(func() {
				if pages.HasPage(pageName) {
					pages.RemovePage(pageName)
				}
			})
		})
		return
	}
	notificationWidget.SetTitle(title)
	notificationWidget.SetText(fmt.Sprintf("[yellow]%s[-]", message))
	go func() {
		app.QueueUpdateDraw(func() {
			flex.RemoveItem(bottomFlex)
			flex.RemoveItem(statusLineWidget)
			bottomFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
				AddItem(textArea, 0, 1, true).
				AddItem(notificationWidget, 40, 1, false)
			flex.AddItem(bottomFlex, 0, 10, true)
			if positionVisible {
				flex.AddItem(statusLineWidget, 0, 2, false)
			}
		})
	}()
	toastTimer = time.AfterFunc(3*time.Second, func() {
		app.QueueUpdateDraw(func() {
			flex.RemoveItem(bottomFlex)
			flex.RemoveItem(statusLineWidget)
			bottomFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
				AddItem(textArea, 0, 1, true).
				AddItem(notificationWidget, 0, 0, false)
			flex.AddItem(bottomFlex, 0, 10, true)
			if positionVisible {
				flex.AddItem(statusLineWidget, 0, 2, false)
			}
		})
	})
}

func initTUI() {
	// Start background goroutine to update model color cache
	startModelColorUpdater()
	tview.Styles = colorschemes["default"]
	app = tview.NewApplication()
	pages = tview.NewPages()
	shellInput = tview.NewInputField().
		SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir)). // dynamic prompt
		SetFieldWidth(0).
		SetDoneFunc(func(key tcell.Key) {
			if key == tcell.KeyEnter {
				cmd := shellInput.GetText()
				if cmd != "" {
					executeCommandAndDisplay(cmd)
				}
				shellInput.SetText("")
			}
		})
	// Copy your file completion logic to shellInput's InputCapture
	shellInput.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		if !shellMode {
			return event
		}
		// Handle Up arrow for history previous
		if event.Key() == tcell.KeyUp {
			if len(shellHistory) > 0 {
				if shellHistoryPos < len(shellHistory)-1 {
					shellHistoryPos++
					shellInput.SetText(shellHistory[len(shellHistory)-1-shellHistoryPos])
				}
			}
			return nil
		}
		// Handle Down arrow for history next
		if event.Key() == tcell.KeyDown {
			if shellHistoryPos > 0 {
				shellHistoryPos--
				shellInput.SetText(shellHistory[len(shellHistory)-1-shellHistoryPos])
			} else if shellHistoryPos == 0 {
				shellHistoryPos = -1
				shellInput.SetText("")
			}
			return nil
		}
		// Reset history position when user types
		if event.Key() == tcell.KeyRune {
			shellHistoryPos = -1
		}
		// Handle Tab key for @ file completion
		if event.Key() == tcell.KeyTab && shellMode {
			currentText := shellInput.GetText()
			atIndex := strings.LastIndex(currentText, "@")
			if atIndex >= 0 {
				filter := currentText[atIndex+1:]
				showShellFileCompletionPopup(filter)
			}
			return nil
		}
		return event
	})
	confirmModal = tview.NewModal().
		SetText("You are trying to send an empty message.\nIt makes sense if the last message in the chat is from you.\nAre you sure?").
		AddButtons([]string{"Yes", "No"}).
		SetButtonBackgroundColor(tcell.ColorBlack).
		SetButtonTextColor(tcell.ColorWhite).
		SetDoneFunc(func(buttonIndex int, buttonLabel string) {
			if buttonLabel == "Yes" {
				persona := cfg.UserRole
				if cfg.WriteNextMsgAs != "" {
					persona = cfg.WriteNextMsgAs
				}
				chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: ""}
			} // In both Yes and No, go back to the main page
			pages.SwitchToPage("main") // or whatever your main page is named
		})
	confirmModal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		if event.Key() == tcell.KeyRune {
			switch event.Rune() {
			case 'y', 'Y':
				persona := cfg.UserRole
				if cfg.WriteNextMsgAs != "" {
					persona = cfg.WriteNextMsgAs
				}
				chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: ""}
				pages.SwitchToPage("main")
				return nil
			case 'n', 'N', 'x', 'X':
				pages.SwitchToPage("main")
				return nil
			}
		}
		return event
	})
	textArea = tview.NewTextArea().
		SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.")
	textArea.SetBorder(true).SetTitle("input")
	textArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		if event.Key() == tcell.KeyInsert {
			text, err := readFromClipboard()
			if err != nil {
				logger.Error("failed to read clipboard", "error", err)
				return event
			}
			maxPaste := 100000
			if len(text) > maxPaste {
				text = text[:maxPaste]
				showToast("paste truncated", "pasted text exceeded 100KB limit")
			}
			current := textArea.GetText()
			textArea.SetText(current+text, true)
			return nil
		}
		return event
	})
	textView = tview.NewTextView().
		SetDynamicColors(true).
		SetRegions(true).
		SetChangedFunc(func() {
			// INFO:
			// https://github.com/rivo/tview/wiki/Concurrency#event-handlers
			// although already called by default per tview specs
			// calling it explicitly makes text streaming to look more smooth
			app.Draw()
		})
	outputHandler = &TUIOutputHandler{tv: textView}
	notificationWidget = tview.NewTextView().
		SetTextAlign(tview.AlignCenter).
		SetDynamicColors(true).
		SetRegions(true).
		SetChangedFunc(func() {
		})
	notificationWidget.SetBorder(true).SetTitle("notification")
	bottomFlex = tview.NewFlex().SetDirection(tview.FlexColumn).
		AddItem(textArea, 0, 1, true).
		AddItem(notificationWidget, 0, 0, false)
	//
	flex = tview.NewFlex().SetDirection(tview.FlexRow).
		AddItem(textView, 0, 40, false).
		AddItem(bottomFlex, 0, 10, true)
	if positionVisible {
		flex.AddItem(statusLineWidget, 0, 2, false)
	}
	// textView.SetBorder(true).SetTitle("chat")
	textView.SetDoneFunc(func(key tcell.Key) {
		if key == tcell.KeyEnter {
			if len(searchResults) > 0 { // Check if a search is active
				hideSearchBar()           // Hide the search bar if visible
				searchResults = nil       // Clear search results
				searchResultLengths = nil // Clear search result lengths
				originalTextForSearch = ""
				textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) // Reset text without search regions
				colorText()                                                  // Apply normal chat coloring
			} else {
				// Original logic if no search is active
				currentSelection := textView.GetHighlights()
				if len(currentSelection) > 0 {
					textView.Highlight()
				} else {
					textView.Highlight("0").ScrollToHighlight()
				}
			}
		}
	})
	textView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		// Handle vim-like navigation in TextView
		if event.Key() == tcell.KeyRune {
			switch event.Rune() {
			case 'j':
				// For line down
				return event
			case 'k':
				// For line up
				return event
			case 'g':
				// Go to beginning
				textView.ScrollToBeginning()
				return nil
			case 'G':
				// Go to end
				textView.ScrollToEnd()
				return nil
			case '/':
				// Search functionality - show search bar
				showSearchBar()
				return nil
			case 'n':
				// Next search result
				searchNext()
				return nil
			case 'N':
				// Previous search result
				searchPrev()
				return nil
			}
		}
		return event
	})
	focusSwitcher[textArea] = textView
	focusSwitcher[textView] = textArea
	statusLineWidget = tview.NewTextView().
		SetDynamicColors(true).
		SetTextAlign(tview.AlignCenter)
	// // vertical text center alignment
	// statusLineWidget.SetDrawFunc(func(screen tcell.Screen, x, y, w, h int) (int, int, int, int) {
	// 	y += h / 2
	// 	return x, y, w, h
	// })
	notificationWidget.SetDrawFunc(func(screen tcell.Screen, x, y, w, h int) (int, int, int, int) {
		y += h / 2
		return x, y, w, h
	})
	// Initially set up flex without search bar
	flex = tview.NewFlex().SetDirection(tview.FlexRow).
		AddItem(textView, 0, 40, false).
		AddItem(bottomFlex, 0, 10, true)
	if positionVisible {
		flex.AddItem(statusLineWidget, 0, 2, false)
	}
	editArea = tview.NewTextArea().
		SetPlaceholder("Replace msg...")
	editArea.SetBorder(true).SetTitle("input")
	editArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		// if event.Key() == tcell.KeyEscape && editMode {
		if event.Key() == tcell.KeyEscape {
			defer colorText()
			editedMsg := editArea.GetText()
			if editedMsg == "" {
				showToast("edit", "no edit provided")
				pages.RemovePage(editMsgPage)
				return nil
			}
			chatBody.Messages[selectedIndex].SetText(editedMsg)
			// change textarea
			textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
			pages.RemovePage(editMsgPage)
			editMode = false
			return nil
		}
		return event
	})
	indexPickWindow = tview.NewInputField().
		SetLabel("Enter a msg index: ").
		SetFieldWidth(4).
		SetAcceptanceFunc(tview.InputFieldInteger).
		SetDoneFunc(func(key tcell.Key) {
			hideIndexBar()
			// colorText()
			// updateStatusLine()
		})
	roleEditWindow = tview.NewInputField().
		SetLabel("Enter new role: ").
		SetPlaceholder("e.g., user, assistant, system, tool").
		SetDoneFunc(func(key tcell.Key) {
			switch key {
			case tcell.KeyEnter:
				newRole := roleEditWindow.GetText()
				if newRole == "" {
					showToast("edit", "no role provided")
					pages.RemovePage(roleEditPage)
					return
				}
				if selectedIndex >= 0 && selectedIndex < len(chatBody.Messages) {
					chatBody.Messages[selectedIndex].Role = newRole
					textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
					colorText()
					pages.RemovePage(roleEditPage)
				}
			case tcell.KeyEscape:
				pages.RemovePage(roleEditPage)
			}
		})
	indexPickWindow.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		switch event.Key() {
		case tcell.KeyBackspace:
			return event
		case tcell.KeyEscape:
			// Hide the index overlay when Escape is pressed
			hideIndexBar()
			return nil
		case tcell.KeyEnter:
			si := indexPickWindow.GetText()
			siInt, err := strconv.Atoi(si)
			if err != nil {
				logger.Error("failed to convert provided index", "error", err, "si", si)
				showToast("cancel", "no index provided, copying user input")
				if err := copyToClipboard(textArea.GetText()); err != nil {
					logger.Error("failed to copy to clipboard", "error", err)
				}
				hideIndexBar() // Hide overlay instead of removing page directly
				return nil
			}
			selectedIndex = siInt
			if len(chatBody.Messages)-1 < selectedIndex || selectedIndex < 0 {
				msg := "chosen index is out of bounds, will copy user input"
				logger.Warn(msg, "index", selectedIndex)
				showToast("error", msg)
				if err := copyToClipboard(textArea.GetText()); err != nil {
					logger.Error("failed to copy to clipboard", "error", err)
				}
				hideIndexBar() // Hide overlay instead of removing page directly
				return nil
			}
			m := chatBody.Messages[selectedIndex]
			switch {
			case roleEditMode:
				hideIndexBar() // Hide overlay first
				// Set the current role as the default text in the input field
				roleEditWindow.SetText(m.Role)
				pages.AddPage(roleEditPage, roleEditWindow, true, true)
				roleEditMode = false // Reset the flag
			case editMode:
				hideIndexBar() // Hide overlay first
				pages.AddPage(editMsgPage, editArea, true, true)
				editArea.SetText(m.GetText(), true)
			default:
				msgText := m.GetText()
				if err := copyToClipboard(msgText); err != nil {
					logger.Error("failed to copy to clipboard", "error", err)
				}
				previewLen := min(30, len(msgText))
				notification := fmt.Sprintf("msg '%s' was copied to the clipboard", msgText[:previewLen])
				showToast("copied", notification)
				hideIndexBar() // Hide overlay after copying
			}
			return nil
		default:
			return event
		}
	})
	//
	renameWindow = tview.NewInputField().
		SetLabel("Enter a msg index: ").
		SetFieldWidth(20).
		SetAcceptanceFunc(tview.InputFieldMaxLength(100)).
		SetDoneFunc(func(key tcell.Key) {
			pages.RemovePage(renamePage)
		})
	renameWindow.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		if event.Key() == tcell.KeyEnter {
			nname := renameWindow.GetText()
			if nname == "" {
				return event
			}
			currentChat := chatMap[activeChatName]
			delete(chatMap, activeChatName)
			currentChat.Name = nname
			activeChatName = nname
			chatMap[activeChatName] = currentChat
			_, err := store.UpsertChat(currentChat)
			if err != nil {
				logger.Error("failed to upsert chat", "error", err, "chat", currentChat)
			}
			notification := fmt.Sprintf("renamed chat to '%s'", activeChatName)
			showToast("renamed", notification)
		}
		return event
	})
	//
	searchField = tview.NewInputField().
		SetPlaceholder("Search... (Enter: search)").
		SetDoneFunc(func(key tcell.Key) {
			if key == tcell.KeyEnter {
				term := searchField.GetText()
				if term == "" {
					// If the search term is empty, cancel the search
					hideSearchBar()
					searchResults = nil
					searchResultLengths = nil
					originalTextForSearch = ""
					textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
					colorText()
					return
				} else {
					performSearch(term)
					// Keep focus on textView after search
					app.SetFocus(textView)
					hideSearchBar()
				}
			}
		})
	searchField.SetBorder(true).SetTitle("Search")
	// Note: Initially hide the search field (handled by not showing it in the layout)
	//
	helpView = tview.NewTextView().SetDynamicColors(true).
		SetText(fmt.Sprintf(helpText, makeStatusLine())).
		SetDoneFunc(func(key tcell.Key) {
			pages.RemovePage(helpPage)
		})
	helpView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		if event.Key() == tcell.KeyEnter {
			return event
		}
		if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
			pages.RemovePage(helpPage)
			return nil
		}
		// Allow scrolling keys to pass through to the TextView
		switch event.Key() {
		case tcell.KeyUp, tcell.KeyDown,
			tcell.KeyPgUp, tcell.KeyPgDn,
			tcell.KeyHome, tcell.KeyEnd:
			return event
		}
		if event.Key() == tcell.KeyRune {
			switch event.Rune() {
			case 'j', 'k', 'g', 'G':
				return event
			}
		}
		return nil
	})
	//
	imgView = tview.NewImage()
	imgView.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		if event.Key() == tcell.KeyEnter {
			pages.RemovePage(imgPage)
			return event
		}
		if isASCII(string(event.Rune())) {
			pages.RemovePage(imgPage)
			return event
		}
		return nil
	})
	//
	textArea.SetMovedFunc(updateStatusLine)
	updateStatusLine()
	textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
	colorText()
	if cfg.AutoScrollEnabled {
		textView.ScrollToEnd()
	}
	// init sysmap
	_, err := initSysCards()
	if err != nil {
		logger.Error("failed to init sys cards", "error", err)
	}
	app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
		if event.Key() == tcell.KeyRune && event.Rune() == '5' && event.Modifiers()&tcell.ModAlt != 0 {
			// switch cfg.ShowSys
			cfg.ShowSys = !cfg.ShowSys
			textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
			colorText()
		}
		if event.Key() == tcell.KeyRune && event.Rune() == '3' && event.Modifiers()&tcell.ModAlt != 0 {
			go summarizeAndStartNewChat()
			return nil
		}
		if event.Key() == tcell.KeyRune && event.Rune() == '6' && event.Modifiers()&tcell.ModAlt != 0 {
			// toggle status line visibility
			if name, _ := pages.GetFrontPage(); name != "main" {
				return event
			}
			positionVisible = !positionVisible
			updateFlexLayout()
		}
		if event.Key() == tcell.KeyRune && event.Rune() == '2' && event.Modifiers()&tcell.ModAlt != 0 {
			// toggle auto-scrolling
			cfg.AutoScrollEnabled = !cfg.AutoScrollEnabled
			status := "disabled"
			if cfg.AutoScrollEnabled {
				status = "enabled"
			}
			showToast("autoscroll", "Auto-scrolling "+status)
			updateStatusLine()
		}
		// Handle Alt+7 to toggle injectRole
		if event.Key() == tcell.KeyRune && event.Rune() == '7' && event.Modifiers()&tcell.ModAlt != 0 {
			injectRole = !injectRole
			updateStatusLine()
		}
		// Handle Alt+T to toggle thinking block visibility
		if event.Key() == tcell.KeyRune && event.Rune() == 't' && event.Modifiers()&tcell.ModAlt != 0 {
			thinkingCollapsed = !thinkingCollapsed
			textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
			colorText()
			status := "expanded"
			if thinkingCollapsed {
				status = "collapsed"
			}
			showToast("thinking", "Thinking blocks "+status)
			return nil
		}
		// Handle Ctrl+T to toggle tool call/response visibility
		if event.Key() == tcell.KeyCtrlT {
			toolCollapsed = !toolCollapsed
			textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
			colorText()
			status := "expanded"
			if toolCollapsed {
				status = "collapsed"
			}
			showToast("tools", "Tool calls/responses "+status)
			return nil
		}
		if event.Key() == tcell.KeyRune && event.Rune() == 'i' && event.Modifiers()&tcell.ModAlt != 0 {
			if isFullScreenPageActive() {
				return event
			}
			showColorschemeSelectionPopup()
			return nil
		}
		if event.Key() == tcell.KeyF1 {
			// chatList, err := loadHistoryChats()
			chatList, err := store.GetChatByChar(cfg.AssistantRole)
			if err != nil {
				logger.Error("failed to load chat history", "error", err)
				return nil
			}
			// Check if there are no chats for this agent
			if len(chatList) == 0 {
				notification := "no chats found for agent: " + cfg.AssistantRole
				showToast("info", notification)
				return nil
			}
			chatMap := make(map[string]models.Chat)
			// nameList := make([]string, len(chatList))
			for _, chat := range chatList {
				// nameList[i] = chat.Name
				chatMap[chat.Name] = chat
			}
			chatActTable := makeChatTable(chatMap)
			pages.AddPage(historyPage, chatActTable, true, true)
			colorText()
			updateStatusLine()
			return nil
		}
		if event.Key() == tcell.KeyF2 && !botRespMode.Load() {
			// regen last msg
			if len(chatBody.Messages) == 0 {
				showToast("info", "no messages to regenerate")
				return nil
			}
			chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
			// there is no case where user msg is regenerated
			// lastRole := chatBody.Messages[len(chatBody.Messages)-1].Role
			textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
			// go chatRound("", cfg.UserRole, textView, true, false)
			if cfg.TTS_ENABLED {
				TTSDoneChan <- true
			}
			chatRoundChan <- &models.ChatRoundReq{Role: cfg.UserRole, Regen: true}
			return nil
		}
		if event.Key() == tcell.KeyF3 && !botRespMode.Load() {
			// delete last msg
			// check textarea text; if it ends with bot icon delete only icon:
			text := textView.GetText(true)
			assistantIcon := roleToIcon(cfg.AssistantRole)
			if strings.HasSuffix(text, assistantIcon) {
				logger.Debug("deleting assistant icon", "icon", assistantIcon)
				textView.SetText(strings.TrimSuffix(text, assistantIcon))
				colorText()
				return nil
			}
			if len(chatBody.Messages) == 0 {
				showToast("info", "no messages to delete")
				return nil
			}
			chatBody.Messages = chatBody.Messages[:len(chatBody.Messages)-1]
			textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
			if cfg.TTS_ENABLED {
				TTSDoneChan <- true
			}
			colorText()
			return nil
		}
		if event.Key() == tcell.KeyF4 {
			// edit msg - show index input as overlay at top
			editMode = true
			showIndexBar()
			return nil
		}
		if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '4' {
			// edit msg role - show index input as overlay at top
			editMode = false // Reset edit mode to false to handle role editing
			showIndexBar()
			// Set a flag to indicate we're in role edit mode
			roleEditMode = true
			return nil
		}
		if event.Key() == tcell.KeyF5 {
			// toggle fullscreen
			fullscreenMode = !fullscreenMode
			focused := app.GetFocus()
			if fullscreenMode {
				if focused == textArea || focused == textView {
					flex.Clear()
					flex.AddItem(focused, 0, 1, true)
				} else {
					// if focus is not on textarea or textview, cancel fullscreen
					fullscreenMode = false
				}
			} else {
				// focused is the fullscreened widget here
				updateFlexLayout()
			}
			return nil
		}
		if event.Key() == tcell.KeyF6 {
			interruptResp.Store(true)
			botRespMode.Store(false)
			toolRunningMode.Store(false)
			return nil
		}
		if event.Key() == tcell.KeyF7 {
			// copy msg to clipboard
			editMode = false
			m := chatBody.Messages[len(chatBody.Messages)-1]
			msgText := m.GetText()
			if err := copyToClipboard(msgText); err != nil {
				logger.Error("failed to copy to clipboard", "error", err)
			}
			previewLen := min(30, len(msgText))
			notification := fmt.Sprintf("msg '%s' was copied to the clipboard", msgText[:previewLen])
			showToast("copied", notification)
			return nil
		}
		if event.Key() == tcell.KeyF8 {
			// copy msg to clipboard
			editMode = false
			showIndexBar()
			return nil
		}
		if event.Key() == tcell.KeyF9 {
			// table of codeblocks to copy
			text := textView.GetText(false)
			cb := models.CodeBlockRE.FindAllString(text, -1)
			if len(cb) == 0 {
				showToast("notify", "no code blocks in chat")
				return nil
			}
			table := makeCodeBlockTable(cb)
			pages.AddPage(codeBlockPage, table, true, true)
			return nil
		}
		if event.Key() == tcell.KeyF10 {
			cfg.SkipLLMResp = !cfg.SkipLLMResp
			updateStatusLine()
		}
		if event.Key() == tcell.KeyF11 {
			// read files in chat_exports
			filelist, err := os.ReadDir(exportDir)
			if err != nil {
				showToast("failed to load exports", err.Error())
				return nil
			}
			fli := []string{}
			for _, f := range filelist {
				if f.IsDir() || !strings.HasSuffix(f.Name(), ".json") {
					continue
				}
				fpath := path.Join(exportDir, f.Name())
				fli = append(fli, fpath)
			}
			// check error
			exportsTable := makeImportChatTable(fli)
			pages.AddPage(historyPage, exportsTable, true, true)
			updateStatusLine()
			return nil
		}
		if event.Key() == tcell.KeyF12 {
			// help window cheatsheet
			// Update help text with current status before showing
			helpView.SetText(fmt.Sprintf(helpText, makeStatusLine()))
			pages.AddPage(helpPage, helpView, true, true)
			return nil
		}
		if event.Key() == tcell.KeyCtrlE {
			// export loaded chat into json file
			if err := exportChat(); err != nil {
				logger.Error("failed to export chat;", "error", err, "chat_name", activeChatName)
				return nil
			}
			showToast("exported chat", "chat: "+activeChatName+" was exported")
			return nil
		}
		if event.Key() == tcell.KeyCtrlP {
			propsTable := makePropsTable(defaultLCPProps)
			pages.AddPage(propsPage, propsTable, true, true)
			return nil
		}
		if event.Key() == tcell.KeyCtrlN {
			startNewChat(true)
			return nil
		}
		if event.Key() == tcell.KeyCtrlO {
			// open file picker
			filePicker := makeFilePicker()
			pages.AddPage(filePickerPage, filePicker, true, true)
			return nil
		}
		if event.Key() == tcell.KeyCtrlL {
			if isFullScreenPageActive() {
				return event
			}
			// Show model selection popup instead of rotating models
			showModelSelectionPopup()
			return nil
		}
		if event.Key() == tcell.KeyCtrlV {
			if isFullScreenPageActive() {
				return event
			}
			// Show API link selection popup instead of rotating APIs
			showAPILinkSelectionPopup()
			return nil
		}
		if event.Key() == tcell.KeyCtrlS {
			// switch sys prompt
			labels, err := initSysCards()
			if err != nil {
				logger.Error("failed to read sys dir", "error", err)
				showToast("error", "failed to read: "+cfg.SysDir)
				return nil
			}
			at := makeAgentTable(labels)
			// sysModal.AddButtons(labels)
			// load all chars
			pages.AddPage(agentPage, at, true, true)
			updateStatusLine()
			return nil
		}
		if event.Key() == tcell.KeyCtrlK {
			// add message from tools
			cfg.ToolUse = !cfg.ToolUse
			UpdateToolCapabilities()
			updateStatusLine()
			return nil
		}
		if event.Key() == tcell.KeyRune && event.Rune() == '8' && event.Modifiers()&tcell.ModAlt != 0 {
			// show image - check for attached image first, then fall back to agent image
			if lastImg != "" {
				// Load the attached image
				file, err := os.Open(lastImg)
				if err != nil {
					logger.Error("failed to open attached image", "path", lastImg, "error", err)
					// Fall back to showing agent image
					if err := loadImage(); err != nil {
						logger.Warn("failed to load agent image", "error", err)
					}
				} else {
					defer file.Close()
					img, _, err := image.Decode(file)
					if err != nil {
						logger.Error("failed to decode attached image", "path", lastImg, "error", err)
						// Fall back to showing agent image
						if err := loadImage(); err != nil {
							logger.Warn("failed to load agent image", "error", err)
						}
					} else {
						imgView.SetImage(img)
					}
				}
			} else {
				// No attached image, show agent image as before
				if err := loadImage(); err != nil {
					logger.Warn("failed to load agent image", "error", err)
				}
			}
			pages.AddPage(imgPage, imgView, true, true)
			return nil
		}
		if event.Key() == tcell.KeyCtrlR && cfg.STT_ENABLED {
			defer updateStatusLine()
			if asr.IsRecording() {
				userSpeech, err := asr.StopRecording()
				if err != nil {
					msg := "failed to inference user speech; error:" + err.Error()
					logger.Error(msg)
					showToast("stt error", msg)
					return nil
				}
				if userSpeech != "" {
					// append indtead of replacing
					prevText := textArea.GetText()
					textArea.SetText(prevText+userSpeech, true)
				} else {
					logger.Warn("empty user speech")
				}
				return nil
			}
			if err := asr.StartRecording(); err != nil {
				logger.Error("failed to start recording user speech", "error", err)
				return nil
			}
		}
		// I need keybind for tts to shut up
		if event.Key() == tcell.KeyCtrlA && cfg.TTS_ENABLED {
			TTSDoneChan <- true
		}
		if event.Key() == tcell.KeyRune && event.Rune() == '0' && event.Modifiers()&tcell.ModAlt != 0 && cfg.TTS_ENABLED {
			if len(chatBody.Messages) > 0 {
				// Stop any currently playing TTS first
				TTSDoneChan <- true
				lastMsg := chatBody.Messages[len(chatBody.Messages)-1]
				cleanedText := models.CleanText(lastMsg.GetText())
				if cleanedText != "" {
					// nolint: errcheck
					go orator.Speak(cleanedText)
				}
			}
			return nil
		}
		if event.Key() == tcell.KeyCtrlW {
			// INFO: continue bot/text message
			// without new role
			lastRole := chatBody.Messages[len(chatBody.Messages)-1].Role
			// go chatRound("", lastRole, textView, false, true)
			chatRoundChan <- &models.ChatRoundReq{Role: lastRole, Resume: true}
			return nil
		}
		if event.Key() == tcell.KeyCtrlQ {
			if isFullScreenPageActive() {
				return event
			}
			// Show user role selection popup instead of cycling through roles
			showUserRoleSelectionPopup()
			return nil
		}
		if event.Key() == tcell.KeyCtrlX {
			if isFullScreenPageActive() {
				return event
			}
			// Show bot role selection popup instead of cycling through roles
			showBotRoleSelectionPopup()
			return nil
		}
		// INFO: shutdown
		if event.Key() == tcell.KeyCtrlC {
			logger.Info("caught Ctrl+C via tcell event")
			go func() {
				if err := tools.PwShutDown(); err != nil {
					logger.Error("shutdown failed", "err", err)
				}
				app.Stop()
			}()
			return nil // swallow the event
		}
		if event.Key() == tcell.KeyCtrlG {
			// cfg.RAGDir is the directory with files to use with RAG
			// rag load
			// menu of the text files from defined rag directory
			files, err := os.ReadDir(cfg.RAGDir)
			if err != nil {
				// Check if the error is because the directory doesn't exist
				if os.IsNotExist(err) {
					// Create the RAG directory if it doesn't exist
					if mkdirErr := os.MkdirAll(cfg.RAGDir, 0755); mkdirErr != nil {
						logger.Error("failed to create RAG directory", "dir", cfg.RAGDir, "error", mkdirErr)
						showToast("failed to create RAG directory", mkdirErr.Error())
						return nil
					}
					// Now try to read the directory again after creating it
					files, err = os.ReadDir(cfg.RAGDir)
					if err != nil {
						logger.Error("failed to read dir after creating it", "dir", cfg.RAGDir, "error", err)
						showToast("failed to read RAG directory", err.Error())
						return nil
					}
				} else {
					// Other error (permissions, etc.)
					logger.Error("failed to read dir", "dir", cfg.RAGDir, "error", err)
					showToast("failed to open RAG files dir", err.Error())
					return nil
				}
			}
			// Get files from ragdir
			fileList := []string{}
			for _, f := range files {
				if f.IsDir() {
					continue
				}
				fileList = append(fileList, f.Name())
			}
			// Get loaded files from vector DB
			loadedFiles, err := ragger.ListLoaded()
			if err != nil {
				logger.Error("failed to list loaded RAG files", "error", err)
				loadedFiles = []string{} // Continue with empty list on error
			}
			chatRAGTable := makeRAGTable(fileList, loadedFiles)
			pages.AddPage(RAGPage, chatRAGTable, true, true)
			return nil
		}
		if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '1' {
			// Toggle shell mode: when enabled, commands are executed locally instead of sent to LLM
			toggleShellMode()
			return nil
		}
		if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '9' {
			// Warm up (load) the currently selected model
			go warmUpModel()
			showToast("model warmup", "loading model: "+chatBody.Model)
			return nil
		}
		// cannot send msg in editMode or botRespMode
		if event.Key() == tcell.KeyEscape && !editMode && !botRespMode.Load() {
			if shellMode {
				cmdText := shellInput.GetText()
				if cmdText != "" {
					executeCommandAndDisplay(cmdText)
					shellInput.SetText("")
				}
				return nil
			}
			msgText := textArea.GetText()
			nl := "\n\n" // keep empty lines between messages
			prevText := textView.GetText(true)
			persona := cfg.UserRole
			// strings.LastIndex()
			// newline is not needed is prev msg ends with one
			if strings.HasSuffix(prevText, nl) {
				nl = ""
			} else if strings.HasSuffix(prevText, "\n") {
				nl = "\n" // only one newline, add another
			}
			if msgText != "" {
				// as what char user sends msg?
				if cfg.WriteNextMsgAs != "" {
					persona = cfg.WriteNextMsgAs
				}
				// check if plain text
				if !injectRole {
					matches := models.RoleRE.FindStringSubmatch(msgText)
					if len(matches) > 1 {
						persona = matches[1]
						msgText = strings.TrimLeft(msgText[len(matches[0]):], " ")
					}
				}
				// add user icon before user msg
				fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
					nl, len(chatBody.Messages), persona, msgText)
				textArea.SetText("", true)
				if cfg.AutoScrollEnabled {
					textView.ScrollToEnd()
				}
				colorText()
			} else {
				pages.AddPage(confirmPageName, confirmModal, true, true)
				return nil
			}
			// go chatRound(msgText, persona, textView, false, false)
			chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msgText}
			return nil
		}
		if event.Key() == tcell.KeyTab && !shellMode {
			currentF := app.GetFocus()
			if currentF == textArea {
				currentText := textArea.GetText()
				atIndex := strings.LastIndex(currentText, "@")
				if atIndex >= 0 {
					filter := currentText[atIndex+1:]
					showTextAreaFileCompletionPopup(filter)
				}
			}
			return nil
		}
		if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn {
			currentF := app.GetFocus()
			app.SetFocus(focusSwitcher[currentF])
			return nil
		}
		if isASCII(string(event.Rune())) && !botRespMode.Load() {
			return event
		}
		return event
	})
}
