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.
@@ -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
@@ -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 = 'master'
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
- dim = t[:dim]
24
- accent = t[:accent]
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
- bg_accent = t[:bg_accent]
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
- # Left side: Branch and Mode
33
- left = " #{dim}~/#{@branch}:#{accent}#{@branch}#{reset} "
46
+ # Badge de diretório
47
+ dir_badge = "#{dim}📁 #{dir}#{reset}"
34
48
 
35
- # Right side: Time
36
- right = " #{dim}#{time}#{reset} "
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
- # Center: Shortcuts
39
- center = "#{dim}tab#{reset} #{white}agents#{reset} #{dim}ctrl+p#{reset} #{white}commands#{reset} #{dim}ctrl+q#{reset} #{white}quit#{reset}"
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
- padding = width - left.length - right.length - clean_center - 2
44
- padding = [padding, 0].max
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
- # Status bar line
47
- "#{bg_accent}#{white} #{@mode} #{reset}#{bg}#{left}#{' ' * padding}#{center} #{right}#{reset}"
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
@@ -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