letsdo 0.4.0 → 0.6.1

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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -2
  3. data/README.md +150 -18
  4. data/bin/letsdo +8 -1
  5. data/docs/config.md +176 -0
  6. data/docs/prompts.md +210 -0
  7. data/docs/task-selection.md +244 -0
  8. data/docs/usage.md +296 -0
  9. data/letsdo.gemspec +3 -1
  10. data/lib/letsdo/agent.rb +30 -17
  11. data/lib/letsdo/agent_identity.rb +46 -0
  12. data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
  13. data/lib/letsdo/agent_loop/tasks.rb +88 -13
  14. data/lib/letsdo/agent_loop.rb +39 -3
  15. data/lib/letsdo/backends/backend.rb +137 -0
  16. data/lib/letsdo/backends/pi/events.rb +77 -0
  17. data/lib/letsdo/backends/pi.rb +88 -0
  18. data/lib/letsdo/cli/builder.rb +47 -95
  19. data/lib/letsdo/cli/builder_assembly.rb +154 -0
  20. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  21. data/lib/letsdo/cli/doctor.rb +16 -0
  22. data/lib/letsdo/cli.rb +15 -1
  23. data/lib/letsdo/config.rb +89 -8
  24. data/lib/letsdo/control/reader.rb +131 -0
  25. data/lib/letsdo/control.rb +6 -3
  26. data/lib/letsdo/doctor/checks.rb +144 -0
  27. data/lib/letsdo/doctor.rb +42 -0
  28. data/lib/letsdo/duration.rb +23 -0
  29. data/lib/letsdo/errors.rb +7 -0
  30. data/lib/letsdo/metrics/fanout.rb +47 -0
  31. data/lib/letsdo/prompt_store.rb +48 -1
  32. data/lib/letsdo/providers/backlog.rb +180 -0
  33. data/lib/letsdo/providers/task.rb +48 -0
  34. data/lib/letsdo/retry_policy.rb +98 -0
  35. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  36. data/lib/letsdo/session_recorder.rb +188 -0
  37. data/lib/letsdo/task_time_writeback.rb +106 -0
  38. data/lib/letsdo/tui/metrics.rb +10 -3
  39. data/lib/letsdo/tui/renderer.rb +9 -1
  40. data/lib/letsdo/tui/session/terminal.rb +47 -0
  41. data/lib/letsdo/tui/session/view.rb +10 -2
  42. data/lib/letsdo/tui/session.rb +27 -22
  43. data/lib/letsdo/tui/window_title.rb +133 -0
  44. data/lib/letsdo/tui.rb +4 -0
  45. data/lib/letsdo/version.rb +1 -1
  46. data/lib/letsdo.rb +16 -5
  47. metadata +27 -6
  48. data/lib/letsdo/backlog_tasks.rb +0 -59
  49. data/lib/letsdo/pi_runner/events.rb +0 -75
  50. data/lib/letsdo/pi_runner/process.rb +0 -68
  51. data/lib/letsdo/pi_runner.rb +0 -101
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module Letsdo
7
+ class SessionRecorder
8
+ # Writes the optional JSON Lines metrics stream for a session recorder:
9
+ # a session_start line on construction, one run_finished line per closed
10
+ # run and a session_stop line at the end. With a nil IO the writer is a
11
+ # no-op, so callers need no nil checks.
12
+ class JsonlWriter
13
+ # @param io [IO, nil] append-only target; nil disables the writer
14
+ # @param name [String] agent name
15
+ # @param handle [String] assignee handle
16
+ # @param wall_clock [Proc] -> ISO8601 UTC timestamp string
17
+ def initialize(io, name:, handle:, wall_clock:)
18
+ @io = io
19
+ @name = name
20
+ @handle = handle
21
+ @wall_clock = wall_clock
22
+ session_start if enabled?
23
+ end
24
+
25
+ # @return [Boolean] whether an output target is configured
26
+ def enabled?
27
+ !@io.nil?
28
+ end
29
+
30
+ # Writes the opening session_start line (no-op when disabled).
31
+ #
32
+ # @return [void]
33
+ def session_start
34
+ write(session_start_payload)
35
+ end
36
+
37
+ # @param run [SessionRecorder::Run] a closed run record
38
+ # @return [void]
39
+ def run_finished(run)
40
+ write(
41
+ 'event' => 'run_finished', 'task' => run.task_id, 'exit' => run.exit_code,
42
+ 'outcome' => run.outcome.to_s, 'elapsed_s' => run.elapsed_s,
43
+ 'ts' => @wall_clock.call
44
+ )
45
+ end
46
+
47
+ # @param summary [SessionRecorder::Summary] the final aggregate
48
+ # @return [void]
49
+ def session_stop(summary)
50
+ write(
51
+ 'event' => 'session_stop', 'agent' => @name, 'handle' => @handle,
52
+ 'ts' => @wall_clock.call, 'done' => summary.done, 'failed' => summary.failed,
53
+ 'interrupted' => summary.interrupted, 'left' => summary.left,
54
+ 'session_s' => summary.session_s, 'runs_s' => summary.active_s
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def session_start_payload
61
+ { 'event' => 'session_start', 'agent' => @name, 'handle' => @handle,
62
+ 'ts' => @wall_clock.call }
63
+ end
64
+
65
+ def write(payload)
66
+ return unless enabled?
67
+
68
+ @io.puts(JSON.generate(payload))
69
+ @io.flush if @io.respond_to?(:flush)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'duration'
4
+ require_relative 'session_recorder/jsonl_writer'
5
+
6
+ module Letsdo
7
+ # A mode-independent session metrics recorder. It receives the same
8
+ # loop-driver events as the TUI metrics facade, records per-run outcomes
9
+ # and durations, and can optionally emit a JSONL event stream.
10
+ #
11
+ # The recorder is intentionally free of TUI dependencies so it can be
12
+ # used in plain mode, TUI mode, or embedded contexts.
13
+ class SessionRecorder
14
+ Run = Struct.new(:task_id, :started_mono, :finished_mono, :elapsed_s,
15
+ :exit_code, :outcome, keyword_init: true)
16
+
17
+ Summary = Struct.new(:done, :failed, :interrupted, :left, :session_s,
18
+ :active_s, :waiting_s, :avg_s, :runs,
19
+ keyword_init: true)
20
+
21
+ MAX_SUMMARY_RUNS = 10
22
+
23
+ HEADLINE = 'letsdo: session: %<done>d done, %<failed>d failed, ' \
24
+ '%<interrupted>d interrupted, %<left>s left open, %<session>s ' \
25
+ '(%<active>s in runs, %<waiting>s waiting, avg %<avg>s)'
26
+
27
+ # Rendering of the human-readable stop summary (TASK-63 C3). Kept nested
28
+ # so SessionRecorder stays within the class-length limit.
29
+ module SummaryFormat
30
+ private
31
+
32
+ def headline(summary)
33
+ left = summary.left.nil? ? 'unknown' : summary.left
34
+ format(HEADLINE, done: summary.done, failed: summary.failed,
35
+ interrupted: summary.interrupted, left: left,
36
+ session: format_duration(summary.session_s),
37
+ active: format_duration(summary.active_s),
38
+ waiting: format_duration(summary.waiting_s),
39
+ avg: format_duration(summary.avg_s))
40
+ end
41
+
42
+ def run_lines(summary)
43
+ lines = summary.runs.first(MAX_SUMMARY_RUNS).map { |run| format_run(run) }
44
+ return lines unless summary.runs.length > MAX_SUMMARY_RUNS
45
+
46
+ lines << "letsdo: … and #{summary.runs.length - MAX_SUMMARY_RUNS} more"
47
+ end
48
+
49
+ def format_run(run)
50
+ return "letsdo: #{run.task_id} interrupted" if run.finished_mono.nil?
51
+
52
+ outcome = run.outcome == :done ? 'done' : 'failed'
53
+ "letsdo: #{run.task_id} #{outcome} in #{format_duration(run.elapsed_s)}"
54
+ end
55
+
56
+ # Shared with the task-time write-back so summary and task record agree.
57
+ def format_duration(seconds)
58
+ Duration.format(seconds)
59
+ end
60
+ end
61
+ include SummaryFormat
62
+
63
+ # @param name [String] agent name
64
+ # @param handle [String] assignee handle
65
+ # @param clock [Proc] monotonic clock -> seconds; default
66
+ # Process.clock_gettime(CLOCK_MONOTONIC)
67
+ # @param wall_clock [Proc] wall clock -> ISO8601 UTC string; default
68
+ # Time.now.utc.iso8601
69
+ # @param metrics_io [IO, nil] optional append-only JSONL target
70
+ def initialize(name:, handle:, clock: nil, wall_clock: nil, metrics_io: nil)
71
+ @name = name
72
+ @handle = handle
73
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
74
+ @wall_clock = wall_clock || -> { Time.now.utc.iso8601 }
75
+ @writer = JsonlWriter.new(metrics_io, name: name, handle: handle, wall_clock: @wall_clock)
76
+ @mutex = Mutex.new
77
+ @runs = []
78
+ @left = nil
79
+ @session_started = @clock.call
80
+ @session_stopped = false
81
+ end
82
+
83
+ # A run started for a task.
84
+ #
85
+ # @param task_id [String] task label
86
+ # @return [void]
87
+ def run_started(task_id)
88
+ @mutex.synchronize do
89
+ @runs << Run.new(task_id: task_id, started_mono: @clock.call)
90
+ end
91
+ end
92
+
93
+ # A run finished. The last still-open run is closed with the given
94
+ # exit code. If no run is open, this is a no-op.
95
+ #
96
+ # @param exit_code [Integer, nil] the run exit code
97
+ # @return [void]
98
+ def run_finished(exit_code = nil)
99
+ @mutex.synchronize do
100
+ run = @runs.reverse.find { |candidate| candidate.finished_mono.nil? }
101
+ return unless run
102
+
103
+ close_run(run, exit_code)
104
+ @writer.run_finished(run)
105
+ end
106
+ end
107
+
108
+ # The latest open-task count from the backlog provider.
109
+ #
110
+ # @param count [Integer, nil] number of open tasks; nil = the backlog
111
+ # state is unreadable
112
+ # @return [void]
113
+ def provider_result(count)
114
+ @mutex.synchronize { @left = count }
115
+ end
116
+
117
+ # A point-in-time summary of the session metrics.
118
+ #
119
+ # waiting_s is a derived approximation: session time minus the sum of
120
+ # run durations. It therefore also includes polling, backlog reads and
121
+ # stop overhead, not only idle waiting for new tasks.
122
+ #
123
+ # @return [Summary] aggregate counts and durations
124
+ def summary
125
+ @mutex.synchronize { build_summary }
126
+ end
127
+
128
+ # Writes the session_stop JSONL event and returns the summary.
129
+ # The event is written only once per recorder instance.
130
+ #
131
+ # @return [Summary]
132
+ def session_stop
133
+ emit = claim_stop_event
134
+ result = summary
135
+ @writer.session_stop(result) if emit
136
+ result
137
+ end
138
+
139
+ # A human-readable summary suitable for stderr.
140
+ #
141
+ # @return [String]
142
+ def summary_line
143
+ s = summary
144
+ lines = [headline(s)] + run_lines(s)
145
+ lines.join("\n")
146
+ end
147
+
148
+ private
149
+
150
+ def close_run(run, exit_code)
151
+ run.finished_mono = @clock.call
152
+ run.elapsed_s = run.finished_mono - run.started_mono
153
+ run.exit_code = exit_code
154
+ run.outcome = exit_code&.zero? ? :done : :failed
155
+ end
156
+
157
+ def build_summary
158
+ session_s = @clock.call - @session_started
159
+ active_s = @runs.sum { |run| run.elapsed_s || 0.0 }
160
+ Summary.new(
161
+ done: count_outcome(:done), failed: count_outcome(:failed),
162
+ interrupted: @runs.count { |run| run.finished_mono.nil? }, left: @left,
163
+ session_s: session_s, active_s: active_s, waiting_s: session_s - active_s,
164
+ avg_s: average_done_run, runs: @runs.dup
165
+ )
166
+ end
167
+
168
+ def count_outcome(outcome)
169
+ @runs.count { |run| run.outcome == outcome }
170
+ end
171
+
172
+ def average_done_run
173
+ done = @runs.select { |run| run.outcome == :done }
174
+ return 0.0 if done.empty?
175
+
176
+ done.sum { |run| run.elapsed_s || 0.0 } / done.size
177
+ end
178
+
179
+ def claim_stop_event
180
+ @mutex.synchronize do
181
+ return false if @session_stopped
182
+
183
+ @session_stopped = true
184
+ @writer.enabled?
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative 'duration'
5
+ require_relative 'capture'
6
+
7
+ module Letsdo
8
+ # Opt-in per-task elapsed write-back (TASK-70; design TASK-63 C3.3).
9
+ #
10
+ # At session stop the CLI hands this provider-agnostic writer the session's
11
+ # run records and one fresh provider snapshot. Every run that exited 0 and
12
+ # whose task is no longer open gets a backlog comment recording the elapsed
13
+ # time:
14
+ #
15
+ # backlog task edit <id> --comment 'letsdo: completed in 4m 12s' \
16
+ # --comment-author @letsdo
17
+ #
18
+ # A task that is still open after its run is skipped — calling it completed
19
+ # would be wrong. The write-back is batched at stop (all agent runs are
20
+ # dead), so it cannot race the agent's own closing edit on the file-based
21
+ # backlog CLI.
22
+ #
23
+ # Failures never abort the stop path: a missing/renamed task or a failing
24
+ # command warns once for that task and is counted, so the summary can report
25
+ # how many comments were not written.
26
+ class TaskTimeWriteback
27
+ COMMENT_AUTHOR = '@letsdo'
28
+ COMMENT_PREFIX = 'letsdo: completed in'
29
+
30
+ # @param command [String] backlog CLI command (LETSDO_BACKLOG_COMMAND)
31
+ # @param cwd [String] project root the CLI runs in
32
+ # @param env [Hash, nil] child environment (nil = inherit the process one)
33
+ # @param stderr [IO] warning sink
34
+ def initialize(command:, cwd:, env: nil, stderr: $stderr)
35
+ @command = command
36
+ @cwd = cwd
37
+ @env = env
38
+ @stderr = stderr
39
+ end
40
+
41
+ # Writes one comment per eligible run.
42
+ #
43
+ # @param runs [Array<SessionRecorder::Run>] the session's run records
44
+ # @param open_tasks [Array<Providers::Task>, nil] fresh provider snapshot;
45
+ # nil when the backlog is unreadable (nothing can be verified)
46
+ # @return [Integer] number of comments not written
47
+ def call(runs, open_tasks)
48
+ return skip_unreadable(runs) if open_tasks.nil?
49
+
50
+ open_ids = open_tasks.map(&:id)
51
+ eligible(runs, open_ids).count { |run| !write(run) }
52
+ end
53
+
54
+ private
55
+
56
+ # Done runs whose task is absent from the fresh snapshot, one entry per
57
+ # task (a task retried after a late success cannot get two comments).
58
+ def eligible(runs, open_ids)
59
+ runs.select { |run| run.outcome == :done }
60
+ .reject { |run| open_ids.include?(run.task_id) }
61
+ .uniq(&:task_id)
62
+ end
63
+
64
+ # Without a readable snapshot we cannot tell closed from still-open, so we
65
+ # write nothing and warn once instead of guessing.
66
+ def skip_unreadable(runs)
67
+ return 0 if runs.none? { |run| run.outcome == :done }
68
+
69
+ @stderr.puts('letsdo: cannot write task time comments: backlog unreadable')
70
+ 0
71
+ end
72
+
73
+ def write(run)
74
+ _out, err, status = capture(run)
75
+ return true if status.success?
76
+
77
+ warn_once(run, failure_message(err, status))
78
+ false
79
+ rescue StandardError => e
80
+ warn_once(run, e.message)
81
+ false
82
+ end
83
+
84
+ def capture(run)
85
+ args = @env ? [@env, *argv(run)] : argv(run)
86
+ Letsdo::Capture.new(*args, chdir: @cwd).run
87
+ end
88
+
89
+ def argv(run)
90
+ [*Shellwords.split(@command), 'task', 'edit', run.task_id,
91
+ '--comment', "#{COMMENT_PREFIX} #{Duration.format(run.elapsed_s)}",
92
+ '--comment-author', COMMENT_AUTHOR]
93
+ end
94
+
95
+ def warn_once(run, reason)
96
+ @stderr.puts("letsdo: cannot write task time comment for #{run.task_id}: #{reason}")
97
+ end
98
+
99
+ def failure_message(err, status)
100
+ message = err.to_s.strip
101
+ return message unless message.empty?
102
+
103
+ "backlog exited with code #{status.exitstatus}"
104
+ end
105
+ end
106
+ end
@@ -29,7 +29,9 @@ module Letsdo
29
29
  :current_task, :current_task_seconds, keyword_init: true)
