ruby_coded 0.3.1 → 0.4.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -0
  3. data/README.md +113 -1
  4. data/lib/ruby_coded/chat/app.rb +35 -34
  5. data/lib/ruby_coded/chat/bridge_common/auto_switch.rb +45 -0
  6. data/lib/ruby_coded/chat/bridge_common/mode_transitions.rb +70 -0
  7. data/lib/ruby_coded/chat/bridge_common/tool_flow.rb +61 -0
  8. data/lib/ruby_coded/chat/bridge_common.rb +31 -0
  9. data/lib/ruby_coded/chat/bridge_factory.rb +61 -0
  10. data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +1 -20
  11. data/lib/ruby_coded/chat/codex_bridge/request_builder.rb +3 -15
  12. data/lib/ruby_coded/chat/codex_bridge/tool_approval.rb +8 -32
  13. data/lib/ruby_coded/chat/codex_bridge/tool_handling.rb +3 -34
  14. data/lib/ruby_coded/chat/codex_bridge.rb +31 -46
  15. data/lib/ruby_coded/chat/command_handler/agent_commands.rb +3 -3
  16. data/lib/ruby_coded/chat/command_handler/plan_commands.rb +1 -1
  17. data/lib/ruby_coded/chat/command_handler/skill_commands.rb +87 -0
  18. data/lib/ruby_coded/chat/command_handler.rb +3 -0
  19. data/lib/ruby_coded/chat/llm_bridge/chat_configuration.rb +38 -0
  20. data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +3 -32
  21. data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +9 -50
  22. data/lib/ruby_coded/chat/llm_bridge.rb +31 -65
  23. data/lib/ruby_coded/chat/prompt_builder.rb +66 -0
  24. data/lib/ruby_coded/chat/renderer/chat_panel.rb +48 -62
  25. data/lib/ruby_coded/chat/renderer/chat_panel_cache.rb +35 -0
  26. data/lib/ruby_coded/chat/renderer/chat_panel_formatting.rb +72 -0
  27. data/lib/ruby_coded/chat/renderer/chat_panel_layout.rb +81 -0
  28. data/lib/ruby_coded/chat/renderer/chat_panel_sections.rb +93 -0
  29. data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +20 -34
  30. data/lib/ruby_coded/chat/renderer/chat_panel_thinking_render.rb +49 -0
  31. data/lib/ruby_coded/chat/renderer/rich_text.rb +92 -0
  32. data/lib/ruby_coded/chat/renderer/rich_text_inline.rb +81 -0
  33. data/lib/ruby_coded/chat/renderer.rb +12 -0
  34. data/lib/ruby_coded/chat/runtime_mode.rb +97 -0
  35. data/lib/ruby_coded/commands/core_provider.rb +7 -0
  36. data/lib/ruby_coded/commands.rb +11 -0
  37. data/lib/ruby_coded/plugins.rb +10 -1
  38. data/lib/ruby_coded/skills/catalog.rb +103 -0
  39. data/lib/ruby_coded/skills/markdown_loader.rb +112 -0
  40. data/lib/ruby_coded/skills/prompt_formatter.rb +42 -0
  41. data/lib/ruby_coded/skills/skill_definition.rb +29 -0
  42. data/lib/ruby_coded/skills.rb +18 -0
  43. data/lib/ruby_coded/tools/base_tool.rb +14 -0
  44. data/lib/ruby_coded/tools/delete_path_tool.rb +5 -8
  45. data/lib/ruby_coded/tools/edit_file_tool.rb +6 -8
  46. data/lib/ruby_coded/tools/execution_pipeline.rb +60 -0
  47. data/lib/ruby_coded/tools/execution_policy.rb +89 -0
  48. data/lib/ruby_coded/tools/write_file_tool.rb +6 -9
  49. data/lib/ruby_coded/version.rb +1 -1
  50. data/lib/ruby_coded.rb +1 -0
  51. metadata +25 -2
@@ -6,36 +6,43 @@ require_relative "../tools/system_prompt"
6
6
  require_relative "../tools/plan_system_prompt"
