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.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +76 -0
  3. data/.github/workflows/release.yml +24 -0
  4. data/.rubocop_todo.yml +122 -0
  5. data/CHANGELOG.md +9 -0
  6. data/CODE_OF_CONDUCT.md +10 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +140 -0
  9. data/Rakefile +12 -0
  10. data/exe/ruby_coded +6 -0
  11. data/lib/ruby_coded/auth/auth_manager.rb +145 -0
  12. data/lib/ruby_coded/auth/callback_servlet.rb +41 -0
  13. data/lib/ruby_coded/auth/credentials_store.rb +35 -0
  14. data/lib/ruby_coded/auth/oauth_callback_server.rb +38 -0
  15. data/lib/ruby_coded/auth/pkce.rb +19 -0
  16. data/lib/ruby_coded/auth/providers/anthropic.rb +32 -0
  17. data/lib/ruby_coded/auth/providers/openai.rb +55 -0
  18. data/lib/ruby_coded/chat/app/event_dispatch.rb +78 -0
  19. data/lib/ruby_coded/chat/app.rb +104 -0
  20. data/lib/ruby_coded/chat/command_handler/agent_commands.rb +53 -0
  21. data/lib/ruby_coded/chat/command_handler/history_commands.rb +38 -0
  22. data/lib/ruby_coded/chat/command_handler/model_commands.rb +91 -0
  23. data/lib/ruby_coded/chat/command_handler/plan_commands.rb +112 -0
  24. data/lib/ruby_coded/chat/command_handler/token_commands.rb +128 -0
  25. data/lib/ruby_coded/chat/command_handler/token_formatting.rb +26 -0
  26. data/lib/ruby_coded/chat/command_handler.rb +89 -0
  27. data/lib/ruby_coded/chat/help.txt +28 -0
  28. data/lib/ruby_coded/chat/input_handler/modal_inputs.rb +102 -0
  29. data/lib/ruby_coded/chat/input_handler/normal_mode_input.rb +116 -0
  30. data/lib/ruby_coded/chat/input_handler.rb +39 -0
  31. data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +73 -0
  32. data/lib/ruby_coded/chat/llm_bridge/streaming_retries.rb +86 -0
  33. data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +129 -0
  34. data/lib/ruby_coded/chat/llm_bridge.rb +131 -0
  35. data/lib/ruby_coded/chat/model_filter.rb +115 -0
  36. data/lib/ruby_coded/chat/plan_clarification_parser.rb +38 -0
  37. data/lib/ruby_coded/chat/renderer/chat_panel.rb +128 -0
  38. data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +56 -0
  39. data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +124 -0
  40. data/lib/ruby_coded/chat/renderer/model_selector.rb +96 -0
  41. data/lib/ruby_coded/chat/renderer/plan_clarifier.rb +112 -0
  42. data/lib/ruby_coded/chat/renderer/plan_clarifier_layout.rb +42 -0
  43. data/lib/ruby_coded/chat/renderer/status_bar.rb +47 -0
  44. data/lib/ruby_coded/chat/renderer.rb +64 -0
  45. data/lib/ruby_coded/chat/state/message_assistant.rb +77 -0
  46. data/lib/ruby_coded/chat/state/message_token_tracking.rb +57 -0
  47. data/lib/ruby_coded/chat/state/messages.rb +70 -0
  48. data/lib/ruby_coded/chat/state/model_selection.rb +79 -0
  49. data/lib/ruby_coded/chat/state/plan_tracking.rb +140 -0
  50. data/lib/ruby_coded/chat/state/scrollable.rb +42 -0
  51. data/lib/ruby_coded/chat/state/token_cost.rb +128 -0
  52. data/lib/ruby_coded/chat/state/tool_confirmation.rb +129 -0
  53. data/lib/ruby_coded/chat/state.rb +205 -0
  54. data/lib/ruby_coded/config/user_config.rb +110 -0
  55. data/lib/ruby_coded/errors/auth_error.rb +12 -0
  56. data/lib/ruby_coded/initializer/cover.rb +29 -0
  57. data/lib/ruby_coded/initializer.rb +52 -0
  58. data/lib/ruby_coded/plugins/base.rb +44 -0
  59. data/lib/ruby_coded/plugins/command_completion/input_extension.rb +30 -0
  60. data/lib/ruby_coded/plugins/command_completion/plugin.rb +27 -0
  61. data/lib/ruby_coded/plugins/command_completion/renderer_extension.rb +54 -0
  62. data/lib/ruby_coded/plugins/command_completion/state_extension.rb +90 -0
  63. data/lib/ruby_coded/plugins/registry.rb +88 -0
  64. data/lib/ruby_coded/plugins.rb +21 -0
  65. data/lib/ruby_coded/strategies/api_key_strategy.rb +39 -0
  66. data/lib/ruby_coded/strategies/base.rb +37 -0
  67. data/lib/ruby_coded/strategies/oauth_strategy.rb +106 -0
  68. data/lib/ruby_coded/tools/agent_cancelled_error.rb +7 -0
  69. data/lib/ruby_coded/tools/agent_iteration_limit_error.rb +7 -0
  70. data/lib/ruby_coded/tools/base_tool.rb +50 -0
  71. data/lib/ruby_coded/tools/create_directory_tool.rb +34 -0
  72. data/lib/ruby_coded/tools/delete_path_tool.rb +50 -0
  73. data/lib/ruby_coded/tools/edit_file_tool.rb +40 -0
  74. data/lib/ruby_coded/tools/list_directory_tool.rb +53 -0
  75. data/lib/ruby_coded/tools/plan_system_prompt.rb +72 -0
  76. data/lib/ruby_coded/tools/read_file_tool.rb +54 -0
  77. data/lib/ruby_coded/tools/registry.rb +66 -0
  78. data/lib/ruby_coded/tools/run_command_tool.rb +75 -0
  79. data/lib/ruby_coded/tools/system_prompt.rb +32 -0
  80. data/lib/ruby_coded/tools/tool_rejected_error.rb +7 -0
  81. data/lib/ruby_coded/tools/write_file_tool.rb +31 -0
  82. data/lib/ruby_coded/version.rb +10 -0
  83. data/lib/ruby_coded.rb +16 -0
  84. data/sig/ruby_coded.rbs +4 -0
  85. 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