Merge branch 'development_decartive' into development_builders

* development_decartive:
  refactor: use osal.processinfo_get for process stats
  feat: add declarative tmux and ttyd management

# Conflicts:
#	lib/osal/tmux/readme.md
This commit is contained in:
2025-08-28 14:26:30 +02:00
8 changed files with 539 additions and 177 deletions

View File

@@ -23,7 +23,12 @@ pub fn play(mut plbook PlayBook) ! {
play_window_delete(mut plbook, mut tmux_instance)!
play_pane_execute(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 {
@@ -43,8 +48,8 @@ fn parse_window_name(name string) !ParsedWindowName {
return error('Window name must be in format "session|window", got: ${name}')
}
return ParsedWindowName{
session: texttools.name_fix(parts[0])
window: texttools.name_fix(parts[1])
session: texttools.name_fix_token(parts[0])
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 ParsedPaneName{
session: texttools.name_fix(parts[0])
window: texttools.name_fix(parts[1])
pane: texttools.name_fix(parts[2])
session: texttools.name_fix_token(parts[0])
window: texttools.name_fix_token(parts[1])
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
}
}
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 is a very capable process manager.
> 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
- window = is typically one process running (you can have panes but in our implementation we skip this)
## structure
tmux library provides functions for managing tmux sessions
@@ -20,72 +18,156 @@ tmux library provides functions for managing tmux sessions
- then windows (is where you see the app running)
- then panes in windows (we don't support yet)
## to attach to a tmux session
> TODO:
>
## HeroScript Declarative Support
The tmux module supports declarative configuration through heroscript, allowing you to define tmux sessions, windows, and panes in a structured way.
## HeroScript Usage Examples
### Running HeroScript
### Imperative
```bash
hero run -p <heroscript_file>
```
action after action builds the output
### Supported Actions
#### Session Management
```heroscript
!!tmux.session_create
// Create a new session
!!tmux.session_create
name:'mysession'
reset:true
reset:true // Optional: delete existing session first
!!tmux.session_delete
// Delete a session
!!tmux.session_delete
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"
cmd:'htop'
env:'VAR1=value1,VAR2=value2'
reset:true
```
!!tmux.window_delete
name:"mysession|mywindow"
#### Pane Management
!!tmux.pane_execute
name:"mysession|mywindow|mypane"
```heroscript
// Execute command in a pane
!!tmux.pane_execute
name:"mysession|mywindow|mypane" // Format: session|window|pane
cmd:'ls -la'
!!tmux.pane_kill
// Kill a pane
!!tmux.pane_kill
name:"mysession|mywindow|mypane"
```
### Declarative
#### Ttyd Management
```heroscript
!!tmux.session_ensure
// Start ttyd for session access
!!tmux.session_ttyd
name:'mysession'
port:8080
editable:true // Optional: allows write access
!!tmux.window_ensure
// Start ttyd for window access
!!tmux.window_ttyd
name:"mysession|mywindow"
cat:"4pane" //we support 16pane, 12pane, 8pane, 6pane, 4pane, 2pane, 1pane
port:8081
editable:false // Optional: read-only access
!!tmux.pane_ensure
name:"mysession|mywindow|1"
label:'ls'
cmd:'ls -la ${HOME}'
// Stop ttyd for session
!!tmux.session_ttyd_stop
name:'mysession'
port:8080
!!tmux.pane_ensure
name:"mysession|mywindow|2"
label:'ps'
cmd:'ps aux'
// Stop ttyd for window
!!tmux.window_ttyd_stop
name:"mysession|mywindow"
port:8081
!!tmux.pane_ensure
name:"mysession|mywindow|3"
label:'echo'
env:'VAR1=value1,VAR2=value2'
cmd:'echo $VAR1'
// Stop all ttyd processes
!!tmux.ttyd_stop_all
```
!!tmux.pane_ensure
name:"mysession|mywindow|4"
label:'htop'
cmd:'htop'
### 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

@@ -3,86 +3,6 @@ module tmux
import freeflowuniverse.herolib.osal.core as osal
import freeflowuniverse.herolib.data.ourtime
import time
// import freeflowuniverse.herolib.session
import os
import freeflowuniverse.herolib.ui.console
// Constants for memory calculations
const kb_to_bytes_factor = 1024
const memory_display_precision = 3
const memory_cache_ttl_seconds = 300 // Cache system memory for 5 minutes
// Global cache for system memory to avoid repeated syscalls
struct MemoryCache {
mut:
total_bytes u64
cached_at time.Time
}
__global (
memory_cache MemoryCache
)
// Platform-specific memory detection
fn get_total_system_memory() !u64 {
$if macos {
result := osal.execute_silent('sysctl -n hw.memsize') or {
return error('Failed to get system memory on macOS: ${err}')
}
return result.trim_space().u64()
} $else $if linux {
// Read from /proc/meminfo
content := os.read_file('/proc/meminfo') or {
return error('Failed to read /proc/meminfo on Linux: ${err}')
}
for line in content.split_into_lines() {
if line.starts_with('MemTotal:') {
parts := line.split_any(' \t').filter(it.len > 0)
if parts.len >= 2 {
kb_value := parts[1].u64()
return kb_value * kb_to_bytes_factor
}
}
}
return error('Could not parse MemTotal from /proc/meminfo')
} $else {
return error('Unsupported platform for memory detection')
}
}
// Get cached or fresh system memory
fn get_system_memory_cached() u64 {
now := time.now()
// Check if cache is valid
if memory_cache.total_bytes > 0
&& now.unix() - memory_cache.cached_at.unix() < memory_cache_ttl_seconds {
return memory_cache.total_bytes
}
// Refresh cache
total_memory := get_total_system_memory() or {
console.print_debug('Failed to get system memory: ${err}')
return 0
}
memory_cache.total_bytes = total_memory
memory_cache.cached_at = now
return total_memory
}
// Calculate accurate memory percentage
fn calculate_memory_percentage(memory_bytes u64, ps_fallback_percent f64) f64 {
total_memory := get_system_memory_cached()
if total_memory > 0 {
return (f64(memory_bytes) / f64(total_memory)) * 100.0
}
// Fallback to ps value if system memory detection fails
return ps_fallback_percent
}
@[heap]
struct Pane {
@@ -106,40 +26,15 @@ pub fn (mut p Pane) stats() !ProcessStats {
}
}
// Use ps command to get CPU and memory stats (cross-platform compatible)
cmd := 'ps -p ${p.pid} -o %cpu,%mem,rss'
result := osal.execute_silent(cmd) or {
// Use ps_tool to get process information
process_info := osal.processinfo_get(p.pid) or {
return error('Cannot get stats for PID ${p.pid}: ${err}')
}
lines := result.split_into_lines()
if lines.len < 2 {
return error('Process ${p.pid} not found')
}
// Skip header line, get data line
data_line := lines[1].trim_space()
if data_line == '' {
return error('Process ${p.pid} not found')
}
parts := data_line.split_any(' \t').filter(it != '')
if parts.len < 3 {
return error('Invalid ps output: ${data_line}')
}
// Parse values from ps output
cpu_percent := parts[0].f64()
ps_memory_percent := parts[1].f64()
memory_bytes := parts[2].u64() * kb_to_bytes_factor
// Calculate accurate memory percentage using cached system memory
memory_percent := calculate_memory_percentage(memory_bytes, ps_memory_percent)
return ProcessStats{
cpu_percent: cpu_percent
memory_percent: memory_percent
memory_bytes: memory_bytes
cpu_percent: f64(process_info.cpu_perc)
memory_percent: f64(process_info.mem_perc)
memory_bytes: u64(process_info.rss * 1024) // rss is in KB, convert to bytes
}
}

View File

@@ -61,16 +61,42 @@ pub fn (mut s Session) scan() ! {
mut current_windows := map[string]bool{}
for line in result.split_into_lines() {
if line.contains('|') {
parts := line.split('|')
line_trimmed := line.trim_space()
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 {
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 {
continue
}
window_id := parts[1].replace('@', '').int()
window_active := parts[2] == '1'
// Safe map assignment
current_windows[window_name] = true
// 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
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)
@@ -217,7 +260,7 @@ pub fn (mut s Session) window_get(args_ WindowGetArgs) !&Window {
if args.name.len == 0 {
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 {
if w.name.len > 0 && w.name == args.name {
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) ! {
// $if debug { console.print_debug(" - window delete: $args_")}
mut args := args_
args.name = texttools.name_fix(args.name)
args.name = texttools.name_fix_token(args.name)
if !(s.window_exist(args)) {
return
}
@@ -284,3 +327,24 @@ pub fn (mut s Session) run_ttyd(args TtydArgs) ! {
pub fn (mut s Session) run_ttyd_readonly(port int) ! {
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 {
// Ignore error if no process is found on the port
// This is normal when no ttyd is running on that port
}
println('ttyd stopped for session ${s.name} on port ${port} (if it was running)')
}
// Stop all ttyd processes (kills all ttyd processes system-wide)
pub fn stop_all_ttyd() ! {
cmd := 'pkill ttyd'
osal.execute_silent(cmd) or {
// Ignore error if no ttyd processes are found (exit code 1)
// This is normal when no ttyd processes are running
}
println('All ttyd processes stopped (if any were running)')
}

View File

@@ -37,11 +37,27 @@ pub fn (mut w Window) scan() ! {
mut current_panes := map[int]bool{}
for line in result.split_into_lines() {
if line.contains('|') {
parts := line.split('|')
if parts.len >= 3 {
pane_id := parts[0].replace('%', '').int()
pane_pid := parts[1].int()
line_trimmed := line.trim_space()
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 {
// 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_cmd := if parts.len > 3 { parts[3] } else { '' }
@@ -77,7 +93,14 @@ pub fn (mut w Window) scan() ! {
}
// 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() ! {
@@ -237,14 +260,8 @@ pub fn (mut w Window) pane_split(args PaneSplitArgs) !&Pane {
w.panes << &new_pane
w.scan()!
// Return reference to the new pane
for mut pane in w.panes {
if pane.id == pane_id {
return pane
}
}
return error('Could not find newly created pane with ID ${pane_id}')
// Return the new pane reference
return &new_pane
}
// Split pane horizontally (side by side)
@@ -289,3 +306,14 @@ pub fn (mut w Window) run_ttyd(args TtydArgs) ! {
pub fn (mut w Window) run_ttyd_readonly(port int) ! {
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 {
// Ignore error if no process is found on the port
// This is normal when no ttyd is running on that port
}
println('ttyd stopped for window ${w.name} on port ${port} (if it was running)')
}