foruiman 0.1.2 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0de0b215283d2c3dc89422aa9e6f6da235acf10f5ede3907afcf8e7c88dd9290
4
- data.tar.gz: 81dfcdcfbc8d7ab445242fd5c07419192b6445e9caa3107fcdce5a2a027d914e
3
+ metadata.gz: 726795acd4c4991d3a924364ec484ca7ec1e7051b8c9a660a53e6b8ecd1759f8
4
+ data.tar.gz: 0c24bdd2fded37d051b8bb0e771991b0284893888a89b3a461662d2cc483dee4
5
5
  SHA512:
6
- metadata.gz: bb3cbdd6d6c38335bd5d6d4b467bbedec757cb2e534896b24d471c8b4275203d22cb22ec642fafe63410df21c1616322ae2e4616b0b26dce277959308f0092c5
7
- data.tar.gz: 03db66ce62f3e21723aac3808791b2a027bb2ab497d415de11730661ff90b5150b82a0d7351ea2c156abc93be670e93a5b575933d06bf6a0fdcdcece35f7b517
6
+ metadata.gz: 68b7fa94a095c09fc3ed636165ee88db37640d24aa2ede0b4d2c417680cf2655910b881ad2180116babc701949c91cc9f751f2e0b67f222d081f71ca9153a178
7
+ data.tar.gz: 22ec4c35cd32fd23d897ca2de19d72d5c219b53d0b86019f3fc3f562b3436371bd353ca5119aac2d4e1c559c68ddca37984c53b68e5e036cc12538e04f71321e
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.0
6
+
7
+ - Keep child stdin open so default watcher modes continue running.
8
+ - Add per-process TTY input from the TUI: select a process, press `i`, and use
9
+ Ctrl-X to return to Foruiman. Ctrl-C interrupts the attached process group.
10
+ - Add a command input bar with local backspace, cursor editing and per-process
11
+ history. Interpret debugger redraws instead of appending repeated prompts.
12
+ - Make `s` stop a running selected process and start it again once stopped,
13
+ without affecting peers. Use clear `▶` running and `■` stopped glyphs.
14
+ - Make `s` on the `all` tab stop every process, matching the scope of `r`.
15
+
5
16
  - Test Ruby 3.2, 3.3, 3.4, and 4.0 in a reusable CI workflow and keep the
6
17
  development dependency set compatible with Ruby 3.2.
7
18
  - Add a tag-driven RubyGems trusted-publishing workflow gated by the full CI suite.
data/README.md CHANGED
@@ -46,6 +46,14 @@ The TUI opens when stdin and stdout are terminals. Otherwise, Foruiman streams
46
46
  plain logs and exits when the processes finish. Processes run independently, and
47
47
  restarting one process does not interrupt the others.
48
48
 
49
+ Each TUI process gets its own open terminal input, so asset builders using their
50
+ default `--watch` mode stay alive. Select a process and press `i` to type into a
51
+ debugger such as Pry, Byebug or IRB. Edit in the input bar, press Enter to send,
52
+ and Ctrl-X to return to Foruiman. Arrow keys edit and recall commands; Ctrl-C
53
+ interrupts the selected process. Debugger tab completion is not forwarded.
54
+ Press `s` to stop the selected process, or all processes from `all`; on a stopped
55
+ process tab, `s` starts it again.
56
+
49
57
  ## Options
50
58
 
51
59
  | Option | Default | Purpose |
@@ -69,9 +77,10 @@ restarting one process does not interrupt the others.
69
77
  | Home / `g` | Jump to oldest retained log |
70
78
  | End / `G` / `f` | Follow new output |
71
79
  | Space | Pause or resume following |
80
+ | `i` / Ctrl-X | Open command input / return to Foruiman |
72
81
  | `r` | Restart the selected process, or all from `all` |
73
82
  | `R` | Restart all processes |
74
- | `s` / `S` | Stop selected / stop all |
83
+ | `s` / `S` | Start/stop selected (`s` on `all` stops all) / stop all |
75
84
  | `?` / Escape | Toggle help / close help |
76
85
  | `q` / Ctrl-C | Stop processes and quit |
77
86
 
data/docs/ARCHITECTURE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `Procfile` retains Foreman's ordered parser/writer API with strict file validation.
4
4
  `Env` retains its quoting rules and adds a non-mutating precedence merge. `Process`
5
- wraps `/bin/sh -c` with a new process group, two output streams, and null stdin.
5
+ wraps `/bin/sh -c` with a new process group, two output streams, and configurable stdin.
6
6
  `CLI < Thor` validates the full configuration before starting the engine.
7
7
 
8
8
  `Engine` owns registration, PID/group tracking, nonblocking pipes, child reaping,
@@ -35,6 +35,7 @@ begin
35
35
  engine.tick(timeout: 0.03)
36
36
  engine.restart("web") # asynchronous; peers keep running
37
37
  engine.stop("worker") # asynchronous
38
+ engine.start("worker") # start it again once stopped
38
39
  engine.shutdown # cancels replacements and requests group cleanup
39
40
  engine.tick until engine.finished?
40
41
  ensure
@@ -50,6 +51,18 @@ process cleanup. `term_timeout:` exists for deterministic embedded tests; CLI
50
51
  shutdown always uses five seconds. `state(name)` exposes lifecycle state for
51
52
  rendering; callers should not mutate it.
52
53
 
54
+ Children inherit the configured input stream in plain and embedded use. Before
55
+ startup, `manage_input!` gives each child a dedicated pseudo-terminal while the
56
+ caller retains the real terminal; `write_input(name, bytes)` forwards input,
57
+ `interrupt_process(name)` sends SIGINT to that process group, and `resize_inputs` keeps
58
+ the pseudo-terminals sized with the UI. The TUI uses this mode so watch commands
59
+ do not see EOF and only the selected process receives interactive input.
60
+
61
+ The TUI edits commands locally in a bounded `InputLine`, with independent history
62
+ for each process, then sends a complete line on Enter. Ctrl-X leaves input mode.
63
+ `OutputLine` interprets carriage returns, backspaces, horizontal cursor moves and
64
+ line erasure before recording child output; screen controls remain suppressed.
65
+
53
66
  `LogStore` uses per-process `Ring` instances and an aggregate `Ring`, each with O(1)
54
67
  append, eviction, record replacement, and identity lookup. Immutable `Data` records
55
68
  are shared. A partial record has one sequence identity; completing it replaces
@@ -74,9 +87,9 @@ clips rows safely. `Theme` uses the terminal's ANSI palette, default foreground
74
87
  background, and reverse-video selection, with a monochrome fallback. It does not