30
30
 
31
31
  # @param name [String] agent name (CLI argument)
32
- # @param handle [String] assignee handle (e.g. "@developer")
32
+ # @param handle [String] assignee (the bare tracker name, e.g.
33
+ # "developer"; the header renders it with the display-only
34
+ # '@' prefix)
33
35
  # @param clock [Proc] monotonic clock, callable → seconds; injected
34
36
  # in tests
35
37
  # @param on_run_start [Proc, nil] called with the task label when a
@@ -60,8 +62,13 @@ module Letsdo
60
62
  end
61
63
 
62
64
  # An agent run finished: increments the done counter and clears the
63
- # current-task state.
64
- def run_finished
65
+ # current-task state. The exit code is accepted for compatibility
66
+ # with the session recorder (TASK-69) but is ignored here — the
67
+ # header does not classify outcomes.
68
+ #
69
+ # @param _exit_code [Integer, nil] the run exit code (ignored)
70
+ # @return [void]
71
+ def run_finished(_exit_code = nil)
65
72
  @mutex.synchronize do
66
73
  @done += 1
67
74
  @current_task = nil
@@ -66,11 +66,19 @@ module Letsdo
66
66
  end
67
67
 
68
68
  def self.header_line(metrics, width)
69
- title = "letsdo · #{metrics.name} (#{metrics.handle})"
69
+ title = "letsdo · #{metrics.name} (#{display_handle(metrics)})"
70
70
  timer = "session #{Text.format_duration(metrics.session_seconds)}"
