ruby_coded 0.1.1
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 +7 -0
- data/.github/workflows/ci.yml +76 -0
- data/.github/workflows/release.yml +24 -0
- data/.rubocop_todo.yml +122 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +140 -0
- data/Rakefile +12 -0
- data/exe/ruby_coded +6 -0
- data/lib/ruby_coded/auth/auth_manager.rb +145 -0
- data/lib/ruby_coded/auth/callback_servlet.rb +41 -0
- data/lib/ruby_coded/auth/credentials_store.rb +35 -0
- data/lib/ruby_coded/auth/oauth_callback_server.rb +38 -0
- data/lib/ruby_coded/auth/pkce.rb +19 -0
- data/lib/ruby_coded/auth/providers/anthropic.rb +32 -0
- data/lib/ruby_coded/auth/providers/openai.rb +55 -0
- data/lib/ruby_coded/chat/app/event_dispatch.rb +78 -0
- data/lib/ruby_coded/chat/app.rb +104 -0
- data/lib/ruby_coded/chat/command_handler/agent_commands.rb +53 -0
- data/lib/ruby_coded/chat/command_handler/history_commands.rb +38 -0
- data/lib/ruby_coded/chat/command_handler/model_commands.rb +91 -0
- data/lib/ruby_coded/chat/command_handler/plan_commands.rb +112 -0
- data/lib/ruby_coded/chat/command_handler/token_commands.rb +128 -0
- data/lib/ruby_coded/chat/command_handler/token_formatting.rb +26 -0
- data/lib/ruby_coded/chat/command_handler.rb +89 -0
- data/lib/ruby_coded/chat/help.txt +28 -0
- data/lib/ruby_coded/chat/input_handler/modal_inputs.rb +102 -0
- data/lib/ruby_coded/chat/input_handler/normal_mode_input.rb +116 -0
- data/lib/ruby_coded/chat/input_handler.rb +39 -0
- data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +73 -0
- data/lib/ruby_coded/chat/llm_bridge/streaming_retries.rb +86 -0
- data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +129 -0
- data/lib/ruby_coded/chat/llm_bridge.rb +131 -0
- data/lib/ruby_coded/chat/model_filter.rb +115 -0
- data/lib/ruby_coded/chat/plan_clarification_parser.rb +38 -0
- data/lib/ruby_coded/chat/renderer/chat_panel.rb +128 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +56 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +124 -0
- data/lib/ruby_coded/chat/renderer/model_selector.rb +96 -0
- data/lib/ruby_coded/chat/renderer/plan_clarifier.rb +112 -0
- data/lib/ruby_coded/chat/renderer/plan_clarifier_layout.rb +42 -0
- data/lib/ruby_coded/chat/renderer/status_bar.rb +47 -0
- data/lib/ruby_coded/chat/renderer.rb +64 -0
- data/lib/ruby_coded/chat/state/message_assistant.rb +77 -0
- data/lib/ruby_coded/chat/state/message_token_tracking.rb +57 -0
- data/lib/ruby_coded/chat/state/messages.rb +70 -0
- data/lib/ruby_coded/chat/state/model_selection.rb +79 -0
- data/lib/ruby_coded/chat/state/plan_tracking.rb +140 -0
- data/lib/ruby_coded/chat/state/scrollable.rb +42 -0
- data/lib/ruby_coded/chat/state/token_cost.rb +128 -0
- data/lib/ruby_coded/chat/state/tool_confirmation.rb +129 -0
- data/lib/ruby_coded/chat/state.rb +205 -0
- data/lib/ruby_coded/config/user_config.rb +110 -0
- data/lib/ruby_coded/errors/auth_error.rb +12 -0
- data/lib/ruby_coded/initializer/cover.rb +29 -0
- data/lib/ruby_coded/initializer.rb +52 -0
- data/lib/ruby_coded/plugins/base.rb +44 -0
- data/lib/ruby_coded/plugins/command_completion/input_extension.rb +30 -0
- data/lib/ruby_coded/plugins/command_completion/plugin.rb +27 -0
- data/lib/ruby_coded/plugins/command_completion/renderer_extension.rb +54 -0
- data/lib/ruby_coded/plugins/command_completion/state_extension.rb +90 -0
- data/lib/ruby_coded/plugins/registry.rb +88 -0
- data/lib/ruby_coded/plugins.rb +21 -0
- data/lib/ruby_coded/strategies/api_key_strategy.rb +39 -0
- data/lib/ruby_coded/strategies/base.rb +37 -0
- data/lib/ruby_coded/strategies/oauth_strategy.rb +106 -0
- data/lib/ruby_coded/tools/agent_cancelled_error.rb +7 -0
- data/lib/ruby_coded/tools/agent_iteration_limit_error.rb +7 -0
- data/lib/ruby_coded/tools/base_tool.rb +50 -0
- data/lib/ruby_coded/tools/create_directory_tool.rb +34 -0
- data/lib/ruby_coded/tools/delete_path_tool.rb +50 -0
- data/lib/ruby_coded/tools/edit_file_tool.rb +40 -0
- data/lib/ruby_coded/tools/list_directory_tool.rb +53 -0
- data/lib/ruby_coded/tools/plan_system_prompt.rb +72 -0
- data/lib/ruby_coded/tools/read_file_tool.rb +54 -0
- data/lib/ruby_coded/tools/registry.rb +66 -0
- data/lib/ruby_coded/tools/run_command_tool.rb +75 -0
- data/lib/ruby_coded/tools/system_prompt.rb +32 -0
- data/lib/ruby_coded/tools/tool_rejected_error.rb +7 -0
- data/lib/ruby_coded/tools/write_file_tool.rb +31 -0
- data/lib/ruby_coded/version.rb +10 -0
- data/lib/ruby_coded.rb +16 -0
- data/sig/ruby_coded.rbs +4 -0
- metadata +206 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class Renderer
|
|
6
|
+
# Thinking-panel detection, parsing, and rendering for agent
|
|
7
|
+
# response cycles that include tool activity or open <think> blocks.
|
|
8
|
+
module ChatPanelThinking
|
|
9
|
+
THINK_OPEN = "<think>"
|
|
10
|
+
THINK_CLOSE = "</think>"
|
|
11
|
+
TOOL_ROLES = %i[tool_call tool_pending tool_result].freeze
|
|
12
|
+
MAX_THINKING_MESSAGES = 20
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def thinking_in_progress?(messages)
|
|
17
|
+
cycle = current_cycle_messages(messages)
|
|
18
|
+
return false if cycle.empty?
|
|
19
|
+
|
|
20
|
+
cycle.any? { |m| TOOL_ROLES.include?(m[:role]) } ||
|
|
21
|
+
cycle.any? { |m| m[:role] == :assistant && open_think_block?(m[:content]) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def current_cycle_messages(messages)
|
|
25
|
+
last_user_idx = messages.rindex { |m| m[:role] == :user }
|
|
26
|
+
return messages unless last_user_idx
|
|
27
|
+
|
|
28
|
+
messages[(last_user_idx + 1)..]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def tail_of_cycle(cycle)
|
|
32
|
+
return cycle if cycle.length <= MAX_THINKING_MESSAGES
|
|
33
|
+
|
|
34
|
+
truncated = cycle.last(MAX_THINKING_MESSAGES)
|
|
35
|
+
omitted = cycle.length - MAX_THINKING_MESSAGES
|
|
36
|
+
header = { role: :system, content: "... #{omitted} earlier messages omitted ...", timestamp: Time.now,
|
|
37
|
+
**RubyCoded::Chat::State::Messages::ZERO_TOKEN_USAGE }
|
|
38
|
+
[header] + truncated
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def open_think_block?(content)
|
|
42
|
+
content.include?(THINK_OPEN) && !content.include?(THINK_CLOSE)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse_thinking_content(content)
|
|
46
|
+
think_start = content.index(THINK_OPEN)
|
|
47
|
+
return [nil, content, true] unless think_start
|
|
48
|
+
|
|
49
|
+
think_end = content.index(THINK_CLOSE)
|
|
50
|
+
if think_end
|
|
51
|
+
parse_closed_think_block(content, think_start, think_end)
|
|
52
|
+
else
|
|
53
|
+
thinking = content[(think_start + THINK_OPEN.length)..]
|
|
54
|
+
[thinking, content[0...think_start].strip, false]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_closed_think_block(content, think_start, think_end)
|
|
59
|
+
thinking = content[(think_start + THINK_OPEN.length)...think_end]
|
|
60
|
+
before = content[0...think_start]
|
|
61
|
+
after = content[(think_end + THINK_CLOSE.length)..]
|
|
62
|
+
[thinking, (before + after).strip, true]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def strip_think_tags(content)
|
|
66
|
+
_, result, = parse_thinking_content(content)
|
|
67
|
+
result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def format_thinking_text(cycle_messages)
|
|
71
|
+
cycle_messages.map { |m| format_thinking_message(m) }.join("\n")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def format_thinking_message(msg)
|
|
75
|
+
case msg[:role]
|
|
76
|
+
when :assistant then msg[:content].gsub(%r{</?think>}, "")
|
|
77
|
+
when :tool_call then ">> #{msg[:content]}"
|
|
78
|
+
when :tool_pending then "?? #{msg[:content]}"
|
|
79
|
+
when :tool_result then " #{msg[:content]}"
|
|
80
|
+
when :system then "--- #{msg[:content]}"
|
|
81
|
+
else msg[:content]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_chat_with_thinking(frame, area, messages)
|
|
86
|
+
full_cycle = current_cycle_messages(messages)
|
|
87
|
+
cycle = tail_of_cycle(full_cycle)
|
|
88
|
+
prior = messages[0...(messages.length - full_cycle.length)]
|
|
89
|
+
chat_area, thinking_area = split_chat_thinking(area)
|
|
90
|
+
|
|
91
|
+
render_messages_in_area(frame, chat_area, prior)
|
|
92
|
+
render_thinking_panel(frame, thinking_area, format_thinking_text(cycle))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def split_chat_thinking(area)
|
|
96
|
+
@tui.layout_split(
|
|
97
|
+
area,
|
|
98
|
+
direction: :vertical,
|
|
99
|
+
constraints: [@tui.constraint_fill(3), @tui.constraint_fill(2)]
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def render_thinking_panel(frame, area, thinking_text)
|
|
104
|
+
scroll_y = thinking_scroll_y(area, thinking_text)
|
|
105
|
+
|
|
106
|
+
widget = @tui.paragraph(
|
|
107
|
+
text: thinking_text,
|
|
108
|
+
wrap: true,
|
|
109
|
+
scroll: [scroll_y, 0],
|
|
110
|
+
block: @tui.block(title: "thinking...", borders: [:all])
|
|
111
|
+
)
|
|
112
|
+
frame.render_widget(widget, area)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def thinking_scroll_y(area, text)
|
|
116
|
+
inner_height = [area.height - 2, 0].max
|
|
117
|
+
inner_width = [area.width - 2, 0].max
|
|
118
|
+
total_lines = count_wrapped_lines(text, inner_width)
|
|
119
|
+
[total_lines - inner_height, 0].max
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class Renderer
|
|
6
|
+
# This module contains the logic for rendering the UI model selector component
|
|
7
|
+
module ModelSelector
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def render_model_selector(frame, area)
|
|
11
|
+
popup_area = centered_popup(area)
|
|
12
|
+
frame.render_widget(@tui.clear, popup_area)
|
|
13
|
+
|
|
14
|
+
search_area, list_area = popup_layout(popup_area)
|
|
15
|
+
render_model_search(frame, search_area)
|
|
16
|
+
render_model_list(frame, list_area)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def popup_layout(popup_area)
|
|
20
|
+
@tui.layout_split(
|
|
21
|
+
popup_area,
|
|
22
|
+
direction: :vertical,
|
|
23
|
+
constraints: [
|
|
24
|
+
@tui.constraint_length(3),
|
|
25
|
+
@tui.constraint_fill(1)
|
|
26
|
+
]
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def render_model_search(frame, area)
|
|
31
|
+
widget = @tui.paragraph(
|
|
32
|
+
text: "Search: #{@state.model_select_filter}",
|
|
33
|
+
block: @tui.block(title: "↑↓ navigate, Enter select, Esc cancel", borders: [:all])
|
|
34
|
+
)
|
|
35
|
+
frame.render_widget(widget, area)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render_model_list(frame, area)
|
|
39
|
+
filtered = @state.filtered_model_list
|
|
40
|
+
items = filtered.map { |m| model_display_label(m) }
|
|
41
|
+
widget = model_list_widget(items, filtered.size)
|
|
42
|
+
frame.render_widget(widget, area)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def model_list_widget(items, count)
|
|
46
|
+
title = @state.model_select_show_all? ? "All Models (#{count})" : "Models (#{count})"
|
|
47
|
+
@tui.list(
|
|
48
|
+
items: items,
|
|
49
|
+
selected_index: @state.model_select_index,
|
|
50
|
+
highlight_style: @tui.style(bg: :blue, fg: :white, modifiers: [:bold]),
|
|
51
|
+
highlight_symbol: "> ",
|
|
52
|
+
scroll_padding: 2,
|
|
53
|
+
block: @tui.block(title: title, borders: [:all])
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def centered_popup(area)
|
|
58
|
+
vertical = centered_vertical(area)
|
|
59
|
+
centered_horizontal(vertical[1])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def centered_vertical(area)
|
|
63
|
+
@tui.layout_split(
|
|
64
|
+
area,
|
|
65
|
+
direction: :vertical,
|
|
66
|
+
constraints: [
|
|
67
|
+
@tui.constraint_percentage(15),
|
|
68
|
+
@tui.constraint_percentage(70),
|
|
69
|
+
@tui.constraint_percentage(15)
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def centered_horizontal(area)
|
|
75
|
+
horizontal = @tui.layout_split(
|
|
76
|
+
area,
|
|
77
|
+
direction: :horizontal,
|
|
78
|
+
constraints: [
|
|
79
|
+
@tui.constraint_percentage(20),
|
|
80
|
+
@tui.constraint_percentage(60),
|
|
81
|
+
@tui.constraint_percentage(20)
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
horizontal[1]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def model_display_label(model)
|
|
88
|
+
id = model.respond_to?(:id) ? model.id : model.to_s
|
|
89
|
+
provider = model.respond_to?(:provider) ? model.provider : "unknown"
|
|
90
|
+
current_marker = id == @state.model ? " *" : ""
|
|
91
|
+
"#{id} (#{provider})#{current_marker}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class Renderer
|
|
6
|
+
# Renders a centered popup for plan clarification questions.
|
|
7
|
+
# The popup has three zones: question, options list, and free text input.
|
|
8
|
+
module PlanClarifier
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def render_plan_clarifier(frame, area)
|
|
12
|
+
popup_area = clarifier_centered_popup(area)
|
|
13
|
+
frame.render_widget(@tui.clear, popup_area)
|
|
14
|
+
|
|
15
|
+
question_area, options_area, input_area = clarifier_layout(popup_area)
|
|
16
|
+
render_clarifier_question(frame, question_area)
|
|
17
|
+
render_clarifier_options(frame, options_area)
|
|
18
|
+
render_clarifier_input(frame, input_area)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clarifier_layout(popup_area)
|
|
22
|
+
question_height = clarifier_question_height(popup_area.width)
|
|
23
|
+
|
|
24
|
+
@tui.layout_split(
|
|
25
|
+
popup_area,
|
|
26
|
+
direction: :vertical,
|
|
27
|
+
constraints: [
|
|
28
|
+
@tui.constraint_length(question_height),
|
|
29
|
+
@tui.constraint_fill(1),
|
|
30
|
+
@tui.constraint_length(3)
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clarifier_question_height(popup_width)
|
|
36
|
+
question = @state.clarification_question || ""
|
|
37
|
+
inner_width = [popup_width - 2, 1].max
|
|
38
|
+
wrapped = (question.length.to_f / inner_width).ceil
|
|
39
|
+
[wrapped + 2, 4].max
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_clarifier_question(frame, area)
|
|
43
|
+
question = @state.clarification_question || ""
|
|
44
|
+
widget = @tui.paragraph(
|
|
45
|
+
text: question,
|
|
46
|
+
wrap: true,
|
|
47
|
+
block: @tui.block(title: "Plan Clarification", borders: [:all])
|
|
48
|
+
)
|
|
49
|
+
frame.render_widget(widget, area)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_clarifier_options(frame, area)
|
|
53
|
+
options = @state.clarification_options
|
|
54
|
+
|
|
55
|
+
widget = @tui.list(
|
|
56
|
+
items: wrap_clarifier_items(options, [area.width - 4, 1].max),
|
|
57
|
+
selected_index: @state.clarification_index,
|
|
58
|
+
highlight_style: clarifier_highlight_style,
|
|
59
|
+
highlight_symbol: "> ",
|
|
60
|
+
scroll_padding: 1,
|
|
61
|
+
block: @tui.block(title: "Options (#{options.size})", borders: [:all])
|
|
62
|
+
)
|
|
63
|
+
frame.render_widget(widget, area)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def wrap_clarifier_items(options, width)
|
|
67
|
+
options.each_with_index.map do |opt, i|
|
|
68
|
+
clarifier_wrap_option("[#{i + 1}] #{opt}", width)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def clarifier_highlight_style
|
|
73
|
+
if @state.clarification_input_mode == :options
|
|
74
|
+
@tui.style(bg: :blue, fg: :white, modifiers: [:bold])
|
|
75
|
+
else
|
|
76
|
+
@tui.style(fg: :dark_gray)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render_clarifier_input(frame, area)
|
|
81
|
+
active = @state.clarification_input_mode == :custom
|
|
82
|
+
hint = active ? "Enter: send | Tab: back to options" : "Tab: switch to free text | Esc: skip"
|
|
83
|
+
text = active ? ">> #{@state.clarification_custom_input}" : hint
|
|
84
|
+
|
|
85
|
+
widget = @tui.paragraph(
|
|
86
|
+
text: text,
|
|
87
|
+
wrap: true,
|
|
88
|
+
block: @tui.block(title: "Free response", borders: [:all])
|
|
89
|
+
)
|
|
90
|
+
frame.render_widget(widget, area)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Wraps a single option string into multiple lines that fit the
|
|
94
|
+
# available width, preserving word boundaries when possible.
|
|
95
|
+
def clarifier_wrap_option(text, max_width)
|
|
96
|
+
return text if text.length <= max_width
|
|
97
|
+
|
|
98
|
+
lines = []
|
|
99
|
+
remaining = text
|
|
100
|
+
while remaining.length > max_width
|
|
101
|
+
break_at = remaining.rindex(" ", max_width) || max_width
|
|
102
|
+
lines << remaining[0...break_at]
|
|
103
|
+
remaining = remaining[break_at..].lstrip
|
|
104
|
+
end
|
|
105
|
+
lines << remaining unless remaining.empty?
|
|
106
|
+
lines.join("\n")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class Renderer
|
|
6
|
+
# Layout helpers for centering the plan clarifier popup.
|
|
7
|
+
module PlanClarifierLayout
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def clarifier_centered_popup(area)
|
|
11
|
+
vertical = clarifier_centered_vertical(area)
|
|
12
|
+
clarifier_centered_horizontal(vertical[1])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def clarifier_centered_vertical(area)
|
|
16
|
+
@tui.layout_split(
|
|
17
|
+
area,
|
|
18
|
+
direction: :vertical,
|
|
19
|
+
constraints: [
|
|
20
|
+
@tui.constraint_percentage(5),
|
|
21
|
+
@tui.constraint_percentage(90),
|
|
22
|
+
@tui.constraint_percentage(5)
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def clarifier_centered_horizontal(area)
|
|
28
|
+
horizontal = @tui.layout_split(
|
|
29
|
+
area,
|
|
30
|
+
direction: :horizontal,
|
|
31
|
+
constraints: [
|
|
32
|
+
@tui.constraint_percentage(5),
|
|
33
|
+
@tui.constraint_percentage(90),
|
|
34
|
+
@tui.constraint_percentage(5)
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
horizontal[1]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class Renderer
|
|
6
|
+
# Renders a single-line status bar showing session token usage,
|
|
7
|
+
# current model, and estimated cost.
|
|
8
|
+
module StatusBar
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def render_status_bar(frame, area)
|
|
12
|
+
widget = @tui.paragraph(text: status_bar_text(area.width))
|
|
13
|
+
frame.render_widget(widget, area)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def status_bar_text(width)
|
|
17
|
+
left = status_bar_left
|
|
18
|
+
right = "#{@state.model} | #{format_cost(@state.total_session_cost)} "
|
|
19
|
+
center_pad = [width - left.length - right.length, 1].max
|
|
20
|
+
"#{left}#{" " * center_pad}#{right}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def status_bar_left
|
|
24
|
+
input_tok = @state.total_input_tokens
|
|
25
|
+
output_tok = @state.total_output_tokens
|
|
26
|
+
thinking_tok = @state.total_thinking_tokens
|
|
27
|
+
total_tok = input_tok + output_tok + thinking_tok
|
|
28
|
+
|
|
29
|
+
left = " ↑#{format_number(input_tok)} ↓#{format_number(output_tok)}"
|
|
30
|
+
left << " 💭#{format_number(thinking_tok)}" if thinking_tok.positive?
|
|
31
|
+
left << " (#{format_number(total_tok)} tokens)"
|
|
32
|
+
left
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def format_number(num)
|
|
36
|
+
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def format_cost(cost)
|
|
40
|
+
return "Cost: N/A" if cost.nil?
|
|
41
|
+
|
|
42
|
+
"Cost: $#{format("%.2f", cost)}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "renderer/chat_panel"
|
|
4
|
+
require_relative "renderer/chat_panel_thinking"
|
|
5
|
+
require_relative "renderer/chat_panel_input"
|
|
6
|
+
require_relative "renderer/model_selector"
|
|
7
|
+
require_relative "renderer/plan_clarifier_layout"
|
|
8
|
+
require_relative "renderer/plan_clarifier"
|
|
9
|
+
require_relative "renderer/status_bar"
|
|
10
|
+
|
|
11
|
+
module RubyCoded
|
|
12
|
+
module Chat
|
|
13
|
+
# This class manages the rendering of the UI elements
|
|
14
|
+
class Renderer
|
|
15
|
+
include ChatPanel
|
|
16
|
+
include ChatPanelThinking
|
|
17
|
+
include ChatPanelInput
|
|
18
|
+
include ModelSelector
|
|
19
|
+
include PlanClarifierLayout
|
|
20
|
+
include PlanClarifier
|
|
21
|
+
include StatusBar
|
|
22
|
+
|
|
23
|
+
def initialize(tui, state)
|
|
24
|
+
@tui = tui
|
|
25
|
+
@state = state
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def draw
|
|
29
|
+
@tui.clear
|
|
30
|
+
|
|
31
|
+
@tui.draw do |frame|
|
|
32
|
+
chat_area, status_area, input_area = main_layout(frame)
|
|
33
|
+
render_chat_panel(frame, chat_area)
|
|
34
|
+
render_status_bar(frame, status_area)
|
|
35
|
+
render_input_panel(frame, input_area)
|
|
36
|
+
render_model_selector(frame, chat_area) if @state.model_select?
|
|
37
|
+
render_plan_clarifier(frame, chat_area) if @state.plan_clarification?
|
|
38
|
+
render_plugin_overlays(frame, chat_area, input_area)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Calls each plugin's render method in registration order.
|
|
45
|
+
def render_plugin_overlays(frame, chat_area, input_area)
|
|
46
|
+
RubyCoded.plugin_registry.render_configs.each do |config|
|
|
47
|
+
send(config[:method], frame, chat_area, input_area)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def main_layout(frame)
|
|
52
|
+
@tui.layout_split(
|
|
53
|
+
frame.area,
|
|
54
|
+
direction: :vertical,
|
|
55
|
+
constraints: [
|
|
56
|
+
@tui.constraint_fill(1),
|
|
57
|
+
@tui.constraint_length(1),
|
|
58
|
+
@tui.constraint_length(3)
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class State
|
|
6
|
+
# Operations on the last assistant message: streaming, reset, and error handling.
|
|
7
|
+
module MessageAssistant
|
|
8
|
+
# Ensures the last message is :assistant so streaming chunks
|
|
9
|
+
# land in the right place after tool call/result messages.
|
|
10
|
+
def ensure_last_is_assistant!
|
|
11
|
+
@mutex.synchronize do
|
|
12
|
+
return if !@messages.empty? && @messages.last[:role] == :assistant
|
|
13
|
+
|
|
14
|
+
@messages << build_message(:assistant)
|
|
15
|
+
@message_generation += 1
|
|
16
|
+
@dirty = true
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Single-mutex operation combining ensure_last_is_assistant! + append.
|
|
21
|
+
def streaming_append(text)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@messages << build_message(:assistant) if @messages.empty? || @messages.last[:role] != :assistant
|
|
24
|
+
@messages.last[:content] << text.to_s
|
|
25
|
+
@message_generation += 1
|
|
26
|
+
@dirty = true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def last_assistant_empty?
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
return true if @messages.empty?
|
|
33
|
+
|
|
34
|
+
last = @messages.last
|
|
35
|
+
last[:role] == :assistant && last[:content].strip.empty?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset_last_assistant_content
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return if @messages.empty?
|
|
42
|
+
|
|
43
|
+
last = @messages.last
|
|
44
|
+
return unless last[:role] == :assistant
|
|
45
|
+
|
|
46
|
+
last[:content] = String.new
|
|
47
|
+
@message_generation += 1
|
|
48
|
+
@dirty = true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fail_last_assistant(error, friendly_message: nil)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
return if @messages.empty?
|
|
55
|
+
|
|
56
|
+
last = @messages.last
|
|
57
|
+
return unless last[:role] == :assistant
|
|
58
|
+
|
|
59
|
+
apply_error_to_message(last, friendly_message || "[Error] #{error.class}: #{error.message}")
|
|
60
|
+
@message_generation += 1
|
|
61
|
+
@dirty = true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def apply_error_to_message(message, err_line)
|
|
68
|
+
if message[:content].strip.empty?
|
|
69
|
+
message[:content] = String.new(err_line)
|
|
70
|
+
else
|
|
71
|
+
message[:content] << "\n\n#{err_line}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class State
|
|
6
|
+
# Tracks per-message and per-model token usage counters.
|
|
7
|
+
module MessageTokenTracking
|
|
8
|
+
TOKEN_KEYS = %i[input_tokens output_tokens thinking_tokens cached_tokens cache_creation_tokens].freeze
|
|
9
|
+
|
|
10
|
+
def update_last_message_tokens(model: nil, **token_counts)
|
|
11
|
+
@mutex.synchronize do
|
|
12
|
+
return if @messages.empty?
|
|
13
|
+
|
|
14
|
+
counts = TOKEN_KEYS.to_h { |key| [key, token_counts[key].to_i] }
|
|
15
|
+
apply_token_counts(@messages.last, counts)
|
|
16
|
+
accumulate_token_counts(model || @model, counts)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def total_input_tokens
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
@messages.sum { |message| message[:input_tokens] }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def total_output_tokens
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@messages.sum { |message| message[:output_tokens] }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def total_thinking_tokens
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@messages.sum { |message| message[:thinking_tokens] }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def token_usage_by_model
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@token_usage_by_model.transform_values(&:dup)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def apply_token_counts(message, counts)
|
|
47
|
+
counts.each { |key, value| message[key] = value }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def accumulate_token_counts(model, counts)
|
|
51
|
+
usage = @token_usage_by_model[model]
|
|
52
|
+
counts.each { |key, value| usage[key] += value }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class State
|
|
6
|
+
# Core message storage: add, append, clear, and snapshot.
|
|
7
|
+
module Messages
|
|
8
|
+
attr_reader :message_generation
|
|
9
|
+
|
|
10
|
+
ZERO_TOKEN_USAGE = {
|
|
11
|
+
input_tokens: 0, output_tokens: 0,
|
|
12
|
+
thinking_tokens: 0, cached_tokens: 0, cache_creation_tokens: 0
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def init_messages
|
|
16
|
+
@message_generation = 0
|
|
17
|
+
@snapshot_cache = nil
|
|
18
|
+
@snapshot_cache_gen = -1
|
|
19
|
+
@token_usage_by_model = Hash.new { |h, k| h[k] = ZERO_TOKEN_USAGE.dup }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add_message(role, content)
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
@messages << build_message(role, content)
|
|
25
|
+
@message_generation += 1
|
|
26
|
+
@dirty = true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
scroll_to_bottom
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_message(role, content = "")
|
|
33
|
+
{ role: role, content: String.new(content.to_s), timestamp: Time.now, **ZERO_TOKEN_USAGE }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def append_to_last_message(text)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
return if @messages.empty?
|
|
39
|
+
|
|
40
|
+
@messages.last[:content] << text.to_s
|
|
41
|
+
@message_generation += 1
|
|
42
|
+
@dirty = true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear_messages!
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
@messages.clear
|
|
49
|
+
@token_usage_by_model.clear
|
|
50
|
+
@message_generation += 1
|
|
51
|
+
@dirty = true
|
|
52
|
+
end
|
|
53
|
+
@scroll_offset = 0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def messages_snapshot
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
return @snapshot_cache if @snapshot_cache_gen == @message_generation
|
|
59
|
+
|
|
60
|
+
@snapshot_cache = @messages.map do |msg|
|
|
61
|
+
msg.dup.tap { |m| m[:content] = m[:content].dup }
|
|
62
|
+
end
|
|
63
|
+
@snapshot_cache_gen = @message_generation
|
|
64
|
+
@snapshot_cache
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|