75
88
  read OS theme files or override palette entries; child SGR resets restore terminal
76
89
  defaults. The header uses the engine's `procfile_path` to identify the loaded file.
77
- `LogFormatter` aligns timestamps,
78
- process names, stream markers, and content. Complete frames reset styles at each row. `Application` connects input and lifecycle
79
- commands to the engine and limits drawing to 30 FPS. Resize handling reads current
90
+ `LogFormatter` aligns timestamps, process names, stream markers, and content.
91
+ Complete frames reset styles at each row. `Application` connects navigation,
92
+ start/stop controls, and selected-child input to the engine and limits drawing to 30 FPS. Resize handling reads current
80
93
  terminal dimensions each loop; it does not replace an application's WINCH handler.
81
94
 
82
95
  `Plain` prints completed records and runs to natural completion. `Diagnostics`
@@ -4,7 +4,7 @@ Foruiman is a fork-derived MVP, not a drop-in replacement for every Foreman feat
4
4
 
5
5
  | Area | Foruiman behavior |
6
6
  | --- | --- |
7
- | Identity | `foruiman` gem/executable, `Foruiman` namespace, version 0.1.2 |
7
+ | Identity | `foruiman` gem/executable, `Foruiman` namespace, version 0.2.0 |
8
8
  | Runtime | Ruby 3.2+, POSIX process groups; Linux CI |
9
9
  | CLI | Thor-based `start [PROCESS]`, `check`, `version`, and `help` |
10
10
  | Excluded features | No export, scaling/formation, `run`, `.foreman` YAML, custom shutdown timeout, forced color, or timestamp toggle |
@@ -18,10 +18,10 @@ Foruiman is a fork-derived MVP, not a drop-in replacement for every Foreman feat
18
18
  | Restart | Stop only the affected group, wait for descendants and output, then replace |
19
19
  | Shutdown | TERM, five-second grace, KILL; track groups after leader exit; restore prior signal handlers |
20
20
  | Output | Separate stdout/stderr metadata; bounded logs and live partial records |
21
- | Terminal | Tabs, independent scroll/follow, restart and stop controls; no child stdin |
21
+ | Terminal | Tabs, independent scroll/follow, restart, start/stop, and selected-process input controls |
22
22
  | Plain exit | Wait for all processes; status 0/1; explicit orderly shutdown returns 0 |
23
23
  | Interactive exit | Remain open after all processes exit; quit explicitly |
24
24
 
25
- Commands needing a PTY, interactive child input, or background daemonization are
26
- outside this release. Remote process control, persistence across Foruiman sessions,
25
+ Full-screen child terminal applications and background daemonization are outside
26
+ this release. Remote process control, persistence across Foruiman sessions,
27
27
  search, horizontal scrolling, and log export are not provided.
data/docs/RAILS.md CHANGED
@@ -46,7 +46,13 @@ exec foruiman start -f Procfile.dev "$@"
46
46
 
47
47
  Select the web tab and press `r` to restart Rails without interrupting the worker
48
48
  or asset watcher. On the `all` tab, `r` restarts all entries; `R` does so from any
49
- tab. The header shows the active Procfile. `q` or Ctrl-C stops all owned process
49
+ tab. Default watch modes stay running because their stdin remains open. When Rails
50
+ stops in Pry, Byebug or IRB, select `web` and press `i`. Edit commands in the input
51
+ bar and press Enter to send them. Up/Down recall history, Left/Right move the
52
+ cursor, and Ctrl-X returns to Foruiman. Ctrl-C interrupts the selected process;
53
+ debugger tab completion is not forwarded. Press `s` to stop the selected entry
54
+ and press it again to start it; `s` on `all` stops every entry. The header shows the
55
+ active Procfile. `q` or Ctrl-C stops all owned process
50
56
  groups, including grandchildren, before returning to the shell.
51
57
 
52
58
  Redirect output with `foruiman start -f Procfile.dev > development.log` to use plain
data/lib/foruiman/ansi.rb CHANGED
@@ -63,10 +63,11 @@ module Foruiman::ANSI
63
63
  end
64
64
  end
65
65
 
66
- # Incremental byte parser: only text, newlines, and SGR can leave this class.
67
- # OSC (including clipboard), DCS, cursor controls, and other escapes are dropped.
66
+ # Screen controls are discarded. Output may opt into horizontal line editing
67
+ # controls, which it interprets before storing or displaying any records.
68
68
  class Decoder
69
- def initialize
69
+ def initialize(line_controls: false)
70
+ @line_controls = line_controls
70
71
  @state = :text
71
72
  @escape = +""
72
73
  @pending = +"".b
@@ -92,6 +93,7 @@ module Foruiman::ANSI
92
93
  case byte
93
94
  when 27 then @state = :escape
94
95
  when 9 then output << " "
96
+ when 8, 13 then output << byte if @line_controls
95
97
  when 10, 32..126, 128..255 then output << byte
96
98
  end
97
99
  when :escape
@@ -108,6 +110,9 @@ module Foruiman::ANSI
108
110
  when :csi
109
111
  if byte.between?(64, 126)
110
112
  output << "\e[#{@escape}m" if byte == 109 && @escape.match?(/\A[0-9;:]*\z/)
113
+ if @line_controls && [67, 68, 71, 75].include?(byte) && @escape.match?(/\A[0-9]*\z/)
114
+ output << "\e[#{@escape}#{byte.chr}"
115
+ end
111
116
  @state = :text
112
117
  elsif @escape.bytesize < 96
113
118
  @escape << byte
@@ -11,14 +11,16 @@ class Foruiman::Engine
11
11
  TERM_TIMEOUT = 5.0
12
12
  READ_CHUNK = 4096
13
13
  READ_BUDGET = 64 * 1024
14
+ INPUT_BUDGET = 64 * 1024
14
15
  State = Struct.new(:name, :process, :port, :pid, :pgid, :status, :exit_status,
15
16
  :generation, :restart_pending, :deadline, :reaped, :group_gone,
17
+ :input, :input_buffer,
16
18
  keyword_init: true)
17
19
  Event = Data.define(:type, :name, :pid, :status, :record, :message)
18
20
 
19
21
  attr_reader :logs, :env, :processes, :root, :procfile_path
20
22
 
21
- def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, port: 5000, log_lines: 10_000,
23
+ def initialize(procfile: nil, root: Dir.pwd, env: ENV.to_h, input: $stdin, port: 5000, log_lines: 10_000,
22
24
  term_timeout: TERM_TIMEOUT)
23
25
  raise Foruiman::Error, "port must be an integer in 1..65535" unless port.is_a?(Integer) && (1..65_535).cover?(port)
24
26
  raise Foruiman::Error, "log-lines must be a positive integer" unless log_lines.is_a?(Integer) && log_lines.positive?
@@ -27,6 +29,8 @@ class Foruiman::Engine
27
29
  raise Foruiman::Error, "working directory does not exist: #{@root}" unless File.directory?(@root)
28
30
 
29
31
  @env = env.dup.freeze
32
+ @input = input
33
+ @managed_input = false
30
34
  @base_port = port
31
35
  @log_lines = log_lines
32
36
  @term_timeout = term_timeout
@@ -34,6 +38,7 @@ class Foruiman::Engine
34
38
  @names = {}
35
39
  @running = {}
36
40
  @readers = {}
41
+ @inputs = {}
37
42
  @listeners = []
38
43
  @shutdown = false
39
44
  @explicit_shutdown = false
@@ -57,7 +62,8 @@ class Foruiman::Engine
57
62
  Foruiman::Procfile.new[name] = command
58
63
  process = Foruiman::Process.new(command, cwd: root, env: env)
59
64
  state = State.new(name: name.freeze, process: process, port: @base_port + (processes.size * 100),
60
- status: :pending, generation: 0, restart_pending: false, reaped: true, group_gone: true)
65
+ status: :pending, generation: 0, restart_pending: false, reaped: true, group_gone: true,
66
+ input_buffer: +"".b)
61
67
  @names[name] = state
62
68
  processes << state
63
69
  state
@@ -100,6 +106,16 @@ class Foruiman::Engine
100
106
  self
101
107
  end
102
108
 
109
+ # Give every child a dedicated pseudo-terminal for stdin. The TUI remains the
110
+ # sole reader of the real terminal and explicitly forwards input to one child.
111
+ def manage_input!
112
+ raise Foruiman::Error, "cannot change input mode after startup" if @started
113
+
114
+ require "pty"
115
+ @managed_input = true
116
+ self
117
+ end
118
+
103
119
  def start(name = nil)
104
120
  raise Foruiman::Error, "supervisor is closed" if @closed
105
121
  return if @shutdown
@@ -145,6 +161,33 @@ class Foruiman::Engine
145
161
  terminate(entry)
146
162
  end
147
163
 
164
+ def write_input(name, bytes)
165
+ entry = state(name)
166
+ return false unless entry.status == :running && entry.input && !entry.input.closed?
167
+ return false if entry.input_buffer.bytesize + bytes.bytesize > INPUT_BUDGET
168
+
169
+ entry.input_buffer << bytes.b
170
+ flush_input(entry)
171
+ rescue IOError, SystemCallError
172
+ false
173
+ end
174
+
175
+ def interrupt_process(name)
176
+ entry = state(name)
177
+ return unless entry.pgid
178
+
179
+ signal_group(entry, :INT)
180
+ self
181
+ end
182
+
183
+ def resize_inputs(rows, columns)
184
+ processes.each do |entry|
185
+ entry.input&.winsize = [rows, columns]
186
+ rescue IOError, SystemCallError
187
+ nil
188
+ end
189
+ end
190
+
148
191
  def shutdown(explicit: true)
149
192
  @explicit_shutdown ||= explicit
150
193
  return if @shutdown
@@ -189,9 +232,11 @@ class Foruiman::Engine
189
232
  shutdown if @signal_requested
190
233
  reap_children
191
234
  advance_groups
192
- ready = IO.select([@self_reader, *@readers.keys], nil, nil, timeout)&.first || []
235
+ writable = processes.filter_map { |entry| entry.input unless entry.input_buffer.empty? }
236
+ ready, writable = IO.select([@self_reader, *@readers.keys], writable, nil, timeout) || [[], []]
193
237
  drain_signal_pipe if ready.delete(@self_reader)
194
238
  shutdown if @signal_requested
239
+ writable.each { |input| flush_input(@inputs.fetch(input)) if @inputs.key?(input) }
195
240
  read_output(ready)
196
241
  reap_children
197
242
  advance_groups
@@ -224,12 +269,15 @@ class Foruiman::Engine
224
269
 
225
270
  stdout_reader, stdout_writer = create_pipe
226
271
  stderr_reader, stderr_writer = create_pipe
272
+ input_master, input_slave = create_managed_input if @managed_input
227
273
  begin
228
274
  pid = entry.process.run(output: stdout_writer, error: stderr_writer,
275
+ input: input_slave || @input,
229
276
  env: { "PORT" => entry.port.to_s, "PS" => "#{entry.name}.1" })
230
277
  rescue SystemCallError => e
231
278
  stdout_reader.close
232
279
  stderr_reader.close
280
+ input_master&.close
233
281
  entry.status = :failed
234
282
  entry.restart_pending = false
235
283
  @failed = true
@@ -243,12 +291,19 @@ class Foruiman::Engine
243
291
  entry.restart_pending = false
244
292
  entry.deadline = nil
245
293
  entry.reaped = entry.group_gone = false
294
+ entry.input = input_master
295
+ entry.input_buffer.clear
246
296
  @running[pid] = entry
247
297
  [[stdout_reader, :stdout], [stderr_reader, :stderr]].each do |reader, stream|
248
298
  @readers[reader] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: stream, pid: pid)]
249
299
  end
300
+ if input_master
301
+ @inputs[input_master] = entry
302
+ @readers[input_master] = [entry, Foruiman::Output.new(logs, name: entry.name, stream: :stdin, pid: pid)]
303
+ end
250
304
  lifecycle(entry, :started, "started with pid #{pid} (generation #{entry.generation})")
251
305
  ensure
306
+ input_slave&.close
252
307
  stdout_writer&.close
253
308
  stderr_writer&.close
254
309
  [stdout_reader, stderr_reader].compact.each do |reader|
@@ -278,8 +333,9 @@ class Foruiman::Engine
278
333
  entry.reaped = true
279
334
  entry.exit_status = result.last
280
335
  success = entry.exit_status.success?
281
- @failed ||= !success && !%i[stopping restarting].include?(entry.status)
282
- entry.status = success ? :exited : :failed unless %i[stopping restarting].include?(entry.status)
336
+ transitioning = %i[stopping restarting].include?(entry.status)
337
+ @failed ||= !success && !transitioning
338
+ entry.status = success ? :exited : :failed unless transitioning
283
339
  lifecycle(entry, :exited, termination_message_for(entry.exit_status))
284
340
  terminate(entry)
285
341
  end
@@ -299,6 +355,7 @@ class Foruiman::Engine
299
355
  next unless entry.reaped && entry.group_gone
300
356
  next if @readers.any? { |_reader, (owner, _output)| owner.equal?(entry) }
301
357
 
358
+ close_input(entry)
302
359
  entry.pgid = nil
303
360
  entry.deadline = nil
304
361
  entry.status = :stopped if entry.status == :stopping
@@ -328,16 +385,19 @@ class Foruiman::Engine
328
385
  break if budget <= 0
329
386
 
330
387
  entry, output = @readers.fetch(reader)
331
- bytes = reader.read_nonblock(READ_CHUNK, exception: false)
332
- if bytes.nil? || (bytes == :wait_readable && entry.group_gone)
333
- @readers.delete(reader)
334
- reader.close
335
- output.feed("", eof: true)
336
- elsif bytes != :wait_readable
337
- budget -= bytes.bytesize
338
- output.feed(bytes)
339
- # Rotate serviced readers to the back for the next select.
340
- @readers[reader] = @readers.delete(reader)
388
+ begin
389
+ bytes = reader.read_nonblock(READ_CHUNK, exception: false)
390
+ if bytes.nil? || (bytes == :wait_readable && entry.group_gone)
391
+ finish_reader(reader, entry, output)
392
+ elsif bytes != :wait_readable
393
+ budget -= bytes.bytesize
394
+ output.feed(bytes)
395
+ # Rotate serviced readers to the back for the next select.
396
+ @readers[reader] = @readers.delete(reader)
397
+ end
398
+ rescue Errno::EIO
399
+ # PTY masters report EIO when the child closes the slave side.
400
+ finish_reader(reader, entry, output)
341
401
  end
342
402
  end
343
403
  # A daemon that escaped the group may still hold a pipe open. Drain available
@@ -346,12 +406,50 @@ class Foruiman::Engine
346
406
  entry, output = @readers.fetch(reader)
347
407
  next unless entry.group_gone && !ready.include?(reader)
348
408
 
349
- @readers.delete(reader)
350
- reader.close
351
- output.feed("", eof: true)
409
+ finish_reader(reader, entry, output)
352
410
  end
353
411
  end
354
412
 
413
+ def create_managed_input
414
+ master, slave = PTY.open
415
+ [master, slave].each do |io|
416
+ io.binmode
417
+ io.close_on_exec = true
418
+ end
419
+ [master, slave]
420
+ end
421
+
422
+ def flush_input(entry)
423
+ return if entry.input_buffer.empty? || !entry.input
424
+
425
+ written = entry.input.write_nonblock(entry.input_buffer, exception: false)
426
+ entry.input_buffer.slice!(0, written) if written.is_a?(Integer)
427
+ true
428
+ rescue IOError, SystemCallError
429
+ reader = entry.input
430
+ finish_reader(reader, entry, @readers.fetch(reader).last) if reader && @readers.key?(reader)
431
+ false
432
+ end
433
+
434
+ def finish_reader(reader, entry, output)
435
+ @readers.delete(reader)
436
+ @inputs.delete(reader)
437
+ entry.input = nil if entry.input.equal?(reader)
438
+ reader.close unless reader.closed?
439
+ output.feed("", eof: true)
440
+ end
441
+
442
+ def close_input(entry)
443
+ input = entry.input
444
+ return unless input
445
+
446
+ @inputs.delete(input)
447
+ @readers.delete(input)
448
+ input.close unless input.closed?
449
+ entry.input = nil
450
+ entry.input_buffer.clear
451
+ end
452
+
355
453
  def lifecycle(entry, type, message)
356
454
  logs&.write(name: entry.name, stream: :lifecycle, pid: entry.pid,
357
455
  text: "--- #{message} ---", complete: true)
@@ -8,7 +8,7 @@ class Foruiman::Output
8
8
  def initialize(logs, name:, stream:, pid:)
9
9
  @logs = logs
10
10
  @metadata = { name: name, stream: stream, pid: pid }
11
- @decoder = Foruiman::ANSI::Decoder.new
11
+ @decoder = Foruiman::ANSI::Decoder.new(line_controls: true)
12
12
  @styles = Foruiman::ANSI::Styles.new
13
13
  @text = +""
14
14
  @previous = nil
@@ -18,20 +18,25 @@ class Foruiman::Output
18
18
 
19
19
  def feed(bytes, eof: false)
20
20
  decoded = @decoder.feed(bytes, eof: eof)
21
- decoded.scan(/\e\[[0-9;:]*m|\n|[^\e\n]+/).each do |token|
21
+ decoded.scan(/\e\[[0-9;:]*[mGCDK]|[\n\r\b]|[^\e\n\r\b]+/).each do |token|
22
22
  if token == "\n"
23
23
  if @split_boundary && !@dirty && !@previous
24
24
  @split_boundary = false
25
25
  else
26
26
  publish(true)
27
27
  end
28
+ elsif token == "\r" || token == "\b" || token.match?(/\A\e\[\d*[GCDK]\z/)
29
+ require_relative "output_line"
30
+ @line ||= Foruiman::OutputLine.new(@text)
31
+ @line.control(token, limit: MAX_BYTES / 2)
32
+ @dirty = true
28
33
  elsif token.start_with?("\e[")
29
34
  split_record if @text.bytesize + token.bytesize > MAX_BYTES
30
- @text << token
35
+ @text << token unless @line
31
36
  @styles.apply(token)
32
37
  @dirty = true
33
38
  else
34
- append_text(token)
39
+ @line ? edit_text(token) : append_text(token)
35
40
  end
36
41
  end
37
42
  if eof
@@ -43,6 +48,17 @@ class Foruiman::Output
43
48
 
44
49
  private
45
50
 
51
+ def edit_text(text)
52
+ text.scan(/\X/).each do |character|
53
+ if @line.cost + character.bytesize + @styles.prefix.bytesize + 4 > MAX_BYTES / 2
54
+ split_record
55
+ @line = Foruiman::OutputLine.new(@text)
56
+ end
57
+ @line.write(character, @styles.prefix)
58
+ @dirty = true
59
+ end
60
+ end
61
+
46
62
  def append_text(text)
47
63
  until text.empty?
48
64
  available = MAX_BYTES - @text.bytesize
@@ -66,11 +82,13 @@ class Foruiman::Output
66
82
 
67
83
  def publish(complete)
68
84
  @split_boundary = false
85
+ @text = @line.to_s if @line
69
86
  @previous = @logs.write(**@metadata, text: @text, complete: complete, previous: @previous)
70
87
  @dirty = false
71
88
  return unless complete
72
89
 
73
90
  @text = +@styles.prefix
91
+ @line = nil
74
92
  @previous = nil
75
93
  end
76
94
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+ require_relative "ansi"
5
+
6
+ # A single editable terminal row. Used only after a child emits a horizontal
7
+ # cursor control; ordinary append-only logs keep their fast streaming path.
8
+ class Foruiman::OutputLine
9
+ attr_reader :cost
10
+
11
+ def initialize(text)
12
+ @cells = []
13
+ @cursor = 0
14
+ @cost = 0
15
+ styles = Foruiman::ANSI::Styles.new
16
+ text.scan(/\e\[[0-9;:]*m|\X/).each do |token|
17
+ if token.match?(Foruiman::ANSI::SGR)
18
+ styles.apply(token)
19
+ else
20
+ write(token, styles.prefix)
21
+ end
22
+ end
23
+ end
24
+
25
+ def write(character, style)
26
+ width = Unicode::DisplayWidth.of(character, emoji: :all)
27
+ if width.zero? && @cursor.positive?
28
+ index = @cursor - 1
29
+ index -= 1 while index.positive? && @cells[index] == :continuation
30
+ @cells[index][0] += character if @cells[index].is_a?(Array)
31
+ else
32
+ width.times { |offset| clear_cell(@cursor + offset) }
33
+ @cells[@cursor] = [character, style]
34
+ (1...width).each { |offset| @cells[@cursor + offset] = :continuation }
35
+ @cursor += width
36
+ end
37
+ # Conservative, constant-time bound including style transitions.
38
+ @cost += character.bytesize + style.bytesize + Foruiman::ANSI::RESET.bytesize
39
+ end
40
+
41
+ def control(token, limit:)
42
+ @cost = to_s.bytesize
43
+ number = token[/\d+/].to_i
44
+ amount = [number, 1].max
45
+ case token
46
+ when "\r" then @cursor = 0
47
+ when "\b" then @cursor = [@cursor - 1, 0].max
48
+ else
49
+ case token[-1]
50
+ when "G" then @cursor = (amount - 1).clamp(0, limit)
51
+ when "C" then @cursor = (@cursor + amount).clamp(0, limit)
52
+ when "D" then @cursor = [@cursor - amount, 0].max
53
+ when "K" then erase(number)
54
+ end
55
+ end
56
+ end
57
+
58
+ def to_s
59
+ style = ""
60
+ @cells.each_with_object(+"") do |cell, text|
61
+ next if cell == :continuation
62
+
63
+ character, next_style = cell || [" ", ""]
64
+ if next_style != style
65
+ text << Foruiman::ANSI::RESET unless style.empty?
66
+ text << next_style
67
+ style = next_style
68
+ end
69
+ text << character
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def clear_cell(index)
76
+ if @cells[index] == :continuation
77
+ start = index - 1
78
+ start -= 1 while start.positive? && @cells[start] == :continuation
79
+ @cells[start] = nil
80
+ end
81
+ following = index + 1
82
+ while @cells[following] == :continuation
83
+ @cells[following] = nil
84
+ following += 1
85
+ end
86
+ @cells[index] = nil
87
+ end
88
+
89
+ def erase(mode)
90
+ case mode
91
+ when 0
92
+ clear_cell(@cursor) if @cursor < @cells.size
93
+ @cells.slice!(@cursor..) if @cursor < @cells.size
94
+ when 1
95
+ [@cursor + 1, @cells.size].min.times { |index| clear_cell(index) }
96
+ when 2 then @cells.clear
97
+ end
98
+ @cost = to_s.bytesize
99
+ end
100
+ end
@@ -12,7 +12,7 @@ class Foruiman::Process
12
12
 
13
13
  def run(options = {})
14
14
  ::Process.spawn(env.merge(options.fetch(:env, {})), "/bin/sh", "-c", command,
15
- chdir: cwd, in: File::NULL, out: options.fetch(:output, $stdout),
15
+ chdir: cwd, in: options.fetch(:input, $stdin), out: options.fetch(:output, $stdout),
16
16
  err: options.fetch(:error, $stderr), pgroup: true, unsetenv_others: true,
17
17
  close_others: true)
18
18
  end
@@ -22,13 +22,16 @@ module Foruiman::TUI
22
22
  end
23
23
 
24
24
  def run
25
+ @engine.manage_input!
25
26
  @terminal.session do
26
27
  @engine.run(keep_open: true) do
27
28
  rows, columns = @terminal.size
29
+ @engine.resize_inputs(rows, columns)
30
+ detach_unavailable_input
28
31
  input = @terminal.read
29
32
  @engine.shutdown if input.nil?
30
- height = [Renderer.log_height(rows: rows, columns: columns), 1].max
31
- @keyboard.feed(input.is_a?(String) ? input : "").each { |key| handle(key, height) }
33
+ height = [Renderer.log_height(rows: rows, columns: columns, input: state.input?), 1].max
34
+ read_keys(input.is_a?(String) ? input : "", height)
32
35
  state.feedback = nil if monotonic >= @feedback_until
33
36
  next if monotonic < @next_frame_at && @was_shutting_down == @engine.shutting_down?
34
37
 
@@ -66,7 +69,9 @@ module Foruiman::TUI
66
69
  when :toggle_follow then state.viewport.toggle(buffer, height)
67
70
  when :help then state.help = !state.help
68
71
  when :escape then state.help = false
69
- when :restart, :stop then control(key)
72
+ when :restart then control(key)
73
+ when :stop then toggle_process
74
+ when :input then begin_input
70
75
  when :restart_all, :stop_all
71
76
  control_all(key == :restart_all ? :restart : :stop)
72
77
  when :quit then @engine.shutdown
@@ -75,6 +80,76 @@ module Foruiman::TUI
75
80
 
76
81
  private
77
82
 
83
+ def read_keys(bytes, height)
84
+ @keyboard.feed("").each { |key| handle(key, height) } unless state.input?
85
+ bytes.bytes.each_with_index do |byte, index|
86
+ if state.input?
87
+ forward_input(bytes.byteslice(index..))
88
+ break
89
+ end
90
+ @keyboard.feed(byte.chr).each { |key| handle(key, height) }
91
+ end
92
+ end
93
+
94
+ def begin_input
95
+ if state.name == "all"
96
+ feedback("Select a process before entering input mode")
97
+ elsif @engine.state(state.name).status != :running
98
+ feedback("#{state.name} is not running")
99
+ else
100
+ state.help = false
101
+ state.viewport.follow
102
+ state.input_target = state.name
103
+ state.feedback = nil
104
+ end
105
+ end
106
+
107
+ def forward_input(bytes)
108
+ name = state.input_target
109
+ state.input_line.feed(bytes).each do |event|
110
+ case event
111
+ when :detach
112
+ state.input_target = nil
113
+ feedback("Returned from #{name}")
114
+ when :interrupt then @engine.interrupt_process(name)
115
+ when :eof then send_input(name, "\x04")
116
+ when Array then send_input(name, "#{event.last}\n")
117
+ end
118
+ end
119
+ end
120
+
121
+ def send_input(name, bytes)
122
+ return if @engine.write_input(name, bytes)
123
+
124
+ state.input_target = nil
125
+ feedback("Input unavailable for #{name}")
126
+ end
127
+
128
+ def detach_unavailable_input
129
+ return unless state.input?
130
+ return if @engine.state(state.input_target).status == :running
131
+
132
+ name = state.input_target
133
+ state.input_target = nil
134
+ feedback("Input closed for #{name}")
135
+ end
136
+
137
+ def toggle_process
138
+ if state.name == "all"
139
+ control_all(:stop)
140
+ return
141
+ end
142
+
143
+ entry = @engine.state(state.name)
144
+ if entry.pgid
145
+ @engine.stop(state.name)
146
+ feedback("Stopping #{state.name}")
147
+ else
148
+ @engine.start(state.name)
149
+ feedback("Starting #{state.name}")
150
+ end
151
+ end
152
+
78
153
  def control(action)
79
154
  if state.name == "all"
80
155
  if action == :restart
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foruiman::TUI
4
+ class InputLine
5
+ # Leave room for the newline in the child's canonical terminal buffer.
6
+ MAX_BYTES = 4095
7
+ HISTORY_SIZE = 100
8
+ SEQUENCES = {
9
+ "\e[A" => :history_previous, "\e[B" => :history_next,
10
+ "\e[C" => :right, "\e[D" => :left,
11
+ "\e[H" => :home, "\e[F" => :end,
12
+ "\e[1~" => :home, "\e[4~" => :end, "\e[3~" => :delete
13
+ }.freeze
14
+ CONTROLS = {
15
+ 1 => :home, 3 => :interrupt, 4 => :eof, 5 => :end, 8 => :backspace,
16
+ 10 => :submit, 13 => :submit, 18 => :history_previous, 21 => :clear, 23 => :delete_word,
17
+ 24 => :detach, 127 => :backspace
18
+ }.freeze
19
+
20
+ attr_reader :text, :cursor
21
+
22
+ def initialize
23
+ @text = +""
24
+ @cursor = 0
25
+ @bytes = +"".b
26
+ @history = []
27
+ @history_index = nil
28
+ @draft = nil
29
+ end
30
+
31
+ def feed(bytes)
32
+ bytes, detach, = bytes.b.partition("\x18")
33
+ @bytes << bytes
34
+ events = []
35
+ until @bytes.empty?
36
+ if @bytes.start_with?("\e")
37
+ if @bytes.bytesize > 64 && incomplete_escape?
38
+ @bytes.clear
39
+ break
40
+ end
41
+ break if incomplete_escape?
42
+
43
+ sequence = SEQUENCES.keys.find { |candidate| @bytes.start_with?(candidate) }
44
+ if sequence
45
+ edit(SEQUENCES.fetch(sequence))
46
+ @bytes.slice!(0, sequence.bytesize)
47
+ else
48
+ match = @bytes.match(%r{\A\e(?:\[[0-?]*[ -/]*[@-~]|O.|.)}m)
49
+ @bytes.slice!(0, match ? match[0].bytesize : 1)
50
+ end
51
+ next
52
+ end
53
+
54
+ byte = @bytes.getbyte(0)
55
+ if @after_cr && byte == 10
56
+ @bytes.slice!(0, 1)
57
+ @after_cr = false
58
+ next
59
+ end
60
+ @after_cr = byte == 13
61
+ if CONTROLS.key?(byte)
62
+ @bytes.slice!(0, 1)
63
+ event = edit(CONTROLS.fetch(byte))
64
+ events << event if event
65
+ elsif byte < 32
66
+ @bytes.slice!(0, 1)
67
+ else
68
+ character = next_character
69
+ break unless character
70
+
71
+ insert(character)
72
+ end
73
+ end
74
+ unless detach.empty?
75
+ @bytes.clear
76
+ clear
77
+ events << :detach
78
+ end
79
+ events
80
+ end
81
+
82
+ def clear
83
+ replace("")
84
+ @history_index = nil
85
+ @draft = nil
86
+ end
87
+
88
+ private
89
+
90
+ def edit(action)
91
+ case action
92
+ when :left then @cursor -= 1 if cursor.positive?
93
+ when :right then @cursor += 1 if cursor < graphemes.size
94
+ when :home then @cursor = 0
95
+ when :end then @cursor = graphemes.size
96
+ when :backspace then remove(cursor - 1) if cursor.positive?
97
+ when :delete then remove(cursor) if cursor < graphemes.size
98
+ when :delete_word then delete_word
99
+ when :clear then clear
100
+ when :history_previous then history_previous
101
+ when :history_next then history_next
102
+ when :submit then return submit
103
+ when :interrupt
104
+ clear
105
+ return :interrupt
106
+ when :eof
107
+ return :eof if text.empty?
108
+
109
+ remove(cursor) if cursor < graphemes.size
110
+ when :detach
111
+ clear
112
+ return :detach
113
+ end
114
+ nil
115
+ end
116
+
117
+ def submit
118
+ line = text.dup
119
+ @history << line.freeze unless line.empty? || @history.last == line
120
+ @history.shift if @history.size > HISTORY_SIZE
121
+ clear
122
+ [:submit, line]
123
+ end
124
+
125
+ def insert(character)
126
+ return if text.bytesize + character.bytesize > MAX_BYTES
127
+ return if character.match?(/[\u0080-\u009f]/)
128
+
129
+ if character.ascii_only? && cursor == text.length
130
+ @text << character
131
+ @cursor += 1
132
+ @history_index = nil
133
+ return
134
+ end
135
+
136
+ parts = graphemes
137
+ parts.insert(cursor, character)
138
+ @text = parts.join
139
+ @cursor = [cursor + 1, graphemes.size].min
140
+ @history_index = nil
141
+ end
142
+
143
+ def remove(index)
144
+ parts = graphemes
145
+ parts.delete_at(index)
146
+ @text = parts.join
147
+ @cursor -= 1 if index < cursor
148
+ @history_index = nil
149
+ end
150
+
151
+ def delete_word
152
+ parts = graphemes
153
+ index = cursor
154
+ index -= 1 while index.positive? && parts[index - 1].match?(/\s/)
155
+ index -= 1 while index.positive? && !parts[index - 1].match?(/\s/)
156
+ parts.slice!(index...cursor)
157
+ @text = parts.join
158
+ @cursor = index
159
+ @history_index = nil
160
+ end
161
+
162
+ def history_previous
163
+ return if @history.empty?
164
+
165
+ @draft = text.dup unless @history_index
166
+ @history_index = [(@history_index || @history.size) - 1, 0].max
167
+ replace(@history.fetch(@history_index))
168
+ end
169
+
170
+ def history_next
171
+ return unless @history_index
172
+
173
+ @history_index += 1
174
+ if @history_index >= @history.size
175
+ @history_index = nil
176
+ replace(@draft.to_s)
177
+ @draft = nil
178
+ else
179
+ replace(@history.fetch(@history_index))
180
+ end
181
+ end
182
+
183
+ def replace(value)
184
+ @text = +value
185
+ @cursor = graphemes.size
186
+ end
187
+
188
+ def graphemes
189
+ text.scan(/\X/)
190
+ end
191
+
192
+ def next_character
193
+ size = utf8_size(@bytes.getbyte(0))
194
+ return @bytes.slice!(0, 1).force_encoding(Encoding::UTF_8).scrub unless size
195
+ unless @bytes.byteslice(1, size - 1).bytes.all? { |byte| byte.between?(128, 191) }
196
+ return @bytes.slice!(0, 1).force_encoding(Encoding::UTF_8).scrub
197
+ end
198
+ return if @bytes.bytesize < size
199
+
200
+ @bytes.slice!(0, size).force_encoding(Encoding::UTF_8).scrub
201
+ end
202
+
203
+ def utf8_size(byte)
204
+ case byte
205
+ when 0..127 then 1
206
+ when 194..223 then 2
207
+ when 224..239 then 3
208
+ when 240..244 then 4
209
+ end
210
+ end
211
+
212
+ def incomplete_escape?
213
+ @bytes == "\e" || @bytes == "\e[" || @bytes == "\eO" ||
214
+ (@bytes.start_with?("\e[") && !@bytes.match?(%r{\A\e\[[0-?]*[ -/]*[@-~]}))
215
+ end
216
+ end
217
+ end
@@ -12,7 +12,7 @@ module Foruiman::TUI
12
12
  "\t" => :next, "h" => :previous, "l" => :next, "k" => :up, "j" => :down,
13
13
  "g" => :home, "G" => :end, "f" => :follow, " " => :toggle_follow,
14
14
  "r" => :restart, "R" => :restart_all, "s" => :stop, "S" => :stop_all,
15
- "?" => :help, "q" => :quit, "\x03" => :quit,
15
+ "i" => :input, "?" => :help, "q" => :quit, "\x03" => :quit,
16
16
  "\x15" => :page_up, "\x04" => :page_down
17
17
  }.freeze
18
18
 
@@ -30,6 +30,7 @@ module Foruiman::TUI
30
30
 
31
31
  def stream_style(stream)
32
32
  case stream
33
+ when :stdin then ["in ", :cyan]
33
34
  when :stderr then ["err", :red]
34
35
  when :lifecycle then ["sys", :amber]
35
36
  else ["out", :faint]
@@ -38,6 +39,7 @@ module Foruiman::TUI
38
39
 
39
40
  def body_color(stream)
40
41
  case stream
42
+ when :stdin then :cyan
41
43
  when :stderr then :red
42
44
  when :lifecycle then :muted
43
45
  else :text
@@ -16,7 +16,8 @@ module Foruiman::TUI
16
16
  [nil, "f / G / End", "Follow newest output", nil],
17
17
  [nil, "Space", "Pause / resume following", nil],
18
18
  ["⚙ PROCESSES", "r / R", "Restart selected / all", "r on all restarts all"],
19
- [nil, "s / S", "Stop selected / all", nil],
19
+ [nil, "s / S", "Start/stop selected / stop all", "s on all stops all"],
20
+ ["⌨ INPUT", "i", "Edit a command; Enter sends", "Ctrl-X returns to Foruiman"],
20
21
  ["◇ SESSION", "? / Escape", "Close help", nil],
21
22
  [nil, "q / Ctrl-C", "Stop processes and quit", nil]
22
23
  ].freeze
@@ -30,16 +31,16 @@ module Foruiman::TUI
30
31
  rows >= 14 && columns >= 60
31
32
  end
32
33
 
33
- def self.log_height(rows:, columns:)
34
+ def self.log_height(rows:, columns:, input: false)
34
35
  return 0 if rows < 6 || columns < 24
35
36
 
36
- rows - (expanded?(rows: rows, columns: columns) ? 7 : 5)
37
+ rows - (expanded?(rows: rows, columns: columns) ? 7 : 5) - (input && rows >= 8 ? 2 : 0)
37
38
  end
38
39
 
39
40
  def render(state, engine, rows:, columns:)
40
41
  @width = [columns - 1, 1].max
41
42
  @inside = [@width - 4, 0].max
42
- @height = self.class.log_height(rows: rows, columns: columns)
43
+ @height = self.class.log_height(rows: rows, columns: columns, input: state.input?)
43
44
  return tiny_frame(state, engine, rows) if @height.zero?
44
45
 
45
46
  lines = [header(engine)]
@@ -54,6 +55,7 @@ module Foruiman::TUI
54
55
  lines << log_header(state, engine)
55
56
  lines.concat(state.help ? help_rows : log_rows(state, engine))
56
57
  lines << log_footer(state, engine)
58
+ lines.concat(input_rows(state)) if state.input? && rows >= 8
57
59
  lines << controls(state, engine)
58
60
  frame(lines, rows)
59
61
  end
@@ -157,7 +159,9 @@ module Foruiman::TUI
157
159
  else
158
160
  engine.state(state.name).status == :running
159
161
  end
160
- mode = if !state.viewport.following
162
+ mode = if state.input?
163
+ @theme.paint(" ▶ INPUT ", :cyan)
164
+ elsif !state.viewport.following
161
165
  @theme.paint(" Ⅱ PAUSED ", :amber)
162
166
  elsif active
163
167
  @theme.paint(" ● LIVE ", :green)
@@ -244,6 +248,12 @@ module Foruiman::TUI
244
248
  end
245
249
 
246
250
  def controls(state, engine)
251
+ if state.input?
252
+ left = @theme.paint(" Enter send · ↑↓ history · Ctrl-C interrupt", :muted)
253
+ right = @theme.paint(" Ctrl-X back ", :amber, bold: true)
254
+ return distribute(left, right, @width)
255
+ end
256
+
247
257
  quit = @theme.paint(" q ", :accent, bold: true) + @theme.paint(" × quit ", :muted)
248
258
  left = if engine.shutting_down?
249
259
  @theme.paint(" ◌ Stopping process groups · TERM → KILL after 5s", :amber)
@@ -252,20 +262,46 @@ module Foruiman::TUI
252
262
  elsif state.help
253
263
  @theme.paint(" ? / Escape close help", :muted)
254
264
  else
255
- shortcuts(state)
265
+ shortcuts(state, engine)
256
266
  end
257
267
  distribute(left, quit, @width)
258
268
  end
259
269
 
260
- def shortcuts(state)
270
+ def shortcuts(state, engine)
261
271
  restart = ["r", state.name == "all" ? "↻ restart all" : "↻ restart"]
262
- pairs = [%w[Tab switch], ["↑↓", "scroll"], %w[f follow], restart, ["?", "help"]]
263
- pairs = [restart, ["?", "help"]] if @width < 65
272
+ toggle = if state.name == "all"
273
+ ["s", "■ stop all"]
274
+ elsif process_active?(engine.state(state.name))
275
+ ["s", "■ stop"]
276
+ else
277
+ ["s", "▶ start"]
278
+ end
279
+ pairs = [%w[Tab switch], ["↑↓", "scroll"], %w[f follow], restart,
280
+ toggle, %w[i input], ["?", "help"]]
281
+ pairs = [restart, toggle, %w[i input], ["?", "help"]] if @width < 85
264
282
  pairs.map do |key, label|
265
283
  @theme.paint(" #{key} ", :accent, bold: true) + @theme.paint(" #{label} ", :muted)
266
284
  end.join
267
285
  end
268
286
 
287
+ def process_active?(entry)
288
+ entry.pgid || %i[running restarting stopping].include?(entry.status)
289
+ end
290
+
291
+ def input_rows(state)
292
+ editor = state.input_line
293
+ parts = editor.text.scan(/\X/)
294
+ before = parts.take(editor.cursor)
295
+ current = parts[editor.cursor] || " "
296
+ after = parts.drop(editor.cursor + 1).join
297
+ available = [@inside - 5, 1].max
298
+ before.shift while !before.empty? && Text.width(before.join + current) > available
299
+ draft = @theme.paint("❯ ", :cyan, bold: true) + before.join +
300
+ @theme.paint(current,
301
+ selected: true) + Text.clip(after, [available - Text.width(before.join + current), 0].max)
302
+ [border(@theme.paint(" ⌨ Input to #{state.input_target} ", :cyan, bold: true)), panel(draft)]
303
+ end
304
+
269
305
  def border(left, right = "", bottom: false)
270
306
  corners = bottom ? %w[╰ ╯] : %w[╭ ╮]
271
307
  middle = distribute(left, right, @inside, fill: "─")
@@ -290,7 +326,7 @@ module Foruiman::TUI
290
326
  title = @theme.paint(" FORUIMAN", :accent, bold: true)
291
327
  message = engine.shutting_down? ? " Stopping…" : " #{state.name} · enlarge terminal"
292
328
  lines = [title, @theme.paint(message, :muted)]
293
- lines[rows - 1] = @theme.paint(" q quit", :accent) if rows > 2
329
+ lines[rows - 1] = @theme.paint(state.input? ? " Ctrl-X back" : " q quit", :accent) if rows > 2
294
330
  frame(lines, rows)
295
331
  end
296
332
 
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "viewport"
4
+ require_relative "input_line"
4
5
 
5
6
  module Foruiman::TUI
6
7
  class State
7
8
  attr_reader :tabs, :selected, :viewports
8
- attr_accessor :help, :feedback
9
+ attr_accessor :help, :feedback, :input_target
9
10
 
10
11
  def initialize(names)
11
12
  @tabs = [*names, "all"].freeze
@@ -13,6 +14,8 @@ module Foruiman::TUI
13
14
  @viewports = tabs.to_h { |name| [name, Viewport.new] }
14
15
  @help = false
15
16
  @feedback = nil
17
+ @input_target = nil
18
+ @input_lines = names.to_h { |name| [name, InputLine.new] }
16
19
  end
17
20
 
18
21
  def name
@@ -23,6 +26,14 @@ module Foruiman::TUI
23
26
  viewports.fetch(name)
24
27
  end
25
28
 
29
+ def input?
30
+ !input_target.nil?
31
+ end
32
+
33
+ def input_line
34
+ @input_lines.fetch(input_target || name)
35
+ end
36
+
26
37
  def select(index)
27
38
  @selected = index if index.between?(0, tabs.size - 1)
28
39
  end
@@ -16,8 +16,8 @@ module Foruiman::TUI
16
16
  stopped: :muted, exited: :muted, failed: :red
17
17
  }.freeze
18
18
  STATUS_MARKS = {
19
- pending: "○", running: "", restarting: "↻", stopping: "",
20
- stopped: "■", exited: "✓", failed: "×"
19
+ pending: "○", running: "", restarting: "↻", stopping: "",
20
+ stopped: "■", exited: "✓", failed: ""
21
21
  }.freeze
22
22
 
23
23
  attr_reader :enabled
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Foruiman
4
- VERSION = "0.1.2"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foruiman
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foruiman contributors
@@ -75,11 +75,13 @@ files:
75
75
  - lib/foruiman/env.rb
76
76
  - lib/foruiman/log_store.rb
77
77
  - lib/foruiman/output.rb
78
+ - lib/foruiman/output_line.rb
78
79
  - lib/foruiman/plain.rb
79
80
  - lib/foruiman/process.rb
80
81
  - lib/foruiman/procfile.rb
81
82
  - lib/foruiman/ring.rb
82
83
  - lib/foruiman/tui/application.rb
84
+ - lib/foruiman/tui/input_line.rb
83
85
  - lib/foruiman/tui/keyboard.rb
84
86
  - lib/foruiman/tui/log_formatter.rb
85
87
  - lib/foruiman/tui/renderer.rb