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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +113 -1
- data/lib/ruby_coded/chat/app.rb +35 -34
- data/lib/ruby_coded/chat/bridge_common/auto_switch.rb +45 -0
- data/lib/ruby_coded/chat/bridge_common/mode_transitions.rb +70 -0
- data/lib/ruby_coded/chat/bridge_common/tool_flow.rb +61 -0
- data/lib/ruby_coded/chat/bridge_common.rb +31 -0
- data/lib/ruby_coded/chat/bridge_factory.rb +61 -0
- data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +1 -20
- data/lib/ruby_coded/chat/codex_bridge/request_builder.rb +3 -15
- data/lib/ruby_coded/chat/codex_bridge/tool_approval.rb +8 -32
- data/lib/ruby_coded/chat/codex_bridge/tool_handling.rb +3 -34
- data/lib/ruby_coded/chat/codex_bridge.rb +31 -46
- data/lib/ruby_coded/chat/command_handler/agent_commands.rb +3 -3
- data/lib/ruby_coded/chat/command_handler/plan_commands.rb +1 -1
- data/lib/ruby_coded/chat/command_handler/skill_commands.rb +87 -0
- data/lib/ruby_coded/chat/command_handler.rb +3 -0
- data/lib/ruby_coded/chat/llm_bridge/chat_configuration.rb +38 -0
- data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +3 -32
- data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +9 -50
- data/lib/ruby_coded/chat/llm_bridge.rb +31 -65
- data/lib/ruby_coded/chat/prompt_builder.rb +66 -0
- data/lib/ruby_coded/chat/renderer/chat_panel.rb +48 -62
- data/lib/ruby_coded/chat/renderer/chat_panel_cache.rb +35 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_formatting.rb +72 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_layout.rb +81 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_sections.rb +93 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +20 -34
- data/lib/ruby_coded/chat/renderer/chat_panel_thinking_render.rb +49 -0
- data/lib/ruby_coded/chat/renderer/rich_text.rb +92 -0
- data/lib/ruby_coded/chat/renderer/rich_text_inline.rb +81 -0
- data/lib/ruby_coded/chat/renderer.rb +12 -0
- data/lib/ruby_coded/chat/runtime_mode.rb +97 -0
- data/lib/ruby_coded/commands/core_provider.rb +7 -0
- data/lib/ruby_coded/commands.rb +11 -0
- data/lib/ruby_coded/plugins.rb +10 -1
- data/lib/ruby_coded/skills/catalog.rb +103 -0
- data/lib/ruby_coded/skills/markdown_loader.rb +112 -0
- data/lib/ruby_coded/skills/prompt_formatter.rb +42 -0
- data/lib/ruby_coded/skills/skill_definition.rb +29 -0
- data/lib/ruby_coded/skills.rb +18 -0
- data/lib/ruby_coded/tools/base_tool.rb +14 -0
- data/lib/ruby_coded/tools/delete_path_tool.rb +5 -8
- data/lib/ruby_coded/tools/edit_file_tool.rb +6 -8
- data/lib/ruby_coded/tools/execution_pipeline.rb +60 -0
- data/lib/ruby_coded/tools/execution_policy.rb +89 -0
- data/lib/ruby_coded/tools/write_file_tool.rb +6 -9
- data/lib/ruby_coded/version.rb +1 -1
- data/lib/ruby_coded.rb +1 -0
- 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 =
|
|
25
|
-
MAX_TOTAL_TOOL_ROUNDS =
|
|
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 :
|
|
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
|
-
@
|
|
37
|
-
@
|
|
38
|
-
|
|
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
|
-
@
|
|
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 @
|
|
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
|
|
110
|
-
@
|
|
111
|
-
@
|
|
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
|
|
115
|
-
@
|
|
116
|
-
apply_mode_config!(@chat)
|
|
117
|
-
end
|
|
86
|
+
def reset_call_counts
|
|
87
|
+
@policy.reset_counters!
|
|
118
88
|
end
|
|
119
89
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
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
|
-
|
|
9
|
+
STICKY_HEADER_TITLE = "current prompt"
|
|
10
|
+
STICKY_HEADER_HEIGHT = 4
|
|
12
11
|
|
|
13
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
49
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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
|