summaryrefslogtreecommitdiff
path: root/tui.go
diff options
context:
space:
mode:
authorGrail Finder <wohilas@gmail.com>2025-11-28 14:29:52 +0300
committerGrail Finder <wohilas@gmail.com>2025-11-28 14:29:52 +0300
commit860160ea0e4d940eee43da8f20538293612093a5 (patch)
tree754ebf42161561c513651a51af53d98fe9fa9f2d /tui.go
parentf5aab322afbf337d797a62f24cf85ef7766a96bb (diff)
Feat: shell mode
Diffstat (limited to 'tui.go')
-rw-r--r--tui.go177
1 files changed, 144 insertions, 33 deletions
diff --git a/tui.go b/tui.go
index 63821d7..0f0a3e8 100644
--- a/tui.go
+++ b/tui.go
@@ -8,6 +8,7 @@ import (
_ "image/jpeg"
_ "image/png"
"os"
+ "os/exec"
"path"
"slices"
"strconv"
@@ -78,6 +79,7 @@ var (
[yellow]Ctrl+y[white]: list loaded RAG files (view and manage loaded files)
[yellow]Ctrl+q[white]: cycle through mentioned chars in chat, to pick persona to send next msg as
[yellow]Ctrl+x[white]: cycle through mentioned chars in chat, to pick persona to send next msg as (for llm)
+[yellow]Alt+1[white]: toggle shell mode (execute commands locally)
=== scrolling chat window (some keys similar to vim) ===
[yellow]arrows up/down and j/k[white]: scroll up and down
@@ -207,6 +209,102 @@ func makePropsForm(props map[string]float32) *tview.Form {
return form
}
+func toggleShellMode() {
+ shellMode = !shellMode
+ if shellMode {
+ // Update input placeholder to indicate shell mode
+ textArea.SetPlaceholder("SHELL MODE: Enter command and press <Esc> to execute")
+ } else {
+ // Reset to normal mode
+ textArea.SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message. Alt+1 to exit shell mode")
+ }
+ updateStatusLine()
+}
+
+func executeCommandAndDisplay(cmdText string) {
+ // Parse the command (split by spaces, but handle quoted arguments)
+ cmdParts := parseCommand(cmdText)
+ if len(cmdParts) == 0 {
+ fmt.Fprintf(textView, "\n[red]Error: No command provided[-:-:-]\n")
+ textView.ScrollToEnd()
+ colorText()
+ return
+ }
+
+ command := cmdParts[0]
+ args := []string{}
+ if len(cmdParts) > 1 {
+ args = cmdParts[1:]
+ }
+
+ // Create the command execution
+ cmd := exec.Command(command, args...)
+
+ // Execute the command and get output
+ output, err := cmd.CombinedOutput()
+
+ // Add the command being executed to the chat
+ fmt.Fprintf(textView, "\n[yellow]$ %s[-:-:-]\n", cmdText)
+
+ if err != nil {
+ // Include both output and error
+ fmt.Fprintf(textView, "[red]Error: %s[-:-:-]\n", err.Error())
+ if len(output) > 0 {
+ fmt.Fprintf(textView, "[red]%s[-:-:-]\n", string(output))
+ }
+ } else {
+ // Only output if successful
+ if len(output) > 0 {
+ fmt.Fprintf(textView, "[green]%s[-:-:-]\n", string(output))
+ } else {
+ fmt.Fprintf(textView, "[green]Command executed successfully (no output)[-:-:-]\n")
+ }
+ }
+
+ // Scroll to end and update colors
+ textView.ScrollToEnd()
+ colorText()
+}
+
+// parseCommand splits command string handling quotes properly
+func parseCommand(cmd string) []string {
+ var args []string
+ var current string
+ var inQuotes bool
+ var quoteChar rune
+
+ for _, r := range cmd {
+ switch r {
+ case '"', '\'':
+ if inQuotes {
+ if r == quoteChar {
+ inQuotes = false
+ } else {
+ current += string(r)
+ }
+ } else {
+ inQuotes = true
+ quoteChar = r
+ }
+ case ' ', '\t':
+ if inQuotes {
+ current += string(r)
+ } else if current != "" {
+ args = append(args, current)
+ current = ""
+ }
+ default:
+ current += string(r)
+ }
+ }
+
+ if current != "" {
+ args = append(args, current)
+ }
+
+ return args
+}
+
func init() {
tview.Styles = colorschemes["default"]
app = tview.NewApplication()
@@ -800,46 +898,59 @@ func init() {
pages.AddPage(RAGLoadedPage, chatLoadedRAGTable, 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
+ }
// cannot send msg in editMode or botRespMode
if event.Key() == tcell.KeyEscape && !editMode && !botRespMode {
- // read all text into buffer
msgText := textArea.GetText()
- nl := "\n"
- 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 = ""
- }
- if msgText != "" {
- // as what char user sends msg?
- if cfg.WriteNextMsgAs != "" {
- persona = cfg.WriteNextMsgAs
+
+ if shellMode && msgText != "" {
+ // In shell mode, execute command instead of sending to LLM
+ executeCommandAndDisplay(msgText)
+ textArea.SetText("", true) // Clear the input area
+ return nil
+ } else if !shellMode {
+ // Normal mode - send to LLM
+ nl := "\n"
+ 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 = ""
}
- // check if plain text
- if !injectRole {
- matches := roleRE.FindStringSubmatch(msgText)
- if len(matches) > 1 {
- persona = matches[1]
- msgText = strings.TrimLeft(msgText[len(matches[0]):], " ")
+ if msgText != "" {
+ // as what char user sends msg?
+ if cfg.WriteNextMsgAs != "" {
+ persona = cfg.WriteNextMsgAs
}
+ // check if plain text
+ if !injectRole {
+ matches := 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)
+ textView.ScrollToEnd()
+ colorText()
}
- // 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)
- textView.ScrollToEnd()
- colorText()
+ go chatRound(msgText, persona, textView, false, false)
+ // Also clear any image attachment after sending the message
+ go func() {
+ // Wait a short moment for the message to be processed, then clear the image attachment
+ // This allows the image to be sent with the current message if it was attached
+ // But clears it for the next message
+ ClearImageAttachment()
+ }()
}
- go chatRound(msgText, persona, textView, false, false)
- // Also clear any image attachment after sending the message
- go func() {
- // Wait a short moment for the message to be processed, then clear the image attachment
- // This allows the image to be sent with the current message if it was attached
- // But clears it for the next message
- ClearImageAttachment()
- }()
return nil
}
if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn {