lazyrails-tui 0.3.1 → 0.3.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b014fd01ecf58a9dba38ea0464ee6b274faa39bcd1c408fdf0c466fcfb0779fb
4
- data.tar.gz: 435a884a616aa3921899073db046fe50baebded69bbbf1331582ceef3ac493be
3
+ metadata.gz: 425bb616025ba482fb9c62f4831fa1a1406d0258d1871929970271fdf5aec6c1
4
+ data.tar.gz: 6eec64b46c70ce39ddc12e97b602f525bd5377e8a321db3dd8f789e30bf354a7
5
5
  SHA512:
6
- metadata.gz: b0d18fc34206ee05582dab201447aa691321c5d20dd8e61284beecd00d612f70d21a7e74feb8b4c9d4020809c4a1d1389840e904cc1d5c0b004758542d623993
7
- data.tar.gz: cf19b44f5d6872fe978f1772e0039608a4c9d02cbc4c89b8adec1c78156f4a598d62159755ce74e237ced397d2f446c3ea69f406c4afbea19fad84fffb1201a8
6
+ metadata.gz: d0063d9de772c58862725cb7f5c8adb748dfb0581a1bdc60564d8681075b908ff8541e7be0947b9d6ba9fdd96f395296fa4e245ec7ecd462e32f9e50702f1dd7
7
+ data.tar.gz: 2a0d7d625bf68f306bc3b1dea48390408e391de5088844e1938eedc46c93ba2cb4ec84cd4390c16aedd0aae2bc8d1913c212244ab6b3d874b8772fa96bf3e304
data/bin/lazyrails CHANGED
@@ -41,4 +41,4 @@ if project.old_rails?
41
41
  end
42
42
 
43
43
  app = LazyRails::App.new(project)
44
- Chamomile.run(app, alt_screen: true)
44
+ Chamomile.run(app, alt_screen: true, mouse: :cell_motion)
data/lib/lazyrails/app.rb CHANGED
@@ -91,6 +91,9 @@ module LazyRails
91
91
  @jobs_available = nil
92
92
  @jobs_tick_counter = 0
93
93
  @pending_job_action = nil
94
+ @panel_busy = Set.new
95
+ @last_command_result = {}
96
+ @force_quit = false
94
97
 
95
98
  # Caches
96
99
  @detail_content = ""
@@ -137,6 +140,7 @@ module LazyRails
137
140
  case msg
138
141
  when Chamomile::ResizeEvent then handle_resize(msg)
139
142
  when Chamomile::KeyEvent then return handle_key(msg)
143
+ when Chamomile::MouseEvent then return handle_mouse(msg)
140
144
  when Chamomile::TickEvent then return handle_tick
141
145
  when Chamomile::InterruptEvent then return shutdown
142
146
  when IntrospectLoadedEvent then return handle_introspect_loaded(msg)
@@ -283,6 +287,13 @@ module LazyRails
283
287
  end
284
288
 
285
289
  def shutdown
290
+ unless @panel_busy.empty? || @force_quit
291
+ busy_names = @panel_busy.map { |t| t.to_s.capitalize }.join(", ")
292
+ @force_quit = true
293
+ set_flash("#{busy_names} still running. Press q again to quit (tasks will continue in background).")
294
+ return nil
295
+ end
296
+
286
297
  @server.stop
287
298
  @log_watcher.stop
288
299
  quit
@@ -381,6 +392,7 @@ module LazyRails
381
392
  if msg.key == :tab && msg.shift?
382
393
  clear_panel_state
383
394
  @focused_panel = (@focused_panel - 1) % @panels.size
395
+ @detail_viewport.goto_top
384
396
  update_detail_content
385
397
  return nil
386
398
  end
@@ -392,23 +404,32 @@ module LazyRails
392
404
  when :tab, :right, "l"
393
405
  clear_panel_state
394
406
  @focused_panel = (@focused_panel + 1) % @panels.size
407
+ @detail_viewport.goto_top
395
408
  update_detail_content
396
409
  when :left, "h"
397
410
  clear_panel_state
