letsdo 0.1.0 → 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 +4 -4
- data/README.md +4 -4
- data/bin/letsdo +20 -12
- data/lib/letsdo/agent.rb +6 -4
- data/lib/letsdo/agent_loop/tasks.rb +62 -0
- data/lib/letsdo/agent_loop.rb +41 -126
- data/lib/letsdo/backlog_tasks.rb +10 -11
- data/lib/letsdo/capture.rb +89 -0
- data/lib/letsdo/cli/init.rb +33 -0
- data/lib/letsdo/cli/launch.rb +82 -0
- data/lib/letsdo/cli.rb +64 -124
- data/lib/letsdo/control.rb +39 -0
- data/lib/letsdo/default_prompt.rb +69 -0
- data/lib/letsdo/errors.rb +2 -12
- data/lib/letsdo/loop.rb +31 -15
- data/lib/letsdo/output_streamer/arg_summary.rb +81 -0
- data/lib/letsdo/output_streamer.rb +13 -210
- data/lib/letsdo/pi_runner/events.rb +75 -0
- data/lib/letsdo/pi_runner/process.rb +68 -0
- data/lib/letsdo/pi_runner.rb +62 -221
- data/lib/letsdo/prompt_store.rb +47 -8
- data/lib/letsdo/tui/input.rb +4 -4
- data/lib/letsdo/tui/log_buffer.rb +3 -3
- data/lib/letsdo/tui/metrics.rb +1 -1
- data/lib/letsdo/tui/renderer.rb +103 -80
- data/lib/letsdo/tui/session/keys.rb +121 -0
- data/lib/letsdo/tui/session/view.rb +89 -0
- data/lib/letsdo/tui/session.rb +40 -183
- data/lib/letsdo/tui/terminal.rb +7 -4
- data/lib/letsdo/tui.rb +6 -6
- data/lib/letsdo/version.rb +2 -2
- data/lib/letsdo.rb +19 -13
- metadata +31 -1
data/lib/letsdo/pi_runner.rb
CHANGED
|
@@ -1,260 +1,101 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module Letsdo
|
|
6
|
-
# Runs pi in --mode json and hands events to the output streamer
|
|
7
|
-
# are generated. The pi exit code is propagated outward.
|
|
8
|
-
#
|
|
9
|
-
# pi --mode json streams events line by line: each line is parsed as JSON
|
|
10
|
-
# and reacted to:
|
|
11
|
-
# message_update (assistantMessageEvent):
|
|
12
|
-
# text_delta - a fragment of the agent's answer text → stdout;
|
|
13
|
-
# toolcall_start - the model started emitting a tool call,
|
|
14
|
-
# id→name is remembered (fallback, see below);
|
|
15
|
-
# tool_execution_start - a tool started executing: name and full
|
|
16
|
-
# arguments are available (e.g. bash command text);
|
|
17
|
-
# tool_execution_end - a tool finished: result (output) and isError;
|
|
18
|
-
# agent_end - the end of the run.
|
|
19
|
-
#
|
|
20
|
-
# The "HH:MM:SS ⚙ name: arguments" header is printed at execution start,
|
|
21
|
-
# the result and "✓/✖ name" completion line — at completion. If for some
|
|
22
|
-
# reason no tool_execution_* events arrive (older pi versions, etc.), "⚙ name"
|
|
23
|
-
# placeholders from remembered toolcall_start events are printed at the end
|
|
24
|
-
# of the run.
|
|
25
|
-
#
|
|
26
|
-
# pi stdout (events) is read by us; pi stderr (its own log) is inherited
|
|
27
|
-
# and goes to our stderr.
|
|
6
|
+
# Runs pi in --mode json and hands events to the output streamer.
|
|
28
7
|
class PiRunner
|
|
29
|
-
COMMAND =
|
|
30
|
-
MODE =
|
|
31
|
-
MESSAGE_UPDATE =
|
|
32
|
-
TOOL_EXECUTION_START =
|
|
33
|
-
TOOL_EXECUTION_END =
|
|
34
|
-
AGENT_END =
|
|
8
|
+
COMMAND = 'pi'
|
|
9
|
+
MODE = 'json'
|
|
10
|
+
MESSAGE_UPDATE = 'message_update'
|
|
11
|
+
TOOL_EXECUTION_START = 'tool_execution_start'
|
|
12
|
+
TOOL_EXECUTION_END = 'tool_execution_end'
|
|
13
|
+
AGENT_END = 'agent_end'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
require_relative 'pi_runner/events'
|
|
18
|
+
require_relative 'pi_runner/process'
|
|
35
19
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
20
|
+
module Letsdo
|
|
21
|
+
# Pi process spawn, stream drain, and pause/resume signals.
|
|
22
|
+
class PiRunner
|
|
23
|
+
include PiRunnerEvents
|
|
24
|
+
include PiRunnerProcess
|
|
25
|
+
|
|
26
|
+
def initialize(prompt:, streamer:, **opts)
|
|
42
27
|
@prompt = prompt
|
|
43
|
-
@flags = flags
|
|
44
28
|
@streamer = streamer
|
|
45
|
-
@
|
|
29
|
+
@flags = opts.fetch(:flags, [])
|
|
30
|
+
@command = opts.fetch(:command, COMMAND)
|
|
46
31
|
@pending_tools = {}
|
|
47
|
-
@
|
|
32
|
+
@reaped_status = nil
|
|
33
|
+
@debug = opts[:debug].nil? ? ENV['LETSDO_DEBUG'] == '1' : opts[:debug]
|
|
48
34
|
end
|
|
49
35
|
|
|
50
|
-
# Runs pi and waits for completion.
|
|
51
|
-
#
|
|
52
|
-
# pi is spawned in its own process group so the orchestrator can stop it
|
|
53
|
-
# (signal handlers interrupt the loop, not the pi child directly): a stop
|
|
54
|
-
# terminates the whole group via #terminate.
|
|
55
|
-
#
|
|
56
|
-
# The event stream is read on the main thread. A stop signal arrives as
|
|
57
|
-
# Letsdo::Stopped raised by the trap (see Letsdo::AgentLoop#on_signal):
|
|
58
|
-
# the raise interrupts the blocking read directly — no reader threads,
|
|
59
|
-
# polls or flags. Note for future work on this file: CRuby 4.0 (M:N
|
|
60
|
-
# threads) here does not execute traps while the main thread is in
|
|
61
|
-
# Thread#join, defers them with another thread blocked on IO, and does
|
|
62
|
-
# not wake IO.select on pipe data — the raise-in-trap approach avoids
|
|
63
|
-
# all of it.
|
|
64
|
-
#
|
|
65
|
-
# @return [Integer] pi exit code (128+signal if pi was killed by a signal)
|
|
66
36
|
def run
|
|
67
|
-
|
|
68
|
-
out_r
|
|
69
|
-
|
|
70
|
-
out_w.close
|
|
71
|
-
debug("spawned pid=#{@pid} (own group)")
|
|
72
|
-
|
|
73
|
-
begin
|
|
74
|
-
read_pi_stream(out_r)
|
|
75
|
-
rescue Letsdo::Stopped
|
|
76
|
-
# The signal handler already sent SIGTERM to the pi group; make sure
|
|
77
|
-
# it is gone (grace loop here runs in the main context, not a trap)
|
|
78
|
-
# and reap it before propagating the stop.
|
|
79
|
-
debug("stopped by signal, terminating pi")
|
|
80
|
-
terminate
|
|
81
|
-
status = wait_status(@pid)
|
|
82
|
-
@streamer.finish
|
|
83
|
-
raise
|
|
84
|
-
ensure
|
|
85
|
-
begin
|
|
86
|
-
out_r.close
|
|
87
|
-
rescue IOError
|
|
88
|
-
nil
|
|
89
|
-
end
|
|
90
|
-
flush_pending_tools
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
status = wait_status(@pid)
|
|
94
|
-
@streamer.finish
|
|
95
|
-
debug("exit status=#{status.inspect} code=#{exit_code(status)}")
|
|
96
|
-
exit_code(status)
|
|
37
|
+
out_r = spawn_pi
|
|
38
|
+
drain_stream(out_r)
|
|
39
|
+
finish_run
|
|
97
40
|
ensure
|
|
98
41
|
@pid = nil
|
|
42
|
+
@reaped_status = nil
|
|
99
43
|
end
|
|
100
44
|
|
|
101
45
|
def debug(message)
|
|
102
46
|
warn("[letsdo] pi: #{message}") if @debug
|
|
103
47
|
end
|
|
104
48
|
|
|
105
|
-
# One-shot SIGTERM to the pi process group for a signal handler: no
|
|
106
|
-
# waits, sleeps or IO — safe inside a trap. The caller reaps the child
|
|
107
|
-
# afterwards (see #run).
|
|
108
49
|
def terminate_now
|
|
109
50
|
pid = @pid
|
|
110
|
-
send_signal(
|
|
51
|
+
send_signal('TERM', pid) if pid
|
|
111
52
|
end
|
|
112
53
|
|
|
113
|
-
|
|
114
|
-
# the grace period if it did not exit. Safe to call when the run already
|
|
115
|
-
# finished (no-op).
|
|
116
|
-
#
|
|
117
|
-
# @param signal [String] the first signal to send
|
|
118
|
-
# @param grace [Float] seconds to wait before falling back to SIGKILL
|
|
119
|
-
def terminate(signal: "TERM", grace: 3.0, tick: 0.05)
|
|
54
|
+
def pause
|
|
120
55
|
pid = @pid
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
send_signal(signal, pid)
|
|
124
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
|
|
125
|
-
loop do
|
|
126
|
-
break unless alive?(pid)
|
|
127
|
-
|
|
128
|
-
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
129
|
-
send_signal("KILL", pid)
|
|
130
|
-
break
|
|
131
|
-
end
|
|
132
|
-
sleep(tick)
|
|
133
|
-
end
|
|
134
|
-
true
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
private
|
|
138
|
-
|
|
139
|
-
# Reads the pi event stream until EOF. Runs on the main thread; a stop
|
|
140
|
-
# signal interrupts it via Letsdo::Stopped raised from the trap.
|
|
141
|
-
def read_pi_stream(out_r)
|
|
142
|
-
out_r.each_line { |line| handle_line(line) }
|
|
143
|
-
debug("stream: EOF")
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# Parses one line of the pi event stream and passes it to the streamer.
|
|
147
|
-
def handle_line(line)
|
|
148
|
-
line = line.strip
|
|
149
|
-
return if line.empty?
|
|
150
|
-
|
|
151
|
-
event = parse_event(line)
|
|
152
|
-
return unless event
|
|
153
|
-
|
|
154
|
-
case event["type"]
|
|
155
|
-
when MESSAGE_UPDATE
|
|
156
|
-
handle_message_update(event)
|
|
157
|
-
when TOOL_EXECUTION_START
|
|
158
|
-
@pending_tools.delete(event["toolCallId"])
|
|
159
|
-
@streamer.tool_start(event["toolName"] || "tool", args: event["args"])
|
|
160
|
-
when TOOL_EXECUTION_END
|
|
161
|
-
handle_tool_execution_end(event)
|
|
162
|
-
when AGENT_END
|
|
163
|
-
flush_pending_tools
|
|
164
|
-
end
|
|
56
|
+
send_signal('STOP', pid) if pid
|
|
165
57
|
end
|
|
166
58
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return unless payload
|
|
171
|
-
|
|
172
|
-
case payload["type"]
|
|
173
|
-
when "text_delta"
|
|
174
|
-
delta = payload["delta"]
|
|
175
|
-
@streamer.text_delta(delta) if delta && !delta.empty?
|
|
176
|
-
when "toolcall_start"
|
|
177
|
-
# The call has just started to be generated: the name is known
|
|
178
|
-
# immediately, arguments will arrive with the execution start
|
|
179
|
-
# (tool_execution_start).
|
|
180
|
-
id = event["id"] || payload["id"]
|
|
181
|
-
name = event["toolName"] || payload["toolName"] || "tool"
|
|
182
|
-
@pending_tools[id] = name unless id.nil?
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Tool execution result: text from result.content plus the error flag.
|
|
187
|
-
# An empty error text is replaced with a clear wording. The completion
|
|
188
|
-
# line is always printed (even for an empty result), so that the action
|
|
189
|
-
# completion is visible in the service output.
|
|
190
|
-
def handle_tool_execution_end(event)
|
|
191
|
-
name = event["toolName"] || "tool"
|
|
192
|
-
text = result_text(event["result"])
|
|
193
|
-
error = event["isError"] == true
|
|
194
|
-
text = "tool failed with an error" if (text.nil? || text.empty?) && error
|
|
195
|
-
@streamer.tool_result(name, text, error: error)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
# Collects the result text from {type: "text"} content blocks.
|
|
199
|
-
# Image blocks and other types do not get into the text.
|
|
200
|
-
def result_text(result)
|
|
201
|
-
return nil unless result.is_a?(Hash)
|
|
202
|
-
|
|
203
|
-
content = result["content"]
|
|
204
|
-
return nil unless content.is_a?(Array)
|
|
205
|
-
|
|
206
|
-
parts = content.filter_map do |block|
|
|
207
|
-
next nil unless block.is_a?(Hash)
|
|
208
|
-
|
|
209
|
-
text = block["text"]
|
|
210
|
-
text if text.is_a?(String) && !text.empty? &&
|
|
211
|
-
(block["type"] == "text" || !block.key?("type"))
|
|
212
|
-
end
|
|
213
|
-
text = parts.join
|
|
214
|
-
text.empty? ? nil : text
|
|
59
|
+
def resume
|
|
60
|
+
pid = @pid
|
|
61
|
+
send_signal('CONT', pid) if pid
|
|
215
62
|
end
|
|
216
63
|
|
|
217
|
-
|
|
218
|
-
# prints "⚙ name" without arguments. Called both on agent_end and in
|
|
219
|
-
# the ensure block after reading the stream; clear protects against
|
|
220
|
-
# duplicates.
|
|
221
|
-
def flush_pending_tools
|
|
222
|
-
@pending_tools.each_value { |name| @streamer.tool_start(name) }
|
|
223
|
-
@pending_tools.clear
|
|
224
|
-
end
|
|
64
|
+
private
|
|
225
65
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
66
|
+
def spawn_pi
|
|
67
|
+
cmd = [@command, '--mode', MODE, *@flags, @prompt]
|
|
68
|
+
out_r, out_w = IO.pipe
|
|
69
|
+
@pid = Process.spawn(*cmd, out: out_w, err: $stderr, pgroup: true)
|
|
70
|
+
out_w.close
|
|
71
|
+
debug("spawned pid=#{@pid} (own group)")
|
|
72
|
+
out_r
|
|
231
73
|
end
|
|
232
74
|
|
|
233
|
-
def
|
|
234
|
-
|
|
235
|
-
|
|
75
|
+
def drain_stream(out_r)
|
|
76
|
+
read_pi_stream(out_r)
|
|
77
|
+
rescue Letsdo::Stopped
|
|
78
|
+
debug('stopped by signal, terminating pi')
|
|
79
|
+
terminate
|
|
80
|
+
wait_status(@pid)
|
|
81
|
+
@streamer.finish
|
|
82
|
+
raise
|
|
83
|
+
ensure
|
|
84
|
+
close_pipe(out_r)
|
|
85
|
+
flush_pending_tools
|
|
236
86
|
end
|
|
237
87
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
Process.kill(signal, -pid)
|
|
242
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
88
|
+
def close_pipe(out_r)
|
|
89
|
+
out_r.close
|
|
90
|
+
rescue IOError
|
|
243
91
|
nil
|
|
244
92
|
end
|
|
245
93
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
false
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def exit_code(status)
|
|
255
|
-
return status.exitstatus if status.exitstatus
|
|
256
|
-
|
|
257
|
-
status.termsig ? 128 + status.termsig : 1
|
|
94
|
+
def finish_run
|
|
95
|
+
status = wait_status(@pid)
|
|
96
|
+
@streamer.finish
|
|
97
|
+
debug("exit status=#{status.inspect} code=#{exit_code(status)}")
|
|
98
|
+
exit_code(status)
|
|
258
99
|
end
|
|
259
100
|
end
|
|
260
|
-
end
|
|
101
|
+
end
|
data/lib/letsdo/prompt_store.rb
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
3
5
|
module Letsdo
|
|
4
6
|
# Access to agent prompts: the agents/<name>.md directory in the project
|
|
5
7
|
# root. A new agent = a new agents/<name>.md file, no code changes needed.
|
|
6
8
|
class PromptStore
|
|
7
|
-
AGENTS_DIR =
|
|
9
|
+
AGENTS_DIR = 'agents'
|
|
10
|
+
|
|
11
|
+
# Whether a name may be used as an agent prompt file name. Refuses
|
|
12
|
+
# path separators (no writes outside agents/ via traversal) and the
|
|
13
|
+
# dot names. Shared by create_agent and the CLI so both agree.
|
|
14
|
+
#
|
|
15
|
+
# @param name [String] agent name
|
|
16
|
+
# @return [Boolean]
|
|
17
|
+
def self.unsafe_name?(name)
|
|
18
|
+
name == '.' || name == '..' || name.match?(%r{[/\\]})
|
|
19
|
+
end
|
|
8
20
|
|
|
9
21
|
# @param root [String] project root (agents/ lives there)
|
|
10
22
|
def initialize(root:)
|
|
@@ -15,25 +27,52 @@ module Letsdo
|
|
|
15
27
|
#
|
|
16
28
|
# @return [Array<String>]
|
|
17
29
|
def list
|
|
18
|
-
Dir.glob(File.join(agents_dir,
|
|
30
|
+
Dir.glob(File.join(agents_dir, '*.md')).sort.map { |path| File.basename(path, '.md') }
|
|
19
31
|
end
|
|
20
32
|
|
|
21
|
-
# Reads an agent prompt.
|
|
33
|
+
# Reads an agent prompt. There are no unknown agents: when
|
|
34
|
+
# agents/<name>.md is missing, the caller falls back to the built-in
|
|
35
|
+
# default prompt (Letsdo::DefaultPrompt) and announces it (see CLI).
|
|
22
36
|
#
|
|
23
37
|
# @param name [String] agent name
|
|
24
|
-
# @return [String] contents of agents/<name>.md
|
|
25
|
-
# @raise [UnknownAgentError] if there is no such agent
|
|
38
|
+
# @return [String, nil] contents of agents/<name>.md, nil when missing
|
|
26
39
|
def read(name)
|
|
27
|
-
path =
|
|
28
|
-
|
|
40
|
+
path = agent_path(name)
|
|
41
|
+
return nil unless File.file?(path)
|
|
29
42
|
|
|
30
43
|
File.read(path)
|
|
31
44
|
end
|
|
32
45
|
|
|
46
|
+
# Absolute path of an agent's prompt file, whether or not it exists.
|
|
47
|
+
#
|
|
48
|
+
# @param name [String] agent name
|
|
49
|
+
# @return [String] <root>/agents/<name>.md (resolved to an absolute path)
|
|
50
|
+
def agent_path(name)
|
|
51
|
+
File.expand_path(File.join(agents_dir, "#{name}.md"))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Creates agents/<name>.md with the given content. Never raises and
|
|
55
|
+
# never writes anything when the file already exists or the name is
|
|
56
|
+
# unsafe (contains "/" or "\\", or is "."/"..") — returns false in
|
|
57
|
+
# both cases. Otherwise mkdir_p the agents/ dir, writes the content
|
|
58
|
+
# and returns true. Creation always stays inside agents/.
|
|
59
|
+
#
|
|
60
|
+
# @param name [String] agent name (also the would-be file name)
|
|
61
|
+
# @param content [String] prompt text to write
|
|
62
|
+
# @return [Boolean] true when the file was created, false otherwise
|
|
63
|
+
def create_agent(name, content)
|
|
64
|
+
return false if self.class.unsafe_name?(name)
|
|
65
|
+
return false if File.exist?(agent_path(name))
|
|
66
|
+
|
|
67
|
+
FileUtils.mkdir_p(agents_dir)
|
|
68
|
+
File.write(agent_path(name), content)
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
33
72
|
private
|
|
34
73
|
|
|
35
74
|
def agents_dir
|
|
36
75
|
File.join(@root, AGENTS_DIR)
|
|
37
76
|
end
|
|
38
77
|
end
|
|
39
|
-
end
|
|
78
|
+
end
|
data/lib/letsdo/tui/input.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'stringio'
|
|
4
4
|
|
|
5
5
|
module Letsdo
|
|
6
6
|
module Tui
|
|
@@ -21,7 +21,7 @@ module Letsdo
|
|
|
21
21
|
POLL_TIMEOUT = 0.1
|
|
22
22
|
|
|
23
23
|
KEY_BY_VALUE = {
|
|
24
|
-
|
|
24
|
+
'p' => :p, 'r' => :r, 'q' => :q,
|
|
25
25
|
"\u0003" => :ctrl_c,
|
|
26
26
|
"\e[A" => :up, "\eOA" => :up,
|
|
27
27
|
"\e[B" => :down, "\eOB" => :down,
|
|
@@ -37,7 +37,7 @@ module Letsdo
|
|
|
37
37
|
# @param stdin [IO] the keyboard stream (a terminal when engaged)
|
|
38
38
|
# @param poll_timeout [Numeric] nil-poll interval for non-tty inputs
|
|
39
39
|
def initialize(stdin:, poll_timeout: POLL_TIMEOUT)
|
|
40
|
-
require
|
|
40
|
+
require 'tty-reader'
|
|
41
41
|
|
|
42
42
|
@stdin = stdin
|
|
43
43
|
@poll_timeout = poll_timeout
|
|
@@ -90,4 +90,4 @@ module Letsdo
|
|
|
90
90
|
end
|
|
91
91
|
end
|
|
92
92
|
end
|
|
93
|
-
end
|
|
93
|
+
end
|
|
@@ -19,14 +19,14 @@ module Letsdo
|
|
|
19
19
|
# Default maximum number of complete lines kept in the buffer.
|
|
20
20
|
MAX_LINES = 2_000
|
|
21
21
|
# The run-boundary separator line.
|
|
22
|
-
DIVIDER =
|
|
22
|
+
DIVIDER = '─' * 40
|
|
23
23
|
|
|
24
24
|
# @param max_lines [Integer] maximum number of complete lines kept;
|
|
25
25
|
# older lines are dropped
|
|
26
26
|
def initialize(max_lines: MAX_LINES)
|
|
27
27
|
@mutex = Mutex.new
|
|
28
28
|
@lines = []
|
|
29
|
-
@pending = +
|
|
29
|
+
@pending = +''
|
|
30
30
|
@max_lines = max_lines
|
|
31
31
|
@version = 0
|
|
32
32
|
end
|
|
@@ -108,4 +108,4 @@ module Letsdo
|
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
|
-
end
|
|
111
|
+
end
|
data/lib/letsdo/tui/metrics.rb
CHANGED