legion-tty 0.4.20 → 0.4.22

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2d9ef0fe2336f2750785f4742bc14661032836b05fab83062ba5de23c60cbbc
4
- data.tar.gz: cf9fe410cc87ba108cc52e994b5210351bb403b84aa43a27cbcb5ad94ff65232
3
+ metadata.gz: 45ef88ce519140b5056ce0b934f3c3512781a4f26e30dc74203c431e6f20cc3c
4
+ data.tar.gz: ba632f83ea2022683c097b5451849bf129287c46959a050a8db90b356abe1f29
5
5
  SHA512:
6
- metadata.gz: 194101f68a10f6e0c9b7a9c14d38690adcfe37724fcb5c2ee894d5a13b757a897aa3b4e85fe48e5db5b44b14f597ffffa9ce1a88ede2da54a1858ca42f759bc9
7
- data.tar.gz: 1eb5e7cafbdc7e1025fcdd8424256eea456c3b24f3d03077effd4417a694bddf5f77fd232b6514e89dad53d813161b2d753b6e9a4d7564c1b13ecea6dde1cd95
6
+ metadata.gz: affafac87b03a47e29ae08bfe5815ba9eb8f72de5fc3feda9350b2871021537f6319261177de1a20f349b8d38cda3295d6b5cca6ff941dbe9414744fa27562e5
7
+ data.tar.gz: bf14853b34688cd959cf6301a56542ec9448fc9f4db2fb2489e7c6aeec3d56e08fc6e44c1627dc31c33631a8b9291717786032dee7ab72b566c545693ca2b57e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.22] - 2026-03-19
4
+
5
+ ### Added
6
+ - `/truncate [N|off]` command: display-only truncation of long messages (preserves originals)
7
+ - `/archive [name]` command: archive session to `~/.legionio/archives/` with timestamp and start fresh
8
+ - `/archives` command: list all archived sessions with file sizes
9
+ - `/tee <path>` command: copy new messages to file in real-time (like Unix tee)
10
+ - `/pipe <command>` command: pipe last assistant response through a shell command
11
+ - `/calc <expression>` command: safe math expression evaluator with Math functions
12
+ - `/rand [N|min..max]` command: generate random numbers (float, integer, or range)
13
+
14
+ ## [0.4.21] - 2026-03-19
15
+
16
+ ### Added
17
+ - `/annotate [N] <text>` command: add notes/annotations to specific messages with timestamps
18
+ - `/annotations` command: list all annotated messages with their notes
19
+ - `/filter [role|tag|pinned|clear]` command: filter displayed messages by role, tag, or pinned status
20
+ - `/multiline` command: toggle multi-line input mode (submit with empty line)
21
+ - `/export yaml` format: export chat history as YAML alongside existing md/json/html formats
22
+ - Annotation rendering in message stream (displayed after reactions)
23
+ - `[ML]` status bar indicator for multi-line input mode
24
+
3
25
  ## [0.4.20] - 2026-03-19
4
26
 
5
27
  ### Added
@@ -132,7 +132,7 @@ module Legion
132
132
 
133
133
  private
134
134
 
135
- def boot_legion_subsystems # rubocop:disable Metrics/MethodLength
135
+ def boot_legion_subsystems
136
136
  # Follow the same init order as Legion::Service:
137
137
  # 1. logging 2. settings 3. crypt 4. resolve secrets 5. LLM merge
138
138
  require 'legion/logging'
@@ -39,7 +39,7 @@ module Legion
39
39
  end
40
40
  # rubocop:enable Metrics/AbcSize
41
41
 
42
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
42
+ # rubocop:disable Metrics/AbcSize
43
43
  def run_async(queue, remotes: [], quick_profile: nil)
44
44
  Thread.new do
45
45
  @log&.log('github', "probing with #{remotes.size} remotes: #{remotes.first(5).inspect}")
@@ -59,7 +59,7 @@ module Legion
59
59
  queue.push({ type: :github_error, error: e.message })
60
60
  end
61
61
  end
62
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
62
+ # rubocop:enable Metrics/AbcSize
63
63
 
64
64
  private
65
65
 
@@ -282,7 +282,6 @@ module Legion
282
282
 
283
283
  # --- Token resolution ---
284
284
 
285
- # rubocop:disable Metrics/CyclomaticComplexity
286
285
  def resolve_token
287
286
  env_token = ENV.fetch('GITHUB_TOKEN', nil) ||
288
287
  ENV.fetch('GH_TOKEN', nil) ||
@@ -301,9 +300,7 @@ module Legion
301
300
  @log&.log('github', 'no token found (no env var, no gh CLI)')
302
301
  nil
303
302
  end
304
- # rubocop:enable Metrics/CyclomaticComplexity
305
303
 
306
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
307
304
  def token_from_gh_cli
308
305
  gh_path = `which gh 2>/dev/null`.strip
309
306
  return nil if gh_path.empty?
@@ -322,8 +319,6 @@ module Legion
322
319
  @log&.log('github', "gh CLI error: #{e.message}")
323
320
  nil
324
321
  end
325
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
326
-
327
322
  # --- HTTP ---
328
323
 
329
324
  def api_get(path)
@@ -62,7 +62,6 @@ module Legion
62
62
  query_ldap(username: username, host: dc_host, base_dn: base_dn)