398
411
  @focused_panel = (@focused_panel - 1) % @panels.size
412
+ @detail_viewport.goto_top
399
413
  update_detail_content
400
414
  when "1".."9"
401
415
  idx = msg.key.to_i - 1
402
416
  if idx < @panels.size
403
417
  @focused_panel = idx
418
+ @detail_viewport.goto_top
404
419
  update_detail_content
405
420
  end
406
421
  when "j", :down
407
422
  current_panel.move_cursor(1, panel_visible_height)
423
+ @detail_viewport.goto_top
408
424
  update_detail_content
409
425
  when "k", :up
410
426
  current_panel.move_cursor(-1, panel_visible_height)
427
+ @detail_viewport.goto_top
411
428
  update_detail_content
429
+ when "J"
430
+ @detail_viewport.scroll_down(1)
431
+ when "K"
432
+ @detail_viewport.scroll_up(1)
412
433
  when :enter then return handle_enter
413
434
  when "/" then start_filter
414
435
  when "G" then show_generator_menu
@@ -421,6 +442,127 @@ module LazyRails
421
442
  nil
422
443
  end
423
444
 
445
+ # ─── Mouse handling ──────────────────────────────────
446
+
447
+ MOUSE_SCROLL_LINES = 3
448
+
449
+ def handle_mouse(msg)
450
+ if @help.visible?
451
+ handle_help_mouse(msg)
452
+ return nil
453
+ end
454
+
455
+ if @command_log_overlay.visible?
456
+ handle_command_log_mouse(msg)
457
+ return nil
458
+ end
459
+
460
+ # Ignore mouse during modal overlays
461
+ return nil if @welcome.visible? || @menu.visible? || @confirmation ||
462
+ @table_browser.visible? || @generator_wizard.visible? || @input_mode.active?
463
+
464
+ # Convert 1-based terminal coordinates to 0-based
465
+ mx = msg.x - 1
466
+ my = msg.y - 1
467
+ left_width = (@width * LEFT_WIDTH_RATIO).to_i
468
+
469
+ if mx < left_width
470
+ handle_left_pane_mouse(msg, my)
471
+ else
472
+ handle_right_pane_mouse(msg)
473
+ end
474
+
475
+ nil
476
+ end
477
+
478
+ def handle_left_pane_mouse(msg, my)
479
+ if msg.wheel?
480
+ delta = msg.button == Chamomile::MOUSE_WHEEL_DOWN ? 1 : -1
481
+ current_panel.move_cursor(delta, panel_visible_height)
482
+ @detail_viewport.goto_top
483
+ update_detail_content
484
+ elsif msg.button == Chamomile::MOUSE_LEFT && msg.press?
485
+ panel_idx = panel_index_at_y(my)
486
+ return unless panel_idx
487
+
488
+ changed = false
489
+
490
+ if panel_idx != @focused_panel
491
+ clear_panel_state
492
+ @focused_panel = panel_idx
493
+ changed = true
494
+ end
495
+
496
+ item_idx = item_index_at_y(panel_idx, my)
497
+ if item_idx && item_idx != current_panel.cursor
498
+ delta = item_idx - current_panel.cursor
499
+ current_panel.move_cursor(delta, panel_visible_height)
500
+ changed = true
501
+ end
502
+
503
+ if changed
504
+ @detail_viewport.goto_top
505
+ update_detail_content
506
+ end
507
+ end
508
+ end
509
+
510
+ def handle_right_pane_mouse(msg)
511
+ return unless msg.wheel?
512
+
513
+ if msg.button == Chamomile::MOUSE_WHEEL_DOWN
514
+ @detail_viewport.scroll_down(MOUSE_SCROLL_LINES)
515
+ else
516
+ @detail_viewport.scroll_up(MOUSE_SCROLL_LINES)
517
+ end
518
+ end
519
+
520
+ def handle_help_mouse(msg)
521
+ return unless msg.wheel?
522
+
523
+ if msg.button == Chamomile::MOUSE_WHEEL_DOWN
524
+ @help.handle_key(:down)
525
+ else
526
+ @help.handle_key(:up)
527
+ end
528
+ end
529
+
530
+ def handle_command_log_mouse(msg)
531
+ return unless msg.wheel?
532
+
533
+ if msg.button == Chamomile::MOUSE_WHEEL_DOWN
534
+ @command_log_overlay.handle_key(:down)
535
+ else
536
+ @command_log_overlay.handle_key(:up)
537
+ end
538
+ end
539
+
540
+ def panel_index_at_y(y)
541
+ heights = distribute_panel_heights(@height - 2)
542
+ cumulative = 0
543
+ heights.each_with_index do |h, i|
544
+ return i if y >= cumulative && y < cumulative + h
545
+ cumulative += h
546
+ end
547
+ nil
548
+ end
549
+
550
+ def item_index_at_y(panel_index, y)
551
+ heights = distribute_panel_heights(@height - 2)
552
+ panel_start = heights[0...panel_index].sum
553
+ panel_h = heights[panel_index]
554
+
555
+ # Content area is inside the border (1 line top, 1 line bottom)
556
+ content_y = y - panel_start - 1
557
+ return nil if content_y < 0 || content_y >= panel_h - 2
558
+
559
+ panel = @panels[panel_index]
560
+ idx = panel.scroll_offset + content_y
561
+ return nil if idx >= panel.filtered_items.size
562
+
563
+ idx
564
+ end
565
+
424
566
  def handle_table_browser_key(msg)
