letsdo 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +232 -0
- data/README.md +156 -23
- data/bin/letsdo +8 -1
- data/docs/config.md +176 -0
- data/docs/prompts.md +210 -0
- data/docs/task-selection.md +244 -0
- data/docs/usage.md +296 -0
- data/letsdo.gemspec +63 -0
- data/lib/letsdo/agent.rb +30 -17
- data/lib/letsdo/agent_identity.rb +46 -0
- data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
- data/lib/letsdo/agent_loop/tasks.rb +88 -13
- data/lib/letsdo/agent_loop.rb +46 -4
- data/lib/letsdo/backends/backend.rb +137 -0
- data/lib/letsdo/backends/pi/events.rb +77 -0
- data/lib/letsdo/backends/pi.rb +88 -0
- data/lib/letsdo/cli/builder.rb +102 -0
- data/lib/letsdo/cli/builder_assembly.rb +154 -0
- data/lib/letsdo/cli/builder_metrics.rb +82 -0
- data/lib/letsdo/cli/doctor.rb +16 -0
- data/lib/letsdo/cli.rb +17 -56
- data/lib/letsdo/config.rb +152 -0
- data/lib/letsdo/control/reader.rb +131 -0
- data/lib/letsdo/control.rb +6 -3
- data/lib/letsdo/doctor/checks.rb +144 -0
- data/lib/letsdo/doctor.rb +42 -0
- data/lib/letsdo/duration.rb +23 -0
- data/lib/letsdo/errors.rb +7 -0
- data/lib/letsdo/metrics/fanout.rb +47 -0
- data/lib/letsdo/prompt_store.rb +48 -1
- data/lib/letsdo/providers/backlog.rb +180 -0
- data/lib/letsdo/providers/task.rb +48 -0
- data/lib/letsdo/retry_policy.rb +98 -0
- data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
- data/lib/letsdo/session_recorder.rb +188 -0
- data/lib/letsdo/task_time_writeback.rb +106 -0
- data/lib/letsdo/tui/metrics.rb +10 -3
- data/lib/letsdo/tui/renderer.rb +9 -1
- data/lib/letsdo/tui/session/terminal.rb +47 -0
- data/lib/letsdo/tui/session/view.rb +10 -2
- data/lib/letsdo/tui/session.rb +27 -22
- data/lib/letsdo/tui/window_title.rb +133 -0
- data/lib/letsdo/tui.rb +4 -0
- data/lib/letsdo/version.rb +1 -1
- data/lib/letsdo.rb +17 -5
- metadata +39 -13
- data/lib/letsdo/backlog_tasks.rb +0 -59
- data/lib/letsdo/cli/launch.rb +0 -87
- data/lib/letsdo/pi_runner/events.rb +0 -75
- data/lib/letsdo/pi_runner/process.rb +0 -68
- data/lib/letsdo/pi_runner.rb +0 -101
|
@@ -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
|
data/lib/letsdo/tui/metrics.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
data/lib/letsdo/tui/renderer.rb
CHANGED
|
@@ -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
|
|
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
|
|
14
|
-
|
|
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
|
data/lib/letsdo/tui/session.rb
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
module Letsdo
|
|
6
|
+
module Tui
|
|
7
|
+
# Names the tmux window after the running agent (TASK-90) and restores
|
|
8
|
+
# the previous label on exit.
|
|
9
|
+
#
|
|
10
|
+
# tmux labels a window after its foreground process (automatic-rename),
|
|
11
|
+
# which for letsdo is always "ruby" — useless when one agent runs per
|
|
12
|
+
# pane. Two tmux facts (verified against tmux 3.4) shape this class:
|
|
13
|
+
#
|
|
14
|
+
# * an OSC-2 title escape only sets the *pane* title; the window label
|
|
15
|
+
# still follows the process name;
|
|
16
|
+
# * so the window must be renamed explicitly and automatic-rename
|
|
17
|
+
# disabled for the session, or tmux overwrites the label.
|
|
18
|
+
#
|
|
19
|
+
# Every tmux call goes through an injectable runner (tests stub it) and
|
|
20
|
+
# every failure is swallowed: title handling may never break a session.
|
|
21
|
+
# Being inside tmux on a TTY stream is the gate, so outside tmux nothing
|
|
22
|
+
# is written and the tmux binary is never invoked.
|
|
23
|
+
class WindowTitle
|
|
24
|
+
# OSC-2 title escape; only emitted inside tmux (see #install).
|
|
25
|
+
TITLE_TEMPLATE = "\e]2;%s\a"
|
|
26
|
+
# automatic-rename value meaning "no explicit setting" (tmux default on).
|
|
27
|
+
UNSET = ''
|
|
28
|
+
|
|
29
|
+
# @param stream [IO] terminal output stream (title escapes go here)
|
|
30
|
+
# @param env [Hash, ENV] source of TMUX / TMUX_PANE
|
|
31
|
+
# @param runner [Proc, nil] callable(Array<String>) → stdout String;
|
|
32
|
+
# defaults to the real tmux binary, stubbed in tests
|
|
33
|
+
def initialize(stream:, env:, runner: nil)
|
|
34
|
+
@stream = stream
|
|
35
|
+
@env = env
|
|
36
|
+
@runner = runner || method(:run_tmux)
|
|
37
|
+
@previous = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Saves the current window label and labels the window with `name`.
|
|
41
|
+
# No-op outside tmux, or when the stream is not a TTY.
|
|
42
|
+
def install(name)
|
|
43
|
+
return unless tmux?
|
|
44
|
+
|
|
45
|
+
state = capture
|
|
46
|
+
return unless state
|
|
47
|
+
|
|
48
|
+
label(name)
|
|
49
|
+
write_title(name)
|
|
50
|
+
@previous = state
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Puts the saved label back and restores automatic-rename, unsetting
|
|
54
|
+
# the option when it had no explicit value before (no leftover state).
|
|
55
|
+
def restore
|
|
56
|
+
state = @previous
|
|
57
|
+
return unless state
|
|
58
|
+
|
|
59
|
+
@previous = nil
|
|
60
|
+
label(state[:name])
|
|
61
|
+
restore_automatic_rename(state[:automatic_rename])
|
|
62
|
+
write_title(state[:name])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether a tmux session hosts this process and titles can be set.
|
|
66
|
+
def tmux?
|
|
67
|
+
return false unless @stream.respond_to?(:tty?) && @stream.tty?
|
|
68
|
+
|
|
69
|
+
!@env['TMUX'].to_s.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Renames the window, then disables automatic-rename so tmux keeps the
|
|
75
|
+
# label for the whole session.
|
|
76
|
+
def label(name)
|
|
77
|
+
tmux('rename-window', name)
|
|
78
|
+
tmux('set-option', '-w', 'automatic-rename', 'off')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def restore_automatic_rename(value)
|
|
82
|
+
return tmux('set-option', '-w', '-u', 'automatic-rename') if value == UNSET
|
|
83
|
+
|
|
84
|
+
tmux('set-option', '-w', 'automatic-rename', value)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The current window label and automatic-rename value, or nil when the
|
|
88
|
+
# label cannot be read (no tmux, no server, failed command).
|
|
89
|
+
def capture
|
|
90
|
+
name = tmux('display-message', '-p', "\#{window_name}").to_s.strip
|
|
91
|
+
return nil if name.empty?
|
|
92
|
+
|
|
93
|
+
{ name: name, automatic_rename: automatic_rename_option }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def automatic_rename_option
|
|
97
|
+
tmux('show-window-options', '-v', 'automatic-rename').to_s.strip
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def write_title(text)
|
|
101
|
+
@stream.write(format(TITLE_TEMPLATE, text))
|
|
102
|
+
@stream.flush
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# One tmux command through the injected runner; a missing binary, an
|
|
106
|
+
# unreachable server or a failed command all yield nil.
|
|
107
|
+
def tmux(*args)
|
|
108
|
+
@runner.call(window_args(*args))
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Targets the pane letsdo runs in, so the commands act on the right
|
|
114
|
+
# window even when the tmux server has no attached client. `-t` is a
|
|
115
|
+
# command option and must follow the subcommand.
|
|
116
|
+
def window_args(*args)
|
|
117
|
+
pane = @env['TMUX_PANE'].to_s
|
|
118
|
+
return args if pane.empty?
|
|
119
|
+
|
|
120
|
+
[args.first, '-t', pane, *args.drop(1)]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param args [Array<String>] tmux arguments
|
|
124
|
+
# @return [String] the command's stdout
|
|
125
|
+
def run_tmux(args)
|
|
126
|
+
out, _err, status = Open3.capture3('tmux', *args)
|
|
127
|
+
raise "tmux failed: #{args.join(' ')}" unless status.success?
|
|
128
|
+
|
|
129
|
+
out
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/letsdo/tui.rb
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
# timer, tasks remaining, current-task elapsed, waiting/PAUSED states), a
|
|
8
8
|
# scrollable combined log and a key-help footer, and repaints from a
|
|
9
9
|
# background input thread. Non-TTY output stays the plain line-stream.
|
|
10
|
+
# Inside tmux the window is labeled with the agent name for the session and
|
|
11
|
+
# the previous label is restored on exit (TASK-90).
|
|
10
12
|
#
|
|
11
13
|
# Components:
|
|
12
14
|
# Letsdo::Tui::LogBuffer - thread-safe combined log (streamer target)
|
|
@@ -14,11 +16,13 @@
|
|
|
14
16
|
# Letsdo::Tui::Renderer - pure function: state → framed String
|
|
15
17
|
# Letsdo::Tui::Terminal - alt screen, cursor, frame rendering
|
|
16
18
|
# Letsdo::Tui::Input - tty-reader key decoding (injectable)
|
|
19
|
+
# Letsdo::Tui::WindowTitle - names the tmux window after the agent
|
|
17
20
|
# Letsdo::Tui::Session - controller: terminal lifecycle + input thread
|
|
18
21
|
|
|
19
22
|
require_relative 'tui/log_buffer'
|
|
20
23
|
require_relative 'tui/metrics'
|
|
21
24
|
require_relative 'tui/renderer'
|
|
22
25
|
require_relative 'tui/terminal'
|
|
26
|
+
require_relative 'tui/window_title'
|
|
23
27
|
require_relative 'tui/input'
|
|
24
28
|
require_relative 'tui/session'
|
data/lib/letsdo/version.rb
CHANGED