63
63
  end
64
64
 
65
- # rubocop:disable Metrics/CyclomaticComplexity
66
65
  def discover_dc(realm)
67
66
  domain = realm.downcase
68
67
  srv_name = "_ldap._tcp.#{domain}"
@@ -74,7 +73,6 @@ module Legion
74
73
  @log&.log('kerberos', "SRV lookup failed: #{e.message}")
75
74
  nil
76
75
  end
77
- # rubocop:enable Metrics/CyclomaticComplexity
78
76
 
79
77
  def realm_to_base_dn(realm)
80
78
  realm.downcase.split('.').map { |part| "DC=#{part}" }.join(',')
@@ -149,7 +147,7 @@ module Legion
149
147
  }.compact
150
148
  end
151
149
 
152
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
150
+ # rubocop:disable Metrics/AbcSize
153
151
  def calculate_tenure(when_created)
154
152
  return nil unless when_created&.length&.>=(8)
155
153
 
@@ -184,7 +182,7 @@ module Legion
184
182
 
185
183
  { years: years, months: months, days: days }
186
184
  end
187
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
185
+ # rubocop:enable Metrics/AbcSize
188
186
 
189
187
  def days_in_month(month, year)
190
188
  Time.new(year, month, -1).day
@@ -93,7 +93,6 @@ module Legion
93
93
  false
94
94
  end
95
95
 
96
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
97
96
  def collect_repos(base, depth = 0)
98
97
  return [] unless File.directory?(base)
99
98
  return [build_repo_entry(base)] if File.directory?(File.join(base, '.git'))
@@ -110,7 +109,6 @@ module Legion
110
109
  rescue StandardError
111
110
  []
112
111
  end
113
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
114
112
 
115
113
  def build_repo_entry(path)
116
114
  { path: path, name: File.basename(path), remote: git_remote(path),
@@ -9,7 +9,7 @@ module Legion
9
9
  # rubocop:disable Metrics/ClassLength
10
10
  class MessageStream
11
11
  attr_reader :messages, :scroll_offset
12
- attr_accessor :mute_system, :highlights
12
+ attr_accessor :mute_system, :highlights, :filter, :truncate_limit
13
13
 
14
14
  HIGHLIGHT_COLOR = "\e[1;33m"
15
15
  HIGHLIGHT_RESET = "\e[0m"
@@ -77,13 +77,28 @@ module Legion
77
77
  private
78
78
 
79
79
  def build_all_lines(width)
80
- @messages.flat_map do |msg|
80
+ filtered_messages.flat_map do |msg|
81
81
  next [] if @mute_system && msg[:role] == :system
82
82
 
83
83
  render_message(msg, width)
84
84
  end
85
85
  end
86
86
 
87
+ def filtered_messages
88
+ return @messages if @filter.nil?
89
+
90
+ case @filter[:type]
91
+ when :role
92
+ @messages.select { |m| m[:role].to_s == @filter[:value].to_s }
93
+ when :tag
94
+ @messages.select { |m| (m[:tags] || []).include?(@filter[:value]) }
95
+ when :pinned
96
+ @messages.select { |m| m[:pinned] }
97
+ else
98
+ @messages
99
+ end
100
+ end
101
+
87
102
  def render_message(msg, width)
88
103
  role_lines(msg, width) + panel_lines(msg, width)
89
104
  end
@@ -104,6 +119,7 @@ module Legion
104
119
  content = apply_highlights(msg[:content].to_s)
105
120
  lines = ['', "#{header}: #{content}"]
106
121
  lines << reaction_line(msg) if msg[:reactions]&.any?
122
+ lines.concat(annotation_lines(msg)) if msg[:annotations]&.any?
107
123
  lines
108
124
  end
109
125
 
@@ -114,18 +130,34 @@ module Legion
114
130
  end
115
131
 
116
132
  def assistant_lines(msg, width)
117
- rendered = render_markdown(msg[:content], width)
133
+ content = display_content(msg[:content])
134
+ rendered = render_markdown(content, width)
118
135
  rendered = apply_highlights(rendered)
119
136
  lines = ['', *rendered.split("\n")]
120
137
  lines << reaction_line(msg) if msg[:reactions]&.any?
138
+ lines.concat(annotation_lines(msg)) if msg[:annotations]&.any?
121
139
  lines
122
140
  end
123
141
 
142
+ def display_content(content)
143
+ return content unless @truncate_limit
144
+ return content if content.to_s.length <= @truncate_limit
145
+
146
+ "#{content[0...@truncate_limit]}... [truncated]"
147
+ end
148
+
124
149
  def reaction_line(msg)
125
150
  reactions = msg[:reactions].map { |r| "[#{r}]" }.join(' ')
126
151
  " #{Theme.c(:muted, reactions)}"
127
152
  end
128
153
 
154
+ def annotation_lines(msg)
155
+ msg[:annotations].map do |a|
156
+ ts = a[:timestamp].to_s[11..15] || ''
157
+ " #{Theme.c(:muted, "note [#{ts}]: #{a[:text]}")}"
158
+ end
159
+ end
160
+
129
161
  def render_markdown(text, width)
130
162
  require_relative 'markdown_view'
131
163
  MarkdownView.render(text, width: width)
@@ -9,7 +9,7 @@ module Legion
9
9
  @current_model = current_model
10
10
  end
11
11
 
12
- def available_models # rubocop:disable Metrics/CyclomaticComplexity
12
+ def available_models
13
13
  return [] unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:settings)
14
14
 
15
15
  providers = Legion::LLM.settings[:providers]
@@ -10,7 +10,7 @@ module Legion
10
10
  class StatusBar
11
11
  def initialize
12
12
  @state = { model: nil, tokens: 0, cost: 0.0, session: 'default', thinking: false, plan_mode: false,
13
- debug_mode: false, message_count: 0 }
13
+ debug_mode: false, message_count: 0, multiline: false }
14
14
  @notifications = []
