legion-tty 0.4.35 → 0.4.36
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 +14 -0
- data/README.md +1 -1
- data/lib/legion/tty/app.rb +325 -36
- data/lib/legion/tty/components/input_bar.rb +159 -35
- data/lib/legion/tty/hotkeys.rb +1 -4
- data/lib/legion/tty/screens/base.rb +4 -0
- data/lib/legion/tty/screens/chat/model_commands.rb +4 -1
- data/lib/legion/tty/screens/chat/ui_commands.rb +5 -2
- data/lib/legion/tty/screens/chat.rb +25 -97
- data/lib/legion/tty/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e331de1a9cb6b1a10576797303b5f1c691b639a74212c5cca6dc5d5fb161f7c
|
|
4
|
+
data.tar.gz: 01b5bfdefa8e991d01db3da00c1f0d7f51ff029d08c89e6a008a1abb607c8543
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f94def1314643beeda49eb00c7a9f70a4cacee8843c56551bf24b9edfa77dfcbf17fbca6f9c5f708574a14bdf3316e382a04d41e542e8d841eba21324cc0d248
|
|
7
|
+
data.tar.gz: fa6d3c970d1e33492185f3cb89e3dbc9f349c8f36431558f86f13bd1a48c96656a6b0adecee84bd2dc3713ee11c25a09282bdacc3cc7ffff6a3886e785b1839e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.36] - 2026-03-26
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Rebuild rendering engine with raw-mode event loop replacing synchronous readline blocking
|
|
7
|
+
- App#run_loop uses character-by-character input via IO.select + $stdin.raw with manual escape sequence parsing
|
|
8
|
+
- Differential rendering compares frame buffers line-by-line to eliminate screen flicker
|
|
9
|
+
- Overlay compositing renders TTY::Box frames centered over screen content, persists until Escape
|
|
10
|
+
- Key normalization maps raw escape sequences and control chars to symbols for dispatch
|
|
11
|
+
- InputBar rewritten with handle_key line buffer for non-blocking single-key processing
|
|
12
|
+
- Chat screen uses render/handle_input contract with page_up/page_down scroll
|
|
13
|
+
- Streaming flag enables 50ms refresh during LLM streaming for responsive re-rendering
|
|
14
|
+
- Hotkeys now use normalized symbol keys (:ctrl_d, :ctrl_l, :ctrl_k, :ctrl_s)
|
|
15
|
+
- Screens (Dashboard, Extensions, Config) driven by App event loop instead of standalone blocking loops
|
|
16
|
+
|
|
3
17
|
## [0.4.35] - 2026-03-25
|
|
4
18
|
|
|
5
19
|
### Changed
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Rich terminal UI for the LegionIO async cognition engine.
|
|
4
4
|
|
|
5
|
-
**Version**: 0.4.
|
|
5
|
+
**Version**: 0.4.35
|
|
6
6
|
|
|
7
7
|
Think Claude Code meets Codex CLI, but for LegionIO: onboarding wizard with identity detection, streaming AI chat shell with 115 slash commands, operational dashboard, extensions browser, config editor, and session persistence - all rendered with the [tty-ruby](https://ttytoolkit.org/) gem ecosystem.
|
|
8
8
|
|
data/lib/legion/tty/app.rb
CHANGED
|
@@ -13,7 +13,21 @@ module Legion
|
|
|
13
13
|
class App
|
|
14
14
|
CONFIG_DIR = File.expand_path('~/.legionio/settings')
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# Key normalization: raw escape sequences and control chars to symbols
|
|
17
|
+
KEY_MAP = {
|
|
18
|
+
"\e[A" => :up, "\e[B" => :down, "\e[C" => :right, "\e[D" => :left,
|
|
19
|
+
"\r" => :enter, "\n" => :enter, "\e" => :escape,
|
|
20
|
+
"\e[5~" => :page_up, "\e[6~" => :page_down,
|
|
21
|
+
"\e[H" => :home, "\eOH" => :home, "\e[F" => :end, "\eOF" => :end,
|
|
22
|
+
"\e[1~" => :home, "\e[4~" => :end,
|
|
23
|
+
"\x7f" => :backspace, "\b" => :backspace, "\t" => :tab,
|
|
24
|
+
"\x03" => :ctrl_c, "\x04" => :ctrl_d,
|
|
25
|
+
"\x01" => :ctrl_a, "\x05" => :ctrl_e,
|
|
26
|
+
"\x0B" => :ctrl_k, "\x0C" => :ctrl_l, "\x13" => :ctrl_s,
|
|
27
|
+
"\x15" => :ctrl_u
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :config, :credentials, :screen_manager, :hotkeys, :llm_chat, :input_bar
|
|
17
31
|
|
|
18
32
|
def self.run(argv = [])
|
|
19
33
|
opts = parse_argv(argv)
|
|
@@ -42,23 +56,59 @@ module Legion
|
|
|
42
56
|
@screen_manager = ScreenManager.new
|
|
43
57
|
@hotkeys = Hotkeys.new
|
|
44
58
|
@llm_chat = nil
|
|
59
|
+
@input_bar = nil
|
|
60
|
+
@running = false
|
|
61
|
+
@prev_frame = []
|
|
62
|
+
@raw_mode = false
|
|
45
63
|
end
|
|
46
64
|
|
|
47
65
|
def start
|
|
48
66
|
setup_hotkeys
|
|
49
|
-
if self.class.first_run?(config_dir: @config_dir)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
run_onboarding if self.class.first_run?(config_dir: @config_dir)
|
|
68
|
+
setup_for_chat
|
|
69
|
+
run_loop
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Public: called by screens (e.g., Chat during LLM streaming) to force a re-render
|
|
73
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
74
|
+
def render_frame
|
|
75
|
+
width = terminal_width
|
|
76
|
+
height = terminal_height
|
|
77
|
+
active = @screen_manager.active_screen
|
|
78
|
+
return unless active
|
|
79
|
+
|
|
80
|
+
has_input = active.respond_to?(:needs_input_bar?) && active.needs_input_bar?
|
|
81
|
+
screen_height = has_input ? height - 1 : height
|
|
82
|
+
|
|
83
|
+
lines = active.render(width, screen_height)
|
|
84
|
+
lines << @input_bar.render_line(width: width) if has_input && @input_bar
|
|
85
|
+
|
|
86
|
+
lines = lines[0, height] if lines.size > height
|
|
87
|
+
lines += Array.new(height - lines.size, '') if lines.size < height
|
|
88
|
+
|
|
89
|
+
lines = composite_overlay(lines, width, height) if @screen_manager.overlay
|
|
90
|
+
|
|
91
|
+
write_differential(lines, width)
|
|
92
|
+
|
|
93
|
+
if has_input && @input_bar
|
|
94
|
+
col = [@input_bar.cursor_column, width - 1].min
|
|
95
|
+
$stdout.print cursor.move_to(col, height - 1)
|
|
53
96
|
end
|
|
97
|
+
|
|
98
|
+
$stdout.flush
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
Legion::Logging.warn("render_frame failed: #{e.message}") if defined?(Legion::Logging)
|
|
54
101
|
end
|
|
102
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
55
103
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
104
|
+
# Temporarily exit raw mode for blocking prompts (TTY::Prompt, etc.)
|
|
105
|
+
def with_cooked_mode(&)
|
|
106
|
+
return yield unless @raw_mode
|
|
107
|
+
|
|
108
|
+
$stdout.print cursor.show
|
|
109
|
+
$stdin.cooked(&)
|
|
110
|
+
$stdout.print cursor.hide
|
|
111
|
+
@prev_frame = []
|
|
62
112
|
end
|
|
63
113
|
|
|
64
114
|
def toggle_dashboard
|
|
@@ -72,23 +122,273 @@ module Legion
|
|
|
72
122
|
end
|
|
73
123
|
end
|
|
74
124
|
|
|
125
|
+
def shutdown
|
|
126
|
+
@running = false
|
|
127
|
+
@screen_manager.teardown_all
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# --- Boot ---
|
|
133
|
+
|
|
134
|
+
def setup_for_chat
|
|
135
|
+
rescan_environment
|
|
136
|
+
setup_llm
|
|
137
|
+
cfg = safe_config
|
|
138
|
+
name = cfg[:name] || 'User'
|
|
139
|
+
@input_bar = Components::InputBar.new(name: name, completions: Screens::Chat::SLASH_COMMANDS)
|
|
140
|
+
chat = Screens::Chat.new(self)
|
|
141
|
+
@screen_manager.push(chat)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# --- Event Loop ---
|
|
145
|
+
|
|
146
|
+
# rubocop:disable Metrics/AbcSize
|
|
147
|
+
def run_loop
|
|
148
|
+
require 'io/console'
|
|
149
|
+
|
|
150
|
+
@running = true
|
|
151
|
+
@raw_mode = true
|
|
152
|
+
$stdout.print cursor.hide
|
|
153
|
+
$stdout.print cursor.clear_screen
|
|
154
|
+
|
|
155
|
+
$stdin.raw do |raw_in|
|
|
156
|
+
while @running
|
|
157
|
+
render_frame
|
|
158
|
+
timeout = needs_refresh? ? 0.05 : nil
|
|
159
|
+
raw_key = read_raw_key(raw_in, timeout: timeout)
|
|
160
|
+
next unless raw_key
|
|
161
|
+
|
|
162
|
+
key = normalize_key(raw_key)
|
|
163
|
+
dispatch_key(key)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
rescue Interrupt
|
|
167
|
+
nil
|
|
168
|
+
ensure
|
|
169
|
+
@raw_mode = false
|
|
170
|
+
$stdout.print cursor.show
|
|
171
|
+
$stdout.print cursor.move_to(0, terminal_height - 1)
|
|
172
|
+
$stdout.puts
|
|
173
|
+
shutdown
|
|
174
|
+
end
|
|
175
|
+
# rubocop:enable Metrics/AbcSize
|
|
176
|
+
|
|
177
|
+
def needs_refresh?
|
|
178
|
+
active = @screen_manager.active_screen
|
|
179
|
+
active.respond_to?(:streaming?) && active.streaming?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def normalize_key(raw)
|
|
183
|
+
KEY_MAP[raw] || raw
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# --- Key Dispatch ---
|
|
187
|
+
|
|
188
|
+
def dispatch_key(key)
|
|
189
|
+
if key == :ctrl_c
|
|
190
|
+
@running = false
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if key == :escape && @screen_manager.overlay
|
|
195
|
+
@screen_manager.dismiss_overlay
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
result = @hotkeys.handle(key)
|
|
200
|
+
if result
|
|
201
|
+
handle_hotkey_result(result)
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
active = @screen_manager.active_screen
|
|
206
|
+
return unless active
|
|
207
|
+
|
|
208
|
+
if active.respond_to?(:needs_input_bar?) && active.needs_input_bar? && @input_bar
|
|
209
|
+
dispatch_to_input_screen(active, key)
|
|
210
|
+
else
|
|
211
|
+
dispatch_to_screen(active, key)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def dispatch_to_input_screen(screen, key)
|
|
216
|
+
result = @input_bar.handle_key(key)
|
|
217
|
+
if result.is_a?(Array) && result[0] == :submit
|
|
218
|
+
screen_result = screen.handle_line(result[1])
|
|
219
|
+
handle_screen_result(screen_result)
|
|
220
|
+
elsif result == :pass
|
|
221
|
+
screen_result = screen.handle_input(key)
|
|
222
|
+
handle_screen_result(screen_result)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def dispatch_to_screen(screen, key)
|
|
227
|
+
result = screen.handle_input(key)
|
|
228
|
+
handle_screen_result(result)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def handle_screen_result(result)
|
|
232
|
+
case result
|
|
233
|
+
when :pop_screen then @screen_manager.pop
|
|
234
|
+
when :quit then @running = false
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def handle_hotkey_result(result)
|
|
239
|
+
case result
|
|
240
|
+
when :command_palette
|
|
241
|
+
active = @screen_manager.active_screen
|
|
242
|
+
active.send(:handle_palette) if active.respond_to?(:handle_palette, true)
|
|
243
|
+
when :session_picker
|
|
244
|
+
active = @screen_manager.active_screen
|
|
245
|
+
active.send(:handle_sessions_picker) if active.respond_to?(:handle_sessions_picker, true)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# --- Raw Key Reading ---
|
|
250
|
+
|
|
251
|
+
def read_raw_key(io, timeout: nil)
|
|
252
|
+
return nil unless io.wait_readable(timeout)
|
|
253
|
+
|
|
254
|
+
c = io.getc
|
|
255
|
+
return nil unless c
|
|
256
|
+
|
|
257
|
+
return c unless c == "\e"
|
|
258
|
+
|
|
259
|
+
read_escape_sequence(io)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def read_escape_sequence(io)
|
|
263
|
+
return "\e" unless io.wait_readable(0.05)
|
|
264
|
+
|
|
265
|
+
c2 = io.getc
|
|
266
|
+
return "\e" unless c2
|
|
267
|
+
|
|
268
|
+
if c2 == '['
|
|
269
|
+
read_csi_sequence(io)
|
|
270
|
+
elsif c2 == 'O'
|
|
271
|
+
c3 = io.wait_readable(0.05) ? io.getc : nil
|
|
272
|
+
c3 ? "\eO#{c3}" : "\eO"
|
|
273
|
+
else
|
|
274
|
+
"\e#{c2}"
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def read_csi_sequence(io)
|
|
279
|
+
seq = +"\e["
|
|
280
|
+
loop do
|
|
281
|
+
break unless io.wait_readable(0.05)
|
|
282
|
+
|
|
283
|
+
c = io.getc
|
|
284
|
+
break unless c
|
|
285
|
+
|
|
286
|
+
seq << c
|
|
287
|
+
break if c.ord.between?(0x40, 0x7E)
|
|
288
|
+
end
|
|
289
|
+
seq
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# --- Rendering ---
|
|
293
|
+
|
|
294
|
+
# rubocop:disable Metrics/AbcSize
|
|
295
|
+
def composite_overlay(lines, width, height)
|
|
296
|
+
require 'tty-box'
|
|
297
|
+
text = @screen_manager.overlay.to_s
|
|
298
|
+
box_width = (width - 4).clamp(40, width)
|
|
299
|
+
box = ::TTY::Box.frame(
|
|
300
|
+
width: box_width,
|
|
301
|
+
padding: 1,
|
|
302
|
+
title: { top_left: ' Help ' },
|
|
303
|
+
border: :round
|
|
304
|
+
) { text }
|
|
305
|
+
|
|
306
|
+
overlay_lines = box.split("\n")
|
|
307
|
+
start_row = [(height - overlay_lines.size) / 2, 0].max
|
|
308
|
+
left_pad = [(width - box_width) / 2, 0].max
|
|
309
|
+
|
|
310
|
+
result = lines.dup
|
|
311
|
+
overlay_lines.each_with_index do |ol, i|
|
|
312
|
+
row = start_row + i
|
|
313
|
+
next if row >= height
|
|
314
|
+
|
|
315
|
+
result[row] = (' ' * left_pad) + ol
|
|
316
|
+
end
|
|
317
|
+
result
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
Legion::Logging.warn("composite_overlay failed: #{e.message}") if defined?(Legion::Logging)
|
|
320
|
+
lines
|
|
321
|
+
end
|
|
322
|
+
# rubocop:enable Metrics/AbcSize
|
|
323
|
+
|
|
324
|
+
def write_differential(lines, width)
|
|
325
|
+
lines.each_with_index do |line, row|
|
|
326
|
+
next if @prev_frame[row] == line
|
|
327
|
+
|
|
328
|
+
$stdout.print cursor.move_to(0, row)
|
|
329
|
+
$stdout.print line
|
|
330
|
+
plain_len = strip_ansi(line).length
|
|
331
|
+
$stdout.print(' ' * (width - plain_len)) if plain_len < width
|
|
332
|
+
end
|
|
333
|
+
@prev_frame = lines.dup
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def strip_ansi(str)
|
|
337
|
+
str.to_s.gsub(/\e\[[0-9;]*m/, '')
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def cursor
|
|
341
|
+
require 'tty-cursor'
|
|
342
|
+
::TTY::Cursor
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def terminal_width
|
|
346
|
+
require 'tty-screen'
|
|
347
|
+
::TTY::Screen.width
|
|
348
|
+
rescue StandardError
|
|
349
|
+
80
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def terminal_height
|
|
353
|
+
require 'tty-screen'
|
|
354
|
+
::TTY::Screen.height
|
|
355
|
+
rescue StandardError
|
|
356
|
+
24
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# --- Hotkeys ---
|
|
360
|
+
|
|
361
|
+
def setup_hotkeys
|
|
362
|
+
@hotkeys.register(:ctrl_d, 'Toggle dashboard (Ctrl+D)') do
|
|
363
|
+
toggle_dashboard
|
|
364
|
+
:handled
|
|
365
|
+
end
|
|
366
|
+
@hotkeys.register(:ctrl_l, 'Refresh screen (Ctrl+L)') do
|
|
367
|
+
@prev_frame = []
|
|
368
|
+
:handled
|
|
369
|
+
end
|
|
370
|
+
@hotkeys.register(:ctrl_k, 'Command palette (Ctrl+K)') { :command_palette }
|
|
371
|
+
@hotkeys.register(:ctrl_s, 'Session picker (Ctrl+S)') { :session_picker }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# --- Onboarding ---
|
|
375
|
+
|
|
75
376
|
def run_onboarding
|
|
76
377
|
onboarding = Screens::Onboarding.new(self, skip_rain: @skip_rain)
|
|
77
378
|
data = onboarding.activate
|
|
78
379
|
save_config(data)
|
|
79
380
|
@config = load_config
|
|
80
381
|
@credentials = load_credentials
|
|
81
|
-
run_chat
|
|
82
382
|
end
|
|
83
383
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@screen_manager.push(chat)
|
|
89
|
-
chat.run
|
|
384
|
+
def save_config(data)
|
|
385
|
+
FileUtils.mkdir_p(@config_dir)
|
|
386
|
+
save_identity(data)
|
|
387
|
+
save_credentials(data)
|
|
90
388
|
end
|
|
91
389
|
|
|
390
|
+
# --- LLM Setup ---
|
|
391
|
+
|
|
92
392
|
def setup_llm
|
|
93
393
|
boot_legion_subsystems
|
|
94
394
|
@llm_chat = try_settings_llm
|
|
@@ -120,25 +420,10 @@ module Legion
|
|
|
120
420
|
nil
|
|
121
421
|
end
|
|
122
422
|
end
|
|
123
|
-
|
|
124
423
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
125
424
|
|
|
126
|
-
def save_config(data)
|
|
127
|
-
FileUtils.mkdir_p(@config_dir)
|
|
128
|
-
save_identity(data)
|
|
129
|
-
save_credentials(data)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def shutdown
|
|
133
|
-
@screen_manager.teardown_all
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
private
|
|
137
|
-
|
|
138
425
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
139
426
|
def boot_legion_subsystems
|
|
140
|
-
# Follow the same init order as Legion::Service:
|
|
141
|
-
# 1. logging 2. settings 3. crypt 4. resolve secrets 5. LLM merge
|
|
142
427
|
require 'legion/logging'
|
|
143
428
|
Legion::Logging.setup(log_level: 'error', level: 'error', trace: false)
|
|
144
429
|
|
|
@@ -197,6 +482,13 @@ module Legion
|
|
|
197
482
|
nil
|
|
198
483
|
end
|
|
199
484
|
|
|
485
|
+
# --- Config & Credentials ---
|
|
486
|
+
|
|
487
|
+
def safe_config
|
|
488
|
+
cfg = @config
|
|
489
|
+
cfg.is_a?(Hash) ? cfg : {}
|
|
490
|
+
end
|
|
491
|
+
|
|
200
492
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
201
493
|
def save_identity(data)
|
|
202
494
|
identity = {
|
|
@@ -205,7 +497,6 @@ module Legion
|
|
|
205
497
|
created_at: Time.now.iso8601
|
|
206
498
|
}
|
|
207
499
|
|
|
208
|
-
# Kerberos identity
|
|
209
500
|
if data[:identity].is_a?(Hash)
|
|
210
501
|
id = data[:identity]
|
|
211
502
|
identity[:kerberos] = {
|
|
@@ -226,7 +517,6 @@ module Legion
|
|
|
226
517
|
}.compact
|
|
227
518
|
end
|
|
228
519
|
|
|
229
|
-
# GitHub profile
|
|
230
520
|
if data[:github].is_a?(Hash) && data[:github][:username]
|
|
231
521
|
gh = data[:github]
|
|
232
522
|
identity[:github] = {
|
|
@@ -237,7 +527,6 @@ module Legion
|
|
|
237
527
|
}.compact
|
|
238
528
|
end
|
|
239
529
|
|
|
240
|
-
# Environment scan
|
|
241
530
|
if data[:scan].is_a?(Hash)
|
|
242
531
|
scan = data[:scan]
|
|
243
532
|
services = scan[:services]&.values&.select { |s| s[:running] }&.map { |s| s[:name] } || []
|
|
@@ -5,13 +5,20 @@ require_relative '../theme'
|
|
|
5
5
|
module Legion
|
|
6
6
|
module TTY
|
|
7
7
|
module Components
|
|
8
|
-
class InputBar
|
|
9
|
-
attr_reader :completions
|
|
8
|
+
class InputBar # rubocop:disable Metrics/ClassLength
|
|
9
|
+
attr_reader :completions, :buffer
|
|
10
10
|
|
|
11
11
|
def initialize(name: 'User', reader: nil, completions: [])
|
|
12
12
|
@name = name
|
|
13
13
|
@completions = completions
|
|
14
|
-
@
|
|
14
|
+
@buffer = +''
|
|
15
|
+
@cursor_pos = 0
|
|
16
|
+
@history_entries = []
|
|
17
|
+
@history_index = nil
|
|
18
|
+
@saved_buffer = nil
|
|
19
|
+
@tab_matches = []
|
|
20
|
+
@tab_index = 0
|
|
21
|
+
@legacy_reader = reader
|
|
15
22
|
@thinking = false
|
|
16
23
|
end
|
|
17
24
|
|
|
@@ -19,20 +26,80 @@ module Legion
|
|
|
19
26
|
"#{Theme.c(:accent, @name)} #{Theme.c(:primary, '>')} "
|
|
20
27
|
end
|
|
21
28
|
|
|
22
|
-
def
|
|
23
|
-
@
|
|
29
|
+
def prompt_plain_length
|
|
30
|
+
@name.length + 3
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
34
|
+
def handle_key(key)
|
|
35
|
+
case key
|
|
36
|
+
when :enter
|
|
37
|
+
submit_line
|
|
38
|
+
when :backspace
|
|
39
|
+
handle_backspace
|
|
40
|
+
when :tab
|
|
41
|
+
handle_tab_key
|
|
42
|
+
when :up
|
|
43
|
+
history_prev
|
|
44
|
+
when :down
|
|
45
|
+
history_next
|
|
46
|
+
when :right
|
|
47
|
+
@cursor_pos = [@cursor_pos + 1, @buffer.length].min
|
|
48
|
+
:handled
|
|
49
|
+
when :left
|
|
50
|
+
@cursor_pos = [@cursor_pos - 1, 0].max
|
|
51
|
+
:handled
|
|
52
|
+
when :home, :ctrl_a
|
|
53
|
+
@cursor_pos = 0
|
|
54
|
+
:handled
|
|
55
|
+
when :end, :ctrl_e
|
|
56
|
+
@cursor_pos = @buffer.length
|
|
57
|
+
:handled
|
|
58
|
+
when :ctrl_u
|
|
59
|
+
@buffer = +''
|
|
60
|
+
@cursor_pos = 0
|
|
61
|
+
:handled
|
|
62
|
+
when String
|
|
63
|
+
insert_char(key)
|
|
64
|
+
else
|
|
65
|
+
:pass
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
69
|
+
|
|
70
|
+
def render_line(width:)
|
|
71
|
+
avail = [width - prompt_plain_length, 1].max
|
|
72
|
+
display = if @buffer.length > avail
|
|
73
|
+
@buffer[(@buffer.length - avail)..]
|
|
74
|
+
else
|
|
75
|
+
@buffer
|
|
76
|
+
end
|
|
77
|
+
"#{prompt_string}#{display}"
|
|
28
78
|
end
|
|
29
79
|
|
|
30
|
-
def
|
|
31
|
-
|
|
80
|
+
def cursor_column
|
|
81
|
+
prompt_plain_length + @cursor_pos
|
|
32
82
|
end
|
|
33
83
|
|
|
34
|
-
def
|
|
35
|
-
@
|
|
84
|
+
def current_line
|
|
85
|
+
@buffer.dup
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def clear_buffer
|
|
89
|
+
@buffer = +''
|
|
90
|
+
@cursor_pos = 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def history
|
|
94
|
+
@history_entries.dup
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Backward-compatible blocking read for onboarding/tests
|
|
98
|
+
def read_line
|
|
99
|
+
reader = @legacy_reader || build_legacy_reader
|
|
100
|
+
reader&.read_line(prompt_string)
|
|
101
|
+
rescue Interrupt
|
|
102
|
+
nil
|
|
36
103
|
end
|
|
37
104
|
|
|
38
105
|
def complete(partial)
|
|
@@ -41,47 +108,104 @@ module Legion
|
|
|
41
108
|
@completions.select { |c| c.start_with?(partial) }.sort
|
|
42
109
|
end
|
|
43
110
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@reader.history.to_a
|
|
111
|
+
def show_thinking
|
|
112
|
+
@thinking = true
|
|
48
113
|
end
|
|
49
114
|
|
|
50
|
-
|
|
115
|
+
def clear_thinking
|
|
116
|
+
@thinking = false
|
|
117
|
+
end
|
|
51
118
|
|
|
52
|
-
def
|
|
53
|
-
|
|
54
|
-
reader = ::TTY::Reader.new(history_cycle: true)
|
|
55
|
-
register_tab_completion(reader)
|
|
56
|
-
reader
|
|
57
|
-
rescue LoadError => e
|
|
58
|
-
Legion::Logging.debug("tty-reader not available: #{e.message}") if defined?(Legion::Logging)
|
|
59
|
-
nil
|
|
119
|
+
def thinking?
|
|
120
|
+
@thinking
|
|
60
121
|
end
|
|
61
122
|
|
|
62
|
-
|
|
63
|
-
return if @completions.empty?
|
|
123
|
+
private
|
|
64
124
|
|
|
125
|
+
def submit_line
|
|
126
|
+
line = @buffer.dup
|
|
127
|
+
@history_entries << line unless line.strip.empty? || line == @history_entries.last
|
|
128
|
+
@buffer = +''
|
|
129
|
+
@cursor_pos = 0
|
|
130
|
+
@history_index = nil
|
|
131
|
+
@saved_buffer = nil
|
|
65
132
|
@tab_matches = []
|
|
66
133
|
@tab_index = 0
|
|
134
|
+
[:submit, line]
|
|
135
|
+
end
|
|
67
136
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
137
|
+
def handle_backspace
|
|
138
|
+
return :handled if @cursor_pos.zero?
|
|
139
|
+
|
|
140
|
+
@buffer.slice!(@cursor_pos - 1)
|
|
141
|
+
@cursor_pos -= 1
|
|
142
|
+
@tab_matches = []
|
|
143
|
+
:handled
|
|
71
144
|
end
|
|
72
145
|
|
|
73
|
-
def
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
146
|
+
def handle_tab_key
|
|
147
|
+
return :handled if @buffer.empty?
|
|
148
|
+
|
|
149
|
+
matches = complete(@buffer)
|
|
150
|
+
return :handled if matches.empty?
|
|
77
151
|
|
|
78
152
|
if matches.size == 1
|
|
79
|
-
|
|
153
|
+
@buffer = "#{matches.first} "
|
|
154
|
+
@cursor_pos = @buffer.length
|
|
80
155
|
else
|
|
81
156
|
@tab_matches = matches unless @tab_matches == matches
|
|
82
|
-
|
|
157
|
+
@buffer = +@tab_matches[@tab_index % @tab_matches.size].to_s
|
|
158
|
+
@cursor_pos = @buffer.length
|
|
83
159
|
@tab_index += 1
|
|
84
160
|
end
|
|
161
|
+
:handled
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def history_prev
|
|
165
|
+
return :handled if @history_entries.empty?
|
|
166
|
+
|
|
167
|
+
if @history_index.nil?
|
|
168
|
+
@saved_buffer = @buffer.dup
|
|
169
|
+
@history_index = @history_entries.size - 1
|
|
170
|
+
elsif @history_index.positive?
|
|
171
|
+
@history_index -= 1
|
|
172
|
+
else
|
|
173
|
+
return :handled
|
|
174
|
+
end
|
|
175
|
+
@buffer = +@history_entries[@history_index].to_s
|
|
176
|
+
@cursor_pos = @buffer.length
|
|
177
|
+
:handled
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def history_next
|
|
181
|
+
return :handled if @history_index.nil?
|
|
182
|
+
|
|
183
|
+
if @history_index < @history_entries.size - 1
|
|
184
|
+
@history_index += 1
|
|
185
|
+
@buffer = +@history_entries[@history_index].to_s
|
|
186
|
+
else
|
|
187
|
+
@history_index = nil
|
|
188
|
+
@buffer = +(@saved_buffer || '')
|
|
189
|
+
@saved_buffer = nil
|
|
190
|
+
end
|
|
191
|
+
@cursor_pos = @buffer.length
|
|
192
|
+
:handled
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def insert_char(key)
|
|
196
|
+
return :pass unless key.is_a?(String) && key.length == 1 && key.ord >= 32
|
|
197
|
+
|
|
198
|
+
@buffer.insert(@cursor_pos, key)
|
|
199
|
+
@cursor_pos += 1
|
|
200
|
+
@tab_matches = []
|
|
201
|
+
:handled
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def build_legacy_reader
|
|
205
|
+
require 'tty-reader'
|
|
206
|
+
::TTY::Reader.new(history_cycle: true)
|
|
207
|
+
rescue LoadError
|
|
208
|
+
nil
|
|
85
209
|
end
|
|
86
210
|
end
|
|
87
211
|
end
|
data/lib/legion/tty/hotkeys.rb
CHANGED
|
@@ -11,15 +11,12 @@ module Legion
|
|
|
11
11
|
@bindings[key] = { description: description, action: block }
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
# rubocop:disable Naming/PredicateMethod
|
|
15
14
|
def handle(key)
|
|
16
15
|
binding_entry = @bindings[key]
|
|
17
|
-
return
|
|
16
|
+
return nil unless binding_entry
|
|
18
17
|
|
|
19
18
|
binding_entry[:action].call
|
|
20
|
-
true
|
|
21
19
|
end
|
|
22
|
-
# rubocop:enable Naming/PredicateMethod
|
|
23
20
|
|
|
24
21
|
def list
|
|
25
22
|
@bindings.map { |key, b| { key: key, description: b[:description] } }
|
|
@@ -65,7 +65,10 @@ module Legion
|
|
|
65
65
|
current_provider: safe_config[:provider],
|
|
66
66
|
current_model: @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : nil
|
|
67
67
|
)
|
|
68
|
-
selection =
|
|
68
|
+
selection = nil
|
|
69
|
+
@app.with_cooked_mode do
|
|
70
|
+
selection = picker.select_with_prompt(output: @output)
|
|
71
|
+
end
|
|
69
72
|
return unless selection
|
|
70
73
|
|
|
71
74
|
switch_model(selection[:provider])
|
|
@@ -122,7 +122,10 @@ module Legion
|
|
|
122
122
|
def handle_palette
|
|
123
123
|
require_relative '../components/command_palette'
|
|
124
124
|
palette = Components::CommandPalette.new(session_store: @session_store)
|
|
125
|
-
selection =
|
|
125
|
+
selection = nil
|
|
126
|
+
@app.with_cooked_mode do
|
|
127
|
+
selection = palette.select_with_prompt(output: @output)
|
|
128
|
+
end
|
|
126
129
|
return :handled unless selection
|
|
127
130
|
|
|
128
131
|
if selection.start_with?('/')
|
|
@@ -176,7 +179,7 @@ module Legion
|
|
|
176
179
|
end
|
|
177
180
|
|
|
178
181
|
def handle_history
|
|
179
|
-
entries = @input_bar.history
|
|
182
|
+
entries = @app.respond_to?(:input_bar) && @app.input_bar ? @app.input_bar.history : []
|
|
180
183
|
if entries.empty?
|
|
181
184
|
@message_stream.add_message(role: :system, content: 'No input history.')
|
|
182
185
|
else
|
|
@@ -68,13 +68,11 @@ module Legion
|
|
|
68
68
|
attr_reader :message_stream, :status_bar
|
|
69
69
|
|
|
70
70
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
71
|
-
def initialize(app, output: $stdout, input_bar: nil)
|
|
71
|
+
def initialize(app, output: $stdout, input_bar: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
72
72
|
super(app)
|
|
73
73
|
@output = output
|
|
74
74
|
@message_stream = Components::MessageStream.new
|
|
75
75
|
@status_bar = Components::StatusBar.new
|
|
76
|
-
@running = false
|
|
77
|
-
@input_bar = input_bar || build_default_input_bar
|
|
78
76
|
@llm_chat = app.respond_to?(:llm_chat) ? app.llm_chat : nil
|
|
79
77
|
@token_tracker = Components::TokenTracker.new(provider: detect_provider)
|
|
80
78
|
@session_store = SessionStore.new
|
|
@@ -105,12 +103,12 @@ module Legion
|
|
|
105
103
|
@timer_thread = nil
|
|
106
104
|
@message_prefix = nil
|
|
107
105
|
@message_suffix = nil
|
|
106
|
+
@streaming = false
|
|
108
107
|
end
|
|
109
108
|
|
|
110
109
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
111
110
|
|
|
112
111
|
def activate
|
|
113
|
-
@running = true
|
|
114
112
|
cfg = safe_config
|
|
115
113
|
@status_bar.update(model: cfg[:provider], session: 'default')
|
|
116
114
|
setup_system_prompt
|
|
@@ -121,34 +119,25 @@ module Legion
|
|
|
121
119
|
@status_bar.update(message_count: @message_stream.messages.size)
|
|
122
120
|
end
|
|
123
121
|
|
|
124
|
-
def
|
|
125
|
-
|
|
122
|
+
def needs_input_bar?
|
|
123
|
+
true
|
|
126
124
|
end
|
|
127
125
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
while @running
|
|
132
|
-
render_screen
|
|
133
|
-
input = read_input
|
|
134
|
-
break if input.nil?
|
|
135
|
-
|
|
136
|
-
if @app.respond_to?(:screen_manager) && @app.screen_manager.overlay
|
|
137
|
-
@app.screen_manager.dismiss_overlay
|
|
138
|
-
next
|
|
139
|
-
end
|
|
126
|
+
def streaming?
|
|
127
|
+
@streaming
|
|
128
|
+
end
|
|
140
129
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
end
|
|
130
|
+
def handle_line(line)
|
|
131
|
+
return :handled if line.strip.empty?
|
|
132
|
+
|
|
133
|
+
result = handle_slash_command(line)
|
|
134
|
+
if result == :quit
|
|
135
|
+
auto_save_session
|
|
136
|
+
return :quit
|
|
149
137
|
end
|
|
138
|
+
handle_user_message(line) if result.nil?
|
|
139
|
+
:handled
|
|
150
140
|
end
|
|
151
|
-
# rubocop:enable Metrics/AbcSize
|
|
152
141
|
|
|
153
142
|
def handle_slash_command(input)
|
|
154
143
|
return nil unless input.start_with?('/')
|
|
@@ -179,7 +168,6 @@ module Legion
|
|
|
179
168
|
end
|
|
180
169
|
@status_bar.update(message_count: @message_stream.messages.size)
|
|
181
170
|
check_autosave
|
|
182
|
-
render_screen
|
|
183
171
|
end
|
|
184
172
|
|
|
185
173
|
def send_to_llm(message)
|
|
@@ -207,11 +195,11 @@ module Legion
|
|
|
207
195
|
|
|
208
196
|
def handle_input(key)
|
|
209
197
|
case key
|
|
210
|
-
when :
|
|
211
|
-
@message_stream.scroll_up
|
|
198
|
+
when :page_up
|
|
199
|
+
@message_stream.scroll_up(10)
|
|
212
200
|
:handled
|
|
213
|
-
when :
|
|
214
|
-
@message_stream.scroll_down
|
|
201
|
+
when :page_down
|
|
202
|
+
@message_stream.scroll_down(10)
|
|
215
203
|
:handled
|
|
216
204
|
else
|
|
217
205
|
:pass
|
|
@@ -279,7 +267,8 @@ module Legion
|
|
|
279
267
|
return unless @llm_chat
|
|
280
268
|
|
|
281
269
|
@status_bar.update(thinking: true)
|
|
282
|
-
|
|
270
|
+
@streaming = true
|
|
271
|
+
@app.render_frame if @app.respond_to?(:render_frame)
|
|
283
272
|
start_time = Time.now
|
|
284
273
|
response_text = +''
|
|
285
274
|
parser = build_tool_call_parser
|
|
@@ -289,13 +278,15 @@ module Legion
|
|
|
289
278
|
response_text << chunk.content
|
|
290
279
|
parser.feed(chunk.content)
|
|
291
280
|
end
|
|
292
|
-
|
|
281
|
+
@app.render_frame if @app.respond_to?(:render_frame)
|
|
293
282
|
end
|
|
294
283
|
parser.flush
|
|
295
284
|
record_response_time(Time.now - start_time)
|
|
296
285
|
@status_bar.update(thinking: false)
|
|
297
286
|
track_response_tokens(response)
|
|
298
287
|
speak_response(response_text) if @speak_mode
|
|
288
|
+
ensure
|
|
289
|
+
@streaming = false
|
|
299
290
|
end
|
|
300
291
|
# rubocop:enable Metrics/AbcSize
|
|
301
292
|
|
|
@@ -360,63 +351,6 @@ module Legion
|
|
|
360
351
|
cfg.is_a?(Hash) ? cfg : {}
|
|
361
352
|
end
|
|
362
353
|
|
|
363
|
-
def render_screen
|
|
364
|
-
require 'tty-cursor'
|
|
365
|
-
lines = render(terminal_width, terminal_height - 1)
|
|
366
|
-
@output.print ::TTY::Cursor.move_to(0, 0)
|
|
367
|
-
@output.print ::TTY::Cursor.clear_screen_down
|
|
368
|
-
lines.each { |line| @output.puts line }
|
|
369
|
-
render_overlay if @app.respond_to?(:screen_manager) && @app.screen_manager.overlay
|
|
370
|
-
end
|
|
371
|
-
|
|
372
|
-
# rubocop:disable Metrics/AbcSize
|
|
373
|
-
def render_overlay
|
|
374
|
-
require 'tty-box'
|
|
375
|
-
text = @app.screen_manager.overlay.to_s
|
|
376
|
-
width = (terminal_width - 4).clamp(40, terminal_width)
|
|
377
|
-
box = ::TTY::Box.frame(
|
|
378
|
-
width: width,
|
|
379
|
-
padding: 1,
|
|
380
|
-
title: { top_left: ' Help ' },
|
|
381
|
-
border: :round
|
|
382
|
-
) { text }
|
|
383
|
-
overlay_lines = box.split("\n")
|
|
384
|
-
start_row = [(terminal_height - overlay_lines.size) / 2, 0].max
|
|
385
|
-
overlay_lines.each_with_index do |line, i|
|
|
386
|
-
@output.print ::TTY::Cursor.move_to(2, start_row + i)
|
|
387
|
-
@output.print line
|
|
388
|
-
end
|
|
389
|
-
rescue StandardError => e
|
|
390
|
-
Legion::Logging.warn("render_overlay failed: #{e.message}") if defined?(Legion::Logging)
|
|
391
|
-
nil
|
|
392
|
-
end
|
|
393
|
-
# rubocop:enable Metrics/AbcSize
|
|
394
|
-
|
|
395
|
-
def read_input
|
|
396
|
-
return nil unless @input_bar.respond_to?(:read_line)
|
|
397
|
-
return read_multiline_input if @multiline_mode
|
|
398
|
-
|
|
399
|
-
@input_bar.read_line
|
|
400
|
-
rescue Interrupt => e
|
|
401
|
-
Legion::Logging.debug("read_input interrupted: #{e.message}") if defined?(Legion::Logging)
|
|
402
|
-
nil
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
def read_multiline_input
|
|
406
|
-
lines = []
|
|
407
|
-
loop do
|
|
408
|
-
line = @input_bar.read_line
|
|
409
|
-
return nil if line.nil? && lines.empty?
|
|
410
|
-
break if line.nil? || line.empty?
|
|
411
|
-
|
|
412
|
-
lines << line
|
|
413
|
-
end
|
|
414
|
-
lines.empty? ? nil : lines.join("\n")
|
|
415
|
-
rescue Interrupt => e
|
|
416
|
-
Legion::Logging.debug("read_multiline_input interrupted: #{e.message}") if defined?(Legion::Logging)
|
|
417
|
-
nil
|
|
418
|
-
end
|
|
419
|
-
|
|
420
354
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
421
355
|
def dispatch_slash(cmd, input)
|
|
422
356
|
case cmd
|
|
@@ -641,12 +575,6 @@ module Legion
|
|
|
641
575
|
)
|
|
642
576
|
end
|
|
643
577
|
|
|
644
|
-
def build_default_input_bar
|
|
645
|
-
cfg = safe_config
|
|
646
|
-
name = cfg[:name] || 'User'
|
|
647
|
-
Components::InputBar.new(name: name, completions: SLASH_COMMANDS)
|
|
648
|
-
end
|
|
649
|
-
|
|
650
578
|
def terminal_width
|
|
651
579
|
require 'tty-screen'
|
|
652
580
|
::TTY::Screen.width
|
data/lib/legion/tty/version.rb
CHANGED