Files
herolib/lib/osal/tmux/tmux_pane.v
2025-10-12 12:30:19 +03:00

718 lines
22 KiB
V

module tmux
import incubaid.herolib.osal.core as osal
import incubaid.herolib.data.ourtime
import incubaid.herolib.ui.console
import time
import os
@[heap]
pub struct Pane {
pub mut:
window &Window @[str: skip]
id int // pane id (e.g., %1, %2)
pid int // process id
active bool // is this the active pane
cmd string // command running in pane
env map[string]string
created_at time.Time
last_output_offset int // for tracking new logs
// Logging fields
log_enabled bool // whether logging is enabled for this pane
log_path string // path where logs are stored
logger_pid int // process id of the logger process
}
pub fn (mut p Pane) stats() !ProcessStats {
if p.pid == 0 {
return ProcessStats{
cpu_percent: 0.0
memory_percent: 0.0
memory_bytes: 0
}
}
// 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}')
}
return ProcessStats{
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
}
}
pub struct TMuxLogEntry {
pub mut:
content string
timestamp time.Time
offset int
}
pub struct LogsGetArgs {
pub mut:
reset bool
}
// get new logs since last call
pub fn (mut p Pane) logs_get_new(args LogsGetArgs) ![]TMuxLogEntry {
if args.reset {
p.last_output_offset = 0
}
// Capture pane content with line numbers
cmd := 'tmux capture-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} -S ${p.last_output_offset} -p'
result := osal.execute_silent(cmd) or { return error('Cannot capture pane output: ${err}') }
lines := result.split_into_lines()
mut entries := []TMuxLogEntry{}
mut i := 0
for line in lines {
if line.trim_space() != '' {
entries << TMuxLogEntry{
content: line
timestamp: time.now()
offset: p.last_output_offset + i + 1
}
}
}
// Update offset to avoid duplicates next time
if entries.len > 0 {
p.last_output_offset = entries.last().offset
}
return entries
}
pub fn (mut p Pane) exit_status() !ProcessStatus {
// Get the last few lines to see if there's an exit status
logs := p.logs_all()!
lines := logs.split_into_lines()
// Look for shell prompt indicating command finished
for line in lines.reverse() {
line_clean := line.trim_space()
if line_clean.contains('$') || line_clean.contains('#') || line_clean.contains('>') {
// Found shell prompt, command likely finished
// Could also check for specific exit codes in history
return .finished_ok
}
}
return .finished_error
}
pub fn (mut p Pane) logs_all() !string {
cmd := 'tmux capture-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} -S -2000 -p'
return osal.execute_silent(cmd) or { error('Cannot capture pane output: ${err}') }
}
// Fix the output_wait method to use correct method name
pub fn (mut p Pane) output_wait(c_ string, timeoutsec int) ! {
mut t := ourtime.now()
start := t.unix()
c := c_.replace('\n', '')
for _ in 0 .. 2000 {
entries := p.logs_get_new(reset: false)!
for entry in entries {
if entry.content.replace('\n', '').contains(c) {
return
}
}
mut t2 := ourtime.now()
if t2.unix() > start + timeoutsec {
return error('timeout on output wait for tmux.\n${p} .\nwaiting for:\n${c}')
}
time.sleep(100 * time.millisecond)
}
}
// Get process information for this pane and all its children
pub fn (mut p Pane) processinfo() !osal.ProcessMap {
if p.pid == 0 {
return error('Pane has no associated process (pid is 0)')
}
return osal.processinfo_with_children(p.pid)!
}
// Get process information for just this pane's main process
pub fn (mut p Pane) processinfo_main() !osal.ProcessInfo {
if p.pid == 0 {
return error('Pane has no associated process (pid is 0)')
}
return osal.processinfo_get(p.pid)!
}
// Send a command to this pane
// Supports both single-line and multi-line commands
pub fn (mut p Pane) send_command(command string) ! {
// Check if command contains multiple lines
if command.contains('\n') {
// Multi-line command - create temporary script
p.send_multiline_command(command)!
} else {
// Single-line command - send directly
cmd := 'tmux send-keys -t ${p.window.session.name}:@${p.window.id}.%${p.id} "${command}" Enter'
osal.execute_silent(cmd) or { return error('Cannot send command to pane %${p.id}: ${err}') }
}
}
// Send command with declarative mode logic (intelligent state management)
// This method implements the full declarative logic:
// 1. Check if pane has previous command (Redis lookup)
// 2. If previous command exists:
// a. Check if still running (process verification)
// b. Compare MD5 hashes
// c. If different command OR not running: proceed
// d. If same command AND running: skip
// 3. If proceeding: kill existing processes, then start new command
pub fn (mut p Pane) send_command_declarative(command string) ! {
console.print_debug('Declarative command for pane ${p.id}: ${command[..if command.len > 50 {
50
} else {
command.len
}]}...')
// Step 1: Check if command has changed
command_changed := p.has_command_changed(command)
// Step 2: Check if stored command is still running
stored_running := p.is_stored_command_running()
// Step 3: Decide whether to proceed
should_execute := command_changed || !stored_running
if !should_execute {
console.print_debug('Skipping command execution for pane ${p.id}: same command already running')
return
}
// Step 4: If we have a running command that needs to be replaced, kill it
if stored_running && command_changed {
console.print_debug('Killing existing command in pane ${p.id} before starting new one')
p.kill_running_command()!
// Give processes time to die
time.sleep(500 * time.millisecond)
}
// Step 5: Ensure bash is the parent process
p.ensure_bash_parent()!
// Step 6: Reset pane if it appears empty or needs cleanup
p.reset_if_needed()!
// Step 7: Execute the new command
p.send_command(command)!
// Step 8: Store the new command state
// Get the PID of the command we just started (this is approximate)
time.sleep(100 * time.millisecond) // Give command time to start
p.store_command_state(command, 'running', p.pid)!
console.print_debug('Successfully executed declarative command for pane ${p.id}')
}
// Kill the currently running command in this pane
pub fn (mut p Pane) kill_running_command() ! {
stored_state := p.get_command_state() or { return }
if stored_state.pid > 0 && osal.process_exists(stored_state.pid) {
// Kill the process and its children
osal.process_kill_recursive(pid: stored_state.pid)!
console.print_debug('Killed running command (PID: ${stored_state.pid}) in pane ${p.id}')
}
// Also try to kill any processes that might be running in the pane
p.kill_pane_process_group()!
// Update the command state to reflect that it's no longer running
p.update_command_status('killed')!
}
// Reset pane if it appears empty or needs cleanup
pub fn (mut p Pane) reset_if_needed() ! {
if p.is_pane_empty()! {
console.print_debug('Pane ${p.id} appears empty, sending reset')
p.send_reset()!
return
}
if !p.is_at_clean_prompt()! {
console.print_debug('Pane ${p.id} not at clean prompt, sending reset')
p.send_reset()!
}
}
// Check if pane is completely empty
pub fn (mut p Pane) is_pane_empty() !bool {
logs := p.logs_all() or { return true }
lines := logs.split_into_lines()
// Filter out empty lines
mut non_empty_lines := []string{}
for line in lines {
if line.trim_space().len > 0 {
non_empty_lines << line
}
}
return non_empty_lines.len == 0
}
// Check if pane is at a clean shell prompt
pub fn (mut p Pane) is_at_clean_prompt() !bool {
logs := p.logs_all() or { return false }
lines := logs.split_into_lines()
if lines.len == 0 {
return false
}
// Check last few lines for shell prompt indicators
check_lines := if lines.len > 5 { lines[lines.len - 5..] } else { lines }
for line in check_lines.reverse() {
line_clean := line.trim_space()
if line_clean.len == 0 {
continue
}
// Look for common shell prompt patterns
if line_clean.ends_with('$ ') || line_clean.ends_with('# ') || line_clean.ends_with('> ')
|| line_clean.ends_with('$') || line_clean.ends_with('#') || line_clean.ends_with('>') {
console.print_debug('Found clean prompt in pane ${p.id}: "${line_clean}"')
return true
}
// If we find a non-prompt line, we're not at a clean prompt
break
}
return false
}
// Send reset command to pane
pub fn (mut p Pane) send_reset() ! {
cmd := 'tmux send-keys -t ${p.window.session.name}:@${p.window.id}.%${p.id} "reset" Enter'
osal.execute_silent(cmd) or { return error('Cannot send reset to pane %${p.id}: ${err}') }
console.print_debug('Sent reset command to pane ${p.id}')
// Give reset time to complete
time.sleep(200 * time.millisecond)
}
// Verify that bash is the first process in this pane
pub fn (mut p Pane) verify_bash_parent() !bool {
if p.pid <= 0 {
return false
}
// Get process information for the pane's main process
proc_info := osal.processinfo_get(p.pid) or { return false }
// Check if the process command contains bash
if proc_info.cmd.contains('bash') || proc_info.cmd.contains('/bin/bash')
|| proc_info.cmd.contains('/usr/bin/bash') {
console.print_debug('Pane ${p.id} has bash as parent process (PID: ${p.pid})')
return true
}
console.print_debug('Pane ${p.id} does NOT have bash as parent process. Current: ${proc_info.cmd}')
return false
}
// Ensure bash is the first process in the pane
pub fn (mut p Pane) ensure_bash_parent() ! {
if p.verify_bash_parent()! {
return
}
console.print_debug('Ensuring bash is parent process for pane ${p.id}')
// Kill any existing processes in the pane
p.kill_pane_process_group()!
// Send a new bash command to establish bash as the parent
cmd := 'tmux send-keys -t ${p.window.session.name}:@${p.window.id}.%${p.id} "exec bash" Enter'
osal.execute_silent(cmd) or { return error('Cannot start bash in pane %${p.id}: ${err}') }
// Give bash time to start
time.sleep(500 * time.millisecond)
// Update pane information
p.window.scan()!
// Verify bash is now running
if !p.verify_bash_parent()! {
return error('Failed to establish bash as parent process in pane ${p.id}')
}
console.print_debug('Successfully established bash as parent process for pane ${p.id}')
}
// Get all child processes of this pane's main process
pub fn (mut p Pane) get_child_processes() ![]osal.ProcessInfo {
if p.pid <= 0 {
return []osal.ProcessInfo{}
}
children_map := osal.processinfo_children(p.pid)!
return children_map.processes
}
// Check if commands are running as children of bash
pub fn (mut p Pane) verify_command_hierarchy() !bool {
// First verify bash is the parent
if !p.verify_bash_parent()! {
return false
}
// Get child processes
children := p.get_child_processes()!
if children.len == 0 {
// No child processes, which is fine
return true
}
// Check if child processes have bash as their parent
for child in children {
if child.ppid != p.pid {
console.print_debug('Child process ${child.pid} (${child.cmd}) does not have pane process as parent')
return false
}
}
console.print_debug('Command hierarchy verified for pane ${p.id}: ${children.len} child processes')
return true
}
// Handle multi-line commands by creating a temporary script
fn (mut p Pane) send_multiline_command(command string) ! {
// Create temporary directory for tmux scripts
script_dir := '/tmp/tmux/${p.window.session.name}'
os.mkdir_all(script_dir) or { return error('Cannot create script directory: ${err}') }
// Create unique script file for this pane
script_path := '${script_dir}/pane_${p.id}_script.sh'
// Prepare script content with proper shebang and commands
script_content := '#!/bin/bash\n' + command.trim_space()
// Write script to file
os.write_file(script_path, script_content) or {
return error('Cannot write script file ${script_path}: ${err}')
}
// Make script executable
os.chmod(script_path, 0o755) or {
return error('Cannot make script executable ${script_path}: ${err}')
}
// Execute the script in the pane
cmd := 'tmux send-keys -t ${p.window.session.name}:@${p.window.id}.%${p.id} "${script_path}" Enter'
osal.execute_silent(cmd) or { return error('Cannot execute script in pane %${p.id}: ${err}') }
// Optional: Clean up script after a delay (commented out for debugging)
// spawn {
// time.sleep(5 * time.second)
// os.rm(script_path) or {}
// }
}
// Send raw keys to this pane (without Enter)
pub fn (mut p Pane) send_keys(keys string) ! {
cmd := 'tmux send-keys -t ${p.window.session.name}:@${p.window.id}.%${p.id} "${keys}"'
osal.execute_silent(cmd) or { return error('Cannot send keys to pane %${p.id}: ${err}') }
}
// Kill this specific pane with comprehensive process cleanup
pub fn (mut p Pane) kill() ! {
// First, disable logging if enabled
if p.log_enabled {
p.logging_disable() or {
console.print_debug('Warning: Failed to disable logging for pane %${p.id}: ${err}')
}
}
// Then, kill all processes running in this pane
p.kill_processes()!
// Finally, kill the tmux pane itself
cmd := 'tmux kill-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id}'
osal.execute_silent(cmd) or { return error('Cannot kill pane %${p.id}: ${err}') }
}
// Kill all processes associated with this pane (main process and all children)
pub fn (mut p Pane) kill_processes() ! {
if p.pid == 0 {
console.print_debug('Pane %${p.id} has no associated process (pid is 0)')
return
}
console.print_debug('Killing all processes for pane %${p.id} (main PID: ${p.pid})')
// Use the recursive process killer to terminate the main process and all its children
osal.process_kill_recursive(pid: p.pid) or {
console.print_debug('Failed to kill processes for pane %${p.id}: ${err}')
// Continue anyway - the process might already be dead
}
// Also try to kill any processes that might be running in the pane's process group
// This handles cases where processes might have detached from the main process tree
p.kill_pane_process_group()!
}
// Kill processes in the pane's process group (fallback cleanup)
fn (mut p Pane) kill_pane_process_group() ! {
// Get all processes and find ones that might be related to this pane
_ := osal.processmap_get() or {
console.print_debug('Could not get process map for pane cleanup')
return
}
// Look for processes that might be children of the pane's shell
// or processes running commands that were sent to this pane
mut pane_processes := []int{}
// First, collect the main process and its direct children
if p.pid > 0 && osal.process_exists(p.pid) {
children_map := osal.processinfo_children(p.pid) or {
console.print_debug('Could not get children for PID ${p.pid}')
return
}
for child in children_map.processes {
pane_processes << child.pid
}
}
// Kill any remaining processes with SIGTERM first, then SIGKILL
for pid in pane_processes {
if osal.process_exists(pid) {
// Try SIGTERM first (graceful shutdown)
osal.execute_silent('kill -TERM ${pid}') or {
console.print_debug('Could not send SIGTERM to PID ${pid}')
}
}
}
// Wait a moment for graceful shutdown
time.sleep(500 * time.millisecond)
// Force kill any remaining processes with SIGKILL
for pid in pane_processes {
if osal.process_exists(pid) {
osal.execute_silent('kill -KILL ${pid}') or {
console.print_debug('Could not send SIGKILL to PID ${pid}')
}
}
}
}
// Select/activate this pane
pub fn (mut p Pane) select() ! {
cmd := 'tmux select-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id}'
osal.execute_silent(cmd) or { return error('Cannot select pane %${p.id}: ${err}') }
p.active = true
}
@[params]
pub struct PaneResizeArgs {
pub mut:
direction string = 'right' // 'up', 'down', 'left', 'right'
cells int = 5 // number of cells to resize by
}
// Resize this pane
pub fn (mut p Pane) resize(args PaneResizeArgs) ! {
direction_flag := match args.direction.to_lower() {
'up', 'u' { '-U' }
'down', 'd' { '-D' }
'left', 'l' { '-L' }
'right', 'r' { '-R' }
else { return error('Invalid resize direction: ${args.direction}. Use up, down, left, or right') }
}
cmd := 'tmux resize-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} ${direction_flag} ${args.cells}'
osal.execute_silent(cmd) or { return error('Cannot resize pane %${p.id}: ${err}') }
}
// Convenience methods for resizing
pub fn (mut p Pane) resize_up(cells int) ! {
p.resize(direction: 'up', cells: cells)!
}
pub fn (mut p Pane) resize_down(cells int) ! {
p.resize(direction: 'down', cells: cells)!
}
pub fn (mut p Pane) resize_left(cells int) ! {
p.resize(direction: 'left', cells: cells)!
}
pub fn (mut p Pane) resize_right(cells int) ! {
p.resize(direction: 'right', cells: cells)!
}
// Get current pane width
pub fn (p Pane) get_width() !int {
cmd := 'tmux display-message -t ${p.window.session.name}:@${p.window.id}.%${p.id} -p "#{pane_width}"'
res := osal.exec(cmd: cmd, stdout: false, name: 'tmux_get_pane_width') or {
return error("Can't get pane width: ${err}")
}
return res.output.trim_space().int()
}
// Get current pane height
pub fn (p Pane) get_height() !int {
cmd := 'tmux display-message -t ${p.window.session.name}:@${p.window.id}.%${p.id} -p "#{pane_height}"'
res := osal.exec(cmd: cmd, stdout: false, name: 'tmux_get_pane_height') or {
return error("Can't get pane height: ${err}")
}
return res.output.trim_space().int()
}
@[params]
pub struct PaneLoggingEnableArgs {
pub mut:
logpath string // custom log path, if empty uses default
logreset bool // whether to reset/clear existing logs
}
// Enable logging for this pane
pub fn (mut p Pane) logging_enable(args PaneLoggingEnableArgs) ! {
if p.log_enabled {
return error('Logging is already enabled for pane %${p.id}')
}
// Determine log path
mut log_path := args.logpath
if log_path == '' {
// Default path: /tmp/tmux_logs/session/window/pane_id
log_path = '/tmp/tmux_logs/${p.window.session.name}/${p.window.name}/pane_${p.id}'
}
// Create log directory if it doesn't exist
osal.exec(cmd: 'mkdir -p "${log_path}"', stdout: false, name: 'tmux_create_log_dir') or {
return error("Can't create log directory ${log_path}: ${err}")
}
// Reset logs if requested
if args.logreset {
osal.exec(
cmd: 'rm -f "${log_path}"/*.log'
stdout: false
name: 'tmux_reset_logs'
ignore_error: true
) or {}
}
// Get the path to tmux_logger binary - use a more reliable path resolution
// Find the herolib root by looking for the lib directory
mut herolib_root := os.getwd()
for {
if os.exists('${herolib_root}/lib/osal/tmux/bin/tmux_logger.v') {
break
}
parent := os.dir(herolib_root)
if parent == herolib_root {
return error('Could not find herolib root directory')
}
herolib_root = parent
}
logger_binary := '${herolib_root}/lib/osal/tmux/bin/tmux_logger'
logger_source := '${herolib_root}/lib/osal/tmux/bin/tmux_logger.v'
// Check if binary exists, if not try to compile it
if !os.exists(logger_binary) {
console.print_debug('Compiling tmux_logger binary...')
console.print_debug('Source: ${logger_source}')
console.print_debug('Binary: ${logger_binary}')
compile_cmd := 'v -enable-globals -o "${logger_binary}" "${logger_source}"'
osal.exec(cmd: compile_cmd, stdout: false, name: 'tmux_compile_logger') or {
return error("Can't compile tmux_logger: ${err}")
}
}
// Use the simple and reliable tmux pipe-pane approach with tmux_logger binary
// This is the proven approach that works perfectly
// Determine the pane identifier for logging
pane_log_id := 'pane${p.id}'
// Set up tmux pipe-pane to send all output directly to tmux_logger
pipe_cmd := 'tmux pipe-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id} -o "${logger_binary} ${log_path} ${pane_log_id}"'
console.print_debug('Starting real-time logging: ${pipe_cmd}')
osal.exec(cmd: pipe_cmd, stdout: false, name: 'tmux_start_pipe_logging') or {
return error("Can't start pipe logging for pane %${p.id}: ${err}")
}
// Wait a moment for the process to start
time.sleep(500 * time.millisecond)
// Update pane state
p.log_enabled = true
p.log_path = log_path
// Note: tmux pipe-pane doesn't return a PID, we'll track it differently if needed
console.print_debug('Logging enabled for pane %${p.id} -> ${log_path}')
}
// Disable logging for this pane
pub fn (mut p Pane) logging_disable() ! {
if !p.log_enabled {
return error('Logging is not enabled for pane %${p.id}')
}
// Stop pipe-pane (in case it's running)
cmd := 'tmux pipe-pane -t ${p.window.session.name}:@${p.window.id}.%${p.id}'
osal.exec(cmd: cmd, stdout: false, name: 'tmux_stop_logging', ignore_error: true) or {}
// Kill the tmux_logger process for this pane
pane_log_id := 'pane${p.id}'
kill_cmd := 'pkill -f "tmux_logger.*${pane_log_id}"'
osal.exec(cmd: kill_cmd, stdout: false, name: 'kill_tmux_logger', ignore_error: true) or {}
// No temp files to clean up with the simple pipe approach
// Update pane state
p.log_enabled = false
p.log_path = ''
p.logger_pid = 0
console.print_debug('Logging disabled for pane %${p.id}')
}
// Get logging status for this pane
pub fn (p Pane) logging_status() string {
if p.log_enabled {
return 'enabled (${p.log_path})'
}
return 'disabled'
}
pub fn (mut p Pane) clear() ! {
// Kill current process in the pane
osal.exec(
cmd: 'tmux send-keys -t %${p.id} C-c'
stdout: false
name: 'tmux_pane_interrupt'
) or {}
// Reset pane by running a new bash
osal.exec(
cmd: "tmux send-keys -t %${p.id} '/bin/bash' Enter"
stdout: false
name: 'tmux_pane_reset_shell'
)!
// Update pane info
p.window.scan()!
}