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.
@@ -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