legion-tty 0.4.0 → 0.4.2

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: d7b05c783669ce2e8482bd36b3654b75d626d3fc2b738e684a6b9fad407881e1
4
- data.tar.gz: 34418c0af4571764d36096de930f126a92b192fe87ff22c2acbbcbb71f45a090
3
+ metadata.gz: 3985cb489efcaf4eaf208514bee79ff9650b1f841a00cd76a32468a0734e4198
4
+ data.tar.gz: fa7c612dac3248efe49762a4bd6b869b8348c4388ee569fdbd60c030501db918
5
5
  SHA512:
6
- metadata.gz: 1f913c1e7ec15e45a7d27d24393e721852ffd28e7d61c1d3284996fe564bd30ee2dcd9910676eb40bf5239cfbf9068f5561c95f61bf55c0f1beb8ff95677925f
7
- data.tar.gz: 995fc6176e39c16dae3b1570149ed8292c0c30174fa5eecd5effcafcfa5004eb8006a135b8c815199bf1003e46f26c227a65f1ecb3f00a35ebcc0b48d16f5fc6
6
+ metadata.gz: 9909b4d373d5b22892611dd3c302d02cb4960bfec8524da602c117b62de5d8fbacb9f39a777b97e5eabe9c379e4918fb79aab313d2f67e5c4129ff1214153d32
7
+ data.tar.gz: 3b8e072e3ad9cb3bd73b9e7e7bb5b6fb6baa86527e4da6e0045564c40851ec77e5e67a89cf7409d1280b59dc1a34831780859d56632e6e4c9a7b6095991a555f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.2] - 2026-03-19
4
+
5
+ ### Added
6
+ - Multi-provider model switching: `/model <provider>` creates new Legion::LLM.chat instance
7
+ - Model picker integration: `open_model_picker` for interactive provider/model selection
8
+ - ToolPanel wiring in MessageStream: `add_tool_call`, `update_tool_call` methods
9
+ - Tool call rendering in chat messages (`:tool` role with panel display)
10
+
11
+ ### Fixed
12
+ - Flaky table_view_spec: added explicit `require 'tty-table'` to prevent test ordering failures
13
+
14
+ ## [0.4.1] - 2026-03-19
15
+
16
+ ### Added
17
+ - Progress panel component wrapping tty-progressbar for long operations
18
+ - Tab completion for slash commands (type `/` + Tab to cycle through matches)
19
+ - InputBar now accepts `completions:` parameter for configurable auto-complete
20
+
21
+ ### Changed
22
+ - README.md updated to reflect 0.4.x features, hotkeys, architecture
23
+ - CLAUDE.md updated to reflect current version, components, LLM integration notes
24
+
3
25
  ## [0.4.0] - 2026-03-19
4
26
 
5
27
  ### Fixed
data/README.md CHANGED
@@ -2,19 +2,26 @@
2
2
 
3
3
  Rich terminal UI for the LegionIO async cognition engine.
4
4
 
5
- **Version**: 0.2.9
5
+ **Version**: 0.4.1
6
6
 
7
- Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell, operational dashboard, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
7
+ Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell, operational dashboard, extensions browser, config editor, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
8
8
 
9
9
  ## Features
10
10
 
11
11
  - **Onboarding wizard** - First-run setup with Kerberos identity detection, GitHub profile probing, environment scanning, and LLM provider selection
12
12
  - **Digital rain intro** - Matrix-style rain using discovered LEX extension names
13
- - **AI chat shell** - Streaming LLM chat with slash commands, tool panels, and markdown rendering
13
+ - **AI chat shell** - Streaming LLM chat with 19 slash commands, tab completion, markdown rendering, and tool panels
14
14
  - **Operational dashboard** - Service status, extension inventory, system info, recent activity (Ctrl+D or `/dashboard`)
