fantasy-cli 1.2.6

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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +456 -0
  4. data/bin/gsd +8 -0
  5. data/bin/gsd-core-darwin-amd64 +0 -0
  6. data/bin/gsd-core-darwin-arm64 +0 -0
  7. data/bin/gsd-core-linux-amd64 +0 -0
  8. data/bin/gsd-core-linux-arm64 +0 -0
  9. data/bin/gsd-core-windows-amd64.exe +0 -0
  10. data/bin/gsd-core-windows-arm64.exe +0 -0
  11. data/bin/gsd-core.exe +0 -0
  12. data/lib/gsd/agents/coordinator.rb +195 -0
  13. data/lib/gsd/agents/task_manager.rb +158 -0
  14. data/lib/gsd/agents/worker.rb +162 -0
  15. data/lib/gsd/agents.rb +30 -0
  16. data/lib/gsd/ai/chat.rb +486 -0
  17. data/lib/gsd/ai/cli.rb +248 -0
  18. data/lib/gsd/ai/command_parser.rb +97 -0
  19. data/lib/gsd/ai/commands/base.rb +42 -0
  20. data/lib/gsd/ai/commands/clear.rb +20 -0
  21. data/lib/gsd/ai/commands/context.rb +30 -0
  22. data/lib/gsd/ai/commands/cost.rb +30 -0
  23. data/lib/gsd/ai/commands/export.rb +42 -0
  24. data/lib/gsd/ai/commands/help.rb +61 -0
  25. data/lib/gsd/ai/commands/model.rb +67 -0
  26. data/lib/gsd/ai/commands/reset.rb +22 -0
  27. data/lib/gsd/ai/config.rb +256 -0
  28. data/lib/gsd/ai/context.rb +324 -0
  29. data/lib/gsd/ai/cost_tracker.rb +361 -0
  30. data/lib/gsd/ai/git_context.rb +169 -0
  31. data/lib/gsd/ai/history.rb +384 -0
  32. data/lib/gsd/ai/providers/anthropic.rb +429 -0
  33. data/lib/gsd/ai/providers/base.rb +282 -0
  34. data/lib/gsd/ai/providers/lmstudio.rb +279 -0
  35. data/lib/gsd/ai/providers/ollama.rb +336 -0
  36. data/lib/gsd/ai/providers/openai.rb +396 -0
  37. data/lib/gsd/ai/providers/openrouter.rb +429 -0
  38. data/lib/gsd/ai/reference_resolver.rb +225 -0
  39. data/lib/gsd/ai/repl.rb +349 -0
  40. data/lib/gsd/ai/streaming.rb +438 -0
  41. data/lib/gsd/ai/ui.rb +429 -0
  42. data/lib/gsd/buddy/cli.rb +284 -0
  43. data/lib/gsd/buddy/gacha.rb +148 -0
  44. data/lib/gsd/buddy/renderer.rb +108 -0
  45. data/lib/gsd/buddy/species.rb +190 -0
  46. data/lib/gsd/buddy/stats.rb +156 -0
  47. data/lib/gsd/buddy.rb +28 -0
  48. data/lib/gsd/cli.rb +455 -0
  49. data/lib/gsd/commands.rb +198 -0
  50. data/lib/gsd/config.rb +183 -0
  51. data/lib/gsd/error.rb +188 -0
  52. data/lib/gsd/frontmatter.rb +123 -0
  53. data/lib/gsd/go/bridge.rb +173 -0
  54. data/lib/gsd/history.rb +76 -0
  55. data/lib/gsd/milestone.rb +75 -0
  56. data/lib/gsd/output.rb +184 -0
  57. data/lib/gsd/phase.rb +102 -0
  58. data/lib/gsd/plugins/base.rb +92 -0
  59. data/lib/gsd/plugins/cli.rb +330 -0
  60. data/lib/gsd/plugins/config.rb +164 -0
  61. data/lib/gsd/plugins/hooks.rb +132 -0
  62. data/lib/gsd/plugins/installer.rb +158 -0
  63. data/lib/gsd/plugins/loader.rb +122 -0
  64. data/lib/gsd/plugins/manager.rb +187 -0
  65. data/lib/gsd/plugins/marketplace.rb +142 -0
  66. data/lib/gsd/plugins/sandbox.rb +114 -0
  67. data/lib/gsd/plugins/search.rb +131 -0
  68. data/lib/gsd/plugins/validator.rb +157 -0
  69. data/lib/gsd/plugins.rb +48 -0
  70. data/lib/gsd/profile.rb +127 -0
  71. data/lib/gsd/research.rb +85 -0
  72. data/lib/gsd/roadmap.rb +90 -0
  73. data/lib/gsd/skills/bundled/commit.md +58 -0
  74. data/lib/gsd/skills/bundled/debug.md +28 -0
  75. data/lib/gsd/skills/bundled/explain.md +41 -0
  76. data/lib/gsd/skills/bundled/plan.md +42 -0
  77. data/lib/gsd/skills/bundled/verify.md +26 -0
  78. data/lib/gsd/skills/loader.rb +189 -0
  79. data/lib/gsd/state.rb +102 -0
  80. data/lib/gsd/template.rb +106 -0
  81. data/lib/gsd/tools/ask_user_question.rb +179 -0
  82. data/lib/gsd/tools/base.rb +204 -0
  83. data/lib/gsd/tools/bash.rb +246 -0
  84. data/lib/gsd/tools/file_edit.rb +297 -0
  85. data/lib/gsd/tools/file_read.rb +199 -0
  86. data/lib/gsd/tools/file_write.rb +153 -0
  87. data/lib/gsd/tools/glob.rb +202 -0
  88. data/lib/gsd/tools/grep.rb +227 -0
  89. data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
  90. data/lib/gsd/tools/gsd_phase.rb +140 -0
  91. data/lib/gsd/tools/gsd_roadmap.rb +108 -0
  92. data/lib/gsd/tools/gsd_state.rb +143 -0
  93. data/lib/gsd/tools/gsd_template.rb +157 -0
  94. data/lib/gsd/tools/gsd_verify.rb +159 -0
  95. data/lib/gsd/tools/registry.rb +103 -0
  96. data/lib/gsd/tools/task.rb +235 -0
  97. data/lib/gsd/tools/todo_write.rb +290 -0
  98. data/lib/gsd/tools/web.rb +260 -0
  99. data/lib/gsd/tui/app.rb +366 -0
  100. data/lib/gsd/tui/auto_complete.rb +79 -0
  101. data/lib/gsd/tui/colors.rb +111 -0
  102. data/lib/gsd/tui/command_palette.rb +126 -0
  103. data/lib/gsd/tui/header.rb +38 -0
  104. data/lib/gsd/tui/input_box.rb +199 -0
  105. data/lib/gsd/tui/spinner.rb +40 -0
  106. data/lib/gsd/tui/status_bar.rb +51 -0
  107. data/lib/gsd/tui.rb +17 -0
  108. data/lib/gsd/validator.rb +216 -0
  109. data/lib/gsd/verify.rb +175 -0
  110. data/lib/gsd/version.rb +5 -0
  111. data/lib/gsd/workstream.rb +91 -0
  112. metadata +231 -0
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gsd/tools/base'
4
+ require 'gsd/go/bridge'
5
+ require 'net/http'
6
+ require 'json'
7
+
8
+ module Gsd
9
+ module Tools
10
+ # WebSearchTool - Pesquisa na web usando Brave API
11
+ class WebSearchTool < Base
12
+ class << self
13
+ tool_name('web_search')
14
+ tool_description('Search the web using Brave Search API')
15
+ tool_input_schema({
16
+ type: 'object',
17
+ properties: {
18
+ query: {
19
+ type: 'string',
20
+ description: 'Search query'
21
+ },
22
+ count: {
23
+ type: 'integer',
24
+ description: 'Number of results (default: 10, max: 20)',
25
+ default: 10
26
+ },
27
+ freshness: {
28
+ type: 'string',
29
+ description: 'Results freshness: day, week, month',
30
+ enum: ['day', 'week', 'month']
31
+ }
32
+ },
33
+ required: ['query']
34
+ })
35
+ end
36
+
37
+ def execute(args)
38
+ query = args[:query] || args['query']
39
+ count = args[:count] || args['count'] || 10
40
+ freshness = args[:freshness] || args['freshness']
41
+
42
+ raise ArgumentError, 'Query is required' unless query
43
+
44
+ log_debug("Web search: #{query}")
45
+
46
+ # Tenta usar Go bridge primeiro
47
+ begin
48
+ params = {
49
+ 'search' => true,
50
+ 'query' => query,
51
+ 'count' => [count, 20].min,
52
+ 'freshness' => freshness
53
+ }
54
+
55
+ result = Gsd::Go::Bridge.call('research', params, cwd: @cwd)
56
+
57
+ if result['success']
58
+ return {
59
+ success: true,
60
+ operation: 'search',
61
+ query: query,
62
+ data: result['data'],
63
+ cwd: @cwd
64
+ }
65
+ end
66
+ rescue => e
67
+ log_debug("Go bridge failed: #{e.message}")
68
+ # Fallback para implementação Ruby
69
+ end
70
+
71
+ # Implementação Ruby fallback
72
+ search_with_brave(query, count, freshness)
73
+ rescue => e
74
+ error_result(e, 'search')
75
+ end
76
+
77
+ def self.safe?(args); true; end
78
+ def self.read_only?(args); true; end
79
+
80
+ private
81
+
82
+ def search_with_brave(query, count, freshness)
83
+ api_key = ENV['BRAVE_API_KEY']
84
+
85
+ unless api_key
86
+ return {
87
+ success: false,
88
+ error: 'missing_api_key',
89
+ message: 'BRAVE_API_KEY environment variable not set',
90
+ operation: 'search'
91
+ }
92
+ end
93
+
94
+ uri = URI('https://api.search.brave.com/res/v1/web/search')
95
+ uri.query = URI.encode_www_form({
96
+ q: query,
97
+ count: [count, 20].min,
98
+ freshness: freshness
99
+ })
100
+
101
+ http = Net::HTTP.new(uri.host, uri.port)
102
+ http.use_ssl = true
103
+
104
+ request = Net::HTTP::Get.new(uri.path_query)
105
+ request['Accept'] = 'application/json'
106
+ request['X-Subscription-Token'] = api_key
107
+
108
+ response = http.request(request)
109
+
110
+ if response.code == '200'
111
+ data = JSON.parse(response.body)
112
+ {
113
+ success: true,
114
+ operation: 'search',
115
+ query: query,
116
+ data: data,
117
+ cwd: @cwd
118
+ }
119
+ else
120
+ {
121
+ success: false,
122
+ error: 'api_error',
123
+ message: "Brave API error: #{response.code}",
124
+ operation: 'search'
125
+ }
126
+ end
127
+ end
128
+
129
+ def error_result(error, operation)
130
+ log_debug("Error: #{error.message}")
131
+ {
132
+ success: false,
133
+ error: 'search_error',
134
+ message: error.message,
135
+ operation: operation
136
+ }
137
+ end
138
+ end
139
+
140
+ # WebFetchTool - Fetch conteúdo de URL
141
+ class WebFetchTool < Base
142
+ class << self
143
+ tool_name('web_fetch')
144
+ tool_description('Fetch content from a URL')
145
+ tool_input_schema({
146
+ type: 'object',
147
+ properties: {
148
+ url: {
149
+ type: 'string',
150
+ description: 'URL to fetch'
151
+ },
152
+ max_length: {
153
+ type: 'integer',
154
+ description: 'Maximum content length (default: 50000)',
155
+ default: 50000
156
+ }
157
+ },
158
+ required: ['url']
159
+ })
160
+ end
161
+
162
+ def execute(args)
163
+ url = args[:url] || args['url']
164
+ max_length = args[:max_length] || args['max_length'] || 50000
165
+
166
+ raise ArgumentError, 'URL is required' unless url
167
+ raise ArgumentError, 'Invalid URL' unless valid_url?(url)
168
+
169
+ log_debug("Web fetch: #{url}")
170
+
171
+ # Tenta usar Go bridge primeiro
172
+ begin
173
+ result = Gsd::Go::Bridge.call('research', {
174
+ 'fetch' => true,
175
+ 'url' => url,
176
+ 'max_length' => max_length
177
+ }, cwd: @cwd)
178
+
179
+ if result['success']
180
+ return {
181
+ success: true,
182
+ operation: 'fetch',
183
+ url: url,
184
+ data: result['data'],
185
+ cwd: @cwd
186
+ }
187
+ end
188
+ rescue => e
189
+ log_debug("Go bridge failed: #{e.message}")
190
+ # Fallback para implementação Ruby
191
+ end
192
+
193
+ # Implementação Ruby fallback
194
+ fetch_url(url, max_length)
195
+ rescue => e
196
+ error_result(e, 'fetch')
197
+ end
198
+
199
+ def self.safe?(args); true; end
200
+ def self.read_only?(args); true; end
201
+
202
+ private
203
+
204
+ def valid_url?(url)
205
+ url.start_with?('http://', 'https://')
206
+ end
207
+
208
+ def fetch_url(url, max_length)
209
+ uri = URI(url)
210
+ http = Net::HTTP.new(uri.host, uri.port)
211
+ http.use_ssl = (uri.scheme == 'https')
212
+ http.read_timeout = 10
213
+
214
+ request = Net::HTTP::Get.new(uri.path_query)
215
+ request['User-Agent'] = 'GSD-Tools/1.0'
216
+
217
+ response = http.request(request)
218
+
219
+ if response.code == '200'
220
+ content = response.body
221
+ content = content[0...max_length] if content.length > max_length
222
+
223
+ {
224
+ success: true,
225
+ operation: 'fetch',
226
+ url: url,
227
+ data: {
228
+ content: content,
229
+ length: content.length,
230
+ truncated: response.body.length > max_length,
231
+ content_type: response['Content-Type']
232
+ },
233
+ cwd: @cwd
234
+ }
235
+ else
236
+ {
237
+ success: false,
238
+ error: 'fetch_error',
239
+ message: "HTTP #{response.code}",
240
+ operation: 'fetch'
241
+ }
242
+ end
243
+ end
244
+
245
+ def error_result(error, operation)
246
+ log_debug("Error: #{error.message}")
247
+ {
248
+ success: false,
249
+ error: 'fetch_error',
250
+ message: error.message,
251
+ operation: operation
252
+ }
253
+ end
254
+ end
255
+ end
256
+ end
257
+
258
+ # Registra as tools
259
+ Gsd::Tools::Registry.register('web_search', Gsd::Tools::WebSearchTool, category: :web)
260
+ Gsd::Tools::Registry.register('web_fetch', Gsd::Tools::WebFetchTool, category: :web)
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'gsd/tui/colors'
5
+ require 'gsd/tui/header'
6
+ require 'gsd/tui/input_box'
7
+ require 'gsd/tui/status_bar'
8
+ require 'gsd/tui/auto_complete'
9
+ require 'gsd/tui/command_palette'
10
+ require 'gsd/tui/spinner'
11
+
12
+ module Gsd
13
+ module TUI
14
+ class App
15
+ def initialize(theme: :fantasy, header_style: :pixel)
16
+ @theme = theme
17
+ @header_style = header_style
18
+ @running = false
19
+ @is_windows = RUBY_PLATFORM =~ /mswin|mingw/
20
+
21
+ @header = Header.new(style: header_style)
22
+ @input_box = InputBox.new
23
+ @status_bar = StatusBar.new
24
+ @auto_complete = AutoComplete.new
25
+ @command_palette = CommandPalette.new
26
+ @spinner = Spinner.new
27
+
28
+ @agents = ['Code', 'Kilo Auto Free', 'Kilo Gateway']
29
+ @selected_agent = 1
30
+
31
+ @history = []
32
+ @history_index = -1
33
+ @output = []
34
+ @frame_count = 0
35
+ @render_count = 0
36
+ end
37
+
38
+ def run
39
+ @running = true
40
+
41
+ setup_console
42
+
43
+ trap(:WINCH) { full_render if @running } unless @is_windows
44
+
45
+ print Colors::CURSOR_HIDE
46
+
47
+ if @is_windows
48
+ system('cls')
49
+ else
50
+ print Colors::CLEAR_SCREEN
51
+ end
52
+ print Colors::HOME
53
+
54
+ @output << { type: :system, text: 'Welcome to Fantasy CLI v1.2.0!' }
55
+ @output << { type: :system, text: 'Press Ctrl+P for commands, Ctrl+Q to quit.' }
56
+
57
+ full_render
58
+
59
+ while @running
60
+ @frame_count += 1
61
+ @spinner.next_frame if (@frame_count % 5).zero?
62
+
63
+ handle_input
64
+ end
65
+
66
+ cleanup_console
67
+ end
68
+
69
+ def stop
70
+ @running = false
71
+ end
72
+
73
+ private
74
+
75
+ # Setup console for raw input (Windows-safe)
76
+ def setup_console
77
+ if @is_windows
78
+ # Windows: STDIN.getch already works in raw mode
79
+ # No need for stty
80
+ begin
81
+ $stdin.echo = false if $stdin.respond_to?(:echo=)
82
+ rescue StandardError
83
+ # Ignore if not supported
84
+ end
85
+ else
86
+ # Unix: use stty
87
+ begin
88
+ system('stty raw -echo 2>/dev/null')
89
+ rescue StandardError
90
+ # Ignore errors
91
+ end
92
+ end
93
+ end
94
+
95
+ # Restore console (Windows-safe)
96
+ def cleanup_console
97
+ if @is_windows
98
+ begin
99
+ $stdin.echo = true if $stdin.respond_to?(:echo=)
100
+ rescue StandardError
101
+ # Ignore
102
+ end
103
+ else
104
+ begin
105
+ system('stty -raw echo 2>/dev/null')
106
+ rescue StandardError
107
+ # Ignore
108
+ end
109
+ end
110
+
111
+ # Always show cursor and clear screen
112
+ print Colors::CURSOR_SHOW + Colors::CLEAR_SCREEN + Colors::HOME
113
+ puts 'Goodbye!'
114
+ end
115
+
116
+ # Full render (clears and redraws everything)
117
+ def full_render
118
+ @render_count += 1
119
+
120
+ # Move to top (ANSI or cls fallback)
121
+ if @is_windows
122
+ # Windows: cls is more reliable than ANSI HOME
123
+ system('cls')
124
+ else
125
+ # Unix: ANSI HOME works
126
+ print Colors::HOME
127
+ end
128
+
129
+ lines = []
130
+
131
+ # Header
132
+ lines << @header.render
133
+
134
+ if @command_palette.visible?
135
+ lines << @command_palette.render(width: 58)
136
+ else
137
+ # Output messages
138
+ @output.each do |msg|
139
+ t = Colors.theme
140
+ case msg[:type]
141
+ when :user
142
+ lines << "#{t[:accent]}❯#{Colors::RESET} #{t[:text]}#{msg[:text]}#{Colors::RESET}"
143
+ when :assistant
144
+ lines << "#{@spinner.render}#{t[:success]}❮#{Colors::RESET} #{t[:text]}#{msg[:text]}#{Colors::RESET}"
145
+ when :system
146
+ lines << "#{t[:warning]}⚡ #{t[:text]}#{msg[:text]}#{Colors::RESET}"
147
+ end
148
+ end
149
+
150
+ # Fill remaining lines for consistent layout
151
+ lines_needed = 4 - @output.length
152
+ lines_needed.times { lines << '' } if lines_needed.positive?
153
+
154
+ # Auto-complete
155
+ lines << if @auto_complete.has_suggestions?
156
+ @auto_complete.render(@input_box.text, width: 60)
157
+ else
158
+ ''
159
+ end
160
+
161
+ # Input box
162
+ lines << @input_box.render
163
+
164
+ # Agent selector
165
+ lines << render_agent_selector
166
+ end
167
+
168
+ # Status bar
169
+ lines << @status_bar.render
170
+
171
+ # Join and print
172
+ output_text = lines.join("\n")
173
+ print output_text
174
+
175
+ # Flush output
176
+ $stdout.flush
177
+ end
178
+
179
+ def handle_input
180
+ begin
181
+ char = $stdin.getch
182
+ rescue EOFError, IOError
183
+ return
184
+ end
185
+
186
+ if char.nil?
187
+ stop
188
+ return
189
+ end
190
+
191
+ # Debug: Log raw character
192
+ puts "DEBUG: char=#{char.inspect} ord=#{char.ord}" if ENV['TUI_DEBUG'] == '1'
193
+
194
+ # Normalize common Windows keys
195
+ case char
196
+ when "\r" # Windows Enter
197
+ char = "\n"
198
+ when "\x08" # Windows Backspace
199
+ char = "\x7F"
200
+ when "\x03" # Ctrl+C
201
+ char = "\cC"
202
+ when "\x10" # Ctrl+P (16 = 0x10)
203
+ char = "\cP"
204
+ when "\x11" # Ctrl+Q (17 = 0x11)
205
+ char = "\cQ"
206
+ end
207
+
208
+ if @command_palette.visible?
209
+ handle_palette_input(char)
210
+ full_render
211
+ return
212
+ end
213
+
214
+ case char
215
+ when "\e"
216
+ handle_escape_sequence
217
+ full_render
218
+ when "\n", "\r"
219
+ handle_enter
220
+ full_render
221
+ when "\x7F", "\b"
222
+ @input_box.backspace
223
+ @auto_complete.update(@input_box.text)
224
+ full_render
225
+ when "\cC", "\cQ"
226
+ stop
227
+ when "\cP"
228
+ @command_palette.show
229
+ full_render
230
+ when "\t"
231
+ handle_tab
232
+ full_render
233
+ else
234
+ if char.length == 1 && char.match?(/[[:print:]]/) && char.ord >= 32
235
+ @input_box.add_char(char)
236
+ @auto_complete.update(@input_box.text)
237
+ full_render
238
+ end
239
+ end
240
+ end
241
+
242
+ def handle_escape_sequence
243
+ $stdin.getch
244
+ c2 = $stdin.getch
245
+
246
+ case c2
247
+ when 'A'
248
+ handle_up_arrow
249
+ full_render
250
+ when 'B'
251
+ handle_down_arrow
252
+ full_render
253
+ when 'D'
254
+ @input_box.move_left
255
+ full_render
256
+ when 'C'
257
+ @input_box.move_right
258
+ full_render
259
+ end
260
+ rescue EOFError, IOError
261
+ nil
262
+ end
263
+
264
+ def handle_palette_input(char)
265
+ case char
266
+ when "\r", "\n"
267
+ action = @command_palette.execute
268
+ handle_palette_action(action)
269
+ when "\x7F", "\b"
270
+ @command_palette.backspace
271
+ when "\cP"
272
+ @command_palette.hide
273
+ when "\cQ"
274
+ stop
275
+ else
276
+ @command_palette.add_char(char) if char.length == 1 && char.match?(/[[:print:]]/) && char.ord >= 32
277
+ end
278
+ end
279
+
280
+ def handle_palette_action(action)
281
+ case action
282
+ when :quit
283
+ stop
284
+ when :theme_fantasy, :theme_kilo, :theme_dark, :theme_light, :theme_nord
285
+ theme_name = action.to_s.split('_').last.to_sym
286
+ set_theme(theme_name)
287
+ else
288
+ @output << { type: :system, text: "Action: #{action} (WIP)" }
289
+ end
290
+ full_render
291
+ end
292
+
293
+ def set_theme(theme_name)
294
+ @theme = theme_name
295
+ @output << { type: :system, text: "Theme: #{Colors.theme(theme_name)[:name]}" }
296
+ end
297
+
298
+ def handle_enter
299
+ text = @input_box.submit
300
+ return if text.strip.empty?
301
+
302
+ @history << text
303
+ @history_index = @history.length
304
+ @output << { type: :user, text: text }
305
+ @auto_complete.clear
306
+
307
+ @spinner.start
308
+
309
+ @output << { type: :assistant, text: "Received: #{text}" }
310
+ @output = @output.last(20)
311
+
312
+ # Simulate processing delay
313
+ sleep(0.3)
314
+ @spinner.stop
315
+ end
316
+
317
+ def handle_up_arrow
318
+ return if @history.empty?
319
+
320
+ @history_index = [@history_index - 1, 0].max
321
+ @input_box.clear
322
+ @history[@history_index]&.each_char { |c| @input_box.add_char(c) }
323
+ end
324
+
325
+ def handle_down_arrow
326
+ return if @history.empty?
327
+
328
+ @history_index = [@history_index + 1, @history.length].min
329
+ @input_box.clear
330
+ return unless @history_index < @history.length
331
+
332
+ @history[@history_index]&.each_char { |c| @input_box.add_char(c) }
333
+ end
334
+
335
+ def handle_tab
336
+ if @auto_complete.has_suggestions?
337
+ suggestion = @auto_complete.selected
338
+ if suggestion
339
+ @input_box.clear
340
+ suggestion.each_char { |c| @input_box.add_char(c) }
341
+ end
342
+ else
343
+ @selected_agent = (@selected_agent + 1) % @agents.length
344
+ end
345
+ end
346
+
347
+ def render_agent_selector
348
+ t = Colors.theme
349
+ accent = t[:accent]
350
+ white = t[:text]
351
+ dim = t[:dim]
352
+ reset = Colors::RESET
353
+
354
+ parts = @agents.each_with_index.map do |agent, idx|
355
+ if idx == @selected_agent
356
+ "#{accent}[#{reset}#{white}#{agent}#{reset}#{accent}]#{reset}"
357
+ else
358
+ "#{dim}[#{agent}]#{reset}"
359
+ end
360
+ end
361
+
362
+ " #{parts.join(' ')}"
363
+ end
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gsd/tui/colors'
4
+
5
+ module Gsd
6
+ module TUI
7
+ class AutoComplete
8
+ COMMANDS = %w[
9
+ help version hello ai state phase roadmap plugins agent buddy tui
10
+ config list install uninstall search info enable disable
11
+ spawn send stop output status
12
+ pull get stats feed play evolve
13
+ load json update patch get find list next-decimal add
14
+ get-phase analyze
15
+ ].freeze
16
+
17
+ def initialize
18
+ @suggestions = []
19
+ @selected_index = 0
20
+ end
21
+
22
+ def update(text)
23
+ return [] if text.strip.empty?
24
+
25
+ @suggestions = COMMANDS.select { |cmd| cmd.start_with?(text.downcase) }
26
+ @selected_index = 0
27
+ @suggestions
28
+ end
29
+
30
+ def next
31
+ return nil if @suggestions.empty?
32
+
33
+ @selected_index = (@selected_index + 1) % @suggestions.length
34
+ @suggestions[@selected_index]
35
+ end
36
+
37
+ def prev
38
+ return nil if @suggestions.empty?
39
+
40
+ @selected_index = (@selected_index - 1) % @suggestions.length
41
+ @selected_index = @suggestions.length - 1 if @selected_index.negative?
42
+ @suggestions[@selected_index]
43
+ end
44
+
45
+ def selected
46
+ @suggestions[@selected_index]
47
+ end
48
+
49
+ def render(_text, width: 60)
50
+ return '' if @suggestions.empty?
51
+
52
+ t = Colors.theme
53
+ bg = t[:bg]
54
+ selected_fg = t[:accent]
55
+ dim = t[:dim]
56
+ reset = Colors::RESET
57
+
58
+ display = @suggestions.first(3).map.with_index do |sug, i|
59
+ if i == @selected_index
60
+ " #{bg}#{selected_fg} #{sug} #{reset}"
61
+ else
62
+ " #{dim}#{sug}#{reset}"
63
+ end
64
+ end.join(' ')
65
+
66
+ display[0...width]
67
+ end
68
+
69
+ def has_suggestions?
70
+ !@suggestions.empty?
71
+ end
72
+
73
+ def clear
74
+ @suggestions = []
75
+ @selected_index = 0
76
+ end
77
+ end
78
+ end
79
+ end