Feat: shell mode

This commit is contained in:
Grail Finder
2025-11-28 14:29:52 +03:00
parent f5aab322af
commit 860160ea0e
3 changed files with 155 additions and 34 deletions

View File

@@ -209,8 +209,17 @@ func makeStatusLine() string {
} else { } else {
imageInfo = "" imageInfo = ""
} }
// Add shell mode status to status line
var shellModeInfo string
if shellMode {
shellModeInfo = " | [green:-:b]SHELL MODE[-:-:-]"
} else {
shellModeInfo = ""
}
statusLine := fmt.Sprintf(indexLineCompletion, botRespMode, cfg.AssistantRole, activeChatName, statusLine := fmt.Sprintf(indexLineCompletion, botRespMode, cfg.AssistantRole, activeChatName,
cfg.ToolUse, chatBody.Model, cfg.SkipLLMResp, cfg.CurrentAPI, cfg.ThinkUse, logLevel.Level(), cfg.ToolUse, chatBody.Model, cfg.SkipLLMResp, cfg.CurrentAPI, cfg.ThinkUse, logLevel.Level(),
isRecording, persona, botPersona, injectRole) isRecording, persona, botPersona, injectRole)
return statusLine + imageInfo return statusLine + imageInfo + shellModeInfo
} }

View File

@@ -15,6 +15,7 @@ var (
selectedIndex = int(-1) selectedIndex = int(-1)
currentAPIIndex = 0 // Index to track current API in ApiLinks slice currentAPIIndex = 0 // Index to track current API in ApiLinks slice
currentORModelIndex = 0 // Index to track current OpenRouter model in ORFreeModels slice currentORModelIndex = 0 // Index to track current OpenRouter model in ORFreeModels slice
shellMode = false
// indexLine = "F12 to show keys help | bot resp mode: [orange:-:b]%v[-:-:-] (F6) | card's char: [orange:-:b]%s[-:-:-] (ctrl+s) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [orange:-:b]%v[-:-:-] (ctrl+k) | model: [orange:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [orange:-:b]%v[-:-:-] (F10)\nAPI_URL: [orange:-:b]%s[-:-:-] (ctrl+v) | ThinkUse: [orange:-:b]%v[-:-:-] (ctrl+p) | Log Level: [orange:-:b]%v[-:-:-] (ctrl+p) | Recording: [orange:-:b]%v[-:-:-] (ctrl+r) | Writing as: [orange:-:b]%s[-:-:-] (ctrl+q)" // indexLine = "F12 to show keys help | bot resp mode: [orange:-:b]%v[-:-:-] (F6) | card's char: [orange:-:b]%s[-:-:-] (ctrl+s) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [orange:-:b]%v[-:-:-] (ctrl+k) | model: [orange:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [orange:-:b]%v[-:-:-] (F10)\nAPI_URL: [orange:-:b]%s[-:-:-] (ctrl+v) | ThinkUse: [orange:-:b]%v[-:-:-] (ctrl+p) | Log Level: [orange:-:b]%v[-:-:-] (ctrl+p) | Recording: [orange:-:b]%v[-:-:-] (ctrl+r) | Writing as: [orange:-:b]%s[-:-:-] (ctrl+q)"
indexLineCompletion = "F12 to show keys help | bot resp mode: [orange:-:b]%v[-:-:-] (F6) | card's char: [orange:-:b]%s[-:-:-] (ctrl+s) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [orange:-:b]%v[-:-:-] (ctrl+k) | model: [orange:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [orange:-:b]%v[-:-:-] (F10)\nAPI_URL: [orange:-:b]%s[-:-:-] (ctrl+v) | Insert <think>: [orange:-:b]%v[-:-:-] (ctrl+p) | Log Level: [orange:-:b]%v[-:-:-] (ctrl+p) | Recording: [orange:-:b]%v[-:-:-] (ctrl+r) | Writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | Bot will write as [orange:-:b]%s[-:-:-] (ctrl+x) | role_inject [orange:-:b]%v[-:-:-]" indexLineCompletion = "F12 to show keys help | bot resp mode: [orange:-:b]%v[-:-:-] (F6) | card's char: [orange:-:b]%s[-:-:-] (ctrl+s) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [orange:-:b]%v[-:-:-] (ctrl+k) | model: [orange:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [orange:-:b]%v[-:-:-] (F10)\nAPI_URL: [orange:-:b]%s[-:-:-] (ctrl+v) | Insert <think>: [orange:-:b]%v[-:-:-] (ctrl+p) | Log Level: [orange:-:b]%v[-:-:-] (ctrl+p) | Recording: [orange:-:b]%v[-:-:-] (ctrl+r) | Writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | Bot will write as [orange:-:b]%s[-:-:-] (ctrl+x) | role_inject [orange:-:b]%v[-:-:-]"
focusSwitcher = map[tview.Primitive]tview.Primitive{} focusSwitcher = map[tview.Primitive]tview.Primitive{}

113
tui.go
View File

@@ -8,6 +8,7 @@ import (
_ "image/jpeg" _ "image/jpeg"
_ "image/png" _ "image/png"
"os" "os"
"os/exec"
"path" "path"
"slices" "slices"
"strconv" "strconv"
@@ -78,6 +79,7 @@ var (
[yellow]Ctrl+y[white]: list loaded RAG files (view and manage loaded files) [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+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]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) === === scrolling chat window (some keys similar to vim) ===
[yellow]arrows up/down and j/k[white]: scroll up and down [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 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() { func init() {
tview.Styles = colorschemes["default"] tview.Styles = colorschemes["default"]
app = tview.NewApplication() app = tview.NewApplication()
@@ -800,10 +898,22 @@ func init() {
pages.AddPage(RAGLoadedPage, chatLoadedRAGTable, true, true) pages.AddPage(RAGLoadedPage, chatLoadedRAGTable, true, true)
return nil 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 // cannot send msg in editMode or botRespMode
if event.Key() == tcell.KeyEscape && !editMode && !botRespMode { if event.Key() == tcell.KeyEscape && !editMode && !botRespMode {
// read all text into buffer
msgText := textArea.GetText() msgText := textArea.GetText()
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" nl := "\n"
prevText := textView.GetText(true) prevText := textView.GetText(true)
persona := cfg.UserRole persona := cfg.UserRole
@@ -840,6 +950,7 @@ func init() {
// But clears it for the next message // But clears it for the next message
ClearImageAttachment() ClearImageAttachment()
}() }()
}
return nil return nil
} }
if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn { if event.Key() == tcell.KeyPgUp || event.Key() == tcell.KeyPgDn {