71
71
  Text.fit_line_with_right(title, timer, width)
72
72
  end
73
73
 
74
+ # The header keeps the historical "name (@assignee)" look: the '@'
75
+ # is display notation only (TASK-96) — the tracker value is the bare
76
+ # name, and a legacy @-prefixed handle is shown as stored.
77
+ def self.display_handle(metrics)
78
+ handle = metrics.handle.to_s
79
+ handle.start_with?('@') ? handle : "@#{handle}"
80
+ end
81
+
74
82
  # The state line: done/left plus either the running task with its
75
83
  # elapsed time, a waiting reason, or the PAUSED overlay.
76
84
  def self.state_line(metrics, width, paused, wait_seconds)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Tui
5
+ # Terminal lifecycle for the TUI session: entering/leaving the alternate
6
+ # screen, the tmux window label around it, and the SIGWINCH handler.
7
+ # Extracted from Session so the class stays within the class-length limit
8
+ # (same reason as SessionKeys / SessionView).
9
+ module SessionTerminal
10
+ private
11
+
12
+ # Prepares the session terminal: SIGWINCH tracking, the alternate
13
+ # screen, and — inside tmux — the agent-named window label.
14
+ def install_terminal
15
+ install_winch_handler
16
+ @terminal.enter
17
+ @title&.install(@name)
18
+ end
19
+
20
+ # Runs the full terminal cleanup on every exit path: stops the input
21
+ # thread, restores the tmux window label, leaves the alternate screen
22
+ # and restores the SIGWINCH handler.
23
+ def restore_terminal
24
+ stop_input_thread
25
+ @title&.restore
26
+ leave_terminal
27
+ restore_winch_handler
28
+ end
29
+
30
+ def leave_terminal
31
+ @terminal.leave
32
+ end
33
+
34
+ def install_winch_handler
35
+ Signal.trap('SIGWINCH') { @winch = true }
36
+ rescue ArgumentError
37
+ nil
38
+ end
39
+
40
+ def restore_winch_handler
41
+ Signal.trap('SIGWINCH', 'DEFAULT')
42
+ rescue ArgumentError
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
@@ -10,8 +10,16 @@ module Letsdo
10
10
  repaint
