feat: add declarative tmux and ttyd management

- Implement `tmux.pane_split` action
- Add declarative `tmux.session_ttyd` and `tmux.window_ttyd`
- Include `tmux.session_ttyd_stop`, `window_ttyd_stop`, `ttyd_stop_all`
- Update tmux documentation and add usage examples
- Improve robustness of tmux session and window scanning
This commit is contained in:
Mahmoud-Emad
2025-08-28 12:45:46 +03:00
parent 566d871399
commit b6324849a4
7 changed files with 538 additions and 44 deletions

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env hero
// Tmux Cleanup Script - Tears down all tmux sessions, windows, and panes
// Run this after tmux_setup.heroscript to clean up everything
// Kill specific windows first (optional - sessions will kill all windows anyway)
!!tmux.window_delete
name:"dev|editor"
!!tmux.window_delete
name:"dev|server"
!!tmux.window_delete
name:"dev|logs"
!!tmux.window_delete
name:"dev|services"
!!tmux.window_delete
name:"monitoring|htop"
!!tmux.window_delete
name:"monitoring|network"
// Delete all sessions (this will kill all windows and panes within them)
!!tmux.session_delete
name:'dev'
!!tmux.session_delete
name:'monitoring'
// Optional: Kill any remaining panes explicitly (usually not needed after session delete)
!!tmux.pane_kill
name:"dev|editor|main"
!!tmux.pane_kill
name:"dev|server|main"
!!tmux.pane_kill
name:"monitoring|htop|main"
// Stop any remaining ttyd processes system-wide
// This will clean up all ttyd processes regardless of which sessions exist
!!tmux.ttyd_stop_all

View File

@@ -0,0 +1,105 @@
// Create development session
!!tmux.session_create
name:'dev'
reset:true
// Create monitoring session
!!tmux.session_create
name:'monitoring'
reset:true
// Create development windows (use reset to ensure clean state)
!!tmux.window_create
name:"dev|editor"
cmd:'vim'
reset:true
!!tmux.window_create
name:"dev|server"
cmd:'python3 -m http.server 8000'
env:'PORT=8000,DEBUG=true'
reset:true
!!tmux.window_create
name:"dev|logs"
cmd:'tail -f /var/log/system.log'
reset:true
// Create monitoring windows
!!tmux.window_create
name:"monitoring|htop"
cmd:'htop'
reset:true
!!tmux.window_create
name:"monitoring|network"
cmd:'netstat -tuln'
reset:true
// Create a multi-service window with 4 panes
!!tmux.window_create
name:"dev|services"
cmd:'htop'
reset:true
// Split the services window into 4 panes (1 initial + 3 splits = 4 total)
!!tmux.pane_split
name:"dev|services"
cmd:'python3 -m http.server 3000'
horizontal:true
!!tmux.pane_split
name:"dev|services"
cmd:'watch -n 1 ps aux'
horizontal:false
!!tmux.pane_split
name:"dev|services"
cmd:'tail -f /var/log/system.log'
horizontal:true
!!tmux.pane_split
name:"dev|services"
cmd:'echo Fourth pane ready for commands'
horizontal:false
// Execute welcome commands in panes
!!tmux.pane_execute
name:"dev|editor|main"
cmd:'echo Welcome to the editor pane'
!!tmux.pane_execute
name:"dev|server|main"
cmd:'echo Starting development server'
!!tmux.pane_execute
name:"monitoring|htop|main"
cmd:'echo System monitoring active'
// Split panes for better workflow
!!tmux.pane_split
name:"dev|editor"
cmd:'echo Split pane for terminal'
horizontal:false
!!tmux.pane_split
name:"monitoring|htop"
cmd:'watch -n 1 df -h'
horizontal:true
// Expose sessions and windows via web browser using ttyd
!!tmux.session_ttyd
name:'dev'
port:8080
editable:true
!!tmux.window_ttyd
name:"monitoring|htop"
port:8081
editable:false
// Expose the 4-pane services window via web
!!tmux.window_ttyd
name:"dev|services"
port:7681
editable:false

View File

