3 Commits

Author SHA1 Message Date
Grail Finder
0d94734090 Enha: tool role index for shellmode 2026-02-27 08:07:55 +03:00
Grail Finder
a0ff384b81 Enha: shellmode within inputfield 2026-02-27 07:58:00 +03:00
Grail Finder
09b5e0d08f Enha: shell mode in filepickerdir 2026-02-26 20:10:00 +03:00
5 changed files with 107 additions and 58 deletions

View File

@@ -426,12 +426,11 @@ func deepseekModelValidator() error {
func toggleShellMode() {
shellMode = !shellMode
setShellMode(shellMode)
if shellMode {
// Update input placeholder to indicate shell mode
textArea.SetPlaceholder("SHELL MODE: Enter command and press <Esc> to execute")
shellInput.SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir))
} 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")
textArea.SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.")
}
updateStatusLine()
}
@@ -443,15 +442,22 @@ func updateFlexLayout() {
}
flex.Clear()
flex.AddItem(textView, 0, 40, false)
if shellMode {
flex.AddItem(shellInput, 0, 10, false)
} else {
flex.AddItem(textArea, 0, 10, false)
}
if positionVisible {
flex.AddItem(statusLineWidget, 0, 2, false)
}
// Keep focus on currently focused widget
focused := app.GetFocus()
if focused == textView {
switch {
case focused == textView:
app.SetFocus(textView)
} else {
case shellMode:
app.SetFocus(shellInput)
default:
app.SetFocus(textArea)
}
}
@@ -474,10 +480,12 @@ func executeCommandAndDisplay(cmdText string) {
}
// Create the command execution
cmd := exec.Command(command, args...)
cmd.Dir = cfg.FilePickerDir
// 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)
fmt.Fprintf(textView, "\n[-:-:b](%d) <%s>: [-:-:-]\n$ %s\n",
len(chatBody.Messages), cfg.ToolRole, cmdText)
var outputContent string
if err != nil {
// Include both output and error
@@ -514,6 +522,11 @@ func executeCommandAndDisplay(cmdText string) {
textView.ScrollToEnd()
}
colorText()
// Add command to history (avoid duplicates at the end)
if len(shellHistory) == 0 || shellHistory[len(shellHistory)-1] != cmdText {
shellHistory = append(shellHistory, cmdText)
}
shellHistoryPos = -1
}
// parseCommand splits command string handling quotes properly

View File

@@ -13,6 +13,8 @@ var (
injectRole = true
selectedIndex = int(-1)
shellMode = false
shellHistory []string
shellHistoryPos int = -1
thinkingCollapsed = false
statusLineTempl = "help (F12) | [%s:-:b]llm writes[-:-:-] (F6 to interrupt) | chat: [orange:-:b]%s[-:-:-] (F1) | [%s:-:b]tool use[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | [%s:-:b]skip LLM resp[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)"
focusSwitcher = map[tview.Primitive]tview.Primitive{}

View File

@@ -343,7 +343,7 @@ func showBotRoleSelectionPopup() {
app.SetFocus(roleListWidget)
}
func showFileCompletionPopup(filter string) {
func showShellFileCompletionPopup(filter string) {
baseDir := cfg.FilePickerDir
if baseDir == "" {
baseDir = "."
@@ -352,13 +352,12 @@ func showFileCompletionPopup(filter string) {
if len(complMatches) == 0 {
return
}
// If only one match, auto-complete without showing popup
if len(complMatches) == 1 {
currentText := textArea.GetText()
currentText := shellInput.GetText()
atIdx := strings.LastIndex(currentText, "@")
if atIdx >= 0 {
before := currentText[:atIdx]
textArea.SetText(before+complMatches[0], true)
shellInput.SetText(before + complMatches[0])
}
return
}
@@ -369,24 +368,24 @@ func showFileCompletionPopup(filter string) {
widget.AddItem(m, "", 0, nil)
}
widget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
currentText := textArea.GetText()
currentText := shellInput.GetText()
atIdx := strings.LastIndex(currentText, "@")
if atIdx >= 0 {
before := currentText[:atIdx]
textArea.SetText(before+mainText, true)
shellInput.SetText(before + mainText)
}
pages.RemovePage("fileCompletionPopup")
app.SetFocus(textArea)
pages.RemovePage("shellFileCompletionPopup")
app.SetFocus(shellInput)
})
widget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
pages.RemovePage("fileCompletionPopup")
app.SetFocus(textArea)
pages.RemovePage("shellFileCompletionPopup")
app.SetFocus(shellInput)
return nil
}
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
pages.RemovePage("fileCompletionPopup")
app.SetFocus(textArea)
pages.RemovePage("shellFileCompletionPopup")
app.SetFocus(shellInput)
return nil
}
return event
@@ -400,8 +399,7 @@ func showFileCompletionPopup(filter string) {
AddItem(nil, 0, 1, false), width, 1, true).
AddItem(nil, 0, 1, false)
}
// Add modal page and make it visible
pages.AddPage("fileCompletionPopup", modal(widget, 80, 20), true, true)
pages.AddPage("shellFileCompletionPopup", modal(widget, 80, 20), true, true)
app.SetFocus(widget)
}