11
11
  @last_repaint = @clock.call
12
12
  loop { break if @stop || !poll_once }
13
- rescue StandardError
14
- nil
13
+ rescue Letsdo::Stopped
14
+ raise
15
+ rescue StandardError => e
16
+ # A paint/input/resize failure here must not leave a blank,
17
+ # unresponsive TUI: record it and interrupt the run so it is surfaced
18
+ # after the terminal is restored (TASK-92).
19
+ return if @stop
20
+
21
+ @input_error = e
22
+ quit
15
23
  end
16
24
 
17
25
  def poll_once
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'session/keys'
4
4
  require_relative 'session/view'
5
+ require_relative 'session/terminal'
5
6
 
6
7
  module Letsdo
7
8
  module Tui
@@ -10,6 +11,7 @@ module Letsdo
10
11
  class Session
11
12
  include SessionKeys
12
13
  include SessionView
14
+ include SessionTerminal
13
15
 
14
16
  REPAINT_INTERVAL = 1.0
15
17
  IDLE_SLEEP = 0.01
@@ -23,15 +25,15 @@ module Letsdo
23
25
  end
24
26
 
25
27
  def run(&work)
26
- install_winch_handler
27
- @terminal.enter
28
- run_work(&work)
28
+ install_terminal
29
+ result = run_work(&work)
30
+ raise_input_error!
31
+ result
29
32
  rescue Letsdo::Stopped
33
+ raise_input_error!
30
34
  0
31
35
  ensure
32
- stop_input_thread
33
- leave_terminal
34
- restore_winch_handler
36
+ restore_terminal
35
37
  end
36
38
 
37
39
  def run_work(&work)
@@ -41,6 +43,23 @@ module Letsdo
41
43
  end
42
44
  end
43
45
 
46
+ # Re-raises a failure captured by the background input/render thread
47
+ # so a paint or input error is never silently swallowed into a blank,
48
+ # unresponsive TUI (TASK-92). Called after the work ends, on both the
49
+ # normal and the stopped path, before the ensure restores the terminal;
50
+ # the error therefore propagates only once the alternate screen is left.
51
+ #
52
+ # A fresh instance is raised rather than the captured object: quit
53
+ # interrupts the main thread from inside the input thread's rescue, so
54
+ # the injected Letsdo::Stopped already carries the captured error as its
55
+ # cause, and re-raising that object would form a circular cause chain.
56
+ def raise_input_error!
57
+ return unless @input_error
58
+
59
+ error = @input_error
60
+ raise error.class, error.message, error.backtrace
61
+ end
62
+
44
63
  private
45
64
 
46
65
  def assign_identity(opts)
@@ -53,6 +72,7 @@ module Letsdo
53
72
  def assign_io(opts)