15
15
  end
16
16
 
@@ -42,6 +42,7 @@ module Legion
42
42
  [
43
43
  model_segment,
44
44
  plan_segment,
45
+ multiline_segment,
45
46
  debug_segment,
46
47
  thinking_segment,
47
48
  notification_segment,
@@ -63,6 +64,12 @@ module Legion
63
64
  Theme.c(:warning, '[PLAN]')
64
65
  end
65
66
 
67
+ def multiline_segment
68
+ return nil unless @state[:multiline]
69
+
70
+ Theme.c(:accent, '[ML]')
71
+ end
72
+
66
73
  def debug_segment
67
74
  return nil unless @state[:debug_mode]
68
75
 
@@ -4,7 +4,6 @@ module Legion
4
4
  module TTY
5
5
  module Screens
6
6
  class Chat < Base
7
- # rubocop:disable Metrics/ModuleLength
8
7
  module CustomCommands
9
8
  TEMPLATES = {
10
9
  'explain' => 'Explain the following concept in simple terms: ',
@@ -19,7 +18,6 @@ module Legion
19
18
 
20
19
  private
21
20
 
22
- # rubocop:disable Metrics/MethodLength
23
21
  def handle_template(input)
24
22
  name = input.split(nil, 2)[1]
25
23
  unless name
@@ -47,7 +45,6 @@ module Legion
47
45
  )
48
46
  :handled
49
47
  end
50
- # rubocop:enable Metrics/MethodLength
51
48
 
52
49
  def handle_alias(input)
53
50
  parts = input.split(nil, 3)
@@ -263,7 +260,6 @@ module Legion
263
260
  end
264
261
  # rubocop:enable Metrics/AbcSize
265
262
 
266
- # rubocop:disable Metrics/MethodLength
267
263
  def handle_macro(input)
268
264
  parts = input.split(nil, 3)
269
265
  subcommand = parts[1]
@@ -288,7 +284,6 @@ module Legion
288
284
  end
289
285
  :handled
290
286
  end
291
- # rubocop:enable Metrics/MethodLength
292
287
 
293
288
  def macro_record(name)
294
289
  unless name
@@ -378,7 +373,7 @@ module Legion
378
373
  end
379
374
  end
380
375
 
381
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
376
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
382
377
  def handle_chain(input)
383
378
  args = input.split(nil, 2)[1]
384
379
  unless args
@@ -412,9 +407,8 @@ module Legion
412
407
  )
413
408
  :handled
414
409
  end
415
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
410
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
416
411
  end
417
- # rubocop:enable Metrics/ModuleLength
418
412
  end
419
413
  end
420
414
  end
@@ -21,19 +21,22 @@ module Legion
21
21
 
22
22
  def build_export_path(input)
23
23
  format = input.split[1]&.downcase
24
- format = 'md' unless %w[json md html].include?(format)
24
+ format = 'md' unless %w[json md html yaml].include?(format)
25
25
  exports_dir = File.expand_path('~/.legionio/exports')
26
26
  FileUtils.mkdir_p(exports_dir)
27
27
  timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
28
- ext = { 'json' => 'json', 'md' => 'md', 'html' => 'html' }[format]
28
+ ext = { 'json' => 'json', 'md' => 'md', 'html' => 'html', 'yaml' => 'yaml' }[format]
29
29
  File.join(exports_dir, "chat-#{timestamp}.#{ext}")
30
30
  end
31
31
 
32
32
  def dispatch_export(path, format)
33
- if format == 'json'
33
+ case format
34
+ when 'json'
34
35
  export_json(path)
35
- elsif format == 'html'
36
+ when 'html'
36
37
  export_html(path)
38
+ when 'yaml'
39
+ export_yaml(path)
37
40
  else
38
41
  export_markdown(path)
39
42
  end
@@ -58,6 +61,17 @@ module Legion
58
61
  File.write(path, ::JSON.pretty_generate(data))
59
62
  end
60
63
 
64
+ def export_yaml(path)
65
+ require 'yaml'
66
+ data = {
67
+ 'exported_at' => Time.now.iso8601,
68
+ 'messages' => @message_stream.messages.map do |m|
69
+ { 'role' => m[:role].to_s, 'content' => m[:content], 'timestamp' => m[:timestamp]&.iso8601 }
70
+ end
71
+ }
72
+ File.write(path, ::YAML.dump(data))
73
+ end
74
+
61
75
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
62
76
  def export_html(path)
