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,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Tui
|
|
5
|
+
# The frame renderer: a pure function from state to text.
|
|
6
|
+
#
|
|
7
|
+
# (metrics snapshot, log lines, terminal width/height,
|
|
8
|
+
# view offset / follow / paused) → a framed String
|
|
9
|
+
#
|
|
10
|
+
# Pure means: no IO, no terminal, no clock — the same inputs always
|
|
11
|
+
# produce the same string, so every rendering path (session controller,
|
|
12
|
+
# tests) feeds it identically and tests need no real TTY.
|
|
13
|
+
#
|
|
14
|
+
# Layout (bottom line is the last, no trailing newline):
|
|
15
|
+
#
|
|
16
|
+
# letsdo · developer (@developer) session 00:12:34
|
|
17
|
+
# done 3 · left 2 · task TASK-42 · 00:03:21
|
|
18
|
+
# ├────────────────────────────────────────────────────────────┤
|
|
19
|
+
# <body_height scrollable log lines, newest at the bottom>
|
|
20
|
+
# ↑/↓ PgUp/PgDn scroll · p pause · r refresh · q quit
|
|
21
|
+
module Renderer
|
|
22
|
+
HEADER_HEIGHT = 2
|
|
23
|
+
FOOTER_HEIGHT = 1
|
|
24
|
+
DIVIDER_HEIGHT = 1
|
|
25
|
+
ELLIPSIS = "…"
|
|
26
|
+
|
|
27
|
+
# Renders the full frame.
|
|
28
|
+
#
|
|
29
|
+
# @param metrics [Metrics::Snapshot] header metrics snapshot
|
|
30
|
+
# @param lines [Array<String>] log lines, oldest first
|
|
31
|
+
# @param width [Integer] terminal width in columns
|
|
32
|
+
# @param height [Integer] terminal height in rows (>= HEADER_HEIGHT +
|
|
33
|
+
# FOOTER_HEIGHT + DIVIDER_HEIGHT + 1)
|
|
34
|
+
# @param offset [Integer] index of the first visible log line
|
|
35
|
+
# @param follow [Boolean] whether the view sticks to the newest line
|
|
36
|
+
# @param paused [Boolean] display-freeze state (PAUSED in the header)
|
|
37
|
+
# @param wait_seconds [Numeric] retry interval shown in the waiting
|
|
38
|
+
# state
|
|
39
|
+
# @return [String] the framed screen, lines joined with "\n"
|
|
40
|
+
def self.render(metrics:, lines:, width:, height:, offset:, follow:, paused:, wait_seconds: 10.0)
|
|
41
|
+
body_height = body_height_for(height)
|
|
42
|
+
frame = []
|
|
43
|
+
frame << header_line(metrics, width)
|
|
44
|
+
frame << state_line(metrics, width, paused, wait_seconds)
|
|
45
|
+
frame << divider_line(width)
|
|
46
|
+
body_lines(lines, offset, body_height, width).each { |line| frame << line }
|
|
47
|
+
frame << footer_line(width)
|
|
48
|
+
frame.join("\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The number of body lines a terminal of the given height fits.
|
|
52
|
+
def self.body_height_for(height)
|
|
53
|
+
[height - HEADER_HEIGHT - FOOTER_HEIGHT - DIVIDER_HEIGHT, 1].max
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The index of the last line that can be the first visible one.
|
|
57
|
+
def self.max_offset(lines, body_height)
|
|
58
|
+
[lines.length - body_height, 0].max
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Clamps a view offset into the valid range.
|
|
62
|
+
def self.clamp_offset(offset, lines, body_height)
|
|
63
|
+
[[offset, max_offset(lines, body_height)].min, 0].max
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.header_line(metrics, width)
|
|
67
|
+
title = "letsdo · #{metrics.name} (#{metrics.handle})"
|
|
68
|
+
timer = "session #{format_duration(metrics.session_seconds)}"
|
|
69
|
+
fit_line_with_right(title, timer, width)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The state line: done/left plus either the running task with its
|
|
73
|
+
# elapsed time, a waiting reason, or the PAUSED overlay.
|
|
74
|
+
def self.state_line(metrics, width, paused, wait_seconds)
|
|
75
|
+
parts = ["done #{metrics.done}", "left #{metrics.left.nil? ? "?" : metrics.left}"]
|
|
76
|
+
if paused
|
|
77
|
+
parts << "PAUSED"
|
|
78
|
+
elsif metrics.current_task
|
|
79
|
+
parts << "task #{metrics.current_task}"
|
|
80
|
+
parts << format_duration(metrics.current_task_seconds) if metrics.current_task_seconds
|
|
81
|
+
else
|
|
82
|
+
parts << "waiting: #{waiting_reason(metrics.left, wait_seconds)}"
|
|
83
|
+
end
|
|
84
|
+
fit_line(parts.join(" · "), width)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.body_lines(lines, offset, body_height, width)
|
|
88
|
+
Array.new(body_height) do |index|
|
|
89
|
+
line = lines[offset + index]
|
|
90
|
+
line.nil? ? (" " * width) : fit_line(line, width)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.divider_line(width)
|
|
95
|
+
return "" if width < 4
|
|
96
|
+
|
|
97
|
+
body = "─" * (width - 2)
|
|
98
|
+
"├#{body}┤"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.footer_line(width)
|
|
102
|
+
fit_line("↑/↓ PgUp/PgDn scroll · p pause · r refresh · q quit", width)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# "no open tasks, retrying in 10s" style reason for the waiting state.
|
|
106
|
+
def self.waiting_reason(left, wait_seconds)
|
|
107
|
+
formatted = wait_seconds == wait_seconds.to_i ? wait_seconds.to_i : wait_seconds
|
|
108
|
+
suffix = " (retry in #{formatted}s)"
|
|
109
|
+
if left.nil?
|
|
110
|
+
"backlog unavailable#{suffix}"
|
|
111
|
+
elsif left.zero?
|
|
112
|
+
"no open tasks#{suffix}"
|
|
113
|
+
else
|
|
114
|
+
"next task"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# HH:MM:SS (hours can exceed two digits for long sessions).
|
|
119
|
+
def self.format_duration(seconds)
|
|
120
|
+
total = [seconds.to_i, 0].max
|
|
121
|
+
hours, remainder = total.divmod(3600)
|
|
122
|
+
minutes, secs = remainder.divmod(60)
|
|
123
|
+
format("%02d:%02d:%02d", hours, minutes, secs)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.fit_line_with_right(left, right, width)
|
|
127
|
+
gap = [width - display_width(left) - display_width(right), 0].max
|
|
128
|
+
fit_line("#{left}#{" " * gap}#{right}", width)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Fits a line to the width: display-width truncation with an
|
|
132
|
+
# ellipsis, then whitespace padding so the whole line is rewritten
|
|
133
|
+
# (a narrow terminal resize leaves no stale content).
|
|
134
|
+
def self.fit_line(line, width)
|
|
135
|
+
fitted = fit(line, width)
|
|
136
|
+
pad = width - display_width(fitted)
|
|
137
|
+
pad.positive? ? fitted + (" " * pad) : fitted
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Cuts the line on a character boundary by display width, adding an
|
|
141
|
+
# ellipsis when anything was cut. Lines within the width are kept
|
|
142
|
+
# verbatim.
|
|
143
|
+
def self.fit(line, width)
|
|
144
|
+
text_width = display_width(line)
|
|
145
|
+
return line if width >= text_width
|
|
146
|
+
return "" if width <= 1
|
|
147
|
+
|
|
148
|
+
out = +""
|
|
149
|
+
acc = 0
|
|
150
|
+
limit = width - display_width(ELLIPSIS)
|
|
151
|
+
line.each_char do |char|
|
|
152
|
+
char_width = display_width(char)
|
|
153
|
+
break if acc + char_width > limit
|
|
154
|
+
|
|
155
|
+
out << char
|
|
156
|
+
acc += char_width
|
|
157
|
+
end
|
|
158
|
+
out << ELLIPSIS
|
|
159
|
+
out
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Display width of a string (Unicode-aware: ✓/⚙/…/CJK are counted
|
|
163
|
+
# correctly). Requires "unicode/display_width" lazily so the plain
|
|
164
|
+
# (non-TTY) path needs no extra gems.
|
|
165
|
+
def self.display_width(text)
|
|
166
|
+
return 0 if text.nil? || text.empty?
|
|
167
|
+
|
|
168
|
+
require "unicode/display_width"
|
|
169
|
+
Unicode::DisplayWidth.of(text)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Tui
|
|
5
|
+
# The TUI controller: owns the terminal lifecycle, the background input
|
|
6
|
+
# thread and the repaint loop around an orchestrator run.
|
|
7
|
+
#
|
|
8
|
+
# Threading model (the orchestrator loop stays on the calling thread,
|
|
9
|
+
# exactly as in plain mode — Letsdo::PiRunner's signal design keeps
|
|
10
|
+
# working unchanged):
|
|
11
|
+
# main thread — the injected block (Letsdo::AgentLoop#run with its
|
|
12
|
+
# own signal traps): provider queries, agent runs,
|
|
13
|
+
# streamer writes into the log, metrics events;
|
|
14
|
+
# input thread — the SOLE repainter: polls keys (tty-reader),
|
|
15
|
+
# drains the log snapshot, tracks the metrics
|
|
16
|
+
# snapshot and repaints on keys, data, 1s timer
|
|
17
|
+
# ticks and SIGWINCH. Never touches the loop.
|
|
18
|
+
#
|
|
19
|
+
# Interactive actions (footer lists them):
|
|
20
|
+
# ↑ / ↓ / PgUp / PgDn / Home / End — scroll; the view auto-follows
|
|
21
|
+
# the newest line while at the bottom (tail -f semantics);
|
|
22
|
+
# p — pause/resume: display freeze (PAUSED in the header, the log
|
|
23
|
+
# keeps buffering, the view stays); keys still repaint;
|
|
24
|
+
# r — immediate backlog re-query for the 'left' metric;
|
|
25
|
+
# q / Ctrl-C — quit: exactly the signal-stop unwinding — Letsdo::Stopped
|
|
26
|
+
# raised into the main thread terminates the pi child (PiRunner
|
|
27
|
+
# rescue), stops the loop (AgentLoop rescue) and restores the
|
|
28
|
+
# terminal from the ensure block; exit code 0.
|
|
29
|
+
#
|
|
30
|
+
# Signals: SIGWINCH sets a flag → the input thread re-queries the size
|
|
31
|
+
# and repaints (no corruption). SIGINT/SIGTERM keep working through the
|
|
32
|
+
# loop's own traps (main thread).
|
|
33
|
+
class Session
|
|
34
|
+
# 1s session-timer repaint interval.
|
|
35
|
+
REPAINT_INTERVAL = 1.0
|
|
36
|
+
# Poll sleep when the display is frozen or nothing happened (avoids a
|
|
37
|
+
# busy loop while still repainting at the timer interval).
|
|
38
|
+
IDLE_SLEEP = 0.01
|
|
39
|
+
# How long teardown waits for the input thread to finish its poll.
|
|
40
|
+
INPUT_JOIN_TIMEOUT = 2.0
|
|
41
|
+
|
|
42
|
+
# @param name [String] agent name (header identity)
|
|
43
|
+
# @param handle [String] assignee handle (header identity)
|
|
44
|
+
# @param log [LogBuffer] the combined log the streamer writes into
|
|
45
|
+
# @param metrics [Metrics] the header metrics facade
|
|
46
|
+
# @param terminal [Terminal] the terminal wrapper
|
|
47
|
+
# @param input [Input] the key reader
|
|
48
|
+
# @param refresh [Proc, nil] callable → open task count (Integer) or
|
|
49
|
+
# nil (backlog unreadable); 'r' uses it for an immediate
|
|
50
|
+
# re-query
|
|
51
|
+
# @param wait_seconds [Numeric] retry interval for the waiting state
|
|
52
|
+
# @param clock [Proc] monotonic clock for tick timing (injectable)
|
|
53
|
+
def initialize(name:, handle:, log:, metrics:, terminal:, input:,
|
|
54
|
+
refresh: nil, wait_seconds: 10.0, clock: nil)
|
|
55
|
+
@name = name
|
|
56
|
+
@handle = handle
|
|
57
|
+
@log = log
|
|
58
|
+
@metrics = metrics
|
|
59
|
+
@terminal = terminal
|
|
60
|
+
@input = input
|
|
61
|
+
@refresh = refresh
|
|
62
|
+
@wait_seconds = wait_seconds
|
|
63
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
64
|
+
@offset = 0
|
|
65
|
+
@follow = true
|
|
66
|
+
@paused = false
|
|
67
|
+
@stop = false
|
|
68
|
+
@winch = false
|
|
69
|
+
@body_height = 1
|
|
70
|
+
@seen_version = 0
|
|
71
|
+
@input_thread = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Runs the TUI around the given orchestrator work.
|
|
75
|
+
#
|
|
76
|
+
# The block runs on the calling thread. On quit (q/Ctrl-C) or a stop
|
|
77
|
+
# signal Letsdo::Stopped unwinds it; the terminal is restored from
|
|
78
|
+
# the ensure block on every exit path.
|
|
79
|
+
#
|
|
80
|
+
# @yield the orchestrator run (e.g. Letsdo::AgentLoop#run)
|
|
81
|
+
# @return [Integer] the block's result, or 0 when stopped
|
|
82
|
+
def run(&work)
|
|
83
|
+
install_winch_handler
|
|
84
|
+
@terminal.enter
|
|
85
|
+
start_input_thread
|
|
86
|
+
result = work.call
|
|
87
|
+
result
|
|
88
|
+
rescue Letsdo::Stopped
|
|
89
|
+
0
|
|
90
|
+
ensure
|
|
91
|
+
stop_input_thread
|
|
92
|
+
leave_terminal
|
|
93
|
+
restore_winch_handler
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# The input thread body: the only place that writes to the terminal.
|
|
99
|
+
def input_loop
|
|
100
|
+
with_raw_input do
|
|
101
|
+
repaint
|
|
102
|
+
@last_repaint = @clock.call
|
|
103
|
+
loop do
|
|
104
|
+
break if @stop
|
|
105
|
+
|
|
106
|
+
key = @input.next_key
|
|
107
|
+
handled = key ? handle_key(key) : false
|
|
108
|
+
winch = take_winch
|
|
109
|
+
handled = true if winch
|
|
110
|
+
now = @clock.call
|
|
111
|
+
tick = now - @last_repaint >= REPAINT_INTERVAL
|
|
112
|
+
new_data = @log.version != @seen_version
|
|
113
|
+
if handled || (!@paused && (tick || new_data))
|
|
114
|
+
repaint
|
|
115
|
+
@last_repaint = @clock.call
|
|
116
|
+
else
|
|
117
|
+
sleep(IDLE_SLEEP)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
rescue StandardError
|
|
122
|
+
# The input thread must never die loudly into the middle of the
|
|
123
|
+
# screen, and a quiet exit still leaves the loop running (teardown
|
|
124
|
+
# joins it). Terminal state is restored by the ensure blocks.
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def handle_key(key)
|
|
129
|
+
case key
|
|
130
|
+
when :up, :page_up
|
|
131
|
+
@offset -= key == :page_up ? page_step : 1
|
|
132
|
+
@follow = false
|
|
133
|
+
true
|
|
134
|
+
when :down, :page_down
|
|
135
|
+
@offset += key == :page_down ? page_step : 1
|
|
136
|
+
true # repaint decides re-sticking at the bottom
|
|
137
|
+
when :home
|
|
138
|
+
@offset = 0
|
|
139
|
+
@follow = false
|
|
140
|
+
true
|
|
141
|
+
when :end
|
|
142
|
+
@follow = true
|
|
143
|
+
true
|
|
144
|
+
when :p
|
|
145
|
+
@paused = !@paused
|
|
146
|
+
true
|
|
147
|
+
when :r
|
|
148
|
+
refresh
|
|
149
|
+
true
|
|
150
|
+
when :q, :ctrl_c
|
|
151
|
+
quit
|
|
152
|
+
false # leaving the session; no repaint needed
|
|
153
|
+
else
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Immediate backlog re-query: the provider is re-run on this thread
|
|
159
|
+
# (a subprocess call, thread-safe) and the count reaches the header
|
|
160
|
+
# snapshot.
|
|
161
|
+
def refresh
|
|
162
|
+
return unless @refresh
|
|
163
|
+
|
|
164
|
+
@metrics.provider_result(@refresh.call)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Quit = the same unwinding as a stop signal: Letsdo::Stopped raised
|
|
168
|
+
# into the main thread (inside the orchestrator work) terminates the
|
|
169
|
+
# pi child and stops the loop; the ensure block restores the
|
|
170
|
+
# terminal. No-op after teardown already started.
|
|
171
|
+
def quit
|
|
172
|
+
return if @stop
|
|
173
|
+
|
|
174
|
+
Thread.main.raise(Letsdo::Stopped)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Repaints the whole frame from the current state.
|
|
178
|
+
def repaint
|
|
179
|
+
lines, version = @log.lines
|
|
180
|
+
@seen_version = version
|
|
181
|
+
height, width = @terminal.size
|
|
182
|
+
@body_height = Renderer.body_height_for(height)
|
|
183
|
+
max_offset = Renderer.max_offset(lines, @body_height)
|
|
184
|
+
if @paused
|
|
185
|
+
@offset = [[@offset, max_offset].min, 0].max
|
|
186
|
+
elsif @follow
|
|
187
|
+
@offset = max_offset
|
|
188
|
+
else
|
|
189
|
+
@offset = [[@offset, max_offset].min, 0].max
|
|
190
|
+
@follow = true if @offset == max_offset
|
|
191
|
+
end
|
|
192
|
+
frame = Renderer.render(
|
|
193
|
+
metrics: @metrics.snapshot, lines: lines, width: width, height: height,
|
|
194
|
+
offset: @offset, follow: @follow, paused: @paused,
|
|
195
|
+
wait_seconds: @wait_seconds
|
|
196
|
+
)
|
|
197
|
+
@terminal.render(frame)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Page height for PgUp/PgDn.
|
|
201
|
+
def page_step
|
|
202
|
+
@body_height.positive? ? @body_height : 1
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def take_winch
|
|
206
|
+
flag = @winch
|
|
207
|
+
@winch = false
|
|
208
|
+
flag
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Raw keyboard mode for the whole input session (the tty-reader
|
|
212
|
+
# per-read raw wraps would leave the terminal echoing between polls);
|
|
213
|
+
# no-op for non-tty inputs (tests use a pipe).
|
|
214
|
+
def with_raw_input(&block)
|
|
215
|
+
if @input.stdin.tty? && @input.stdin.respond_to?(:raw)
|
|
216
|
+
@input.stdin.raw(&block)
|
|
217
|
+
else
|
|
218
|
+
block.call
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def install_winch_handler
|
|
223
|
+
Signal.trap("SIGWINCH") { @winch = true }
|
|
224
|
+
rescue ArgumentError
|
|
225
|
+
nil # no SIGWINCH on this platform — no resize repaints
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def restore_winch_handler
|
|
229
|
+
Signal.trap("SIGWINCH", "DEFAULT")
|
|
230
|
+
rescue ArgumentError
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def start_input_thread
|
|
235
|
+
@input_thread = Thread.new { input_loop }
|
|
236
|
+
@input_thread.report_on_exception = false
|
|
237
|
+
@input_thread
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def stop_input_thread
|
|
241
|
+
@stop = true
|
|
242
|
+
thread = @input_thread
|
|
243
|
+
return unless thread
|
|
244
|
+
|
|
245
|
+
thread.join(INPUT_JOIN_TIMEOUT) || thread.kill
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def leave_terminal
|
|
249
|
+
@terminal.leave
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-cursor"
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
module Tui
|
|
7
|
+
# Thin wrapper over the terminal: alternate screen, cursor handling and
|
|
8
|
+
# frame rendering, all through an injected stream (StringIO in tests).
|
|
9
|
+
#
|
|
10
|
+
# The alternate screen is entered/exited with the raw ANSI sequences
|
|
11
|
+
# (tty-cursor has no alt-screen helper); cursor hide/show and the home
|
|
12
|
+
# position come from TTY::Cursor. The frame render is a single write:
|
|
13
|
+
# cursor-to-top + the framed text. Resizes are handled by re-querying
|
|
14
|
+
# the size provider before each render.
|
|
15
|
+
class Terminal
|
|
16
|
+
# Alternate screen buffer on (keeps the caller's terminal content).
|
|
17
|
+
ENTER_ALT_SCREEN = "\e[?1049h"
|
|
18
|
+
# Alternate screen buffer off.
|
|
19
|
+
LEAVE_ALT_SCREEN = "\e[?1049l"
|
|
20
|
+
# Cursor-to-home-column-1-row-1 (cell 1,1 per tty-cursor convention).
|
|
21
|
+
HOME = "\e[1;1H"
|
|
22
|
+
|
|
23
|
+
# @param stream [IO] the terminal output stream
|
|
24
|
+
# @param size_provider [Proc] callable → [height, width]; defaults to
|
|
25
|
+
# TTY::Screen.size; injected in tests for deterministic size
|
|
26
|
+
def initialize(stream:, size_provider: nil)
|
|
27
|
+
require "tty-screen" unless defined?(TTY::Screen)
|
|
28
|
+
|
|
29
|
+
@stream = stream
|
|
30
|
+
@size_provider = size_provider ||
|
|
31
|
+
-> { size = TTY::Screen.size; [size[0] || 24, size[1] || 80] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Enters the alternate screen and hides the cursor.
|
|
35
|
+
def enter
|
|
36
|
+
write(ENTER_ALT_SCREEN + TTY::Cursor.hide)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Shows the cursor and leaves the alternate screen.
|
|
40
|
+
def leave
|
|
41
|
+
write(TTY::Cursor.show + LEAVE_ALT_SCREEN)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Repaints the whole frame: cursor to the top left, then the frame.
|
|
45
|
+
#
|
|
46
|
+
# @param frame [String] the rendered screen (see Letsdo::Tui::Renderer)
|
|
47
|
+
def render(frame)
|
|
48
|
+
write(HOME + frame)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The current terminal size.
|
|
52
|
+
#
|
|
53
|
+
# @return [Array(Integer, Integer)] height and width in rows/columns
|
|
54
|
+
def size
|
|
55
|
+
dims = @size_provider.call
|
|
56
|
+
[dims[0] || 24, dims[1] || 80]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def write(text)
|
|
62
|
+
@stream.write(text)
|
|
63
|
+
@stream.flush
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/letsdo/tui.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The interactive TUI (TASK-42): a full-screen interface for letsdo <name>
|
|
4
|
+
# when stdout and stdin are terminals (TERM != "dumb"). In TUI mode the
|
|
5
|
+
# streamer and the loop driver feed a log buffer + metrics facade; the
|
|
6
|
+
# session renders a header (name + handle, done-in-session counter, session
|
|
7
|
+
# timer, tasks remaining, current-task elapsed, waiting/PAUSED states), a
|
|
8
|
+
# scrollable combined log and a key-help footer, and repaints from a
|
|
9
|
+
# background input thread. Non-TTY output stays the plain line-stream.
|
|
10
|
+
#
|
|
11
|
+
# Components:
|
|
12
|
+
# Letsdo::Tui::LogBuffer - thread-safe combined log (streamer target)
|
|
13
|
+
# Letsdo::Tui::Metrics - header metrics facade (loop driver events)
|
|
14
|
+
# Letsdo::Tui::Renderer - pure function: state → framed String
|
|
15
|
+
# Letsdo::Tui::Terminal - alt screen, cursor, frame rendering
|
|
16
|
+
# Letsdo::Tui::Input - tty-reader key decoding (injectable)
|
|
17
|
+
# Letsdo::Tui::Session - controller: terminal lifecycle + input thread
|
|
18
|
+
|
|
19
|
+
require_relative "tui/log_buffer"
|
|
20
|
+
require_relative "tui/metrics"
|
|
21
|
+
require_relative "tui/renderer"
|
|
22
|
+
require_relative "tui/terminal"
|
|
23
|
+
require_relative "tui/input"
|
|
24
|
+
require_relative "tui/session"
|
data/lib/letsdo.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Letsdo package — a local agent worker for Backlog.md/markdown tasks.
|
|
4
|
+
#
|
|
5
|
+
# OOP structure:
|
|
6
|
+
# Letsdo::Errors - error hierarchy (UnknownAgentError and others)
|
|
7
|
+
# Letsdo::PromptStore - access to agents/*.md prompts
|
|
8
|
+
# Letsdo::OutputStreamer - where and how agent text and service lines are printed
|
|
9
|
+
# Letsdo::PiRunner - running pi --mode json and parsing the event stream
|
|
10
|
+
# Letsdo::Agent - a single agent run: prompt from agents/ + pi
|
|
11
|
+
# Letsdo::BacklogTasks - open tasks from the backlog CLI (the task provider)
|
|
12
|
+
# Letsdo::Loop - generic orchestrator: tasks → runs → waiting
|
|
13
|
+
# Letsdo::AgentLoop - letsdo wiring: provider + agent + signals + messages
|
|
14
|
+
# Letsdo::Tui - the interactive TUI (LogBuffer, Metrics,
|
|
15
|
+
# Renderer, Terminal, Input, Session)
|
|
16
|
+
# Letsdo::CLI - command-line arguments, usage, exit code
|
|
17
|
+
#
|
|
18
|
+
# Entry point — bin/letsdo (a thin wrapper over Letsdo::CLI).
|
|
19
|
+
|
|
20
|
+
require_relative "letsdo/version"
|
|
21
|
+
require_relative "letsdo/errors"
|
|
22
|
+
require_relative "letsdo/prompt_store"
|
|
23
|
+
require_relative "letsdo/output_streamer"
|
|
24
|
+
require_relative "letsdo/pi_runner"
|
|
25
|
+
require_relative "letsdo/agent"
|
|
26
|
+
require_relative "letsdo/backlog_tasks"
|
|
27
|
+
require_relative "letsdo/loop"
|
|
28
|
+
require_relative "letsdo/agent_loop"
|
|
29
|
+
require_relative "letsdo/tui"
|
|
30
|
+
require_relative "letsdo/cli"
|
metadata
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: letsdo
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sergei O. Udalov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: tty-screen
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.8'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.8'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: tty-cursor
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.7'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.7'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: tty-reader
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.9'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.9'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: unicode-display_width
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.0'
|
|
68
|
+
description: 'letsdo — a local agent worker for Backlog.md/markdown tasks. OOP structure:
|
|
69
|
+
Letsdo::PromptStore (agents/), Letsdo::OutputStreamer and Letsdo::PiRunner (pi --mode
|
|
70
|
+
json), Letsdo::Agent (one run), Letsdo::Loop (orchestrator loop), Letsdo::CLI. Minitest
|
|
71
|
+
tests. Will later be split into a separate repository.'
|
|
72
|
+
email:
|
|
73
|
+
- udalov.x@mail.ru
|
|
74
|
+
executables:
|
|
75
|
+
- letsdo
|
|
76
|
+
extensions: []
|
|
77
|
+
extra_rdoc_files: []
|
|
78
|
+
files:
|
|
79
|
+
- LICENSE
|
|
80
|
+
- README.md
|
|
81
|
+
- bin/letsdo
|
|
82
|
+
- lib/letsdo.rb
|
|
83
|
+
- lib/letsdo/agent.rb
|
|
84
|
+
- lib/letsdo/agent_loop.rb
|
|
85
|
+
- lib/letsdo/backlog_tasks.rb
|
|
86
|
+
- lib/letsdo/cli.rb
|
|
87
|
+
- lib/letsdo/errors.rb
|
|
88
|
+
- lib/letsdo/loop.rb
|
|
89
|
+
- lib/letsdo/output_streamer.rb
|
|
90
|
+
- lib/letsdo/pi_runner.rb
|
|
91
|
+
- lib/letsdo/prompt_store.rb
|
|
92
|
+
- lib/letsdo/tui.rb
|
|
93
|
+
- lib/letsdo/tui/input.rb
|
|
94
|
+
- lib/letsdo/tui/log_buffer.rb
|
|
95
|
+
- lib/letsdo/tui/metrics.rb
|
|
96
|
+
- lib/letsdo/tui/renderer.rb
|
|
97
|
+
- lib/letsdo/tui/session.rb
|
|
98
|
+
- lib/letsdo/tui/terminal.rb
|
|
99
|
+
- lib/letsdo/version.rb
|
|
100
|
+
licenses:
|
|
101
|
+
- MIT
|
|
102
|
+
metadata:
|
|
103
|
+
rubygems_mfa_required: 'true'
|
|
104
|
+
rdoc_options: []
|
|
105
|
+
require_paths:
|
|
106
|
+
- lib
|
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: '3.0'
|
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
requirements: []
|
|
118
|
+
rubygems_version: 4.0.6
|
|
119
|
+
specification_version: 4
|
|
120
|
+
summary: A local agent worker for Backlog.md/markdown tasks
|
|
121
|
+
test_files: []
|