54
73
  @terminal = opts.fetch(:terminal)
55
74
  @input = opts.fetch(:input)
75
+ @title = opts[:title]
56
76
  @refresh = opts[:refresh]
57
77
  @wait_seconds = opts.fetch(:wait_seconds, 10.0)
58
78
  @clock = opts[:clock] || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
@@ -72,6 +92,7 @@ module Letsdo
72
92
  @body_height = 1
73
93
  @seen_version = 0
74
94
  @input_thread = nil
95
+ @input_error = nil
75
96
  end
76
97
 
77
98
  # Enters raw mode on the keyboard for the duration of the block. Held
@@ -86,18 +107,6 @@ module Letsdo
86
107
  end
87
108
  end
88
109
 
89
- def install_winch_handler
90
- Signal.trap('SIGWINCH') { @winch = true }
91
- rescue ArgumentError
92
- nil
93
- end
94
-
95
- def restore_winch_handler
96
- Signal.trap('SIGWINCH', 'DEFAULT')
97
- rescue ArgumentError
98
- nil
99
- end
100
-
101
110
  def start_input_thread
102
111
  @input_thread = Thread.new { input_loop }
103
112
  @input_thread.report_on_exception = false
@@ -111,10 +120,6 @@ module Letsdo
111
120
 
112
121
  thread.join(INPUT_JOIN_TIMEOUT) || thread.kill
113
122
  end
114
-
115
- def leave_terminal
116
- @terminal.leave
117
- end
118
123
  end
119
124
  end
120
125
  end