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/tui/renderer.rb
CHANGED
|
@@ -4,8 +4,8 @@ module Letsdo
|
|
|
4
4
|
module Tui
|
|
5
5
|
# The frame renderer: a pure function from state to text.
|
|
6
6
|
#
|
|
7
|
-
# (metrics snapshot, log lines, terminal
|
|
8
|
-
#
|
|
7
|
+
# (metrics snapshot, log lines, terminal size, view,
|
|
8
|
+
# wait interval) → a framed String
|
|
9
9
|
#
|
|
10
10
|
# Pure means: no IO, no terminal, no clock — the same inputs always
|
|
11
11
|
# produce the same string, so every rendering path (session controller,
|
|
@@ -17,35 +17,37 @@ module Letsdo
|
|
|
17
17
|
# done 3 · left 2 · task TASK-42 · 00:03:21
|
|
18
18
|
# ├────────────────────────────────────────────────────────────┤
|
|
19
19
|
# <body_height scrollable log lines, newest at the bottom>
|
|
20
|
-
# ↑/↓ PgUp/PgDn scroll · p pause · r refresh · q quit
|
|
20
|
+
# ↑/↓ PgUp/PgDn scroll · p pause/resume · r refresh · q quit
|
|
21
21
|
module Renderer
|
|
22
22
|
HEADER_HEIGHT = 2
|
|
23
23
|
FOOTER_HEIGHT = 1
|
|
24
24
|
DIVIDER_HEIGHT = 1
|
|
25
|
-
ELLIPSIS =
|
|
25
|
+
ELLIPSIS = '…'
|
|
26
26
|
|
|
27
27
|
# Renders the full frame.
|
|
28
28
|
#
|
|
29
29
|
# @param metrics [Metrics::Snapshot] header metrics snapshot
|
|
30
30
|
# @param lines [Array<String>] log lines, oldest first
|
|
31
|
-
# @param
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
# @param
|
|
35
|
-
#
|
|
36
|
-
#
|
|
31
|
+
# @param size [Hash{Symbol => Integer}] :width and :height of the
|
|
32
|
+
# terminal (rows >= HEADER_HEIGHT + FOOTER_HEIGHT +
|
|
33
|
+
# DIVIDER_HEIGHT + 1)
|
|
34
|
+
# @param view [Hash{Symbol => Integer, Boolean}] :offset (index of
|
|
35
|
+
# the first visible log line) and :paused (display-freeze
|
|
36
|
+
# overlay)
|
|
37
37
|
# @param wait_seconds [Numeric] retry interval shown in the waiting
|
|
38
38
|
# state
|
|
39
39
|
# @return [String] the framed screen, lines joined with "\n"
|
|
40
|
-
def self.render(metrics:, lines:,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
def self.render(metrics:, lines:, size:, view:, wait_seconds: 10.0)
|
|
41
|
+
width = size.fetch(:width)
|
|
42
|
+
paused = view.fetch(:paused)
|
|
43
|
+
height = body_height_for(size.fetch(:height))
|
|
44
|
+
[
|
|
45
|
+
header_line(metrics, width),
|
|
46
|
+
state_line(metrics, width, paused, wait_seconds),
|
|
47
|
+
divider_line(width),
|
|
48
|
+
*body_lines(lines, view.fetch(:offset), height, width),
|
|
49
|
+
footer_line(width, paused)
|
|
50
|
+
].join("\n")
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
# The number of body lines a terminal of the given height fits.
|
|
@@ -65,41 +67,56 @@ module Letsdo
|
|
|
65
67
|
|
|
66
68
|
def self.header_line(metrics, width)
|
|
67
69
|
title = "letsdo · #{metrics.name} (#{metrics.handle})"
|
|
68
|
-
timer = "session #{format_duration(metrics.session_seconds)}"
|
|
69
|
-
fit_line_with_right(title, timer, width)
|
|
70
|
+
timer = "session #{Text.format_duration(metrics.session_seconds)}"
|
|
71
|
+
Text.fit_line_with_right(title, timer, width)
|
|
70
72
|
end
|
|
71
73
|
|
|
72
74
|
# The state line: done/left plus either the running task with its
|
|
73
75
|
# elapsed time, a waiting reason, or the PAUSED overlay.
|
|
74
76
|
def self.state_line(metrics, width, paused, wait_seconds)
|
|
75
|
-
parts = ["done #{metrics.done}", "left #{metrics.left
|
|
77
|
+
parts = ["done #{metrics.done}", "left #{left_or_question(metrics.left)}"]
|
|
78
|
+
parts.concat(state_overlay(metrics, paused, wait_seconds))
|
|
79
|
+
Text.fit_line(parts.join(' · '), width)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.left_or_question(left)
|
|
83
|
+
left.nil? ? '?' : left
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The current-state fragment after "left N ·": task + elapsed time,
|
|
87
|
+
# a waiting reason, or the PAUSED overlay.
|
|
88
|
+
def self.state_overlay(metrics, paused, wait_seconds)
|
|
76
89
|
if paused
|
|
77
|
-
|
|
90
|
+
['PAUSED']
|
|
78
91
|
elsif metrics.current_task
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
overlay = ["task #{metrics.current_task}"]
|
|
93
|
+
overlay << Text.format_duration(metrics.current_task_seconds) if metrics.current_task_seconds
|
|
94
|
+
overlay
|
|
81
95
|
else
|
|
82
|
-
|
|
96
|
+
["waiting: #{waiting_reason(metrics.left, wait_seconds)}"]
|
|
83
97
|
end
|
|
84
|
-
fit_line(parts.join(" · "), width)
|
|
85
98
|
end
|
|
86
99
|
|
|
87
100
|
def self.body_lines(lines, offset, body_height, width)
|
|
88
101
|
Array.new(body_height) do |index|
|
|
89
102
|
line = lines[offset + index]
|
|
90
|
-
line.nil? ? (
|
|
103
|
+
line.nil? ? (' ' * width) : Text.fit_line(line, width)
|
|
91
104
|
end
|
|
92
105
|
end
|
|
93
106
|
|
|
94
107
|
def self.divider_line(width)
|
|
95
|
-
return
|
|
108
|
+
return '' if width < 4
|
|
96
109
|
|
|
97
|
-
body =
|
|
110
|
+
body = '─' * (width - 2)
|
|
98
111
|
"├#{body}┤"
|
|
99
112
|
end
|
|
100
113
|
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
# The key-help footer. The 'p' hint mirrors the toggle state:
|
|
115
|
+
# 'p pause' when the run is active, 'p resume' when it is suspended
|
|
116
|
+
# (the display is frozen, PAUSED in the header).
|
|
117
|
+
def self.footer_line(width, paused)
|
|
118
|
+
hint = paused ? 'p resume' : 'p pause'
|
|
119
|
+
Text.fit_line("↑/↓ PgUp/PgDn scroll · #{hint} · r refresh · q quit", width)
|
|
103
120
|
end
|
|
104
121
|
|
|
105
122
|
# "no open tasks, retrying in 10s" style reason for the waiting state.
|
|
@@ -111,63 +128,69 @@ module Letsdo
|
|
|
111
128
|
elsif left.zero?
|
|
112
129
|
"no open tasks#{suffix}"
|
|
113
130
|
else
|
|
114
|
-
|
|
131
|
+
'next task'
|
|
115
132
|
end
|
|
116
133
|
end
|
|
117
134
|
|
|
118
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
# Text fitting helpers — kept separate so the frame logic stays short.
|
|
136
|
+
module Text
|
|
137
|
+
# HH:MM:SS (hours can exceed two digits for long sessions).
|
|
138
|
+
def self.format_duration(seconds)
|
|
139
|
+
total = [seconds.to_i, 0].max
|
|
140
|
+
hours, remainder = total.divmod(3600)
|
|
141
|
+
minutes, secs = remainder.divmod(60)
|
|
142
|
+
format('%<hours>02d:%<minutes>02d:%<secs>02d', hours: hours, minutes: minutes, secs: secs)
|
|
143
|
+
end
|
|
125
144
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
def self.fit_line_with_right(left, right, width)
|
|
146
|
+
gap = [width - display_width(left) - display_width(right), 0].max
|
|
147
|
+
fit_line("#{left}#{' ' * gap}#{right}", width)
|
|
148
|
+
end
|
|
130
149
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
150
|
+
# Fits a line to the width: display-width truncation with an
|
|
151
|
+
# ellipsis, then whitespace padding so the whole line is rewritten
|
|
152
|
+
# (a narrow terminal resize leaves no stale content).
|
|
153
|
+
def self.fit_line(line, width)
|
|
154
|
+
fitted = fit(line, width)
|
|
155
|
+
pad = width - display_width(fitted)
|
|
156
|
+
pad.positive? ? fitted + (' ' * pad) : fitted
|
|
157
|
+
end
|
|
139
158
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
159
|
+
# Cuts the line on a character boundary by display width, adding an
|
|
160
|
+
# ellipsis when anything was cut. Lines within the width are kept
|
|
161
|
+
# verbatim.
|
|
162
|
+
def self.fit(line, width)
|
|
163
|
+
text_width = display_width(line)
|
|
164
|
+
return line if width >= text_width
|
|
165
|
+
return '' if width <= 1
|
|
166
|
+
|
|
167
|
+
"#{truncate(line, width - display_width(ELLIPSIS))}#{ELLIPSIS}"
|
|
157
168
|
end
|
|
158
|
-
out << ELLIPSIS
|
|
159
|
-
out
|
|
160
|
-
end
|
|
161
169
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
# Characters of the line that fit into the width budget.
|
|
171
|
+
def self.truncate(line, budget)
|
|
172
|
+
out = +''
|
|
173
|
+
acc = 0
|
|
174
|
+
line.each_char do |char|
|
|
175
|
+
char_width = display_width(char)
|
|
176
|
+
break if acc + char_width > budget
|
|
177
|
+
|
|
178
|
+
out << char
|
|
179
|
+
acc += char_width
|
|
180
|
+
end
|
|
181
|
+
out
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Display width of a string (Unicode-aware: ✓/⚙/…/CJK are counted
|
|
185
|
+
# correctly). Requires "unicode/display_width" lazily so the plain
|
|
186
|
+
# (non-TTY) path needs no extra gems.
|
|
187
|
+
def self.display_width(text)
|
|
188
|
+
return 0 if text.nil? || text.empty?
|
|
167
189
|
|
|
168
|
-
|
|
169
|
-
|
|
190
|
+
require 'unicode/display_width'
|
|
191
|
+
Unicode::DisplayWidth.of(text)
|
|
192
|
+
end
|
|
170
193
|
end
|
|
171
194
|
end
|
|
172
195
|
end
|
|
173
|
-
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Tui
|
|
5
|
+
# Keyboard actions for the TUI session input thread.
|
|
6
|
+
module SessionKeys
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
KEY_METHODS = {
|
|
10
|
+
up: :scroll_up,
|
|
11
|
+
page_up: :scroll_page_up,
|
|
12
|
+
down: :scroll_down,
|
|
13
|
+
page_down: :scroll_page_down,
|
|
14
|
+
home: :go_home,
|
|
15
|
+
end: :go_end,
|
|
16
|
+
p: :pause_key,
|
|
17
|
+
r: :refresh_key,
|
|
18
|
+
q: :quit_key,
|
|
19
|
+
ctrl_c: :quit_key
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def handle_key(key)
|
|
23
|
+
send(KEY_METHODS.fetch(key, :ignore_key))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def scroll_up
|
|
27
|
+
@offset -= 1
|
|
28
|
+
@follow = false
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def scroll_page_up
|
|
33
|
+
@offset -= page_step
|
|
34
|
+
@follow = false
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def scroll_down
|
|
39
|
+
@offset += 1
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scroll_page_down
|
|
44
|
+
@offset += page_step
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def go_home
|
|
49
|
+
@offset = 0
|
|
50
|
+
@follow = false
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def go_end
|
|
55
|
+
@follow = true
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def pause_key
|
|
60
|
+
toggle_pause
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def refresh_key
|
|
65
|
+
refresh
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def quit_key
|
|
70
|
+
quit
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def ignore_key
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def refresh
|
|
79
|
+
return unless @refresh
|
|
80
|
+
|
|
81
|
+
@metrics.provider_result(@refresh.call)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def toggle_pause
|
|
85
|
+
@paused = !@paused
|
|
86
|
+
send(@paused ? :engage_pause : :engage_resume)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def engage_pause
|
|
90
|
+
invoke_gate(:pause)
|
|
91
|
+
invoke_runner(:pause)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def engage_resume
|
|
95
|
+
invoke_gate(:resume)
|
|
96
|
+
invoke_runner(:resume)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def invoke_gate(action)
|
|
100
|
+
gate = @pause_gate
|
|
101
|
+
return unless gate
|
|
102
|
+
|
|
103
|
+
gate.public_send(action)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def invoke_runner(action)
|
|
107
|
+
runner = @runner.call if @runner
|
|
108
|
+
return unless runner
|
|
109
|
+
|
|
110
|
+
runner.public_send(action)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def quit
|
|
114
|
+
return if @stop
|
|
115
|
+
|
|
116
|
+
@stop = true
|
|
117
|
+
Thread.main.raise(Letsdo::Stopped)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Letsdo
|
|
4
|
+
module Tui
|
|
5
|
+
# Frame painting and view-offset tracking for the TUI session.
|
|
6
|
+
module SessionView
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def input_loop
|
|
10
|
+
with_raw_input do
|
|
11
|
+
repaint
|
|
12
|
+
@last_repaint = @clock.call
|
|
13
|
+
loop { break if @stop || !poll_once }
|
|
14
|
+
end
|
|
15
|
+
rescue StandardError
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def poll_once
|
|
20
|
+
handled = process_input
|
|
21
|
+
if handled || (!@paused && (tick? || new_data?))
|
|
22
|
+
repaint
|
|
23
|
+
@last_repaint = @clock.call
|
|
24
|
+
else
|
|
25
|
+
sleep(Session::IDLE_SLEEP)
|
|
26
|
+
end
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def process_input
|
|
31
|
+
key = @input.next_key
|
|
32
|
+
handled = key ? handle_key(key) : false
|
|
33
|
+
take_winch ? true : handled
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tick?
|
|
37
|
+
@clock.call - @last_repaint >= Session::REPAINT_INTERVAL
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def new_data?
|
|
41
|
+
@log.version != @seen_version
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def take_winch
|
|
45
|
+
flag = @winch
|
|
46
|
+
@winch = false
|
|
47
|
+
flag
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def repaint
|
|
51
|
+
lines, version = @log.lines
|
|
52
|
+
@seen_version = version
|
|
53
|
+
height, width = @terminal.size
|
|
54
|
+
@body_height = Renderer.body_height_for(height)
|
|
55
|
+
update_offset(lines)
|
|
56
|
+
@terminal.render(frame_for(lines, width, height))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def update_offset(lines)
|
|
60
|
+
max_offset = Renderer.max_offset(lines, @body_height)
|
|
61
|
+
if @paused
|
|
62
|
+
@offset = clamp_offset(max_offset)
|
|
63
|
+
elsif @follow
|
|
64
|
+
@offset = max_offset
|
|
65
|
+
else
|
|
66
|
+
@offset = clamp_offset(max_offset)
|
|
67
|
+
@follow = true if @offset == max_offset
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def clamp_offset(max_offset)
|
|
72
|
+
[[@offset, max_offset].min, 0].max
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def frame_for(lines, width, height)
|
|
76
|
+
Renderer.render(
|
|
77
|
+
metrics: @metrics.snapshot, lines: lines,
|
|
78
|
+
size: { width: width, height: height },
|
|
79
|
+
view: { offset: @offset, paused: @paused },
|
|
80
|
+
wait_seconds: @wait_seconds
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def page_step
|
|
85
|
+
@body_height.positive? ? @body_height : 1
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|