63
77
  lines = [
@@ -118,6 +132,35 @@ module Legion
118
132
  :handled
119
133
  end
120
134
  # rubocop:enable Metrics/AbcSize
135
+
136
+ def handle_tee(input)
137
+ arg = input.split(nil, 2)[1]
138
+ if arg.nil?
139
+ status = @tee_path ? "Tee active: #{@tee_path}" : 'Tee inactive.'
140
+ @message_stream.add_message(role: :system, content: status)
141
+ return :handled
142
+ end
143
+
144
+ if arg.strip == 'off'
145
+ @tee_path = nil
146
+ @message_stream.add_message(role: :system, content: 'Tee stopped.')
147
+ else
148
+ @tee_path = File.expand_path(arg.strip)
149
+ @message_stream.add_message(role: :system, content: "Tee started: #{@tee_path}")
150
+ end
151
+ :handled
152
+ rescue StandardError => e
153
+ @message_stream.add_message(role: :system, content: "Tee error: #{e.message}")
154
+ :handled
155
+ end
156
+
157
+ def tee_message(line)
158
+ return unless @tee_path
159
+
160
+ File.open(@tee_path, 'a') { |f| f.puts(line) }
161
+ rescue StandardError
162
+ nil
163
+ end
121
164
  end
122
165
  end
123
166
  end
@@ -4,7 +4,6 @@ module Legion
4
4
  module TTY
5
5
  module Screens
6
6
  class Chat < Base
7
- # rubocop:disable Metrics/ModuleLength
8
7
  module MessageCommands
9
8
  private
10
9
 
@@ -402,7 +401,7 @@ module Legion
402
401
  File.write(favorites_file, ::JSON.generate(favs))
403
402
  end
404
403
 
405
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
404
+ # rubocop:disable Metrics/AbcSize
406
405
  def handle_fav(input)
407
406
  idx_str = input.split(nil, 2)[1]
408
407
  msg = if idx_str
@@ -428,7 +427,51 @@ module Legion
428
427
  @message_stream.add_message(role: :system, content: "Favorited: #{preview}")
429
428
  :handled
430
429
  end
431
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
430
+ # rubocop:enable Metrics/AbcSize
431
+
432
+ # rubocop:disable Metrics/AbcSize
433
+ def handle_annotate(input)
434
+ parts = input.split(nil, 3)
435
+ if parts.size >= 3 && parts[1].match?(/\A\d+\z/)
436
+ idx = parts[1].to_i
437
+ text = parts[2]
438
+ msg = @message_stream.messages[idx]
439
+ elsif parts.size == 2 && !parts[1].match?(/\A\d+\z/)
440
+ text = parts[1]
441
+ msg = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
442
+ elsif parts.size >= 3
443
+ text = parts[1..].join(' ')
444
+ msg = @message_stream.messages.reverse.find { |m| m[:role] == :assistant }
445
+ else
446
+ @message_stream.add_message(role: :system, content: 'Usage: /annotate [N] <text>')
447
+ return :handled
448
+ end
449
+
450
+ unless msg
451
+ @message_stream.add_message(role: :system, content: 'No message to annotate.')
452
+ return :handled
453
+ end
454
+
455
+ msg[:annotations] ||= []
456
+ msg[:annotations] << { text: text, timestamp: Time.now.iso8601 }
457
+ @message_stream.add_message(role: :system, content: "Annotation added: #{text}")
458
+ :handled
459
+ end
460
+ # rubocop:enable Metrics/AbcSize
461
+
462
+ def handle_annotations(_input)
463
+ annotated = @message_stream.messages.each_with_index.select { |m, _| m[:annotations]&.any? }
464
+ if annotated.empty?
465
+ @message_stream.add_message(role: :system, content: 'No annotated messages.')
466
+ return :handled
467
+ end
468
+
469
+ lines = annotated.flat_map do |msg, idx|
470
+ msg[:annotations].map { |a| " [#{idx}][#{msg[:role]}] #{a[:text]} (#{a[:timestamp]})" }
471
+ end
472
+ @message_stream.add_message(role: :system, content: "Annotations:\n#{lines.join("\n")}")
473
+ :handled
474
+ end
432
475
 
433
476
  def handle_favs
434
477
  all_favs = load_favorites
@@ -447,8 +490,77 @@ module Legion
447
490
  )
448
491
  :handled
449
492
  end