425
567
  signal = @table_browser.handle_key(msg.key)
426
568
  case signal
@@ -474,6 +616,11 @@ module LazyRails
474
616
  # ─── Confirmation ─────────────────────────────────────
475
617
 
476
618
  def start_confirmation(command, tier: nil, required_text: nil)
619
+ if @panel_busy.include?(current_panel.type)
620
+ set_flash("Command already running...")
621
+ return nil
622
+ end
623
+
477
624
  tier ||= Confirmation.detect_tier(command)
478
625
  return run_rails_cmd(command, current_panel.type) if tier == :green
479
626
 
@@ -9,6 +9,7 @@ module LazyRails
9
9
  @visible = false
10
10
  @cursor = 0
11
11
  @detail = nil
12
+ @scroll_offset = 0
12
13
  end
13
14
 
14
15
  def visible? = @visible
@@ -17,6 +18,7 @@ module LazyRails
17
18
  @visible = true
18
19
  @cursor = 0
19
20
  @detail = nil
21
+ @scroll_offset = 0
20
22
  end
21
23
 
22
24
  def hide
@@ -40,17 +42,38 @@ module LazyRails
40
42
  @detail = nil
41
43
  nil
42
44
  when :enter
43
- @detail = @command_log.entries[@cursor]
45
+ entry = @command_log.entries[@cursor]
46
+ @detail = @detail ? nil : entry
44
47
  nil
45
48
  when "q" then :quit
46
49
  end
47
50
  end
48
51
 
49
- def render(width:)
52
+ def render(width:, height: nil)
50
53
  return "No commands executed yet.\n\nPress L or Esc to close." if @command_log.empty?
51
54
 
52
55
  header = Chamomile::Style.new.bold.render("Command Log")
53
- list = Views::CommandLogView.render(@command_log, width: width - 4, selected: @cursor)
56
+ footer = "j/k navigate | Enter detail | L or Esc close"
57
+
58
+ if height
59
+ # Reserve: header(1) + blank(1) + footer blank(1) + footer(1) = 4
60
+ usable = height - 4
61
+ list_height = if @detail
62
+ # Split: top portion for list, rest for detail
63
+ [(usable * 0.4).to_i, 3].max
64
+ else
65
+ [usable, 3].max
66
+ end
67
+ else
68
+ list_height = @command_log.size
69
+ end
70
+
71
+ clamp_scroll(list_height)
72
+
73
+ list = Views::CommandLogView.render_window(
74
+ @command_log, width: width - 4, selected: @cursor,
75
+ offset: @scroll_offset, limit: list_height
76
+ )
54
77
 