7
7
  require_relative "../tools/agent_cancelled_error"
8
8
  require_relative "../tools/agent_iteration_limit_error"
9
+ require_relative "../tools/execution_policy"
10
+ require_relative "../skills"
9
11
  require_relative "plan_clarification_parser"
12
+ require_relative "runtime_mode"
13
+ require_relative "bridge_common"
14
+ require_relative "prompt_builder"
10
15
  require_relative "llm_bridge/tool_call_handling"
11
16
  require_relative "llm_bridge/streaming_retries"
12
17
  require_relative "llm_bridge/plan_mode"
18
+ require_relative "llm_bridge/chat_configuration"
13
19
 
14
20
  module RubyCoded
15
21
  module Chat
16
22
  # Sends prompts to RubyLLM and streams assistant output into State.
17
23
  class LLMBridge
24
+ include BridgeCommon
18
25
  include ToolCallHandling
19
26
  include StreamingRetries
20
27
  include PlanMode
28
+ include ChatConfiguration
21
29
 
22
30
  MAX_RATE_LIMIT_RETRIES = 2
23
31
  RATE_LIMIT_BASE_DELAY = 2
24
- MAX_WRITE_TOOL_ROUNDS = 50
25
- MAX_TOTAL_TOOL_ROUNDS = 200
26
- TOOL_ROUNDS_WARNING_THRESHOLD = 0.8
32
+ MAX_WRITE_TOOL_ROUNDS = Tools::ExecutionPolicy::MAX_WRITE_TOOL_ROUNDS
33
+ MAX_TOTAL_TOOL_ROUNDS = Tools::ExecutionPolicy::MAX_TOTAL_TOOL_ROUNDS
27
34
  MAX_TOOL_RESULT_CHARS = 10_000
28
35
 
29
- attr_reader :agentic_mode, :plan_mode, :project_root
36
+ attr_reader :mode, :project_root
30
37
 
31
- def initialize(state, project_root: Dir.pwd)
38
+ def initialize(state, project_root: Dir.pwd, skill_catalog: nil)
32
39
  @state = state
33
40
  @chat_mutex = Mutex.new
34
41
  @cancel_requested = false
35
42
  @project_root = project_root
36
- @agentic_mode = false
37
- @plan_mode = false
38
- @tool_registry = Tools::Registry.new(project_root: @project_root)
43
+ @skill_catalog = skill_catalog || RubyCoded::Skills::Catalog.new(project_root: @project_root)
44
+ @mode = RuntimeMode.chat
45
+ setup_agent_pipeline!
39
46
  reset_chat!(@state.model)
40
47
  end
41
48
 
@@ -46,33 +53,11 @@ module RubyCoded
46
53
  end
47
54
  end
48
55
 
49
- def toggle_agentic_mode!(enabled)
50
- @agentic_mode = enabled
51
- @state.agentic_mode = enabled
52
- if enabled && @plan_mode
53
- @plan_mode = false
54
- @state.deactivate_plan_mode!
55
- end
56
- @state.disable_auto_approve! unless enabled
57
- reconfigure_chat!
58
- end
59
-
60
56
  def reset_agent_session!
61
- @tool_call_count = 0
62
- @write_tool_call_count = 0
57
+ @policy.reset_counters!
63
58
  reset_chat!(@state.model)
64
59
  end
65
60
 
66
- def toggle_plan_mode!(enabled)
67
- @plan_mode = enabled
68
- if enabled && @agentic_mode
69
- @agentic_mode = false
70
- @state.agentic_mode = false
71
- @state.disable_auto_approve!
72
- end
73
- reconfigure_chat!
74
- end
75
-
76
61
  def send_async(input)
77
62
  auto_switch_to_agent! if should_auto_switch_to_agent?(input)
78
63
  reset_call_counts
@@ -80,51 +65,32 @@ module RubyCoded
80
65
  Thread.new do
81
66
  response = attempt_with_retries(chat, input)