493
+
494
+ def handle_filter(input)
495
+ parts = input.split(nil, 3)
496
+ subcommand = parts[1]
497
+ case subcommand
498
+ when 'role'
499
+ apply_role_filter(parts[2])
500
+ when 'tag'
501
+ apply_tag_filter(parts[2])
502
+ when 'pinned'
503
+ @message_stream.filter = { type: :pinned }
504
+ @message_stream.add_message(role: :system, content: 'Filter: pinned messages only.')
505
+ when 'clear'
506
+ @message_stream.filter = nil
507
+ @message_stream.add_message(role: :system, content: 'Filter cleared.')
508
+ when nil
509
+ show_filter_status
510
+ else
511
+ @message_stream.add_message(
512
+ role: :system,
513
+ content: 'Usage: /filter [role|tag|pinned|clear] [value]'
514
+ )
515
+ end
516
+ :handled
517
+ end
518
+
519
+ def apply_role_filter(value)
520
+ unless value
521
+ @message_stream.add_message(role: :system, content: 'Usage: /filter role <user|assistant|system>')
522
+ return
523
+ end
524
+
525
+ @message_stream.filter = { type: :role, value: value }
526
+ @message_stream.add_message(role: :system, content: "Filter: role=#{value}.")
527
+ end
528
+
529
+ def apply_tag_filter(value)
530
+ unless value
531
+ @message_stream.add_message(role: :system, content: 'Usage: /filter tag <label>')
532
+ return
533
+ end
534
+
535
+ @message_stream.filter = { type: :tag, value: value }
536
+ @message_stream.add_message(role: :system, content: "Filter: tag=#{value}.")
537
+ end
538
+
539
+ def show_filter_status
540
+ f = @message_stream.filter
541
+ content = if f.nil?
542
+ 'No active filter.'
543
+ elsif f[:type] == :pinned
544
+ 'Active filter: pinned.'
545
+ else
546
+ "Active filter: #{f[:type]}=#{f[:value]}."
547
+ end
548
+ @message_stream.add_message(role: :system, content: content)
549
+ end
550
+
551
+ def handle_truncate(input)
552
+ n = (input.split(nil, 2)[1] || '10').to_i.clamp(1, 500)
553
+ msgs = @message_stream.messages
554
+ if msgs.size <= n
555
+ @message_stream.add_message(role: :system, content: "Already #{msgs.size} messages (<=#{n}).")
556
+ return :handled
557
+ end
558
+
559
+ @message_stream.messages.replace(msgs.last(n))
560
+ @message_stream.add_message(role: :system, content: "Truncated to last #{n} messages.")
561
+ :handled
562
+ end
450
563
  end
451
- # rubocop:enable Metrics/ModuleLength
452
564
  end
453
565
  end
454
566
  end
@@ -4,7 +4,6 @@ module Legion
4
4
  module TTY
5
5
  module Screens
6
6
  class Chat < Base
7
- # rubocop:disable Metrics/ModuleLength
8
7
  module ModelCommands
9
8
  private
10
9
 
@@ -134,7 +133,6 @@ module Legion
134
133
  :handled
135
134
  end
136
135
  end
137
- # rubocop:enable Metrics/ModuleLength
138
136
  end
139
137
  end
140
138
  end
@@ -4,7 +4,6 @@ module Legion
4
4
  module TTY
5
5
  module Screens
6
6
  class Chat < Base
7
- # rubocop:disable Metrics/ModuleLength
8
7
  module SessionCommands
9
8
  private
10
9
 
@@ -233,6 +232,34 @@ module Legion
233
232
  :handled
234
233
  end
235
234
 
235
+ def handle_archive(input)
236
+ name = input.split(nil, 2)[1] || "#{@session_name}-#{Time.now.strftime('%Y%m%d-%H%M%S')}"
237
+ archive_dir = File.expand_path('~/.legionio/archives')
238
+ FileUtils.mkdir_p(archive_dir)
239
+ path = File.join(archive_dir, "#{name}.json")
240
+ data = { name: name, messages: @message_stream.messages, archived_at: Time.now.iso8601 }
241
+ File.write(path, ::JSON.generate(data))
242
+ @message_stream.messages.clear
243
+ @message_stream.add_message(role: :system, content: "Session archived as: #{name}")
244
+ @status_bar.notify(message: "Archived: #{name}", level: :success, ttl: 3)
245
+ :handled
246
+ end
247
+
248
+ def handle_archives
249
+ archive_dir = File.expand_path('~/.legionio/archives')
250
+ files = Dir.glob(File.join(archive_dir, '*.json'))
251
+ if files.empty?
252
+ @message_stream.add_message(role: :system, content: 'No archives found.')
253
+ else
254
+ lines = files.sort.map do |f|
255
+ size = File.size(f)
256
+ " #{File.basename(f, '.json')} (#{size} bytes)"
257
+ end
258
+ @message_stream.add_message(role: :system, content: "Archives:\n#{lines.join("\n")}")
259
+ end
260
+ :handled
261
+ end
262
+
236
263
  def handle_merge(input)
237
264
  name = input.split(nil, 2)[1]
238
265
  unless name
@@ -256,7 +283,6 @@ module Legion
256
283
  :handled
257
284
  end
258
285
  end
259
- # rubocop:enable Metrics/ModuleLength
260
286
  end
261
287
  end
262
288
  end
@@ -4,7 +4,6 @@ module Legion
4
4
  module TTY
5
5
  module Screens
6
6
  class Chat < Base
7
- # rubocop:disable Metrics/ModuleLength
8
7
  module UiCommands
9
8
  TIPS = [
10
9
  'Press Tab after / to auto-complete commands',
@@ -31,10 +30,14 @@ module Legion
31
30
  'NAV : /dashboard /extensions /config /palette /hotkeys',
32
31
  'DISPLAY : /theme /plan /debug /context /time /uptime',
33
32
  'TOOLS : /tools /export /bookmark /pin /pins /alias /snippet /history',
33
+ 'UTILS : /calc /rand',
34
34
  '',
35
35
  'Hotkeys: Ctrl+D=dashboard Ctrl+K=palette Ctrl+S=sessions Esc=back'
36
36
  ].freeze
37
37
 
