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,388 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsd
4
+ module TUI
5
+ # Mouse - Suporte a mouse e eventos de clique
6
+ #
7
+ # Suporta:
8
+ # - Cliques (left, right, middle)
9
+ # - Scroll (up, down)
10
+ # - Drag
11
+ # - Hover
12
+ # - Seleção de texto
13
+ class Mouse
14
+ attr_reader :x, :y, :button, :event_type
15
+
16
+ BUTTONS = {
17
+ 0 => :left,
18
+ 1 => :middle,
19
+ 2 => :right,
20
+ 64 => :scroll_up,
21
+ 65 => :scroll_down
22
+ }.freeze
23
+
24
+ def initialize
25
+ @enabled = false
26
+ @x = 0
27
+ @y = 0
28
+ @button = nil
29
+ @event_type = nil
30
+ @handlers = {}
31
+ @drag_start = nil
32
+ @selection = nil
33
+ end
34
+
35
+ # Habilita suporte ao mouse
36
+ def enable
37
+ return if @enabled
38
+
39
+ @enabled = true
40
+ # Habilita modo mouse do terminal (1000, 1002, 1006)
41
+ print "\e[?1000h\e[?1002h\e[?1006h" unless Gem.win_platform?
42
+ end
43
+
44
+ # Desabilita suporte ao mouse
45
+ def disable
46
+ return unless @enabled
47
+
48
+ @enabled = false
49
+ print "\e[?1000l\e[?1002l\e[?1006l" unless Gem.win_platform?
50
+ end
51
+
52
+ # Processa input de mouse
53
+ # Formato: \e[<button;x;yM (press) ou \e[<button;x;ym (release)
54
+ def parse(input)
55
+ return nil unless input.start_with?("\e[<")
56
+
57
+ # Extrai dados do formato SGR (1006)
58
+ match = input.match(/\e\[<(\d+);(\d+);(\d+)([Mm])/)
59
+ return nil unless match
60
+
61
+ button_code = match[1].to_i
62
+ @x = match[2].to_i
63
+ @y = match[3].to_i
64
+ action = match[4] == 'M' ? :press : :release
65
+
66
+ @button = BUTTONS[button_code] || :unknown
67
+ @event_type = determine_event_type(button_code, action)
68
+
69
+ MouseEvent.new(
70
+ x: @x,
71
+ y: @y,
72
+ button: @button,
73
+ type: @event_type,
74
+ raw: input
75
+ )
76
+ end
77
+
78
+ # Registra handler para evento
79
+ def on(event, &block)
80
+ @handlers[event] ||= []
81
+ @handlers[event] << block
82
+ end
83
+
84
+ # Processa evento
85
+ def handle(event)
86
+ return unless event.is_a?(MouseEvent)
87
+
88
+ # Atualiza estado de drag
89
+ update_drag_state(event)
90
+
91
+ # Chama handlers
92
+ handlers = @handlers[event.type] || []
93
+ handlers.each { |h| h.call(event) }
94
+
95
+ # Handler genérico
96
+ generic = @handlers[:any] || []
97
+ generic.each { |h| h.call(event) }
98
+ end
99
+
100
+ # Verifica se coordenada está dentro de uma região
101
+ def in_region?(x, y, width, height)
102
+ @x >= x && @x < x + width && @y >= y && @y < y + height
103
+ end
104
+
105
+ # Verifica se está em uma linha específica
106
+ def on_line?(y)
107
+ @y == y
108
+ end
109
+
110
+ # Seleção atual
111
+ def selection
112
+ @selection
113
+ end
114
+
115
+ # Inicia seleção
116
+ def start_selection(x, y)
117
+ @selection = { start_x: x, start_y: y, end_x: x, end_y: y }
118
+ end
119
+
120
+ # Atualiza seleção
121
+ def update_selection(x, y)
122
+ return unless @selection
123
+ @selection[:end_x] = x
124
+ @selection[:end_y] = y
125
+ end
126
+
127
+ # Finaliza seleção
128
+ def end_selection
129
+ sel = @selection
130
+ @selection = nil
131
+ sel
132
+ end
133
+
134
+ # Estado de drag
135
+ def dragging?
136
+ @drag_start && !@drag_start[:ended]
137
+ end
138
+
139
+ # Posição inicial do drag
140
+ def drag_start
141
+ @drag_start
142
+ end
143
+
144
+ private
145
+
146
+ def determine_event_type(button_code, action)
147
+ case button_code
148
+ when 64
149
+ :scroll_up
150
+ when 65
151
+ :scroll_down
152
+ else
153
+ action == :press ? :click : :release
154
+ end
155
+ end
156
+
157
+ def update_drag_state(event)
158
+ case event.type
159
+ when :click
160
+ @drag_start = { x: event.x, y: event.y, button: event.button, ended: false }
161
+ when :release
162
+ @drag_start[:ended] = true if @drag_start
163
+ end
164
+ end
165
+ end
166
+
167
+ # MouseEvent - Representa um evento de mouse
168
+ class MouseEvent
169
+ attr_reader :x, :y, :button, :type, :raw
170
+
171
+ def initialize(x:, y:, button:, type:, raw: nil)
172
+ @x = x
173
+ @y = y
174
+ @button = button
175
+ @type = type
176
+ @raw = raw
177
+ end
178
+
179
+ # Verifica se é clique esquerdo
180
+ def left_click?
181
+ @button == :left && @type == :click
182
+ end
183
+
184
+ # Verifica se é clique direito
185
+ def right_click?
186
+ @button == :right && @type == :click
187
+ end
188
+
189
+ # Verifica se é scroll
190
+ def scroll?
191
+ @type == :scroll_up || @type == :scroll_down
192
+ end
193
+
194
+ # Direção do scroll
195
+ def scroll_direction
196
+ return nil unless scroll?
197
+ @type == :scroll_up ? :up : :down
198
+ end
199
+
200
+ # Converte para string legível
201
+ def to_s
202
+ "#{type} at (#{x},#{y}) with #{button}"
203
+ end
204
+ end
205
+
206
+ # ClickableRegion - Região clicável na tela
207
+ class ClickableRegion
208
+ attr_reader :x, :y, :width, :height, :id, :data
209
+
210
+ def initialize(x:, y:, width:, height:, id:, data: nil, &block)
211
+ @x = x
212
+ @y = y
213
+ @width = width
214
+ @height = height
215
+ @id = id
216
+ @data = data
217
+ @callback = block
218
+ @hover = false
219
+ end
220
+
221
+ # Verifica se ponto está dentro da região
222
+ def contains?(px, py)
223
+ px >= @x && px < @x + @width && py >= @y && py < @y + @height
224
+ end
225
+
226
+ # Handler de clique
227
+ def on_click(event)
228
+ @callback&.call(event, self)
229
+ end
230
+
231
+ # Handler de hover
232
+ def on_hover(event)
233
+ @hover = true
234
+ end
235
+
236
+ # Handler de leave
237
+ def on_leave
238
+ @hover = false
239
+ end
240
+
241
+ # Está com hover?
242
+ def hover?
243
+ @hover
244
+ end
245
+
246
+ # Renderiza com highlight se hover
247
+ def render(content, theme: nil)
248
+ t = theme || Colors.theme
249
+ if @hover
250
+ "#{t[:accent]}#{content}#{Colors::RESET}"
251
+ else
252
+ content
253
+ end
254
+ end
255
+ end
256
+
257
+ # RegionManager - Gerencia regiões clicáveis
258
+ class RegionManager
259
+ def initialize
260
+ @regions = []
261
+ @mutex = Mutex.new
262
+ end
263
+
264
+ # Adiciona região
265
+ def add(region)
266
+ @mutex.synchronize { @regions << region }
267
+ end
268
+
269
+ # Remove região
270
+ def remove(id)
271
+ @mutex.synchronize { @regions.delete_if { |r| r.id == id } }
272
+ end
273
+
274
+ # Limpa todas
275
+ def clear
276
+ @mutex.synchronize { @regions.clear }
277
+ end
278
+
279
+ # Encontra região em coordenada
280
+ def find_at(x, y)
281
+ @mutex.synchronize do
282
+ @regions.find { |r| r.contains?(x, y) }
283
+ end
284
+ end
285
+
286
+ # Processa evento de mouse
287
+ def handle_event(event)
288
+ region = find_at(event.x, event.y)
289
+
290
+ case event.type
291
+ when :click
292
+ region&.on_click(event)
293
+ when :scroll_up, :scroll_down
294
+ region&.on_scroll(event)
295
+ end
296
+
297
+ # Atualiza hover states
298
+ update_hover_states(event)
299
+
300
+ region
301
+ end
302
+
303
+ # Lista todas regiões
304
+ def all
305
+ @mutex.synchronize { @regions.dup }
306
+ end
307
+
308
+ private
309
+
310
+ def update_hover_states(event)
311
+ @regions.each do |r|
312
+ was_hover = r.hover?
313
+ is_hover = r.contains?(event.x, event.y)
314
+
315
+ if !was_hover && is_hover
316
+ r.on_hover(event)
317
+ elsif was_hover && !is_hover
318
+ r.on_leave
319
+ end
320
+ end
321
+ end
322
+ end
323
+
324
+ # MouseIntegration - Integração com App
325
+ class MouseIntegration
326
+ def initialize(app)
327
+ @app = app
328
+ @mouse = Mouse.new
329
+ @regions = RegionManager.new
330
+ setup_handlers
331
+ end
332
+
333
+ def enable
334
+ @mouse.enable
335
+ end
336
+
337
+ def disable
338
+ @mouse.disable
339
+ end
340
+
341
+ def process(input)
342
+ event = @mouse.parse(input)
343
+ return nil unless event
344
+
345
+ @mouse.handle(event)
346
+ @regions.handle_event(event)
347
+
348
+ # Integração específica com App
349
+ handle_app_interaction(event)
350
+
351
+ event
352
+ end
353
+
354
+ def add_clickable_region(x, y, width, height, id, data = nil, &block)
355
+ @regions.add(ClickableRegion.new(
356
+ x: x, y: y, width: width, height: height,
357
+ id: id, data: data, &block
358
+ ))
359
+ end
360
+
361
+ private
362
+
363
+ def setup_handlers
364
+ @mouse.on(:scroll_up) do |event|
365
+ # Scroll up no output
366
+ @app.scroll_output(-3)
367
+ end
368
+
369
+ @mouse.on(:scroll_down) do |event|
370
+ # Scroll down no output
371
+ @app.scroll_output(3)
372
+ end
373
+
374
+ @mouse.on(:click) do |event|
375
+ # Verifica se clicou em elemento específico
376
+ if event.y == 0
377
+ # Header area - toggle palette
378
+ @app.toggle_palette if event.x < 20
379
+ end
380
+ end
381
+ end
382
+
383
+ def handle_app_interaction(event)
384
+ # Delega para regiões ou comportamentos padrão
385
+ end
386
+ end
387
+ end
388
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module Gsd
7
+ module TUI
8
+ # Persistence - Camada de persistência para o TUI
9
+ #
10
+ # Gerencia:
11
+ # - Save/restore de estado do TUI
12
+ # - Persistência de conversas
13
+ # - Checkpoints
14
+ # - Auto-save
15
+ class Persistence
16
+ SESSIONS_DIR = File.expand_path('~/.gsd/sessions').freeze
17
+
18
+ attr_reader :sessions_dir
19
+
20
+ def initialize(sessions_dir: SESSIONS_DIR)
21
+ @sessions_dir = sessions_dir
22
+ ensure_directory
23
+ end
24
+
25
+ # Salva um estado de sessão
26
+ #
27
+ # @param session_id [String] ID da sessão
28
+ # @param data [Hash] Dados a serem salvos
29
+ # @return [String] Caminho do arquivo salvo
30
+ def save(session_id:, data:)
31
+ filepath = session_path(session_id)
32
+
33
+ File.write(filepath, JSON.pretty_generate(data))
34
+ filepath
35
+ end
36
+
37
+ # Carrega um estado de sessão
38
+ #
39
+ # @param session_id [String] ID da sessão
40
+ # @return [Hash, nil] Dados da sessão ou nil se não existir
41
+ def load(session_id:)
42
+ filepath = session_path(session_id)
43
+ return nil unless File.exist?(filepath)
44
+
45
+ JSON.parse(File.read(filepath), symbolize_names: true)
46
+ rescue JSON::ParserError => e
47
+ puts "Error loading session #{session_id}: #{e.message}"
48
+ nil
49
+ end
50
+
51
+ # Lista todas as sessões disponíveis
52
+ #
53
+ # @return [Array<Hash>] Lista de metadados das sessões
54
+ def list_sessions
55
+ return [] unless Dir.exist?(@sessions_dir)
56
+
57
+ Dir.glob(File.join(@sessions_dir, '*.json')).map do |filepath|
58
+ begin
59
+ data = JSON.parse(File.read(filepath), symbolize_names: true)
60
+ {
61
+ id: File.basename(filepath, '.json'),
62
+ name: data[:name] || 'Unnamed Session',
63
+ created_at: data[:created_at],
64
+ updated_at: data[:updated_at],
65
+ message_count: data[:output]&.length || 0,
66
+ agent_count: data[:swarm_stats]&.dig(:total_agents) || 0
67
+ }
68
+ rescue => e
69
+ nil
70
+ end
71
+ end.compact.sort_by { |s| s[:updated_at] || s[:created_at] || '' }.reverse
72
+ end
73
+
74
+ # Deleta uma sessão
75
+ #
76
+ # @param session_id [String] ID da sessão
77
+ def delete(session_id:)
78
+ filepath = session_path(session_id)
79
+ File.delete(filepath) if File.exist?(filepath)
80
+ end
81
+
82
+ # Cria um checkpoint (snapshot) da sessão
83
+ #
84
+ # @param session_id [String] ID da sessão
85
+ # @param checkpoint_name [String] Nome do checkpoint
86
+ def create_checkpoint(session_id:, checkpoint_name:)
87
+ data = load(session_id: session_id)
88
+ return unless data
89
+
90
+ checkpoint_id = "#{session_id}_#{checkpoint_name}_#{Time.now.to_i}"
91
+ checkpoint_path = File.join(@sessions_dir, 'checkpoints', "#{checkpoint_id}.json")
92
+
93
+ FileUtils.mkdir_p(File.dirname(checkpoint_path))
94
+ File.write(checkpoint_path, JSON.pretty_generate(data))
95
+
96
+ checkpoint_id
97
+ end
98
+
99
+ # Restaura de um checkpoint
100
+ #
101
+ # @param checkpoint_id [String] ID do checkpoint
102
+ # @return [Hash, nil] Dados do checkpoint
103
+ def restore_checkpoint(checkpoint_id:)
104
+ checkpoint_path = File.join(@sessions_dir, 'checkpoints', "#{checkpoint_id}.json")
105
+ return nil unless File.exist?(checkpoint_path)
106
+
107
+ JSON.parse(File.read(checkpoint_path), symbolize_names: true)
108
+ end
109
+
110
+ # Lista checkpoints de uma sessão
111
+ #
112
+ # @param session_id [String] ID da sessão
113
+ # @return [Array<Hash>] Lista de checkpoints
114
+ def list_checkpoints(session_id:)
115
+ pattern = File.join(@sessions_dir, 'checkpoints', "#{session_id}_*.json")
116
+
117
+ Dir.glob(pattern).map do |filepath|
118
+ filename = File.basename(filepath, '.json')
119
+ parts = filename.split('_')
120
+
121
+ {
122
+ id: filename,
123
+ name: parts[1..-2].join('_'),
124
+ timestamp: parts.last.to_i,
125
+ created_at: Time.at(parts.last.to_i).to_s
126
+ }
127
+ end.sort_by { |c| c[:timestamp] }.reverse
128
+ end
129
+
130
+ # Exporta sessão para formato legível (Markdown)
131
+ #
132
+ # @param session_id [String] ID da sessão
133
+ # @param format [Symbol] Formato (:markdown, :json)
134
+ # @return [String] Conteúdo exportado
135
+ def export(session_id:, format: :markdown)
136
+ data = load(session_id: session_id)
137
+ return nil unless data
138
+
139
+ case format
140
+ when :markdown
141
+ export_to_markdown(data)
142
+ when :json
143
+ JSON.pretty_generate(data)
144
+ else
145
+ JSON.pretty_generate(data)
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def ensure_directory
152
+ FileUtils.mkdir_p(@sessions_dir)
153
+ FileUtils.mkdir_p(File.join(@sessions_dir, 'checkpoints'))
154
+ end
155
+
156
+ def session_path(session_id)
157
+ File.join(@sessions_dir, "#{session_id}.json")
158
+ end
159
+
160
+ def export_to_markdown(data)
161
+ lines = []
162
+ lines << "# Session: #{data[:name] || 'Unnamed'}"
163
+ lines << ""
164
+ lines << "**Created:** #{data[:created_at]}"
165
+ lines << "**Updated:** #{data[:updated_at]}"
166
+ lines << ""
167
+ lines << "## Conversation"
168
+ lines << ""
169
+
170
+ data[:output]&.each do |msg|
171
+ case msg[:type]
172
+ when :user
173
+ lines << "### User"
174
+ lines << ""
175
+ lines << msg[:text]
176
+ lines << ""
177
+ when :assistant
178
+ lines << "### Assistant"
179
+ lines << ""
180
+ lines << msg[:text]
181
+ lines << ""
182
+ when :system
183
+ lines << "*#{msg[:text]}*"
184
+ lines << ""
185
+ end
186
+ end
187
+
188
+ lines.join("\n")
189
+ end
190
+ end
191
+ end
192
+ end