82
67
  update_response_tokens(response)
83
- post_process_plan_response if @plan_mode && !@cancel_requested
68
+ post_process_plan_response if @mode.plan? && !@cancel_requested
84
69
  ensure
85
70
  @state.streaming = false
86
71
  end
87
72
  end
88
73
 
89
- def cancel!
90
- @cancel_requested = true
91
- @state.mutex.synchronize { @state.tool_cv.signal }
92
- end
93
-
94
- def approve_tool!
95
- @state.tool_confirmation_response = :approved
96
- end
97
-
98
- def approve_all_tools!
99
- @state.enable_auto_approve!
100
- @state.tool_confirmation_response = :approved
101
- end
102
-
103
- def reject_tool!
104
- @state.tool_confirmation_response = :rejected
105
- end
106
-
107
74
  private
108
75
 
109
- def reset_call_counts
110
- @tool_call_count = 0
111
- @write_tool_call_count = 0
76
+ def setup_agent_pipeline!
77
+ @tool_registry = Tools::Registry.new(project_root: @project_root)
78
+ @policy = Tools::ExecutionPolicy.new(state: @state, registry: @tool_registry)
79
+ @prompt_builder = PromptBuilder.new(
80
+ project_root: @project_root,
81
+ skill_catalog: @skill_catalog,
82
+ chat_base: :agentic
83
+ )
112
84
  end
113
85
 
114
- def reconfigure_chat!
115
- @chat_mutex.synchronize do
116
- apply_mode_config!(@chat)
117
- end
86
+ def reset_call_counts
87
+ @policy.reset_counters!
118
88
  end
119
89
 
120
- def apply_mode_config!(chat)
121
- if @agentic_mode
122
- configure_agentic!(chat)
123
- elsif @plan_mode
124
- configure_plan!(chat)
125
- else
126
- chat.with_tools(replace: true)
127
- end
90
+ # Called by BridgeCommon after any mode transition to
91
+ # re-apply tools/instructions on the RubyLLM chat.
92
+ def after_mode_change!
93
+ reconfigure_chat!
128
94
  end
129
95
  end
130
96
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tools/system_prompt"
4
+ require_relative "../tools/plan_system_prompt"
5
+ require_relative "../tools/execution_policy"
6
+ require_relative "../skills/prompt_formatter"
7
+
8
+ module RubyCoded
9
+ module Chat
10
+ # Assembles the final system instruction set for a given RuntimeMode.
11
+ #
12
+ # Composes the base prompt (agent/plan/chat) with project skills
13
+ # for the same mode. Both LLMBridge and CodexBridge use this to
14
+ # keep prompt logic in a single place.
15
+ class PromptBuilder
16
+ DEFAULT_CHAT_INSTRUCTIONS = "You are a helpful coding assistant. " \
17
+ "Answer concisely and provide code examples when relevant."
18
+
19
+ MAX_WRITE_ROUNDS = Tools::ExecutionPolicy::MAX_WRITE_TOOL_ROUNDS
20
+ MAX_TOTAL_ROUNDS = Tools::ExecutionPolicy::MAX_TOTAL_TOOL_ROUNDS
21
+
22
+ # `chat_base`:
23
+ # - `:agentic` — chat mode still uses the full agentic prompt
24
+ # (matches LLMBridge, where tools may still be relevant),
25
+ # - `:simple` — chat mode uses a light default instruction
26
+ # (matches the Codex Responses API).
27
+ def initialize(project_root:, skill_catalog:, chat_base: :agentic)
28
+ @project_root = project_root
29
+ @skill_catalog = skill_catalog
30
+ @chat_base = chat_base
31
+ end
32
+
33
+ def build(mode)
34
+ base = base_instructions(mode)
35
+ skills = @skill_catalog.relevant_skills_for(mode: mode.skill_mode)
36
+ Skills::PromptFormatter.append(base, skills)
37
+ end
38
+
39
+ private
40
+
41
+ def base_instructions(mode)
42
+ case mode.name
43
+ when :agent then agentic_prompt
44
+ when :plan then plan_prompt
45
+ else chat_prompt
46
+ end
47
+ end
48
+
49
+ def chat_prompt
50
+ @chat_base == :simple ? DEFAULT_CHAT_INSTRUCTIONS : agentic_prompt
51
+ end
52
+
53
+ def agentic_prompt
54
+ Tools::SystemPrompt.build(
55
+ project_root: @project_root,
56
+ max_write_rounds: MAX_WRITE_ROUNDS,
57
+ max_total_rounds: MAX_TOTAL_ROUNDS
58
+ )
59
+ end
60
+
61
+ def plan_prompt
62
+ Tools::PlanSystemPrompt.build(project_root: @project_root)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,28 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "unicode/display_width"
4
-
5
3
  module RubyCoded