38
+ CALC_SAFE_PATTERN = %r{\A[\d\s+\-*/.()%]*\z}
39
+ CALC_MATH_PATTERN = %r{\A[\d\s+\-*/.()%]*(Math\.\w+\([\d\s+\-*/.()%,]*\)[\d\s+\-*/.()%]*)*\z}
40
+
38
41
  private
39
42
 
40
43
  def handle_help
@@ -314,6 +317,39 @@ module Legion
314
317
  :handled
315
318
  end
316
319
 
320
+ def handle_truncate(input)
321
+ arg = input.split(nil, 2)[1]&.strip
322
+ if arg.nil?
323
+ status = @message_stream.truncate_limit ? "#{@message_stream.truncate_limit} chars" : 'off'
324
+ @message_stream.add_message(role: :system, content: "Truncation: #{status}")
325
+ elsif arg == 'off'
326
+ @message_stream.truncate_limit = nil
327
+ @message_stream.add_message(role: :system, content: 'Truncation disabled.')
328
+ else
329
+ limit = arg.to_i
330
+ if limit.positive?
331
+ @message_stream.truncate_limit = limit
332
+ @message_stream.add_message(role: :system, content: "Truncation set to #{limit} chars.")
333
+ else
334
+ @message_stream.add_message(role: :system, content: 'Usage: /truncate [N|off]')
335
+ end
336
+ end
337
+ :handled
338
+ end
339
+
340
+ def handle_multiline
341
+ @multiline_mode = !@multiline_mode
342
+ if @multiline_mode
343
+ @status_bar.update(multiline: true)
344
+ @message_stream.add_message(role: :system,
345
+ content: 'Multi-line mode ON. Submit with empty line.')
346
+ else
347
+ @status_bar.update(multiline: false)
348
+ @message_stream.add_message(role: :system, content: 'Multi-line mode OFF.')
349
+ end
350
+ :handled
351
+ end
352
+
317
353
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
318
354
  def handle_scroll(input)
319
355
  arg = input.split(nil, 2)[1]
@@ -390,7 +426,56 @@ module Legion
390
426
  @message_stream.add_message(role: :system, content: "Highlight added: '#{pattern}'")
391
427
  end
392
428
 
393
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
429
+ def handle_calc(input)
430
+ expr = input.split(nil, 2)[1]&.strip
431
+ unless expr
432
+ @message_stream.add_message(role: :system, content: 'Usage: /calc <expression>')
433
+ return :handled
434
+ end
435
+
436
+ unless safe_calc_expr?(expr)
437
+ @message_stream.add_message(role: :system, content: "Unsafe expression blocked: #{expr}")
438
+ return :handled
439
+ end
440
+
441
+ result = binding.send(:eval, expr)
442
+ @message_stream.add_message(role: :system, content: "= #{result}")
443
+ :handled
444
+ rescue SyntaxError, ZeroDivisionError, Math::DomainError => e
445
+ @message_stream.add_message(role: :system, content: "Error: #{e.message}")
446
+ :handled
447
+ end
448
+
449
+ def handle_rand(input)
450
+ arg = input.split(nil, 2)[1]&.strip
451
+ result = parse_rand_arg(arg)
452
+ if result == :invalid
453
+ @message_stream.add_message(role: :system, content: 'Usage: /rand [N|min..max]')
454
+ return :handled
455
+ end
456
+
457
+ @message_stream.add_message(role: :system, content: "Random: #{result}")
458
+ :handled
459
+ end
460
+
461
+ def parse_rand_arg(arg)
462
+ if arg.nil? || arg.empty?
463
+ rand
464
+ elsif arg.match?(/\A\d+\.\.\d+\z/)
465
+ parts = arg.split('..').map(&:to_i)
466
+ rand(parts[0]..parts[1])
467
+ elsif arg.match?(/\A\d+\z/)
468
+ rand(arg.to_i)
469
+ else
470
+ :invalid
471
+ end
472
+ end
473
+
474
+ def safe_calc_expr?(expr)
475
+ CALC_SAFE_PATTERN.match?(expr) || CALC_MATH_PATTERN.match?(expr)
476
+ end
477
+
478
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
394
479
  def handle_summary
395
480
  msgs = @message_stream.messages
396
481
  elapsed = Time.now - @session_start
@@ -423,9 +508,40 @@ module Legion
423
508
  @message_stream.add_message(role: :system, content: lines.join("\n"))
424
509
  :handled
425
510
  end
426
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
511
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
512
+
513
+ def handle_pipe(input)
514
+ cmd = input.split(nil, 2)[1]
515
+ unless cmd
516
+ @message_stream.add_message(role: :system, content: 'Usage: /pipe <shell command>')
517
+ return :handled
518
+ end
519
+
520
+ last_msg = @message_stream.messages.select { |m| m[:role] == :assistant }.last
521
+ unless last_msg
522
+ @message_stream.add_message(role: :system, content: 'No assistant message to pipe.')
523
+ return :handled
524
+ end
525
+
526
+ output = pipe_through_command(cmd, last_msg[:content].to_s)
527
+ @message_stream.add_message(role: :system, content: "pipe | #{cmd}:\n#{output}")
528
+ :handled
529
+ rescue StandardError => e
530
+ @message_stream.add_message(role: :system, content: "Pipe error: #{e.message}")
531
+ :handled
532
+ end
533
+
534
+ def pipe_through_command(cmd, content)
535
+ result = IO.popen(cmd, 'r+') do |io|
536
+ io.write(content)
537
+ io.close_write
538
+ io.read
539
+ end
540
+ result.to_s.chomp
541
+ rescue StandardError => e
542
+ raise "command failed: #{e.message}"
543
+ end
427
544
  end
