legion-tty 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e5ec362f89174ce0ccc182a50343da7a6ce2846f3c01ad51ad14afe8a7f80f8
4
- data.tar.gz: 763d695f1c7a2e8c13e3a518b36d2e90ae25713954a2127f5ed71869eaa31908
3
+ metadata.gz: 93f9cd78860072bfa0df27f33574552a7d5ac749fbb9440f61017ed76f7f9f4d
4
+ data.tar.gz: c590bb6fb1cbe8639b5df4e46e0aa255f5d23f01499c8d2b1137ad86700412b7
5
5
  SHA512:
6
- metadata.gz: 4cde6c72b3a66dd7b5daffce9c3407c0d82275a41bb1ed564579ccd013d31bd2f42f4e0366426446adf19f382d5f4911de6cc04622c86fd2e7090663ba1b7177
7
- data.tar.gz: e4350ae0a6145f62758118c141f809de74d90ba5db873cbae1bd499c0c6b958325fa2196f91edd3df7aa3ddeb933f0fc818e3b7a372fab9ff8feb157f68ac680
6
+ metadata.gz: 8880c1013b5c36a3d9a7d378280b6a1212ba82f527a37dd7f5f81b5b0fc591435ea10fec4a565b5e95a714be1b6340b587214ce9ccde08ee1dfbc63572e0d2f9
7
+ data.tar.gz: d99de6e491d811a3e891833760c4f25fe45c4034494bd200322b41145fc39a16929b8ea3b5295c2044cc1e123e45f30022f310ca97aaf1643f842696a424db77
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.3] - 2026-03-19
4
+
5
+ ### Added
6
+ - Daemon-first chat routing: chat screen routes through LegionIO daemon when available
7
+ - `send_via_daemon` and `send_via_direct` methods with automatic fallback
8
+ - `daemon_available?` guard for `Legion::LLM::DaemonClient` presence
9
+
10
+ ## [0.4.2] - 2026-03-19
11
+
12
+ ### Added
13
+ - Multi-provider model switching: `/model <provider>` creates new Legion::LLM.chat instance
14
+ - Model picker integration: `open_model_picker` for interactive provider/model selection
15
+ - ToolPanel wiring in MessageStream: `add_tool_call`, `update_tool_call` methods
16
+ - Tool call rendering in chat messages (`:tool` role with panel display)
17
+
18
+ ### Fixed
19
+ - Flaky table_view_spec: added explicit `require 'tty-table'` to prevent test ordering failures
20
+
3
21
  ## [0.4.1] - 2026-03-19
4
22
 
5
23
  ### Added
@@ -41,11 +41,17 @@ module Legion
41
41
  @completions.select { |c| c.start_with?(partial) }.sort
42
42
  end
43
43
 
44
+ def history
45
+ return [] unless @reader.respond_to?(:history)
46
+
47
+ @reader.history.to_a
48
+ end
49
+
44
50
  private
45
51
 
46
52
  def build_default_reader
47
53
  require 'tty-reader'
48
- reader = ::TTY::Reader.new
54
+ reader = ::TTY::Reader.new(history_cycle: true)
49
55
  register_tab_completion(reader)
50
56
  reader
51
57
  rescue LoadError
@@ -29,6 +29,21 @@ module Legion
29
29
  @messages.last[:tool_panels] << panel
30
30
  end
31
31
 
32
+ def add_tool_call(name:, args: {}, status: :running)
33
+ require_relative 'tool_panel'
34
+ panel = ToolPanel.new(name: name, args: args, status: status)
35
+ @messages << { role: :tool, content: panel, tool_panel: true }
36
+ end
37
+
38
+ def update_tool_call(name:, status:, duration: nil, result: nil, error: nil)
39
+ tool_msg = @messages.reverse.find do |m|
40
+ m[:tool_panel] && m[:content].is_a?(ToolPanel) && m[:content].instance_variable_get(:@name) == name
41
+ end
42
+ return unless tool_msg
43
+
44
+ apply_tool_panel_update(tool_msg[:content], status: status, duration: duration, result: result, error: error)
45
+ end
46
+
32
47
  def scroll_up(lines = 1)
33
48
  @scroll_offset += lines
