From 4e1487f7d8d2b3befd50747eda819992b18bd94d Mon Sep 17 00:00:00 2001 From: derekstride Date: Wed, 25 Feb 2026 17:05:47 -0500 Subject: [PATCH 1/2] feat: add ExecCommand for running terminal subprocesses Adds `Bubbletea.exec(callable, message:)` which suspends terminal management, runs a callable, then restores terminal state. This enables spawning editors, pagers, and other TUI subprocesses cleanly, similar to Go bubbletea's `tea.Exec`. Co-Authored-By: Claude Opus 4.6 --- lib/bubbletea/commands.rb | 14 ++++++++++++++ lib/bubbletea/runner.rb | 23 +++++++++++++++++++++++ test/runner_test.rb | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/lib/bubbletea/commands.rb b/lib/bubbletea/commands.rb index c4b939f..850afc7 100644 --- a/lib/bubbletea/commands.rb +++ b/lib/bubbletea/commands.rb @@ -78,6 +78,16 @@ def initialize(text) class SuspendCommand < Command end + class ExecCommand < Command + attr_reader :callable, :message + + def initialize(callable, message: nil) + super() + @callable = callable + @message = message + end + end + class << self def quit QuitCommand.new @@ -122,5 +132,9 @@ def puts(text) def suspend SuspendCommand.new end + + def exec(callable, message: nil) + ExecCommand.new(callable, message: message) + end end end diff --git a/lib/bubbletea/runner.rb b/lib/bubbletea/runner.rb index 7a6c1cd..a70d856 100644 --- a/lib/bubbletea/runner.rb +++ b/lib/bubbletea/runner.rb @@ -233,6 +233,9 @@ def process_command(command) when SuspendCommand suspend_process + when ExecCommand + exec_process(command) + when Proc Thread.new do result = command.call @@ -286,6 +289,9 @@ def execute_command_sync(command) when SuspendCommand suspend_process + when ExecCommand + exec_process(command) + when Proc result = command.call return unless result @@ -360,6 +366,23 @@ def suspend_process handle_message(ResumeMessage.new) end + def exec_process(command) + @program.disable_mouse if @options[:mouse_cell_motion] || @options[:mouse_all_motion] + @program.show_cursor + @program.stop_input_reader + @program.exit_raw_mode + + command.callable.call + + @program.enter_raw_mode + @program.hide_cursor + @program.start_input_reader + @program.enable_mouse_cell_motion if @options[:mouse_cell_motion] + @program.enable_mouse_all_motion if @options[:mouse_all_motion] + + handle_message(command.message) if command.message + end + def render return if @options[:without_renderer] return unless @renderer_id diff --git a/test/runner_test.rb b/test/runner_test.rb index 16c1c12..36972c9 100644 --- a/test/runner_test.rb +++ b/test/runner_test.rb @@ -160,4 +160,39 @@ def setup it "process nil command" do @runner.__send__(:process_command, nil) end + + it "process exec command calls callable" do + called = false + callable = -> { called = true } + + program = Object.new + [:disable_mouse, :show_cursor, :stop_input_reader, :exit_raw_mode, :enter_raw_mode, :hide_cursor, :start_input_reader].each do |method| + program.define_singleton_method(method) { nil } + end + + @runner.instance_variable_set(:@program, program) + + cmd = Bubbletea.exec(callable) + @runner.__send__(:process_command, cmd) + + assert called + end + + it "process exec command dispatches message" do + called = false + callable = -> { called = true } + + program = Object.new + [:disable_mouse, :show_cursor, :stop_input_reader, :exit_raw_mode, :enter_raw_mode, :hide_cursor, :start_input_reader].each do |method| + program.define_singleton_method(method) { nil } + end + + @runner.instance_variable_set(:@program, program) + + cmd = Bubbletea.exec(callable, message: :exec_done) + @runner.__send__(:process_command, cmd) + + assert called + assert_includes @model.messages, :exec_done + end end From 9f7e101faab419ff402305531fc9fc8f5c821f32 Mon Sep 17 00:00:00 2001 From: derekstride Date: Thu, 26 Feb 2026 10:45:38 -0500 Subject: [PATCH 2/2] docs(demo): update exec example to use Bubbletea.exec Co-Authored-By: Claude Opus 4.6 --- demo/exec | 33 ++------------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/demo/exec b/demo/exec index 63292f6..2580a9b 100755 --- a/demo/exec +++ b/demo/exec @@ -7,22 +7,11 @@ require "bundler/setup" require "bubbletea" -class EditorFinishedMessage < Bubbletea::Message - attr_reader :error - - def initialize(error = nil) - super() - - @error = error - end -end - class ExecDemo include Bubbletea::Model def initialize @altscreen_active = false - @error = nil end def init @@ -39,38 +28,20 @@ class ExecDemo return [self, cmd] when "e" - return [self, open_editor] + editor = ENV["EDITOR"] || "vim" + return [self, Bubbletea.exec(-> { system(editor) })] when "ctrl+c", "q" return [self, Bubbletea.quit] end - when EditorFinishedMessage - if message.error - @error = message.error - return [self, Bubbletea.quit] - end end [self, nil] end def view - return "Error: #{@error}\n" if @error - "Press 'e' to open your EDITOR.\nPress 'a' to toggle the altscreen\nPress 'q' to quit.\n" end - - private - - def open_editor - lambda do - editor = ENV["EDITOR"] || "vim" - system(editor) - EditorFinishedMessage.new - rescue StandardError => e - EditorFinishedMessage.new(e.message) - end - end end Bubbletea.run(ExecDemo.new)