legion-tty 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df932fba0f861bcb1473808566486e7ebc769ab735d53b4464f2d1c77a78f7a0
4
- data.tar.gz: a13036205e7d36cee04d0dbdea574041b358f62cf7d533b87a564a86a39a4ddd
3
+ metadata.gz: d7b05c783669ce2e8482bd36b3654b75d626d3fc2b738e684a6b9fad407881e1
4
+ data.tar.gz: 34418c0af4571764d36096de930f126a92b192fe87ff22c2acbbcbb71f45a090
5
5
  SHA512:
6
- metadata.gz: f9d27f13872938348b97a31cb8949cb93d158c3b288dc9972ad48de25b9e47abdd298a5fa0688c3b9497648ce864280308d1ec82653502192a57ea00535ed80a
7
- data.tar.gz: aad5dfe5fa9b5960acde7a58d086ac87ccd49c2503d6bed0e3a0220d2a5caae4d121b38e2d53f78cfa940c43dc665d252c5ffc5ddbdb32ce4bd5f791b8837149
6
+ metadata.gz: 1f913c1e7ec15e45a7d27d24393e721852ffd28e7d61c1d3284996fe564bd30ee2dcd9910676eb40bf5239cfbf9068f5561c95f61bf55c0f1beb8ff95677925f
7
+ data.tar.gz: 995fc6176e39c16dae3b1570149ed8292c0c30174fa5eecd5effcafcfa5004eb8006a135b8c815199bf1003e46f26c227a65f1ecb3f00a35ebcc0b48d16f5fc6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-03-19
4
+
5
+ ### Fixed
6
+ - /model crash: empty or invalid model name no longer crashes the shell
7
+ - Removed all RubyLLM direct usage -- all LLM access goes through Legion::LLM exclusively
8
+ - Kerberos username key mismatch in vault auth pre-fill (was :samaccountname, now :username)
9
+ - Overlay rendering: help overlay now actually displays on screen
10
+ - Thinking indicator: status bar shows "thinking..." during LLM requests
11
+ - --skip-rain CLI option now forwarded to onboarding
12
+
13
+ ### Added
14
+ - Per-model token pricing (Opus/Sonnet/Haiku, GPT-4o/4o-mini, Gemini Flash/Pro)
15
+ - Markdown rendering for assistant messages via TTY::Markdown
16
+ - /system command: set or override system prompt at runtime
17
+ - /delete command: delete saved sessions
18
+ - /plan command: toggle read-only bookmark mode with [PLAN] status indicator
19
+ - /palette command: fuzzy-search command palette for all commands, screens, sessions
20
+ - /extensions command: browse installed LEX gems by category
21
+ - /config command: view and edit ~/.legionio/settings/*.json files
22
+ - Command palette component with fuzzy search
23
+ - Model picker component for switching LLM providers
24
+ - Session picker component for quick session switching
25
+ - Table view component wrapping tty-table
26
+ - Extensions browser screen (grouped by core/agentic/service/AI/other)
27
+ - Config viewer/editor screen with vault:// masking
28
+ - Hotkeys: Ctrl+K (palette), Ctrl+S (sessions), Escape (back)
29
+ - Plan mode: bookmark messages without sending to LLM
30
+
31
+ ### Changed
32
+ - Token tracker now uses per-model rates with provider fallback
33
+ - Hotkey ? removed (conflicted with typing questions)
34
+ - Help text updated with all new commands and hotkey reference
35
+
36
+ ### Removed
37
+ - RubyLLM direct fallback in app.rb (PROVIDER_MAP, try_credentials_llm, configure_llm_provider)
38
+
3
39
  ## [0.3.1] - 2026-03-19
4
40
 
5
41
  ### Fixed
@@ -16,19 +16,26 @@ module Legion
16
16
  attr_reader :config, :credentials, :screen_manager, :hotkeys, :llm_chat
17
17
 
18
18
  def self.run(argv = [])
19
- _ = argv
20
- app = new
19
+ opts = parse_argv(argv)
20
+ app = new(**opts)
21
21
  app.start
22
22
  rescue Interrupt
23
23
  app&.shutdown
24
24
  end
25
25
 
26
+ def self.parse_argv(argv)
27
+ opts = {}
28
+ opts[:skip_rain] = true if argv.include?('--skip-rain')
29
+ opts
30
+ end
31
+
26
32
  def self.first_run?(config_dir: CONFIG_DIR)
27
33
  !File.exist?(File.join(config_dir, 'identity.json'))
28
34
  end
29
35
 
30
- def initialize(config_dir: CONFIG_DIR)
36
+ def initialize(config_dir: CONFIG_DIR, skip_rain: false)
31
37
  @config_dir = config_dir
38
+ @skip_rain = skip_rain
32
39
  @config = load_config
33
40
  @credentials = load_credentials
34
41
  @screen_manager = ScreenManager.new
@@ -46,15 +53,11 @@ module Legion
46
53
  end
47
54
 
48
55
  def setup_hotkeys
49
- @hotkeys.register("\x04", 'Toggle dashboard (Ctrl+D)') do
50
- toggle_dashboard
51
- end
52
- @hotkeys.register("\x0C", 'Refresh screen (Ctrl+L)') do
53
- :refresh
54
- end
55
- @hotkeys.register('?', 'Show help overlay') do
56
- show_help_overlay
57
- end
56
+ @hotkeys.register("\x04", 'Toggle dashboard (Ctrl+D)') { toggle_dashboard }
57
+ @hotkeys.register("\x0C", 'Refresh screen (Ctrl+L)') { :refresh }
58
+ @hotkeys.register("\x0B", 'Command palette (Ctrl+K)') { :command_palette }
59
+ @hotkeys.register("\x13", 'Session picker (Ctrl+S)') { :session_picker }
60
+ @hotkeys.register("\e", 'Go back (Escape)') { :escape }
58
61
  end
59
62
 
60
63
  def toggle_dashboard
@@ -68,15 +71,8 @@ module Legion
68
71
  end
69
72
  end
70
73
 
71
- def show_help_overlay
72
- bindings = @hotkeys.list
73
- lines = bindings.map { |b| " #{b[:key].inspect} - #{b[:description]}" }
74
- text = "Hotkeys:\n#{lines.join("\n")}"
75
- @screen_manager.show_overlay(text)
76
- end
77
-
78
74
  def run_onboarding
79
- onboarding = Screens::Onboarding.new(self)
75
+ onboarding = Screens::Onboarding.new(self, skip_rain: @skip_rain)
80
76
  data = onboarding.activate
81
77
  save_config(data)
82
78
  @config = load_config
@@ -94,7 +90,7 @@ module Legion
94
90
 
95
91
  def setup_llm
96
92
  boot_legion_subsystems
97
- @llm_chat = try_settings_llm || try_credentials_llm
93
+ @llm_chat = try_settings_llm
98
94
  rescue StandardError
99
95
  @llm_chat = nil
100
96
  end
@@ -134,11 +130,6 @@ module Legion
134
130
  @screen_manager.teardown_all
135
131
  end
136
132
 
137
- PROVIDER_MAP = {
138
- claude: :anthropic,
139
- azure: :openai
140
- }.freeze
141
-
142
133
  private
143
134
 
144
135
  def boot_legion_subsystems # rubocop:disable Metrics/MethodLength
@@ -194,17 +185,6 @@ module Legion
194
185
  nil
195
186
  end
196
187
 
197
- def try_credentials_llm
198
- return nil unless @credentials[:provider] && @credentials[:api_key]
199
-
200
- provider = @credentials[:provider].to_sym
201
- api_key = @credentials[:api_key]
202
- configure_llm_provider(provider, api_key)
203
- create_llm_chat(provider)
204
- rescue StandardError
205
- nil
206
- end
207
-
208
188
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
209
189
  def save_identity(data)
210
190
  identity = {
@@ -270,43 +250,6 @@ module Legion
270
250
  {}
271
251
  end
272
252
 
273
- def configure_llm_provider(provider, api_key)
274
- llm_provider = PROVIDER_MAP[provider] || provider
275
-
276
- # Try Legion::LLM first (full stack)
277
- return if try_legion_llm(llm_provider, api_key)
278
-
279
- # Fallback: configure ruby_llm directly
280
- require 'ruby_llm'
281
- RubyLLM.configure do |c|
282
- case llm_provider
283
- when :anthropic then c.anthropic_api_key = api_key
284
- when :openai then c.openai_api_key = api_key
285
- when :gemini then c.gemini_api_key = api_key
286
- end
287
- end
288
- end
289
-
290
- def try_legion_llm(llm_provider, api_key)
291
- return false unless defined?(Legion::LLM) && defined?(Legion::Settings)
292
-
293
- Legion::Settings[:llm][:providers][llm_provider][:enabled] = true
294
- Legion::Settings[:llm][:providers][llm_provider][:api_key] = api_key
295
- Legion::LLM.start unless Legion::LLM.started?
296
- true
297
- rescue StandardError
298
- false
299
- end
300
-
301
- def create_llm_chat(provider)
302
- llm_provider = PROVIDER_MAP[provider] || provider
303
- if defined?(Legion::LLM) && Legion::LLM.started?
304
- Legion::LLM.chat(provider: llm_provider)
305
- else
306
- RubyLLM.chat(provider: llm_provider)
307
- end
308
- end
309
-
310
253
  def save_credentials(data)
311
254
  credentials = { api_key: data[:api_key], provider: data[:provider] }
312
255
  creds_path = File.join(@config_dir, 'credentials.json')
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TTY
5
+ module Components
6
+ class CommandPalette
7
+ COMMANDS = %w[/help /quit /clear /model /session /cost /export /tools
8
+ /dashboard /hotkeys /save /load /sessions /system /delete /plan
9
+ /palette /extensions /config].freeze
10
+
11
+ SCREENS = %w[chat dashboard extensions config].freeze
12
+
13
+ def initialize(session_store: nil)
14
+ @session_store = session_store
15
+ end
16
+
17
+ def entries
18
+ all = COMMANDS.map { |cmd| { label: cmd, category: 'Commands' } }
19
+ SCREENS.each { |s| all << { label: s, category: 'Screens' } }
20
+ @session_store&.list&.each do |s|
21
+ all << { label: "/load #{s[:name]}", category: 'Sessions' }
22
+ end
23
+ all
24
+ end
25
+
26
+ def search(query)
27
+ return entries if query.nil? || query.empty?
28
+
29
+ q = query.downcase
30
+ entries.select { |e| e[:label].downcase.include?(q) }
31
+ end
32
+
33
+ def select_with_prompt(output: $stdout)
34
+ require 'tty-prompt'
35
+ prompt = ::TTY::Prompt.new(output: output)
36
+ choices = entries.map { |e| { name: "#{e[:label]} (#{e[:category]})", value: e[:label] } }
37
+ prompt.select('Command:', choices, filter: true, per_page: 15)
38
+ rescue ::TTY::Reader::InputInterrupt, Interrupt
39
+ nil
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -52,13 +52,13 @@ module Legion
52
52
  end
53
53
 
54
54
  def render_message(msg, width)
55
- role_lines(msg) + panel_lines(msg, width)
55
+ role_lines(msg, width) + panel_lines(msg, width)
56
56
  end
57
57
 
58
- def role_lines(msg)
58
+ def role_lines(msg, width)
59
59
  case msg[:role]
60
60
  when :user then user_lines(msg)
61
- when :assistant then assistant_lines(msg)
61
+ when :assistant then assistant_lines(msg, width)
62
62
  when :system then system_lines(msg)
63
63
  else []
64
64
  end
@@ -69,8 +69,16 @@ module Legion
69
69
  ['', "#{prefix}: #{msg[:content]}"]
70
70
  end
71
71
 
72
- def assistant_lines(msg)
73
- ['', *msg[:content].split("\n")]
72
+ def assistant_lines(msg, width)
73
+ rendered = render_markdown(msg[:content], width)
74
+ ['', *rendered.split("\n")]
75
+ end
76
+
77
+ def render_markdown(text, width)
78
+ require_relative 'markdown_view'
79
+ MarkdownView.render(text, width: width)
80
+ rescue StandardError
81
+ text
74
82
  end
75
83
 
76
84
  def system_lines(msg)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TTY
5
+ module Components
6
+ class ModelPicker
7
+ def initialize(current_provider: nil, current_model: nil)
8
+ @current_provider = current_provider
9
+ @current_model = current_model
10
+ end
11
+
12
+ def available_models # rubocop:disable Metrics/CyclomaticComplexity
13
+ return [] unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:settings)
14
+
15
+ providers = Legion::LLM.settings[:providers]
16
+ return [] unless providers.is_a?(Hash)
17
+
18
+ models = []
19
+ providers.each do |name, config|
20
+ next unless config.is_a?(Hash) && config[:enabled]
21
+
22
+ model = config[:default_model] || name.to_s
23
+ current = (name.to_s == @current_provider.to_s)
24
+ models << { provider: name.to_s, model: model, current: current }
25
+ end
26
+ models
27
+ end
28
+
29
+ def select_with_prompt(output: $stdout)
30
+ models = available_models
31
+ return nil if models.empty?
32
+
33
+ require 'tty-prompt'
34
+ prompt = ::TTY::Prompt.new(output: output)
35
+ choices = models.map do |m|
36
+ indicator = m[:current] ? ' (current)' : ''
37
+ { name: "#{m[:provider]} / #{m[:model]}#{indicator}", value: m }
38
+ end
39
+ prompt.select('Select model:', choices, per_page: 10)
40
+ rescue ::TTY::Reader::InputInterrupt, Interrupt
41
+ nil
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TTY
5
+ module Components
6
+ class SessionPicker
7
+ def initialize(session_store:)
8
+ @session_store = session_store
9
+ end
10
+
11
+ def select_with_prompt(output: $stdout)
12
+ sessions = @session_store.list
13
+ return nil if sessions.empty?
14
+
15
+ require 'tty-prompt'
16
+ prompt = ::TTY::Prompt.new(output: output)
17
+ choices = sessions.map do |s|
18
+ { name: "#{s[:name]} (#{s[:message_count]} msgs, #{s[:saved_at]})", value: s[:name] }
19
+ end
20
+ choices << { name: '+ New session', value: :new }
21
+ prompt.select('Select session:', choices, per_page: 10)
22
+ rescue ::TTY::Reader::InputInterrupt, Interrupt
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -7,7 +7,7 @@ module Legion
7
7
  module Components
8
8
  class StatusBar
9
9
  def initialize
10
- @state = { model: nil, tokens: 0, cost: 0.0, session: 'default' }
10
+ @state = { model: nil, tokens: 0, cost: 0.0, session: 'default', thinking: false, plan_mode: false }
11
11
  end
12
12
 
13
13
  def update(**fields)
@@ -31,6 +31,8 @@ module Legion
31
31
  def build_segments
32
32
  [
33
33
  model_segment,
34
+ plan_segment,
35
+ thinking_segment,
34
36
  tokens_segment,
35
37
  cost_segment,
36
38
  session_segment
@@ -41,6 +43,18 @@ module Legion
41
43
  Theme.c(:accent, @state[:model]) if @state[:model]
42
44
  end
43
45
 
46
+ def plan_segment
47
+ return nil unless @state[:plan_mode]
48
+
49
+ Theme.c(:warning, '[PLAN]')
50
+ end
51
+
52
+ def thinking_segment
53
+ return nil unless @state[:thinking]
54
+
55
+ Theme.c(:warning, 'thinking...')
56
+ end
57
+
44
58
  def tokens_segment
45
59
  Theme.c(:secondary, "#{format_number(@state[:tokens])} tokens") if @state[:tokens].to_i.positive?
46
60
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TTY
5
+ module Components
6
+ module TableView
7
+ def self.render(headers:, rows:, width: 80)
8
+ require 'tty-table'
9
+ table = ::TTY::Table.new(header: headers, rows: rows)
10
+ table.render(:unicode, width: width, padding: [0, 1]) || ''
11
+ rescue StandardError => e
12
+ "Table render error: #{e.message}"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -4,28 +4,51 @@ module Legion
4
4
  module TTY
5
5
  module Components
6
6
  class TokenTracker
7
- PRICING = {
7
+ # Rates per 1k tokens (input/output) — current as of 2026-03
8
+ MODEL_PRICING = {
9
+ 'claude-opus-4-6' => { input: 0.015, output: 0.075 },
10
+ 'claude-sonnet-4-6' => { input: 0.003, output: 0.015 },
11
+ 'claude-haiku-4-5' => { input: 0.0008, output: 0.004 },
12
+ 'gpt-4o' => { input: 0.0025, output: 0.010 },
13
+ 'gpt-4o-mini' => { input: 0.00015, output: 0.0006 },
14
+ 'gpt-4.1' => { input: 0.002, output: 0.008 },
15
+ 'gemini-2.0-flash' => { input: 0.0001, output: 0.0004 },
16
+ 'gemini-2.5-pro' => { input: 0.00125, output: 0.01 },
17
+ 'local' => { input: 0.0, output: 0.0 }
18
+ }.freeze
19
+
20
+ PROVIDER_PRICING = {
21
+ 'anthropic' => { input: 0.003, output: 0.015 },
8
22
  'claude' => { input: 0.003, output: 0.015 },
9
- 'openai' => { input: 0.005, output: 0.015 },
10
- 'gemini' => { input: 0.001, output: 0.002 },
11
- 'azure' => { input: 0.005, output: 0.015 },
23
+ 'openai' => { input: 0.0025, output: 0.010 },
24
+ 'gemini' => { input: 0.0001, output: 0.0004 },
25
+ 'bedrock' => { input: 0.003, output: 0.015 },
26
+ 'azure' => { input: 0.0025, output: 0.010 },
27
+ 'ollama' => { input: 0.0, output: 0.0 },
12
28
  'local' => { input: 0.0, output: 0.0 }
13
29
  }.freeze
14
30
 
15
31
  attr_reader :total_input_tokens, :total_output_tokens, :total_cost
16
32
 
17
- def initialize(provider: 'claude')
33
+ def initialize(provider: 'claude', model: nil)
18
34
  @provider = provider
35
+ @model = model
19
36
  @total_input_tokens = 0
20
37
  @total_output_tokens = 0
21
38
  @total_cost = 0.0
22
39
  end
23
40
 
24
- def track(input_tokens:, output_tokens:)
41
+ def update_model(model)
42
+ @model = model
43
+ end
44
+
45
+ def track(input_tokens:, output_tokens:, model: nil)
46
+ @model = model if model
25
47
  @total_input_tokens += input_tokens.to_i
26
48
  @total_output_tokens += output_tokens.to_i
27
- rates = PRICING[@provider] || PRICING['claude']
28
- @total_cost += (input_tokens.to_i * rates[:input] / 1000.0) + (output_tokens.to_i * rates[:output] / 1000.0)
49
+ rates = rates_for(@model, @provider)
50
+ @total_cost += (input_tokens.to_i * rates[:input] / 1000.0) +
51
+ (output_tokens.to_i * rates[:output] / 1000.0)
29
52
  end
30
53
 
31
54
  def summary
@@ -37,6 +60,16 @@ module Legion
37
60
 
38
61
  private
39
62
 
63
+ def rates_for(model, provider)
64
+ return MODEL_PRICING[model] if model && MODEL_PRICING.key?(model)
65
+
66
+ MODEL_PRICING.each do |key, rates|
67
+ return rates if model&.include?(key)
68
+ end
69
+
70
+ PROVIDER_PRICING[provider] || PROVIDER_PRICING['claude']
71
+ end
72
+
40
73
  def format_number(num)
41
74
  num.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse
42
75
  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].freeze
16
+ /sessions /system /delete /plan /palette /extensions /config].freeze
17
17
 
18
18
  attr_reader :message_stream, :status_bar
19
19
 
@@ -28,6 +28,7 @@ module Legion
28
28
  @token_tracker = Components::TokenTracker.new(provider: detect_provider)
29
29
  @session_store = SessionStore.new
30
30
  @session_name = 'default'
31
+ @plan_mode = false
31
32
  end
32
33
 
33
34
  def activate
@@ -45,6 +46,7 @@ module Legion
45
46
  @running
46
47
  end
47
48
 
49
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
48
50
  def run
49
51
  activate
50
52
  while @running
@@ -52,6 +54,11 @@ module Legion
52
54
  input = read_input
53
55
  break if input.nil?
54
56
 
57
+ if @app.respond_to?(:screen_manager) && @app.screen_manager.overlay
58
+ @app.screen_manager.dismiss_overlay
59
+ next
60
+ end
61
+
55
62
  result = handle_slash_command(input)
56
63
  if result == :quit
57
64
  auto_save_session
@@ -62,6 +69,7 @@ module Legion
62
69
  end
63
70
  end
64
71
  end
72
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
65
73
 
66
74
  def handle_slash_command(input)
67
75
  return nil unless input.start_with?('/')
@@ -74,25 +82,32 @@ module Legion
74
82
 
75
83
  def handle_user_message(input)
76
84
  @message_stream.add_message(role: :user, content: input)
77
- @message_stream.add_message(role: :assistant, content: '')
78
- send_to_llm(input)
85
+ if @plan_mode
86
+ @message_stream.add_message(role: :system, content: '(bookmarked)')
87
+ else
88
+ @message_stream.add_message(role: :assistant, content: '')
89
+ send_to_llm(input)
90
+ end
79
91
  render_screen
80
92
  end
81
93
 
82
94
  def send_to_llm(message)
83
95
  unless @llm_chat
84
- @message_stream.append_streaming(
85
- 'LLM not configured. Use /help for commands.'
86
- )
96
+ @message_stream.append_streaming('LLM not configured. Use /help for commands.')
87
97
  return
88
98
  end
89
99
 
100
+ @status_bar.update(thinking: true)
101
+ render_screen
90
102
  response = @llm_chat.ask(message) do |chunk|
103
+ @status_bar.update(thinking: false)
91
104
  @message_stream.append_streaming(chunk.content) if chunk.content
92
105
  render_screen
93
106
  end
107
+ @status_bar.update(thinking: false)
94
108
  track_response_tokens(response)
95
109
  rescue StandardError => e
110
+ @status_bar.update(thinking: false)
96
111
  @message_stream.append_streaming("\n[Error: #{e.message}]")
97
112
  end
98
113
 
@@ -175,7 +190,30 @@ module Legion
175
190
  @output.print ::TTY::Cursor.move_to(0, 0)
176
191
  @output.print ::TTY::Cursor.clear_screen_down
177
192
  lines.each { |line| @output.puts line }
193
+ render_overlay if @app.respond_to?(:screen_manager) && @app.screen_manager.overlay
194
+ end
195
+
196
+ # rubocop:disable Metrics/AbcSize
197
+ def render_overlay
198
+ require 'tty-box'
199
+ text = @app.screen_manager.overlay.to_s
200
+ width = (terminal_width - 4).clamp(40, terminal_width)
201
+ box = ::TTY::Box.frame(
202
+ width: width,
203
+ padding: 1,
204
+ title: { top_left: ' Help ' },
205
+ border: :round
206
+ ) { text }
207
+ overlay_lines = box.split("\n")
208
+ start_row = [(terminal_height - overlay_lines.size) / 2, 0].max
209
+ overlay_lines.each_with_index do |line, i|
210
+ @output.print ::TTY::Cursor.move_to(2, start_row + i)
211
+ @output.print line
212
+ end
213
+ rescue StandardError
214
+ nil
178
215
  end
216
+ # rubocop:enable Metrics/AbcSize
179
217
 
180
218
  def read_input
181
219
  return nil unless @input_bar.respond_to?(:read_line)
@@ -185,7 +223,7 @@ module Legion
185
223
  nil
186
224
  end
187
225
 
188
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
226
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
189
227
  def dispatch_slash(cmd, input)
190
228
  case cmd
191
229
  when '/quit' then :quit
@@ -201,16 +239,24 @@ module Legion
201
239
  when '/sessions' then handle_sessions
202
240
  when '/dashboard' then handle_dashboard
203
241
  when '/hotkeys' then handle_hotkeys
242
+ when '/system' then handle_system(input)
243
+ when '/delete' then handle_delete(input)
244
+ when '/plan' then handle_plan
245
+ when '/palette' then handle_palette
246
+ when '/extensions' then handle_extensions_screen
247
+ when '/config' then handle_config_screen
204
248
  else :handled
205
249
  end
206
250
  end
207
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
251
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
208
252
 
209
253
  def handle_help
210
254
  @message_stream.add_message(
211
255
  role: :system,
212
- content: "Commands: /help /quit /clear /model <name> /session <name> /cost\n " \
213
- '/export [md|json] /tools /dashboard /hotkeys /save /load /sessions'
256
+ content: "Commands:\n /help /quit /clear /model <name> /session <name> /cost\n " \
257
+ "/export [md|json] /tools /dashboard /hotkeys /save /load /sessions\n " \
258
+ "/system <prompt> /delete <session> /plan /palette /extensions /config\n\n" \
259
+ 'Hotkeys: Ctrl+D=dashboard Ctrl+K=palette Ctrl+S=sessions Esc=back'
214
260
  )
215
261
  :handled
216
262
  end
@@ -223,21 +269,38 @@ module Legion
223
269
  def handle_model(input)
224
270
  name = input.split(nil, 2)[1]
225
271
  if name
226
- if @llm_chat.respond_to?(:with_model)
227
- @llm_chat.with_model(name)
228
- @status_bar.update(model: name)
229
- @message_stream.add_message(role: :system, content: "Model switched to: #{name}")
230
- else
231
- @status_bar.update(model: name)
232
- @message_stream.add_message(role: :system, content: "Model set to: #{name} (no active LLM session)")
233
- end
272
+ switch_model(name)
234
273
  else
235
- current = safe_config[:provider] || 'unknown'
236
- @message_stream.add_message(role: :system, content: "Current model: #{current}")
274
+ show_current_model
237
275
  end
238
276
  :handled
239
277
  end
240
278
 
279
+ def switch_model(name)
280
+ unless @llm_chat
281
+ @message_stream.add_message(role: :system, content: 'No active LLM session.')
282
+ return
283
+ end
284
+
285
+ if @llm_chat.respond_to?(:with_model)
286
+ @llm_chat.with_model(name)
287
+ @status_bar.update(model: name)
288
+ @message_stream.add_message(role: :system, content: "Model switched to: #{name}")
289
+ else
290
+ @status_bar.update(model: name)
291
+ @message_stream.add_message(role: :system, content: "Model set to: #{name}")
292
+ end
293
+ rescue StandardError => e
294
+ @message_stream.add_message(role: :system, content: "Failed to switch model: #{e.message}")
295
+ end
296
+
297
+ def show_current_model
298
+ model = @llm_chat.respond_to?(:model) ? @llm_chat.model : nil
299
+ provider = safe_config[:provider] || 'unknown'
300
+ info = model ? "#{model} (#{provider})" : provider
301
+ @message_stream.add_message(role: :system, content: "Current model: #{info}")
302
+ end
303
+
241
304
  def handle_session(input)
242
305
  name = input.split(nil, 2)[1]
243
306
  if name
@@ -362,10 +425,91 @@ module Legion
362
425
  :handled
363
426
  end
364
427
 
428
+ def handle_system(input)
429
+ text = input.split(nil, 2)[1]
430
+ if text
431
+ if @llm_chat.respond_to?(:with_instructions)
432
+ @llm_chat.with_instructions(text)
433
+ @message_stream.add_message(role: :system, content: 'System prompt updated.')
434
+ else
435
+ @message_stream.add_message(role: :system, content: 'No active LLM session.')
436
+ end
437
+ else
438
+ @message_stream.add_message(role: :system, content: 'Usage: /system <prompt text>')
439
+ end
440
+ :handled
441
+ end
442
+
443
+ def handle_delete(input)
444
+ name = input.split(nil, 2)[1]
445
+ unless name
446
+ @message_stream.add_message(role: :system, content: 'Usage: /delete <session-name>')
447
+ return :handled
448
+ end
449
+ @session_store.delete(name)
450
+ @message_stream.add_message(role: :system, content: "Session '#{name}' deleted.")
451
+ :handled
452
+ end
453
+
454
+ def handle_plan
455
+ @plan_mode = !@plan_mode
456
+ if @plan_mode
457
+ @status_bar.update(plan_mode: true)
458
+ @message_stream.add_message(role: :system,
459
+ content: 'Plan mode ON -- messages are bookmarked, not sent to LLM.')
460
+ else
461
+ @status_bar.update(plan_mode: false)
462
+ @message_stream.add_message(role: :system, content: 'Plan mode OFF -- messages sent to LLM.')
463
+ end
464
+ :handled
465
+ end
466
+
467
+ def handle_palette
468
+ require_relative '../components/command_palette'
469
+ palette = Components::CommandPalette.new(session_store: @session_store)
470
+ selection = palette.select_with_prompt(output: @output)
471
+ return :handled unless selection
472
+
473
+ if selection.start_with?('/')
474
+ handle_slash_command(selection)
475
+ else
476
+ dispatch_screen_by_name(selection)
477
+ end
478
+ :handled
479
+ end
480
+
481
+ def dispatch_screen_by_name(name)
482
+ case name
483
+ when 'dashboard' then handle_dashboard
484
+ when 'extensions' then handle_extensions_screen
485
+ when 'config' then handle_config_screen
486
+ end
487
+ end
488
+
489
+ def handle_extensions_screen
490
+ require_relative '../screens/extensions'
491
+ screen = Screens::Extensions.new(@app, output: @output)
492
+ @app.screen_manager.push(screen)
493
+ :handled
494
+ rescue LoadError
495
+ @message_stream.add_message(role: :system, content: 'Extensions screen not available.')
496
+ :handled
497
+ end
498
+
499
+ def handle_config_screen
500
+ require_relative '../screens/config'
501
+ screen = Screens::Config.new(@app, output: @output)
502
+ @app.screen_manager.push(screen)
503
+ :handled
504
+ rescue LoadError
505
+ @message_stream.add_message(role: :system, content: 'Config screen not available.')
506
+ :handled
507
+ end
508
+
365
509
  def detect_provider
366
510
  cfg = safe_config
367
511
  provider = cfg[:provider].to_s.downcase
368
- return provider if Components::TokenTracker::PRICING.key?(provider)
512
+ return provider if Components::TokenTracker::PROVIDER_PRICING.key?(provider)
369
513
 
370
514
  'claude'
371
515
  end
@@ -373,9 +517,11 @@ module Legion
373
517
  def track_response_tokens(response)
374
518
  return unless response.respond_to?(:input_tokens)
375
519
 
520
+ model_id = response.respond_to?(:model) ? response.model.to_s : nil
376
521
  @token_tracker.track(
377
522
  input_tokens: response.input_tokens.to_i,
378
- output_tokens: response.output_tokens.to_i
523
+ output_tokens: response.output_tokens.to_i,
524
+ model: model_id
379
525
  )
380
526
  @status_bar.update(
381
527
  tokens: @token_tracker.total_input_tokens + @token_tracker.total_output_tokens,
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'base'
5
+ require_relative '../theme'
6
+
7
+ module Legion
8
+ module TTY
9
+ module Screens
10
+ # rubocop:disable Metrics/ClassLength
11
+ class Config < Base
12
+ MASKED_PATTERNS = %w[vault:// env://].freeze
13
+
14
+ def initialize(app, output: $stdout, config_dir: nil)
15
+ super(app)
16
+ @output = output
17
+ @config_dir = config_dir || File.expand_path('~/.legionio/settings')
18
+ @files = []
19
+ @selected_file = 0
20
+ @selected_key = 0
21
+ @viewing_file = false
22
+ @file_data = {}
23
+ end
24
+
25
+ def activate
26
+ @files = discover_config_files
27
+ end
28
+
29
+ def discover_config_files
30
+ return [] unless Dir.exist?(@config_dir)
31
+
32
+ Dir.glob(File.join(@config_dir, '*.json')).map do |path|
33
+ { name: File.basename(path), path: path }
34
+ end
35
+ end
36
+
37
+ def render(_width, height)
38
+ lines = [Theme.c(:accent, ' Settings'), '']
39
+ lines += if @viewing_file
40
+ file_detail_lines(height - 4)
41
+ else
42
+ file_list_lines(height - 4)
43
+ end
44
+ lines += ['', Theme.c(:muted, ' Enter=view e=edit q=back')]
45
+ pad_lines(lines, height)
46
+ end
47
+
48
+ def handle_input(key)
49
+ if @viewing_file
50
+ handle_file_view_input(key)
51
+ else
52
+ handle_file_list_input(key)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def handle_file_list_input(key)
59
+ case key
60
+ when :up then @selected_file = [(@selected_file - 1), 0].max
61
+ :handled
62
+ when :down then @selected_file = [(@selected_file + 1), @files.size - 1].max
63
+ :handled
64
+ when :enter then open_file
65
+ :handled
66
+ when 'q', :escape then :pop_screen
67
+ else
68
+ :pass
69
+ end
70
+ end
71
+
72
+ def handle_file_view_input(key)
73
+ keys = @file_data.keys
74
+ max = [keys.size - 1, 0].max
75
+ case key
76
+ when :up then @selected_key = [(@selected_key - 1), 0].max
77
+ :handled
78
+ when :down then @selected_key = [(@selected_key + 1), max].max
79
+ :handled
80
+ when 'e', :enter then edit_selected_key
81
+ :handled
82
+ when 'q', :escape
83
+ @viewing_file = false
84
+ @selected_key = 0
85
+ :handled
86
+ else
87
+ :pass
88
+ end
89
+ end
90
+
91
+ def open_file
92
+ return unless @files[@selected_file]
93
+
94
+ path = @files[@selected_file][:path]
95
+ @file_data = ::JSON.parse(File.read(path))
96
+ @viewing_file = true
97
+ @selected_key = 0
98
+ rescue ::JSON::ParserError, Errno::ENOENT
99
+ @file_data = { 'error' => 'Failed to parse file' }
100
+ @viewing_file = true
101
+ end
102
+
103
+ def edit_selected_key # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
104
+ keys = @file_data.keys
105
+ return unless keys[@selected_key]
106
+
107
+ key = keys[@selected_key]
108
+ current = @file_data[key]
109
+ return if current.is_a?(Hash) || current.is_a?(Array)
110
+
111
+ require 'tty-prompt'
112
+ prompt = ::TTY::Prompt.new
113
+ display = masked?(current.to_s) ? '********' : current.to_s
114
+ new_val = prompt.ask("#{key}:", default: display)
115
+ return if new_val.nil? || new_val == '********'
116
+
117
+ @file_data[key] = new_val
118
+ save_current_file
119
+ rescue ::TTY::Reader::InputInterrupt, Interrupt
120
+ nil
121
+ end
122
+
123
+ def save_current_file
124
+ return unless @files[@selected_file]
125
+
126
+ path = @files[@selected_file][:path]
127
+ File.write(path, ::JSON.pretty_generate(@file_data))
128
+ end
129
+
130
+ def masked?(val)
131
+ MASKED_PATTERNS.any? { |p| val.to_s.start_with?(p) }
132
+ end
133
+
134
+ def file_list_lines(max_height)
135
+ @files.each_with_index.map do |f, i|
136
+ indicator = i == @selected_file ? Theme.c(:accent, '>') : ' '
137
+ " #{indicator} #{f[:name]}"
138
+ end.first(max_height)
139
+ end
140
+
141
+ def file_detail_lines(max_height)
142
+ return [" #{Theme.c(:muted, 'Empty file')}"] if @file_data.empty?
143
+
144
+ name = @files[@selected_file]&.dig(:name) || 'unknown'
145
+ lines = [" #{Theme.c(:secondary, name)}", '']
146
+ @file_data.each_with_index do |(key, val), i|
147
+ indicator = i == @selected_key ? Theme.c(:accent, '>') : ' '
148
+ display_val = format_value(val)
149
+ lines << " #{indicator} #{Theme.c(:accent, key)}: #{display_val}"
150
+ end
151
+ lines.first(max_height)
152
+ end
153
+
154
+ def format_value(val)
155
+ case val
156
+ when Hash then Theme.c(:muted, "{#{val.size} keys}")
157
+ when Array then Theme.c(:muted, "[#{val.size} items]")
158
+ else
159
+ masked?(val.to_s) ? Theme.c(:warning, '********') : val.to_s
160
+ end
161
+ end
162
+
163
+ def pad_lines(lines, height)
164
+ lines + Array.new([height - lines.size, 0].max, '')
165
+ end
166
+ end
167
+ # rubocop:enable Metrics/ClassLength
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../theme'
5
+
6
+ module Legion
7
+ module TTY
8
+ module Screens
9
+ # rubocop:disable Metrics/ClassLength
10
+ class Extensions < Base
11
+ CORE = %w[lex-node lex-tasker lex-scheduler lex-conditioner lex-transformer
12
+ lex-synapse lex-health lex-log lex-ping lex-metering lex-llm-gateway
13
+ lex-codegen lex-exec lex-lex lex-telemetry lex-audit lex-detect].freeze
14
+
15
+ AI = %w[lex-claude lex-openai lex-gemini].freeze
16
+
17
+ SERVICE = %w[lex-http lex-vault lex-github lex-consul lex-kerberos lex-tfe
18
+ lex-redis lex-memcached lex-elasticsearch lex-s3].freeze
19
+
20
+ def initialize(app, output: $stdout)
21
+ super(app)
22
+ @output = output
23
+ @gems = []
24
+ @selected = 0
25
+ @detail = false
26
+ end
27
+
28
+ def activate
29
+ @gems = discover_extensions
30
+ end
31
+
32
+ def discover_extensions
33
+ Gem::Specification.select { |s| s.name.start_with?('lex-') }
34
+ .sort_by(&:name)
35
+ .map { |s| build_entry(s) }
36
+ end
37
+
38
+ def render(_width, height)
39
+ lines = [Theme.c(:accent, ' LEX Extensions'), '']
40
+ lines += if @detail && @gems[@selected]
41
+ detail_lines(@gems[@selected])
42
+ else
43
+ list_lines(height - 4)
44
+ end
45
+ lines += ['', Theme.c(:muted, ' Enter=detail q=back')]
46
+ pad_lines(lines, height)
47
+ end
48
+
49
+ def handle_input(key)
50
+ case key
51
+ when :up
52
+ @selected = [(@selected - 1), 0].max
53
+ :handled
54
+ when :down
55
+ @selected = [(@selected + 1), @gems.size - 1].min
56
+ :handled
57
+ when :enter
58
+ @detail = !@detail
59
+ :handled
60
+ when 'q', :escape
61
+ if @detail
62
+ @detail = false
63
+ :handled
64
+ else
65
+ :pop_screen
66
+ end
67
+ else
68
+ :pass
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def build_entry(spec)
75
+ loaded = $LOADED_FEATURES.any? { |f| f.include?(spec.name.tr('-', '/')) }
76
+ {
77
+ name: spec.name,
78
+ version: spec.version.to_s,
79
+ summary: spec.summary,
80
+ homepage: spec.homepage,
81
+ loaded: loaded,
82
+ category: categorize(spec.name),
83
+ deps: spec.runtime_dependencies.map { |d| "#{d.name} #{d.requirement}" }
84
+ }
85
+ end
86
+
87
+ def categorize(name)
88
+ return 'Core' if CORE.include?(name)
89
+ return 'AI' if AI.include?(name)
90
+ return 'Service' if SERVICE.include?(name)
91
+ return 'Agentic' if name.match?(/^lex-agentic-|^lex-theory-|^lex-mind-|^lex-planning|^lex-attention/)
92
+
93
+ 'Other'
94
+ end
95
+
96
+ # rubocop:disable Metrics/AbcSize
97
+ def list_lines(max_height)
98
+ grouped = @gems.group_by { |g| g[:category] }
99
+ lines = []
100
+ idx = 0
101
+ grouped.each do |cat, gems|
102
+ lines << Theme.c(:secondary, " #{cat} (#{gems.size})")
103
+ gems.each do |g|
104
+ indicator = idx == @selected ? Theme.c(:accent, '>') : ' '
105
+ status = g[:loaded] ? Theme.c(:success, 'loaded') : Theme.c(:muted, 'avail')
106
+ lines << " #{indicator} #{g[:name]} #{Theme.c(:muted, g[:version])} [#{status}]"
107
+ idx += 1
108
+ end
109
+ lines << ''
110
+ end
111
+ lines.first(max_height)
112
+ end
113
+
114
+ # rubocop:enable Metrics/AbcSize
115
+
116
+ def detail_lines(gem_entry)
117
+ [
118
+ " #{Theme.c(:accent, gem_entry[:name])} #{gem_entry[:version]}",
119
+ " #{Theme.c(:muted, gem_entry[:category])}",
120
+ '',
121
+ " #{gem_entry[:summary]}",
122
+ '',
123
+ " #{Theme.c(:secondary, 'Dependencies:')}",
124
+ *gem_entry[:deps].map { |d| " #{d}" },
125
+ '',
126
+ " #{Theme.c(:muted, gem_entry[:homepage] || 'no homepage')}"
127
+ ]
128
+ end
129
+
130
+ def pad_lines(lines, height)
131
+ lines + Array.new([height - lines.size, 0].max, '')
132
+ end
133
+ end
134
+ # rubocop:enable Metrics/ClassLength
135
+ end
136
+ end
137
+ end
@@ -387,8 +387,8 @@ module Legion
387
387
  end
388
388
 
389
389
  def default_vault_username
390
- if @kerberos_identity&.dig(:samaccountname)
391
- @kerberos_identity[:samaccountname]
390
+ if @kerberos_identity&.dig(:username)
391
+ @kerberos_identity[:username]
392
392
  else
393
393
  ENV.fetch('USER', 'unknown')
394
394
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.3.1'
5
+ VERSION = '0.4.0'
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.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -201,11 +201,15 @@ files:
201
201
  - lib/legion/tty/background/llm_probe.rb
202
202
  - lib/legion/tty/background/scanner.rb
203
203
  - lib/legion/tty/boot_logger.rb
204
+ - lib/legion/tty/components/command_palette.rb
204
205
  - lib/legion/tty/components/digital_rain.rb
205
206
  - lib/legion/tty/components/input_bar.rb
206
207
  - lib/legion/tty/components/markdown_view.rb
207
208
  - lib/legion/tty/components/message_stream.rb
209
+ - lib/legion/tty/components/model_picker.rb
210
+ - lib/legion/tty/components/session_picker.rb
208
211
  - lib/legion/tty/components/status_bar.rb
212
+ - lib/legion/tty/components/table_view.rb
209
213
  - lib/legion/tty/components/token_tracker.rb
210
214
  - lib/legion/tty/components/tool_panel.rb
211
215
  - lib/legion/tty/components/wizard_prompt.rb
@@ -213,7 +217,9 @@ files:
213
217
  - lib/legion/tty/screen_manager.rb
214
218
  - lib/legion/tty/screens/base.rb
215
219
  - lib/legion/tty/screens/chat.rb
220
+ - lib/legion/tty/screens/config.rb
216
221
  - lib/legion/tty/screens/dashboard.rb
222
+ - lib/legion/tty/screens/extensions.rb
217
223
  - lib/legion/tty/screens/onboarding.rb
218
224
  - lib/legion/tty/session_store.rb
219
225
  - lib/legion/tty/theme.rb