34
49
  end
@@ -60,6 +75,7 @@ module Legion
60
75
  when :user then user_lines(msg)
61
76
  when :assistant then assistant_lines(msg, width)
62
77
  when :system then system_lines(msg)
78
+ when :tool then tool_call_lines(msg, width)
63
79
  else []
64
80
  end
65
81
  end
@@ -85,9 +101,22 @@ module Legion
85
101
  msg[:content].split("\n").map { |l| " #{Theme.c(:muted, l)}" }
86
102
  end
87
103
 
104
+ def tool_call_lines(msg, width)
105
+ return [] unless msg[:tool_panel] && msg[:content].respond_to?(:render)
106
+
107
+ msg[:content].render(width: width).split("\n")
108
+ end
109
+
88
110
  def panel_lines(msg, width)
89
111
  msg[:tool_panels].flat_map { |panel| panel.render(width: width).split("\n") }
90
112
  end
113
+
114
+ def apply_tool_panel_update(panel, status:, duration:, result:, error:)
115
+ panel.instance_variable_set(:@status, status)
116
+ panel.instance_variable_set(:@duration, duration) if duration
117
+ panel.instance_variable_set(:@result, result) if result
118
+ panel.instance_variable_set(:@error, error) if error
119
+ end
91
120
  end
92
121
  end
93
122
  end
@@ -13,7 +13,7 @@ module Legion
13
13
  # rubocop:disable Metrics/ClassLength
14
14
  class Chat < Base
15
15
  SLASH_COMMANDS = %w[/help /quit /clear /model /session /cost /export /tools /dashboard /hotkeys /save /load
16
- /sessions /system /delete /plan /palette /extensions /config].freeze
16
+ /sessions /system /delete /plan /palette /extensions /config /theme].freeze
17
17
 
18
18
  attr_reader :message_stream, :status_bar
19
19
 
@@ -92,20 +92,16 @@ module Legion
92
92
  end
93
93
 
94
94
  def send_to_llm(message)
95
- unless @llm_chat
95
+ unless @llm_chat || daemon_available?
96
96
  @message_stream.append_streaming('LLM not configured. Use /help for commands.')
97
97
  return
98
98
  end
99
99
 
100
- @status_bar.update(thinking: true)
101
- render_screen
102
- response = @llm_chat.ask(message) do |chunk|
103
- @status_bar.update(thinking: false)
104
- @message_stream.append_streaming(chunk.content) if chunk.content
105
- render_screen
100
+ if daemon_available?
101
+ send_via_daemon(message)
102
+ else
103
+ send_via_direct(message)
106
104
  end
107
- @status_bar.update(thinking: false)
108
- track_response_tokens(response)
109
105
  rescue StandardError => e
110
106
  @status_bar.update(thinking: false)
111
107
  @message_stream.append_streaming("\n[Error: #{e.message}]")
@@ -142,6 +138,40 @@ module Legion
142
138
  @llm_chat.with_instructions(prompt) if @llm_chat.respond_to?(:with_instructions)
143
139
  end
144
140
 
141
+ def send_via_daemon(message)
142
+ result = Legion::LLM.ask(message: message)
143
+
144
+ case result&.dig(:status)
145
+ when :done
146
+ @message_stream.append_streaming(result[:response])
147
+ when :error
148
+ err = result.dig(:error, :message) || 'Unknown error'
149
+ @message_stream.append_streaming("\n[Daemon error: #{err}]")
150
+ else
151
+ send_via_direct(message)
152
+ end
153
+ rescue StandardError
154
+ send_via_direct(message)
155
+ end
156
+
157
+ def send_via_direct(message)
158
+ return unless @llm_chat
159
+
160
+ @status_bar.update(thinking: true)
161
+ render_screen
162
+ response = @llm_chat.ask(message) do |chunk|
163
+ @status_bar.update(thinking: false)
164
+ @message_stream.append_streaming(chunk.content) if chunk.content
165
+ render_screen
166
+ end
167
+ @status_bar.update(thinking: false)
168
+ track_response_tokens(response)
169
+ end
170
+
171
+ def daemon_available?
172
+ !!(defined?(Legion::LLM::DaemonClient) && Legion::LLM::DaemonClient.available?)
173
+ end
174
+
145
175
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
146
176
  def build_system_prompt(cfg)
