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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +456 -0
- data/bin/gsd +8 -0
- data/bin/gsd-core-darwin-amd64 +0 -0
- data/bin/gsd-core-darwin-arm64 +0 -0
- data/bin/gsd-core-linux-amd64 +0 -0
- data/bin/gsd-core-linux-arm64 +0 -0
- data/bin/gsd-core-windows-amd64.exe +0 -0
- data/bin/gsd-core-windows-arm64.exe +0 -0
- data/bin/gsd-core.exe +0 -0
- data/lib/gsd/agents/coordinator.rb +195 -0
- data/lib/gsd/agents/task_manager.rb +158 -0
- data/lib/gsd/agents/worker.rb +162 -0
- data/lib/gsd/agents.rb +30 -0
- data/lib/gsd/ai/chat.rb +486 -0
- data/lib/gsd/ai/cli.rb +248 -0
- data/lib/gsd/ai/command_parser.rb +97 -0
- data/lib/gsd/ai/commands/base.rb +42 -0
- data/lib/gsd/ai/commands/clear.rb +20 -0
- data/lib/gsd/ai/commands/context.rb +30 -0
- data/lib/gsd/ai/commands/cost.rb +30 -0
- data/lib/gsd/ai/commands/export.rb +42 -0
- data/lib/gsd/ai/commands/help.rb +61 -0
- data/lib/gsd/ai/commands/model.rb +67 -0
- data/lib/gsd/ai/commands/reset.rb +22 -0
- data/lib/gsd/ai/config.rb +256 -0
- data/lib/gsd/ai/context.rb +324 -0
- data/lib/gsd/ai/cost_tracker.rb +361 -0
- data/lib/gsd/ai/git_context.rb +169 -0
- data/lib/gsd/ai/history.rb +384 -0
- data/lib/gsd/ai/providers/anthropic.rb +429 -0
- data/lib/gsd/ai/providers/base.rb +282 -0
- data/lib/gsd/ai/providers/lmstudio.rb +279 -0
- data/lib/gsd/ai/providers/ollama.rb +336 -0
- data/lib/gsd/ai/providers/openai.rb +396 -0
- data/lib/gsd/ai/providers/openrouter.rb +429 -0
- data/lib/gsd/ai/reference_resolver.rb +225 -0
- data/lib/gsd/ai/repl.rb +349 -0
- data/lib/gsd/ai/streaming.rb +438 -0
- data/lib/gsd/ai/ui.rb +429 -0
- data/lib/gsd/buddy/cli.rb +284 -0
- data/lib/gsd/buddy/gacha.rb +148 -0
- data/lib/gsd/buddy/renderer.rb +108 -0
- data/lib/gsd/buddy/species.rb +190 -0
- data/lib/gsd/buddy/stats.rb +156 -0
- data/lib/gsd/buddy.rb +28 -0
- data/lib/gsd/cli.rb +455 -0
- data/lib/gsd/commands.rb +198 -0
- data/lib/gsd/config.rb +183 -0
- data/lib/gsd/error.rb +188 -0
- data/lib/gsd/frontmatter.rb +123 -0
- data/lib/gsd/go/bridge.rb +173 -0
- data/lib/gsd/history.rb +76 -0
- data/lib/gsd/milestone.rb +75 -0
- data/lib/gsd/output.rb +184 -0
- data/lib/gsd/phase.rb +102 -0
- data/lib/gsd/plugins/base.rb +92 -0
- data/lib/gsd/plugins/cli.rb +330 -0
- data/lib/gsd/plugins/config.rb +164 -0
- data/lib/gsd/plugins/hooks.rb +132 -0
- data/lib/gsd/plugins/installer.rb +158 -0
- data/lib/gsd/plugins/loader.rb +122 -0
- data/lib/gsd/plugins/manager.rb +187 -0
- data/lib/gsd/plugins/marketplace.rb +142 -0
- data/lib/gsd/plugins/sandbox.rb +114 -0
- data/lib/gsd/plugins/search.rb +131 -0
- data/lib/gsd/plugins/validator.rb +157 -0
- data/lib/gsd/plugins.rb +48 -0
- data/lib/gsd/profile.rb +127 -0
- data/lib/gsd/research.rb +85 -0
- data/lib/gsd/roadmap.rb +90 -0
- data/lib/gsd/skills/bundled/commit.md +58 -0
- data/lib/gsd/skills/bundled/debug.md +28 -0
- data/lib/gsd/skills/bundled/explain.md +41 -0
- data/lib/gsd/skills/bundled/plan.md +42 -0
- data/lib/gsd/skills/bundled/verify.md +26 -0
- data/lib/gsd/skills/loader.rb +189 -0
- data/lib/gsd/state.rb +102 -0
- data/lib/gsd/template.rb +106 -0
- data/lib/gsd/tools/ask_user_question.rb +179 -0
- data/lib/gsd/tools/base.rb +204 -0
- data/lib/gsd/tools/bash.rb +246 -0
- data/lib/gsd/tools/file_edit.rb +297 -0
- data/lib/gsd/tools/file_read.rb +199 -0
- data/lib/gsd/tools/file_write.rb +153 -0
- data/lib/gsd/tools/glob.rb +202 -0
- data/lib/gsd/tools/grep.rb +227 -0
- data/lib/gsd/tools/gsd_frontmatter.rb +165 -0
- data/lib/gsd/tools/gsd_phase.rb +140 -0
- data/lib/gsd/tools/gsd_roadmap.rb +108 -0
- data/lib/gsd/tools/gsd_state.rb +143 -0
- data/lib/gsd/tools/gsd_template.rb +157 -0
- data/lib/gsd/tools/gsd_verify.rb +159 -0
- data/lib/gsd/tools/registry.rb +103 -0
- data/lib/gsd/tools/task.rb +235 -0
- data/lib/gsd/tools/todo_write.rb +290 -0
- data/lib/gsd/tools/web.rb +260 -0
- data/lib/gsd/tui/app.rb +366 -0
- data/lib/gsd/tui/auto_complete.rb +79 -0
- data/lib/gsd/tui/colors.rb +111 -0
- data/lib/gsd/tui/command_palette.rb +126 -0
- data/lib/gsd/tui/header.rb +38 -0
- data/lib/gsd/tui/input_box.rb +199 -0
- data/lib/gsd/tui/spinner.rb +40 -0
- data/lib/gsd/tui/status_bar.rb +51 -0
- data/lib/gsd/tui.rb +17 -0
- data/lib/gsd/validator.rb +216 -0
- data/lib/gsd/verify.rb +175 -0
- data/lib/gsd/version.rb +5 -0
- data/lib/gsd/workstream.rb +91 -0
- 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)
|
data/lib/gsd/tui/app.rb
ADDED
|
@@ -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
|