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 +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +50 -27
- data/lib/legion/tty/app.rb +17 -74
- data/lib/legion/tty/components/command_palette.rb +44 -0
- data/lib/legion/tty/components/input_bar.rb +38 -2
- 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/progress_panel.rb +70 -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 +169 -23
- 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 +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e5ec362f89174ce0ccc182a50343da7a6ce2846f3c01ad51ad14afe8a7f80f8
|
|
4
|
+
data.tar.gz: 763d695f1c7a2e8c13e3a518b36d2e90ae25713954a2127f5ed71869eaa31908
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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,
|
|
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
|
-
- **
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
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
|
|
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 #
|
|
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
|
-
##
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
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
|
|
@@ -6,8 +6,11 @@ module Legion
|
|
|
6
6
|
module TTY
|
|
7
7
|
module Components
|
|
8
8
|
class InputBar
|
|
9
|
-
|
|
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
|
-
|
|
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
|