legion-tty 0.4.19 → 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 +4 -4
- data/CHANGELOG.md +30 -0
- data/lib/legion/tty/app.rb +1 -1
- data/lib/legion/tty/background/github_probe.rb +2 -7
- data/lib/legion/tty/background/kerberos_probe.rb +2 -4
- data/lib/legion/tty/background/scanner.rb +0 -2
- data/lib/legion/tty/components/message_stream.rb +53 -4
- data/lib/legion/tty/components/model_picker.rb +1 -1
- data/lib/legion/tty/components/status_bar.rb +8 -1
- data/lib/legion/tty/screens/chat/custom_commands.rb +99 -8
- data/lib/legion/tty/screens/chat/export_commands.rb +47 -4
- data/lib/legion/tty/screens/chat/message_commands.rb +151 -4
- data/lib/legion/tty/screens/chat/model_commands.rb +0 -2
- data/lib/legion/tty/screens/chat/session_commands.rb +50 -2
- data/lib/legion/tty/screens/chat/ui_commands.rb +159 -4
- data/lib/legion/tty/screens/chat.rb +42 -3
- data/lib/legion/tty/screens/config.rb +1 -1
- data/lib/legion/tty/screens/dashboard.rb +6 -6
- data/lib/legion/tty/screens/onboarding.rb +12 -12
- data/lib/legion/tty/theme.rb +0 -2
- 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: 45ef88ce519140b5056ce0b934f3c3512781a4f26e30dc74203c431e6f20cc3c
|
|
4
|
+
data.tar.gz: ba632f83ea2022683c097b5451849bf129287c46959a050a8db90b356abe1f29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: affafac87b03a47e29ae08bfe5815ba9eb8f72de5fc3feda9350b2871021537f6319261177de1a20f349b8d38cda3295d6b5cca6ff941dbe9414744fa27562e5
|
|
7
|
+
data.tar.gz: bf14853b34688cd959cf6301a56542ec9448fc9f4db2fb2489e7c6aeec3d56e08fc6e44c1627dc31c33631a8b9291717786032dee7ab72b566c545693ca2b57e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
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
|
+
|
|
25
|
+
## [0.4.20] - 2026-03-19
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- `/prompt save|load|list|delete` command: persist and reuse custom system prompts
|
|
29
|
+
- `/reset` command: reset session to clean state (clears messages, modes, aliases, macros)
|
|
30
|
+
- `/replace old >>> new` command: find and replace text across all messages
|
|
31
|
+
- `/highlight` command: highlight text patterns in message rendering with ANSI color
|
|
32
|
+
|
|
3
33
|
## [0.4.19] - 2026-03-19
|
|
4
34
|
|
|
5
35
|
### Added
|
data/lib/legion/tty/app.rb
CHANGED
|
@@ -132,7 +132,7 @@ module Legion
|
|
|
132
132
|
|
|
133
133
|
private
|
|
134
134
|
|
|
135
|
-
def boot_legion_subsystems
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'English'
|
|
3
4
|
require_relative '../theme'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
@@ -8,12 +9,16 @@ module Legion
|
|
|
8
9
|
# rubocop:disable Metrics/ClassLength
|
|
9
10
|
class MessageStream
|
|
10
11
|
attr_reader :messages, :scroll_offset
|
|
11
|
-
attr_accessor :mute_system
|
|
12
|
+
attr_accessor :mute_system, :highlights, :filter, :truncate_limit
|
|
13
|
+
|
|
14
|
+
HIGHLIGHT_COLOR = "\e[1;33m"
|
|
15
|
+
HIGHLIGHT_RESET = "\e[0m"
|
|
12
16
|
|
|
13
17
|
def initialize
|
|
14
18
|
@messages = []
|
|
15
19
|
@scroll_offset = 0
|
|
16
20
|
@mute_system = false
|
|
21
|
+
@highlights = []
|
|
17
22
|
end
|
|
18
23
|
|
|
19
24
|
def add_message(role:, content:)
|
|
@@ -72,13 +77,28 @@ module Legion
|
|
|
72
77
|
private
|
|
73
78
|
|
|
74
79
|
def build_all_lines(width)
|
|
75
|
-
|
|
80
|
+
filtered_messages.flat_map do |msg|
|
|
76
81
|
next [] if @mute_system && msg[:role] == :system
|
|
77
82
|
|
|
78
83
|
render_message(msg, width)
|
|
79
84
|
end
|
|
80
85
|
end
|
|
81
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
|
+
|
|
82
102
|
def render_message(msg, width)
|
|
83
103
|
role_lines(msg, width) + panel_lines(msg, width)
|
|
84
104
|
end
|
|
@@ -96,8 +116,10 @@ module Legion
|
|
|
96
116
|
def user_lines(msg, _width)
|
|
97
117
|
ts = format_timestamp(msg[:timestamp])
|
|
98
118
|
header = "#{Theme.c(:accent, 'You')} #{Theme.c(:muted, ts)}"
|
|
99
|
-
|
|
119
|
+
content = apply_highlights(msg[:content].to_s)
|
|
120
|
+
lines = ['', "#{header}: #{content}"]
|
|
100
121
|
lines << reaction_line(msg) if msg[:reactions]&.any?
|
|
122
|
+
lines.concat(annotation_lines(msg)) if msg[:annotations]&.any?
|
|
101
123
|
lines
|
|
102
124
|
end
|
|
103
125
|
|
|
@@ -108,17 +130,34 @@ module Legion
|
|
|
108
130
|
end
|
|
109
131
|
|
|
110
132
|
def assistant_lines(msg, width)
|
|
111
|
-
|
|
133
|
+
content = display_content(msg[:content])
|
|
134
|
+
rendered = render_markdown(content, width)
|
|
135
|
+
rendered = apply_highlights(rendered)
|
|
112
136
|
lines = ['', *rendered.split("\n")]
|
|
113
137
|
lines << reaction_line(msg) if msg[:reactions]&.any?
|
|
138
|
+
lines.concat(annotation_lines(msg)) if msg[:annotations]&.any?
|
|
114
139
|
lines
|
|
115
140
|
end
|
|
116
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
|
+
|
|
117
149
|
def reaction_line(msg)
|
|
118
150
|
reactions = msg[:reactions].map { |r| "[#{r}]" }.join(' ')
|
|
119
151
|
" #{Theme.c(:muted, reactions)}"
|
|
120
152
|
end
|
|
121
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
|
+
|
|
122
161
|
def render_markdown(text, width)
|
|
123
162
|
require_relative 'markdown_view'
|
|
124
163
|
MarkdownView.render(text, width: width)
|
|
@@ -140,6 +179,16 @@ module Legion
|
|
|
140
179
|
msg[:tool_panels].flat_map { |panel| panel.render(width: width).split("\n") }
|
|
141
180
|
end
|
|
142
181
|
|
|
182
|
+
def apply_highlights(text)
|
|
183
|
+
return text if @highlights.nil? || @highlights.empty?
|
|
184
|
+
|
|
185
|
+
@highlights.reduce(text) do |result, pattern|
|
|
186
|
+
result.gsub(pattern) { "#{HIGHLIGHT_COLOR}#{$LAST_MATCH_INFO}#{HIGHLIGHT_RESET}" }
|
|
187
|
+
end
|
|
188
|
+
rescue StandardError
|
|
189
|
+
text
|
|
190
|
+
end
|
|
191
|
+
|
|
143
192
|
def apply_tool_panel_update(panel, status:, duration:, result:, error:)
|
|
144
193
|
panel.instance_variable_set(:@status, status)
|
|
145
194
|
panel.instance_variable_set(:@duration, duration) if duration
|
|
@@ -9,7 +9,7 @@ module Legion
|
|
|
9
9
|
@current_model = current_model
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def available_models
|
|
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)
|
|
@@ -97,6 +94,103 @@ module Legion
|
|
|
97
94
|
:handled
|
|
98
95
|
end
|
|
99
96
|
|
|
97
|
+
def handle_prompt(input)
|
|
98
|
+
parts = input.split(nil, 3)
|
|
99
|
+
subcommand = parts[1]
|
|
100
|
+
name = parts[2]
|
|
101
|
+
|
|
102
|
+
case subcommand
|
|
103
|
+
when 'save'
|
|
104
|
+
prompt_save(name)
|
|
105
|
+
when 'load'
|
|
106
|
+
prompt_load(name)
|
|
107
|
+
when 'list'
|
|
108
|
+
prompt_list
|
|
109
|
+
when 'delete'
|
|
110
|
+
prompt_delete(name)
|
|
111
|
+
else
|
|
112
|
+
@message_stream.add_message(
|
|
113
|
+
role: :system,
|
|
114
|
+
content: 'Usage: /prompt save|load|list|delete <name>'
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
:handled
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def prompt_dir
|
|
121
|
+
File.expand_path('~/.legionio/prompts')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def prompt_save(name)
|
|
125
|
+
unless name
|
|
126
|
+
@message_stream.add_message(role: :system, content: 'Usage: /prompt save <name>')
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
current = @llm_chat.respond_to?(:instructions) ? @llm_chat.instructions.to_s : ''
|
|
131
|
+
if current.empty?
|
|
132
|
+
@message_stream.add_message(role: :system, content: 'No system prompt is currently set.')
|
|
133
|
+
return
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
require 'fileutils'
|
|
137
|
+
FileUtils.mkdir_p(prompt_dir)
|
|
138
|
+
path = File.join(prompt_dir, "#{name}.txt")
|
|
139
|
+
File.write(path, current)
|
|
140
|
+
@message_stream.add_message(role: :system, content: "Prompt '#{name}' saved.")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def prompt_load(name)
|
|
144
|
+
unless name
|
|
145
|
+
@message_stream.add_message(role: :system, content: 'Usage: /prompt load <name>')
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
path = File.join(prompt_dir, "#{name}.txt")
|
|
150
|
+
unless File.exist?(path)
|
|
151
|
+
@message_stream.add_message(role: :system, content: "Prompt '#{name}' not found.")
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
content = File.read(path)
|
|
156
|
+
@llm_chat.with_instructions(content) if @llm_chat.respond_to?(:with_instructions)
|
|
157
|
+
@message_stream.add_message(role: :system, content: "Prompt '#{name}' loaded as system prompt.")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# rubocop:disable Metrics/AbcSize
|
|
161
|
+
def prompt_list
|
|
162
|
+
disk_prompts = Dir.glob(File.join(prompt_dir, '*.txt')).map { |f| File.basename(f, '.txt') }.sort
|
|
163
|
+
|
|
164
|
+
if disk_prompts.empty?
|
|
165
|
+
@message_stream.add_message(role: :system, content: 'No prompts saved.')
|
|
166
|
+
return
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
lines = disk_prompts.map do |pname|
|
|
170
|
+
path = File.join(prompt_dir, "#{pname}.txt")
|
|
171
|
+
preview = File.exist?(path) ? truncate_text(File.read(path), 60) : ''
|
|
172
|
+
" #{pname}: #{preview}"
|
|
173
|
+
end
|
|
174
|
+
@message_stream.add_message(role: :system,
|
|
175
|
+
content: "Prompts (#{disk_prompts.size}):\n#{lines.join("\n")}")
|
|
176
|
+
end
|
|
177
|
+
# rubocop:enable Metrics/AbcSize
|
|
178
|
+
|
|
179
|
+
def prompt_delete(name)
|
|
180
|
+
unless name
|
|
181
|
+
@message_stream.add_message(role: :system, content: 'Usage: /prompt delete <name>')
|
|
182
|
+
return
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
path = File.join(prompt_dir, "#{name}.txt")
|
|
186
|
+
if File.exist?(path)
|
|
187
|
+
File.delete(path)
|
|
188
|
+
@message_stream.add_message(role: :system, content: "Prompt '#{name}' deleted.")
|
|
189
|
+
else
|
|
190
|
+
@message_stream.add_message(role: :system, content: "Prompt '#{name}' not found.")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
100
194
|
def snippet_dir
|
|
101
195
|
File.expand_path('~/.legionio/snippets')
|
|
102
196
|
end
|
|
@@ -166,7 +260,6 @@ module Legion
|
|
|
166
260
|
end
|
|
167
261
|
# rubocop:enable Metrics/AbcSize
|
|
168
262
|
|
|
169
|
-
# rubocop:disable Metrics/MethodLength
|
|
170
263
|
def handle_macro(input)
|
|
171
264
|
parts = input.split(nil, 3)
|
|
172
265
|
subcommand = parts[1]
|
|
@@ -191,7 +284,6 @@ module Legion
|
|
|
191
284
|
end
|
|
192
285
|
:handled
|
|
193
286
|
end
|
|
194
|
-
# rubocop:enable Metrics/MethodLength
|
|
195
287
|
|
|
196
288
|
def macro_record(name)
|
|
197
289
|
unless name
|
|
@@ -281,7 +373,7 @@ module Legion
|
|
|
281
373
|
end
|
|
282
374
|
end
|
|
283
375
|
|
|
284
|
-
# rubocop:disable Metrics/AbcSize, Metrics/
|
|
376
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
285
377
|
def handle_chain(input)
|
|
286
378
|
args = input.split(nil, 2)[1]
|
|
287
379
|
unless args
|
|
@@ -315,9 +407,8 @@ module Legion
|
|
|
315
407
|
)
|
|
316
408
|
:handled
|
|
317
409
|
end
|
|
318
|
-
# rubocop:enable Metrics/AbcSize, Metrics/
|
|
410
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
319
411
|
end
|
|
320
|
-
# rubocop:enable Metrics/ModuleLength
|
|
321
412
|
end
|
|
322
413
|
end
|
|
323
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
|
-
|
|
33
|
+
case format
|
|
34
|
+
when 'json'
|
|
34
35
|
export_json(path)
|
|
35
|
-
|
|
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
|
|
|
@@ -248,6 +247,41 @@ module Legion
|
|
|
248
247
|
:handled
|
|
249
248
|
end
|
|
250
249
|
|
|
250
|
+
def handle_replace(input)
|
|
251
|
+
args = input.split(nil, 2)[1]
|
|
252
|
+
unless args&.include?(' >>> ')
|
|
253
|
+
@message_stream.add_message(role: :system, content: 'Usage: /replace old >>> new')
|
|
254
|
+
return :handled
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
parts = args.split(' >>> ', 2)
|
|
258
|
+
count = apply_replace(parts[0], parts[1] || '')
|
|
259
|
+
report_replace_result(count, parts[0], parts[1] || '')
|
|
260
|
+
:handled
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def apply_replace(old_text, new_text)
|
|
264
|
+
count = 0
|
|
265
|
+
@message_stream.messages.each do |msg|
|
|
266
|
+
next unless msg[:content].is_a?(::String) && msg[:content].include?(old_text)
|
|
267
|
+
|
|
268
|
+
count += msg[:content].scan(old_text).size
|
|
269
|
+
msg[:content] = msg[:content].gsub(old_text, new_text)
|
|
270
|
+
end
|
|
271
|
+
count
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def report_replace_result(count, old_text, new_text)
|
|
275
|
+
if count.zero?
|
|
276
|
+
@message_stream.add_message(role: :system, content: "No occurrences of '#{old_text}' found.")
|
|
277
|
+
else
|
|
278
|
+
@message_stream.add_message(
|
|
279
|
+
role: :system,
|
|
280
|
+
content: "Replaced #{count} occurrence#{'s' unless count == 1} of '#{old_text}' with '#{new_text}'."
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
251
285
|
def search_messages(query)
|
|
252
286
|
pattern = query.downcase
|
|
253
287
|
@message_stream.messages.select do |msg|
|
|
@@ -367,7 +401,7 @@ module Legion
|
|
|
367
401
|
File.write(favorites_file, ::JSON.generate(favs))
|
|
368
402
|
end
|
|
369
403
|
|
|
370
|
-
# rubocop:disable Metrics/AbcSize
|
|
404
|
+
# rubocop:disable Metrics/AbcSize
|
|
371
405
|
def handle_fav(input)
|
|
372
406
|
idx_str = input.split(nil, 2)[1]
|
|
373
407
|
msg = if idx_str
|
|
@@ -393,7 +427,51 @@ module Legion
|
|
|
393
427
|
@message_stream.add_message(role: :system, content: "Favorited: #{preview}")
|
|
394
428
|
:handled
|
|
395
429
|
end
|
|
396
|
-
# rubocop:enable Metrics/AbcSize
|
|
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
|
|
397
475
|
|
|
398
476
|
def handle_favs
|
|
399
477
|
all_favs = load_favorites
|
|
@@ -412,8 +490,77 @@ module Legion
|
|
|
412
490
|
)
|
|
413
491
|
:handled
|
|
414
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
|
|
415
563
|
end
|
|
416
|
-
# rubocop:enable Metrics/ModuleLength
|
|
417
564
|
end
|
|
418
565
|
end
|
|
419
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
|
|
|
@@ -211,6 +210,56 @@ module Legion
|
|
|
211
210
|
end
|
|
212
211
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
213
212
|
|
|
213
|
+
def handle_reset
|
|
214
|
+
@message_stream.messages.clear
|
|
215
|
+
@plan_mode = false
|
|
216
|
+
@focus_mode = false
|
|
217
|
+
@debug_mode = false
|
|
218
|
+
@muted_system = false
|
|
219
|
+
@pinned_messages = []
|
|
220
|
+
@aliases = {}
|
|
221
|
+
@macros = {}
|
|
222
|
+
@recording_macro = nil
|
|
223
|
+
@macro_buffer = []
|
|
224
|
+
@session_name = 'default'
|
|
225
|
+
@status_bar.update(session: 'default', plan_mode: false, debug_mode: false)
|
|
226
|
+
cfg = safe_config
|
|
227
|
+
@message_stream.add_message(
|
|
228
|
+
role: :system,
|
|
229
|
+
content: "Welcome#{", #{cfg[:name]}" if cfg[:name]}. Type /help for commands."
|
|
230
|
+
)
|
|
231
|
+
@status_bar.notify(message: 'Session reset', level: :info, ttl: 3)
|
|
232
|
+
:handled
|
|
233
|
+
end
|
|
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
|
+
|
|
214
263
|
def handle_merge(input)
|
|
215
264
|
name = input.split(nil, 2)[1]
|
|
216
265
|
unless name
|
|
@@ -234,7 +283,6 @@ module Legion
|
|
|
234
283
|
:handled
|
|
235
284
|
end
|
|
236
285
|
end
|
|
237
|
-
# rubocop:enable Metrics/ModuleLength
|
|
238
286
|
end
|
|
239
287
|
end
|
|
240
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]
|
|
@@ -351,7 +387,95 @@ module Legion
|
|
|
351
387
|
end
|
|
352
388
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
353
389
|
|
|
354
|
-
|
|
390
|
+
def handle_highlight(input)
|
|
391
|
+
arg = input.split(nil, 2)[1]
|
|
392
|
+
@highlights ||= []
|
|
393
|
+
|
|
394
|
+
unless arg
|
|
395
|
+
@message_stream.add_message(role: :system, content: 'Usage: /highlight <pattern> | clear | list')
|
|
396
|
+
return :handled
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
case arg.strip
|
|
400
|
+
when 'clear' then highlight_clear
|
|
401
|
+
when 'list' then highlight_list
|
|
402
|
+
else highlight_add(arg.strip)
|
|
403
|
+
end
|
|
404
|
+
:handled
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def highlight_clear
|
|
408
|
+
@highlights = []
|
|
409
|
+
@message_stream.highlights = @highlights
|
|
410
|
+
@message_stream.add_message(role: :system, content: 'Highlights cleared.')
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def highlight_list
|
|
414
|
+
if @highlights.empty?
|
|
415
|
+
@message_stream.add_message(role: :system, content: 'No active highlights.')
|
|
416
|
+
else
|
|
417
|
+
lines = @highlights.each_with_index.map { |p, i| " #{i + 1}. #{p}" }
|
|
418
|
+
@message_stream.add_message(role: :system,
|
|
419
|
+
content: "Active highlights (#{@highlights.size}):\n#{lines.join("\n")}")
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def highlight_add(pattern)
|
|
424
|
+
@highlights << pattern
|
|
425
|
+
@message_stream.highlights = @highlights
|
|
426
|
+
@message_stream.add_message(role: :system, content: "Highlight added: '#{pattern}'")
|
|
427
|
+
end
|
|
428
|
+
|
|
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
|
|
355
479
|
def handle_summary
|
|
356
480
|
msgs = @message_stream.messages
|
|
357
481
|
elapsed = Time.now - @session_start
|
|
@@ -384,9 +508,40 @@ module Legion
|
|
|
384
508
|
@message_stream.add_message(role: :system, content: lines.join("\n"))
|
|
385
509
|
:handled
|
|
386
510
|
end
|
|
387
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/
|
|
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
|
|
388
544
|
end
|
|
389
|
-
# rubocop:enable Metrics/ModuleLength
|
|
390
545
|
end
|
|
391
546
|
end
|
|
392
547
|
end
|
|
@@ -32,7 +32,12 @@ module Legion
|
|
|
32
32
|
/wc /import /mute /autosave /react /macro /tag /tags /repeat /count
|
|
33
33
|
/template /fav /favs /log /version
|
|
34
34
|
/focus /retry /merge /sort
|
|
35
|
-
/chain /info /scroll /summary
|
|
35
|
+
/chain /info /scroll /summary
|
|
36
|
+
/prompt /reset /replace /highlight /multiline
|
|
37
|
+
/annotate /annotations /filter /truncate
|
|
38
|
+
/tee /pipe
|
|
39
|
+
/archive /archives
|
|
40
|
+
/calc /rand].freeze
|
|
36
41
|
|
|
37
42
|
PERSONALITIES = {
|
|
38
43
|
'default' => 'You are Legion, an async cognition engine and AI assistant. Be helpful and concise.',
|
|
@@ -72,6 +77,8 @@ module Legion
|
|
|
72
77
|
@last_command = nil
|
|
73
78
|
@focus_mode = false
|
|
74
79
|
@last_user_input = nil
|
|
80
|
+
@highlights = []
|
|
81
|
+
@multiline_mode = false
|
|
75
82
|
end
|
|
76
83
|
|
|
77
84
|
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
@@ -92,7 +99,7 @@ module Legion
|
|
|
92
99
|
@running
|
|
93
100
|
end
|
|
94
101
|
|
|
95
|
-
# rubocop:disable Metrics/AbcSize
|
|
102
|
+
# rubocop:disable Metrics/AbcSize
|
|
96
103
|
def run
|
|
97
104
|
activate
|
|
98
105
|
while @running
|
|
@@ -115,7 +122,7 @@ module Legion
|
|
|
115
122
|
end
|
|
116
123
|
end
|
|
117
124
|
end
|
|
118
|
-
# rubocop:enable Metrics/AbcSize
|
|
125
|
+
# rubocop:enable Metrics/AbcSize
|
|
119
126
|
|
|
120
127
|
def handle_slash_command(input)
|
|
121
128
|
return nil unless input.start_with?('/')
|
|
@@ -137,6 +144,7 @@ module Legion
|
|
|
137
144
|
def handle_user_message(input)
|
|
138
145
|
@last_user_input = input
|
|
139
146
|
@message_stream.add_message(role: :user, content: input)
|
|
147
|
+
tee_message("[user] #{input}") if @tee_path
|
|
140
148
|
if @plan_mode
|
|
141
149
|
@message_stream.add_message(role: :system, content: '(bookmarked)')
|
|
142
150
|
else
|
|
@@ -337,12 +345,27 @@ module Legion
|
|
|
337
345
|
|
|
338
346
|
def read_input
|
|
339
347
|
return nil unless @input_bar.respond_to?(:read_line)
|
|
348
|
+
return read_multiline_input if @multiline_mode
|
|
340
349
|
|
|
341
350
|
@input_bar.read_line
|
|
342
351
|
rescue Interrupt
|
|
343
352
|
nil
|
|
344
353
|
end
|
|
345
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
|
+
|
|
346
369
|
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
347
370
|
def dispatch_slash(cmd, input)
|
|
348
371
|
case cmd
|
|
@@ -410,6 +433,21 @@ module Legion
|
|
|
410
433
|
when '/info' then handle_info
|
|
411
434
|
when '/scroll' then handle_scroll(input)
|
|
412
435
|
when '/summary' then handle_summary
|
|
436
|
+
when '/prompt' then handle_prompt(input)
|
|
437
|
+
when '/reset' then handle_reset
|
|
438
|
+
when '/replace' then handle_replace(input)
|
|
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)
|
|
413
451
|
else :handled
|
|
414
452
|
end
|
|
415
453
|
end
|
|
@@ -494,6 +532,7 @@ module Legion
|
|
|
494
532
|
"[DEBUG] msgs:#{@message_stream.messages.size} " \
|
|
495
533
|
"scroll:#{@message_stream.scroll_position&.dig(:current) || 0} " \
|
|
496
534
|
"plan:#{@plan_mode} " \
|
|
535
|
+
"multiline:#{@multiline_mode} " \
|
|
497
536
|
"personality:#{@personality || 'default'} " \
|
|
498
537
|
"aliases:#{@aliases.size} " \
|
|
499
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
658
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
659
659
|
|
|
660
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
|
|
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
|
|
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|
|
data/lib/legion/tty/theme.rb
CHANGED
|
@@ -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
|
data/lib/legion/tty/version.rb
CHANGED