tui-td 0.1.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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +479 -0
- data/bin/tui-td +9 -0
- data/lib/tui_td/ansi_parser.rb +405 -0
- data/lib/tui_td/cli.rb +232 -0
- data/lib/tui_td/driver.rb +188 -0
- data/lib/tui_td/html_renderer.rb +228 -0
- data/lib/tui_td/matchers.rb +72 -0
- data/lib/tui_td/mcp/server.rb +463 -0
- data/lib/tui_td/screenshot.rb +271 -0
- data/lib/tui_td/state.rb +111 -0
- data/lib/tui_td/test_runner.rb +178 -0
- data/lib/tui_td/version.rb +5 -0
- data/lib/tui_td.rb +25 -0
- metadata +159 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module TUITD
|
|
6
|
+
module MCP
|
|
7
|
+
# Model Context Protocol server for tui-td.
|
|
8
|
+
#
|
|
9
|
+
# Implements JSON-RPC 2.0 over stdio transport.
|
|
10
|
+
# Compatible with MCP specification (protocol version 2024-11-05).
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# tui-td serve
|
|
14
|
+
#
|
|
15
|
+
# Exposes tools that any MCP client can call:
|
|
16
|
+
# tui_start, tui_send, tui_send_key, tui_wait_for_text,
|
|
17
|
+
# tui_state, tui_screenshot, tui_html_render, tui_plain_text, tui_close
|
|
18
|
+
#
|
|
19
|
+
class Server
|
|
20
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
21
|
+
SERVER_NAME = "tui-td"
|
|
22
|
+
SERVER_VERSION = TUITD::VERSION
|
|
23
|
+
|
|
24
|
+
def initialize(rows: 40, cols: 120, timeout: 30)
|
|
25
|
+
@rows = rows
|
|
26
|
+
@cols = cols
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
@driver = nil
|
|
29
|
+
@running = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Start the MCP server (reads from stdin, writes to stdout)
|
|
33
|
+
def start
|
|
34
|
+
$stdout.sync = true
|
|
35
|
+
$stderr.sync = true
|
|
36
|
+
|
|
37
|
+
# Signal readiness
|
|
38
|
+
$stderr.puts "[tui-td MCP] Server started, awaiting JSON-RPC on stdin..."
|
|
39
|
+
|
|
40
|
+
while @running && (line = $stdin.gets)
|
|
41
|
+
line = line.strip
|
|
42
|
+
next if line.empty?
|
|
43
|
+
|
|
44
|
+
begin
|
|
45
|
+
request = JSON.parse(line)
|
|
46
|
+
response = handle_request(request)
|
|
47
|
+
|
|
48
|
+
puts JSON.generate(response) if response
|
|
49
|
+
$stdout.flush
|
|
50
|
+
rescue JSON::ParserError => e
|
|
51
|
+
error_response(nil, -32700, "Parse error: #{e.message}")
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
$stderr.puts "[tui-td MCP] Error: #{e.class}: #{e.message}"
|
|
54
|
+
$stderr.puts e.backtrace.first(5).join("\n ") if $DEBUG
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@driver&.close
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def handle_request(request)
|
|
64
|
+
method = request["method"]
|
|
65
|
+
id = request["id"]
|
|
66
|
+
params = request["params"] || {}
|
|
67
|
+
|
|
68
|
+
case method
|
|
69
|
+
when "initialize"
|
|
70
|
+
handle_initialize(params, id)
|
|
71
|
+
when "notifications/initialized"
|
|
72
|
+
nil # No response needed
|
|
73
|
+
when "tools/list"
|
|
74
|
+
handle_tools_list(id)
|
|
75
|
+
when "tools/call"
|
|
76
|
+
handle_tools_call(params, id)
|
|
77
|
+
else
|
|
78
|
+
if method&.start_with?("notifications/")
|
|
79
|
+
nil # Ignore unknown notifications
|
|
80
|
+
else
|
|
81
|
+
error_response(id, -32601, "Method not found: #{method}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Initialize handshake
|
|
87
|
+
def handle_initialize(params, id)
|
|
88
|
+
{
|
|
89
|
+
jsonrpc: "2.0",
|
|
90
|
+
id: id,
|
|
91
|
+
result: {
|
|
92
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
93
|
+
capabilities: {
|
|
94
|
+
tools: {}
|
|
95
|
+
},
|
|
96
|
+
serverInfo: {
|
|
97
|
+
name: SERVER_NAME,
|
|
98
|
+
version: SERVER_VERSION
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Return list of available tools
|
|
105
|
+
def handle_tools_list(id)
|
|
106
|
+
{
|
|
107
|
+
jsonrpc: "2.0",
|
|
108
|
+
id: id,
|
|
109
|
+
result: {
|
|
110
|
+
tools: [
|
|
111
|
+
{
|
|
112
|
+
name: "tui_start",
|
|
113
|
+
description: "Start a TUI (Terminal User Interface) application in a PTY. Must be called first before any other tui_* tools.",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: {
|
|
117
|
+
command: {
|
|
118
|
+
type: "string",
|
|
119
|
+
description: "The command to run (e.g., 'htop', 'vim file.txt')"
|
|
120
|
+
},
|
|
121
|
+
rows: {
|
|
122
|
+
type: "integer",
|
|
123
|
+
description: "Terminal height in rows (default: 40)",
|
|
124
|
+
default: 40
|
|
125
|
+
},
|
|
126
|
+
cols: {
|
|
127
|
+
type: "integer",
|
|
128
|
+
description: "Terminal width in columns (default: 120)",
|
|
129
|
+
default: 120
|
|
130
|
+
},
|
|
131
|
+
timeout: {
|
|
132
|
+
type: "integer",
|
|
133
|
+
description: "Timeout in seconds for waits (default: 30)",
|
|
134
|
+
default: 30
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
required: ["command"]
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "tui_send",
|
|
142
|
+
description: "Send text input to the running TUI. Text is written as-is (use \\n for newline/enter, \\r just in case).",
|
|
143
|
+
inputSchema: {
|
|
144
|
+
type: "object",
|
|
145
|
+
properties: {
|
|
146
|
+
text: {
|
|
147
|
+
type: "string",
|
|
148
|
+
description: "Text to send to the TUI. Use \\n for enter/newline."
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
required: ["text"]
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "tui_send_key",
|
|
156
|
+
description: "Send a special key press (arrow keys, enter, tab, etc.) to the TUI.",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
key: {
|
|
161
|
+
type: "string",
|
|
162
|
+
enum: ["enter", "tab", "escape", "up", "down", "left", "right",
|
|
163
|
+
"backspace", "ctrl_c", "ctrl_d", "ctrl_z", "page_up", "page_down",
|
|
164
|
+
"home", "end", "delete"],
|
|
165
|
+
description: "Key to press"
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
required: ["key"]
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "tui_wait_for_text",
|
|
173
|
+
description: "Wait until the terminal output contains the specified text (with timeout).",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: {
|
|
177
|
+
text: {
|
|
178
|
+
type: "string",
|
|
179
|
+
description: "Text to wait for in the terminal output"
|
|
180
|
+
},
|
|
181
|
+
timeout: {
|
|
182
|
+
type: "integer",
|
|
183
|
+
description: "Custom timeout in seconds (overrides default)",
|
|
184
|
+
default: 30
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
required: ["text"]
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "tui_wait_for_stable",
|
|
192
|
+
description: "Wait until the terminal output stabilizes (no new data for 300ms). Useful after sending commands.",
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: "object",
|
|
195
|
+
properties: {
|
|
196
|
+
timeout: {
|
|
197
|
+
type: "integer",
|
|
198
|
+
description: "Custom timeout in seconds",
|
|
199
|
+
default: 30
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: "tui_state",
|
|
206
|
+
description: "Get the current state of the terminal: cursor position, plain text, visual highlights (bold/colored text), and grid size.",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
format: {
|
|
211
|
+
type: "string",
|
|
212
|
+
enum: ["ai", "full", "text"],
|
|
213
|
+
description: "Output format: 'ai' (compact, text+highlights, default), 'full' (complete cell grid with ANSI colors), 'text' (plain text only)",
|
|
214
|
+
default: "ai"
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "tui_plain_text",
|
|
221
|
+
description: "Get the current terminal content as plain text (all ANSI stripped).",
|
|
222
|
+
inputSchema: {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: {}
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "tui_screenshot",
|
|
229
|
+
description: "Capture a PNG screenshot of the current terminal state. Renders the terminal grid directly using an embedded monochrome font.",
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: {
|
|
233
|
+
path: {
|
|
234
|
+
type: "string",
|
|
235
|
+
description: "Output file path (optional, auto-generated if omitted)"
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "tui_html_render",
|
|
242
|
+
description: "Render the current terminal state as a self-contained HTML document. Returns faithful browser visualization with colors, bold/italic/underline, cursor indicator. Use this to SEE exactly what the TUI displays.",
|
|
243
|
+
inputSchema: {
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: {
|
|
246
|
+
path: {
|
|
247
|
+
type: "string",
|
|
248
|
+
description: "Optional file path to save the HTML. If omitted, the HTML content is returned inline so you can view it directly."
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: "tui_close",
|
|
255
|
+
description: "Close the TUI application and clean up the PTY session. Call this when finished.",
|
|
256
|
+
inputSchema: {
|
|
257
|
+
type: "object",
|
|
258
|
+
properties: {}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
]
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Call a tool
|
|
267
|
+
def handle_tools_call(params, id)
|
|
268
|
+
tool_name = params["name"]
|
|
269
|
+
args = params["arguments"] || {}
|
|
270
|
+
|
|
271
|
+
result = case tool_name
|
|
272
|
+
when "tui_start" then call_tui_start(args)
|
|
273
|
+
when "tui_send" then call_tui_send(args)
|
|
274
|
+
when "tui_send_key" then call_tui_send_key(args)
|
|
275
|
+
when "tui_wait_for_text" then call_tui_wait_for_text(args)
|
|
276
|
+
when "tui_wait_for_stable" then call_tui_wait_for_stable(args)
|
|
277
|
+
when "tui_state" then call_tui_state(args)
|
|
278
|
+
when "tui_plain_text" then call_tui_plain_text
|
|
279
|
+
when "tui_screenshot" then call_tui_screenshot(args)
|
|
280
|
+
when "tui_html_render" then call_tui_html_render(args)
|
|
281
|
+
when "tui_close" then call_tui_close
|
|
282
|
+
else
|
|
283
|
+
return error_response(id, -32602, "Unknown tool: #{tool_name}")
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
{
|
|
287
|
+
jsonrpc: "2.0",
|
|
288
|
+
id: id,
|
|
289
|
+
result: {
|
|
290
|
+
content: [
|
|
291
|
+
{
|
|
292
|
+
type: "text",
|
|
293
|
+
text: result
|
|
294
|
+
}
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
rescue TUITD::TimeoutError => e
|
|
299
|
+
{
|
|
300
|
+
jsonrpc: "2.0",
|
|
301
|
+
id: id,
|
|
302
|
+
result: {
|
|
303
|
+
content: [
|
|
304
|
+
{
|
|
305
|
+
type: "text",
|
|
306
|
+
text: "TIMEOUT: #{e.message}"
|
|
307
|
+
}
|
|
308
|
+
],
|
|
309
|
+
isError: false
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
rescue StandardError => e
|
|
313
|
+
{
|
|
314
|
+
jsonrpc: "2.0",
|
|
315
|
+
id: id,
|
|
316
|
+
result: {
|
|
317
|
+
content: [
|
|
318
|
+
{
|
|
319
|
+
type: "text",
|
|
320
|
+
text: "ERROR: #{e.class}: #{e.message}"
|
|
321
|
+
}
|
|
322
|
+
],
|
|
323
|
+
isError: true
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# --- Tool implementations ---
|
|
329
|
+
|
|
330
|
+
def call_tui_start(args)
|
|
331
|
+
command = args["command"] or return "ERROR: 'command' argument is required"
|
|
332
|
+
rows = args["rows"] || @rows
|
|
333
|
+
cols = args["cols"] || @cols
|
|
334
|
+
timeout = args["timeout"] || @timeout
|
|
335
|
+
|
|
336
|
+
@driver&.close
|
|
337
|
+
@driver = Driver.new(command, rows: rows, cols: cols, timeout: timeout)
|
|
338
|
+
@driver.start
|
|
339
|
+
|
|
340
|
+
state_data = @driver.state_data
|
|
341
|
+
text = state_to_ai_text(state_data)
|
|
342
|
+
|
|
343
|
+
"OK: Started '#{command}' (#{cols}x#{rows})\n#{text}"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def call_tui_send(args)
|
|
347
|
+
ensure_driver!
|
|
348
|
+
text = args["text"] or return "ERROR: 'text' argument is required"
|
|
349
|
+
@driver.send(text)
|
|
350
|
+
"OK: Sent #{text.length} characters"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def call_tui_send_key(args)
|
|
354
|
+
ensure_driver!
|
|
355
|
+
key = args["key"] or return "ERROR: 'key' argument is required"
|
|
356
|
+
key_sym = key.to_sym
|
|
357
|
+
@driver.send_keys(key_sym)
|
|
358
|
+
"OK: Sent key '#{key}'"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def call_tui_wait_for_text(args)
|
|
362
|
+
ensure_driver!
|
|
363
|
+
text = args["text"] or return "ERROR: 'text' argument is required"
|
|
364
|
+
@driver.wait_for_text(text)
|
|
365
|
+
|
|
366
|
+
state_data = @driver.state_data
|
|
367
|
+
state_to_ai_text(state_data)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def call_tui_wait_for_stable(args)
|
|
371
|
+
ensure_driver!
|
|
372
|
+
@driver.wait_for_stable
|
|
373
|
+
|
|
374
|
+
state_data = @driver.state_data
|
|
375
|
+
state_to_ai_text(state_data)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def call_tui_state(args)
|
|
379
|
+
ensure_driver!
|
|
380
|
+
state = TUITD::State.new(@driver.state_data)
|
|
381
|
+
format = args["format"] || "ai"
|
|
382
|
+
|
|
383
|
+
case format
|
|
384
|
+
when "full"
|
|
385
|
+
JSON.pretty_generate(@driver.state_data)
|
|
386
|
+
when "text"
|
|
387
|
+
state.plain_text
|
|
388
|
+
else
|
|
389
|
+
JSON.pretty_generate(state.to_ai_json)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def call_tui_plain_text
|
|
394
|
+
ensure_driver!
|
|
395
|
+
state = TUITD::State.new(@driver.state_data)
|
|
396
|
+
state.plain_text
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def call_tui_screenshot(args)
|
|
400
|
+
ensure_driver!
|
|
401
|
+
path = args["path"] || "/tmp/tui_td_#{Time.now.to_i}.png"
|
|
402
|
+
result = @driver.screenshot(path)
|
|
403
|
+
"OK: Screenshot saved to #{result}"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def call_tui_html_render(args)
|
|
407
|
+
ensure_driver!
|
|
408
|
+
path = args["path"]
|
|
409
|
+
renderer = HtmlRenderer.new(@driver.state_data)
|
|
410
|
+
|
|
411
|
+
if path
|
|
412
|
+
renderer.render(path)
|
|
413
|
+
"OK: HTML saved to #{path}"
|
|
414
|
+
else
|
|
415
|
+
renderer.to_html
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def call_tui_close
|
|
420
|
+
@driver&.close
|
|
421
|
+
@driver = nil
|
|
422
|
+
"OK: TUI session closed"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# --- Helpers ---
|
|
426
|
+
|
|
427
|
+
def ensure_driver!
|
|
428
|
+
raise Error, "No TUI session active. Call tui_start first." if @driver.nil?
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def state_to_ai_text(state_data)
|
|
432
|
+
state = TUITD::State.new(state_data)
|
|
433
|
+
json = state.to_ai_json
|
|
434
|
+
|
|
435
|
+
lines = []
|
|
436
|
+
lines << "Terminal: #{json[:size][:cols]}x#{json[:size][:rows]}"
|
|
437
|
+
lines << "Cursor: [#{json[:cursor][:row]}, #{json[:cursor][:col]}]"
|
|
438
|
+
|
|
439
|
+
if json[:highlights]&.any?
|
|
440
|
+
lines << "Highlights (bold/colored text):"
|
|
441
|
+
json[:highlights].each { |h| lines << " row #{h[:row]}: #{h[:text]}" }
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
lines << "--- Full text ---"
|
|
445
|
+
text_lines = json[:text].split("\n")
|
|
446
|
+
text_lines.each { |l| lines << l }
|
|
447
|
+
|
|
448
|
+
lines.join("\n")
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def error_response(id, code, message)
|
|
452
|
+
{
|
|
453
|
+
jsonrpc: "2.0",
|
|
454
|
+
id: id,
|
|
455
|
+
error: {
|
|
456
|
+
code: code,
|
|
457
|
+
message: message
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
end
|