15
- - **Session persistence** - Auto-save on quit, `/save`, `/load`, `/sessions` to manage history across runs
16
- - **Token tracking** - Real-time input/output token counts and estimated cost via `/cost`
17
- - **Hotkey navigation** - Ctrl+D (dashboard), Ctrl+L (refresh), ? (help overlay)
15
+ - **Extensions browser** - Browse installed LEX gems by category (core, agentic, service, AI, other) with detail view
16
+ - **Config viewer/editor** - View and edit `~/.legionio/settings/*.json` with vault:// masking
17
+ - **Command palette** - Fuzzy-search overlay for all commands, screens, and sessions (Ctrl+K or `/palette`)
18
+ - **Model picker** - Switch LLM providers interactively
19
+ - **Session management** - Auto-save on quit, `/save`, `/load`, `/sessions`, session picker (Ctrl+S)
20
+ - **Token tracking** - Per-model pricing for 9 models across 8 providers via `/cost`
21
+ - **Plan mode** - Bookmark messages without sending to LLM (`/plan`)
22
+ - **Hotkey navigation** - Ctrl+D (dashboard), Ctrl+K (palette), Ctrl+S (sessions), Escape (back)
23
+ - **Tab completion** - Type `/` and Tab to auto-complete slash commands
24
+ - **Progress panel** - Visual progress bars for long operations (extension scanning, gem ops)
18
25
  - **Second-run flow** - Skips onboarding, re-scans environment, drops into chat
19
26
 
20
27
  ## Installation
@@ -35,6 +42,7 @@ brew install legion
35
42
 
36
43
  ```bash
37
44
  legion-tty
45
+ legion-tty --skip-rain # skip digital rain animation
38
46
  ```
39
47
 
40
48
  ### Via LegionIO CLI
@@ -56,7 +64,7 @@ legion chat prompt "explain async cognition"
56
64
 
57
65
  | Command | Description |
58
66
  |---------|-------------|
59
- | `/help` | Show all commands |
67
+ | `/help` | Show all commands and hotkeys |
60
68
  | `/quit` | Exit (auto-saves session) |
61
69
  | `/clear` | Clear message history |
62
70
  | `/model <name>` | Switch LLM model at runtime |
@@ -69,6 +77,23 @@ legion chat prompt "explain async cognition"
69
77
  | `/save [name]` | Save current session |
70
78
  | `/load <name>` | Load a saved session |
71
79
  | `/sessions` | List all saved sessions |
80
+ | `/system <prompt>` | Set or override system prompt |
81
+ | `/delete <session>` | Delete a saved session |
82
+ | `/plan` | Toggle read-only bookmark mode |
83
+ | `/palette` | Open command palette (fuzzy search) |
84
+ | `/extensions` | Browse installed LEX extensions |
85
+ | `/config` | View and edit settings files |
86
+
87
+ ## Hotkeys
88
+
89
+ | Key | Action |
90
+ |-----|--------|
91
+ | Ctrl+D | Toggle dashboard |
92
+ | Ctrl+K | Open command palette |
93
+ | Ctrl+S | Open session picker |
94
+ | Ctrl+L | Refresh screen |
95
+ | Escape | Go back / dismiss overlay |
96
+ | Tab | Auto-complete slash commands |
72
97
 
73
98
  ## Architecture
74
99
 
@@ -84,16 +109,23 @@ legion-tty
84
109
  Onboarding # First-run wizard (rain -> intro -> wizard -> reveal)
85
110
  Chat # AI chat REPL with streaming + slash commands
86
111
  Dashboard # Operational status panels
112
+ Extensions # LEX gem browser by category
113
+ Config # Settings file viewer/editor
87
114
 
88
115
  Components/
89
116
  DigitalRain # Matrix-style falling characters
90
- InputBar # Prompt line with thinking indicator
91
- MessageStream # Scrollable message history
92
- StatusBar # Model, tokens, cost, session display
117
+ InputBar # Prompt line with tab completion + thinking indicator
118
+ MessageStream # Scrollable message history with markdown rendering
119
+ StatusBar # Model, plan mode, thinking, tokens, cost, session
93
120
  ToolPanel # Expandable tool use panels
94
121
  MarkdownView # TTY::Markdown rendering
95
122
  WizardPrompt # TTY::Prompt wrappers
96
- TokenTracker # Token counting and cost estimation
123
+ TokenTracker # Per-model token counting and cost estimation
124
+ CommandPalette # Fuzzy-search command/screen/session overlay
125
+ ModelPicker # LLM provider/model selection
126
+ SessionPicker # Session list and selection
127
+ TableView # TTY::Table wrapper
128
+ ProgressPanel # TTY::ProgressBar wrapper
97
129
 
98
130
  Background/
99
131
  Scanner # Service port probing, git repo discovery, shell history
@@ -101,20 +133,11 @@ legion-tty
101
133
  KerberosProbe # klist + LDAP profile resolution