147
177
  lines = ['You are Legion, an async cognition engine and AI assistant.']
@@ -245,6 +275,7 @@ module Legion
245
275
  when '/palette' then handle_palette
246
276
  when '/extensions' then handle_extensions_screen
247
277
  when '/config' then handle_config_screen
278
+ when '/theme' then handle_theme(input)
248
279
  else :handled
249
280
  end
250
281
  end
@@ -255,7 +286,8 @@ module Legion
255
286
  role: :system,
256
287
  content: "Commands:\n /help /quit /clear /model <name> /session <name> /cost\n " \
257
288
  "/export [md|json] /tools /dashboard /hotkeys /save /load /sessions\n " \
258
- "/system <prompt> /delete <session> /plan /palette /extensions /config\n\n" \
289
+ "/system <prompt> /delete <session> /plan /palette /extensions /config\n " \
290
+ "/theme [name] -- switch color theme (purple, green, blue, amber)\n\n" \
259
291
  'Hotkeys: Ctrl+D=dashboard Ctrl+K=palette Ctrl+S=sessions Esc=back'
260
292
  )
261
293
  :handled
@@ -282,16 +314,50 @@ module Legion
282
314
  return
283
315
  end
284
316
 
285
- if @llm_chat.respond_to?(:with_model)
317
+ apply_model_switch(name)
318
+ rescue StandardError => e
319
+ @message_stream.add_message(role: :system, content: "Failed to switch model: #{e.message}")
320
+ end
321
+
322
+ def apply_model_switch(name)
323
+ new_chat = try_provider_switch(name)
324
+ if new_chat
325
+ @llm_chat = new_chat
326
+ @status_bar.update(model: name)
327
+ @token_tracker.update_model(name)
328
+ @message_stream.add_message(role: :system, content: "Switched to provider: #{name}")
329
+ elsif @llm_chat.respond_to?(:with_model)
286
330
  @llm_chat.with_model(name)
287
331
  @status_bar.update(model: name)
332
+ @token_tracker.update_model(name)
288
333
  @message_stream.add_message(role: :system, content: "Model switched to: #{name}")
289
334
  else
290
335
  @status_bar.update(model: name)
291
336
  @message_stream.add_message(role: :system, content: "Model set to: #{name}")
292
337
  end
293
- rescue StandardError => e
294
- @message_stream.add_message(role: :system, content: "Failed to switch model: #{e.message}")
338
+ end
339
+
340
+ def try_provider_switch(name)
341
+ return nil unless defined?(Legion::LLM)
342
+
343
+ providers = Legion::LLM.settings[:providers]
344
+ return nil unless providers.is_a?(Hash) && providers.key?(name.to_sym)
345
+
346
+ Legion::LLM.chat(provider: name)
347
+ rescue StandardError
348
+ nil
349
+ end
350
+
351
+ def open_model_picker
352
+ require_relative '../components/model_picker'
353
+ picker = Components::ModelPicker.new(
354
+ current_provider: safe_config[:provider],
355
+ current_model: @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : nil
356
+ )
357
+ selection = picker.select_with_prompt(output: @output)
358
+ return unless selection
359
+
360
+ switch_model(selection[:provider])
295
361
  end
296
362
 
297
363
  def show_current_model
@@ -506,6 +572,23 @@ module Legion
506
572
  :handled
507
573
  end
508
574
 
575
+ def handle_theme(input)
576
+ name = input.split(nil, 2)[1]
577
+ if name
578
+ if Theme.switch(name)
579
+ @message_stream.add_message(role: :system, content: "Theme switched to: #{name}")
580
+ else
581
+ available = Theme.available_themes.join(', ')
582
+ @message_stream.add_message(role: :system, content: "Unknown theme '#{name}'. Available: #{available}")
583
+ end
584
+ else
585
+ current = Theme.current_theme
586
+ available = Theme.available_themes.join(', ')
587
+ @message_stream.add_message(role: :system, content: "Current theme: #{current}\nAvailable: #{available}")
588
+ end
589
+ :handled
590
+ end
591
+
509
592
  def detect_provider
510
593
  cfg = safe_config
511
594
  provider = cfg[:provider].to_s.downcase
@@ -2,44 +2,100 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
+ # rubocop:disable Metrics/ModuleLength
5
6
  module Theme
6
7
  # rubocop:disable Naming/VariableNumber
7
- PALETTE = {
8
- purple_1: [30, 27, 46],
9
- purple_2: [41, 37, 63],
10
- purple_3: [52, 47, 80],
11
- purple_4: [63, 57, 97],
12
- purple_5: [74, 67, 114],
13
- purple_6: [85, 77, 131],
14
- purple_7: [96, 87, 148],
15
- purple_8: [107, 97, 165],
16
- purple_9: [118, 107, 182],
17
- purple_10: [129, 119, 199],
18
- purple_11: [140, 131, 210],
19
- purple_12: [157, 148, 221],
20
- purple_13: [174, 167, 230],
21
- purple_14: [191, 186, 239],
22
- purple_15: [208, 205, 245],
23
- purple_16: [225, 224, 250],
24
- purple_17: [242, 243, 255]
8
+ THEMES = {
9
+ purple: {
10
+ palette: {
11
+ shade_1: [30, 27, 46], shade_2: [41, 37, 63], shade_3: [52, 47, 80],
12
+ shade_4: [63, 57, 97], shade_5: [74, 67, 114], shade_6: [85, 77, 131],
13
+ shade_7: [96, 87, 148], shade_8: [107, 97, 165], shade_9: [118, 107, 182],
14
+ shade_10: [129, 119, 199], shade_11: [140, 131, 210], shade_12: [157, 148, 221],
15
+ shade_13: [174, 167, 230], shade_14: [191, 186, 239], shade_15: [208, 205, 245],
16
+ shade_16: [225, 224, 250], shade_17: [242, 243, 255]
17
+ },
18
+ semantic: {
19
+ primary: :shade_9, secondary: :shade_6, accent: :shade_12,
20
+ success: [0, 200, 83], warning: [255, 191, 0], error: [255, 69, 58],
21
+ info: :shade_7, surface: :shade_1, muted: :shade_4,
22
+ rain: :shade_11, rain_fade: :shade_3
23
+ }
24
+ },
25
+ green: {
26
+ palette: {
27
+ shade_1: [15, 30, 15], shade_2: [20, 45, 20], shade_3: [25, 60, 25],
28
+ shade_4: [30, 75, 30], shade_5: [35, 90, 35], shade_6: [40, 110, 40],
29
+ shade_7: [50, 130, 50], shade_8: [60, 150, 60], shade_9: [75, 170, 75],
30
+ shade_10: [90, 190, 90], shade_11: [110, 210, 110], shade_12: [140, 225, 140],
31
+ shade_13: [170, 235, 170], shade_14: [195, 242, 195], shade_15: [215, 248, 215],
32
+ shade_16: [230, 252, 230], shade_17: [245, 255, 245]
33
+ },
34
+ semantic: {
35
+ primary: :shade_9, secondary: :shade_6, accent: :shade_12,
36
+ success: [0, 200, 83], warning: [255, 191, 0], error: [255, 69, 58],
37
+ info: :shade_7, surface: :shade_1, muted: :shade_4,
38
+ rain: :shade_11, rain_fade: :shade_3
39
+ }
40
+ },
41
+ blue: {
42
+ palette: {
43
+ shade_1: [15, 20, 40], shade_2: [20, 30, 60], shade_3: [25, 40, 80],
44
+ shade_4: [30, 50, 100], shade_5: [40, 65, 120], shade_6: [50, 80, 140],
45
+ shade_7: [65, 100, 160], shade_8: [80, 120, 180], shade_9: [100, 140, 200],
46
+ shade_10: [120, 160, 215], shade_11: [145, 185, 225], shade_12: [170, 205, 235],
47
+ shade_13: [195, 220, 242], shade_14: [210, 230, 248], shade_15: [225, 240, 252],
48
+ shade_16: [238, 248, 255], shade_17: [248, 252, 255]
49
+ },
50
+ semantic: {
51
+ primary: :shade_9, secondary: :shade_6, accent: :shade_12,
52
+ success: [0, 200, 83], warning: [255, 191, 0], error: [255, 69, 58],
53
+ info: :shade_7, surface: :shade_1, muted: :shade_4,
54
+ rain: :shade_11, rain_fade: :shade_3
55
+ }
56
+ },
57
+ amber: {
58
+ palette: {
59
+ shade_1: [35, 25, 10], shade_2: [50, 35, 15], shade_3: [65, 45, 20],
60
+ shade_4: [80, 55, 25], shade_5: [100, 70, 30], shade_6: [120, 85, 35],
61
+ shade_7: [140, 100, 40], shade_8: [165, 120, 50], shade_9: [190, 140, 60],
62
+ shade_10: [210, 160, 70], shade_11: [225, 180, 85], shade_12: [235, 200, 110],
63
+ shade_13: [242, 215, 140], shade_14: [248, 230, 170], shade_15: [252, 240, 200],
64
+ shade_16: [255, 248, 225], shade_17: [255, 252, 245]
65
+ },
66
+ semantic: {
67
+ primary: :shade_9, secondary: :shade_6, accent: :shade_12,
68
+ success: [0, 200, 83], warning: [255, 191, 0], error: [255, 69, 58],
69
+ info: :shade_7, surface: :shade_1, muted: :shade_4,
70
+ rain: :shade_11, rain_fade: :shade_3
71
+ }
72
+ }
25
73
  }.freeze
26
74
 
27
- SEMANTIC = {
28
- primary: :purple_9,
29
- secondary: :purple_6,
30
- accent: :purple_12,
31
- success: [0, 200, 83],
32
- warning: [255, 191, 0],
33
- error: [255, 69, 58],
34
- info: :purple_7,
35
- surface: :purple_1,
36
- muted: :purple_4,
37
- rain: :purple_11,
38
- rain_fade: :purple_3
39
- }.freeze
75
+ # Legacy aliases for backward compatibility
76
+ PALETTE = THEMES[:purple][:palette].transform_keys { |k| k.to_s.sub('shade_', 'purple_').to_sym }.freeze
77
+ SEMANTIC = THEMES[:purple][:semantic].freeze
40
78
  # rubocop:enable Naming/VariableNumber
41
79
 
42
80
  class << self
81
+ def current_theme
82
+ @current_theme || :purple
83
+ end
84
+
85
+ # rubocop:disable Naming/PredicateMethod
86
+ def switch(name)
87
+ name = name.to_sym
88
+ return false unless THEMES.key?(name)
89
+
90
+ @current_theme = name
91
+ true
92
+ end
93
+ # rubocop:enable Naming/PredicateMethod
94
+
95
+ def available_themes
96
+ THEMES.keys
97
+ end
98
+
43
99
  def c(name, text)
44
100
  rgb = resolve_rgb(name)
45
101
  return text unless rgb
@@ -47,17 +103,28 @@ module Legion
47
103
  "\e[38;2;#{rgb[0]};#{rgb[1]};#{rgb[2]}m#{text}\e[0m"
48
104
  end
49
105
 
106
+ def reset_theme
107
+ @current_theme = :purple
108
+ end
109
+
50
110
  private
51
111
 
52
112
  def resolve_rgb(name)
53
- if PALETTE.key?(name)
113
+ theme = THEMES[current_theme]
114
+ palette = theme[:palette]
115
+ semantic = theme[:semantic]
116
+
117
+ if palette.key?(name)
118
+ palette[name]
119
+ elsif semantic.key?(name)
120
+ ref = semantic[name]
121
+ ref.is_a?(Symbol) ? palette[ref] : ref
122
+ elsif PALETTE.key?(name)
54
123
  PALETTE[name]
55
- elsif SEMANTIC.key?(name)
56
- ref = SEMANTIC[name]
57
- ref.is_a?(Symbol) ? PALETTE[ref] : ref
58
124
  end
59
125
  end
60
126
  end
61
127
  end
128
+ # rubocop:enable Metrics/ModuleLength
62
129
  end
63
130
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.1'
5
+ VERSION = '0.4.3'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-tty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity