fantasy-cli 1.2.14 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +8 -1
- data/lib/gsd/agents/communication.rb +260 -0
- data/lib/gsd/agents/swarm.rb +341 -0
- data/lib/gsd/lsp/client.rb +394 -0
- data/lib/gsd/lsp/completion.rb +266 -0
- data/lib/gsd/lsp/diagnostics.rb +259 -0
- data/lib/gsd/lsp/hover.rb +244 -0
- data/lib/gsd/lsp/protocol.rb +434 -0
- data/lib/gsd/lsp/server_manager.rb +290 -0
- data/lib/gsd/lsp/symbols.rb +368 -0
- data/lib/gsd/plugins/api.rb +340 -0
- data/lib/gsd/plugins/hooks.rb +117 -95
- data/lib/gsd/plugins/hot_reload.rb +293 -0
- data/lib/gsd/plugins/registry.rb +273 -0
- data/lib/gsd/tui/agent_panel.rb +182 -0
- data/lib/gsd/tui/animations.rb +320 -0
- data/lib/gsd/tui/app.rb +442 -2
- data/lib/gsd/tui/colors.rb +15 -0
- data/lib/gsd/tui/effects.rb +263 -0
- data/lib/gsd/tui/header.rb +13 -5
- data/lib/gsd/tui/input_box.rb +10 -7
- data/lib/gsd/tui/mouse.rb +388 -0
- data/lib/gsd/tui/persistence.rb +192 -0
- data/lib/gsd/tui/session.rb +273 -0
- data/lib/gsd/tui/status_bar.rb +63 -15
- data/lib/gsd/tui/tab.rb +112 -0
- data/lib/gsd/tui/tab_manager.rb +191 -0
- data/lib/gsd/tui/transitions.rb +262 -0
- data/lib/gsd/version.rb +1 -1
- metadata +22 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsd
|
|
4
|
+
module LSP
|
|
5
|
+
# Diagnostics - Manages LSP diagnostics display
|
|
6
|
+
class Diagnostics
|
|
7
|
+
attr_reader :by_file, :max_diagnostics
|
|
8
|
+
|
|
9
|
+
def initialize(max: 100)
|
|
10
|
+
@by_file = {}
|
|
11
|
+
@max_diagnostics = max
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Add/update diagnostics for a file
|
|
16
|
+
def update(uri, diagnostics)
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
file_path = uri_to_path(uri)
|
|
19
|
+
@by_file[file_path] = diagnostics.first(@max_diagnostics)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get diagnostics for a file
|
|
24
|
+
def for_file(file_path)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
@by_file[file_path] || []
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get diagnostics for a specific line
|
|
31
|
+
def at_line(file_path, line)
|
|
32
|
+
diagnostics = for_file(file_path)
|
|
33
|
+
diagnostics.select { |d| d.range.start.line == line }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Get diagnostics at position
|
|
37
|
+
def at_position(file_path, line, character)
|
|
38
|
+
diagnostics = for_file(file_path)
|
|
39
|
+
diagnostics.select do |d|
|
|
40
|
+
range = d.range
|
|
41
|
+
start_pos = range.start
|
|
42
|
+
end_pos = range.end
|
|
43
|
+
|
|
44
|
+
line >= start_pos.line && line <= end_pos.line &&
|
|
45
|
+
(line != start_pos.line || character >= start_pos.character) &&
|
|
46
|
+
(line != end_pos.line || character <= end_pos.character)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Count total diagnostics
|
|
51
|
+
def count
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
@by_file.values.sum(&:length)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Count by severity
|
|
58
|
+
def count_by_severity
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
counts = { error: 0, warning: 0, information: 0, hint: 0 }
|
|
61
|
+
|
|
62
|
+
@by_file.each_value do |diagnostics|
|
|
63
|
+
diagnostics.each do |d|
|
|
64
|
+
case d.severity
|
|
65
|
+
when 1 then counts[:error] += 1
|
|
66
|
+
when 2 then counts[:warning] += 1
|
|
67
|
+
when 3 then counts[:information] += 1
|
|
68
|
+
when 4 then counts[:hint] += 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
counts
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Clear diagnostics for a file
|
|
78
|
+
def clear_file(file_path)
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
@by_file.delete(file_path)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Clear all diagnostics
|
|
85
|
+
def clear_all
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
@by_file.clear
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# List files with diagnostics
|
|
92
|
+
def files
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
@by_file.keys.dup
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get all diagnostics as flat list
|
|
99
|
+
def all
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
@by_file.flat_map do |file, diagnostics|
|
|
102
|
+
diagnostics.map { |d| { file: file, diagnostic: d } }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Render diagnostics for display
|
|
108
|
+
def render_for_file(file_path, width: 80)
|
|
109
|
+
diagnostics = for_file(file_path)
|
|
110
|
+
return [] if diagnostics.empty?
|
|
111
|
+
|
|
112
|
+
diagnostics.map do |d|
|
|
113
|
+
severity_char = severity_to_char(d.severity)
|
|
114
|
+
location = "#{d.range.start.line + 1}:#{d.range.start.character + 1}"
|
|
115
|
+
message = d.message.lines.first.chomp
|
|
116
|
+
|
|
117
|
+
"#{severity_char} [#{location}] #{message[0...width - 10]}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Format diagnostic for TUI
|
|
122
|
+
def format_for_tui(file_path, width: 80)
|
|
123
|
+
lines = []
|
|
124
|
+
diagnostics = for_file(file_path)
|
|
125
|
+
|
|
126
|
+
return lines if diagnostics.empty?
|
|
127
|
+
|
|
128
|
+
lines << "📋 Diagnostics (#{diagnostics.length}):"
|
|
129
|
+
|
|
130
|
+
diagnostics.first(10).each do |d|
|
|
131
|
+
icon = severity_to_icon(d.severity)
|
|
132
|
+
line = d.range.start.line + 1
|
|
133
|
+
message = d.message.lines.first.chomp[0...width - 15]
|
|
134
|
+
lines << " #{icon} L#{line}: #{message}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if diagnostics.length > 10
|
|
138
|
+
lines << " ... and #{diagnostics.length - 10} more"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
lines
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check if file has errors
|
|
145
|
+
def has_errors?(file_path)
|
|
146
|
+
diagnostics = for_file(file_path)
|
|
147
|
+
diagnostics.any? { |d| d.severity == 1 }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Check if file has warnings
|
|
151
|
+
def has_warnings?(file_path)
|
|
152
|
+
diagnostics = for_file(file_path)
|
|
153
|
+
diagnostics.any? { |d| d.severity == 2 }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Get error count for file
|
|
157
|
+
def error_count(file_path)
|
|
158
|
+
for_file(file_path).count { |d| d.severity == 1 }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Get warning count for file
|
|
162
|
+
def warning_count(file_path)
|
|
163
|
+
for_file(file_path).count { |d| d.severity == 2 }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Summary for status bar
|
|
167
|
+
def status_summary
|
|
168
|
+
counts = count_by_severity
|
|
169
|
+
parts = []
|
|
170
|
+
parts << "E:#{counts[:error]}" if counts[:error] > 0
|
|
171
|
+
parts << "W:#{counts[:warning]}" if counts[:warning] > 0
|
|
172
|
+
parts.join(' ') unless parts.empty?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def uri_to_path(uri)
|
|
178
|
+
return uri unless uri.start_with?('file://')
|
|
179
|
+
|
|
180
|
+
uri[7..-1]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def severity_to_char(severity)
|
|
184
|
+
case severity
|
|
185
|
+
when 1 then 'E'
|
|
186
|
+
when 2 then 'W'
|
|
187
|
+
when 3 then 'I'
|
|
188
|
+
when 4 then 'H'
|
|
189
|
+
else '?'
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def severity_to_icon(severity)
|
|
194
|
+
case severity
|
|
195
|
+
when 1 then '🔴'
|
|
196
|
+
when 2 then '🟡'
|
|
197
|
+
when 3 then '🔵'
|
|
198
|
+
when 4 then '💡'
|
|
199
|
+
else '❓'
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# DiagnosticsPanel - TUI panel for diagnostics
|
|
205
|
+
class DiagnosticsPanel
|
|
206
|
+
attr_reader :visible, :width, :height
|
|
207
|
+
|
|
208
|
+
def initialize(width: 60, height: 15)
|
|
209
|
+
@visible = false
|
|
210
|
+
@width = width
|
|
211
|
+
@height = height
|
|
212
|
+
@diagnostics = Diagnostics.new
|
|
213
|
+
@current_file = nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def show
|
|
217
|
+
@visible = true
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def hide
|
|
221
|
+
@visible = false
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def toggle
|
|
225
|
+
@visible = !@visible
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def update(uri, diagnostics)
|
|
229
|
+
@diagnostics.update(uri, diagnostics)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def set_current_file(file_path)
|
|
233
|
+
@current_file = file_path
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def render
|
|
237
|
+
return [] unless @visible
|
|
238
|
+
|
|
239
|
+
lines = []
|
|
240
|
+
lines << '─' * @width
|
|
241
|
+
lines << ' Diagnostics'.ljust(@width)
|
|
242
|
+
lines << '─' * @width
|
|
243
|
+
|
|
244
|
+
if @current_file
|
|
245
|
+
lines.concat(@diagnostics.format_for_tui(@current_file, width: @width))
|
|
246
|
+
else
|
|
247
|
+
lines << ' No file selected'
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Pad to height
|
|
251
|
+
while lines.length < @height
|
|
252
|
+
lines << ''
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
lines.first(@height)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsd
|
|
4
|
+
module LSP
|
|
5
|
+
# Hover - Handles LSP hover information
|
|
6
|
+
class Hover
|
|
7
|
+
attr_reader :client, :cache, :cache_ttl
|
|
8
|
+
|
|
9
|
+
def initialize(client)
|
|
10
|
+
@client = client
|
|
11
|
+
@cache = {}
|
|
12
|
+
@cache_ttl = 10 # seconds
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get hover info at position
|
|
17
|
+
def get(uri, line, character)
|
|
18
|
+
return nil unless @client&.running?
|
|
19
|
+
|
|
20
|
+
cache_key = "#{uri}:#{line}:#{character}"
|
|
21
|
+
|
|
22
|
+
# Check cache
|
|
23
|
+
cached = check_cache(cache_key)
|
|
24
|
+
return cached if cached
|
|
25
|
+
|
|
26
|
+
# Request from server
|
|
27
|
+
result = @client.hover(uri, line, character)
|
|
28
|
+
return nil unless result
|
|
29
|
+
|
|
30
|
+
# Parse hover
|
|
31
|
+
info = parse_hover_result(result)
|
|
32
|
+
|
|
33
|
+
# Cache result
|
|
34
|
+
store_cache(cache_key, info)
|
|
35
|
+
|
|
36
|
+
info
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get hover at cursor from text
|
|
40
|
+
def get_at_cursor(file_path, text, cursor_pos)
|
|
41
|
+
line, character = position_from_offset(text, cursor_pos)
|
|
42
|
+
uri = path_to_uri(file_path)
|
|
43
|
+
|
|
44
|
+
get(uri, line, character)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Format hover for display
|
|
48
|
+
def format(info, width: 60, max_height: 10)
|
|
49
|
+
return nil unless info
|
|
50
|
+
|
|
51
|
+
contents = info[:contents]
|
|
52
|
+
return nil if contents.nil? || contents.empty?
|
|
53
|
+
|
|
54
|
+
lines = extract_lines(contents)
|
|
55
|
+
return nil if lines.empty?
|
|
56
|
+
|
|
57
|
+
# Format and truncate
|
|
58
|
+
formatted = lines.map { |line| line[0...width] }
|
|
59
|
+
formatted.first(max_height)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Format hover for TUI panel
|
|
63
|
+
def format_for_tui(info, width: 60, max_height: 10)
|
|
64
|
+
return [] unless info
|
|
65
|
+
|
|
66
|
+
lines = format(info, width: width - 2, max_height: max_height - 2)
|
|
67
|
+
return [] unless lines
|
|
68
|
+
|
|
69
|
+
result = []
|
|
70
|
+
result << '─' * width
|
|
71
|
+
result << ' Hover'.ljust(width)
|
|
72
|
+
result << '─' * width
|
|
73
|
+
|
|
74
|
+
lines.each { |line| result << " #{line}" }
|
|
75
|
+
|
|
76
|
+
# Pad to max_height
|
|
77
|
+
while result.length < max_height
|
|
78
|
+
result << ''
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
result.first(max_height)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get type information
|
|
85
|
+
def type_info(info)
|
|
86
|
+
return nil unless info
|
|
87
|
+
|
|
88
|
+
contents = info[:contents]
|
|
89
|
+
return nil if contents.nil?
|
|
90
|
+
|
|
91
|
+
# Extract type from hover info
|
|
92
|
+
if contents.is_a?(String)
|
|
93
|
+
extract_type_from_string(contents)
|
|
94
|
+
elsif contents.is_a?(Hash)
|
|
95
|
+
contents['value'] || contents[:value]
|
|
96
|
+
elsif contents.is_a?(Array)
|
|
97
|
+
contents.first&.[]('value') || contents.first&.[](:value)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Clear cache
|
|
102
|
+
def clear_cache
|
|
103
|
+
@mutex.synchronize do
|
|
104
|
+
@cache.clear
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def parse_hover_result(result)
|
|
111
|
+
contents = result['contents'] || result[:contents]
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
contents: contents,
|
|
115
|
+
range: result['range'] || result[:range]
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def extract_lines(contents)
|
|
120
|
+
return [] unless contents
|
|
121
|
+
|
|
122
|
+
if contents.is_a?(String)
|
|
123
|
+
contents.lines
|
|
124
|
+
elsif contents.is_a?(Hash)
|
|
125
|
+
value = contents['value'] || contents[:value]
|
|
126
|
+
value ? value.lines : []
|
|
127
|
+
elsif contents.is_a?(Array)
|
|
128
|
+
contents.flat_map do |item|
|
|
129
|
+
if item.is_a?(String)
|
|
130
|
+
item.lines
|
|
131
|
+
elsif item.is_a?(Hash)
|
|
132
|
+
value = item['value'] || item[:value]
|
|
133
|
+
value ? value.lines : []
|
|
134
|
+
else
|
|
135
|
+
[]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
[]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def extract_type_from_string(str)
|
|
144
|
+
# Try to extract type from common patterns
|
|
145
|
+
# Ruby: "[Float] method description"
|
|
146
|
+
# TypeScript: "(parameter) x: string"
|
|
147
|
+
# Python: "function foo -> int"
|
|
148
|
+
|
|
149
|
+
if str =~ /^\[([^\]]+)\]/
|
|
150
|
+
return $1
|
|
151
|
+
elsif str =~ /:\s*(\S+)\s*$/
|
|
152
|
+
return $1
|
|
153
|
+
elsif str =~ /->\s*(\S+)/
|
|
154
|
+
return $1
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def check_cache(key)
|
|
161
|
+
@mutex.synchronize do
|
|
162
|
+
entry = @cache[key]
|
|
163
|
+
return nil unless entry
|
|
164
|
+
|
|
165
|
+
if Time.now - entry[:time] > @cache_ttl
|
|
166
|
+
@cache.delete(key)
|
|
167
|
+
nil
|
|
168
|
+
else
|
|
169
|
+
entry[:info]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def store_cache(key, info)
|
|
175
|
+
@mutex.synchronize do
|
|
176
|
+
@cache[key] = {
|
|
177
|
+
info: info,
|
|
178
|
+
time: Time.now
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Limit cache size
|
|
182
|
+
while @cache.size > 100
|
|
183
|
+
@cache.shift
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def position_from_offset(text, offset)
|
|
189
|
+
line = 0
|
|
190
|
+
character = 0
|
|
191
|
+
|
|
192
|
+
text[0...offset].each_char do |char|
|
|
193
|
+
if char == "\n"
|
|
194
|
+
line += 1
|
|
195
|
+
character = 0
|
|
196
|
+
else
|
|
197
|
+
character += 1
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
[line, character]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def path_to_uri(path)
|
|
205
|
+
absolute = File.expand_path(path)
|
|
206
|
+
"file://#{absolute}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# HoverPanel - TUI panel for hover info
|
|
211
|
+
class HoverPanel
|
|
212
|
+
attr_reader :visible, :width, :height, :current_info
|
|
213
|
+
|
|
214
|
+
def initialize(width: 60, height: 12)
|
|
215
|
+
@visible = false
|
|
216
|
+
@width = width
|
|
217
|
+
@height = height
|
|
218
|
+
@current_info = nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def show
|
|
222
|
+
@visible = true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def hide
|
|
226
|
+
@visible = false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def toggle
|
|
230
|
+
@visible = !@visible
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def update(info)
|
|
234
|
+
@current_info = info
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def render
|
|
238
|
+
return [] unless @visible
|
|
239
|
+
|
|
240
|
+
Hover.new(nil).format_for_tui(@current_info, width: @width, max_height: @height)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|