102
134
  ```
103
135
 
104
- ## Comparison
105
-
106
- | Feature | legion-tty | Claude Code | Codex CLI |
107
- |---------|-----------|-------------|-----------|
108
- | Onboarding wizard | Yes (identity detection) | No (API key only) | No |
109
- | Streaming chat | Yes | Yes | Yes |
110
- | Tool use panels | Yes | Yes | Yes |
111
- | Dashboard | Yes (services, extensions) | No | No |
112
- | Session persistence | Yes | Yes (conversations) | No |
113
- | Environment scanning | Yes (services, repos, history) | Yes (git context) | Yes (git context) |
114
- | Extension ecosystem | Yes (LEX gems) | Yes (MCP servers) | Yes (tools) |
115
- | Identity probing | Yes (Kerberos, GitHub, LDAP) | No | No |
116
- | Token/cost tracking | Yes | Yes | Yes |
117
- | Hotkey navigation | Yes | Yes | No |
136
+ ## LLM Integration
137
+
138
+ legion-tty uses **Legion::LLM exclusively** for all LLM operations. No direct RubyLLM calls. If Legion::LLM is not available or not started, the chat shell runs without LLM (commands still work, messages show "LLM not configured").
139
+
140
+ The boot sequence mirrors `Legion::Service`: logging -> settings -> crypt -> resolve_secrets -> LLM merge -> start.
118
141
 
119
142
  ## Configuration
120
143
 
@@ -131,8 +154,8 @@ Boot logs go to `~/.legionio/logs/tty-boot.log`.
131
154
 
132
155
  ```bash
133
156
  bundle install