@@ -7,6 +7,7 @@ import freeflowuniverse.herolib.web.site
import freeflowuniverse.herolib.web.docusaurus import freeflowuniverse.herolib.web.docusaurus
import freeflowuniverse.herolib.clients.openai import freeflowuniverse.herolib.clients.openai
import freeflowuniverse.herolib.clients.giteaclient import freeflowuniverse.herolib.clients.giteaclient
import freeflowuniverse.herolib.osal.tmux
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// run entry point for all HeroScript playcommands // run entry point for all HeroScript playcommands
@@ -39,6 +40,9 @@ pub fn run(args_ PlayArgs) ! {
// Git actions // Git actions
play_git(mut plbook)! play_git(mut plbook)!
// Tmux actions
tmux.play(mut plbook)!
// Business model (e.g. currency, bizmodel) // Business model (e.g. currency, bizmodel)
bizmodel.play(mut plbook)! bizmodel.play(mut plbook)!

View File

@@ -23,7 +23,12 @@ pub fn play(mut plbook PlayBook) ! {
play_window_delete(mut plbook, mut tmux_instance)! play_window_delete(mut plbook, mut tmux_instance)!
play_pane_execute(mut plbook, mut tmux_instance)! play_pane_execute(mut plbook, mut tmux_instance)!
play_pane_kill(mut plbook, mut tmux_instance)! play_pane_kill(mut plbook, mut tmux_instance)!
// TODO: Implement pane_create, pane_delete, pane_split when pane API is extended play_pane_split(mut plbook, mut tmux_instance)!
play_session_ttyd(mut plbook, mut tmux_instance)!
play_window_ttyd(mut plbook, mut tmux_instance)!
play_session_ttyd_stop(mut plbook, mut tmux_instance)!
play_window_ttyd_stop(mut plbook, mut tmux_instance)!
play_ttyd_stop_all(mut plbook, mut tmux_instance)!
} }
struct ParsedWindowName { struct ParsedWindowName {
@@ -43,8 +48,8 @@ fn parse_window_name(name string) !ParsedWindowName {
return error('Window name must be in format "session|window", got: ${name}') return error('Window name must be in format "session|window", got: ${name}')
} }
return ParsedWindowName{ return ParsedWindowName{
session: texttools.name_fix(parts[0]) session: texttools.name_fix_token(parts[0])
window: texttools.name_fix(parts[1]) window: texttools.name_fix_token(parts[1])
} }
} }
@@ -54,9 +59,9 @@ fn parse_pane_name(name string) !ParsedPaneName {
return error('Pane name must be in format "session|window|pane", got: ${name}') return error('Pane name must be in format "session|window|pane", got: ${name}')
} }
return ParsedPaneName{ return ParsedPaneName{
session: texttools.name_fix(parts[0]) session: texttools.name_fix_token(parts[0])
window: texttools.name_fix(parts[1]) window: texttools.name_fix_token(parts[1])
pane: texttools.name_fix(parts[2]) pane: texttools.name_fix_token(parts[2])
} }
} }
@@ -197,3 +202,138 @@ fn play_pane_kill(mut plbook PlayBook, mut tmux_instance Tmux) ! {
action.done = true action.done = true
} }
} }
fn play_pane_split(mut plbook PlayBook, mut tmux_instance Tmux) ! {
mut actions := plbook.find(filter: 'tmux.pane_split')!
for mut action in actions {
mut p := action.params
name := p.get('name')!
cmd := p.get_default('cmd', '')!
horizontal := p.get_default_false('horizontal')
parsed := parse_window_name(name)!
// Parse environment variables if provided
mut env := map[string]string{}
if env_str := p.get_default('env', '') {
env_pairs := env_str.split(',')
for pair in env_pairs {
kv := pair.split('=')
if kv.len == 2 {
env[kv[0].trim_space()] = kv[1].trim_space()
}
}
}
// Find the session and window
if tmux_instance.session_exist(parsed.session) {
mut session := tmux_instance.session_get(parsed.session)!
if session.window_exist(name: parsed.window) {
mut window := session.window_get(name: parsed.window)!
// Split the pane
window.pane_split(
cmd: cmd
horizontal: horizontal
env: env
)!
}
}
action.done = true
}
}
fn play_session_ttyd(mut plbook PlayBook, mut tmux_instance Tmux) ! {
mut actions := plbook.find(filter: 'tmux.session_ttyd')!
for mut action in actions {
mut p := action.params
session_name := p.get('name')!
port := p.get_int('port')!
editable := p.get_default_false('editable')
if tmux_instance.session_exist(session_name) {
mut session := tmux_instance.session_get(session_name)!
session.run_ttyd(
port: port
editable: editable
)!
}
action.done = true
}
}
fn play_window_ttyd(mut plbook PlayBook, mut tmux_instance Tmux) ! {
mut actions := plbook.find(filter: 'tmux.window_ttyd')!
for mut action in actions {
mut p := action.params
name := p.get('name')!
port := p.get_int('port')!
editable := p.get_default_false('editable')
parsed := parse_window_name(name)!
if tmux_instance.session_exist(parsed.session) {
mut session := tmux_instance.session_get(parsed.session)!
if session.window_exist(name: parsed.window) {
mut window := session.window_get(name: parsed.window)!
window.run_ttyd(
port: port
editable: editable
)!
}
}
action.done = true
}
}
// Handle tmux.session_ttyd_stop actions
fn play_session_ttyd_stop(mut plbook PlayBook, mut tmux_instance Tmux) ! {
for mut action in plbook.find(filter: 'tmux.session_ttyd_stop')! {
if action.done {
continue
}
mut p := action.params
session_name := p.get('name')!
port := p.get_int('port')!
mut session := tmux_instance.session_get(session_name)!
session.stop_ttyd(port)!
action.done = true
}
}
// Handle tmux.window_ttyd_stop actions
fn play_window_ttyd_stop(mut plbook PlayBook, mut tmux_instance Tmux) ! {
for mut action in plbook.find(filter: 'tmux.window_ttyd_stop')! {
if action.done {
continue
}
mut p := action.params
name := p.get('name')!
port := p.get_int('port')!
parsed := parse_window_name(name)!
mut session := tmux_instance.session_get(parsed.session)!
mut window := session.window_get(name: parsed.window)!
window.stop_ttyd(port)!
action.done = true
}
}
// Handle tmux.ttyd_stop_all actions
fn play_ttyd_stop_all(mut plbook PlayBook, mut tmux_instance Tmux) ! {
for mut action in plbook.find(filter: 'tmux.ttyd_stop_all')! {
if action.done {
continue
}
stop_all_ttyd()!
action.done = true
}
}

