console_agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ <a href="<%= console_agent.sessions_path %>" class="back-link">&larr; Back to sessions</a>
2
+
3
+ <div class="meta-card">
4
+ <div class="meta-grid">
5
+ <div class="meta-item">
6
+ <label>Query</label>
7
+ <span><%= @session.query %></span>
8
+ </div>
9
+ <% if @session.name.present? %>
10
+ <div class="meta-item">
11
+ <label>Name</label>
12
+ <span><%= @session.name %></span>
13
+ </div>
14
+ <% end %>
15
+ <div class="meta-item">
16
+ <label>Mode</label>
17
+ <span class="badge badge-<%= @session.mode %>"><%= @session.mode %></span>
18
+ </div>
19
+ <div class="meta-item">
20
+ <label>User</label>
21
+ <span><%= @session.user_name || '-' %></span>
22
+ </div>
23
+ <div class="meta-item">
24
+ <label>Provider / Model</label>
25
+ <span><%= @session.provider %> / <%= @session.model %></span>
26
+ </div>
27
+ <div class="meta-item">
28
+ <label>Tokens (in / out)</label>
29
+ <span class="mono"><%= @session.input_tokens %> / <%= @session.output_tokens %></span>
30
+ </div>
31
+ <div class="meta-item">
32
+ <label>Duration</label>
33
+ <span class="mono"><%= @session.duration_ms ? "#{@session.duration_ms}ms" : '-' %></span>
34
+ </div>
35
+ <div class="meta-item">
36
+ <label>Executed?</label>
37
+ <span><%= @session.executed? ? '<span class="check">Yes</span>'.html_safe : '<span class="cross">No</span>'.html_safe %></span>
38
+ </div>
39
+ <div class="meta-item">
40
+ <label>Time</label>
41
+ <span><%= @session.created_at.strftime('%Y-%m-%d %H:%M:%S') %></span>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <h3 style="font-size: 16px; margin-bottom: 12px;">Console Output</h3>
47
+
48
+ <% if @session.console_output.present? %>
49
+ <div class="terminal">
50
+ <pre><%= ansi_to_html(@session.console_output) %></pre>
51
+ </div>
52
+ <% else %>
53
+ <div class="meta-card">
54
+ <p class="text-muted">No console output recorded for this session.</p>
55
+ </div>
56
+ <% end %>
@@ -0,0 +1,83 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>ConsoleAgent Admin</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <style>
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ body {
9
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
10
+ background: #f5f5f5;
11
+ color: #333;
12
+ line-height: 1.6;
13
+ }
14
+ .header {
15
+ background: #1a1a2e;
16
+ color: #fff;
17
+ padding: 16px 24px;
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ }
22
+ .header h1 { font-size: 18px; font-weight: 600; }
23
+ .header a { color: #8be9fd; text-decoration: none; font-size: 14px; }
24
+ .container { max-width: 1200px; margin: 24px auto; padding: 0 24px; }
25
+ table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
26
+ th { background: #f8f9fa; text-align: left; padding: 12px 16px; font-size: 13px; font-weight: 600; color: #555; border-bottom: 2px solid #e9ecef; }
27
+ td { padding: 10px 16px; border-bottom: 1px solid #f0f0f0; font-size: 14px; }
28
+ tr:hover td { background: #f8f9fa; }
29
+ a { color: #4a6fa5; text-decoration: none; }
30
+ a:hover { text-decoration: underline; }
31
+ .badge {
32
+ display: inline-block; padding: 2px 8px; border-radius: 12px;
33
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
34
+ }
35
+ .badge-one_shot { background: #d4edda; color: #155724; }
36
+ .badge-interactive { background: #cce5ff; color: #004085; }
37
+ .badge-explain { background: #fff3cd; color: #856404; }
38
+ .meta-card {
39
+ background: #fff; border-radius: 8px; padding: 20px;
40
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 24px;
41
+ }
42
+ .meta-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
43
+ .meta-item label { font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; display: block; }
44
+ .meta-item span { font-size: 14px; color: #333; }
45
+ .terminal {
46
+ background: #1e1e1e; border-radius: 8px; overflow: hidden;
47
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
48
+ }
49
+ .terminal pre {
50
+ background: transparent; color: #d4d4d4;
51
+ padding: 16px 20px; margin: 0;
52
+ font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace;
53
+ font-size: 13px; line-height: 1.5;
54
+ overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;
55
+ }
56
+ .mono { font-family: "SF Mono", "Monaco", "Menlo", "Consolas", monospace; font-size: 13px; }
57
+ .text-muted { color: #888; }
58
+ .back-link { margin-bottom: 16px; display: inline-block; }
59
+ .check { color: #28a745; }
60
+ .cross { color: #dc3545; }
61
+ .pagination {
62
+ display: flex; justify-content: space-between; align-items: center;
63
+ margin-top: 16px; padding: 12px 0;
64
+ }
65
+ .pagination a {
66
+ padding: 6px 14px; background: #fff; border: 1px solid #ddd; border-radius: 4px;
67
+ font-size: 13px; color: #4a6fa5;
68
+ }
69
+ .pagination a:hover { background: #f8f9fa; text-decoration: none; }
70
+ .pagination .disabled { padding: 6px 14px; font-size: 13px; color: #ccc; }
71
+ .pagination .page-info { font-size: 13px; color: #888; }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <div class="header">
76
+ <h1>ConsoleAgent Admin</h1>
77
+ <a href="<%= console_agent.root_path %>">Sessions</a>
78
+ </div>
79
+ <div class="container">
80
+ <%= yield %>
81
+ </div>
82
+ </body>
83
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ ConsoleAgent::Engine.routes.draw do
2
+ root to: 'sessions#index'
3
+ resources :sessions, only: [:index, :show]
4
+ end
@@ -1,12 +1,13 @@
1
1
  module ConsoleAgent
2
2
  class Configuration
3
3
  PROVIDERS = %i[anthropic openai].freeze
4
- CONTEXT_MODES = %i[full smart].freeze
5
4
 
6
5
  attr_accessor :provider, :api_key, :model, :max_tokens,
7
- :auto_execute, :context_mode, :temperature,
6
+ :auto_execute, :temperature,
8
7
  :timeout, :debug, :max_tool_rounds,
9
- :storage_adapter, :memories_enabled
8
+ :storage_adapter, :memories_enabled,
9
+ :session_logging, :connection_class,
10
+ :admin_username, :admin_password
10
11
 
11
12
  def initialize
12
13
  @provider = :anthropic
@@ -14,13 +15,16 @@ module ConsoleAgent
14
15
  @model = nil
15
16
  @max_tokens = 4096
16
17
  @auto_execute = false
17
- @context_mode = :smart
18
18
  @temperature = 0.2
19
19
  @timeout = 30
20
20
  @debug = false
21
21
  @max_tool_rounds = 100
22
22
  @storage_adapter = nil
23
23
  @memories_enabled = true
24
+ @session_logging = true
25
+ @connection_class = nil
26
+ @admin_username = nil
27
+ @admin_password = nil
24
28
  end
25
29
 
26
30
  def resolved_api_key
@@ -46,17 +46,99 @@ module ConsoleAgent
46
46
  nil
47
47
  end
48
48
 
49
+ def ai_sessions(n = 10, search: nil)
50
+ require 'console_agent/session_logger'
51
+ session_class = Object.const_get('ConsoleAgent::Session')
52
+
53
+ scope = session_class.recent
54
+ scope = scope.search(search) if search
55
+ sessions = scope.limit(n)
56
+
57
+ if sessions.empty?
58
+ $stdout.puts "\e[2mNo sessions found.\e[0m"
59
+ return nil
60
+ end
61
+
62
+ $stdout.puts "\e[36m[Sessions — showing #{sessions.length}#{search ? " matching \"#{search}\"" : ''}]\e[0m"
63
+ $stdout.puts
64
+
65
+ sessions.each do |s|
66
+ id_str = "\e[2m##{s.id}\e[0m"
67
+ name_str = s.name ? "\e[33m#{s.name}\e[0m " : ""
68
+ query_str = s.name ? "\e[2m#{truncate_str(s.query, 50)}\e[0m" : truncate_str(s.query, 50)
69
+ mode_str = "\e[2m[#{s.mode}]\e[0m"
70
+ time_str = "\e[2m#{time_ago(s.created_at)}\e[0m"
71
+ tokens = (s.input_tokens || 0) + (s.output_tokens || 0)
72
+ token_str = tokens > 0 ? "\e[2m#{tokens} tokens\e[0m" : ""
73
+
74
+ $stdout.puts " #{id_str} #{name_str}#{query_str}"
75
+ $stdout.puts " #{mode_str} #{time_str} #{token_str}"
76
+ $stdout.puts
77
+ end
78
+
79
+ $stdout.puts "\e[2mUse ai_resume(id_or_name) to resume a session.\e[0m"
80
+ $stdout.puts "\e[2mUse ai_sessions(n, search: \"term\") to filter.\e[0m"
81
+ nil
82
+ rescue => e
83
+ $stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
84
+ nil
85
+ end
86
+
87
+ def ai_resume(identifier)
88
+ __ensure_console_agent_user
89
+
90
+ require 'console_agent/context_builder'
91
+ require 'console_agent/providers/base'
92
+ require 'console_agent/executor'
93
+ require 'console_agent/repl'
94
+ require 'console_agent/session_logger'
95
+
96
+ session = __find_session(identifier)
97
+ unless session
98
+ $stderr.puts "\e[31mSession not found: #{identifier}\e[0m"
99
+ return nil
100
+ end
101
+
102
+ repl = Repl.new(__console_agent_binding)
103
+ repl.resume(session)
104
+ rescue => e
105
+ $stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
106
+ nil
107
+ end
108
+
109
+ def ai_name(identifier, new_name)
110
+ require 'console_agent/session_logger'
111
+
112
+ session = __find_session(identifier)
113
+ unless session
114
+ $stderr.puts "\e[31mSession not found: #{identifier}\e[0m"
115
+ return nil
116
+ end
117
+
118
+ ConsoleAgent::SessionLogger.update(session.id, name: new_name)
119
+ $stdout.puts "\e[36mSession ##{session.id} named: #{new_name}\e[0m"
120
+ nil
121
+ rescue => e
122
+ $stderr.puts "\e[31mConsoleAgent error: #{e.message}\e[0m"
123
+ nil
124
+ end
125
+
49
126
  def ai(query = nil)
50
127
  if query.nil?
51
128
  $stderr.puts "\e[33mUsage: ai \"your question here\"\e[0m"
52
- $stderr.puts "\e[33m ai \"query\" - ask + confirm execution\e[0m"
53
- $stderr.puts "\e[33m ai! \"query\" - enter interactive mode (or ai! with no args)\e[0m"
54
- $stderr.puts "\e[33m ai? \"query\" - explain only, no execution\e[0m"
55
- $stderr.puts "\e[33m ai_status - show current configuration\e[0m"
56
- $stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
129
+ $stderr.puts "\e[33m ai \"query\" - ask + confirm execution\e[0m"
130
+ $stderr.puts "\e[33m ai! \"query\" - enter interactive mode (or ai! with no args)\e[0m"
131
+ $stderr.puts "\e[33m ai? \"query\" - explain only, no execution\e[0m"
132
+ $stderr.puts "\e[33m ai_sessions - list recent sessions\e[0m"
133
+ $stderr.puts "\e[33m ai_resume - resume a session by name or id\e[0m"
134
+ $stderr.puts "\e[33m ai_name - name a session: ai_name 42, \"my_label\"\e[0m"
135
+ $stderr.puts "\e[33m ai_status - show current configuration\e[0m"
136
+ $stderr.puts "\e[33m ai_memories - show recent memories (ai_memories(n) for last n)\e[0m"
57
137
  return nil
58
138
  end
59
139
 
140
+ __ensure_console_agent_user
141
+
60
142
  require 'console_agent/context_builder'
61
143
  require 'console_agent/providers/base'
62
144
  require 'console_agent/executor'
@@ -70,6 +152,8 @@ module ConsoleAgent
70
152
  end
71
153
 
72
154
  def ai!(query = nil)
155
+ __ensure_console_agent_user
156
+
73
157
  require 'console_agent/context_builder'
74
158
  require 'console_agent/providers/base'
75
159
  require 'console_agent/executor'
@@ -93,6 +177,8 @@ module ConsoleAgent
93
177
  return nil
94
178
  end
95
179
 
180
+ __ensure_console_agent_user
181
+
96
182
  require 'console_agent/context_builder'
97
183
  require 'console_agent/providers/base'
98
184
  require 'console_agent/executor'
@@ -107,6 +193,40 @@ module ConsoleAgent
107
193
 
108
194
  private
109
195
 
196
+ def __find_session(identifier)
197
+ session_class = Object.const_get('ConsoleAgent::Session')
198
+ if identifier.is_a?(Integer)
199
+ session_class.find_by(id: identifier)
200
+ else
201
+ session_class.where(name: identifier.to_s).recent.first ||
202
+ session_class.find_by(id: identifier.to_i)
203
+ end
204
+ end
205
+
206
+ def truncate_str(str, max)
207
+ return '' if str.nil?
208
+ str.length > max ? str[0...max] + '...' : str
209
+ end
210
+
211
+ def time_ago(time)
212
+ return '' unless time
213
+ seconds = Time.now - time
214
+ case seconds
215
+ when 0...60 then "just now"
216
+ when 60...3600 then "#{(seconds / 60).to_i}m ago"
217
+ when 3600...86400 then "#{(seconds / 3600).to_i}h ago"
218
+ else "#{(seconds / 86400).to_i}d ago"
219
+ end
220
+ end
221
+
222
+ def __ensure_console_agent_user
223
+ return if ConsoleAgent.current_user
224
+ $stdout.puts "\e[36mConsoleAgent logs all AI sessions for audit purposes.\e[0m"
225
+ $stdout.print "\e[36mPlease enter your name: \e[0m"
226
+ name = $stdin.gets.to_s.strip
227
+ ConsoleAgent.current_user = name.empty? ? ENV['USER'] : name
228
+ end
229
+
110
230
  def __console_agent_binding
111
231
  # Try IRB workspace binding
112
232
  if defined?(IRB) && IRB.respond_to?(:CurrentContext)
@@ -5,25 +5,10 @@ module ConsoleAgent
5
5
  end
6
6
 
7
7
  def build
8
- case @config.context_mode
9
- when :smart
10
- build_smart
11
- else
12
- build_full
13
- end
8
+ build_smart
14
9
  rescue => e
15
10
  ConsoleAgent.logger.warn("ConsoleAgent: context build error: #{e.message}")
16
- system_instructions + "\n\n" + environment_context
17
- end
18
-
19
- def build_full
20
- parts = []
21
- parts << system_instructions
22
- parts << environment_context
23
- parts << schema_context
24
- parts << models_context
25
- parts << routes_context
26
- parts.compact.join("\n\n")
11
+ smart_system_instructions + "\n\n" + environment_context
27
12
  end
28
13
 
29
14
  def build_smart
@@ -60,10 +45,18 @@ module ConsoleAgent
60
45
  When you use a memory, mention it briefly (e.g. "Based on what I know about sharding...").
61
46
  When you discover important patterns about this app, save them as memories.
62
47
 
48
+ You have an execute_plan tool to run multi-step code. When a task requires multiple
49
+ sequential operations, use execute_plan with an array of steps (each with a description
50
+ and Ruby code). The plan is shown to the user for review before execution begins.
51
+ After each step runs, its return value is stored as step1, step2, etc. — use these
52
+ variables in later steps to reference earlier results (e.g. `api = SalesforceApi.new(step1)`).
53
+ For simple single-expression answers, you may respond with a ```ruby code block instead.
54
+
63
55
  RULES:
64
56
  - Give ONE concise answer. Do not offer multiple alternatives or variations.
65
- - Respond with a single ```ruby code block that directly answers the question.
66
- - Include a brief one-line explanation before the code block.
57
+ - For multi-step tasks, use execute_plan to break the work into small, clear steps.
58
+ - For simple queries, respond with a single ```ruby code block.
59
+ - Include a brief one-line explanation before any code block.
67
60
  - Use the app's actual model names, associations, and schema.
68
61
  - Prefer ActiveRecord query interface over raw SQL.
69
62
  - For destructive operations, add a comment warning.
@@ -74,23 +67,6 @@ module ConsoleAgent
74
67
  PROMPT
75
68
  end
76
69
 
77
- def system_instructions
78
- <<~PROMPT.strip
79
- You are a Ruby on Rails console assistant. The user is in a `rails console` session.
80
- You help them query data, debug issues, and understand their application.
81
-
82
- RULES:
83
- - Give ONE concise answer. Do not offer multiple alternatives or variations.
84
- - Respond with a single ```ruby code block that directly answers the question.
85
- - Include a brief one-line explanation before the code block.
86
- - Use the app's actual model names, associations, and schema.
87
- - Prefer ActiveRecord query interface over raw SQL.
88
- - For destructive operations, add a comment warning.
89
- - If the request is ambiguous, ask a clarifying question instead of guessing.
90
- - Keep code concise and idiomatic.
91
- PROMPT
92
- end
93
-
94
70
  def environment_context
95
71
  lines = ["## Environment"]
96
72
  lines << "- Ruby #{RUBY_VERSION}"
@@ -112,93 +88,6 @@ module ConsoleAgent
112
88
  lines.join("\n")
113
89
  end
114
90
 
115
- def schema_context
116
- return nil unless defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
117
-
118
- conn = ActiveRecord::Base.connection
119
- tables = conn.tables.sort
120
- return nil if tables.empty?
121
-
122
- lines = ["## Database Schema"]
123
- tables.each do |table|
124
- next if table == 'schema_migrations' || table == 'ar_internal_metadata'
125
-
126
- cols = conn.columns(table).map { |c| "#{c.name}:#{c.type}" }
127
- lines << "- #{table} (#{cols.join(', ')})"
128
- end
129
- lines.join("\n")
130
- rescue => e
131
- ConsoleAgent.logger.debug("ConsoleAgent: schema introspection failed: #{e.message}")
132
- nil
133
- end
134
-
135
- def models_context
136
- return nil unless defined?(ActiveRecord::Base)
137
-
138
- eager_load_app!
139
- base_class = defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base
140
- models = ObjectSpace.each_object(Class).select { |c|
141
- c < base_class && !c.abstract_class? && c.name && !c.name.start_with?('HABTM_')
142
- }.sort_by(&:name)
143
-
144
- return nil if models.empty?
145
-
146
- lines = ["## Models"]
147
- models.each do |model|
148
- parts = [model.name]
149
-
150
- assocs = model.reflect_on_all_associations.map { |a| "#{a.macro} :#{a.name}" }
151
- parts << " associations: #{assocs.join(', ')}" unless assocs.empty?
152
-
153
- scopes = model.methods.grep(/^_scope_/).map { |m| m.to_s.sub('_scope_', '') } rescue []
154
- if scopes.empty?
155
- # Alternative: check singleton methods that return ActiveRecord::Relation
156
- scope_names = (model.singleton_methods - base_class.singleton_methods).select { |m|
157
- m != :all && !m.to_s.start_with?('_') && !m.to_s.start_with?('find')
158
- }.first(10)
159
- # We won't list these to avoid noise — scopes are hard to detect reliably
160
- end
161
-
162
- validations = model.validators.map { |v|
163
- attrs = v.attributes.join(', ')
164
- "#{v.class.name.demodulize.underscore.sub('_validator', '')} on #{attrs}"
165
- }.uniq.first(5) rescue []
166
- parts << " validations: #{validations.join('; ')}" unless validations.empty?
167
-
168
- lines << "- #{parts.join("\n")}"
169
- end
170
- lines.join("\n")
171
- rescue => e
172
- ConsoleAgent.logger.debug("ConsoleAgent: model introspection failed: #{e.message}")
173
- nil
174
- end
175
-
176
- def routes_context
177
- return nil unless defined?(Rails) && Rails.respond_to?(:application)
178
-
179
- routes = Rails.application.routes.routes
180
- return nil if routes.empty?
181
-
182
- lines = ["## Routes (summary)"]
183
- count = 0
184
- routes.each do |route|
185
- next if route.internal?
186
- path = route.path.spec.to_s.sub('(.:format)', '')
187
- verb = route.verb.to_s
188
- next if verb.empty?
189
- action = route.defaults[:controller].to_s + '#' + route.defaults[:action].to_s
190
- lines << "- #{verb} #{path} -> #{action}"
191
- count += 1
192
- break if count >= 50
193
- end
194
- lines << "- ... (#{routes.size - count} more routes)" if routes.size > count
195
-
196
- lines.join("\n")
197
- rescue => e
198
- ConsoleAgent.logger.debug("ConsoleAgent: route introspection failed: #{e.message}")
199
- nil
200
- end
201
-
202
91
  def memory_context
203
92
  return nil unless @config.memories_enabled
204
93
 
@@ -216,14 +105,5 @@ module ConsoleAgent
216
105
  nil
217
106
  end
218
107
 
219
- def eager_load_app!
220
- return unless defined?(Rails) && Rails.respond_to?(:application)
221
-
222
- if Rails.application.respond_to?(:eager_load!)
223
- Rails.application.eager_load!
224
- end
225
- rescue => e
226
- ConsoleAgent.logger.debug("ConsoleAgent: eager_load failed: #{e.message}")
227
- end
228
108
  end
229
109
  end
@@ -0,0 +1,5 @@
1
+ module ConsoleAgent
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ConsoleAgent
4
+ end
5
+ end
@@ -3,6 +3,8 @@ require 'stringio'
3
3
  module ConsoleAgent
4
4
  # Writes to two IO streams simultaneously
5
5
  class TeeIO
6
+ attr_reader :secondary
7
+
6
8
  def initialize(primary, secondary)
7
9
  @primary = primary
8
10
  @secondary = secondary
@@ -42,6 +44,7 @@ module ConsoleAgent
42
44
  CODE_REGEX = /```ruby\s*\n(.*?)```/m
43
45
 
44
46
  attr_reader :binding_context
47
+ attr_accessor :on_prompt
45
48
 
46
49
  def initialize(binding_context)
47
50
  @binding_context = binding_context
@@ -101,6 +104,10 @@ module ConsoleAgent
101
104
  @last_output
102
105
  end
103
106
 
107
+ def last_answer
108
+ @last_answer
109
+ end
110
+
104
111
  def last_cancelled?
105
112
  @last_cancelled
106
113
  end
@@ -109,8 +116,12 @@ module ConsoleAgent
109
116
  return nil if code.nil? || code.strip.empty?
110
117
 
111
118
  @last_cancelled = false
119
+ @last_answer = nil
112
120
  $stdout.print colorize("Execute? [y/N/edit] ", :yellow)
121
+ @on_prompt&.call
113
122
  answer = $stdin.gets.to_s.strip.downcase
123
+ @last_answer = answer
124
+ echo_stdin(answer)
114
125
 
115
126
  case answer
116
127
  when 'y', 'yes'
@@ -121,7 +132,9 @@ module ConsoleAgent
121
132
  $stdout.puts colorize("# Edited code:", :yellow)
122
133
  $stdout.puts highlight_code(edited)
123
134
  $stdout.print colorize("Execute edited code? [y/N] ", :yellow)
124
- if $stdin.gets.to_s.strip.downcase == 'y'
135
+ edit_answer = $stdin.gets.to_s.strip.downcase
136
+ echo_stdin(edit_answer)
137
+ if edit_answer == 'y'
125
138
  execute(edited)
126
139
  else
127
140
  $stdout.puts colorize("Cancelled.", :yellow)
@@ -139,6 +152,11 @@ module ConsoleAgent
139
152
 
140
153
  private
141
154
 
155
+ # Write stdin input to the capture IO only (avoids double-echo on terminal)
156
+ def echo_stdin(text)
157
+ $stdout.secondary.write("#{text}\n") if $stdout.respond_to?(:secondary)
158
+ end
159
+
142
160
  def open_in_editor(code)
143
161
  require 'tempfile'
144
162
  editor = ENV['EDITOR'] || 'vi'