55
78
  parts = [header, "", list]
56
79
 
@@ -61,8 +84,17 @@ module LazyRails
61
84
  end
62
85
 
63
86
  parts << ""
64
- parts << "j/k navigate | Enter detail | L or Esc close | q quit"
87
+ parts << footer
65
88
  parts.join("\n")
66
89
  end
90
+
91
+ private
92
+
93
+ def clamp_scroll(visible_height)
94
+ @scroll_offset = @cursor if @cursor < @scroll_offset
95
+ @scroll_offset = @cursor - visible_height + 1 if @cursor >= @scroll_offset + visible_height
96
+ max_offset = [@command_log.size - visible_height, 0].max
97
+ @scroll_offset = @scroll_offset.clamp(0, max_offset)
98
+ end
67
99
  end
68
100
  end
@@ -61,7 +61,10 @@ module LazyRails
61
61
 
62
62
  def run_rails_cmd(command, panel_type)
63
63
  display = command.is_a?(Array) ? command.join(" ") : command
64
+ @panel_busy << panel_type
65
+ @last_command_result.delete(panel_type)
64
66
  set_flash("Running: #{display}...")
67
+ update_detail_content
65
68
  project_dir = @project.dir
66
69
  cmd(lambda {
67
70
  result = CommandRunner.run(command, dir: project_dir)
@@ -70,7 +73,9 @@ module LazyRails
70
73
  end
71
74
 
72
75
  def run_test_file_cmd(test_file)
76
+ @panel_busy << :tests
73
77
  set_flash("Running: #{test_file.path}...")
78
+ update_detail_content
74
79
  project_dir = @project.dir
75
80
  path = test_file.path
76
81
  test_cmd = path.start_with?("spec/") ? ["bundle", "exec", "rspec", path] : ["bin/rails", "test", path]
@@ -7,6 +7,7 @@ module LazyRails
7
7
  ["Tab/Shift+Tab", "Cycle panels"],
8
8
  ["1-9", "Jump to panel"],
9
9
  ["j/k", "Scroll up/down"],
10
+ ["J/K", "Scroll detail pane"],
10
11
  ["Enter", "Select / expand"],
11
12
  ["q", "Quit"],
12
13
  ["?", "Toggle help"],
@@ -55,11 +56,19 @@ module LazyRails
55
56
  ["f", "Cycle filter"],
56
57
  ["R", "Refresh"]
57
58
  ] },
59
+ { section: "Rake", bindings: [
60
+ ["Enter", "Run selected task"]
61
+ ] },
58
62
  { section: "Gems", bindings: [
59
63
  ["Enter", "Show gem info"],
60
64
  ["u", "Update gem"],
61
65
  ["U", "Update all"],
62
66
  ["o", "Open homepage"]
67
+ ] },
68
+ { section: "Mouse", bindings: [
69
+ ["Scroll wheel", "Scroll panels / detail"],
70
+ ["Left click", "Focus panel / select item"],
71
+ ["Shift+drag", "Select text (copy)"]
63
72
  ] }
64
73
  ].freeze
65
74
 
@@ -91,8 +91,12 @@ module LazyRails
91
91
  end
92
92
 
93
93
  def handle_command_finished(msg)
94
+ @panel_busy.delete(msg.panel)
95
+ @force_quit = false
96
+ @last_command_result[msg.panel] = msg.entry
94
97
  @command_log.add(msg.entry)
95
98
  set_flash("#{msg.entry.success? ? "\u2713" : "\u2717"} #{msg.entry.command} (#{msg.entry.duration_ms}ms)")
99
+ update_detail_content
96
100
 
97
101
  case msg.panel
98
102
  when :database, :models then return load_introspect_cmd
@@ -104,6 +108,8 @@ module LazyRails
104
108
  end
105
109
 
106
110
  def handle_test_finished(msg)
111
+ @panel_busy.delete(:tests)
112
+ @force_quit = false
107
113
  @command_log.add(msg.command_entry) if msg.command_entry
108
114
  tests_panel = find_panel(:tests)