View File

@@ -1,6 +1,5 @@
# TMUX # TMUX
TMUX is a very capable process manager. TMUX is a very capable process manager.
> TODO: TTYD, need to integrate with TMUX for exposing TMUX over http > TODO: TTYD, need to integrate with TMUX for exposing TMUX over http
@@ -11,7 +10,6 @@ TMUX is a very capable process manager.
- session = is a set of windows, it has a name and groups windows - session = is a set of windows, it has a name and groups windows
- window = is typically one process running (you can have panes but in our implementation we skip this) - window = is typically one process running (you can have panes but in our implementation we skip this)
## structure ## structure
tmux library provides functions for managing tmux sessions tmux library provides functions for managing tmux sessions
@@ -20,33 +18,156 @@ tmux library provides functions for managing tmux sessions
- then windows (is where you see the app running) - then windows (is where you see the app running)
- then panes in windows (we don't support yet) - then panes in windows (we don't support yet)
## to attach to a tmux session ## to attach to a tmux session
> TODO: >
## HeroScript Usage Examples ## HeroScript Declarative Support
The tmux module supports declarative configuration through heroscript, allowing you to define tmux sessions, windows, and panes in a structured way.
### Running HeroScript
```bash
hero run -p <heroscript_file>
```
### Supported Actions
#### Session Management
```heroscript ```heroscript
!!tmux.session_create // Create a new session
!!tmux.session_create
name:'mysession' name:'mysession'
reset:true reset:true // Optional: delete existing session first
!!tmux.session_delete // Delete a session
!!tmux.session_delete
name:'mysession' name:'mysession'
```
!!tmux.window_create #### Window Management
```heroscript
// Create a new window
!!tmux.window_create
name:"mysession|mywindow" // Format: session|window
cmd:'htop' // Optional: command to run
env:'VAR1=value1,VAR2=value2' // Optional: environment variables
reset:true // Optional: recreate if exists
// Delete a window
!!tmux.window_delete
name:"mysession|mywindow" name:"mysession|mywindow"
cmd:'htop' ```
env:'VAR1=value1,VAR2=value2'
reset:true
!!tmux.window_delete #### Pane Management
name:"mysession|mywindow"
!!tmux.pane_execute ```heroscript
name:"mysession|mywindow|mypane" // Execute command in a pane
!!tmux.pane_execute
name:"mysession|mywindow|mypane" // Format: session|window|pane
cmd:'ls -la' cmd:'ls -la'
!!tmux.pane_kill // Kill a pane
!!tmux.pane_kill
name:"mysession|mywindow|mypane" name:"mysession|mywindow|mypane"
``` ```
#### Ttyd Management
```heroscript
// Start ttyd for session access
!!tmux.session_ttyd
name:'mysession'
port:8080
editable:true // Optional: allows write access
// Start ttyd for window access
!!tmux.window_ttyd
name:"mysession|mywindow"
port:8081
editable:false // Optional: read-only access
// Stop ttyd for session
!!tmux.session_ttyd_stop
name:'mysession'
port:8080
// Stop ttyd for window
!!tmux.window_ttyd_stop
name:"mysession|mywindow"
port:8081
// Stop all ttyd processes
!!tmux.ttyd_stop_all
```
### Complete Example
```heroscript
#!/usr/bin/env hero
// Create development environment
!!tmux.session_create
name:'dev'
reset:true
!!tmux.window_create
name:"dev|editor"
cmd:'vim'
reset:true
!!tmux.window_create
name:"dev|server"
cmd:'python3 -m http.server 8000'
env:'PORT=8000,DEBUG=true'
reset:true
!!tmux.pane_execute
name:"dev|editor|main"
cmd:'echo "Welcome to development!"'
```
### Naming Convention
- **Sessions**: Simple names like `dev`, `monitoring`, `main`
- **Windows**: Use pipe separator: `session|window` (e.g., `dev|editor`)
- **Panes**: Use pipe separator: `session|window|pane` (e.g., `dev|editor|main`)
Names are automatically normalized using `texttools.name_fix()` for consistency.
## Example Usage
### Setup and Cleanup Scripts
Two example heroscripts are provided to demonstrate complete tmux environment management:
#### 1. Setup Script (`tmux_setup.heroscript`)
Creates a complete development environment with multiple sessions and windows:
```bash
hero run examples/tmux/tmux_setup.heroscript
```
This creates:
- **dev session** with editor, server, logs, and a 4-pane services window
- **monitoring session** with htop and network monitoring windows
- **Web access** via ttyd on ports 8080, 8081, and 7681
#### 2. Cleanup Script (`tmux_cleanup.heroscript`)
Tears down all created tmux resources:
```bash
hero run examples/tmux/tmux_cleanup.heroscript
```
This removes:
- All windows from both sessions
- Both dev and monitoring sessions
- All associated panes
- All ttyd web processes (ports 8080, 8081, 7681)

