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,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsd
|
|
4
|
+
module Plugins
|
|
5
|
+
# Plugin - Representa um plugin individual
|
|
6
|
+
#
|
|
7
|
+
# Cada plugin tem:
|
|
8
|
+
# - Metadata (nome, versão, autor, descrição)
|
|
9
|
+
# - Estado (enabled/disabled)
|
|
10
|
+
# - Hooks registrados
|
|
11
|
+
# - Dependências
|
|
12
|
+
class Plugin
|
|
13
|
+
attr_reader :name, :version, :author, :description, :path, :hooks
|
|
14
|
+
attr_accessor :enabled
|
|
15
|
+
|
|
16
|
+
def initialize(name:, version:, path:, author: nil, description: nil, dependencies: [])
|
|
17
|
+
@name = name
|
|
18
|
+
@version = version
|
|
19
|
+
@path = path
|
|
20
|
+
@author = author || 'Unknown'
|
|
21
|
+
@description = description || 'No description'
|
|
22
|
+
@dependencies = dependencies
|
|
23
|
+
@enabled = false
|
|
24
|
+
@hooks = {}
|
|
25
|
+
@instance = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Ativa o plugin
|
|
29
|
+
def enable!
|
|
30
|
+
return true if @enabled
|
|
31
|
+
|
|
32
|
+
# Verifica dependências
|
|
33
|
+
missing = check_dependencies
|
|
34
|
+
unless missing.empty?
|
|
35
|
+
raise PluginError, "Missing dependencies: #{missing.join(', ')}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@enabled = true
|
|
39
|
+
trigger_hook(:enable)
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Desativa o plugin
|
|
44
|
+
def disable!
|
|
45
|
+
return true unless @enabled
|
|
46
|
+
|
|
47
|
+
trigger_hook(:disable)
|
|
48
|
+
@enabled = false
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Registra um hook
|
|
53
|
+
def register_hook(event, &block)
|
|
54
|
+
@hooks[event] ||= []
|
|
55
|
+
@hooks[event] << block
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Dispara hooks para um evento
|
|
59
|
+
def trigger_hook(event, *args)
|
|
60
|
+
return unless @hooks[event]
|
|
61
|
+
|
|
62
|
+
@hooks[event].each { |h| h.call(*args) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Verifica dependências
|
|
66
|
+
def check_dependencies
|
|
67
|
+
registry = Registry.instance
|
|
68
|
+
@dependencies.reject { |dep| registry.get(dep) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Converte para hash
|
|
72
|
+
def to_h
|
|
73
|
+
{
|
|
74
|
+
name: @name,
|
|
75
|
+
version: @version,
|
|
76
|
+
author: @author,
|
|
77
|
+
description: @description,
|
|
78
|
+
enabled: @enabled,
|
|
79
|
+
dependencies: @dependencies,
|
|
80
|
+
path: @path
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Carrega código do plugin
|
|
85
|
+
def load!
|
|
86
|
+
return if @loaded
|
|
87
|
+
|
|
88
|
+
if File.exist?(@path)
|
|
89
|
+
require @path
|
|
90
|
+
@loaded = true
|
|
91
|
+
trigger_hook(:load)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# PluginError - Exceção para erros de plugin
|
|
97
|
+
class PluginError < StandardError; end
|
|
98
|
+
|
|
99
|
+
# Registry - Registro central de plugins
|
|
100
|
+
#
|
|
101
|
+
# Singleton que gerencia todos os plugins instalados
|
|
102
|
+
class Registry
|
|
103
|
+
include Singleton
|
|
104
|
+
|
|
105
|
+
attr_reader :plugins_dir
|
|
106
|
+
|
|
107
|
+
def initialize
|
|
108
|
+
@plugins = {}
|
|
109
|
+
@mutex = Mutex.new
|
|
110
|
+
@plugins_dir = File.join(Dir.home, '.gsd', 'plugins')
|
|
111
|
+
ensure_plugins_dir
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Registra um novo plugin
|
|
115
|
+
def register(plugin)
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
if @plugins[plugin.name]
|
|
118
|
+
raise PluginError, "Plugin '#{plugin.name}' already registered"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
@plugins[plugin.name] = plugin
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Remove um plugin
|
|
126
|
+
def unregister(name)
|
|
127
|
+
@mutex.synchronize do
|
|
128
|
+
plugin = @plugins[name]
|
|
129
|
+
if plugin
|
|
130
|
+
plugin.disable! if plugin.enabled
|
|
131
|
+
@plugins.delete(name)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Obtém um plugin pelo nome
|
|
137
|
+
def get(name)
|
|
138
|
+
@mutex.synchronize { @plugins[name] }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Lista todos os plugins
|
|
142
|
+
def all
|
|
143
|
+
@mutex.synchronize { @plugins.values.dup }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Lista plugins habilitados
|
|
147
|
+
def enabled
|
|
148
|
+
@mutex.synchronize { @plugins.values.select(&:enabled) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Verifica se plugin existe
|
|
152
|
+
def exists?(name)
|
|
153
|
+
@mutex.synchronize { @plugins.key?(name) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Habilita um plugin
|
|
157
|
+
def enable(name)
|
|
158
|
+
plugin = get(name)
|
|
159
|
+
raise PluginError, "Plugin '#{name}' not found" unless plugin
|
|
160
|
+
|
|
161
|
+
plugin.enable!
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Desabilita um plugin
|
|
165
|
+
def disable(name)
|
|
166
|
+
plugin = get(name)
|
|
167
|
+
raise PluginError, "Plugin '#{name}' not found" unless plugin
|
|
168
|
+
|
|
169
|
+
plugin.disable!
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Busca plugins por nome/padrão
|
|
173
|
+
def search(pattern)
|
|
174
|
+
regex = Regexp.new(pattern, Regexp::IGNORECASE)
|
|
175
|
+
@mutex.synchronize do
|
|
176
|
+
@plugins.values.select { |p| p.name =~ regex || p.description =~ regex }
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Lista plugins que podem ser carregados do disco
|
|
181
|
+
def discover
|
|
182
|
+
discovered = []
|
|
183
|
+
return discovered unless Dir.exist?(@plugins_dir)
|
|
184
|
+
|
|
185
|
+
Dir.glob(File.join(@plugins_dir, '*', 'plugin.rb')).each do |path|
|
|
186
|
+
begin
|
|
187
|
+
metadata = load_metadata(path)
|
|
188
|
+
discovered << Plugin.new(
|
|
189
|
+
name: metadata[:name],
|
|
190
|
+
version: metadata[:version],
|
|
191
|
+
path: path,
|
|
192
|
+
author: metadata[:author],
|
|
193
|
+
description: metadata[:description],
|
|
194
|
+
dependencies: metadata[:dependencies] || []
|
|
195
|
+
)
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
warn "Failed to load plugin from #{path}: #{e.message}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
discovered
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Carrega plugin do disco e registra
|
|
205
|
+
def load_from_disk(name)
|
|
206
|
+
path = File.join(@plugins_dir, name, 'plugin.rb')
|
|
207
|
+
return nil unless File.exist?(path)
|
|
208
|
+
|
|
209
|
+
metadata = load_metadata(path)
|
|
210
|
+
plugin = Plugin.new(
|
|
211
|
+
name: metadata[:name],
|
|
212
|
+
version: metadata[:version],
|
|
213
|
+
path: path,
|
|
214
|
+
author: metadata[:author],
|
|
215
|
+
description: metadata[:description],
|
|
216
|
+
dependencies: metadata[:dependencies] || []
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
register(plugin)
|
|
220
|
+
plugin
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Salva estado dos plugins
|
|
224
|
+
def save_state(path = nil)
|
|
225
|
+
path ||= File.join(Dir.home, '.gsd', 'plugins.json')
|
|
226
|
+
state = {
|
|
227
|
+
enabled: enabled.map(&:name),
|
|
228
|
+
all: all.map(&:to_h)
|
|
229
|
+
}
|
|
230
|
+
File.write(path, JSON.pretty_generate(state))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Carrega estado dos plugins
|
|
234
|
+
def load_state(path = nil)
|
|
235
|
+
path ||= File.join(Dir.home, '.gsd', 'plugins.json')
|
|
236
|
+
return unless File.exist?(path)
|
|
237
|
+
|
|
238
|
+
state = JSON.parse(File.read(path), symbolize_names: true)
|
|
239
|
+
state[:enabled].each { |name| enable(name) rescue nil }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def ensure_plugins_dir
|
|
245
|
+
FileUtils.mkdir_p(@plugins_dir) unless Dir.exist?(@plugins_dir)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def load_metadata(path)
|
|
249
|
+
# Extrai metadata de um arquivo plugin.rb
|
|
250
|
+
content = File.read(path)
|
|
251
|
+
metadata = {}
|
|
252
|
+
|
|
253
|
+
# Procura por comentários de metadata
|
|
254
|
+
if content =~ /#\s*@name\s+(\S+)/
|
|
255
|
+
metadata[:name] = $1
|
|
256
|
+
else
|
|
257
|
+
metadata[:name] = File.basename(File.dirname(path))
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
metadata[:version] = $1 if content =~ /#\s*@version\s+(\S+)/
|
|
261
|
+
metadata[:author] = $1 if content =~ /#\s*@author\s+(.+)/
|
|
262
|
+
metadata[:description] = $1 if content =~ /#\s*@description\s+(.+)/
|
|
263
|
+
|
|
264
|
+
if content =~ /#\s*@dependencies\s+(.+)/
|
|
265
|
+
metadata[:dependencies] = $1.split(',').map(&:strip)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
metadata[:version] ||= '0.1.0'
|
|
269
|
+
metadata
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gsd/tui/colors'
|
|
4
|
+
|
|
5
|
+
module Gsd
|
|
6
|
+
module TUI
|
|
7
|
+
# AgentPanel - UI component for managing agents in the TUI
|
|
8
|
+
#
|
|
9
|
+
# Displays:
|
|
10
|
+
# - List of active agents with status
|
|
11
|
+
# - Agent types and current tasks
|
|
12
|
+
# - Quick actions (spawn, terminate, restart)
|
|
13
|
+
# - Communication between agents
|
|
14
|
+
class AgentPanel
|
|
15
|
+
def initialize(swarm, width: 60, visible: false)
|
|
16
|
+
@swarm = swarm
|
|
17
|
+
@width = width
|
|
18
|
+
@visible = visible
|
|
19
|
+
@selected_index = 0
|
|
20
|
+
@scroll_offset = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def show
|
|
24
|
+
@visible = true
|
|
25
|
+
refresh
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def hide
|
|
29
|
+
@visible = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def visible?
|
|
33
|
+
@visible
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def toggle
|
|
37
|
+
@visible = !@visible
|
|
38
|
+
refresh if @visible
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def move_up
|
|
42
|
+
return unless @visible
|
|
43
|
+
@selected_index = [@selected_index - 1, 0].max
|
|
44
|
+
adjust_scroll
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def move_down
|
|
48
|
+
return unless @visible
|
|
49
|
+
max_index = [@swarm.list.count - 1, 0].max
|
|
50
|
+
@selected_index = [@selected_index + 1, max_index].min
|
|
51
|
+
adjust_scroll
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def select_current
|
|
55
|
+
return unless @visible
|
|
56
|
+
agents = @swarm.list
|
|
57
|
+
return if agents.empty?
|
|
58
|
+
|
|
59
|
+
agent = agents[@selected_index]
|
|
60
|
+
agent&.id
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def render
|
|
64
|
+
return '' unless @visible
|
|
65
|
+
|
|
66
|
+
t = Colors.theme
|
|
67
|
+
cyan = t[:accent]
|
|
68
|
+
magenta = t[:accent2]
|
|
69
|
+
pink = t[:accent3]
|
|
70
|
+
white = t[:text]
|
|
71
|
+
dim = t[:dim]
|
|
72
|
+
bg = t[:bg]
|
|
73
|
+
reset = Colors::RESET
|
|
74
|
+
|
|
75
|
+
lines = []
|
|
76
|
+
|
|
77
|
+
# Header
|
|
78
|
+
lines << "#{cyan}╭#{('─' * (@width - 2))}╮#{reset}"
|
|
79
|
+
lines << "#{cyan}│#{reset} #{magenta}⚡ Agents#{reset}#{dim} (#{@swarm.stats[:total_agents]} active)#{reset} #{cyan}│#{reset}"
|
|
80
|
+
lines << "#{cyan}├#{('─' * (@width - 2))}┤#{reset}"
|
|
81
|
+
|
|
82
|
+
# Agent list
|
|
83
|
+
agents = @swarm.list
|
|
84
|
+
if agents.empty?
|
|
85
|
+
lines << "#{cyan}│#{reset} #{dim}No agents running#{reset} #{cyan}│#{reset}"
|
|
86
|
+
else
|
|
87
|
+
display_count = [agents.count, 5].min # Show max 5 agents
|
|
88
|
+
display_count.times do |i|
|
|
89
|
+
idx = @scroll_offset + i
|
|
90
|
+
break if idx >= agents.count
|
|
91
|
+
|
|
92
|
+
agent = agents[idx]
|
|
93
|
+
lines << render_agent_line(agent, idx == @selected_index, cyan, magenta, pink, white, dim, reset)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Footer with actions
|
|
98
|
+
lines << "#{cyan}├#{('─' * (@width - 2))}┤#{reset}"
|
|
99
|
+
lines << "#{cyan}│#{reset} #{dim}↑↓ navigate | s:spawn | t:terminate | r:restart#{reset} #{cyan}│#{reset}"
|
|
100
|
+
lines << "#{cyan}╰#{('─' * (@width - 2))}╯#{reset}"
|
|
101
|
+
|
|
102
|
+
lines.join("\n")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def handle_key(char)
|
|
106
|
+
return unless @visible
|
|
107
|
+
|
|
108
|
+
case char
|
|
109
|
+
when 's', 'S'
|
|
110
|
+
:spawn_agent
|
|
111
|
+
when 't', 'T'
|
|
112
|
+
:terminate_agent
|
|
113
|
+
when 'r', 'R'
|
|
114
|
+
:restart_agent
|
|
115
|
+
when 'm', 'M'
|
|
116
|
+
:send_message
|
|
117
|
+
else
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def render_agent_line(agent, selected, cyan, magenta, pink, white, dim, reset)
|
|
125
|
+
# Status indicator
|
|
126
|
+
status_color = case agent.state
|
|
127
|
+
when :idle then dim
|
|
128
|
+
when :working then magenta
|
|
129
|
+
when :crashed then pink
|
|
130
|
+
else dim
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
status_icon = case agent.state
|
|
134
|
+
when :idle then '○'
|
|
135
|
+
when :working then '●'
|
|
136
|
+
when :crashed then '✗'
|
|
137
|
+
else '○'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Type badge
|
|
141
|
+
type_badge = case agent.type
|
|
142
|
+
when :code then "#{cyan}[CODE]#{reset}"
|
|
143
|
+
when :plan then "#{magenta}[PLAN]#{reset}"
|
|
144
|
+
when :debug then "#{pink}[DEBUG]#{reset}"
|
|
145
|
+
else "#{dim}[#{agent.type.to_s.upcase}]#{reset}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Selection indicator
|
|
149
|
+
prefix = selected ? "#{cyan}>#{reset}" : "#{dim} #{reset}"
|
|
150
|
+
|
|
151
|
+
# Line content
|
|
152
|
+
content = "#{prefix} #{status_color}#{status_icon}#{reset} #{type_badge} #{white}#{agent.id[0..12]}#{reset}"
|
|
153
|
+
|
|
154
|
+
# Task info if working
|
|
155
|
+
if agent.state == :working && agent.instance_variable_get(:@current_task)
|
|
156
|
+
task_preview = agent.instance_variable_get(:@current_task)[:type] || 'task'
|
|
157
|
+
content += " #{dim}(#{task_preview})#{reset}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Pad to width
|
|
161
|
+
clean_len = content.gsub(/\e\[\d+m/, '').length
|
|
162
|
+
padding = ' ' * [@width - clean_len - 2, 0].max
|
|
163
|
+
|
|
164
|
+
"#{cyan}│#{reset}#{content}#{padding}#{cyan}│#{reset}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def adjust_scroll
|
|
168
|
+
visible_count = 5
|
|
169
|
+
|
|
170
|
+
if @selected_index < @scroll_offset
|
|
171
|
+
@scroll_offset = @selected_index
|
|
172
|
+
elsif @selected_index >= @scroll_offset + visible_count
|
|
173
|
+
@scroll_offset = @selected_index - visible_count + 1
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def refresh
|
|
178
|
+
# Trigger refresh of parent TUI
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|