6
4
  module Chat
7
5
  class Renderer
8
- # Core chat-panel rendering: message formatting, scroll management,
9
- # and the main chat display area.
6
+ # Core chat-panel rendering pipeline: widget composition, caching,
7
+ # and scroll-aware layout for the main chat display area.
10
8
  module ChatPanel
11
- private
9
+ STICKY_HEADER_TITLE = "current prompt"
10
+ STICKY_HEADER_HEIGHT = 4
12
11
 
13
- def init_render_cache
14
- @cached_formatted_text = nil
15
- @cached_format_gen = -1
16
- end
17
-
18
- def cached_formatted_text(messages)
19
- gen = @state.message_generation
20
- if gen != @cached_format_gen
21
- @cached_formatted_text = format_messages_text(messages)
22
- @cached_format_gen = gen
23
- end
24
- @cached_formatted_text
25
- end
12
+ private
26
13
 
27
14
  def render_chat_panel(frame, area)
28
15
  init_render_cache if @cached_format_gen.nil?
@@ -36,20 +23,36 @@ module RubyCoded
36
23
  end
37
24
 
38
25
  def render_chat_standard(frame, area, messages)
39
- text = messages.empty? ? cover_banner : cached_formatted_text(messages)
40
- render_text_panel(frame, area, text, !messages.empty?)
26
+ return render_text_panel(frame, area, cover_banner, false) if messages.empty?
27
+
28
+ sections = cached_chat_sections(messages)
29
+ sticky = sticky_header_for(area, sections)
30
+
31
+ if sticky
32
+ header_area, body_area = split_chat_sticky(area)
33
+ render_sticky_header(frame, header_area, sticky[:header_text], sticky[:header_rich_lines])
34
+ render_sections_panel(frame, body_area, sections)
35
+ else
36
+ render_sections_panel(frame, area, sections)
37
+ end
41
38
  end
42
39
 
43
40
  def render_messages_in_area(frame, area, messages)
44
- text = messages.empty? ? cover_banner : format_messages_text(messages)
45
- render_text_panel(frame, area, text, !messages.empty?)
41
+ return render_text_panel(frame, area, cover_banner, false) if messages.empty?
42
+
43
+ render_sections_panel(frame, area, build_chat_sections(messages))
46
44
  end
47
45
 
48
- def render_text_panel(frame, area, text, scrollable)
49
- scroll_y = scrollable ? chat_scroll_y(area, text) : 0
46
+ def render_sections_panel(frame, area, sections)
47
+ rich_lines = sections_to_rich_lines(sections)
48
+ render_text_panel(frame, area, rich_lines, true)
49
+ end
50
+
51
+ def render_text_panel(frame, area, text_or_lines, scrollable)
52
+ scroll_y = scrollable ? chat_scroll_y(area, text_or_lines) : 0
50
53
 
