Class: VSM::Ports::ChatTTY

Inherits:
VSM::Port show all
Defined in:
lib/vsm/ports/chat_tty.rb

Overview

Generic, customizable chat TTY port.

  • Safe to run alongside an MCP stdio port: prefers IO.console for I/O.

  • Override banner(io) and render_out(message) to customize without reimplementing the core input loop.

Constant Summary collapse

DEFAULT_THEME =
{
  you:    "\e[94mYou\e[0m: ",
  tool:   "\e[90m→ tool\e[0m ",
  turn:   "\e[2m(turn %s)\e[0m"
}.freeze

Instance Method Summary collapse

Methods inherited from VSM::Port

#egress_subscribe, #ingress

Constructor Details

#initialize(capsule:, input: nil, output: nil, banner: nil, prompt: nil, theme: {}, show_tool_results: false) ⇒ ChatTTY

Returns a new instance of ChatTTY.



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/vsm/ports/chat_tty.rb', line 20

def initialize(capsule:, input: nil, output: nil, banner: nil, prompt: nil, theme: {}, show_tool_results: false)
  super(capsule: capsule)
  # Prefer STDIN/STDOUT if they are TTY. If not, try /dev/tty.
  # Avoid IO.console to minimize kqueue/select issues under async.
  tty_io = nil
  if !$stdout.tty?
    begin
      tty_io = File.open("/dev/tty", "r+")
    rescue StandardError
      tty_io = nil
    end
  end

  @in  = input  || (tty_io || ($stdin.tty?  ? $stdin  : nil))
  @out = output || (tty_io || ($stdout.tty? ? $stdout : $stderr))
  @banner = banner # String or ->(io) {}
  @prompt = prompt || DEFAULT_THEME[:you]
  @theme  = DEFAULT_THEME.merge(theme)
  @streaming = false
  @show_tool_results = show_tool_results
end

Instance Method Details

Overridable header/banner



99
100
101
102
103
104
105
106
107
# File 'lib/vsm/ports/chat_tty.rb', line 99

def banner(io)
  if @banner.respond_to?(:call)
    @banner.call(io)
  elsif @banner.is_a?(String)
    io.puts @banner
  else
    io.puts "vsm chat — Ctrl-C to exit"
  end
end

#loopObject



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/vsm/ports/chat_tty.rb', line 46

def loop
  sid = SecureRandom.uuid
  @capsule.roles[:coordination].grant_floor!(sid) if @capsule.roles[:coordination].respond_to?(:grant_floor!)
  banner(@out)

  if @in.nil?
    @out.puts "(no interactive TTY; ChatTTY input disabled)"
    Async::Task.current.sleep # keep task alive for egress rendering
    return
  end

  @out.print @prompt
  while (line = @in.gets&.chomp)
    @capsule.bus.emit VSM::Message.new(kind: :user, payload: line, meta: { session_id: sid })
    if @capsule.roles[:coordination].respond_to?(:wait_for_turn_end)
      @capsule.roles[:coordination].wait_for_turn_end(sid)
    end
    @out.print @prompt
  end
end

#render_out(message) ⇒ Object



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/vsm/ports/chat_tty.rb', line 67

def render_out(message)
  case message.kind
  when :assistant_delta
    @streaming = true
    @out.print(message.payload)
    @out.flush
  when :assistant
    # If we didn't stream content, print the final content now.
    unless @streaming
      txt = message.payload.to_s
      unless txt.empty?
        @out.puts
        @out.puts txt
      end
    end
    turn = message.meta&.dig(:turn_id)
    @out.puts(@theme[:turn] % turn) if turn
    @streaming = false
  when :tool_call
    @out.puts
    @out.puts "#{@theme[:tool]}#{message.payload[:tool]}"
  when :tool_result
    return unless @show_tool_results
    out = message.payload.to_s
    unless out.empty?
      @out.puts
      @out.puts out
    end
  end
end

#should_render?(message) ⇒ Boolean

Returns:

  • (Boolean)


42
43
44
# File 'lib/vsm/ports/chat_tty.rb', line 42

def should_render?(message)
  [:assistant_delta, :assistant, :tool_call, :tool_result].include?(message.kind)
end