Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,23 @@ Reserved flags `-t`, `-P`, and `-F` are managed internally and must not be inclu

If omitted, TmuxAI uses the legacy default: `-d -h`.

### Single-pane mode

Run TmuxAI without creating a separate exec pane — useful for popup windows, bottom bars, or custom layouts where only one pane is available:

```bash
tmuxai --no-exec-pane
```

Or in config:

```yaml
tmux:
no_exec_pane: true
```

In single-pane mode, TmuxAI can still chat, answer questions, and provide instructions — it just cannot execute commands, send keystrokes, or paste content.

### Environment Variables

All configuration options can also be set via environment variables, which take precedence over the config file. Use the prefix `TMUXAI_` followed by the uppercase configuration key:
Expand Down
12 changes: 7 additions & 5 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import (
)

var (
initMessage string
taskFileFlag string
kbFlag string
modelFlag string
yoloFlag bool
initMessage string
taskFileFlag string
kbFlag string
modelFlag string
yoloFlag bool
noExecPaneFlag bool
)

var rootCmd = &cobra.Command{
Expand Down Expand Up @@ -93,6 +94,7 @@ func init() {
rootCmd.Flags().StringVar(&kbFlag, "kb", "", "Comma-separated list of knowledge bases to load (e.g., --kb docker,git)")
rootCmd.Flags().StringVar(&modelFlag, "model", "", "AI model configuration to use (e.g., --model gpt4)")
rootCmd.Flags().BoolVar(&yoloFlag, "yolo", false, "Skip all confirmation prompts and execute commands directly")
rootCmd.Flags().BoolVar(&noExecPaneFlag, "no-exec-pane", false, "Run in single-pane mode without creating an exec pane")
rootCmd.Flags().BoolP("version", "v", false, "Print version information")
}

Expand Down
1 change: 1 addition & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ wait_interval: 5
# ["-d", "-h"] # horizontal split
# ["-d", "-v"] # vertical split
# ["-d", "-v", "-p", "70"] # vertical, 70%
# no_exec_pane: true # single-pane mode: no exec pane, chat-only
tmux:
exec_split_args: ["-d", "-h"]

Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ type KnowledgeBaseConfig struct {
// ExecSplitArgs are raw args passed to `tmux split-window` before target/format flags.
type TmuxConfig struct {
ExecSplitArgs []string `mapstructure:"exec_split_args"`
NoExecPane bool `mapstructure:"no_exec_pane"`
}

// DefaultConfig returns a configuration with default values
Expand Down
8 changes: 7 additions & 1 deletion internal/chat_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ func (m *Manager) ProcessSubCommand(command string) {
return

case prefixMatch(commandPrefix, "/prepare"):
if m.GetNoExecPane() {
m.Println("Cannot prepare exec pane in single-pane mode (no exec pane available).")
return
}
supportedShells := []string{"bash", "zsh", "fish"}
if err := m.InitExecPane(); err != nil {
m.Println(fmt.Sprintf("Error preparing exec pane: %v", err))
Expand Down Expand Up @@ -145,7 +149,9 @@ func (m *Manager) ProcessSubCommand(command string) {
m.Status = ""
m.Messages = []ChatMessage{}
_ = system.TmuxClearPane(m.PaneId)
_ = system.TmuxClearPane(m.ExecPane.Id)
if m.HasExecPane() {
_ = system.TmuxClearPane(m.ExecPane.Id)
}
return

case prefixMatch(commandPrefix, "/exit"):
Expand Down
10 changes: 10 additions & 0 deletions internal/config_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ var AllowedConfigKeys = []string{
"paste_multiline_confirm",
"exec_confirm",
"yolo",
"tmux.no_exec_pane",
"openrouter.model",
"openai.api_key",
"openai.model",
Expand Down Expand Up @@ -103,6 +104,15 @@ func (m *Manager) GetYolo() bool {
return m.Config.Yolo
}

func (m *Manager) GetNoExecPane() bool {
if override, exists := m.SessionOverrides["tmux.no_exec_pane"]; exists {
if val, ok := override.(bool); ok {
return val
}
}
return m.Config.Tmux.NoExecPane
}

func (m *Manager) GetOpenRouterModel() string {
if override, exists := m.SessionOverrides["openrouter.model"]; exists {
if val, ok := override.(string); ok {
Expand Down
5 changes: 5 additions & 0 deletions internal/exec_pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import (
"github.com/alvinunreal/tmuxai/system"
)

// HasExecPane returns true if an exec pane is available for command execution.
func (m *Manager) HasExecPane() bool {
return m.ExecPane != nil && m.ExecPane.Id != ""
}

// GetAvailablePane finds an available pane or creates a new one if none are available
func (m *Manager) GetAvailablePane() system.TmuxPaneDetails {
panes, _ := m.GetTmuxPanes()
Expand Down
6 changes: 4 additions & 2 deletions internal/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ func NewManager(cfg *config.Config) (*Manager, error) {
manager.confirmedToExec = manager.confirmedToExecFn
manager.getTmuxPanesInXml = manager.getTmuxPanesInXmlFn

if err := manager.InitExecPane(); err != nil {
return nil, err
if !manager.GetNoExecPane() {
if err := manager.InitExecPane(); err != nil {
return nil, err
}
}

// Auto-load knowledge bases from config
Expand Down
4 changes: 2 additions & 2 deletions internal/pane_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ func (m *Manager) GetTmuxPanes() ([]system.TmuxPaneDetails, error) {

for i := range currentPanes {
currentPanes[i].IsTmuxAiPane = currentPanes[i].Id == currentPaneId
currentPanes[i].IsTmuxAiExecPane = currentPanes[i].Id == m.ExecPane.Id
currentPanes[i].IsPrepared = currentPanes[i].Id == m.ExecPane.Id
currentPanes[i].IsTmuxAiExecPane = m.HasExecPane() && currentPanes[i].Id == m.ExecPane.Id
currentPanes[i].IsPrepared = m.HasExecPane() && currentPanes[i].Id == m.ExecPane.Id
if currentPanes[i].IsSubShell {
currentPanes[i].OS = "OS Unknown (subshell)"
} else {
Expand Down
118 changes: 67 additions & 51 deletions internal/process_message.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ func (m *Manager) ProcessUserMessage(ctx context.Context, message string) bool {

currentTmuxWindow := m.getTmuxPanesInXml(m.Config)
execPaneEnv := ""
if !m.ExecPane.IsSubShell {
if m.GetNoExecPane() {
execPaneEnv = "SINGLE PANE MODE: No exec pane is available. You can only respond with messages to the user. You cannot execute commands, send keys, or paste content."
} else if m.ExecPane != nil && !m.ExecPane.IsSubShell {
execPaneEnv = fmt.Sprintf("Keep in mind, you are working within the shell: %s and OS: %s", m.ExecPane.Shell, m.ExecPane.OS)
}
currentMessage := ChatMessage{
Expand All @@ -42,9 +44,11 @@ func (m *Manager) ProcessUserMessage(ctx context.Context, message string) bool {
// build current chat history
var history []ChatMessage
switch {
case m.GetNoExecPane():
history = []ChatMessage{m.singlePanePrompt()}
case m.WatchMode:
history = []ChatMessage{m.watchPrompt()}
case m.ExecPane.IsPrepared:
case m.ExecPane != nil && m.ExecPane.IsPrepared:
history = []ChatMessage{m.chatAssistantPrompt(true)}
default:
history = []ChatMessage{m.chatAssistantPrompt(false)}
Expand Down Expand Up @@ -161,8 +165,12 @@ func (m *Manager) ProcessUserMessage(ctx context.Context, message string) bool {
m.Messages = append(m.Messages, currentMessage, responseMsg)
}

// observe/prepared mode
// observe/prepared mode — skip if single pane mode
for _, execCommand := range r.ExecCommand {
if !m.HasExecPane() {
m.Println("No exec pane available. Use --no-exec-pane=false or remove no_exec_pane from config.")
break
}
code, _ := system.HighlightCode("sh", execCommand)
m.Println(code)

Expand All @@ -187,45 +195,49 @@ func (m *Manager) ProcessUserMessage(ctx context.Context, message string) bool {
}
}

// Process SendKeys
// Process SendKeys — skip if single pane mode
if len(r.SendKeys) > 0 {
// Show preview of all keys
keysPreview := "Keys to send:\n"
for i, sendKey := range r.SendKeys {
code, _ := system.HighlightCode("txt", sendKey)
if i == len(r.SendKeys)-1 {
keysPreview += code
} else {
keysPreview += code + "\n"
}
if m.Status == "" {
return false
if !m.HasExecPane() {
m.Println("No exec pane available. Use --no-exec-pane=false or remove no_exec_pane from config.")
} else {
// Show preview of all keys
keysPreview := "Keys to send:\n"
for i, sendKey := range r.SendKeys {
code, _ := system.HighlightCode("txt", sendKey)
if i == len(r.SendKeys)-1 {
keysPreview += code
} else {
keysPreview += code + "\n"
}
if m.Status == "" {
return false
}
}
}

m.Println(keysPreview)
m.Println(keysPreview)

// Determine confirmation message based on number of keys
confirmMessage := "Send this key?"
if len(r.SendKeys) > 1 {
confirmMessage = "Send all these keys?"
}
// Determine confirmation message based on number of keys
confirmMessage := "Send this key?"
if len(r.SendKeys) > 1 {
confirmMessage = "Send all these keys?"
}

// Get confirmation if required
var allConfirmed bool
if m.GetSendKeysConfirm() {
allConfirmed, _ = m.confirmedToExec("keys shown above", confirmMessage, true)
if !allConfirmed {
m.Status = ""
return false
// Get confirmation if required
var allConfirmed bool
if m.GetSendKeysConfirm() {
allConfirmed, _ = m.confirmedToExec("keys shown above", confirmMessage, true)
if !allConfirmed {
m.Status = ""
return false
}
}
}

// Send each key with delay
for _, sendKey := range r.SendKeys {
m.Println("Sending keys: " + sendKey)
_ = system.TmuxSendCommandToPane(m.ExecPane.Id, sendKey, false)
time.Sleep(1 * time.Second)
// Send each key with delay
for _, sendKey := range r.SendKeys {
m.Println("Sending keys: " + sendKey)
_ = system.TmuxSendCommandToPane(m.ExecPane.Id, sendKey, false)
time.Sleep(1 * time.Second)
}
}
}

Expand All @@ -240,25 +252,29 @@ func (m *Manager) ProcessUserMessage(ctx context.Context, message string) bool {
}
}

// observe or prepared mode
// observe or prepared mode — skip if single pane mode
if r.PasteMultilineContent != "" {
code, _ := system.HighlightCode("txt", r.PasteMultilineContent)
fmt.Println(code)

isSafe := false
if m.GetPasteMultilineConfirm() {
isSafe, _ = m.confirmedToExec(r.PasteMultilineContent, "Paste multiline content?", false)
if !m.HasExecPane() {
m.Println("No exec pane available. Use --no-exec-pane=false or remove no_exec_pane from config.")
} else {
isSafe = true
}
code, _ := system.HighlightCode("txt", r.PasteMultilineContent)
fmt.Println(code)

if isSafe {
m.Println("Pasting...")
_ = system.TmuxSendCommandToPane(m.ExecPane.Id, r.PasteMultilineContent, true)
time.Sleep(1 * time.Second)
} else {
m.Status = ""
return false
isSafe := false
if m.GetPasteMultilineConfirm() {
isSafe, _ = m.confirmedToExec(r.PasteMultilineContent, "Paste multiline content?", false)
} else {
isSafe = true
}

if isSafe {
m.Println("Pasting...")
_ = system.TmuxSendCommandToPane(m.ExecPane.Id, r.PasteMultilineContent, true)
time.Sleep(1 * time.Second)
} else {
m.Status = ""
return false
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions internal/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,32 @@ I'll wait for it to complete before proceeding.
}
}

func (m *Manager) singlePanePrompt() ChatMessage {
prompt := m.baseSystemPrompt() + `
You are running in SINGLE PANE MODE — there is no exec pane available.
You cannot execute commands, send keystrokes, or paste content.
Your only way to respond is by talking to the user directly.

You can still:
- Answer questions and provide guidance
- Give instructions and explain concepts
- Discuss code, configurations, and solutions
- Provide shell commands for the user to run themselves

When responding:
- Keep responses helpful but concise
- If the user asks you to run something, explain that you are in single-pane mode and provide the command for them to run
- Use <WaitingForUserResponse>1</WaitingForUserResponse> when you have a question or need input
- Use <RequestAccomplished>1</RequestAccomplished> when you have fully answered the user's request
`

return ChatMessage{
Content: prompt,
Timestamp: time.Now(),
FromUser: false,
}
}

func (m *Manager) watchPrompt() ChatMessage {
chatPrompt := fmt.Sprintf(`
%s
Expand Down