lux-hammer 0.3.22 → 0.3.23

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df3eb16fdf398c03d043585e48e4f9792d09b4427f0a0733eefa9725f1f97b0d
4
- data.tar.gz: d36961cbeb0e10d44fa5119629a436aeed2d16fa384fbc0256dfb9cf1f9893ec
3
+ metadata.gz: 74dd44bca6cf689dcfe0396d90e05e51b0605821314fb6d5b742502662f4e8e8
4
+ data.tar.gz: a84eaa4fcce83f951905bab3419d18adfcce409cd0f9d2f23db910569443a2ce
5
5
  SHA512:
6
- metadata.gz: 8429a1a3a276bca73309522c57c78f3283a78ed327cb0d4354757c3bccd06bf5a0202af888f5ebfa8e66c01850cb4cf927317b8c68970a18b3a9370ff5a70516
7
- data.tar.gz: 68ffb3baf274099eae5e3c3398f77bbda5163c3867980772c737e06d211a8ce26de08702f51e53b8d27c067703eedaac482f5e4f7c2b133e638272ef10b319c6
6
+ metadata.gz: 044a31c525aba212012888f39d938fa37dc2e69e59097fa10f1dda53723b0a267dae2dad0e77887c19ad801a64fcb47e03b8aa95d916b04474d5239628a9b8f0
7
+ data.tar.gz: 3ec9940429c06e4bfb00669184fe3d4f78fff0ed693f857fcd37c682bf89b70a214f3a7e92e24826a1ab5889c62de26f05e0f9b78fe22c1c65b12227d4a18681
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.22
1
+ 0.3.23
@@ -455,11 +455,10 @@ module LlmPlan
455
455
  end
456
456
  end
457
457
 
458
- # Columns at which the clause and the counts start, so rows line up.
459
- # Problem rows share the path column, or they run into their own message.
458
+ # Columns at which the clause and the counts start, so rows line up. Taken
459
+ # from every planned path, so problem rows share the column too.
460
460
  def width
461
- @width ||= (groups.flat_map { |group| group.files.map(&:path) + group.problems.map { |d| d.entry.path } }
462
- .map(&:length).max || 0) + 4
461
+ @width ||= (@outcome.bundle.entries.map { |entry| entry.path.length }.max || 0) + 4
463
462
  end
464
463
 
465
464
  def note_width
@@ -560,18 +559,28 @@ module LlmPlan
560
559
 
561
560
  private
562
561
 
563
- # What a run did, in the past tense.
562
+ # What a run did, in the past tense. Sorted by path, same as the manifest -
563
+ # a landed list is read the same way a planned one is.
564
564
  def result
565
565
  rows = []
566
- @outcome.applied.each { |applied| rows << [" ok #{applied.entry.path} (#{applied.note})", :green] }
567
- @outcome.skipped.each { |entry| rows << [" ok #{entry.path} (already applied)", :gray] }
568
- rows << ['', nil] if rows.any? && @outcome.drifted.any?
566
+ landed = @outcome.applied.map { |applied| [applied.entry, applied.note] } +
567
+ @outcome.skipped.map { |entry| [entry, 'already applied'] }
568
+
569
+ unless landed.empty?
570
+ rows << [paint('Landed', :green), nil]
571
+ landed.sort_by { |entry, _| entry.path }.each { |entry, note| rows << [landed_row(entry, note), nil] }
572
+ rows << ['', nil]
573
+ end
569
574
 
570
575
  @outcome.drifted.each { |drifted| rows.concat drift_block(drifted) }
571
576
  rows.concat summary
572
577
  rows
573
578
  end
574
579
 
580
+ def landed_row(entry, note)
581
+ " #{paint(entry.path.ljust(@manifest.width), :cyan)}#{paint(note, :gray)}"
582
+ end
583
+
575
584
  # What a run would do, for a human to approve. One line per file, grouped
576
585
  # and sorted, each with its own clause. Never counts of lines touched -
577
586
  # that says nothing about whether the change is the right one.
@@ -17,6 +17,12 @@ require 'shellwords'
17
17
  # so text the program inserts for you (history recall, autocomplete, menu
18
18
  # picks) never shows up - that is the price of working with any program at all.
19
19
  #
20
+ # A line starting with ! is taken over: it never reaches the child at all, it
21
+ # runs in a shell here, and its output is shown in the bar (Bang, Pane). The
22
+ # round trip that saves is the point - `!git status` typed at an agent is a
23
+ # tool call, a permission check and a model turn for something the shell
24
+ # answers in milliseconds, and this way it costs no context either.
25
+ #
20
26
  # Wrapping a program hides it: the child lives on our PTY, in its own session,
21
27
  # so anything outside looking at the terminal sees this process and not the
22
28
  # program. Terminals that watch what is running in a pane - Herdr naming a tab,
@@ -45,6 +51,10 @@ module LlmWrap
45
51
  DRAIN_MAX ||= 262_144
46
52
  MAX_BLOCK ||= 1.5
47
53
 
54
+ # The bar grows to show ! output, and this is what it may never take: the
55
+ # child keeps at least this many rows whatever the output was.
56
+ MIN_CHILD ||= 5
57
+
48
58
  # Introducers of the escape sequences that carry a string payload:
49
59
  # OSC ], DCS P, SOS X, PM ^, APC _. They run to BEL or ST rather than to a
50
60
  # final byte, so both stream parsers here - KeyBuffer on the way in, OutScan
@@ -100,6 +110,87 @@ module LlmWrap
100
110
  end
101
111
  end
102
112
 
113
+ # Drawing shared by everything that puts a row in the bar: the prompt history
114
+ # and the ! output pane.
115
+ module Text
116
+ CYAN = "\e[36m"
117
+ YELLOW = "\e[33m"
118
+ DIM = "\e[2m"
119
+ GRAY = "\e[38;5;245m"
120
+ REV = "\e[7m"
121
+ RESET = "\e[0m"
122
+
123
+ # Dashed, and deliberately not the solid U+2500 "─" it wants to be.
124
+ #
125
+ # A pane watcher works out where a program's own furniture is by looking for
126
+ # the last solid rule on the screen: Herdr hangs its "the prompt box is
127
+ # here" and "the permission dialog is here" regions off it. Our bar is below
128
+ # everything, so a solid rule down here becomes the last one and those
129
+ # regions land on the prompt history instead - detection then sees no prompt
130
+ # and no dialog, and the pane reads as neither idle nor blocked. Any glyph
131
+ # that is not box-drawing keeps the bar out of that reckoning; a dashed rule
132
+ # is also a fair signal that this row is not part of the program above.
133
+ RULE ||= '┄'
134
+
135
+ # Roughly the double-width ranges of UEAW - enough to keep CJK and emoji
136
+ # from overflowing the bar without pulling in a character-width gem.
137
+ WIDE ||= [
138
+ 0x1100..0x115f, 0x2e80..0x303e, 0x3041..0x33ff, 0x3400..0x4dbf,
139
+ 0x4e00..0x9fff, 0xa000..0xa4cf, 0xac00..0xd7a3, 0xf900..0xfaff,
140
+ 0xfe30..0xfe6f, 0xff00..0xff60, 0xffe0..0xffe6,
141
+ 0x1f300..0x1f64f, 0x1f900..0x1f9ff, 0x20000..0x2fffd
142
+ ].freeze
143
+ ZERO ||= [0xfe00..0xfe0f, 0x200b..0x200f].freeze
144
+
145
+ private
146
+
147
+ # A full-width rule with the label centred in it, marking off the bar from
148
+ # whatever the wrapped program is drawing above it.
149
+ def rule(cols, label)
150
+ return '' if cols <= 0
151
+
152
+ label = " #{label} "
153
+ pad = cols - width(label)
154
+ return "#{GRAY}#{RULE * cols}#{RESET}" if pad < 2
155
+
156
+ left = pad / 2
157
+ "#{GRAY}#{RULE * left}#{label}#{RULE * (pad - left)}#{RESET}"
158
+ end
159
+
160
+ def dim(text)
161
+ "#{DIM}#{text}#{RESET}"
162
+ end
163
+
164
+ def clip(text, cells)
165
+ return '' if cells <= 0
166
+ return text if width(text) <= cells
167
+
168
+ out = +''
169
+ used = 0
170
+ text.each_char do |ch|
171
+ w = char_width(ch)
172
+ break if used + w > cells - 1
173
+
174
+ out << ch
175
+ used += w
176
+ end
177
+ out << '…'
178
+ end
179
+
180
+ def width(text)
181
+ text.each_char.sum { |ch| char_width(ch) }
182
+ end
183
+
184
+ def char_width(char)
185
+ cp = char.ord
186
+ return 0 if cp == 0x200d || ZERO.any? { |r| r.cover?(cp) }
187
+
188
+ WIDE.any? { |r| r.cover?(cp) } ? 2 : 1
189
+ rescue RangeError
190
+ 1
191
+ end
192
+ end
193
+
103
194
  # Taking the child's name costs us our own command line, and that is the only
104
195
  # record of how the pane was started - Herdr's clone-tab reads it back off the
105
196
  # foreground process to reopen the same wrapper. So leave it in a file named
@@ -218,6 +309,7 @@ module LlmWrap
218
309
  @origin = origin
219
310
  @bar_rows = keep + 1 # the prompts, plus the rule above them
220
311
  @keys = KeyBuffer.new(keep)
312
+ @pane = nil # the last ! command's output, while one is up
221
313
  @out = OutScan.new
222
314
  @winch = false
223
315
  @dirty = true
@@ -324,8 +416,11 @@ module LlmWrap
324
416
  # child holding a saved cursor across this point would lose it - drawing
325
417
  # only once output has settled makes that vanishingly rare in practice.
326
418
  def draw_bar
419
+ lines = bar_lines
420
+ resize_bar(lines.size)
421
+
327
422
  out = +"\e7\e[r"
328
- @keys.lines(@cols).each_with_index do |line, i|
423
+ lines.first(@bar_rows).each_with_index do |line, i|
329
424
  out << "\e[#{child_rows + 1 + i};1H\e[K" << line
330
425
  end
331
426
  out << "\e[1;#{child_rows}r\e8"
@@ -335,6 +430,37 @@ module LlmWrap
335
430
  @drawn_at = now
336
431
  end
337
432
 
433
+ # The prompt history, or the last ! command's output while one is up. Both
434
+ # are painted the same way; only the number of rows differs.
435
+ def bar_lines
436
+ @pane ? @pane.lines(@cols, max_bar) : @keys.lines(@cols)
437
+ end
438
+
439
+ def max_bar
440
+ [@rows - MIN_CHILD, 2].max
441
+ end
442
+
443
+ # A taller bar moves the line the child ends on, so the scroll region and
444
+ # the child's own idea of the screen have to move with it. Rows handed back
445
+ # still have our text on them and the child has no reason to touch them
446
+ # until it repaints, so clear them on the way out.
447
+ def resize_bar(rows)
448
+ rows = rows.clamp(1, max_bar)
449
+ return if rows == @bar_rows
450
+
451
+ back = @bar_rows - rows # positive when the bar shrinks
452
+ @bar_rows = rows
453
+
454
+ if back.positive?
455
+ out = +"\e7\e[r"
456
+ (child_rows - back + 1..child_rows).each { |row| out << "\e[#{row};1H\e[K" }
457
+ emit out << "\e8"
458
+ end
459
+
460
+ set_region
461
+ resize_child # the kernel SIGWINCHes the child, which repaints itself
462
+ end
463
+
338
464
  def emit(str)
339
465
  $stdout.write(str)
340
466
  end
@@ -366,10 +492,27 @@ module LlmWrap
366
492
  ready[0].each do |io|
367
493
  if io.equal?($stdin)
368
494
  data = slurp($stdin) or return
369
- @pty_in.write(data)
370
495
  taking = capture?
371
- @dirty = true if taking && @keys.feed(data)
496
+
497
+ # KeyBuffer decides what the child is allowed to see: everything,
498
+ # unless a ! line is being typed, which is ours from the ! to the
499
+ # Enter. Nothing is captured at all while the child is asking for a
500
+ # password, and then it gets the bytes untouched.
501
+ if taking
502
+ @dirty = true if @keys.feed(data)
503
+ @pty_in.write(@keys.pass) unless @keys.pass.empty?
504
+ else
505
+ @pty_in.write(data)
506
+ end
507
+
372
508
  trace_in(data, taking)
509
+
510
+ # After the trace, so the log shows the line that ran before its
511
+ # output arrives, and after the write, so nothing waits on a shell.
512
+ if taking
513
+ @keys.bangs.each { |cmd| run_bang(cmd) }
514
+ close_pane if @keys.typing?
515
+ end
373
516
  else
374
517
  data = drain(@pty_out) or return
