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.
@@ -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