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 +4 -4
- data/CHANGELOG.md +36 -0
- data/lib/legion/tty/app.rb +17 -74
- data/lib/legion/tty/components/command_palette.rb +44 -0
- data/lib/legion/tty/components/message_stream.rb +13 -5
- data/lib/legion/tty/components/model_picker.rb +46 -0
- data/lib/legion/tty/components/session_picker.rb +28 -0
- data/lib/legion/tty/components/status_bar.rb +15 -1
- data/lib/legion/tty/components/table_view.rb +17 -0
- data/lib/legion/tty/components/token_tracker.rb +41 -8
- data/lib/legion/tty/screens/chat.rb +168 -22
- data/lib/legion/tty/screens/config.rb +170 -0
- data/lib/legion/tty/screens/extensions.rb +137 -0
- data/lib/legion/tty/screens/onboarding.rb +2 -2
- data/lib/legion/tty/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7b05c783669ce2e8482bd36b3654b75d626d3fc2b738e684a6b9fad407881e1
|
|
4
|
+
data.tar.gz: 34418c0af4571764d36096de930f126a92b192fe87ff22c2acbbcbb71f45a090
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/legion/tty/app.rb
CHANGED
|
@@ -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
|
-
|
|
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)')
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@hotkeys.register("\
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
10
|
-
'gemini' => { input: 0.
|
|
11
|
-
'
|
|
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
|
|
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 =
|
|
28
|
-
@total_cost += (input_tokens.to_i * rates[:input] / 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
|
-
@
|
|
78
|
-
|
|
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
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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::
|
|
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(:
|
|
391
|
-
@kerberos_identity[:
|
|
390
|
+
if @kerberos_identity&.dig(:username)
|
|
391
|
+
@kerberos_identity[:username]
|
|
392
392
|
else
|
|
393
393
|
ENV.fetch('USER', 'unknown')
|
|
394
394
|
end
|
data/lib/legion/tty/version.rb
CHANGED
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
|
+
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
|