375
518
  $stdout.write(data)
@@ -389,6 +532,35 @@ module LlmWrap
389
532
  nil
390
533
  end
391
534
 
535
+ # A ! line never went to the child, so this is the whole of it: run it here
536
+ # and put the result in the bar. A bare ! puts the bar back to the prompt
537
+ # history, which is how you close the output without running anything.
538
+ #
539
+ # The pump is stopped while the shell runs, which is why Bang keeps a
540
+ # deadline on it. The command is painted before it runs so a slow one does
541
+ # not look like a hung terminal.
542
+ def run_bang(cmd)
543
+ return close_pane if cmd.empty?
544
+
545
+ @pane = Bang.pending(cmd)
546
+ @dirty = true
547
+ paint
548
+
549
+ @pane = Bang.run(cmd)
550
+ trace('BANG', "#{cmd.inspect} -> exit=#{@pane.status.inspect} #{@pane.note}")
551
+ @dirty = true
552
+ paint
553
+ end
554
+
555
+ # The first keystroke of the next line takes the screen back: the output was
556
+ # for the question you were about to ask, and now you are asking it.
557
+ def close_pane
558
+ return unless @pane
559
+
560
+ @pane = nil
561
+ @dirty = true
562
+ end
563
+
392
564
  def slurp(io)
393
565
  io.readpartial(CHUNK)
394
566
  rescue EOFError, Errno::EIO, IOError
@@ -623,6 +795,195 @@ module LlmWrap
623
795
  end
624
796
  end
625
797
 
798
+ # What a ! command left behind, as rows for the bar.
799
+ #
800
+ # It stays on this side of the wrapper. Nothing is typed into the child, so
801
+ # the output is not in the transcript and not in the model's context - and it
802
+ # is not painted over either, because these rows are outside the child's
803
+ # screen by construction, the same trick that keeps the prompt history safe.
804
+ class Pane
805
+ include Text
806
+
807
+ LABEL ||= 'shell'
808
+
809
+ attr_reader :cmd, :pwd, :out, :status, :note
810
+
811
+ def initialize(cmd, out: [], status: nil, note: nil)
812
+ @cmd = cmd
813
+ @pwd = Dir.pwd
814
+ @out = out
815
+ @status = status
816
+ @note = note
817
+ end
818
+
819
+ # Where it ran, what ran, then what it printed - under a rule saying whose
820
+ # rows these are. `max` is everything the bar is allowed to take.
821
+ #
822
+ # The command keeps the ! you typed rather than wearing a shell's $: it is
823
+ # the same row you were just looking at while typing it, and the bar is a
824
+ # record of what you typed.
825
+ def lines(cols, max)
826
+ head = [dim(clip(tilde(@pwd), cols)),
827
+ "#{YELLOW}!#{RESET} #{clip(@cmd, cols - 2)}"]
828
+ body = @out.empty? ? [dim('(no output)')] : @out.map { |line| clip(line, cols) }
829
+
830
+ [rule(cols, label)] + head + fit(body, max - 1 - head.size)
831
+ end
832
+
833
+ private
834
+
835
+ def label
836
+ bits = [LABEL]
837
+ bits << "exit #{@status}" if @status.to_i.positive?
838
+ bits << @note if @note
839
+ bits.join(' - ')
840
+ end
841
+
842
+ # Long output is cut from the bottom. The bar is a few rows and not a pager:
843
+ # what is worth reading here a command says first, and anything else is a
844
+ # pipe into head or a file away.
845
+ def fit(rows, room)
846
+ return rows if rows.size <= room
847
+ return [] if room < 1
848
+
849
+ rows.first(room - 1) << dim("… +#{rows.size - room + 1} more lines")
850
+ end
851
+
852
+ def tilde(path)
853
+ home = Dir.home
854
+ path.start_with?("#{home}/") || path == home ? path.sub(home, '~') : path
855
+ end
856
+ end
857
+
858
+ # Runs a ! line, here, instead of handing it to the agent.
859
+ module Bang
860
+ # A ! line is meant to be quick, and the pump is stopped while it runs: no
861
+ # keys reach the child and the screen does not repaint. Anything slower than
862
+ # this wants to be a real terminal, or the agent's own tools.
863
+ TIMEOUT ||= 10
864
+ MAX_OUT ||= 65_536
865
+ READ ||= 4096
866
+
867
+ # `cd` on its own, with nothing that would need a shell to work out. Every !
868
+ # line gets a fresh shell, so a cd inside one dies with it and the pwd row
869
+ # above the output would be a lie the moment you tried. Do it here instead.
870
+ # This moves us and nothing else: the agent has its own working directory
871
+ # and we are not reaching into it.
872
+ CD ||= /\Acd(?:\s+(?<path>[^|&;<>()`$\n]*\S))?\z/
873
+
874
+ # Yours, non-interactive - so zsh syntax works, but ~/.zshrc is not read and
875
+ # your aliases and functions are not there. Loading it per line would cost
876
+ # more than the round trip this is here to save.
877
+ def self.shell
878
+ sh = ENV['SHELL'].to_s
879
+ sh.empty? ? '/bin/sh' : sh
880
+ end
881
+
882
+ def self.pending(cmd)
883
+ Pane.new(cmd, note: 'running')
884
+ end
885
+
886
+ def self.run(cmd)
887
+ cmd = cmd.strip
888
+ (m = CD.match(cmd)) ? chdir(cmd, m[:path]) : capture(cmd)
889
+ end
890
+
891
+ def self.chdir(cmd, path)
892
+ prev = Dir.pwd
893
+ Dir.chdir(target(path))
894
+ @back = prev
895
+ Pane.new(cmd, status: 0)
896
+ rescue SystemCallError => e
897
+ Pane.new(cmd, out: [e.message], status: 1)
898
+ end
899
+
900
+ def self.target(path)
901
+ case path
902
+ when nil then Dir.home
903
+ when '-' then @back || Dir.pwd
904
+ else File.expand_path(path)
905
+ end
906
+ end
907
+
908
+ def self.capture(cmd)
909
+ out = String.new(encoding: Encoding::BINARY)
910
+ note = nil
911
+ # No keyboard: our stdin is the terminal, in raw mode, and a command that
912
+ # reads it would be taking the keystrokes meant for the agent. Anything
913
+ # wanting input is not a ! line.
914
+ io = IO.popen([shell, '-c', cmd], in: File::NULL, err: %i[child out], pgroup: true)
915
+ till = clock + TIMEOUT
916
+
917
+ loop do
918
+ # Waiting on the pipe and not on the process: a command that forks and
919
+ # leaves the pipe open (`something &`) holds this until the deadline,
920
+ # and then it is stopped like anything else that overstayed.
921
+ left = till - clock
922
+ if left <= 0 || !IO.select([io], nil, nil, left)
923
+ note = "timed out after #{TIMEOUT}s"
924
+ break
925
+ end
926
+
927
+ begin
928
+ out << io.readpartial(READ)
929
+ rescue EOFError
930
+ break
931
+ end
932
+
933
+ note = 'output cut' if out.bytesize >= MAX_OUT
934
+ break if note
935
+ end
936
+
937
+ stop(io) if note
938
+ Pane.new(cmd, out: rows(out), status: close(io), note: note)
939
+ rescue SystemCallError, IOError => e
940
+ Pane.new(cmd, out: [e.message], status: 1, note: 'did not run')
941
+ end
942
+
943
+ # Whatever it is doing, it does not get to outlive the line that started it -
944
+ # the wrapper is not pumping keys while it lives. TERM first, then insist,
945
+ # and the whole group because a shell -c is usually not the only process.
946
+ def self.stop(io)
947
+ Process.kill('TERM', -io.pid)
948
+ sleep 0.05
949
+ Process.kill('KILL', -io.pid)
950
+ rescue SystemCallError
951
+ nil
952
+ end
953
+
954
+ def self.close(io)
955
+ io.close
956
+ $?&.exitstatus
957
+ rescue SystemCallError, IOError
958
+ nil
959
+ end
960
+
961
+ def self.clock
962
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
963
+ end
964
+
965
+ # Output as bar rows: no escape sequences left to fight with our own colour
966
+ # or walk the cursor out of the pane, and tabs where a terminal would put
967
+ # them. The trailing newline every command ends with is not a blank row.
968
+ def self.rows(bytes)
969
+ text = bytes.dup.force_encoding(Encoding::UTF_8).scrub('')
970
+ out = text.gsub(/\e\[[\d;?]*[ -\/]*[@-~]|\e[\]P^_X].*?(?:\a|\e\\)|\e./m, '')
971
+ .split("\n", -1)
972
+ .map { |line| untab(line).gsub(/[\x00-\x1f\x7f]/, '') }
973
+
974
+ out.pop while !out.empty? && out.last.empty?
975
+ out
976
+ end
977
+
978
+ def self.untab(line)
979
+ return line unless line.include?("\t")
980
+
981
+ line.each_char.with_object(+'') do |ch, out|
982
+ ch == "\t" ? out << ' ' * (8 - (out.length % 8)) : out << ch
983
+ end
984
+ end
985
+ end
986
+
626
987
  # Rebuilds the line you are typing from the raw byte stream on its way to the
627
988
  # child, and keeps the last `keep` submitted lines.
628
989
  #
@@ -630,10 +991,13 @@ module LlmWrap
630
991
  # reads can split a UTF-8 character or an escape sequence, so bytes are
631
992
  # accumulated in a binary buffer and only decoded when rendered.
632
993
  class KeyBuffer
994
+ include Text
995
+
633
996
  MAX_LEN ||= 4096
634
997
  NO_PROMPTS ||= '(nothing typed yet)'
635
998
  LABEL ||= 'prompt history'
636
999
 
1000
+ BANG = 0x21
637
1001
  CR = 0x0d
638
1002
  LF = 0x0a
639
1003
  ESC = 0x1b
@@ -658,33 +1022,52 @@ module LlmWrap
658
1022
  # CSI 27 ; <mods> ; <code> ~ (xterm modifyOtherKeys)
659
1023
  XTERM_KEY ||= /\A\e\[27;(\d+);(\d+)~\z/
660
1024
 
661
- # Roughly the double-width ranges of UEAW - enough to keep CJK and emoji
662
- # from overflowing the bar without pulling in a character-width gem.
663
- WIDE ||= [
664
- 0x1100..0x115f, 0x2e80..0x303e, 0x3041..0x33ff, 0x3400..0x4dbf,
665
- 0x4e00..0x9fff, 0xa000..0xa4cf, 0xac00..0xd7a3, 0xf900..0xfaff,
666
- 0xfe30..0xfe6f, 0xff00..0xff60, 0xffe0..0xffe6,
667
- 0x1f300..0x1f64f, 0x1f900..0x1f9ff, 0x20000..0x2fffd
668
- ].freeze
669
- ZERO ||= [0xfe00..0xfe0f, 0x200b..0x200f].freeze
670
-
671
1025
  def initialize(keep)
672
1026
  @keep = keep
673
1027
  @prompts = []
674
1028
  @buf = binary
675
1029
  @esc = nil
676
1030
  @paste = false
1031
+ @bang = false
1032
+ @bangs = []
1033
+ @pass = binary
1034
+ @hold = binary
677
1035
  end
678
1036
 
1037
+ # What the last feed decided, for the pump:
1038
+ #
1039
+ # pass the bytes the child is allowed to see. Everything, until a line
1040
+ # starts with !, and then nothing until that line is done with.
1041
+ # bangs ! lines submitted in it, in order. An empty one is a bare !.
1042
+ # typing? there is a line on the go, so the screen is wanted for it.
1043
+ attr_reader :pass, :bangs
1044
+
1045
+ def typing? = !@buf.empty?
1046
+ def bang? = @bang
1047
+
679
1048
  # The line being typed right now, for the debug trace.
680
1049
  def pending
681
1050
  decode(@buf)
682
1051
  end
683
1052
 
684
- # Returns true when the pinned prompts changed.
1053
+ # Returns true when what the bar shows changed.
685
1054
  def feed(bytes)
686
- changed = false
687
- bytes.each_byte { |b| changed = true if @esc ? escape(b) : plain(b) }
1055
+ @pass = binary
1056
+ @hold = binary
1057
+ @bangs = []
1058
+ changed = false
1059
+
1060
+ bytes.each_byte do |b|
1061
+ @hold << b
1062
+ was = @bang
1063
+ hit = @esc ? escape(b) : plain(b)
1064
+ # A ! line redraws on every keystroke: the bar is the only place it is
1065
+ # shown, so it has to keep up the way an input box would.
1066
+ changed = true if hit || @bang || was
1067
+ release unless @esc
1068
+ end
1069
+
1070
+ release # a sequence still arriving: the child waits no longer than today
688
1071
  changed
689
1072
  end
690
1073
 
@@ -704,39 +1087,29 @@ module LlmWrap
704
1087
  end
705
1088
  end
706
1089
 
707
- [rule(cols)] + rows
1090
+ rows[-1] = typing(cols) if @bang
1091
+ [rule(cols, LABEL)] + rows
708
1092
  end
709
1093
 
710
1094
  private
711
1095
 
712
- CYAN = "\e[36m"
713
- DIM = "\e[2m"
714
- GRAY = "\e[38;5;245m"
715
- RESET = "\e[0m"
716
-
717
- # Dashed, and deliberately not the solid U+2500 "─" it wants to be.
718
- #
719
- # A pane watcher works out where a program's own furniture is by looking for
720
- # the last solid rule on the screen: Herdr hangs its "the prompt box is
721
- # here" and "the permission dialog is here" regions off it. Our bar is below
722
- # everything, so a solid rule down here becomes the last one and those
723
- # regions land on the prompt history instead - detection then sees no prompt
724
- # and no dialog, and the pane reads as neither idle nor blocked. Any glyph
725
- # that is not box-drawing keeps the bar out of that reckoning; a dashed rule
726
- # is also a fair signal that this row is not part of the program above.
727
- RULE ||= '┄'
728
-
729
- # A full-width rule with the label centred in it, marking off the bar from
730
- # whatever the wrapped program is drawing above it.
731
- def rule(cols)
732
- return '' if cols <= 0
1096
+ # A ! line is not going anywhere near the child, so nothing else on screen
1097
+ # is showing it: this row is the only place you can see what you are typing,
1098
+ # cursor included. It takes the newest prompt's row, which comes back the
1099
+ # moment the line is run or dropped.
1100
+ def typing(cols)
1101
+ text = decode(@buf).delete_prefix('!').lstrip
1102
+ "#{YELLOW}!#{RESET} #{clip(text, cols - 4)}#{REV} #{RESET}"
1103
+ end
733
1104
 
734
- label = " #{LABEL} "
735
- pad = cols - width(label)
736
- return "#{GRAY}#{RULE * cols}#{RESET}" if pad < 2
1105
+ # Bytes reach the child unless a ! line is holding them back. @swallow
1106
+ # carries the Enter that ended one, which is ours and not the child's.
1107
+ def release
1108
+ return if @hold.empty?
737
1109
 
738
- left = pad / 2
739
- "#{GRAY}#{RULE * left}#{label}#{RULE * (pad - left)}#{RESET}"
1110
+ @pass << @hold unless @bang || @swallow
1111
+ @swallow = false
1112
+ @hold = binary
740
1113
  end
741
1114
 
742
1115
  def binary
@@ -748,7 +1121,7 @@ module LlmWrap
748
1121
  when ESC then @esc = binary << byte; false
749
1122
  when CR, LF then @paste ? (append("\n"); false) : submit
750
1123
  when DEL, BS then chop_char; false
751
- when CTRL_U, CTRL_C then @buf = binary; false
1124
+ when CTRL_U, CTRL_C then clear_line; false
752
1125
  when CTRL_W then drop_word; false
753
1126
  when TAB then false # completion text is inserted by the app
754
1127
  else
@@ -762,16 +1135,6 @@ module LlmWrap
762
1135
  # bracketed-paste markers.
763
1136
  def escape(byte)
764
1137
  @esc << byte
765
-
766
- if @esc.bytesize == 2
767
- case byte
768
- when 0x5b, 0x4f then return false # CSI / SS3: keep collecting
769
- when *STRING_INTRO then return false # OSC / DCS / APC / PM / SOS
770
- when CR, LF then @esc = nil; append("\n"); return false # Option+Enter
771
- else @esc = nil; return false
772
- end
773
- end
774
-
775
1138
  intro = @esc.getbyte(1)
776
1139
 
777
1140
  # String sequences run to BEL or ST (ESC \) rather than a final byte, and
@@ -779,12 +1142,34 @@ module LlmWrap
779
1142
  # question the program asked - Codex queries the fg/bg colour on startup
780
1143
  # and gets back "\e]10;rgb:cdcd/d6d6/f4f4\e\\" - so the whole thing is
781
1144
  # dropped. Treating them as CSI would spill that payload into the prompt.
782
- if STRING_INTRO.include?(intro)
1145
+ # An ESC in here is the start of that ST, so this goes before the resync.
1146
+ if @esc.bytesize > 2 && STRING_INTRO.include?(intro)
783
1147
  @esc = nil if byte == BEL || (byte == 0x5c && @esc.getbyte(-2) == ESC)
784
1148
  @esc = nil if @esc && @esc.bytesize > 1024 # unterminated: cut losses
785
1149
  return false
786
1150
  end
787
1151
 
1152
+ # An ESC part-way through a sequence never belongs to it: the one being
1153
+ # collected was abandoned and a new one starts here. That is the ordinary
1154
+ # shape of a bare Escape keypress followed by any other key - the terminal
1155
+ # sends "\e" and then the next key's own sequence - and of a read that
1156
+ # split one sequence off the end of another. Resync rather than give up:
1157
+ # dropping the state would leave the rest of the new sequence to arrive as
1158
+ # plain bytes, and "\e[27;1:3u" would land in the prompt as "[27;1:3u".
1159
+ if byte == ESC
1160
+ @esc = binary << byte
1161
+ return false
1162
+ end
1163
+
1164
+ if @esc.bytesize == 2
1165
+ case byte
1166
+ when 0x5b, 0x4f then return false # CSI / SS3: keep collecting
1167
+ when *STRING_INTRO then return false # OSC / DCS / APC / PM / SOS
1168
+ when CR, LF then @esc = nil; append("\n"); return false # Option+Enter
1169
+ else @esc = nil; return false
1170
+ end
1171
+ end
1172
+
788
1173
  if intro == 0x4f # SS3 is always three bytes
789
1174
  @esc = nil
790
1175
  return false
@@ -796,7 +1181,9 @@ module LlmWrap
796
1181
  return csi(seq)
797
1182
  end
798
1183
 
799
- @esc = nil if @esc.bytesize > 32 # runaway guard
1184
+ # Lost the thread of it. Keep swallowing to the final byte rather than
1185
+ # dropping the state, for the same reason as the resync above.
1186
+ @esc = @esc.byteslice(0, 2) if @esc.bytesize > 32
800
1187
  false
801
1188
  end
802
1189
 
@@ -841,7 +1228,7 @@ module LlmWrap
841
1228
  # image in Claude Code, which puts no typed text on the wire at all.
842
1229
  if bits & CTRL != 0
843
1230
  case code
844
- when 117, 99 then @buf = binary # ctrl-u, ctrl-c
1231
+ when 117, 99 then clear_line # ctrl-u, ctrl-c
845
1232
  when 119 then drop_word # ctrl-w
846
1233
  end
847
1234
  return false
@@ -865,13 +1252,48 @@ module LlmWrap
865
1252
  end
866
1253
 
867
1254
  def append_byte(byte)
1255
+ return if byte == BANG && !bang_char
1256
+
868
1257
  @buf << byte if @buf.bytesize < MAX_LEN
869
1258
  end
870
1259
 
871
1260
  def append(str)
1261
+ return if str == '!' && !bang_char
1262
+
872
1263
  @buf << str.b if @buf.bytesize < MAX_LEN
873
1264
  end
874
1265
 
1266
+ # A ! in the first column is ours: from here to Enter nothing reaches the
1267
+ # child and the line runs in a shell instead. Typed twice it is handed back,
1268
+ # so `!!ls` arrives at the agent as `!ls` and its own bash mode is still
1269
+ # there when that is what you wanted. A leading space is the other way out.
1270
+ #
1271
+ # False when the character has been dealt with and must not be appended.
1272
+ def bang_char
1273
+ if @bang && @buf == '!'.b
1274
+ @bang = false # the second !: this one is the child's, and the
1275
+ return false # buffer is already holding a ! for it
1276
+ end
1277
+
1278
+ @bang = true if @buf.empty? && !@paste
1279
+ true
1280
+ end
1281
+
1282
+ def clear_line
1283
+ end_bang
1284
+ @buf = binary
1285
+ end
1286
+
1287
+ # Ending a ! line takes the keystroke that ended it along: a backspace or a
1288
+ # ctrl-c that cancelled one was editing our line and not the child's, and a
1289
+ # ctrl-c in particular would interrupt the agent over nothing.
1290
+ def end_bang
1291
+ return unless @bang
1292
+
1293
+ @bang = false
1294
+ @swallow = true
1295
+ end
1296
+
875
1297
  # Drop one whole character: back over any UTF-8 continuation bytes first.
876
1298
  def chop_char
877
1299
  return if @buf.empty?
@@ -879,6 +1301,7 @@ module LlmWrap
879
1301
  i = @buf.bytesize - 1
880
1302
  i -= 1 while i.positive? && (@buf.getbyte(i) & 0xc0) == 0x80
881
1303
  @buf.slice!(i..)
1304
+ end_bang if @buf.empty?
882
1305
  end
883
1306
 
884
1307
  # Readline's unix-word-rubout: eat trailing whitespace, then the word, and
@@ -886,11 +1309,21 @@ module LlmWrap
886
1309
  def drop_word
887
1310
  @buf.sub!(/\s+\z/, '')
888
1311
  @buf.sub!(/\S+\z/, '')
1312
+ end_bang if @buf.empty?
889
1313
  end
890
1314
 
891
1315
  def submit
892
1316
  text = decode(@buf)
893
- @buf = binary
1317
+ bang = @bang
1318
+ raw = @buf
1319
+ clear_line
1320
+
1321
+ # A ! line is not a prompt: it was never said to the agent, so it has no
1322
+ # business in a history of what was.
1323
+ if bang
1324
+ @bangs << command(raw)
1325
+ return true
1326
+ end
894
1327
 
895
1328
  return false if text.empty? || @prompts.first == text
896
1329
 
@@ -899,6 +1332,12 @@ module LlmWrap
899
1332
  true
900
1333
  end
901
1334
 
1335
+ # The line as a shell should see it, which is not how the bar sees it:
1336
+ # newlines are separators a shell understands and must survive whole.
1337
+ def command(bytes)
1338
+ bytes.dup.force_encoding(Encoding::UTF_8).scrub('').strip.delete_prefix('!').strip
1339
+ end
1340
+
902
1341
  # A bar row is one line, so a multi-line prompt - a continuation with
903
1342
  # Shift/Option+Enter, or a pasted block - is flattened onto it: line breaks
904
1343
  # show as a backslash + space, and clip() caps the result to the terminal
@@ -910,34 +1349,5 @@ module LlmWrap
910
1349
  text.gsub(/[^\S\n]+/, ' ') # runs of spaces/tabs, newlines kept
911
1350
  .gsub(/ ?\n+ ?/) { '\ ' } # block form: no backslash escaping here
912
1351
  end
913
-
914
- def clip(text, cells)
915
- return '' if cells <= 0
916
- return text if width(text) <= cells
917
-
918
- out = +''
919
- used = 0
920
- text.each_char do |ch|
921
- w = char_width(ch)
922
- break if used + w > cells - 1
923
-
924
- out << ch
925
- used += w
926
- end
927
- out << '…'
928
- end
929
-
930
- def width(text)
931
- text.each_char.sum { |ch| char_width(ch) }
932
- end
933
-
934
- def char_width(char)
935
- cp = char.ord
936
- return 0 if cp == 0x200d || ZERO.any? { |r| r.cover?(cp) }
937
-
938
- WIDE.any? { |r| r.cover?(cp) } ? 2 : 1
939
- rescue RangeError
940
- 1
941
- end
942
1352
  end
943
1353
  end
data/recipes/llm.rb CHANGED
@@ -9,6 +9,7 @@ desc <<~TXT
9
9
  memory persistent memory store (backs the Claude Code memory plugin)
10
10
  plan apply a /plan bundle - sha1 checked, drift aware
11
11
  prompt token-prefix prompt expander (UserPromptSubmit hook + CLI)
12
+ todo per-project task queue for agents (./LLM_TODO.local.md)
12
13
 
13
14
  Commands:
14
15
  usage subscription limits and per-model token usage
@@ -100,6 +101,17 @@ task :wrap do
100
101
  inserts for you (history recall, autocomplete, menu picks) will not show up, and
101
102
  nothing is captured while the program has echo off in canonical mode (sudo, ssh).
102
103
 
104
+ A line starting with ! is taken over: nothing reaches the wrapped program, the line
105
+ runs in $SHELL here, and the bar grows to show where it ran, what ran and what it
106
+ printed. That is a shell round trip instead of a tool call, a permission check and a
107
+ model turn - and it costs no context either, since the agent is never told.
108
+
109
+ Output stays up until you type anything at all, and a bare ! clears it too. Long
110
+ output is cut to the rows there are, and a command still running after 10s is killed,
111
+ since the wrapper is stopped while one runs. `cd` is handled here so that it sticks,
112
+ and moves nothing but the wrapper. Type !! to send a literal ! to the program instead,
113
+ which is how you reach Claude Code's own bash mode; a leading space does the same.
114
+
103
115
  Keys are decoded from plain bytes, the kitty keyboard protocol and xterm
104
116
  modifyOtherKeys, since a program can ask the terminal for any of the three (Claude
105
117
  Code turns on the last two). Mouse reports, focus events, arrows and Ctrl-<key> are
@@ -321,6 +333,287 @@ namespace :memory do
321
333
  end
322
334
  end
323
335
 
336
+ TODO_GUIDE ||= <<~TXT
337
+ Per-project task queue in ./LLM_TODO.local.md - workable by humans and any agent.
338
+
339
+ Human usage:
340
+ llm todo:add "fix login redirect" queue a task (pipe stdin for bulk / multi-line)
341
+ llm todo:list tasks by id, counts, format warnings
342
+ llm todo:pop + llm todo:done take / finish a single task by hand
343
+
344
+ Edit the file freely: only the `# todo`, `# doing` and `# done` sections are
345
+ managed. `# plan`, `# idea` and any prose stay untouched. A task starts with
346
+ `*` at the line start and runs until the next `*` or header; when finished it
347
+ moves to `# done` verbatim.
348
+
349
+ LLM usage - one of these lines is the whole prompt:
350
+ "Run `llm todo go` and follow its output." work through every task
351
+ "Run `llm todo pop`, do the task, run `llm todo done`." do a single task
352
+
353
+ Task text is printed raw on stdout. `todo go` repeats the protocol after each
354
+ task and says when the queue is empty; `todo pop` re-prints the task in
355
+ progress, so an interrupted agent resumes instead of skipping ahead.
356
+ TXT
357
+
358
+ task :todo do
359
+ desc <<~D
360
+ How the todo queue works, for humans and LLMs.
361
+
362
+ #{TODO_GUIDE}
363
+ D
364
+ example 'todo'
365
+
366
+ proc do
367
+ say TODO_GUIDE
368
+ say ''
369
+ self.class.print_help 'todo:'
370
+ end
371
+ end
372
+
373
+ namespace :todo do
374
+ # Same story as :memory - helpers must live inside the namespace block.
375
+ private
376
+
377
+ TODO_FILE ||= 'LLM_TODO.local.md'
378
+ TODO_SECTIONS ||= { todo: 'todo', doing: 'doing', done: 'done' }.freeze
379
+
380
+ def todo_path
381
+ File.join(Dir.pwd, TODO_FILE)
382
+ end
383
+
384
+ # Exact title match on purpose - "# done ideas" must stay an unmanaged section.
385
+ def todo_section_key(title)
386
+ case title.strip.downcase
387
+ when 'todo', 'pending' then :todo
388
+ when 'doing', 'in progress', 'in-progress' then :doing
389
+ when 'done', 'completed' then :done
390
+ end
391
+ end
392
+
393
+ # The tool manages only the `# todo`, `# doing` and `# done` sections (any
394
+ # heading level). Inside them a task starts at a `*` or `-` bullet in the
395
+ # first column and runs until the next bullet or header, so tasks can span
396
+ # multiple lines and move between sections verbatim. Every other section
397
+ # (`# plan`, `# idea`, ...) and any preamble is kept as-is and never touched.
398
+ # Returns [tasks, nodes]; nodes reproduce the document layout for todo_save.
399
+ def todo_parse
400
+ tasks = { todo: [], doing: [], done: [] }
401
+ nodes = []
402
+ return [tasks, nodes] unless File.file?(todo_path)
403
+
404
+ section = nil # managed section key, nil while inside a raw chunk
405
+ current = nil # lines of the task being collected
406
+
407
+ flush_task = lambda do
408
+ tasks[section] << current.join("\n").rstrip if current
409
+ current = nil
410
+ end
411
+
412
+ File.foreach(todo_path) do |raw|
413
+ line = raw.chomp
414
+ if (m = line.match(/^#+\s*(\S.*?)\s*$/))
415
+ flush_task.call
416
+ section = todo_section_key(m[1])
417
+ if section
418
+ nodes << [:section, section] unless nodes.include?([:section, section])
419
+ else
420
+ nodes << [:raw, [line]]
421
+ end
422
+ elsif section
423
+ if line =~ /^[-*]\s+(.*)$/
424
+ flush_task.call
425
+ current = [$1.strip]
426
+ elsif current
427
+ current << line.rstrip
428
+ end
429
+ else
430
+ nodes << [:raw, []] unless nodes.last && nodes.last[0] == :raw
431
+ nodes.last[1] << line.rstrip
432
+ end
433
+ end
434
+ flush_task.call
435
+ [tasks, nodes]
436
+ end
437
+
438
+ # Rewrite: managed sections in canonical form, raw chunks verbatim, original
439
+ # order kept. Managed sections missing from the file are appended at the end.
440
+ def todo_save(tasks, nodes)
441
+ order = nodes + (TODO_SECTIONS.keys - nodes.map { |t, v| v if t == :section }).map { |k| [:section, k] }
442
+
443
+ File.open(todo_path, 'w') do |io|
444
+ order.each_with_index do |(type, value), i|
445
+ io.puts unless i.zero?
446
+ if type == :section
447
+ io.puts "# #{TODO_SECTIONS[value]}"
448
+ tasks[value].each do |text|
449
+ io.puts
450
+ io.puts "* #{text}"
451
+ end
452
+ else
453
+ lines = value.dup
454
+ lines.pop while lines.any? && lines.last.empty?
455
+ lines.each { |l| io.puts l }
456
+ end
457
+ end
458
+ end
459
+ end
460
+
461
+ # One-line label for confirmations - multi-line tasks show their first line.
462
+ def todo_label(text)
463
+ first, rest = text.split("\n", 2)
464
+ rest ? "#{first} ..." : first
465
+ end
466
+
467
+ # Format sanity check for hand-edited files. Returns warning strings.
468
+ def todo_lint(tasks)
469
+ warns = []
470
+ seen = Hash.new(0)
471
+ section = :preamble
472
+ File.foreach(todo_path) do |raw|
473
+ line = raw.chomp
474
+ if (m = line.match(/^#+\s*(\S.*?)\s*$/))
475
+ key = todo_section_key(m[1])
476
+ section = key || :other
477
+ seen[key] += 1 if key
478
+ elsif section == :preamble && line =~ /^[-*]\s/
479
+ warns << "ignored bullet before any section header: #{line}"
480
+ end
481
+ end
482
+ TODO_SECTIONS.each_key do |key|
483
+ warns << "duplicate '# #{key}' sections - their tasks are merged" if seen[key] > 1
484
+ warns << "missing '# #{key}' section - will be added on next write" if seen[key].zero?
485
+ end
486
+ warns << "#{tasks[:doing].length} tasks in doing - expected at most 1" if tasks[:doing].length > 1
487
+ warns
488
+ end
489
+
490
+ task :add do
491
+ desc <<~DESC
492
+ Add task(s) to ./#{TODO_FILE} (created on first add).
493
+
494
+ Text comes from the argument (one task), or from stdin - there a `*` at
495
+ the start of a line begins a new task and following lines belong to it,
496
+ so multi-line tasks and whole lists can be piped in at once.
497
+ DESC
498
+ example 'todo add "migrate the user model to Sequel"'
499
+ example 'cat tasks.md | llm todo add'
500
+ opt :text, desc: 'task text (or pipe tasks on stdin)'
501
+
502
+ proc do |opts|
503
+ # unquoted multi-word input lands in :text + :args - join it back
504
+ arg = [opts[:text], *opts[:args]].compact.join(' ').strip
505
+ if arg.empty?
506
+ new_tasks = []
507
+ opts[:stdin].to_s.split("\n").each do |line|
508
+ if line =~ /^[-*]\s+(.*)$/
509
+ new_tasks << [$1.strip]
510
+ elsif new_tasks.any?
511
+ new_tasks.last << line.rstrip
512
+ elsif line.strip != ''
513
+ new_tasks << [line.rstrip]
514
+ end
515
+ end
516
+ new_tasks = new_tasks.map { |lines| lines.join("\n").rstrip }.reject(&:empty?)
517
+ else
518
+ new_tasks = [arg]
519
+ end
520
+ error 'usage: llm todo add <text> (or pipe tasks on stdin)' if new_tasks.empty?
521
+
522
+ tasks, nodes = todo_parse
523
+ tasks[:todo].concat new_tasks
524
+ todo_save tasks, nodes
525
+ new_tasks.each { |t| say "added: #{todo_label(t)}", :green }
526
+ end
527
+ end
528
+
529
+ task :pop do
530
+ desc <<~DESC
531
+ Print the current task and stop there - one task, no loop.
532
+
533
+ Moves the first todo task to doing if nothing is doing yet. Prints only
534
+ the raw task text (agent-friendly stdout). Re-running without `todo done`
535
+ prints the same task again, so an interrupted agent resumes instead of
536
+ starting the next one. Exits 1 when the list is empty.
537
+ DESC
538
+ example 'todo pop'
539
+
540
+ proc do
541
+ tasks, nodes = todo_parse
542
+ if tasks[:doing].empty?
543
+ error 'todo list is empty' if tasks[:todo].empty?
544
+ tasks[:doing] << tasks[:todo].shift
545
+ todo_save tasks, nodes
546
+ end
547
+ puts tasks[:doing].first
548
+ end
549
+ end
550
+
551
+ task :go do
552
+ desc <<~DESC
553
+ Agent entry point: work through the whole todo list.
554
+
555
+ Prints the current task (pop) followed by the loop protocol, so telling
556
+ any agent "run llm todo go" is enough to drain the queue. When the list
557
+ is empty it says so and tells the agent to stop and summarize.
558
+ DESC
559
+ example 'todo go'
560
+
561
+ proc do
562
+ tasks, nodes = todo_parse
563
+ if tasks[:doing].empty? && tasks[:todo].empty?
564
+ say 'All tasks done - stop the loop and summarize what was done.'
565
+ next
566
+ end
567
+ if tasks[:doing].empty?
568
+ tasks[:doing] << tasks[:todo].shift
569
+ todo_save tasks, nodes
570
+ end
571
+ say "TASK: #{tasks[:doing].first}"
572
+ say ''
573
+ say 'Do this task fully and verify it. Then run `llm todo done`, and `llm todo go` for the next one.'
574
+ end
575
+ end
576
+
577
+ task :done do
578
+ desc 'Mark the in-progress task as done'
579
+ example 'todo done'
580
+
581
+ proc do
582
+ tasks, nodes = todo_parse
583
+ error 'no task in progress (run: llm todo pop)' if tasks[:doing].empty?
584
+ text = tasks[:doing].shift
585
+ tasks[:done] << text
586
+ todo_save tasks, nodes
587
+ say "done: #{todo_label(text)}", :green
588
+ end
589
+ end
590
+
591
+ task :list do
592
+ alt :inspect
593
+ desc 'Inspect the todo file: tasks by id, counts, format validity warnings'
594
+ example 'todo list'
595
+
596
+ proc do
597
+ error "no #{TODO_FILE} in #{Dir.pwd} (run: llm todo add <text>)" unless File.file?(todo_path)
598
+ tasks, = todo_parse
599
+ id = 0
600
+ TODO_SECTIONS.each do |key, title|
601
+ say "#{title}:", :cyan
602
+ color = { todo: nil, doing: :yellow, done: :gray }[key]
603
+ tasks[key].each do |text|
604
+ first, *rest = text.split("\n")
605
+ say "#{(id += 1).to_s.rjust(3)}. #{first}", color
606
+ rest.each { |l| say " #{l}", color }
607
+ end
608
+ say ' (none)', :gray if tasks[key].empty?
609
+ end
610
+ say ''
611
+ say "#{tasks[:todo].length} todo, #{tasks[:doing].length} doing, #{tasks[:done].length} done"
612
+ todo_lint(tasks).each { |w| say "warning: #{w}", :yellow }
613
+ end
614
+ end
615
+ end
616
+
324
617
  namespace :prompt do
325
618
  TOKEN_PATTERN ||= /[a-z0-9_-]+/.freeze
326
619
  TOKEN_LINE_RE ||= /\A(?:\s*:[a-z0-9_-]+)+\s*\z/.freeze
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lux-hammer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.22
4
+ version: 0.3.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dino Reic
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-09 00:00:00.000000000 Z
11
+ date: 2026-08-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest