legion-tty 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df932fba0f861bcb1473808566486e7ebc769ab735d53b4464f2d1c77a78f7a0
4
- data.tar.gz: a13036205e7d36cee04d0dbdea574041b358f62cf7d533b87a564a86a39a4ddd
3
+ metadata.gz: 4e5ec362f89174ce0ccc182a50343da7a6ce2846f3c01ad51ad14afe8a7f80f8
4
+ data.tar.gz: 763d695f1c7a2e8c13e3a518b36d2e90ae25713954a2127f5ed71869eaa31908
5
5
  SHA512:
6
- metadata.gz: f9d27f13872938348b97a31cb8949cb93d158c3b288dc9972ad48de25b9e47abdd298a5fa0688c3b9497648ce864280308d1ec82653502192a57ea00535ed80a
7
- data.tar.gz: aad5dfe5fa9b5960acde7a58d086ac87ccd49c2503d6bed0e3a0220d2a5caae4d121b38e2d53f78cfa940c43dc665d252c5ffc5ddbdb32ce4bd5f791b8837149
6
+ metadata.gz: 4cde6c72b3a66dd7b5daffce9c3407c0d82275a41bb1ed564579ccd013d31bd2f42f4e0366426446adf19f382d5f4911de6cc04622c86fd2e7090663ba1b7177
7
+ data.tar.gz: e4350ae0a6145f62758118c141f809de74d90ba5db873cbae1bd499c0c6b958325fa2196f91edd3df7aa3ddeb933f0fc818e3b7a372fab9ff8feb157f68ac680
data/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.1] - 2026-03-19
4
+
5
+ ### Added
6
+ - Progress panel component wrapping tty-progressbar for long operations
7
+ - Tab completion for slash commands (type `/` + Tab to cycle through matches)
8
+ - InputBar now accepts `completions:` parameter for configurable auto-complete
9
+
10
+ ### Changed
11
+ - README.md updated to reflect 0.4.x features, hotkeys, architecture
12
+ - CLAUDE.md updated to reflect current version, components, LLM integration notes
13
+
14
+ ## [0.4.0] - 2026-03-19
15
+
16
+ ### Fixed
17
+ - /model crash: empty or invalid model name no longer crashes the shell
18
+ - Removed all RubyLLM direct usage -- all LLM access goes through Legion::LLM exclusively
19
+ - Kerberos username key mismatch in vault auth pre-fill (was :samaccountname, now :username)
20
+ - Overlay rendering: help overlay now actually displays on screen
21
+ - Thinking indicator: status bar shows "thinking..." during LLM requests
22
+ - --skip-rain CLI option now forwarded to onboarding
23
+
24
+ ### Added
25
+ - Per-model token pricing (Opus/Sonnet/Haiku, GPT-4o/4o-mini, Gemini Flash/Pro)
26
+ - Markdown rendering for assistant messages via TTY::Markdown
27
+ - /system command: set or override system prompt at runtime
28
+ - /delete command: delete saved sessions
29
+ - /plan command: toggle read-only bookmark mode with [PLAN] status indicator
30
+ - /palette command: fuzzy-search command palette for all commands, screens, sessions
31
+ - /extensions command: browse installed LEX gems by category
32
+ - /config command: view and edit ~/.legionio/settings/*.json files
33
+ - Command palette component with fuzzy search
34
+ - Model picker component for switching LLM providers
35
+ - Session picker component for quick session switching
36
+ - Table view component wrapping tty-table
37
+ - Extensions browser screen (grouped by core/agentic/service/AI/other)
38
+ - Config viewer/editor screen with vault:// masking
39
+ - Hotkeys: Ctrl+K (palette), Ctrl+S (sessions), Escape (back)
40
+ - Plan mode: bookmark messages without sending to LLM
41
+
42
+ ### Changed
43
+ - Token tracker now uses per-model rates with provider fallback
44
+ - Hotkey ? removed (conflicted with typing questions)
45
+ - Help text updated with all new commands and hotkey reference
46
+
47
+ ### Removed
48
+ - RubyLLM direct fallback in app.rb (PROVIDER_MAP, try_credentials_llm, configure_llm_provider)
49
+
3
50
  ## [0.3.1] - 2026-03-19
4
51
 
5
52
  ### 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
@@ -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
@@ -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
@@ -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,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
@@ -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