letsdo 0.4.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 +92 -2
- data/README.md +150 -18
- 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 +3 -1
- 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 +39 -3
- 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 +47 -95
- 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 +15 -1
- data/lib/letsdo/config.rb +89 -8
- 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 +16 -5
- metadata +27 -6
- data/lib/letsdo/backlog_tasks.rb +0 -59
- 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,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module Letsdo
|
|
7
|
+
class SessionRecorder
|
|
8
|
+
# Writes the optional JSON Lines metrics stream for a session recorder:
|
|
9
|
+
# a session_start line on construction, one run_finished line per closed
|
|
10
|
+
# run and a session_stop line at the end. With a nil IO the writer is a
|
|
11
|
+
# no-op, so callers need no nil checks.
|
|
12
|
+
class JsonlWriter
|
|
13
|
+
# @param io [IO, nil] append-only target; nil disables the writer
|
|
14
|
+
# @param name [String] agent name
|
|
15
|
+
# @param handle [String] assignee handle
|
|
16
|
+
# @param wall_clock [Proc] -> ISO8601 UTC timestamp string
|
|
17
|
+
def initialize(io, name:, handle:, wall_clock:)
|
|
18
|
+
@io = io
|
|
19
|
+
@name = name
|
|
20
|
+
@handle = handle
|
|
21
|
+
@wall_clock = wall_clock
|
|
22
|
+
session_start if enabled?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] whether an output target is configured
|
|
26
|
+
def enabled?
|
|
27
|
+
!@io.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Writes the opening session_start line (no-op when disabled).
|
|
31
|
+
#
|
|
32
|
+
# @return [void]
|
|
33
|
+
def session_start
|
|
34
|
+
write(session_start_payload)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param run [SessionRecorder::Run] a closed run record
|
|
38
|
+
# @return [void]
|
|
39
|
+
def run_finished(run)
|
|
40
|
+
write(
|
|
41
|
+
'event' => 'run_finished', 'task' => run.task_id, 'exit' => run.exit_code,
|
|
42
|
+
'outcome' => run.outcome.to_s, 'elapsed_s' => run.elapsed_s,
|
|
43
|
+
'ts' => @wall_clock.call
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @param summary [SessionRecorder::Summary] the final aggregate
|
|
48
|
+
# @return [void]
|
|
49
|
+
def session_stop(summary)
|
|
50
|
+
write(
|
|
51
|
+
'event' => 'session_stop', 'agent' => @name, 'handle' => @handle,
|
|
52
|
+
'ts' => @wall_clock.call, 'done' => summary.done, 'failed' => summary.failed,
|
|
53
|
+
'interrupted' => summary.interrupted, 'left' => summary.left,
|
|
54
|
+
'session_s' => summary.session_s, 'runs_s' => summary.active_s
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def session_start_payload
|
|
61
|
+
{ 'event' => 'session_start', 'agent' => @name, 'handle' => @handle,
|
|
62
|
+
'ts' => @wall_clock.call }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write(payload)
|
|
66
|
+
return unless enabled?
|
|
67
|
+
|
|
68
|
+
@io.puts(JSON.generate(payload))
|
|
69
|
+
@io.flush if @io.respond_to?(:flush)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -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
|