legion-tty 0.2.0
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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE +201 -0
- data/README.md +138 -0
- data/exe/legion-tty +17 -0
- data/lib/legion/tty/app.rb +277 -0
- data/lib/legion/tty/background/github_probe.rb +357 -0
- data/lib/legion/tty/background/kerberos_probe.rb +205 -0
- data/lib/legion/tty/background/scanner.rb +160 -0
- data/lib/legion/tty/boot_logger.rb +34 -0
- data/lib/legion/tty/components/digital_rain.rb +138 -0
- data/lib/legion/tty/components/input_bar.rb +46 -0
- data/lib/legion/tty/components/markdown_view.rb +17 -0
- data/lib/legion/tty/components/message_stream.rb +86 -0
- data/lib/legion/tty/components/status_bar.rb +89 -0
- data/lib/legion/tty/components/token_tracker.rb +46 -0
- data/lib/legion/tty/components/tool_panel.rb +93 -0
- data/lib/legion/tty/components/wizard_prompt.rb +49 -0
- data/lib/legion/tty/hotkeys.rb +29 -0
- data/lib/legion/tty/screen_manager.rb +63 -0
- data/lib/legion/tty/screens/base.rb +28 -0
- data/lib/legion/tty/screens/chat.rb +428 -0
- data/lib/legion/tty/screens/dashboard.rb +211 -0
- data/lib/legion/tty/screens/onboarding.rb +463 -0
- data/lib/legion/tty/session_store.rb +74 -0
- data/lib/legion/tty/theme.rb +63 -0
- data/lib/legion/tty/version.rb +7 -0
- data/lib/legion/tty.rb +30 -0
- metadata +247 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module TTY
|
|
8
|
+
module Background
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
10
|
+
class Scanner
|
|
11
|
+
MAX_DEPTH = 3
|
|
12
|
+
|
|
13
|
+
SERVICES = {
|
|
14
|
+
rabbitmq: 5672,
|
|
15
|
+
redis: 6379,
|
|
16
|
+
memcached: 11_211,
|
|
17
|
+
vault: 8200,
|
|
18
|
+
postgres: 5432
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
CONFIG_FILES = %w[.env Gemfile package.json Dockerfile].freeze
|
|
22
|
+
|
|
23
|
+
LANGUAGE_MAP = {
|
|
24
|
+
'.rb' => 'Ruby',
|
|
25
|
+
'.py' => 'Python',
|
|
26
|
+
'.js' => 'JavaScript',
|
|
27
|
+
'.ts' => 'TypeScript',
|
|
28
|
+
'.go' => 'Go',
|
|
29
|
+
'.java' => 'Java',
|
|
30
|
+
'.rs' => 'Rust',
|
|
31
|
+
'.tf' => 'Terraform',
|
|
32
|
+
'.sh' => 'Shell'
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def initialize(base_dirs: nil, logger: nil)
|
|
36
|
+
@base_dirs = base_dirs || [File.expand_path('~')]
|
|
37
|
+
@log = logger
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def scan_services
|
|
41
|
+
SERVICES.each_with_object({}) do |(name, port), result|
|
|
42
|
+
result[name] = { name: name.to_s, port: port, running: port_open?('127.0.0.1', port) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def scan_git_repos
|
|
47
|
+
@base_dirs.flat_map { |base| collect_repos(base) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def scan_shell_history
|
|
51
|
+
lines = read_history_lines
|
|
52
|
+
tally_commands(lines).sort_by { |_, v| -v }.first(20).to_h
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def scan_config_files
|
|
56
|
+
@base_dirs.flat_map do |base|
|
|
57
|
+
CONFIG_FILES.map { |name| File.join(base, name) }.select { |p| File.exist?(p) }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def scan_all
|
|
62
|
+
{ services: scan_services, repos: scan_git_repos, tools: scan_shell_history,
|
|
63
|
+
configs: scan_config_files }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run_async(queue)
|
|
67
|
+
Thread.new do
|
|
68
|
+
@log&.log('scanner', "starting scan of #{@base_dirs.join(', ')}")
|
|
69
|
+
t0 = Time.now
|
|
70
|
+
data = scan_all
|
|
71
|
+
elapsed = ((Time.now - t0) * 1000).round
|
|
72
|
+
@log&.log('scanner', "scan complete in #{elapsed}ms")
|
|
73
|
+
queue.push({ type: :scan_complete, data: data })
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
@log&.log('scanner', "ERROR: #{e.class}: #{e.message}")
|
|
76
|
+
queue.push({ type: :scan_error, error: e.message })
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def port_open?(host, port)
|
|
83
|
+
::Socket.tcp(host, port, connect_timeout: 1) { true }
|
|
84
|
+
rescue StandardError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
89
|
+
def collect_repos(base, depth = 0)
|
|
90
|
+
return [] unless File.directory?(base)
|
|
91
|
+
return [build_repo_entry(base)] if File.directory?(File.join(base, '.git'))
|
|
92
|
+
return [] if depth >= MAX_DEPTH
|
|
93
|
+
|
|
94
|
+
Dir.children(base).each_with_object([]) do |child, acc|
|
|
95
|
+
next if child.start_with?('.')
|
|
96
|
+
|
|
97
|
+
child_path = File.join(base, child)
|
|
98
|
+
acc.concat(collect_repos(child_path, depth + 1)) if File.directory?(child_path)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
rescue StandardError
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
106
|
+
|
|
107
|
+
def build_repo_entry(path)
|
|
108
|
+
{ path: path, name: File.basename(path), remote: git_remote(path),
|
|
109
|
+
branch: git_branch(path), language: detect_language(path) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def git_remote(path)
|
|
113
|
+
out = `git -C #{path.shellescape} remote get-url origin 2>/dev/null`.strip
|
|
114
|
+
out.empty? ? nil : out
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def git_branch(path)
|
|
120
|
+
out = `git -C #{path.shellescape} rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
|
|
121
|
+
out.empty? ? nil : out
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def detect_language(path)
|
|
127
|
+
ext_counts = Hash.new(0)
|
|
128
|
+
Dir.glob(File.join(path, '**', '*')).each do |f|
|
|
129
|
+
ext = File.extname(f)
|
|
130
|
+
ext_counts[ext] += 1 if File.file?(f) && !ext.empty?
|
|
131
|
+
end
|
|
132
|
+
LANGUAGE_MAP[ext_counts.max_by { |_, v| v }&.first]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_history_lines
|
|
136
|
+
%w[~/.zsh_history ~/.bash_history].flat_map do |path|
|
|
137
|
+
full = File.expand_path(path)
|
|
138
|
+
next [] unless File.exist?(full)
|
|
139
|
+
|
|
140
|
+
File.readlines(full, encoding: 'utf-8', chomp: true).last(500)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
[]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def tally_commands(lines)
|
|
147
|
+
lines.each_with_object(Hash.new(0)) do |line, counts|
|
|
148
|
+
cmd = extract_command(line)
|
|
149
|
+
counts[cmd] += 1 if cmd && !cmd.empty?
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def extract_command(line)
|
|
154
|
+
line.sub(/^: \d+:\d+;/, '').split.first
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
# rubocop:enable Metrics/ClassLength
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module TTY
|
|
7
|
+
class BootLogger
|
|
8
|
+
LOG_DIR = File.expand_path('~/.legionio/logs')
|
|
9
|
+
LOG_FILE = File.join(LOG_DIR, 'tty-boot.log')
|
|
10
|
+
|
|
11
|
+
def initialize(path: LOG_FILE)
|
|
12
|
+
@path = path
|
|
13
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
14
|
+
File.write(@path, '')
|
|
15
|
+
log('boot', 'legion-tty boot logger started')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def log(source, message)
|
|
19
|
+
ts = Time.now.strftime('%H:%M:%S.%L')
|
|
20
|
+
line = "[#{ts}] [#{source}] #{message}\n"
|
|
21
|
+
File.open(@path, 'a') { |f| f.write(line) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def log_hash(source, label, hash)
|
|
25
|
+
log(source, "#{label}:")
|
|
26
|
+
hash.each do |k, v|
|
|
27
|
+
log(source, " #{k}: #{v.inspect}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :path
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../theme'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module TTY
|
|
7
|
+
module Components
|
|
8
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
|
+
class DigitalRain
|
|
10
|
+
# rubocop:disable Naming/VariableNumber
|
|
11
|
+
FADE_SHADES = %i[
|
|
12
|
+
purple_12 purple_11 purple_10 purple_9 purple_8
|
|
13
|
+
purple_7 purple_6 purple_5 purple_4 purple_3
|
|
14
|
+
purple_2 purple_1
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
HEAD_COLOR = :purple_17
|
|
18
|
+
# rubocop:enable Naming/VariableNumber
|
|
19
|
+
|
|
20
|
+
FALLBACK_NAMES = %w[
|
|
21
|
+
hippocampus amygdala prefrontal-cortex cerebellum thalamus hypothalamus
|
|
22
|
+
brainstem synapse apollo tasker scheduler node health telemetry
|
|
23
|
+
conditioner transformer memory dream cortex glia neuron dendrite axon receptor
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :columns, :width, :height
|
|
27
|
+
|
|
28
|
+
def initialize(width:, height:, extensions: nil, density: 0.7)
|
|
29
|
+
@width = width
|
|
30
|
+
@height = height
|
|
31
|
+
@density = density
|
|
32
|
+
@extensions = extensions || self.class.extension_names
|
|
33
|
+
@max_frames = 200
|
|
34
|
+
@frame = 0
|
|
35
|
+
@columns = build_columns
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.extension_names
|
|
39
|
+
gems = Gem::Specification.select { |s| s.name.start_with?('lex-') }
|
|
40
|
+
.map { |s| s.name.sub(/^lex-/, '') }
|
|
41
|
+
gems.empty? ? FALLBACK_NAMES : gems
|
|
42
|
+
rescue StandardError
|
|
43
|
+
FALLBACK_NAMES
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tick
|
|
47
|
+
@frame += 1
|
|
48
|
+
@columns.each do |col|
|
|
49
|
+
col[:y] += col[:speed]
|
|
50
|
+
reset_column(col) if col[:y] - col[:length] > @height
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_frame
|
|
55
|
+
grid = Array.new(@height) { Array.new(@width) { { char: ' ', color: nil } } }
|
|
56
|
+
paint_columns(grid)
|
|
57
|
+
render_grid(grid)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# rubocop:disable Metrics/AbcSize
|
|
61
|
+
def run(duration_seconds: 7, fps: 18, output: $stdout)
|
|
62
|
+
require 'tty-cursor'
|
|
63
|
+
cursor = ::TTY::Cursor
|
|
64
|
+
frame_delay = 1.0 / fps
|
|
65
|
+
output.print cursor.hide
|
|
66
|
+
output.print cursor.save
|
|
67
|
+
(duration_seconds * fps).times do
|
|
68
|
+
output.print cursor.restore
|
|
69
|
+
render_frame.each { |line| output.puts line }
|
|
70
|
+
tick
|
|
71
|
+
sleep frame_delay
|
|
72
|
+
end
|
|
73
|
+
ensure
|
|
74
|
+
output.print cursor.show
|
|
75
|
+
end
|
|
76
|
+
# rubocop:enable Metrics/AbcSize
|
|
77
|
+
|
|
78
|
+
def done?
|
|
79
|
+
@frame >= @max_frames
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def paint_columns(grid)
|
|
85
|
+
@columns.each do |col|
|
|
86
|
+
pos = col[:x]
|
|
87
|
+
next if pos >= @width
|
|
88
|
+
|
|
89
|
+
head_y = col[:y].floor
|
|
90
|
+
col[:chars].each_with_index do |ch, idx|
|
|
91
|
+
row = head_y - idx
|
|
92
|
+
next if row.negative? || row >= @height
|
|
93
|
+
|
|
94
|
+
color = pick_color(idx)
|
|
95
|
+
grid[row][pos] = { char: ch, color: color }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def render_grid(grid)
|
|
101
|
+
grid.map do |row|
|
|
102
|
+
row.map { |cell| cell[:color] ? Theme.c(cell[:color], cell[:char]) : cell[:char] }.join
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def pick_color(idx)
|
|
107
|
+
return HEAD_COLOR if idx.zero?
|
|
108
|
+
|
|
109
|
+
idx < FADE_SHADES.size ? FADE_SHADES[idx] : FADE_SHADES.last
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_columns
|
|
113
|
+
count = [(@width * @density).ceil, 1].max
|
|
114
|
+
(0...@width).to_a.sample(count).map { |pos| new_column(pos) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def new_column(pos)
|
|
118
|
+
{ x: pos, y: rand(-@height..0).to_f, speed: rand(0.3..1.0), length: rand(4..14), chars: build_chars }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def reset_column(col)
|
|
122
|
+
col[:y] = rand(-@height..-1).to_f
|
|
123
|
+
col[:speed] = rand(0.3..1.0)
|
|
124
|
+
col[:length] = rand(4..14)
|
|
125
|
+
col[:chars] = build_chars
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_chars
|
|
129
|
+
source = @extensions.sample || 'legion'
|
|
130
|
+
chars = source.chars.reject { |chr| chr == '-' }
|
|
131
|
+
chars = ('a'..'z').to_a if chars.empty?
|
|
132
|
+
Array.new(14) { chars.sample }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
# rubocop:enable Metrics/ClassLength
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../theme'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module TTY
|
|
7
|
+
module Components
|
|
8
|
+
class InputBar
|
|
9
|
+
def initialize(name: 'User', reader: nil)
|
|
10
|
+
@name = name
|
|
11
|
+
@reader = reader || build_default_reader
|
|
12
|
+
@thinking = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def prompt_string
|
|
16
|
+
"#{Theme.c(:accent, @name)} #{Theme.c(:primary, '>')} "
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read_line
|
|
20
|
+
@reader.read_line(prompt_string)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show_thinking
|
|
24
|
+
@thinking = true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def clear_thinking
|
|
28
|
+
@thinking = false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def thinking?
|
|
32
|
+
@thinking
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_default_reader
|
|
38
|
+
require 'tty-reader'
|
|
39
|
+
::TTY::Reader.new
|
|
40
|
+
rescue LoadError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-markdown'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module TTY
|
|
7
|
+
module Components
|
|
8
|
+
module MarkdownView
|
|
9
|
+
def self.render(text, width: 80)
|
|
10
|
+
::TTY::Markdown.parse(text, width: width)
|
|
11
|
+
rescue StandardError => e
|
|
12
|
+
"#{text}\n(markdown render error: #{e.message})"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../theme'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module TTY
|
|
7
|
+
module Components
|
|
8
|
+
class MessageStream
|
|
9
|
+
attr_reader :messages, :scroll_offset
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@messages = []
|
|
13
|
+
@scroll_offset = 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_message(role:, content:)
|
|
17
|
+
@messages << { role: role, content: content, tool_panels: [] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def append_streaming(text)
|
|
21
|
+
return if @messages.empty?
|
|
22
|
+
|
|
23
|
+
@messages.last[:content] = @messages.last[:content] + text
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_tool_panel(panel)
|
|
27
|
+
return if @messages.empty?
|
|
28
|
+
|
|
29
|
+
@messages.last[:tool_panels] << panel
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def scroll_up(lines = 1)
|
|
33
|
+
@scroll_offset += lines
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def scroll_down(lines = 1)
|
|
37
|
+
@scroll_offset = [@scroll_offset - lines, 0].max
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def render(width:, height:)
|
|
41
|
+
all_lines = build_all_lines(width)
|
|
42
|
+
total = all_lines.size
|
|
43
|
+
start_idx = [total - height - @scroll_offset, 0].max
|
|
44
|
+
start_idx = [start_idx, total].min
|
|
45
|
+
all_lines[start_idx, height] || []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def build_all_lines(width)
|
|
51
|
+
@messages.flat_map { |msg| render_message(msg, width) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_message(msg, width)
|
|
55
|
+
role_lines(msg) + panel_lines(msg, width)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def role_lines(msg)
|
|
59
|
+
case msg[:role]
|
|
60
|
+
when :user then user_lines(msg)
|
|
61
|
+
when :assistant then assistant_lines(msg)
|
|
62
|
+
when :system then system_lines(msg)
|
|
63
|
+
else []
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def user_lines(msg)
|
|
68
|
+
prefix = Theme.c(:accent, 'You')
|
|
69
|
+
['', "#{prefix}: #{msg[:content]}"]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def assistant_lines(msg)
|
|
73
|
+
['', *msg[:content].split("\n")]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def system_lines(msg)
|
|
77
|
+
msg[:content].split("\n").map { |l| " #{Theme.c(:muted, l)}" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def panel_lines(msg, width)
|
|
81
|
+
msg[:tool_panels].flat_map { |panel| panel.render(width: width).split("\n") }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../theme'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module TTY
|
|
7
|
+
module Components
|
|
8
|
+
class StatusBar
|
|
9
|
+
def initialize
|
|
10
|
+
@state = { model: nil, tokens: 0, cost: 0.0, session: 'default' }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update(**fields)
|
|
14
|
+
@state.merge!(fields)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def render(width:)
|
|
18
|
+
segments = build_segments
|
|
19
|
+
separator = Theme.c(:muted, ' | ')
|
|
20
|
+
line = segments.join(separator)
|
|
21
|
+
plain_length = strip_ansi(line).length
|
|
22
|
+
if plain_length < width
|
|
23
|
+
line + (' ' * (width - plain_length))
|
|
24
|
+
else
|
|
25
|
+
truncate_to_width(line, width)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_segments
|
|
32
|
+
[
|
|
33
|
+
model_segment,
|
|
34
|
+
tokens_segment,
|
|
35
|
+
cost_segment,
|
|
36
|
+
session_segment
|
|
37
|
+
].compact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def model_segment
|
|
41
|
+
Theme.c(:accent, @state[:model]) if @state[:model]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def tokens_segment
|
|
45
|
+
Theme.c(:secondary, "#{format_number(@state[:tokens])} tokens") if @state[:tokens].to_i.positive?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def cost_segment
|
|
49
|
+
Theme.c(:success, format('$%.3f', @state[:cost])) if @state[:cost].to_f.positive?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def session_segment
|
|
53
|
+
Theme.c(:muted, @state[:session]) if @state[:session]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_number(num)
|
|
57
|
+
num.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def strip_ansi(str)
|
|
61
|
+
str.gsub(/\e\[[0-9;]*m/, '')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def truncate_to_width(str, width)
|
|
65
|
+
plain = strip_ansi(str)
|
|
66
|
+
return str if plain.length <= width
|
|
67
|
+
|
|
68
|
+
result = +''
|
|
69
|
+
visible = 0
|
|
70
|
+
idx = 0
|
|
71
|
+
while idx < str.length && visible < width
|
|
72
|
+
if str[idx] == "\e"
|
|
73
|
+
jdx = str.index('m', idx)
|
|
74
|
+
if jdx
|
|
75
|
+
result << str[idx..jdx]
|
|
76
|
+
idx = jdx + 1
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
result << str[idx]
|
|
81
|
+
visible += 1
|
|
82
|
+
idx += 1
|
|
83
|
+
end
|
|
84
|
+
result
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module TTY
|
|
5
|
+
module Components
|
|
6
|
+
class TokenTracker
|
|
7
|
+
PRICING = {
|
|
8
|
+
'claude' => { input: 0.003, output: 0.015 },
|
|
9
|
+
'openai' => { input: 0.005, output: 0.015 },
|
|
10
|
+
'gemini' => { input: 0.001, output: 0.002 },
|
|
11
|
+
'azure' => { input: 0.005, output: 0.015 },
|
|
12
|
+
'local' => { input: 0.0, output: 0.0 }
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :total_input_tokens, :total_output_tokens, :total_cost
|
|
16
|
+
|
|
17
|
+
def initialize(provider: 'claude')
|
|
18
|
+
@provider = provider
|
|
19
|
+
@total_input_tokens = 0
|
|
20
|
+
@total_output_tokens = 0
|
|
21
|
+
@total_cost = 0.0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def track(input_tokens:, output_tokens:)
|
|
25
|
+
@total_input_tokens += input_tokens.to_i
|
|
26
|
+
@total_output_tokens += output_tokens.to_i
|
|
27
|
+
rates = PRICING[@provider] || PRICING['claude']
|
|
28
|
+
@total_cost += (input_tokens.to_i * rates[:input] / 1000.0) + (output_tokens.to_i * rates[:output] / 1000.0)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def summary
|
|
32
|
+
input = format_number(@total_input_tokens)
|
|
33
|
+
output = format_number(@total_output_tokens)
|
|
34
|
+
cost = format('%.4f', @total_cost)
|
|
35
|
+
"Tokens: #{input} in / #{output} out | Cost: $#{cost}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def format_number(num)
|
|
41
|
+
num.to_s.chars.reverse.each_slice(3).map(&:join).join(',').reverse
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|