View File

@@ -714,6 +714,7 @@ func executeCommand(args map[string]string) []byte {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, command, cmdArgs...)
cmd.Dir = cfg.FilePickerDir
output, err := cmd.CombinedOutput()
if err != nil {
msg := fmt.Sprintf("command '%s' failed; error: %v; output: %s", command, err, string(output))

99
tui.go
View File

@@ -34,6 +34,7 @@ var (
indexPickWindow *tview.InputField
renameWindow *tview.InputField
roleEditWindow *tview.InputField
shellInput *tview.InputField
fullscreenMode bool
positionVisible bool = true
scrollToEndEnabled bool = true
@@ -124,46 +125,78 @@ Press <Enter> or 'x' to return
`
)
func setShellMode(enabled bool) {
shellMode = enabled
go func() {
app.QueueUpdateDraw(func() {
updateFlexLayout()
})
}()
}
func init() {
// Start background goroutine to update model color cache
startModelColorUpdater()
tview.Styles = colorschemes["default"]
app = tview.NewApplication()
pages = tview.NewPages()
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")
// Add input capture for @ completion
textArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
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 Tab key for file completion
// 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 {
currentText := textArea.GetText()
row, col, _, _ := textArea.GetCursor()
// Calculate absolute position from row/col
lines := strings.Split(currentText, "\n")
cursorPos := 0
for i := 0; i < row && i < len(lines); i++ {
cursorPos += len(lines[i]) + 1 // +1 for newline
}
cursorPos += col
// Look backwards from cursor to find @
if cursorPos > 0 {
// Find the last @ before cursor
textBeforeCursor := currentText[:cursorPos]
atIndex := strings.LastIndex(textBeforeCursor, "@")
currentText := shellInput.GetText()
atIndex := strings.LastIndex(currentText, "@")
if atIndex >= 0 {
// Extract the partial match text after @
filter := textBeforeCursor[atIndex+1:]
showFileCompletionPopup(filter)
return nil // Consume the Tab event
}
filter := currentText[atIndex+1:]
showShellFileCompletionPopup(filter)
}
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")
textView = tview.NewTextView().
SetDynamicColors(true).
SetRegions(true).
@@ -948,14 +981,16 @@ func init() {
}
// cannot send msg in editMode or botRespMode
if event.Key() == tcell.KeyEscape && !editMode && !botRespMode {
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
if shellMode {
cmdText := shellInput.GetText()
if cmdText != "" {
executeCommandAndDisplay(cmdText)
shellInput.SetText("")
}
return nil
} else if !shellMode {
// Normal mode - send to LLM
}
msgText := textArea.GetText()
if msgText != "" {
nl := "\n\n" // keep empty lines between messages
prevText := textView.GetText(true)
persona := cfg.UserRole