summaryrefslogtreecommitdiff
path: root/tui.go
diff options
context:
space:
mode:
authorGrail Finder <wohilas@gmail.com>2026-03-09 07:07:36 +0300
committerGrail Finder <wohilas@gmail.com>2026-03-09 07:07:36 +0300
commit0e42a6f069ceea40485162c014c04cf718568cfe (patch)
tree583a6a6cb91b315e506990a03fdda1b32d0fe985 /tui.go
parent2687f38d00ceaa4f61034e3e02b9b59d08efc017 (diff)
parenta1b5f9cdc59938901123650fc0900067ac3447ca (diff)
Merge branch 'master' into feat/agent-flow
Diffstat (limited to 'tui.go')
-rw-r--r--tui.go145
1 files changed, 100 insertions, 45 deletions
diff --git a/tui.go b/tui.go
index b23c3ff..04ce38e 100644
--- a/tui.go
+++ b/tui.go
@@ -29,6 +29,8 @@ var (
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
@@ -36,10 +38,10 @@ var (
roleEditWindow *tview.InputField
shellInput *tview.InputField
confirmModal *tview.Modal
+ toastTimer *time.Timer
confirmPageName = "confirm"
fullscreenMode bool
positionVisible bool = true
- scrollToEndEnabled bool = true
// pages
historyPage = "historyPage"
agentPage = "agentPage"
@@ -48,7 +50,6 @@ var (
helpPage = "helpPage"
renamePage = "renamePage"
RAGPage = "RAGPage"
- RAGLoadedPage = "RAGLoadedPage"
propsPage = "propsPage"
codeBlockPage = "codeBlockPage"
imgPage = "imgPage"
@@ -137,8 +138,8 @@ func setShellMode(enabled bool) {
}()
}
-// showToast displays a temporary message in the top‑right corner.
-// It auto‑hides after 3 seconds and disappears when clicked.
+// showToast displays a temporary notification in the bottom-right corner.
+// It auto-hides after 3 seconds.
func showToast(title, message string) {
sanitize := func(s string, maxLen int) string {
sanitized := strings.Map(func(r rune) rune {
@@ -154,39 +155,74 @@ func showToast(title, message string) {
}
title = sanitize(title, 50)
message = sanitize(message, 197)
- notification := tview.NewTextView().
- SetTextAlign(tview.AlignCenter).
- SetDynamicColors(true).
- SetRegions(true).
- SetText(fmt.Sprintf("[yellow]%s[-]\n", message)).
- SetChangedFunc(func() {
- app.Draw()
+ 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)
+ }
+ })
})
- 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 3 seconds.
- time.AfterFunc(3*time.Second, func() {
+ return
+ }
+ notificationWidget.SetTitle(title)
+ notificationWidget.SetText(fmt.Sprintf("[yellow]%s[-]", message))
+ go func() {
app.QueueUpdateDraw(func() {
- if pages.HasPage(pageName) {
- pages.RemovePage(pageName)
+ 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 init() {
+func initTUI() {
// Start background goroutine to update model color cache
startModelColorUpdater()
tview.Styles = colorschemes["default"]
@@ -235,7 +271,7 @@ func init() {
shellHistoryPos = -1
}
// Handle Tab key for @ file completion
- if event.Key() == tcell.KeyTab {
+ if event.Key() == tcell.KeyTab && shellMode {
currentText := shellInput.GetText()
atIndex := strings.LastIndex(currentText, "@")
if atIndex >= 0 {
@@ -286,12 +322,26 @@ func init() {
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()
})
+ 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(textArea, 0, 10, true) // Restore original height
+ AddItem(bottomFlex, 0, 10, true)
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)
}
@@ -360,10 +410,14 @@ func init() {
// 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(textArea, 0, 10, true) // Restore original height
+ AddItem(bottomFlex, 0, 10, true)
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)
}
@@ -578,7 +632,7 @@ func init() {
updateStatusLine()
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
colorText()
- if scrollToEndEnabled {
+ if cfg.AutoScrollEnabled {
textView.ScrollToEnd()
}
// init sysmap
@@ -607,9 +661,9 @@ func init() {
}
if event.Key() == tcell.KeyRune && event.Rune() == '2' && event.Modifiers()&tcell.ModAlt != 0 {
// toggle auto-scrolling
- scrollToEndEnabled = !scrollToEndEnabled
+ cfg.AutoScrollEnabled = !cfg.AutoScrollEnabled
status := "disabled"
- if scrollToEndEnabled {
+ if cfg.AutoScrollEnabled {
status = "enabled"
}
showToast("autoscroll", "Auto-scrolling "+status)
@@ -676,7 +730,7 @@ func init() {
updateStatusLine()
return nil
}
- if event.Key() == tcell.KeyF2 && !botRespMode {
+ if event.Key() == tcell.KeyF2 && !botRespMode.Load() {
// regen last msg
if len(chatBody.Messages) == 0 {
showToast("info", "no messages to regenerate")
@@ -693,7 +747,7 @@ func init() {
chatRoundChan <- &models.ChatRoundReq{Role: cfg.UserRole, Regen: true}
return nil
}
- if event.Key() == tcell.KeyF3 && !botRespMode {
+ 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)
@@ -749,9 +803,9 @@ func init() {
return nil
}
if event.Key() == tcell.KeyF6 {
- interruptResp = true
- botRespMode = false
- toolRunningMode = false
+ interruptResp.Store(true)
+ botRespMode.Store(false)
+ toolRunningMode.Store(false)
return nil
}
if event.Key() == tcell.KeyF7 {
@@ -1046,7 +1100,7 @@ func init() {
return nil
}
// cannot send msg in editMode or botRespMode
- if event.Key() == tcell.KeyEscape && !editMode && !botRespMode {
+ if event.Key() == tcell.KeyEscape && !editMode && !botRespMode.Load() {
if shellMode {
cmdText := shellInput.GetText()
if cmdText != "" {
@@ -1083,7 +1137,7 @@ func init() {
fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
nl, len(chatBody.Messages), persona, msgText)
textArea.SetText("", true)
- if scrollToEndEnabled {
+ if cfg.AutoScrollEnabled {
textView.ScrollToEnd()
}
colorText()
@@ -1095,7 +1149,7 @@ func init() {
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msgText}
return nil
}
- if event.Key() == tcell.KeyTab {
+ if event.Key() == tcell.KeyTab && !shellMode {
currentF := app.GetFocus()
if currentF == textArea {
currentText := textArea.GetText()
@@ -1112,9 +1166,10 @@ func init() {
app.SetFocus(focusSwitcher[currentF])
return nil
}
- if isASCII(string(event.Rune())) && !botRespMode {
+ if isASCII(string(event.Rune())) && !botRespMode.Load() {
return event
}
return event
})
+ go updateModelLists()
}