428
- # rubocop:enable Metrics/ModuleLength
429
545
  end
430
546
  end
431
547
  end
@@ -33,7 +33,11 @@ module Legion
33
33
  /template /fav /favs /log /version
34
34
  /focus /retry /merge /sort
35
35
  /chain /info /scroll /summary
36
- /prompt /reset /replace /highlight].freeze
36
+ /prompt /reset /replace /highlight /multiline
37
+ /annotate /annotations /filter /truncate
38
+ /tee /pipe
39
+ /archive /archives
40
+ /calc /rand].freeze
37
41
 
38
42
  PERSONALITIES = {
39
43
  'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
@@ -74,6 +78,7 @@ module Legion
74
78
  @focus_mode = false
75
79
  @last_user_input = nil
76
80
  @highlights = []
81
+ @multiline_mode = false
77
82
  end
78
83
 
79
84
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -94,7 +99,7 @@ module Legion
94
99
  @running
95
100
  end
96
101
 
97
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
102
+ # rubocop:disable Metrics/AbcSize
98
103
  def run
99
104
  activate
100
105
  while @running
@@ -117,7 +122,7 @@ module Legion
117
122
  end
118
123
  end
119
124
  end
120
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
125
+ # rubocop:enable Metrics/AbcSize
121
126
 
122
127
  def handle_slash_command(input)
123
128
  return nil unless input.start_with?('/')
@@ -139,6 +144,7 @@ module Legion
139
144
  def handle_user_message(input)
140
145
  @last_user_input = input
141
146
  @message_stream.add_message(role: :user, content: input)
147
+ tee_message("[user] #{input}") if @tee_path
142
148
  if @plan_mode
143
149
  @message_stream.add_message(role: :system, content: '(bookmarked)')
144
150
  else
@@ -339,12 +345,27 @@ module Legion
339
345
 
340
346
  def read_input
341
347
  return nil unless @input_bar.respond_to?(:read_line)
348
+ return read_multiline_input if @multiline_mode
342
349
 
343
350
  @input_bar.read_line
344
351
  rescue Interrupt
345
352
  nil
346
353
  end
347
354
 
355
+ def read_multiline_input
356
+ lines = []
357
+ loop do
358
+ line = @input_bar.read_line
359
+ return nil if line.nil? && lines.empty?
360
+ break if line.nil? || line.empty?
361
+
362
+ lines << line
363
+ end
364
+ lines.empty? ? nil : lines.join("\n")
365
+ rescue Interrupt
366
+ nil
367
+ end
368
+
348
369
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
349
370
  def dispatch_slash(cmd, input)
350
371
  case cmd
@@ -416,6 +437,17 @@ module Legion
416
437
  when '/reset' then handle_reset
417
438
  when '/replace' then handle_replace(input)
418
439
  when '/highlight' then handle_highlight(input)
440
+ when '/multiline' then handle_multiline
441
+ when '/annotate' then handle_annotate(input)
442
+ when '/annotations' then handle_annotations(input)
443
+ when '/filter' then handle_filter(input)
444
+ when '/truncate' then handle_truncate(input)
445
+ when '/tee' then handle_tee(input)
446
+ when '/pipe' then handle_pipe(input)
447
+ when '/archive' then handle_archive(input)
448
+ when '/archives' then handle_archives
449
+ when '/calc' then handle_calc(input)
450
+ when '/rand' then handle_rand(input)
419
451
  else :handled
420
452
  end
421
453
  end
@@ -500,6 +532,7 @@ module Legion
500
532
  "[DEBUG] msgs:#{@message_stream.messages.size} " \
501
533
  "scroll:#{@message_stream.scroll_position&.dig(:current) || 0} " \
502
534
  "plan:#{@plan_mode} " \
535
+ "multiline:#{@multiline_mode} " \
503
536
  "personality:#{@personality || 'default'} " \
504
537
  "aliases:#{@aliases.size} " \
505
538
  "snippets:#{@snippets.size} " \
@@ -105,7 +105,7 @@ module Legion
105
105
  @viewing_file = true
106
106
  end
107
107
 
108
- def edit_selected_key # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
108
+ def edit_selected_key # rubocop:disable Metrics/AbcSize
109
109
  keys = @file_data.keys
110
110
  return unless keys[@selected_key]
111
111
 
@@ -40,7 +40,7 @@ module Legion
40
40
 
41
41
  # rubocop:enable Metrics/AbcSize
42
42
 
43
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
43
+ # rubocop:disable Metrics/CyclomaticComplexity
44
44
  def handle_input(key)
45
45
  case key
46
46
  when 'r', :f5
@@ -66,7 +66,7 @@ module Legion
66
66
  end
67
67
  end
68
68
 
69
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
69
+ # rubocop:enable Metrics/CyclomaticComplexity
70
70
 
71
71
  def selected_panel
72
72
  PANELS[@selected_panel]
@@ -147,7 +147,7 @@ module Legion
147
147
 
148
148
  # rubocop:enable Metrics/AbcSize
149
149
 
150
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
150
+ # rubocop:disable Metrics/AbcSize
151
151
  def render_system_panel(_width)
152
152
  sys = @cached_data[:system] || {}
153
153
  prefix = panel_prefix(:system)
@@ -161,7 +161,7 @@ module Legion
161
161
  lines
162
162
  end
163
163
 
164
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
164
+ # rubocop:enable Metrics/AbcSize
165
165
 
166
166
  def render_activity_panel(_width, max_lines)
167
167
  activity = @cached_data[:activity] || []
@@ -219,7 +219,7 @@ module Legion
219
219
  :pass
220
220
  end
221
221
 
222
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
222
+ # rubocop:disable Metrics/AbcSize
223
223
  def llm_info
224
224
  info = { provider: 'none', model: nil, started: false, daemon: false }
225
225
  if defined?(Legion::LLM)
@@ -237,7 +237,7 @@ module Legion
237
237
  info
238
238
  end
239
239
 
240
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
240
+ # rubocop:enable Metrics/AbcSize
241
241
 
242
242
  def probe_services
243
243
  require 'socket'
@@ -195,7 +195,7 @@ module Legion
195
195
  end
196
196
  end
197
197
 
198
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
198
+ # rubocop:disable Metrics/AbcSize
199
199
  def run_gaia_awakening
200
200
  typed_output('Scanning for active cognition threads...')
201
201
  sleep 1.2
@@ -225,7 +225,7 @@ module Legion
225
225
 
226
226
  @output.puts
227
227
  end
228
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
228
+ # rubocop:enable Metrics/AbcSize
229
229
 
230
230
  def collect_background_results
231
231
  @log.log('collect', 'waiting for scanner results (10s timeout)')
@@ -438,7 +438,7 @@ module Legion
438
438
  end
439
439
  end
440
440
 
441
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
441
+ # rubocop:disable Metrics/AbcSize
442
442
  def run_intro_with_github
443
443
  gh = @github_quick
444
444
  name = gh[:name] || gh[:username]
@@ -462,7 +462,7 @@ module Legion
462
462
 
463
463
  @output.puts
464
464
  end
465
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
465
+ # rubocop:enable Metrics/AbcSize
466
466
 
467
467
  def collect_kerberos_identity
468
468
  @log.log('kerberos', 'collecting identity (2s timeout)')
@@ -475,7 +475,7 @@ module Legion
475
475
  end
476
476
  end
477
477
 
478
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
478
+ # rubocop:disable Metrics/AbcSize
479
479
  def run_intro_with_identity
480
480
  id = @kerberos_identity
481
481
  typed_output("I see you, #{id[:first_name]}.")
@@ -505,7 +505,7 @@ module Legion
505
505
  @output.puts
506
506
  @output.puts
507
507
  end
508
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
508
+ # rubocop:enable Metrics/AbcSize
509
509
 
510
510
  def ask_for_name
511
511
  if @kerberos_identity
@@ -515,7 +515,7 @@ module Legion
515
515
  end
516
516
  end
517
517
 
518
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
518
+ # rubocop:disable Metrics/AbcSize
519
519
  def identity_summary_lines
520
520
  return [] unless @kerberos_identity
521
521
 
@@ -529,7 +529,7 @@ module Legion
529
529
  lines << " Email: #{id[:email]}" if id[:email]
530
530
  lines
531
531
  end
532
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
532
+ # rubocop:enable Metrics/AbcSize
533
533
 
534
534
  def scan_summary_lines(scan_data)
535
535
  return [] unless scan_data.is_a?(Hash)
@@ -622,7 +622,7 @@ module Legion
622
622
  ['', "Terraform: #{dotfiles_tf[:hosts].join(', ')}"]
623
623
  end
624
624
 
625
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
625
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
626
626
  def github_summary_lines(github_data)
627
627
  return [] unless github_data.is_a?(Hash)
628
628
 
@@ -655,9 +655,9 @@ module Legion
655
655
 
656
656
  lines
657
657
  end
658
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/MethodLength
658
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
659
659
 
660
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
660
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
661
661
  def format_tenure(tenure)
662
662
  return tenure.to_s unless tenure.is_a?(Hash)
663
663
 
@@ -670,7 +670,7 @@ module Legion
670
670
  parts << "#{d} day#{'s' if d != 1}" if d&.positive?
671
671
  parts.join(', ')
672
672
  end
673
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
673
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
674
674
 
675
675
  def typed_output(text, delay: TYPED_DELAY)
676
676
  text.chars.each do |char|
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- # rubocop:disable Metrics/ModuleLength
6
5
  module Theme
7
6
  # rubocop:disable Naming/VariableNumber
8
7
  THEMES = {
@@ -125,6 +124,5 @@ module Legion
125
124
  end
126
125
  end
127
126
  end
128
- # rubocop:enable Metrics/ModuleLength
129
127
  end
130
128
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module TTY
5
- VERSION = '0.4.20'
5
+ VERSION = '0.4.22'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-tty
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.20
4
+ version: 0.4.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity