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
|
+
require 'securerandom'
|
|
4
|
+
require 'gsd/tui/persistence'
|
|
5
|
+
|
|
6
|
+
module Gsd
|
|
7
|
+
module TUI
|
|
8
|
+
# Session - Gerenciamento de sessões do TUI
|
|
9
|
+
#
|
|
10
|
+
# Responsável por:
|
|
11
|
+
# - Criar, salvar e restaurar sessões
|
|
12
|
+
# - Auto-save periódico
|
|
13
|
+
# - Checkpoints manuais e automáticos
|
|
14
|
+
class Session
|
|
15
|
+
attr_reader :id, :name, :created_at, :last_saved_at
|
|
16
|
+
attr_accessor :auto_save_interval, :max_checkpoints
|
|
17
|
+
|
|
18
|
+
def initialize(
|
|
19
|
+
id: nil,
|
|
20
|
+
name: nil,
|
|
21
|
+
auto_save_interval: 60, # segundos
|
|
22
|
+
max_checkpoints: 10
|
|
23
|
+
)
|
|
24
|
+
@id = id || generate_session_id
|
|
25
|
+
@name = name || "Session #{Time.now.strftime('%Y-%m-%d %H:%M')}"
|
|
26
|
+
@created_at = Time.now.to_s
|
|
27
|
+
@last_saved_at = nil
|
|
28
|
+
@auto_save_interval = auto_save_interval
|
|
29
|
+
@max_checkpoints = max_checkpoints
|
|
30
|
+
|
|
31
|
+
@persistence = Persistence.new
|
|
32
|
+
@auto_save_thread = nil
|
|
33
|
+
@running = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Inicia auto-save
|
|
37
|
+
def start_auto_save
|
|
38
|
+
return if @running
|
|
39
|
+
|
|
40
|
+
@running = true
|
|
41
|
+
@auto_save_thread = Thread.new do
|
|
42
|
+
while @running
|
|
43
|
+
sleep(@auto_save_interval)
|
|
44
|
+
auto_save if @running
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Para auto-save
|
|
50
|
+
def stop_auto_save
|
|
51
|
+
@running = false
|
|
52
|
+
@auto_save_thread&.kill
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Salva estado atual da aplicação
|
|
56
|
+
#
|
|
57
|
+
# @param app [App] Instância do App do TUI
|
|
58
|
+
# @param metadata [Hash] Metadados adicionais
|
|
59
|
+
# @return [String] Caminho do arquivo salvo
|
|
60
|
+
def save(app:, metadata: {})
|
|
61
|
+
data = capture_state(app, metadata)
|
|
62
|
+
@last_saved_at = Time.now.to_s
|
|
63
|
+
|
|
64
|
+
@persistence.save(session_id: @id, data: data)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Auto-save (silent)
|
|
68
|
+
def auto_save(app: nil)
|
|
69
|
+
return unless app
|
|
70
|
+
|
|
71
|
+
data = capture_state(app, { auto_save: true })
|
|
72
|
+
@last_saved_at = Time.now.to_s
|
|
73
|
+
|
|
74
|
+
@persistence.save(session_id: "#{@id}_auto", data: data)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Restaura sessão
|
|
78
|
+
#
|
|
79
|
+
# @param app [App] Instância do App do TUI
|
|
80
|
+
# @return [Boolean] true se restaurou com sucesso
|
|
81
|
+
def restore(app:)
|
|
82
|
+
data = @persistence.load(session_id: @id)
|
|
83
|
+
return false unless data
|
|
84
|
+
|
|
85
|
+
restore_state(app, data)
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Cria checkpoint manual
|
|
90
|
+
#
|
|
91
|
+
# @param app [App] Instância do App
|
|
92
|
+
# @param name [String] Nome do checkpoint
|
|
93
|
+
# @return [String] ID do checkpoint
|
|
94
|
+
def checkpoint(app:, name:)
|
|
95
|
+
save(app: app, metadata: { checkpoint: true, checkpoint_name: name })
|
|
96
|
+
|
|
97
|
+
checkpoint_id = @persistence.create_checkpoint(
|
|
98
|
+
session_id: @id,
|
|
99
|
+
checkpoint_name: name
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
cleanup_old_checkpoints
|
|
103
|
+
checkpoint_id
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Restaura de checkpoint
|
|
107
|
+
#
|
|
108
|
+
# @param app [App] Instância do App
|
|
109
|
+
# @param checkpoint_id [String] ID do checkpoint
|
|
110
|
+
# @return [Boolean] true se restaurou com sucesso
|
|
111
|
+
def restore_checkpoint(app:, checkpoint_id:)
|
|
112
|
+
data = @persistence.restore_checkpoint(checkpoint_id: checkpoint_id)
|
|
113
|
+
return false unless data
|
|
114
|
+
|
|
115
|
+
restore_state(app, data)
|
|
116
|
+
true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Lista checkpoints disponíveis
|
|
120
|
+
#
|
|
121
|
+
# @return [Array<Hash>] Lista de checkpoints
|
|
122
|
+
def checkpoints
|
|
123
|
+
@persistence.list_checkpoints(session_id: @id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Exporta sessão
|
|
127
|
+
#
|
|
128
|
+
# @param format [Symbol] Formato de exportação
|
|
129
|
+
# @return [String] Conteúdo exportado
|
|
130
|
+
def export(format: :markdown)
|
|
131
|
+
@persistence.export(session_id: @id, format: format)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Renomeia sessão
|
|
135
|
+
#
|
|
136
|
+
# @param new_name [String] Novo nome
|
|
137
|
+
def rename(new_name:)
|
|
138
|
+
@name = new_name
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Deleta sessão
|
|
142
|
+
def delete
|
|
143
|
+
@persistence.delete(session_id: @id)
|
|
144
|
+
stop_auto_save
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Informações da sessão
|
|
148
|
+
#
|
|
149
|
+
# @return [Hash] Metadados da sessão
|
|
150
|
+
def info
|
|
151
|
+
{
|
|
152
|
+
id: @id,
|
|
153
|
+
name: @name,
|
|
154
|
+
created_at: @created_at,
|
|
155
|
+
last_saved_at: @last_saved_at,
|
|
156
|
+
auto_save_enabled: @running,
|
|
157
|
+
auto_save_interval: @auto_save_interval
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Estatísticas da sessão
|
|
162
|
+
#
|
|
163
|
+
# @return [Hash] Estatísticas
|
|
164
|
+
def stats
|
|
165
|
+
data = @persistence.load(session_id: @id)
|
|
166
|
+
return {} unless data
|
|
167
|
+
|
|
168
|
+
{
|
|
169
|
+
message_count: data[:output]&.length || 0,
|
|
170
|
+
user_messages: data[:output]&.count { |m| m[:type] == :user } || 0,
|
|
171
|
+
assistant_messages: data[:output]&.count { |m| m[:type] == :assistant } || 0,
|
|
172
|
+
duration: calculate_duration(data),
|
|
173
|
+
agent_count: data[:swarm_stats]&.dig(:total_agents) || 0
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
class << self
|
|
178
|
+
# Lista todas as sessões disponíveis
|
|
179
|
+
#
|
|
180
|
+
# @return [Array<Hash>] Lista de sessões
|
|
181
|
+
def list_all
|
|
182
|
+
Persistence.new.list_sessions
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Carrega uma sessão existente
|
|
186
|
+
#
|
|
187
|
+
# @param session_id [String] ID da sessão
|
|
188
|
+
# @return [Session, nil] Sessão ou nil
|
|
189
|
+
def load(session_id:)
|
|
190
|
+
data = Persistence.new.load(session_id: session_id)
|
|
191
|
+
return nil unless data
|
|
192
|
+
|
|
193
|
+
session = new(
|
|
194
|
+
id: session_id,
|
|
195
|
+
name: data[:name]
|
|
196
|
+
)
|
|
197
|
+
session.instance_variable_set(:@created_at, data[:created_at])
|
|
198
|
+
session.instance_variable_set(:@last_saved_at, data[:updated_at])
|
|
199
|
+
session
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
def generate_session_id
|
|
206
|
+
"session-#{Time.now.to_i}-#{SecureRandom.hex(4)}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def capture_state(app, metadata)
|
|
210
|
+
{
|
|
211
|
+
id: @id,
|
|
212
|
+
name: @name,
|
|
213
|
+
version: Gsd::VERSION,
|
|
214
|
+
created_at: @created_at,
|
|
215
|
+
updated_at: Time.now.to_s,
|
|
216
|
+
theme: app.instance_variable_get(:@theme),
|
|
217
|
+
output: app.instance_variable_get(:@output)&.dup || [],
|
|
218
|
+
history: app.instance_variable_get(:@history)&.dup || [],
|
|
219
|
+
selected_agent: app.instance_variable_get(:@selected_agent),
|
|
220
|
+
swarm_stats: app.instance_variable_get(:@swarm)&.stats || {},
|
|
221
|
+
**metadata
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def restore_state(app, data)
|
|
226
|
+
app.instance_variable_set(:@output, data[:output] || [])
|
|
227
|
+
app.instance_variable_set(:@history, data[:history] || [])
|
|
228
|
+
app.instance_variable_set(:@selected_agent, data[:selected_agent] || 0)
|
|
229
|
+
|
|
230
|
+
# Restore theme if different
|
|
231
|
+
if data[:theme] && data[:theme] != app.instance_variable_get(:@theme)
|
|
232
|
+
app.send(:set_theme, data[:theme])
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def cleanup_old_checkpoints
|
|
237
|
+
all_checkpoints = @persistence.list_checkpoints(session_id: @id)
|
|
238
|
+
return if all_checkpoints.length <= @max_checkpoints
|
|
239
|
+
|
|
240
|
+
# Remove oldest checkpoints
|
|
241
|
+
to_remove = all_checkpoints[@max_checkpoints..-1]
|
|
242
|
+
to_remove.each do |checkpoint|
|
|
243
|
+
checkpoint_file = File.join(
|
|
244
|
+
@persistence.sessions_dir,
|
|
245
|
+
'checkpoints',
|
|
246
|
+
"#{checkpoint[:id]}.json"
|
|
247
|
+
)
|
|
248
|
+
File.delete(checkpoint_file) if File.exist?(checkpoint_file)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def calculate_duration(data)
|
|
253
|
+
return 'N/A' unless data[:created_at] && data[:updated_at]
|
|
254
|
+
|
|
255
|
+
begin
|
|
256
|
+
created = Time.parse(data[:created_at])
|
|
257
|
+
updated = Time.parse(data[:updated_at])
|
|
258
|
+
duration = updated - created
|
|
259
|
+
|
|
260
|
+
if duration < 60
|
|
261
|
+
"#{duration.to_i}s"
|
|
262
|
+
elsif duration < 3600
|
|
263
|
+
"#{(duration / 60).to_i}m"
|
|
264
|
+
else
|
|
265
|
+
"#{(duration / 3600).to_i}h #{(duration % 3600 / 60).to_i}m"
|
|
266
|
+
end
|
|
267
|
+
rescue
|
|
268
|
+
'N/A'
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
data/lib/gsd/tui/status_bar.rb
CHANGED
|
@@ -4,10 +4,12 @@ require 'gsd/tui/colors'
|
|
|
4
4
|
|
|
5
5
|
module Gsd
|
|
6
6
|
module TUI
|
|
7
|
+
# StatusBar estilo Warp com badges coloridos
|
|
7
8
|
class StatusBar
|
|
8
9
|
def initialize
|
|
9
10
|
@mode = 'NORMAL'
|
|
10
|
-
@branch = '
|
|
11
|
+
@branch = 'main'
|
|
12
|
+
@cwd = Dir.pwd
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
def update_mode(mode)
|
|
@@ -18,33 +20,79 @@ module Gsd
|
|
|
18
20
|
@branch = branch
|
|
19
21
|
end
|
|
20
22
|
|
|
23
|
+
def update_cwd(cwd)
|
|
24
|
+
@cwd = cwd
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
def render(width: 80)
|
|
22
28
|
t = Colors.theme
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
reset = Colors::RESET
|
|
30
|
+
|
|
31
|
+
# Cores Cyber Wave
|
|
32
|
+
cyan = t[:accent] # Ciano neon
|
|
33
|
+
magenta = t[:accent2] # Roxo neon
|
|
34
|
+
pink = t[:accent3] # Rosa
|
|
25
35
|
white = t[:text]
|
|
36
|
+
dim = t[:dim]
|
|
26
37
|
bg = t[:bg]
|
|
27
|
-
|
|
28
|
-
reset = Colors::RESET
|
|
38
|
+
bg_cyan = t[:bg_accent]
|
|
29
39
|
|
|
30
40
|
time = Time.now.strftime('%H:%M:%S')
|
|
41
|
+
dir = File.basename(@cwd)
|
|
42
|
+
|
|
43
|
+
# Badge de modo (CODE/PLAN/DEBUG) com background colorido
|
|
44
|
+
mode_badge = "#{bg_cyan}#{Colors::BLACK} #{@mode} #{reset}"
|
|
31
45
|
|
|
32
|
-
#
|
|
33
|
-
|
|
46
|
+
# Badge de diretório
|
|
47
|
+
dir_badge = "#{dim}📁 #{dir}#{reset}"
|
|
34
48
|
|
|
35
|
-
#
|
|
36
|
-
|
|
49
|
+
# Badge de git
|
|
50
|
+
if git_info_available?
|
|
51
|
+
git_badge = "#{magenta}git:(#{@branch})#{reset}"
|
|
52
|
+
git_status = git_clean? ? "#{Colors::GREEN}✓#{reset}" : "#{pink}●#{reset}"
|
|
53
|
+
git_section = " #{git_badge} #{git_status}"
|
|
54
|
+
else
|
|
55
|
+
git_section = ""
|
|
56
|
+
end
|
|
37
57
|
|
|
38
|
-
#
|
|
39
|
-
|
|
58
|
+
# Left side: badges
|
|
59
|
+
left = "#{mode_badge} #{dir_badge}#{git_section}"
|
|
60
|
+
|
|
61
|
+
# Right side: time
|
|
62
|
+
right = "#{dim}#{time}#{reset}"
|
|
63
|
+
|
|
64
|
+
# Center: shortcuts
|
|
65
|
+
center = "#{dim}tab#{reset} #{white}mode#{reset} #{dim}ctrl+p#{reset} #{white}cmds#{reset} #{dim}ctrl+q#{reset} #{white}quit#{reset}"
|
|
40
66
|
|
|
41
67
|
# Calculate padding
|
|
68
|
+
clean_left = left.gsub(/\e\[\d+m/, '').length
|
|
42
69
|
clean_center = center.gsub(/\e\[\d+m/, '').length
|
|
43
|
-
|
|
44
|
-
|
|
70
|
+
clean_right = right.gsub(/\e\[\d+m/, '').length
|
|
71
|
+
|
|
72
|
+
total_content = clean_left + clean_center + clean_right
|
|
73
|
+
padding = width - total_content
|
|
74
|
+
padding_left = padding / 2
|
|
75
|
+
padding_right = padding - padding_left
|
|
76
|
+
padding_left = [padding_left, 0].max
|
|
77
|
+
padding_right = [padding_right, 0].max
|
|
78
|
+
|
|
79
|
+
# Status bar line estilo Warp
|
|
80
|
+
"#{bg}#{left}#{' ' * padding_left}#{center}#{' ' * padding_right}#{right}#{reset}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def git_info_available?
|
|
86
|
+
File.directory?(File.join(@cwd, '.git'))
|
|
87
|
+
end
|
|
45
88
|
|
|
46
|
-
|
|
47
|
-
|
|
89
|
+
def git_clean?
|
|
90
|
+
return true unless git_info_available?
|
|
91
|
+
|
|
92
|
+
status = `git -C #{@cwd} status --porcelain 2>/dev/null`.strip
|
|
93
|
+
status.empty?
|
|
94
|
+
rescue
|
|
95
|
+
true
|
|
48
96
|
end
|
|
49
97
|
end
|
|
50
98
|
end
|
data/lib/gsd/tui/tab.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Gsd
|
|
6
|
+
module TUI
|
|
7
|
+
# Tab - Representa uma aba individual no TUI
|
|
8
|
+
#
|
|
9
|
+
# Cada aba mantém seu próprio estado:
|
|
10
|
+
# - Histórico de mensagens
|
|
11
|
+
# - Input box state
|
|
12
|
+
# - Agentes ativos
|
|
13
|
+
# - Tema/configurações
|
|
14
|
+
class Tab
|
|
15
|
+
attr_accessor :id, :title, :output, :history, :history_index, :input_text
|
|
16
|
+
attr_accessor :selected_agent, :theme, :swarm_stats
|
|
17
|
+
attr_reader :created_at, :updated_at
|
|
18
|
+
|
|
19
|
+
def initialize(id: nil, title: nil, theme: :cyber_wave)
|
|
20
|
+
@id = id || generate_id
|
|
21
|
+
@title = title || "Tab #{@id[-4..-1]}"
|
|
22
|
+
@theme = theme
|
|
23
|
+
|
|
24
|
+
# State per tab
|
|
25
|
+
@output = []
|
|
26
|
+
@history = []
|
|
27
|
+
@history_index = -1
|
|
28
|
+
@input_text = ''
|
|
29
|
+
@selected_agent = 1
|
|
30
|
+
@swarm_stats = {}
|
|
31
|
+
|
|
32
|
+
@created_at = Time.now
|
|
33
|
+
@updated_at = @created_at
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Adiciona mensagem ao output
|
|
37
|
+
def add_message(type, text)
|
|
38
|
+
@output << { type: type, text: text, timestamp: Time.now.to_s }
|
|
39
|
+
@output = @output.last(100) # Keep last 100 messages
|
|
40
|
+
touch
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Adiciona ao histórico
|
|
44
|
+
def add_to_history(text)
|
|
45
|
+
@history << text
|
|
46
|
+
@history = @history.last(50) # Keep last 50
|
|
47
|
+
@history_index = @history.length
|
|
48
|
+
touch
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Atualiza timestamp
|
|
52
|
+
def touch
|
|
53
|
+
@updated_at = Time.now
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Renomeia aba
|
|
57
|
+
def rename(new_title)
|
|
58
|
+
@title = new_title
|
|
59
|
+
touch
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Estado para persistência
|
|
63
|
+
def to_h
|
|
64
|
+
{
|
|
65
|
+
id: @id,
|
|
66
|
+
title: @title,
|
|
67
|
+
theme: @theme,
|
|
68
|
+
output: @output,
|
|
69
|
+
history: @history,
|
|
70
|
+
history_index: @history_index,
|
|
71
|
+
selected_agent: @selected_agent,
|
|
72
|
+
created_at: @created_at.to_s,
|
|
73
|
+
updated_at: @updated_at.to_s
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Carrega de hash
|
|
78
|
+
def self.from_h(hash)
|
|
79
|
+
tab = new(
|
|
80
|
+
id: hash[:id],
|
|
81
|
+
title: hash[:title],
|
|
82
|
+
theme: hash[:theme]&.to_sym || :cyber_wave
|
|
83
|
+
)
|
|
84
|
+
tab.output = hash[:output] || []
|
|
85
|
+
tab.history = hash[:history] || []
|
|
86
|
+
tab.history_index = hash[:history_index] || -1
|
|
87
|
+
tab.selected_agent = hash[:selected_agent] || 1
|
|
88
|
+
tab
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Contador de mensagens
|
|
92
|
+
def message_count
|
|
93
|
+
@output.length
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Resumo do conteúdo para preview
|
|
97
|
+
def preview
|
|
98
|
+
return 'Empty' if @output.empty?
|
|
99
|
+
|
|
100
|
+
last_msg = @output.last
|
|
101
|
+
text = last_msg[:text] || ''
|
|
102
|
+
text.length > 30 ? "#{text[0..27]}..." : text
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def generate_id
|
|
108
|
+
"tab-#{SecureRandom.hex(4)}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gsd/tui/tab'
|
|
4
|
+
|
|
5
|
+
module Gsd
|
|
6
|
+
module TUI
|
|
7
|
+
# TabManager - Gerenciamento de múltiplas abas no TUI
|
|
8
|
+
#
|
|
9
|
+
# Funcionalidades:
|
|
10
|
+
# - Criar, fechar, navegar entre abas
|
|
11
|
+
# - Switch de contexto (cada aba tem estado independente)
|
|
12
|
+
# - Keybindings: Ctrl+T (new), Ctrl+W (close), Ctrl+Tab (next)
|
|
13
|
+
class TabManager
|
|
14
|
+
attr_reader :tabs, :current_index, :max_tabs
|
|
15
|
+
|
|
16
|
+
def initialize(max_tabs: 10)
|
|
17
|
+
@tabs = []
|
|
18
|
+
@current_index = -1
|
|
19
|
+
@max_tabs = max_tabs
|
|
20
|
+
|
|
21
|
+
# Cria primeira aba automaticamente
|
|
22
|
+
create_tab(title: 'Main')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Cria nova aba
|
|
26
|
+
#
|
|
27
|
+
# @param title [String] Título da aba (opcional)
|
|
28
|
+
# @return [Tab] A nova aba criada
|
|
29
|
+
def create_tab(title: nil)
|
|
30
|
+
return nil if @tabs.length >= @max_tabs
|
|
31
|
+
|
|
32
|
+
tab = Tab.new(title: title)
|
|
33
|
+
@tabs << tab
|
|
34
|
+
@current_index = @tabs.length - 1
|
|
35
|
+
tab
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Fecha aba atual
|
|
39
|
+
#
|
|
40
|
+
# @return [Boolean] true se fechou, false se não (única aba)
|
|
41
|
+
def close_current_tab
|
|
42
|
+
return false if @tabs.length <= 1
|
|
43
|
+
|
|
44
|
+
@tabs.delete_at(@current_index)
|
|
45
|
+
@current_index = [@current_index, @tabs.length - 1].min
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Fecha aba específica
|
|
50
|
+
#
|
|
51
|
+
# @param index [Integer] Índice da aba
|
|
52
|
+
def close_tab(index)
|
|
53
|
+
return false if @tabs.length <= 1
|
|
54
|
+
return false unless index.between?(0, @tabs.length - 1)
|
|
55
|
+
|
|
56
|
+
@tabs.delete_at(index)
|
|
57
|
+
@current_index = [@current_index, @tabs.length - 1].min
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Navega para próxima aba
|
|
62
|
+
def next_tab
|
|
63
|
+
@current_index = (@current_index + 1) % @tabs.length
|
|
64
|
+
current_tab
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Navega para aba anterior
|
|
68
|
+
def previous_tab
|
|
69
|
+
@current_index = (@current_index - 1) % @tabs.length
|
|
70
|
+
current_tab
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Vai para aba específica
|
|
74
|
+
#
|
|
75
|
+
# @param index [Integer] Índice da aba
|
|
76
|
+
def switch_to_tab(index)
|
|
77
|
+
return unless index.between?(0, @tabs.length - 1)
|
|
78
|
+
|
|
79
|
+
@current_index = index
|
|
80
|
+
current_tab
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Aba atual
|
|
84
|
+
#
|
|
85
|
+
# @return [Tab, nil] Aba atual ou nil
|
|
86
|
+
def current_tab
|
|
87
|
+
@tabs[@current_index]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Renomeia aba atual
|
|
91
|
+
#
|
|
92
|
+
# @param title [String] Novo título
|
|
93
|
+
def rename_current_tab(title)
|
|
94
|
+
current_tab&.rename(title)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Total de abas
|
|
98
|
+
def tab_count
|
|
99
|
+
@tabs.length
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Verifica se pode criar mais abas
|
|
103
|
+
def can_create_more?
|
|
104
|
+
@tabs.length < @max_tabs
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Renderiza barra de tabs
|
|
108
|
+
#
|
|
109
|
+
# @param width [Integer] Largura disponível
|
|
110
|
+
# @return [String] Representação em string das tabs
|
|
111
|
+
def render_tab_bar(width: 80)
|
|
112
|
+
return '' if @tabs.empty?
|
|
113
|
+
|
|
114
|
+
t = Colors.theme
|
|
115
|
+
cyan = t[:accent]
|
|
116
|
+
magenta = t[:accent2]
|
|
117
|
+
white = t[:text]
|
|
118
|
+
dim = t[:dim]
|
|
119
|
+
bg = t[:bg]
|
|
120
|
+
reset = Colors::RESET
|
|
121
|
+
|
|
122
|
+
parts = @tabs.each_with_index.map do |tab, i|
|
|
123
|
+
is_active = i == @current_index
|
|
124
|
+
|
|
125
|
+
if is_active
|
|
126
|
+
"#{cyan}[#{reset} #{white}#{tab.title}#{reset} #{cyan}]#{reset}"
|
|
127
|
+
else
|
|
128
|
+
"#{dim}[#{tab.title}]#{reset}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
bar = parts.join(' ')
|
|
133
|
+
|
|
134
|
+
# Truncate if too long
|
|
135
|
+
clean_len = bar.gsub(/\e\[\d+m/, '').length
|
|
136
|
+
if clean_len > width
|
|
137
|
+
# Show current tab and neighbors
|
|
138
|
+
start_idx = [@current_index - 1, 0].max
|
|
139
|
+
end_idx = [start_idx + 2, @tabs.length - 1].min
|
|
140
|
+
|
|
141
|
+
visible = (start_idx..end_idx).map do |i|
|
|
142
|
+
tab = @tabs[i]
|
|
143
|
+
is_active = i == @current_index
|
|
144
|
+
|
|
145
|
+
if is_active
|
|
146
|
+
"#{cyan}[#{reset} #{white}#{tab.title}#{reset} #{cyan}]#{reset}"
|
|
147
|
+
else
|
|
148
|
+
"#{dim}[#{tab.title}]#{reset}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
bar = visible.join(' ')
|
|
153
|
+
bar = "#{dim}... #{reset}#{bar}" if start_idx > 0
|
|
154
|
+
bar = "#{bar} #{dim}...#{reset}" if end_idx < @tabs.length - 1
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
bar
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Lista abas para seleção
|
|
161
|
+
#
|
|
162
|
+
# @return [Array<Hash>] Lista com id, título, índice
|
|
163
|
+
def list_tabs
|
|
164
|
+
@tabs.map.with_index do |tab, i|
|
|
165
|
+
{
|
|
166
|
+
index: i,
|
|
167
|
+
id: tab.id,
|
|
168
|
+
title: tab.title,
|
|
169
|
+
message_count: tab.message_count,
|
|
170
|
+
is_active: i == @current_index
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Persistência: salva todas as abas
|
|
176
|
+
#
|
|
177
|
+
# @return [Array<Hash>] Array de hashes das abas
|
|
178
|
+
def to_a
|
|
179
|
+
@tabs.map(&:to_h)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Persistência: carrega abas
|
|
183
|
+
#
|
|
184
|
+
# @param data [Array<Hash>] Dados das abas
|
|
185
|
+
def load_from_array(data)
|
|
186
|
+
@tabs = data.map { |h| Tab.from_h(h) }
|
|
187
|
+
@current_index = 0
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|