51
54
  widget = @tui.paragraph(
52
- text: text,
55
+ text: text_or_lines,
53
56
  wrap: scrollable,
54
57
  scroll: [scroll_y, 0],
55
58
  block: @tui.block(title: chat_panel_title, borders: [:all])
@@ -57,10 +60,28 @@ module RubyCoded
57
60
  frame.render_widget(widget, area)
58
61
  end
59
62
 
60
- def chat_scroll_y(area, text)
63
+ def render_sticky_header(frame, area, text, rich_lines = nil)
64
+ widget = @tui.paragraph(
65
+ text: rich_lines || text,
66
+ wrap: true,
67
+ scroll: [0, 0],
68
+ block: @tui.block(title: STICKY_HEADER_TITLE, borders: [:all])
69
+ )
70
+ frame.render_widget(widget, area)
71
+ end
72
+
73
+ def split_chat_sticky(area)
74
+ @tui.layout_split(
75
+ area,
76
+ direction: :vertical,
77
+ constraints: [@tui.constraint_length(STICKY_HEADER_HEIGHT), @tui.constraint_fill(1)]
78
+ )
79
+ end
80
+
81
+ def chat_scroll_y(area, text_or_lines)
61
82
  inner_height = [area.height - 2, 0].max
62
83
  inner_width = [area.width - 2, 0].max
63
- total_lines = count_wrapped_lines(text, inner_width)
84
+ total_lines = count_wrapped_lines(text_or_lines, inner_width)
64
85
  @state.update_scroll_metrics(total_lines: total_lines, visible_height: inner_height)
65
86
  compute_scroll_y(total_lines, inner_height)
66
87
  end
@@ -81,47 +102,12 @@ module RubyCoded
81
102
  messages.empty? ? cover_banner : cached_formatted_text(messages)
82
103
  end
83
104
 
84
- def format_messages_text(messages)
85
- messages.filter_map { |m| format_message(m) }.join("\n")
86
- end
87
-
88
- def chat_messages_text
89
- cached_formatted_text(@state.messages_snapshot)
90
- end
91
-
92
- def format_message(msg)
93
- case msg[:role]
94
- when :tool_call, :tool_pending, :tool_result then nil
95
- when :system then "--- #{msg[:content]}"
96
- when :user then "> #{msg[:content]}"
97
- when :assistant then format_assistant_message(msg[:content])
98
- else "#{msg[:role]}: #{msg[:content]}"
99
- end
100
- end
101
-
102
- def format_assistant_message(content)
103
- result = strip_think_tags(content)
104
- result.empty? ? nil : result
105
- end
106
-
107
105
  def compute_scroll_y(total_lines, visible_height)
108
106
  overflow = total_lines - visible_height
109
107
  return 0 if overflow <= 0
110
108
 
111
109
  [overflow - @state.scroll_offset, 0].max
112
110
  end
113
-
114
- def count_wrapped_lines(text, width)
115
- return 1 if width <= 0 || text.empty?
116
-
117
- text.split("\n", -1).sum do |line|
118
- line.empty? ? 1 : (display_width(line).to_f / width).ceil
119
- end
120
- end
121
-
122
- def display_width(line)
123
- Unicode::DisplayWidth.of(line)
124
- end
125
111
  end
126
112
  end
127
113
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class Renderer
6
+ # Generation-aware caching of built chat sections and their derived
7
+ # plain-text and rich-line representations.
8
+ module ChatPanelCache
9
+ private
10
+
11
+ def init_render_cache
12
+ @cached_chat_sections = nil
13
+ @cached_format_gen = -1
14
+ end
15
+
16
+ def cached_chat_sections(messages)
17
+ gen = @state.message_generation
18
+ if gen != @cached_format_gen
19
+ @cached_chat_sections = build_chat_sections(messages)
20
+ @cached_format_gen = gen
21
+ end
22
+ @cached_chat_sections
23
+ end
24
+
25
+ def cached_formatted_text(messages)
26
+ sections_to_text(cached_chat_sections(messages))
27
+ end
28
+
29
+ def cached_formatted_lines(messages)
30
+ sections_to_rich_lines(cached_chat_sections(messages))
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class Renderer
6
+ # Message-to-text formatting helpers used by the chat panel.
7
+ module ChatPanelFormatting
8
+ USER_LABEL = "YOU"
9
+
10
+ private
11
+
12
+ def format_messages_text(messages)
13
+ messages.filter_map { |m| format_message_plain(m) }.join("\n")
14
+ end
15
+
16
+ def format_message_plain(msg)
17
+ plain = format_message_rich(msg)
18
+ return nil unless plain
19
+
20
+ rich_text_plain(plain)
21
+ end
22
+
23
+ def format_message(msg)
24
+ format_message_plain(msg)
25
+ end
26
+
27
+ def format_message_rich(msg)
28
+ case msg[:role]
29
+ when :tool_call, :tool_pending, :tool_result then nil
30
+ when :system then format_system_message_rich(msg[:content])
31
+ when :user then format_user_message_rich(msg[:content])
32
+ when :assistant then format_assistant_message_rich(msg[:content])
33
+ else rich_text_lines("#{msg[:role]}: #{msg[:content]}", role: :assistant)
34
+ end
35
+ end
36
+
37
+ def format_system_message_rich(content)
38
+ rich_text_lines("--- #{content}", role: :system)
39
+ end
40
+
41
+ def format_user_message(content)
42
+ rich_text_plain(format_user_message_rich(content))
43
+ end
44
+
45
+ def format_user_message_rich(content)
46
+ body_lines = rich_text_lines(content, role: :user)
47
+ return [user_label_line] if body_lines.empty?
48
+
49
+ [user_label_line(body_lines.first), *(body_lines[1..] || [])]
50
+ end
51
+
52
+ def user_label_line(first_line = nil)
53
+ label_span = @tui.span(content: "[#{USER_LABEL}]#{" " if first_line}", style: base_text_style(:user_label))
54
+ spans = first_line ? [label_span, *Array(first_line.spans)] : [label_span]
55
+ @tui.line(spans: spans)
56
+ end
57
+
58
+ def format_assistant_message(content)
59
+ result = strip_think_tags(content)
60
+ result.empty? ? nil : result
61
+ end
62
+
63
+ def format_assistant_message_rich(content)
64
+ result = strip_think_tags(content)
65
+ return nil if result.empty?
66
+
67
+ rich_text_lines(result, role: :assistant)
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class Renderer
6
+ # Sticky-header computation and section layout helpers backing the
7
+ # chat panel's scroll-aware rendering.
8
+ module ChatPanelLayout
9
+ private
10
+
11
+ def sticky_header_for(area, sections)
12
+ return nil if sections.empty?
13
+ return nil if @state.scroll_offset <= 0
14
+
15
+ inner_width = [area.width - 2, 0].max
16
+ inner_height = [area.height - 2, 0].max
17
+ return nil if inner_width <= 0 || inner_height <= 0
18
+
19
+ layout = build_section_layout(sections, inner_width)
20
+ active = active_sticky_section(layout, inner_height)
21
+ return nil unless active
22
+
23
+ { header_text: active[:user_text], header_rich_lines: active[:user_rich_lines] }
24
+ end
25
+
26
+ def build_section_layout(sections, inner_width)
27
+ cursor = 0
28
+
29
+ sections.map do |section|
30
+ entry_layouts = section[:entries].map do |entry|
31
+ layout = layout_entry(entry, inner_width, cursor)
32
+ cursor = layout[:end_line] + 1
33
+ layout
34
+ end
35
+ section_layout(section, entry_layouts)
36
+ end
37
+ end
38
+
39
+ def layout_entry(entry, inner_width, start_line)
40
+ wrapped_lines = count_wrapped_lines(entry[:text], inner_width)
41
+ entry.merge(
42
+ wrapped_lines: wrapped_lines,
43
+ start_line: start_line,
44
+ end_line: start_line + wrapped_lines - 1
45
+ )
46
+ end
47
+
48
+ def section_layout(section, entry_layouts)
49
+ user_entry = entry_layouts.find { |entry| entry[:role] == :user }
50
+ {
51
+ user_text: section[:user_text],
52
+ user_rich_lines: section[:user_rich_lines],
53
+ entries: entry_layouts,
54
+ start_line: entry_layouts.first[:start_line],
55
+ end_line: entry_layouts.last[:end_line],
56
+ user_end_line: user_entry ? user_entry[:end_line] : nil
57
+ }
58
+ end
59
+
60
+ def active_sticky_section(layout, visible_height)
61
+ return nil if layout.empty?
62
+
63
+ total_lines = layout.last[:end_line] + 1
64
+ top_visible_line = compute_scroll_y(total_lines, visible_height)
65
+
66
+ layout.find do |section|
67
+ next false unless section[:user_text]
68
+ next false unless line_in_section?(top_visible_line, section)
69
+ next false if section[:user_end_line] && top_visible_line <= section[:user_end_line]
70
+
71
+ true
72
+ end
73
+ end
74
+
75
+ def line_in_section?(line_index, section)
76
+ line_index.between?(section[:start_line], section[:end_line])
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module RubyCoded
6
+ module Chat
7
+ class Renderer
8
+ # Section building, sticky-header computation, and line-measurement
9
+ # helpers backing the chat panel's scroll-aware rendering.
10
+ module ChatPanelSections
11
+ private
12
+
13
+ def build_chat_sections(messages)
14
+ sections = []
15
+ visible_index = 0
16
+
17
+ messages.each do |msg|
18
+ rich_lines = format_message_rich(msg)
19
+ next if rich_lines.nil?
20
+
21
+ append_section_entry(sections, msg, rich_lines, visible_index)
22
+ visible_index += 1
23
+ end
24
+
25
+ sections
26
+ end
27
+
28
+ def append_section_entry(sections, msg, rich_lines, visible_index)
29
+ plain_text = rich_text_plain(rich_lines)
30
+ section = next_section_for(sections, msg, plain_text, rich_lines)
31
+ section[:entries] << {
32
+ role: msg[:role],
33
+ text: plain_text,
34
+ rich_lines: rich_lines,
35
+ visible_index: visible_index
36
+ }
37
+ end
38
+
39
+ def next_section_for(sections, msg, text, rich_lines)
40
+ return sections.last if sections.any? && msg[:role] != :user
41
+
42
+ sections << {
43
+ user_text: msg[:role] == :user ? text : nil,
44
+ user_rich_lines: msg[:role] == :user ? rich_lines : nil,
45
+ entries: []
46
+ }
47
+ sections.last
48
+ end
49
+
50
+ def sections_to_text(sections)
51
+ sections.flat_map { |section| section[:entries].map { |entry| entry[:text] } }.join("\n")
52
+ end
53
+
54
+ def sections_to_rich_lines(sections)
55
+ sections.flat_map.with_index do |section, section_index|
56
+ lines = section[:entries].flat_map { |entry| Array(entry[:rich_lines]) }
57
+ section_index == sections.length - 1 ? lines : lines + [@tui.line(spans: [@tui.span(content: "")])]
58
+ end
59
+ end
60
+
61
+ def count_wrapped_lines(text_or_lines, width)
62
+ return 1 if width <= 0
63
+
64
+ if text_or_lines.is_a?(Array)
65
+ count_wrapped_rich_lines(text_or_lines, width)
66
+ else
67
+ count_wrapped_text_lines(text_or_lines.to_s, width)
68
+ end
69
+ end
70
+
71
+ def count_wrapped_rich_lines(lines, width)
72
+ return 1 if lines.empty?
73
+
74
+ lines.sum { |line| wrapped_line_count(rich_line_plain(line), width) }
75
+ end
76
+
77
+ def count_wrapped_text_lines(text, width)
78
+ return 1 if text.empty?
79
+
80
+ text.split("\n", -1).sum { |line| wrapped_line_count(line, width) }
81
+ end
82
+
83
+ def wrapped_line_count(line, width)
84
+ line.empty? ? 1 : (display_width(line).to_f / width).ceil
85
+ end
86
+
87
+ def display_width(line)
88
+ Unicode::DisplayWidth.of(line)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end