134
- bundle exec rspec
135
- bundle exec rubocop
157
+ bundle exec rspec # 598 examples, 0 failures
158
+ bundle exec rubocop # 68 files, 0 offenses
136
159
  ```
137
160
 
138
161
  ## License
@@ -6,8 +6,11 @@ module Legion
6
6
  module TTY
7
7
  module Components
8
8
  class InputBar
9
- def initialize(name: 'User', reader: nil)
9
+ attr_reader :completions
10
+
11
+ def initialize(name: 'User', reader: nil, completions: [])
10
12
  @name = name
13
+ @completions = completions
11
14
  @reader = reader || build_default_reader
12
15
  @thinking = false
13
16
  end
@@ -32,14 +35,47 @@ module Legion
32
35
  @thinking
33
36
  end
34
37
 
38
+ def complete(partial)
39
+ return [] if partial.nil? || partial.empty?
40
+
41
+ @completions.select { |c| c.start_with?(partial) }.sort
42
+ end
43
+
35
44
  private
36
45
 
37
46
  def build_default_reader
38
47
  require 'tty-reader'
39
- ::TTY::Reader.new
48
+ reader = ::TTY::Reader.new
49
+ register_tab_completion(reader)
50
+ reader
40
51
  rescue LoadError
41
52
  nil
42
53
  end
54
+
55
+ def register_tab_completion(reader)
56
+ return if @completions.empty?
57
+
58
+ @tab_matches = []
59
+ @tab_index = 0
60
+
61
+ reader.on(:keypress) do |event|
62
+ handle_tab(event) if event.value == "\t"
63
+ end
64
+ end
65
+
66
+ def handle_tab(event)
67
+ line = event.line.text.to_s
68
+ matches = complete(line)
69
+ return if matches.empty?
70
+
71
+ if matches.size == 1
72
+ event.line.replace(matches.first)
73
+ else
74
+ @tab_matches = matches unless @tab_matches == matches
75
+ event.line.replace(@tab_matches[@tab_index % @tab_matches.size])
76
+ @tab_index += 1
77
+ end
78
+ end
43
79
  end
44
80
  end
45
81
  end
@@ -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
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../theme'
4
+
5
+ module Legion
6
+ module TTY
7
+ module Components
8
+ class ProgressPanel
9
+ attr_reader :title, :total, :current
10
+
11
+ def initialize(title:, total:, output: $stdout)
12
+ @title = title
13
+ @total = total
14
+ @current = 0
15
+ @output = output
16
+ @bar = build_bar
17
+ end
18
+
19
+ def advance(step = 1)
20
+ @current = [@current + step, @total].min
21
+ @bar&.advance(step)
22
+ end
23
+
24
+ def finish
25
+ remaining = @total - @current
26
+ @bar&.advance(remaining) if remaining.positive?
27
+ @current = @total
28
+ end
29
+
30
+ def finished?
31
+ @current >= @total
32
+ end
33
+
34
+ def percent
35
+ return 0 if @total.zero?
36
+
37
+ ((@current.to_f / @total) * 100).round(1)
38
+ end
39
+
40
+ def render(width: 80)
41
+ pct = percent
42
+ label = Theme.c(:accent, @title)
43
+ bar = build_render_bar(width, pct)
44
+ "#{label} [#{bar}] #{Theme.c(:secondary, "#{pct}%")}"
45
+ end
46
+
47
+ private
48
+
49
+ def build_render_bar(width, pct)
50
+ bar_width = [width - @title.length - 12, 10].max
51
+ filled = (bar_width * pct / 100.0).round
52
+ empty = bar_width - filled
53
+ Theme.c(:primary, '#' * filled) + Theme.c(:muted, '-' * empty)
54
+ end
55
+
56
+ def build_bar
57
+ require 'tty-progressbar'
58
+ ::TTY::ProgressBar.new(
59
+ "#{@title} [:bar] :percent",
60
+ total: @total,
61
+ output: @output,
62
+ width: 40
63
+ )
64
+ rescue LoadError
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -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.']
@@ -282,16 +312,50 @@ module Legion
282
312
  return
283
313
  end
284
314
 
285
- if @llm_chat.respond_to?(:with_model)
315
+ apply_model_switch(name)
316
+ rescue StandardError => e
317
+ @message_stream.add_message(role: :system, content: "Failed to switch model: #{e.message}")
318
+ end
319
+
320
+ def apply_model_switch(name)
321
+ new_chat = try_provider_switch(name)
322
+ if new_chat
323
+ @llm_chat = new_chat
324
+ @status_bar.update(model: name)
325
+ @token_tracker.update_model(name)
326
+ @message_stream.add_message(role: :system, content: "Switched to provider: #{name}")
327
+ elsif @llm_chat.respond_to?(:with_model)
286
328
  @llm_chat.with_model(name)
287
329
  @status_bar.update(model: name)
330
+ @token_tracker.update_model(name)
288
331
  @message_stream.add_message(role: :system, content: "Model switched to: #{name}")
289
332
  else
290
333
  @status_bar.update(model: name)
291
334
  @message_stream.add_message(role: :system, content: "Model set to: #{name}")
292
335
  end
293
- rescue StandardError => e
294
- @message_stream.add_message(role: :system, content: "Failed to switch model: #{e.message}")
336
+ end
337
+
338
+ def try_provider_switch(name)
339
+ return nil unless defined?(Legion::LLM)
340
+
341
+ providers = Legion::LLM.settings[:providers]
342
+ return nil unless providers.is_a?(Hash) && providers.key?(name.to_sym)
343
+
344
+ Legion::LLM.chat(provider: name)
345
+ rescue StandardError
346
+ nil
347
+ end
348
+
349
+ def open_model_picker
350
+ require_relative '../components/model_picker'
351
+ picker = Components::ModelPicker.new(
352
+ current_provider: safe_config[:provider],
353
+ current_model: @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : nil
354
+ )
355
+ selection = picker.select_with_prompt(output: @output)
356
+ return unless selection
357
+
358
+ switch_model(selection[:provider])
295
359
  end
296
360
 
297
361
  def show_current_model
@@ -551,7 +615,7 @@ module Legion
551
615
  def build_default_input_bar
552
616
  cfg = safe_config
553
617
  name = cfg[:name] || 'User'
554
- Components::InputBar.new(name: name)
618
+ Components::InputBar.new(name: name, completions: SLASH_COMMANDS)
555
619
  end
556
620
 
557
621
  def terminal_width
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.0'
5
+ VERSION = '0.4.2'
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.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -207,6 +207,7 @@ files:
207
207
  - lib/legion/tty/components/markdown_view.rb
208
208
  - lib/legion/tty/components/message_stream.rb
209
209
  - lib/legion/tty/components/model_picker.rb
210
+ - lib/legion/tty/components/progress_panel.rb
210
211
  - lib/legion/tty/components/session_picker.rb
211
212
  - lib/legion/tty/components/status_bar.rb
212
213
  - lib/legion/tty/components/table_view.rb