letsdo 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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +291 -0
- data/bin/letsdo +33 -0
- data/lib/letsdo/agent.rb +45 -0
- data/lib/letsdo/agent_loop.rb +170 -0
- data/lib/letsdo/backlog_tasks.rb +60 -0
- data/lib/letsdo/cli.rb +207 -0
- data/lib/letsdo/errors.rb +22 -0
- data/lib/letsdo/loop.rb +58 -0
- data/lib/letsdo/output_streamer.rb +282 -0
- data/lib/letsdo/pi_runner.rb +260 -0
- data/lib/letsdo/prompt_store.rb +39 -0
- data/lib/letsdo/tui/input.rb +93 -0
- data/lib/letsdo/tui/log_buffer.rb +111 -0
- data/lib/letsdo/tui/metrics.rb +97 -0
- data/lib/letsdo/tui/renderer.rb +173 -0
- data/lib/letsdo/tui/session.rb +253 -0
- data/lib/letsdo/tui/terminal.rb +67 -0
- data/lib/letsdo/tui.rb +24 -0
- data/lib/letsdo/version.rb +5 -0
- data/lib/letsdo.rb +30 -0
- metadata +121 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
# Runs pi in --mode json and hands events to the output streamer as they
|
|
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.
|
|
28
|
+
class PiRunner
|
|
29
|
+
COMMAND = "pi"
|
|
30
|
+
MODE = "json"
|
|
31
|
+
MESSAGE_UPDATE = "message_update"
|
|
32
|
+
TOOL_EXECUTION_START = "tool_execution_start"
|
|
33
|
+
TOOL_EXECUTION_END = "tool_execution_end"
|
|
34
|
+
AGENT_END = "agent_end"
|
|
35
|
+
|
|
36
|
+
# @param prompt [String] agent prompt text
|
|
37
|
+
# @param flags [Array<String>] extra pi flags
|
|
38
|
+
# @param streamer [OutputStreamer] where to print output
|
|
39
|
+
# @param command [String] the pi command (overridable for tests)
|
|
40
|
+
# @param debug [Boolean, nil] trace [letsdo] lines to stderr; nil = LETSDO_DEBUG
|
|
41
|
+
def initialize(prompt:, flags: [], streamer:, command: COMMAND, debug: nil)
|
|
42
|
+
@prompt = prompt
|
|
43
|
+
@flags = flags
|
|
44
|
+
@streamer = streamer
|
|
45
|
+
@command = command
|
|
46
|
+
@pending_tools = {}
|
|
47
|
+
@debug = debug.nil? ? ENV["LETSDO_DEBUG"] == "1" : debug
|
|
48
|
+
end
|
|
49
|
+
|
|
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
|
+
def run
|
|
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
|
+
|
|
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)
|
|
97
|
+
ensure
|
|
98
|
+
@pid = nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def debug(message)
|
|
102
|
+
warn("[letsdo] pi: #{message}") if @debug
|
|
103
|
+
end
|
|
104
|
+
|
|
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
|
+
def terminate_now
|
|
109
|
+
pid = @pid
|
|
110
|
+
send_signal("TERM", pid) if pid
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Stops a running pi: SIGTERM to its process group, then SIGKILL after
|
|
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)
|
|
120
|
+
pid = @pid
|
|
121
|
+
return true unless pid
|
|
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
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Handles an assistant message update event.
|
|
168
|
+
def handle_message_update(event)
|
|
169
|
+
payload = event["assistantMessageEvent"]
|
|
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
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Placeholders for calls without execution events (old pi, etc.):
|
|
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
|
|
225
|
+
|
|
226
|
+
# Ignores lines that are not valid JSON events.
|
|
227
|
+
def parse_event(line)
|
|
228
|
+
JSON.parse(line)
|
|
229
|
+
rescue JSON::ParserError
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def wait_status(pid)
|
|
234
|
+
_, status = Process.wait2(pid)
|
|
235
|
+
status
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Sends a signal to the pi process group. Missing/killed groups are
|
|
239
|
+
# silently ignored.
|
|
240
|
+
def send_signal(signal, pid)
|
|
241
|
+
Process.kill(signal, -pid)
|
|
242
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Whether the pi process still exists (does not reap it).
|
|
247
|
+
def alive?(pid)
|
|
248
|
+
Process.kill(0, pid)
|
|
249
|
+
true
|
|
250
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
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
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
# Access to agent prompts: the agents/<name>.md directory in the project
|
|
5
|
+
# root. A new agent = a new agents/<name>.md file, no code changes needed.
|
|
6
|
+
class PromptStore
|
|
7
|
+
AGENTS_DIR = "agents"
|
|
8
|
+
|
|
9
|
+
# @param root [String] project root (agents/ lives there)
|
|
10
|
+
def initialize(root:)
|
|
11
|
+
@root = root
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Sorted list of agent names (file names without extension).
|
|
15
|
+
#
|
|
16
|
+
# @return [Array<String>]
|
|
17
|
+
def list
|
|
18
|
+
Dir.glob(File.join(agents_dir, "*.md")).sort.map { |path| File.basename(path, ".md") }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Reads an agent prompt.
|
|
22
|
+
#
|
|
23
|
+
# @param name [String] agent name
|
|
24
|
+
# @return [String] contents of agents/<name>.md
|
|
25
|
+
# @raise [UnknownAgentError] if there is no such agent
|
|
26
|
+
def read(name)
|
|
27
|
+
path = File.join(agents_dir, "#{name}.md")
|
|
28
|
+
raise UnknownAgentError, name unless File.file?(path)
|
|
29
|
+
|
|
30
|
+
File.read(path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def agents_dir
|
|
36
|
+
File.join(@root, AGENTS_DIR)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
module Tui
|
|
7
|
+
# Non-blocking keyboard input for the TUI: wraps tty-reader and maps
|
|
8
|
+
# escape sequences to plain symbols (:up, :down, :page_up, :page_down,
|
|
9
|
+
# :home, :end, :p, :r, :q, :ctrl_c). Returns nil when no key is waiting
|
|
10
|
+
# within the poll timeout, so the caller can repaint on a 1s timer.
|
|
11
|
+
#
|
|
12
|
+
# Ctrl-C is read as a key (:ctrl_c → quit) instead of raising: the
|
|
13
|
+
# tty-reader raw mode disables the terminal's SIGINT generation, and
|
|
14
|
+
# external SIGINT/SIGTERM still go through the loop's signal path.
|
|
15
|
+
#
|
|
16
|
+
# Tests inject a pipe with the raw escape bytes — tty-reader's
|
|
17
|
+
# mode helpers no-op on non-tty inputs, so no real TTY is needed.
|
|
18
|
+
# tty-reader's unused echo stream is a StringIO (stdlib, required here).
|
|
19
|
+
class Input
|
|
20
|
+
# Poll timeout for readable input, seconds.
|
|
21
|
+
POLL_TIMEOUT = 0.1
|
|
22
|
+
|
|
23
|
+
KEY_BY_VALUE = {
|
|
24
|
+
"p" => :p, "r" => :r, "q" => :q,
|
|
25
|
+
"\u0003" => :ctrl_c,
|
|
26
|
+
"\e[A" => :up, "\eOA" => :up,
|
|
27
|
+
"\e[B" => :down, "\eOB" => :down,
|
|
28
|
+
"\e[5~" => :page_up,
|
|
29
|
+
"\e[6~" => :page_down,
|
|
30
|
+
"\e[H" => :home, "\e[1~" => :home, "\e[7~" => :home, "\eOH" => :home,
|
|
31
|
+
"\e[F" => :end, "\e[4~" => :end, "\e[8~" => :end, "\eOF" => :end
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# The keyboard stream (exposed so the session can wrap it in raw mode).
|
|
35
|
+
attr_reader :stdin
|
|
36
|
+
|
|
37
|
+
# @param stdin [IO] the keyboard stream (a terminal when engaged)
|
|
38
|
+
# @param poll_timeout [Numeric] nil-poll interval for non-tty inputs
|
|
39
|
+
def initialize(stdin:, poll_timeout: POLL_TIMEOUT)
|
|
40
|
+
require "tty-reader"
|
|
41
|
+
|
|
42
|
+
@stdin = stdin
|
|
43
|
+
@poll_timeout = poll_timeout
|
|
44
|
+
@reader = TTY::Reader.new(input: stdin, output: StringIO.new,
|
|
45
|
+
interrupt: :noop, track_history: false)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The next key, or nil when nothing was pressed in time.
|
|
49
|
+
#
|
|
50
|
+
# @return [Symbol, nil]
|
|
51
|
+
def next_key
|
|
52
|
+
return nil unless ready?
|
|
53
|
+
|
|
54
|
+
value = @reader.read_keypress(echo: false, raw: false, nonblock: false)
|
|
55
|
+
return nil if value.nil?
|
|
56
|
+
|
|
57
|
+
# tty-reader 0.9 only continues CSI (`\e[…`); SS3 (`\eO…`) stops
|
|
58
|
+
# after `\eO`. Pull the final byte so `\eOA` / `\eOH` / `\eOF` map.
|
|
59
|
+
if value == "\eO" && (final = ss3_final_byte)
|
|
60
|
+
value += final
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
KEY_BY_VALUE[value]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Whether a key is available now (bounded wait). Real terminals use
|
|
69
|
+
# wait_readable; otherwise eof?.
|
|
70
|
+
def ready?
|
|
71
|
+
if @stdin.respond_to?(:wait_readable)
|
|
72
|
+
@stdin.wait_readable(@poll_timeout)
|
|
73
|
+
else
|
|
74
|
+
!@stdin.eof?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ss3_final_byte
|
|
79
|
+
return nil unless byte_waiting?
|
|
80
|
+
|
|
81
|
+
@stdin.getc
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def byte_waiting?
|
|
85
|
+
if @stdin.respond_to?(:wait_readable)
|
|
86
|
+
@stdin.wait_readable(0)
|
|
87
|
+
else
|
|
88
|
+
!@stdin.eof?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Tui
|
|
5
|
+
# The combined, thread-safe log behind the TUI stream.
|
|
6
|
+
#
|
|
7
|
+
# The TUI uses one buffer for everything the agent produces: answer
|
|
8
|
+
# text deltas, tool lines, loop service messages — in arrival order,
|
|
9
|
+
# exactly what Letsdo::OutputStreamer emits. The streamer (main thread)
|
|
10
|
+
# appends via #write/#puts; the TUI input thread reads a snapshot via
|
|
11
|
+
# #lines for rendering. Both operations are mutex-protected.
|
|
12
|
+
#
|
|
13
|
+
# Lines are capped (oldest dropped); a partial last line (the agent's
|
|
14
|
+
# in-progress text) is kept separately so rendering can show the live
|
|
15
|
+
# tail. #divider appends a run-boundary separator. A monotonically
|
|
16
|
+
# increasing version lets the renderer skip repaints when nothing new
|
|
17
|
+
# arrived.
|
|
18
|
+
class LogBuffer
|
|
19
|
+
# Default maximum number of complete lines kept in the buffer.
|
|
20
|
+
MAX_LINES = 2_000
|
|
21
|
+
# The run-boundary separator line.
|
|
22
|
+
DIVIDER = "─" * 40
|
|
23
|
+
|
|
24
|
+
# @param max_lines [Integer] maximum number of complete lines kept;
|
|
25
|
+
# older lines are dropped
|
|
26
|
+
def initialize(max_lines: MAX_LINES)
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
@lines = []
|
|
29
|
+
@pending = +""
|
|
30
|
+
@max_lines = max_lines
|
|
31
|
+
@version = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Appends raw text (IO-compatible, used by Letsdo::OutputStreamer).
|
|
35
|
+
#
|
|
36
|
+
# @param text [String] the next chunk of the stream
|
|
37
|
+
def write(text)
|
|
38
|
+
return if text.nil? || text.empty?
|
|
39
|
+
|
|
40
|
+
@mutex.synchronize { append(text) }
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Appends a complete line with a trailing newline (IO-compatible,
|
|
45
|
+
# used by Letsdo::AgentLoop service messages).
|
|
46
|
+
#
|
|
47
|
+
# @param text [String, nil] the line or nil for an empty line
|
|
48
|
+
def puts(text = nil)
|
|
49
|
+
write(text.nil? ? "\n" : "#{text}\n")
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# IO-compatible no-op: an in-memory buffer never needs flushing.
|
|
54
|
+
def flush
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Appends a run-boundary divider line (skipped when the buffer is
|
|
59
|
+
# still empty — there is no boundary yet).
|
|
60
|
+
def divider
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
@lines << DIVIDER unless @lines.empty? && @pending.empty?
|
|
63
|
+
@version += 1
|
|
64
|
+
end
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The current buffer content for rendering: complete lines plus the
|
|
69
|
+
# pending partial line, and the current version.
|
|
70
|
+
#
|
|
71
|
+
# @return [Array(Array<String>, Integer)] lines and version
|
|
72
|
+
def lines
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
snapshot = @lines.dup
|
|
75
|
+
snapshot << @pending.dup unless @pending.empty?
|
|
76
|
+
[snapshot, @version]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Monotonic change counter. The input thread compares this to the
|
|
81
|
+
# last rendered version so it can skip repaints when the log is idle.
|
|
82
|
+
#
|
|
83
|
+
# @return [Integer]
|
|
84
|
+
def version
|
|
85
|
+
@mutex.synchronize { @version }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Splits the raw chunk on newlines: everything before the last
|
|
91
|
+
# newline becomes complete lines, the trailing fragment stays
|
|
92
|
+
# pending. The pending fragment is moved to the completed list as
|
|
93
|
+
# soon as a newline arrives, so line order is preserved.
|
|
94
|
+
def append(text)
|
|
95
|
+
chunks = text.split("\n", -1)
|
|
96
|
+
@pending << chunks.shift
|
|
97
|
+
chunks.each do |chunk|
|
|
98
|
+
@lines << @pending
|
|
99
|
+
@pending = chunk
|
|
100
|
+
end
|
|
101
|
+
trim_lines
|
|
102
|
+
@version += 1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def trim_lines
|
|
106
|
+
excess = @lines.length - @max_lines
|
|
107
|
+
@lines.shift(excess) if excess.positive?
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Tui
|
|
5
|
+
# The metrics facade for the TUI header, fed by the loop driver.
|
|
6
|
+
#
|
|
7
|
+
# Data sources (TASK-38 architecture survey):
|
|
8
|
+
# done - number of completed agent runs in this session,
|
|
9
|
+
# counted from the loop's run_finished events;
|
|
10
|
+
# left - latest open-task count from the backlog provider
|
|
11
|
+
# (the loop reports it per iteration; 'r' forces an
|
|
12
|
+
# immediate re-query);
|
|
13
|
+
# current task - the task the agent is running now, started by
|
|
14
|
+
# run_started and elapsed on a monotonic clock;
|
|
15
|
+
# session timer - monotonic time since this facade was created;
|
|
16
|
+
# identity - agent name and assignee handle (the header's
|
|
17
|
+
# "name (@handle)" line).
|
|
18
|
+
#
|
|
19
|
+
# All state changes happen on the loop thread; snapshots are read by
|
|
20
|
+
# the TUI input thread while the loop runs — hence the mutex. The
|
|
21
|
+
# facade is deliberately generic: Letsdo::Loop stays untouched, the
|
|
22
|
+
# events come from Letsdo::AgentLoop (wrapped_provider/wrapped_run).
|
|
23
|
+
#
|
|
24
|
+
# An optional on_run_start hook lets the caller (CLI) mark run
|
|
25
|
+
# boundaries in the log when a run starts.
|
|
26
|
+
class Metrics
|
|
27
|
+
# An immutable snapshot of the header metrics at some moment.
|
|
28
|
+
Snapshot = Struct.new(:name, :handle, :done, :left, :session_seconds,
|
|
29
|
+
:current_task, :current_task_seconds, keyword_init: true)
|
|
30
|
+
|
|
31
|
+
# @param name [String] agent name (CLI argument)
|
|
32
|
+
# @param handle [String] assignee handle (e.g. "@developer")
|
|
33
|
+
# @param clock [Proc] monotonic clock, callable → seconds; injected
|
|
34
|
+
# in tests
|
|
35
|
+
# @param on_run_start [Proc, nil] called with the task label when a
|
|
36
|
+
# run starts (the CLI uses it to insert a log divider)
|
|
37
|
+
def initialize(name:, handle:, clock: nil, on_run_start: nil)
|
|
38
|
+
@name = name
|
|
39
|
+
@handle = handle
|
|
40
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
41
|
+
@on_run_start = on_run_start
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@done = 0
|
|
44
|
+
@left = nil
|
|
45
|
+
@current_task = nil
|
|
46
|
+
@current_started = nil
|
|
47
|
+
@session_started = @clock.call
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# An agent run started for a task: records the task label and the
|
|
51
|
+
# monotonic run start time.
|
|
52
|
+
#
|
|
53
|
+
# @param task [String] task label
|
|
54
|
+
def run_started(task)
|
|
55
|
+
@on_run_start&.call(task)
|
|
56
|
+
@mutex.synchronize do
|
|
57
|
+
@current_task = task
|
|
58
|
+
@current_started = @clock.call
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# An agent run finished: increments the done counter and clears the
|
|
63
|
+
# current-task state.
|
|
64
|
+
def run_finished
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
@done += 1
|
|
67
|
+
@current_task = nil
|
|
68
|
+
@current_started = nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The latest open-task count from the backlog provider.
|
|
73
|
+
#
|
|
74
|
+
# @param count [Integer, nil] number of open tasks; nil = the backlog
|
|
75
|
+
# state is unreadable
|
|
76
|
+
def provider_result(count)
|
|
77
|
+
@mutex.synchronize { @left = count }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A point-in-time snapshot of all header metrics.
|
|
81
|
+
#
|
|
82
|
+
# @return [Snapshot]
|
|
83
|
+
def snapshot
|
|
84
|
+
now = @clock.call
|
|
85
|
+
@mutex.synchronize do
|
|
86
|
+
current_seconds = @current_started ? now - @current_started : nil
|
|
87
|
+
Snapshot.new(
|
|
88
|
+
name: @name, handle: @handle, done: @done, left: @left,
|
|
89
|
+
session_seconds: now - @session_started,
|
|
90
|
+
current_task: @current_task,
|
|
91
|
+
current_task_seconds: current_seconds
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|