109
115
  idx = tests_panel.items.index { |f| f.path == msg.path }
@@ -193,7 +193,7 @@ module LazyRails
193
193
  when "e"
194
194
  item = current_panel.selected_item
195
195
  if item && item.status == "scheduled"
196
- start_confirmation("Dispatch scheduled job ##{item.id} now?", tier: :green)
196
+ start_confirmation("Dispatch scheduled job ##{item.id} now?", tier: :yellow)
197
197
  @pending_job_action = PendingJobAction.new(action: :dispatch, job_id: item.id)
198
198
  end
199
199
  when "f"
@@ -540,7 +540,7 @@ module LazyRails
540
540
  item = current_panel.selected_item
541
541
  return unless item && item.status == "scheduled"
542
542
 
543
- start_confirmation("Dispatch scheduled job ##{item.id} now?", tier: :green)
543
+ start_confirmation("Dispatch scheduled job ##{item.id} now?", tier: :yellow)
544
544
  @pending_job_action = PendingJobAction.new(action: :dispatch, job_id: item.id)
545
545
  end
546
546
 
@@ -34,8 +34,14 @@ module LazyRails
34
34
 
35
35
  box_lines = box.lines
36
36
  if box_lines.any?
37
- title = " #{panel.title} "
38
- title_styled = Chamomile::Style.new.foreground(border_color).bold.render(title)
37
+ running = @panel_busy.include?(panel.type)
38
+ title_label = running ? "#{panel.title} \u25CF" : panel.title
39
+ title = " #{title_label} "
40
+ title_styled = if running
41
+ Chamomile::Style.new.foreground("#e5c07b").bold.render(title)
42
+ else
43
+ Chamomile::Style.new.foreground(border_color).bold.render(title)
44
+ end
39
45
  box_lines[0] = inject_title(box_lines[0], title_styled, title.length)
40
46
  end
41
47
 
@@ -50,7 +56,8 @@ module LazyRails
50
56
  def panel_cache_key(panel, focused, width, height)
51
57
  base = [panel.items.object_id, panel.items.size, panel.cursor,
52
58
  panel.scroll_offset, panel.filter_text, panel.loading,
53
- panel.error, panel.title, focused, width, height]
59
+ panel.error, panel.title, focused, width, height,
60
+ @panel_busy.include?(panel.type)]
54
61
  case panel.type
55
62
  when :status
56
63
  base << @about_data.object_id
@@ -126,9 +133,21 @@ module LazyRails
126
133
 
127
134
  box_lines = box.lines
128
135
  if box_lines.any?
129
- title = " Detail "
136
+ scrollable = @detail_viewport.total_line_count > @detail_viewport.height
137
+ title = if scrollable
138
+ pct = (@detail_viewport.scroll_percent * 100).round
139
+ " Detail (#{pct}%) "
140
+ else
141
+ " Detail "
142
+ end
130
143
  title_styled = Chamomile::Style.new.foreground(border_color).bold.render(title)
131
144
  box_lines[0] = inject_title(box_lines[0], title_styled, title.length)
145
+
146
+ if scrollable && box_lines.size > 1
147
+ footer = " \u2191\u2193 scroll "
148
+ footer_styled = Chamomile::Style.new.foreground(border_color).render(footer)
149
+ box_lines[-1] = inject_title(box_lines[-1], footer_styled, footer.length)
150
+ end
132
151
  end
133
152
 
134
153
  box_lines.join
@@ -151,7 +170,10 @@ module LazyRails
151
170
  "Select a route."
152
171
  end
153
172
  when :database
154
- if item
173
+ if @panel_busy.include?(:database)
174
+ running_label = Chamomile::Style.new.foreground("#e5c07b").bold.render("\u25CF Running command...")
175
+ "#{running_label}\n\nOutput will appear when complete."
176
+ elsif item
155
177
  Views::DatabaseView.render_detail(item, @project.dir, width: detail_width,
156
178
  file_cache: @file_cache)
157
179
  else
@@ -160,11 +182,18 @@ module LazyRails
160
182
  when :models
