legion-tty 0.4.35 → 0.4.37
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 +25 -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 +52 -101
- 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: f6aab5b9e38570a9072b676e7573506c5051693e503ff3861c1f7c042e836267
|
|
4
|
+
data.tar.gz: 412cd703adfd6117e87fb0a57820d8d01c60d2701dea9b2b0c6c30c5f0a44f06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e6164b922a3e05f202cc8ae224ce0a8ebc84cba655ee333bfc74dfaf66f3ab62aeff4dfde49e624d7d40f26e0cb377a3ba36a20b0f03e0806f55f08e9efd43da
|
|
7
|
+
data.tar.gz: 37b23570482bc80906620439d165594bd436d56c87da0f13612729e8e5d06f26ebf786881c88b708236efb9af3d55f71c407e46051680479bd1f6c95940f1d68
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.37] - 2026-03-26
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Token tracking uses `model_id` from RubyLLM::Message response (was incorrectly checking non-existent `model` method)
|
|
7
|
+
- Initialize TokenTracker with actual model from LLM chat session when available
|
|
8
|
+
- Guard against blank `model_id` in `track_response_tokens` — a nil `model_id` no longer overwrites the previously-set model with an empty string
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Daemon path token tracking via `track_daemon_tokens` (reads `meta[:tokens_in]`/`meta[:tokens_out]` from daemon response)
|
|
12
|
+
- Extracted `update_status_bar_tokens` helper to DRY up status bar updates after tracking
|
|
13
|
+
|
|
14
|
+
## [0.4.36] - 2026-03-26
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Rebuild rendering engine with raw-mode event loop replacing synchronous readline blocking
|
|
18
|
+
- App#run_loop uses character-by-character input via IO.select + $stdin.raw with manual escape sequence parsing
|
|
19
|
+
- Differential rendering compares frame buffers line-by-line to eliminate screen flicker
|
|
20
|
+
- Overlay compositing renders TTY::Box frames centered over screen content, persists until Escape
|
|
21
|
+
- Key normalization maps raw escape sequences and control chars to symbols for dispatch
|
|
22
|
+
- InputBar rewritten with handle_key line buffer for non-blocking single-key processing
|
|
23
|
+
- Chat screen uses render/handle_input contract with page_up/page_down scroll
|
|
24
|
+
- Streaming flag enables 50ms refresh during LLM streaming for responsive re-rendering
|
|
25
|
+
- Hotkeys now use normalized symbol keys (:ctrl_d, :ctrl_l, :ctrl_k, :ctrl_s)
|
|
26
|
+
- Screens (Dashboard, Extensions, Config) driven by App event loop instead of standalone blocking loops
|
|
27
|
+
|
|
3
28
|
## [0.4.35] - 2026-03-25
|
|
4
29
|
|
|
5
30
|
### 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,15 +68,16 @@ 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
|
-
@token_tracker = Components::TokenTracker.new(
|
|
77
|
+
@token_tracker = Components::TokenTracker.new(
|
|
78
|
+
provider: detect_provider,
|
|
79
|
+
model: @llm_chat.respond_to?(:model) ? @llm_chat.model.to_s : nil
|
|
80
|
+
)
|
|
80
81
|
@session_store = SessionStore.new
|
|
81
82
|
@session_name = 'default'
|
|
82
83
|
@plan_mode = false
|
|
@@ -105,12 +106,12 @@ module Legion
|
|
|
105
106
|
@timer_thread = nil
|
|
106
107
|
@message_prefix = nil
|
|
107
108
|
@message_suffix = nil
|
|
109
|
+
@streaming = false
|
|
108
110
|
end
|
|
109
111
|
|
|
110
112
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
111
113
|
|
|
112
114
|
def activate
|
|
113
|
-
@running = true
|
|
114
115
|
cfg = safe_config
|
|
115
116
|
@status_bar.update(model: cfg[:provider], session: 'default')
|
|
116
117
|
setup_system_prompt
|
|
@@ -121,34 +122,25 @@ module Legion
|
|
|
121
122
|
@status_bar.update(message_count: @message_stream.messages.size)
|
|
122
123
|
end
|
|
123
124
|
|
|
124
|
-
def
|
|
125
|
-
|
|
125
|
+
def needs_input_bar?
|
|
126
|
+
true
|
|
126
127
|
end
|
|
127
128
|
|
|
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
|
|
129
|
+
def streaming?
|
|
130
|
+
@streaming
|
|
131
|
+
end
|
|
140
132
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
end
|
|
133
|
+
def handle_line(line)
|
|
134
|
+
return :handled if line.strip.empty?
|
|
135
|
+
|
|
136
|
+
result = handle_slash_command(line)
|
|
137
|
+
if result == :quit
|
|
138
|
+
auto_save_session
|
|
139
|
+
return :quit
|
|
149
140
|
end
|
|
141
|
+
handle_user_message(line) if result.nil?
|
|
142
|
+
:handled
|
|
150
143
|
end
|
|
151
|
-
# rubocop:enable Metrics/AbcSize
|
|
152
144
|
|
|
153
145
|
def handle_slash_command(input)
|
|
154
146
|
return nil unless input.start_with?('/')
|
|
@@ -179,7 +171,6 @@ module Legion
|
|
|
179
171
|
end
|
|
180
172
|
@status_bar.update(message_count: @message_stream.messages.size)
|
|
181
173
|
check_autosave
|
|
182
|
-
render_screen
|
|
183
174
|
end
|
|
184
175
|
|
|
185
176
|
def send_to_llm(message)
|
|
@@ -207,11 +198,11 @@ module Legion
|
|
|
207
198
|
|
|
208
199
|
def handle_input(key)
|
|
209
200
|
case key
|
|
210
|
-
when :
|
|
211
|
-
@message_stream.scroll_up
|
|
201
|
+
when :page_up
|
|
202
|
+
@message_stream.scroll_up(10)
|
|
212
203
|
:handled
|
|
213
|
-
when :
|
|
214
|
-
@message_stream.scroll_down
|
|
204
|
+
when :page_down
|
|
205
|
+
@message_stream.scroll_down(10)
|
|
215
206
|
:handled
|
|
216
207
|
else
|
|
217
208
|
:pass
|
|
@@ -263,6 +254,7 @@ module Legion
|
|
|
263
254
|
parser = build_tool_call_parser
|
|
264
255
|
parser.feed(result[:response])
|
|
265
256
|
parser.flush
|
|
257
|
+
track_daemon_tokens(result)
|
|
266
258
|
when :error
|
|
267
259
|
err = result.dig(:error, :message) || 'Unknown error'
|
|
268
260
|
@message_stream.append_streaming("\n[Daemon error: #{err}]")
|
|
@@ -279,7 +271,8 @@ module Legion
|
|
|
279
271
|
return unless @llm_chat
|
|
280
272
|
|
|
281
273
|
@status_bar.update(thinking: true)
|
|
282
|
-
|
|
274
|
+
@streaming = true
|
|
275
|
+
@app.render_frame if @app.respond_to?(:render_frame)
|
|
283
276
|
start_time = Time.now
|
|
284
277
|
response_text = +''
|
|
285
278
|
parser = build_tool_call_parser
|
|
@@ -289,13 +282,15 @@ module Legion
|
|
|
289
282
|
response_text << chunk.content
|
|
290
283
|
parser.feed(chunk.content)
|
|
291
284
|
end
|
|
292
|
-
|
|
285
|
+
@app.render_frame if @app.respond_to?(:render_frame)
|
|
293
286
|
end
|
|
294
287
|
parser.flush
|
|
295
288
|
record_response_time(Time.now - start_time)
|
|
296
289
|
@status_bar.update(thinking: false)
|
|
297
290
|
track_response_tokens(response)
|
|
298
291
|
speak_response(response_text) if @speak_mode
|
|
292
|
+
ensure
|
|
293
|
+
@streaming = false
|
|
299
294
|
end
|
|
300
295
|
# rubocop:enable Metrics/AbcSize
|
|
301
296
|
|
|
@@ -360,63 +355,6 @@ module Legion
|
|
|
360
355
|
cfg.is_a?(Hash) ? cfg : {}
|
|
361
356
|
end
|
|
362
357
|
|
|
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
358
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
421
359
|
def dispatch_slash(cmd, input)
|
|
422
360
|
case cmd
|
|
@@ -641,12 +579,6 @@ module Legion
|
|
|
641
579
|
)
|
|
642
580
|
end
|
|
643
581
|
|
|
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
582
|
def terminal_width
|
|
651
583
|
require 'tty-screen'
|
|
652
584
|
::TTY::Screen.width
|
|
@@ -674,12 +606,31 @@ module Legion
|
|
|
674
606
|
def track_response_tokens(response)
|
|
675
607
|
return unless response.respond_to?(:input_tokens)
|
|
676
608
|
|
|
677
|
-
|
|
609
|
+
raw_model = response.respond_to?(:model_id) ? response.model_id.to_s : nil
|
|
610
|
+
model_id = raw_model && !raw_model.empty? ? raw_model : nil
|
|
611
|
+
input_tokens = response.input_tokens.to_i
|
|
612
|
+
output_tokens = response.respond_to?(:output_tokens) ? response.output_tokens.to_i : 0
|
|
678
613
|
@token_tracker.track(
|
|
679
|
-
input_tokens:
|
|
680
|
-
output_tokens:
|
|
614
|
+
input_tokens: input_tokens,
|
|
615
|
+
output_tokens: output_tokens,
|
|
681
616
|
model: model_id
|
|
682
617
|
)
|
|
618
|
+
update_status_bar_tokens
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def track_daemon_tokens(result)
|
|
622
|
+
meta = result[:meta]
|
|
623
|
+
return unless meta.is_a?(Hash) && (meta[:tokens_in] || meta[:tokens_out])
|
|
624
|
+
|
|
625
|
+
@token_tracker.track(
|
|
626
|
+
input_tokens: meta[:tokens_in].to_i,
|
|
627
|
+
output_tokens: meta[:tokens_out].to_i,
|
|
628
|
+
model: meta[:model]&.to_s
|
|
629
|
+
)
|
|
630
|
+
update_status_bar_tokens
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def update_status_bar_tokens
|
|
683
634
|
@status_bar.update(
|
|
684
635
|
tokens: @token_tracker.total_input_tokens + @token_tracker.total_output_tokens,
|
|
685
636
|
cost: @token_tracker.total_cost
|
data/lib/legion/tty/version.rb
CHANGED