View File

@@ -61,16 +61,42 @@ pub fn (mut s Session) scan() ! {
mut current_windows := map[string]bool{} mut current_windows := map[string]bool{}
for line in result.split_into_lines() { for line in result.split_into_lines() {
if line.contains('|') { line_trimmed := line.trim_space()
parts := line.split('|') if line_trimmed.len == 0 {
continue
}
if line_trimmed.contains('|') {
parts := line_trimmed.split('|')
if parts.len >= 3 && parts[0].len > 0 && parts[1].len > 0 { if parts.len >= 3 && parts[0].len > 0 && parts[1].len > 0 {
window_name := texttools.name_fix(parts[0]) // Safely extract window name with additional validation
raw_window_name := parts[0].trim_space()
if raw_window_name.len == 0 {
continue
}
// Use safer name processing instead of texttools.name_fix
mut window_name := raw_window_name.to_lower().trim_space()
// Replace problematic characters with underscores
window_name = window_name.replace(' ', '_').replace('-', '_').replace('.',
'_')
// Remove any non-ASCII characters safely
mut safe_name := ''
for c in window_name {
if c.is_letter() || c.is_digit() || c == `_` {
safe_name += c.ascii_str()
}
}
window_name = safe_name
if window_name.len == 0 { if window_name.len == 0 {
continue continue
} }
window_id := parts[1].replace('@', '').int() window_id := parts[1].replace('@', '').int()
window_active := parts[2] == '1' window_active := parts[2] == '1'
// Safe map assignment
current_windows[window_name] = true current_windows[window_name] = true
// Update existing window or create new one // Update existing window or create new one
@@ -102,7 +128,24 @@ pub fn (mut s Session) scan() ! {
} }
// Remove windows that no longer exist in tmux // Remove windows that no longer exist in tmux
s.windows = s.windows.filter(it.name.len > 0 && current_windows[it.name] == true) mut valid_windows := []&Window{}
for window in s.windows {
// Safety check: ensure window.name is valid
if window.name.len > 0 {
// Avoid map access entirely - check if window still exists by comparing with current windows
mut window_exists := false
for current_name, _ in current_windows {
if window.name == current_name {
window_exists = true
break
}
}
if window_exists {
valid_windows << window
}
}
}
s.windows = valid_windows
} }
// window_name is the name of the window in session main (will always be called session main) // window_name is the name of the window in session main (will always be called session main)
@@ -217,7 +260,7 @@ pub fn (mut s Session) window_get(args_ WindowGetArgs) !&Window {
if args.name.len == 0 { if args.name.len == 0 {
return error('Window name cannot be empty') return error('Window name cannot be empty')
} }
args.name = texttools.name_fix(args.name) args.name = texttools.name_fix_token(args.name)
for w in s.windows { for w in s.windows {
if w.name.len > 0 && w.name == args.name { if w.name.len > 0 && w.name == args.name {
if (args.id > 0 && w.id == args.id) || args.id == 0 { if (args.id > 0 && w.id == args.id) || args.id == 0 {
@@ -231,7 +274,7 @@ pub fn (mut s Session) window_get(args_ WindowGetArgs) !&Window {
pub fn (mut s Session) window_delete(args_ WindowGetArgs) ! { pub fn (mut s Session) window_delete(args_ WindowGetArgs) ! {
// $if debug { console.print_debug(" - window delete: $args_")} // $if debug { console.print_debug(" - window delete: $args_")}
mut args := args_ mut args := args_
args.name = texttools.name_fix(args.name) args.name = texttools.name_fix_token(args.name)
if !(s.window_exist(args)) { if !(s.window_exist(args)) {
return return
} }
@@ -284,3 +327,16 @@ pub fn (mut s Session) run_ttyd(args TtydArgs) ! {
pub fn (mut s Session) run_ttyd_readonly(port int) ! { pub fn (mut s Session) run_ttyd_readonly(port int) ! {
s.run_ttyd(port: port, editable: false)! s.run_ttyd(port: port, editable: false)!
} }
// Stop ttyd for this session by killing the process on the specified port
pub fn (mut s Session) stop_ttyd(port int) ! {
// Kill any process running on the specified port
cmd := 'lsof -ti:${port} | xargs kill -9'
osal.execute_silent(cmd) or { return error("Can't stop ttyd on port ${port}: ${err}") }
}
// Stop all ttyd processes (kills all ttyd processes system-wide)
pub fn stop_all_ttyd() ! {
cmd := 'pkill ttyd'
osal.execute_silent(cmd) or { return error("Can't stop all ttyd processes: ${err}") }
}

View File

@@ -37,11 +37,27 @@ pub fn (mut w Window) scan() ! {
mut current_panes := map[int]bool{} mut current_panes := map[int]bool{}
for line in result.split_into_lines() { for line in result.split_into_lines() {
if line.contains('|') { line_trimmed := line.trim_space()
parts := line.split('|') if line_trimmed.len == 0 {
if parts.len >= 3 { continue
pane_id := parts[0].replace('%', '').int() }
pane_pid := parts[1].int() if line_trimmed.contains('|') {
parts := line_trimmed.split('|')
if parts.len >= 3 && parts[0].len > 0 && parts[1].len > 0 {
// Safely parse pane ID
pane_id_str := parts[0].replace('%', '').trim_space()
if pane_id_str.len == 0 {
continue
}
pane_id := pane_id_str.int()
// Safely parse PID
pane_pid_str := parts[1].trim_space()
if pane_pid_str.len == 0 {
continue
}
pane_pid := pane_pid_str.int()
pane_active := parts[2] == '1' pane_active := parts[2] == '1'
pane_cmd := if parts.len > 3 { parts[3] } else { '' } pane_cmd := if parts.len > 3 { parts[3] } else { '' }
@@ -77,7 +93,14 @@ pub fn (mut w Window) scan() ! {
} }
// Remove panes that no longer exist // Remove panes that no longer exist
w.panes = w.panes.filter(current_panes[it.id] == true) mut valid_panes := []&Pane{}
for pane in w.panes {
// Use safe map access with 'in' operator first
if pane.id in current_panes && current_panes[pane.id] == true {
valid_panes << pane
}
}
w.panes = valid_panes
} }
pub fn (mut w Window) stop() ! { pub fn (mut w Window) stop() ! {
@@ -237,14 +260,8 @@ pub fn (mut w Window) pane_split(args PaneSplitArgs) !&Pane {
w.panes << &new_pane w.panes << &new_pane
w.scan()! w.scan()!
// Return reference to the new pane // Return the new pane reference
for mut pane in w.panes { return &new_pane
if pane.id == pane_id {
return pane
}
}
return error('Could not find newly created pane with ID ${pane_id}')
} }
// Split pane horizontally (side by side) // Split pane horizontally (side by side)
@@ -289,3 +306,10 @@ pub fn (mut w Window) run_ttyd(args TtydArgs) ! {
pub fn (mut w Window) run_ttyd_readonly(port int) ! { pub fn (mut w Window) run_ttyd_readonly(port int) ! {
w.run_ttyd(port: port, editable: false)! w.run_ttyd(port: port, editable: false)!
} }
// Stop ttyd for this window by killing the process on the specified port
pub fn (mut w Window) stop_ttyd(port int) ! {
// Kill any process running on the specified port
cmd := 'lsof -ti:${port} | xargs kill -9'
osal.execute_silent(cmd) or { return error("Can't stop ttyd on port ${port}: ${err}") }
}