161
183
  item ? Views::ModelsView.render_detail(item, width: detail_width) : "Select a model."
162
184
  when :tests
163
- item ? Views::TestsView.render_detail(item, width: detail_width) : "Select a test file."
185
+ if @panel_busy.include?(:tests) && item
186
+ running_label = Chamomile::Style.new.foreground("#e5c07b").bold.render("\u25CF Running...")
187
+ "#{item.path}\n\n#{running_label}\n\nOutput will appear here when complete."
188
+ elsif item
189
+ Views::TestsView.render_detail(item, width: detail_width)
190
+ else
191
+ "Select a test file."
192
+ end
164
193
  when :gems
165
194
  item ? Views::GemsView.render_detail(item, width: detail_width) : "Select a gem."
166
195
  when :rake
167
- item ? Views::RakeView.render_detail(item, width: detail_width) : "Select a rake task."
196
+ render_rake_detail(item, detail_width)
168
197
  when :console
169
198
  if item
170
199
  Views::ConsoleView.render_detail(item,
@@ -196,8 +225,8 @@ module LazyRails
196
225
  return Chamomile::Style.new.foreground("#e5c07b").width(@width).render(" #{@flash.message}".slice(0, @width)) if @flash.active?
197
226
 
198
227
  hints = [
199
- ["Tab", "navigate"], ["j/k", "scroll"], ["Enter", "select"],
200
- ["x", "actions"], ["L", "log"], ["?", "help"], ["q", "quit"]
228
+ ["Tab", "navigate"], ["j/k", "scroll"], ["J/K", "scroll detail"],
229
+ ["Enter", "select"], ["Shift+drag", "copy text"], ["x", "actions"], ["?", "help"], ["q", "quit"]
201
230
  ]
202
231
  bar = " #{hints.map do |key, desc|
203
232
  styled_key = Chamomile::Style.new.bold.foreground('#b48ead').render(key)
@@ -260,7 +289,7 @@ module LazyRails
260
289
  end
261
290
 
262
291
  def render_command_log_box
263
- content = @command_log_overlay.render(width: @width - 8)
292
+ content = @command_log_overlay.render(width: @width - 8, height: @height - 8)
264
293
 
265
294
  box_width = [@width - 4, 60].max
266
295
  box_width = [box_width, @width - 2].min
@@ -327,6 +356,18 @@ module LazyRails
327
356
  end
328
357
  end
329
358
 
359
+ def render_rake_detail(item, width)
360
+ return "Select a rake task." unless item
361
+
362
+ if @panel_busy.include?(:rake)
363
+ Views::RakeView.render_detail_running(item, width: width)
364
+ elsif @last_command_result[:rake]
365
+ Views::RakeView.render_detail_with_output(item, @last_command_result[:rake], width: width)
366
+ else
367
+ Views::RakeView.render_detail(item, width: width)
368
+ end
369
+ end
370
+
330
371
  def inject_title(top_line, styled_title, title_visible_len)
331
372
  ViewHelpers.inject_title(top_line, styled_title, title_visible_len)
332
373
  end
@@ -145,10 +145,23 @@ module LazyRails
145
145
 
146
146
  # Detect server startup across common Ruby servers
147
147
  def server_ready?(line)
148
- line.include?("Listening on") || # Puma
149
- line.include?("listening on") || # Falcon, Vite
150
- line.include?("port=") || # WEBrick ("port=3000")
151
- line.include?("Ctrl-C to stop") # WEBrick, Thin
148
+ ready = line.include?("Listening on") || # Puma
149
+ line.include?("listening on") || # Falcon, Vite
150
+ line.include?("port=") || # WEBrick ("port=3000")
151
+ line.include?("Ctrl-C to stop") # WEBrick, Thin
152
+ detect_actual_port(line) if ready
153
+ ready
154
+ end
155
+
156
+ # Extract the real port from server output so we display the correct one
157
+ def detect_actual_port(line)
158
+ # Puma: "Listening on http://0.0.0.0:3100"
159
+ # Falcon: "listening on http://localhost:3000"
160
+ # WEBrick: "port=3100"
161
+ if (match = line.match(/:(\d{2,5})\b/))
162
+ detected = match[1].to_i
163
+ @port = detected if detected.positive? && detected < 65_536
164
+ end
152
165
  end
153
166
  end
154
167
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LazyRails
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.2"
5
5
  end
@@ -6,37 +6,51 @@ module LazyRails
6
6
  DIM = "#666666"
7
7
 
8
8
  def self.render(command_log, width:, selected: 0)
9
+ render_window(command_log, width: width, selected: selected,
10
+ offset: 0, limit: command_log.size)
11
+ end
12
+
13
+ def self.render_window(command_log, width:, selected: 0, offset: 0, limit: nil)
9
14
  return "No commands executed yet." if command_log.empty?
10
15
 
11
- command_log.entries.each_with_index.map do |entry, i|
12
- icon = entry.success? ? "\u2713" : "\u2717"
13
- color = entry.success? ? "#04b575" : "#ff6347"
14
- duration = format("%.1fs", entry.duration_ms / 1000.0)
15
- cmd_text = ViewHelpers.truncate(entry.command, width - 12)
16
- text = "#{icon} #{cmd_text.ljust(width - 12)} #{duration}"
17
-
18
- lines = []
19
- if i == selected
20
- lines << ViewHelpers.selected_style.render(text)
21
- else
22
- styled_icon = Chamomile::Style.new.foreground(color).render(icon)
23
- lines << "#{styled_icon} #{cmd_text.ljust(width - 12)} #{duration}"
24
- end
25
-
26
- if entry.annotation
27
- annotation_line = " \u21b3 #{entry.annotation}"
28
- lines << Chamomile::Style.new.foreground(DIM).render(annotation_line)
29
- end
30
-
31
- if entry.undo_command
32
- undo_line = " \u21b3 Undo: #{entry.undo_command.join(' ')}"
33
- lines << Chamomile::Style.new.foreground(DIM).render(undo_line)
34
- end
35
-
36
- lines.join("\n")
16
+ entries = command_log.entries
17
+ limit ||= entries.size
18
+ visible = entries[offset, limit] || []
19
+
20
+ visible.each_with_index.map do |entry, i|
21
+ actual_index = i + offset
22
+ render_entry(entry, width: width, selected: actual_index == selected)
37
23
  end.join("\n")
38
24
  end
39
25
 
26
+ def self.render_entry(entry, width:, selected:)
27
+ icon = entry.success? ? "\u2713" : "\u2717"
28
+ color = entry.success? ? "#04b575" : "#ff6347"
29
+ duration = format("%.1fs", entry.duration_ms / 1000.0)
30
+ cmd_text = ViewHelpers.truncate(entry.command, width - 12)
31
+ text = "#{icon} #{cmd_text.ljust(width - 12)} #{duration}"
32
+
33
+ lines = []
34
+ if selected
35
+ lines << ViewHelpers.selected_style.render(text)
36
+ else
37
+ styled_icon = Chamomile::Style.new.foreground(color).render(icon)
38
+ lines << "#{styled_icon} #{cmd_text.ljust(width - 12)} #{duration}"
39
+ end
40
+
41
+ if entry.annotation
42
+ annotation_line = " \u21b3 #{entry.annotation}"
43
+ lines << Chamomile::Style.new.foreground(DIM).render(annotation_line)
44
+ end
45
+
46
+ if entry.undo_command
47
+ undo_line = " \u21b3 Undo: #{entry.undo_command.join(' ')}"
48
+ lines << Chamomile::Style.new.foreground(DIM).render(undo_line)
49
+ end
50
+
51
+ lines.join("\n")
52
+ end
53
+
40
54
  def self.render_detail(entry, width:)
41
55
  lines = []
42
56
  lines << "Command: #{entry.command}"
@@ -59,19 +73,31 @@ module LazyRails
59
73
  if entry.stdout && !entry.stdout.empty?
60
74
  lines << ""
61
75
  lines << "Output:"
62
- lines << ("-" * [width - 4, 40].min)
63
- lines << entry.stdout
76
+ lines << ("\u2500" * [width - 4, 40].min)
77
+ truncated_output(entry.stdout, max_lines: 20).each { |l| lines << l }
64
78
  end
65
79
 
66
80
  if entry.stderr && !entry.stderr.empty?
67
81
  lines << ""
68
82
  lines << "Errors:"
69
- lines << ("-" * [width - 4, 40].min)
70
- lines << entry.stderr
83
+ lines << ("\u2500" * [width - 4, 40].min)
84
+ truncated_output(entry.stderr, max_lines: 15).each { |l| lines << l }
71
85
  end
72
86
 
73
87
  lines.join("\n")
74
88
  end
89
+
90
+ def self.truncated_output(text, max_lines:)
91
+ all_lines = text.lines.map(&:rstrip)
92
+ if all_lines.size > max_lines
93
+ shown = all_lines.first(max_lines)
94
+ remaining = all_lines.size - max_lines
95
+ shown << Chamomile::Style.new.foreground(DIM).render("... #{remaining} more lines (see full output in detail pane)")
96
+ shown
97
+ else
98
+ all_lines
99
+ end
100
+ end
75
101
  end
76
102
  end
77
103
  end
@@ -36,12 +36,54 @@ module LazyRails
36
36
  def self.render_detail(task, width:)
37
37
  lines = []
38
38
  lines << task.name
39
- lines << ("=" * [width - 4, 40].min)
39
+ lines << ("\u2500" * [width - 4, 40].min)
40
40
  lines << ""
41
41
  lines << "Description: #{task.description.to_s.empty? ? '(none)' : task.description}"
42
42
  lines << "Source: #{task.source.to_s.empty? ? '(unknown)' : task.source}"
43
43
  lines << ""
44
- lines << "Run with: bin/rails #{task.name}"
44
+ lines << Chamomile::Style.new.foreground("#666666").render("Press Enter to run")
45
+ lines.join("\n")
46
+ end
47
+
48
+ def self.render_detail_running(task, width:)
49
+ lines = []
50
+ lines << task.name
51
+ lines << ("\u2500" * [width - 4, 40].min)
52
+ lines << ""
53
+ lines << Chamomile::Style.new.foreground("#e5c07b").bold.render("\u25CF Running...")
54
+ lines << ""
55
+ lines << Chamomile::Style.new.foreground("#666666").render("Output will appear here when complete.")
56
+ lines.join("\n")
57
+ end
58
+
59
+ def self.render_detail_with_output(task, result, width:)
60
+ lines = []
61
+ lines << task.name
62
+ lines << ("\u2500" * [width - 4, 40].min)
63
+ lines << ""
64
+ lines << "Description: #{task.description.to_s.empty? ? '(none)' : task.description}"
65
+ lines << "Source: #{task.source.to_s.empty? ? '(unknown)' : task.source}"
66
+ lines << ""
67
+ lines << ("\u2500" * [width - 4, 40].min)
68
+
69
+ lines << if result.success?
70
+ Chamomile::Style.new.foreground("#98c379").bold.render("\u2713 #{result.command} (#{result.duration_ms}ms)")
71
+ else
72
+ Chamomile::Style.new.foreground("#ff6347").bold.render("\u2717 #{result.command} (exit #{result.exit_code})")
73
+ end
74
+ lines << ""
75
+
76
+ output = result.stdout.to_s.strip
77
+ error_output = result.stderr.to_s.strip
78
+ output.each_line { |l| lines << l.rstrip } unless output.empty?
79
+ unless error_output.empty?
80
+ lines << "" unless output.empty?
81
+ error_output.each_line do |l|
82
+ lines << Chamomile::Style.new.foreground("#ff6347").render(l.rstrip)
83
+ end
84
+ end
85
+ lines << "(no output)" if output.empty? && error_output.empty?
86
+
45
87
  lines.join("\n")
46
88
  end
47
89
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lazyrails-tui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jack Killilea
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-13 00:00:00.000000000 Z
11
+ date: 2026-03-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: chamomile