raindown 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/lib/nodes/endpoint_node.rb +3 -2
  3. data/lib/raindown/actions.rb +5 -3
  4. data/lib/raindown/bare_directives.rb +14 -27
  5. data/lib/raindown/blueprints.rb +255 -0
  6. data/lib/raindown/cli.rb +39 -54
  7. data/lib/raindown/config.rb +2 -3
  8. data/lib/raindown/directives.rb +30 -13
  9. data/lib/raindown/document.rb +21 -15
  10. data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
  11. data/lib/raindown/node_blocks.rb +22 -16
  12. data/lib/raindown/node_injector.rb +7 -10
  13. data/lib/raindown/pages.rb +1 -1
  14. data/lib/raindown/pre_renderer.rb +18 -0
  15. data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
  16. data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
  17. data/lib/raindown/tui/blueprints/canvas.rb +58 -0
  18. data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
  19. data/lib/raindown/tui/blueprints/filter.rb +77 -0
  20. data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
  21. data/lib/raindown/tui/blueprints/tracing.rb +129 -0
  22. data/lib/raindown/tui/{feelings/code_box.rb → code_box.rb} +44 -30
  23. data/lib/raindown/tui/endpoints.rb +2 -2
  24. data/lib/raindown/tui/events.rb +3 -1
  25. data/lib/raindown/tui/feelings/bins.rb +5 -5
  26. data/lib/raindown/tui/feelings/{celebration.rb → celebration/celebration.rb} +5 -2
  27. data/lib/raindown/tui/feelings/{scene.rb → celebration/scene.rb} +4 -1
  28. data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
  29. data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
  30. data/lib/raindown/tui/feelings/feelings_log.rb +22 -19
  31. data/lib/raindown/tui/feelings/feelings_renderer.rb +94 -95
  32. data/lib/raindown/tui/feelings/feelings_view.rb +28 -32
  33. data/lib/raindown/tui/feelings/magnifier.rb +68 -0
  34. data/lib/raindown/tui/feelings/motion.rb +93 -0
  35. data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
  36. data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
  37. data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
  38. data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
  39. data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
  40. data/lib/raindown/tui/feelings/weekly_batch.rb +24 -5
  41. data/lib/raindown/tui/layout.rb +25 -18
  42. data/lib/raindown/tui/navigator/commands.rb +32 -1
  43. data/lib/raindown/tui/navigator/mouse.rb +43 -0
  44. data/lib/raindown/tui/navigator/navigator.rb +40 -44
  45. data/lib/raindown/tui/navigator/{search.rb → teleport.rb} +14 -12
  46. data/lib/raindown/tui/navigator/views.rb +2 -3
  47. data/lib/raindown/tui/{workers/project.rb → project.rb} +33 -22
  48. data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +1 -1
  49. data/lib/raindown/tui/renderers/renderer.rb +1 -0
  50. data/lib/raindown/tui/saves.rb +22 -0
  51. data/lib/raindown/tui/{search_overlay.rb → search_box.rb} +27 -14
  52. data/lib/raindown/tui/terminal/input.rb +58 -7
  53. data/lib/raindown/tui/terminal/screen.rb +31 -26
  54. data/lib/raindown/tui/terminal/styles.rb +55 -28
  55. data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
  56. data/lib/raindown/tui/text.rb +35 -12
  57. data/lib/raindown/tui/views/dashboard_view.rb +2 -2
  58. data/lib/raindown/tui/views/endpoint_view.rb +3 -3
  59. data/lib/raindown/tui/views/list_view.rb +1 -1
  60. data/lib/raindown/tui/views/outline_view.rb +5 -4
  61. data/lib/raindown/tui.rb +17 -7
  62. data/lib/raindown/watcher/changes.rb +33 -0
  63. data/lib/raindown/watcher/file_watcher.rb +58 -0
  64. data/lib/raindown/watcher/project.rb +47 -0
  65. data/lib/raindown/watcher/supervisor.rb +111 -0
  66. data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
  67. data/lib/raindown/workers/workers.rb +63 -0
  68. data/lib/raindown.rb +4 -4
  69. data/lib/version.rb +1 -1
  70. metadata +37 -17
  71. data/lib/raindown/project_worker.rb +0 -48
  72. data/lib/raindown/supervisor.rb +0 -160
  73. data/lib/raindown/tui/feelings/waffle.rb +0 -43
  74. data/lib/raindown/tui/workers/workers.rb +0 -53
  75. data/lib/raindown/watcher.rb +0 -57
  76. /data/lib/raindown/tui/feelings/{fireworks.rb → celebration/fireworks.rb} +0 -0
  77. /data/lib/raindown/tui/feelings/{rain.rb → celebration/rain.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad2e1df6e9cbfbac50533a9d6dd7f1d8bedb254aa6277a1889fb380871c52ec1
4
- data.tar.gz: d32bbefdbcae559679033db6b1fc7e1dc4846f6a6356c0944e711cf50699eab0
3
+ metadata.gz: 7683978b726d755dec718ef81848047c736e081504baeec609b5bbfadb1cb72c
4
+ data.tar.gz: 00e4387dd70604a382a7ad02944df5ac48718c5c90d1994c384011821153404c
5
5
  SHA512:
6
- metadata.gz: 0dd6247d9f49558b5dc53b9eae6860d841c34c1e03edbaca61ba02ff59ec59a932d647c7f70eff731b1fe195ece4f3d2e999be2a783e0e9f1b57f97a166a5b25
7
- data.tar.gz: bfc9d3fed994284ae6f0ec94a51e6599a754e1064e648092e595bd80d6224f8f4acc4e258a790de9fe5bbb9e9cfe07ede3d4c6d7bd633c3574ff9f8ec9a3e90a
6
+ metadata.gz: 7e411970cde88fcf026aeea5c2eb1a7a2566ca99ba32c5920e6e53013f3e6b39f42a5449d8ecb36061a4dac5518a82516e21c54595c6d54c9906830f64f0c929
7
+ data.tar.gz: 1a517a2cc89afa59c9deca7b662a415712eff5a81ad1a6caa339c411fb7c4f154c1b95e0456d86c07473519a00624e94f4eeeb2548c9cad3df5690852860dc0c
@@ -6,7 +6,8 @@ require_relative '../raindown/fragments'
6
6
 
7
7
  module Raindown
8
8
  # Holds an endpoint's curl snippet and response, which render as the code blocks they are,
9
- # between Directives markers for its directive and end tag, kept as a fragment.
9
+ # between hidden Directives markers for its directive and end tag, kept as a fragment. A bare endpoint has markers
10
+ # in bold around it too, from BareDirectives.
10
11
  class EndpointNode < Antlers::BranchNode
11
12
  DEF_KEY = :endpoint_def
12
13
  END_KEY = :endpoint_end
@@ -19,7 +20,7 @@ module Raindown
19
20
 
20
21
  def render(**)
21
22
  html = Raindown.commonmark(super)
22
- Fragments.add("#{Directives.marker(@directive)}\n#{html}#{Directives.marker(':endpoint')}")
23
+ Fragments.add("#{Directives.marker(@directive, shown: false)}\n#{html}#{Directives.marker(':endpoint', shown: false)}")
23
24
  end
24
25
 
25
26
  class << self
@@ -20,14 +20,16 @@ module Raindown
20
20
 
21
21
  module_function
22
22
 
23
- # Writes back the whole file, like saving it while it's watched.
24
- def write_back(path)
25
- Rewriter.update(path:, metadata: Pages.for(path))
23
+ # Writes back the whole file, like saving it while it's watched, with bare directives allowed, and endpoints with no
24
+ # response run, when asked, like with --bare and -x.
25
+ def write_back(path, bare: false, execute: false)
26
+ Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
26
27
  end
27
28
 
28
29
  # Runs one endpoint's request and writes its response, replacing any response it had.
29
30
  def execute(path, directive)
30
31
  raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
32
+ raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
31
33
 
32
34
  content = File.read(path, encoding: Encoding::UTF_8)
33
35
  rewritten = EndpointRunner.new(content:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
@@ -1,27 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'code_blocks'
4
+ require_relative 'directives'
4
5
  require_relative 'front_matter'
5
6
 
6
7
  module Raindown
7
- # Raindown files (.rd/.raindown) can write a directive like <{ if: draft }> as a bare if: draft on its own line.
8
- # Markdown files opt in with "raindown: true" in their front matter, or with the --bare flag.
9
- # Nodes like toc: and files: are handled by NodeBlocks, before the rest.
8
+ # Raindown files (.rd/.raindown) can write a content directive, which writes its output back, bare on its own line,
9
+ # like endpoint: or toc:, which shows in bold in the HTML. Markdown files opt in with "raindown: true" in their front
10
+ # matter, or with the --bare flag. Nodes like toc: and files: are handled by NodeBlocks, before the rest.
11
+ # Template directives, like if: and for:, are always in Antlers' delimiters, so they're never bare.
10
12
  #
11
- # Bare directives have no end tag. A bare block directive closes itself after the content it applies to,
12
- # so where a block needs to wrap more than that, write it with delimiters and an end tag instead:
13
- # <{ if: draft }>
13
+ # Bare directives have no end tag. A bare endpoint closes itself after its curl snippet and response, so where it
14
+ # needs more than that, write it in comments with an end tag instead:
15
+ # <!-- endpoint: -->
14
16
  # ...
15
- # <{ :if }>
17
+ # <!-- :endpoint -->
16
18
  module BareDirectives
17
19
  EXTENSIONS = %w[.rd .raindown].freeze
18
20
  # The block directives, and the method that finds the line their end tag goes after.
19
- BLOCK_KEYWORDS = { 'endpoint:' => :after_endpoint, 'for:' => :after_block, 'if:' => :after_block,
20
- 'list:' => :after_block }.freeze
21
+ BLOCK_KEYWORDS = { 'endpoint:' => :after_endpoint }.freeze
21
22
  # Four spaces of indent would make the line a Markdown code block.
22
23
  LINE = /\A( {0,3})((#{BLOCK_KEYWORDS.keys.join('|')})(?:[ \t]+\S.*?)?)[ \t]*$/
23
- # An end tag on its own line, like one a bare block directive above added, ends a block.
24
- END_TAG = /\A {0,3}<\{\s*:[a-z][\w-]*\s*\}>[ \t]*$/
25
24
 
26
25
  module_function
27
26
 
@@ -69,34 +68,22 @@ module Raindown
69
68
  match = lines[index].match(LINE)
70
69
  return index unless match
71
70
 
72
- lines[index] = wrap(lines[index])
71
+ lines[index] = "#{Directives.marker(match[2], shown: true)}\n#{wrap(lines[index])}"
73
72
  close_block(lines, index, indent: match[1], keyword: match[3])
74
73
  index
75
74
  end
76
75
 
76
+ # The end tag after the block, then the end tag in bold, as a bare directive's shown.
77
77
  def close_block(lines, index, indent:, keyword:)
78
78
  last = public_send(BLOCK_KEYWORDS[keyword], lines, index + 1) || index
79
79
  lines[last] = "#{lines[last]}\n" unless lines[last].end_with?("\n")
80
- lines.insert(last + 1, "#{indent}<{ :#{keyword.delete_suffix(':')} }>\n")
80
+ name = keyword.delete_suffix(':')
81
+ lines.insert(last + 1, "#{indent}<{ :#{name} }>\n#{Directives.marker(":#{name}", shown: true)}\n")
81
82
  end
82
83
 
83
84
  # The last line of the curl snippet and response below an endpoint.
84
85
  def after_endpoint(lines, index)
85
86
  CodeBlocks.endpoint(lines, index).last&.fetch(:close)
86
87
  end
87
-
88
- # The last line of the Markdown block below, after any blank lines: up to the next blank line or end tag,
89
- # with fenced code kept whole. Nil when there's nothing below.
90
- def after_block(lines, index)
91
- index = CodeBlocks.skip_blank_lines(lines, index)
92
- last = nil
93
-
94
- while index < lines.length && !lines[index].strip.empty? && !lines[index].match?(END_TAG)
95
- last = CodeBlocks.at(lines, index)&.fetch(:close) || index
96
- index = last + 1
97
- end
98
-
99
- last
100
- end
101
88
  end
102
89
  end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require 'unicode/display_width'
6
+ require_relative 'projects'
7
+
8
+ module Raindown
9
+ # Blueprints from Claude Code's replies, kept in a project's .raindown/blueprints.md, which the terminal UI's Blueprints page
10
+ # shows, newest first. Each is a code block under a heading of when it was saved and its kind:
11
+ # ## 2026-10-30T03:14:07Z · mockup
12
+ #
13
+ # ```mockup
14
+ # ╭──────╮
15
+ # │ Idea │
16
+ # ╰──────╯
17
+ # ```
18
+ # A blueprint is a code block marked with its kind, like "mockup", or one with no language that's drawn, with box-drawing
19
+ # lines or blocks, whose kind is worked out from what it's drawn with when it's saved (see Blueprints.kind).
20
+ module Blueprints
21
+ # A blueprint, saved at a time, or drawn in a comment in the code, in the file at its path, from the project's root,
22
+ # from its line, with the paragraph above it, if there was one, as its description.
23
+ Blueprint = Data.define(:time, :kind, :lines, :path, :line, :description) do
24
+ # Its description and words, without the lines drawn among them, which its filter matches and lists it by.
25
+ def words = [description, *lines].join(' ').gsub(DRAWN, ' ').squeeze(' ').strip
26
+ alias_method :text, :words
27
+
28
+ # When it was saved, to the minute, or where it's drawn in the code, and its kind.
29
+ def where
30
+ return "#{path}:#{line}#{SEPARATOR}#{kind}" unless time
31
+
32
+ "#{time.sub('T', ' ').sub(/:\d\dZ\z/, ' UTC')}#{SEPARATOR}#{kind}"
33
+ end
34
+
35
+ # As it's saved in .raindown/blueprints.md, under when it was saved and its kind, then its description.
36
+ def markdown(saved_at)
37
+ about = "#{description}\n\n" if description
38
+ "## #{saved_at}#{SEPARATOR}#{kind}\n\n#{about}```#{kind}\n#{lines.join("\n")}\n```\n\n"
39
+ end
40
+ end
41
+ # A box drawn in a blueprint, by the rows and columns of its corners, as a terminal shows them, and the text inside it and in
42
+ # its top border, like a card's title.
43
+ Box = Data.define(:top, :left, :bottom, :right, :text) do
44
+ def cover?(row, column) = row.between?(top, bottom) && column.between?(left, right)
45
+ def above?(other) = bottom < other.top
46
+ def below?(other) = top > other.bottom
47
+
48
+ # How far it is from the other box, in rows, then in columns between their middles.
49
+ def distance(other) = [(top - other.top).abs, (left + right - other.left - other.right).abs]
50
+ end
51
+
52
+ FILE = File.join('.raindown', 'blueprints.md')
53
+ # The kinds a code block can be marked with to be a blueprint.
54
+ KINDS = %w[mockup diagram chart table other].freeze
55
+ # What a blueprint's drawn with: lines, like boxes' and arrows', and blocks, like charts' bars.
56
+ LINES = /[─━│┃┌┐└┘├┤┬┴┼╭╮╯╰═║╔╗╚╝╱╲]/
57
+ BLOCKS = /[▁▂▃▄▅▆▇█▀▏▔▕░▒▓]/
58
+ DRAWN = Regexp.union(LINES, BLOCKS)
59
+ BLOCK = /^```(\w*)\n(.*?)^```$/m
60
+ # Arrows that join things, across, which make a blueprint a diagram, like a flow chart's or a sequence diagram's.
61
+ ACROSS = /─▶|◀─|─>|<─|->|<-|[→←↔]/
62
+ # Arrows that join things up or down, when there's a line above or below them.
63
+ UP_DOWN = %w[▼ ▲].freeze
64
+ # The share of a blueprint's characters that are blocks, from which it's a chart.
65
+ CHART = 0.25
66
+ SEPARATOR = ' · '
67
+ # A box's corners, and the lines its left side is drawn with.
68
+ TOP_LEFT = %w[╭ ┌ ╔].freeze
69
+ TOP_RIGHT = %w[╮ ┐ ╗].freeze
70
+ BOTTOM_LEFT = %w[╰ └ ╚].freeze
71
+ BOTTOM_RIGHT = %w[╯ ┘ ╝].freeze
72
+ SIDE = %w[│ ├ ┤ ┼ ║ ╟ ╢].freeze
73
+
74
+ module_function
75
+
76
+ # The project's blueprints, newest first, each with the line its drawing starts on, counting from 1, like an editor.
77
+ def read(root)
78
+ path = File.join(root, FILE)
79
+ return [] unless File.exist?(path)
80
+
81
+ lines = File.readlines(path, chomp: true)
82
+ starts = lines.each_index.select { lines[it].start_with?('## ') }
83
+ starts.filter_map { |start| blueprint(lines, start) }.reverse
84
+ end
85
+
86
+ # Saves the blueprints in Claude Code's last reply, from its Stop hook's JSON, to the project it's working in, if any,
87
+ # each with its kind.
88
+ def save(hook)
89
+ root = Projects.root(hook['cwd'].to_s) || return
90
+ drawn = drawn(last_reply(hook['transcript_path'].to_s))
91
+ return if drawn.empty?
92
+
93
+ time = Time.now.utc.iso8601
94
+ File.open(File.join(root, FILE), 'a') { |file| drawn.each { file.write(it.markdown(time)) } }
95
+ end
96
+
97
+ # The text's code blocks that are blueprints, each with its kind and the paragraph above it.
98
+ def drawn(text)
99
+ text.to_enum(:scan, BLOCK).filter_map do
100
+ language, body = Regexp.last_match.captures
101
+ lines = body.lines(chomp: true)
102
+ next unless whole?(lines)
103
+
104
+ kind = drawn_kind(language, lines) || next
105
+ Blueprint.new(time: nil, kind:, lines:, path: FILE, line: nil, description: description(text[0...Regexp.last_match.begin(0)]))
106
+ end
107
+ end
108
+
109
+ # The kind the block's marked with, or, without a language, the kind it's drawn as, or nil when it isn't drawn.
110
+ def drawn_kind(language, lines)
111
+ return language if KINDS.include?(language)
112
+
113
+ kind(lines) if language.empty? && lines.join.match?(DRAWN)
114
+ end
115
+
116
+ # The paragraph at the end of the text before a block, joined into a line, unless it's another block, or a list.
117
+ def description(before)
118
+ paragraph = before.split(/\n[ \t]*\n/).last.to_s.strip
119
+ return if paragraph.empty? || paragraph.include?('```') || paragraph.match?(/\A(?:[-*+] |\d+\. |#)/)
120
+
121
+ paragraph.split("\n").map(&:strip).join(' ')
122
+ end
123
+
124
+ # Whether a drawing's more than a line, and closes each box its first line opens, unlike a piece of one quoted, and
125
+ # isn't art.
126
+ def whole?(lines)
127
+ return false if lines.length < 2 || art?(lines)
128
+
129
+ boxes(lines).count { it.top.zero? } == columns(lines.first(1)).first.count { TOP_LEFT.include?(it) }
130
+ end
131
+
132
+ # What a blueprint is, from what it's drawn with: a diagram when arrows join its parts, a chart when it's mostly blocks,
133
+ # a table when its lines cross, a mockup when it's otherwise boxes and lines, and other when it's none of those.
134
+ def kind(lines)
135
+ text = lines.join("\n")
136
+ return 'diagram' if text.match?(ACROSS) || joined_up_or_down?(lines)
137
+ return 'chart' if text.scan(BLOCKS).length >= CHART * text.delete(" \n").length
138
+ return 'table' if text.include?('┼')
139
+ return 'mockup' if text.match?(DRAWN)
140
+
141
+ 'other'
142
+ end
143
+
144
+ # Whether an up or down arrow has a line above or below it, joining it to what it points from.
145
+ def joined_up_or_down?(lines)
146
+ lines.each_with_index.any? do |line, index|
147
+ line.each_char.with_index.any? do |char, column|
148
+ UP_DOWN.include?(char) && [lines[index - 1], lines[index + 1]].any? { it&.[](column) == '│' }
149
+ end
150
+ end
151
+ end
152
+
153
+ # Whether it's art: drawn in blocks and words but no lines, like a stat's number in block digits, or a waffle, or
154
+ # with no words or numbers at all, like rain or balloons.
155
+ def art?(lines)
156
+ text = lines.join
157
+ !text.match?(/[[:alnum:]]/) || (text.match?(BLOCKS) && !text.match?(LINES))
158
+ end
159
+
160
+ # The boxes drawn in the blueprint's lines, in reading order, each a top-left corner, the next top-right corner along its
161
+ # row, and the bottom corners below them, its left side drawn down to the bottom.
162
+ def boxes(lines)
163
+ grid = columns(lines)
164
+ grid.each_with_index.flat_map do |row, top|
165
+ row.each_index.filter_map { |left| box(grid, top, left) if TOP_LEFT.include?(row[left]) }
166
+ end
167
+ end
168
+
169
+ def box(grid, top, left)
170
+ corner = (left + 1...grid[top].length).find { TOP_RIGHT.include?(grid[top][it]) } || return
171
+ bottom = (top + 1...grid.length).find { !SIDE.include?(grid[it][left]) } || return
172
+ right = bottom_right(grid[bottom], left, corner) || return
173
+
174
+ Box.new(top:, left:, bottom:, right:, text: box_text(grid[top...bottom], left, right))
175
+ end
176
+
177
+ # The lines' characters by the columns a terminal shows them in, with an empty string in the second column of a wide
178
+ # one, like an emoji, so what's drawn below it lines up.
179
+ def columns(lines)
180
+ lines.map { |line| line.grapheme_clusters.flat_map { [it, *Array.new([Unicode::DisplayWidth.of(it) - 1, 0].max, '')] } }
181
+ end
182
+
183
+ # The column of the box's bottom right corner, below its top's, or a column either side, as Claude Code sometimes draws
184
+ # a box's top a column short or long, or nil when the row isn't its bottom.
185
+ def bottom_right(row, left, corner) = [corner, corner + 1, corner - 1].find { closed?(row, left, it) }
186
+
187
+ # The grid with each box's right side straight down from its bottom right corner, as Claude Code sometimes draws a
188
+ # box's top, or a row of it, a column short or long, leaving the text inside it as it is.
189
+ def tidied(grid, boxes)
190
+ grid = grid.map(&:dup)
191
+ boxes.each do |box|
192
+ straighten(grid[box.top], box.right, TOP_RIGHT, '─')
193
+ (box.top + 1...box.bottom).each { straighten(grid[it], box.right, SIDE, ' ') }
194
+ end
195
+ grid
196
+ end
197
+
198
+ # Moves the row's edge, one of the edges, from a column either side of the right one, filling where it was, when
199
+ # what's at the right column is blank.
200
+ def straighten(row, right, edges, fill)
201
+ return if edges.include?(row[right]) || !row[right].to_s.strip.empty?
202
+
203
+ off = [right - 1, right + 1].find { edges.include?(row[it]) } || return
204
+ row[right] = row[off]
205
+ row[off] = fill
206
+ end
207
+
208
+ # Whether the row is a box's bottom, from its left corner to its right.
209
+ def closed?(row, left, right) = BOTTOM_LEFT.include?(row[left]) && BOTTOM_RIGHT.include?(row[right])
210
+
211
+ # The text across the rows, between the box's sides, without the lines drawn among it.
212
+ def box_text(rows, left, right) = rows.map { it[(left + 1)...right].to_a.join }.join(' ').gsub(DRAWN, ' ').squeeze(' ').strip
213
+
214
+ # The text of the assistant's messages since the last thing the user typed, in a transcript of JSON lines.
215
+ def last_reply(path)
216
+ return '' unless File.exist?(path)
217
+
218
+ entries = File.readlines(path).filter_map { entry(it) }
219
+ asked = entries.rindex { it['type'] == 'user' && typed?(it.dig('message', 'content')) } || -1
220
+ entries[(asked + 1)..].select { it['type'] == 'assistant' }.flat_map { texts(it.dig('message', 'content')) }.join("\n")
221
+ end
222
+
223
+ # A line of the transcript, or nil for one that isn't JSON, like one still being written.
224
+ def entry(line)
225
+ JSON.parse(line)
226
+ rescue JSON::ParserError
227
+ nil
228
+ end
229
+
230
+ # A user's message is typed when it's text, rather than a tool's result.
231
+ def typed?(content) = content.is_a?(String) || Array(content).any? { it['type'] == 'text' }
232
+
233
+ def texts(content) = Array(content).filter_map { it['text'] if it.is_a?(Hash) && it['type'] == 'text' }
234
+
235
+ def blueprint(lines, start)
236
+ opening = (start + 1...lines.length).find { lines[it].start_with?('```') } || return
237
+ closing = (opening + 1...lines.length).find { lines[it] == '```' } || return
238
+ Blueprint.new(**heading(lines[start]), lines: lines[(opening + 1)...closing], path: FILE, line: opening + 2,
239
+ description: saved_description(lines[(start + 1)...opening]))
240
+ end
241
+
242
+ # When a blueprint was saved and its kind, from its heading, which is other when it's none of the kinds.
243
+ def heading(line)
244
+ time, kind = line.delete_prefix('## ').split(SEPARATOR, 2)
245
+ kind = 'other' unless KINDS.include?(kind)
246
+ { time:, kind: }
247
+ end
248
+
249
+ # The lines between a blueprint's heading and its drawing, joined, or nil when there are none.
250
+ def saved_description(lines)
251
+ description = lines.reject(&:empty?).join(' ')
252
+ description unless description.empty?
253
+ end
254
+ end
255
+ end
data/lib/raindown/cli.rb CHANGED
@@ -4,9 +4,10 @@ require 'fileutils'
4
4
  require 'trees'
5
5
  require_relative '../raindown'
6
6
  require_relative '../version'
7
+ require_relative 'blueprints'
7
8
  require_relative 'rewriter'
8
- require_relative 'supervisor'
9
- require_relative 'watcher'
9
+ require_relative 'watcher/file_watcher'
10
+ require_relative 'watcher/supervisor'
10
11
 
11
12
  module Raindown
12
13
  module CLI
@@ -14,24 +15,28 @@ module Raindown
14
15
 
15
16
  USAGE = <<~TEXT
16
17
  Usage: raindown [PATH] [options]
17
- raindown FILE --html [OUTPUT]
18
+ raindown watch [PATH] [options]
19
+ raindown FILE --html [OUTPUT] [options]
18
20
  raindown init [DIRECTORY]
19
- raindown tui [PATH]
20
21
 
21
- Watches PATH and writes back to its Markdown files as they're saved, like their tables of contents and tables.
22
- PATH is a file, or a directory (default: current directory) whose projects with a .raindown folder are watched.
22
+ Opens PATH (default: current directory) in the terminal UI: browse a project's files and outlines, fold
23
+ directives, run endpoints, open the editor at a line and see a dashboard of the project's charts. While it's open,
24
+ it watches the folder and writes back to its Markdown files as they're saved, like their tables of contents.
23
25
 
24
26
  Commands:
27
+ watch [PATH] Watch PATH without the terminal UI, writing back to its Markdown files as they're saved.
28
+ PATH is a file, or a directory (default: current directory) whose projects with a
29
+ .raindown folder are watched
25
30
  init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
26
- tui [PATH] Browse a project's files and outlines in the terminal, fold directives, run endpoints,
27
- open the editor at a line and see a dashboard of the project's charts
31
+ blueprints Save the blueprints in Claude Code's last reply to .raindown/blueprints.md, for the terminal
32
+ UI's Blueprints page, as a Stop hook that's given the hook's JSON
28
33
 
29
34
  Options:
30
35
  --html [OUTPUT] Render FILE to HTML once, writing to OUTPUT or else printing to stdout
31
36
  --stdin Render Markdown from stdin to HTML once, without formatting FILE, which only needs
32
37
  its extension (to allow bare directives) and can be unsaved. Add --html OUTPUT to write a file
33
38
  --bare Allow bare directives like :toc in Markdown files
34
- -x, --execute Run endpoint curl snippets that have no output yet
39
+ -x, --execute Run endpoint curl snippets that have no output yet, as files are written back
35
40
  -h, --help Show this message
36
41
  -v, --version Show the version
37
42
 
@@ -41,61 +46,40 @@ module Raindown
41
46
  CONFIG = <<~YAML
42
47
  # Raindown project settings. See https://raindown.dev/docs/config
43
48
  #
44
- # Show directives as HTML comments rather than in bold:
45
- # hide_directives: true
46
- #
47
49
  # Leave code blocks unhighlighted, so the browser can highlight them instead:
48
50
  # highlight_code: false
49
51
  YAML
50
52
 
51
- # "--html OUTPUT" is matched as "--html-to OUTPUT" (see run), so a flag after a bare --html isn't read as the OUTPUT.
52
- OPTIONS = { html_to: ['--html-to :output'], html: ['--html'], bare: ['--bare'], execute: ['-x', '--execute'],
53
- stdin: ['--stdin'] }.freeze
54
-
55
- # Trees matches tokens in order, so there's a line for every order and spelling of the options.
56
- OPTION_LINES = (0..OPTIONS.size).flat_map { |count| OPTIONS.keys.permutation(count).to_a }.flat_map do |names|
57
- [[]].product(*names.map { |name| OPTIONS[name] }).map { |flags| [names, flags.flatten] }
53
+ # A path opens the terminal UI, or after "watch" it's watched, and with --html or --stdin it's rendered. The flags
54
+ # are keyword arguments, in any order, like -x for x:, and --html is the OUTPUT when it's given one.
55
+ line(':path') do |path, html: nil, stdin: false, bare: false, execute: false, x: false| # rubocop:disable Metrics/ParameterLists -- each keyword is a flag
56
+ execute { open_path(path, html:, stdin:, bare:, execute: execute || x) }
58
57
  end
59
58
 
60
- OPTION_LINES.each do |names, flags|
61
- # Endpoints run while formatting, which --stdin skips.
62
- next if names.include?(:stdin) && names.include?(:execute)
63
- next if names.include?(:html) && names.include?(:html_to)
64
-
65
- command = if names.include?(:stdin) then :render_stdin
66
- elsif names.intersect?(%i[html html_to]) then :render
67
- else :watch
68
- end
69
- options = { bare: names.include?(:bare), execute: names.include?(:execute) }
70
- pattern = [':path', *flags].join(' ')
71
-
72
- if names.include?(:html_to)
73
- line(pattern) { |path, output| execute { send(command, path, output, **options) } }
74
- else
75
- line(pattern) { |path| execute { send(command, path, **options) } }
76
- end
59
+ line('watch :path') do |path, bare: false, execute: false, x: false|
60
+ execute { watch(path, bare:, execute: execute || x) }
77
61
  end
78
62
 
79
63
  def self.run(argv)
80
64
  return if command(argv)
81
65
 
82
- # Without a path, like "raindown" or "raindown -x", the current directory is watched.
66
+ # Without a path, like "raindown", "raindown -x" or "raindown watch", it's the current directory.
67
+ argv = ['watch', '.', *argv.drop(1)] if argv.first == 'watch' && (argv.size == 1 || argv[1].start_with?('-'))
83
68
  argv = ['.', *argv] if argv.empty? || argv.first.start_with?('-')
84
- argv = html_output(argv)
85
69
 
86
70
  super || abort("Unknown arguments: #{argv.join(' ')}\n\n#{USAGE}")
87
71
  rescue StandardError => e
88
72
  report(e, path: argv.first)
89
73
  end
90
74
 
91
- # The :path line would match help, version, init and tui as the file, so they're checked first.
75
+ # The :path line would match help, version and init as the file, so they're checked first.
92
76
  # Returns true when argv was one of them.
93
77
  def self.command(argv)
94
78
  abort(USAGE) if argv.intersect?(%w[-h --help])
95
79
 
96
80
  if argv.intersect?(%w[-v --version]) then puts(VERSION)
97
81
  elsif argv.first == 'init' && argv.size <= 2 then init(argv[1])
98
- elsif argv.first == 'tui' && argv.size <= 2 then tui(argv[1] || '.')
82
+ elsif argv == ['blueprints'] then Blueprints.save(JSON.parse($stdin.read))
99
83
  else return false
100
84
  end
101
85
 
@@ -109,15 +93,6 @@ module Raindown
109
93
  abort("raindown: #{path}: #{error.message}")
110
94
  end
111
95
 
112
- # "--html out.html" names an OUTPUT, while "--html --bare" doesn't, as OUTPUT never starts with "-".
113
- def self.html_output(argv)
114
- index = argv.index('--html') || (return argv)
115
- output = argv[index + 1]
116
- return argv if output.nil? || output.start_with?('-')
117
-
118
- [*argv[0...index], '--html-to', *argv[(index + 1)..]]
119
- end
120
-
121
96
  # Creates the .raindown folder that marks a project's root and holds its config.yml and nodes.
122
97
  def self.init(directory = nil)
123
98
  folder = File.join(directory || Dir.pwd, Nodes::FOLDER)
@@ -130,17 +105,27 @@ module Raindown
130
105
  puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/nodes'
131
106
  end
132
107
 
133
- # The terminal UI loads curses, so it's only required when it starts.
134
- def self.tui(path)
108
+ # The terminal UI loads curses, so it's only required when it starts. Without a terminal, the path's watched instead.
109
+ def self.tui(path, bare: false, execute: false)
135
110
  abort("File not found: #{path}") unless File.exist?(path)
136
111
 
137
112
  require_relative 'tui'
138
- TUI.start(path)
113
+ TUI.start(path, bare:, execute:) || watch(path, bare:, execute:)
139
114
  end
140
115
 
141
116
  class << self
142
117
  private
143
118
 
119
+ # Endpoints run while formatting, which --stdin skips, so -x can't go with it.
120
+ def open_path(path, html:, stdin:, bare:, execute:)
121
+ output = html unless html == true
122
+ abort("-x runs endpoints as the file's formatted, which --stdin doesn't do\n\n#{USAGE}") if stdin && execute
123
+ return render_stdin(path, output, bare:) if stdin
124
+ return render(path, output, bare:, execute:) if html
125
+
126
+ tui(path, bare:, execute:)
127
+ end
128
+
144
129
  def render(path, output = nil, bare: false, execute: false)
145
130
  ensure_exists(path)
146
131
  abort('--html renders a file, not a directory') if File.directory?(path)
@@ -160,10 +145,10 @@ module Raindown
160
145
  # Watching only writes back to the files, while --html and --stdin render HTML.
161
146
  def watch(path, bare: false, execute: false)
162
147
  ensure_exists(path)
163
- return Supervisor.new(directory: path, bare:, execute:).start if File.directory?(path)
148
+ return Watcher::Supervisor.new(directory: path, bare:, execute:).start if File.directory?(path)
164
149
 
165
150
  Nodes.load(path)
166
- Watcher.new(path:, bare:, execute:).start
151
+ Watcher::FileWatcher.new(path:, bare:, execute:).start
167
152
  end
168
153
 
169
154
  def write(output, html)
@@ -4,11 +4,10 @@ require 'yaml'
4
4
 
5
5
  module Raindown
6
6
  # A project's settings, from config.yml in its .raindown folder:
7
- # hide_directives: true # Directives become HTML comments rather than bold text in the HTML.
8
7
  # highlight_code: false # Code blocks aren't highlighted, so the browser can highlight them instead.
9
8
  module Config
10
9
  FILE = 'config.yml'
11
- DEFAULTS = { hide_directives: false, highlight_code: true }.freeze
10
+ DEFAULTS = { highlight_code: true }.freeze
12
11
 
13
12
  module_function
14
13
 
@@ -23,7 +22,7 @@ module Raindown
23
22
 
24
23
  # The settings for rendering a file, like Raindown.render(**Config.render_options(path)).
25
24
  def render_options(path)
26
- self.for(path).slice(:hide_directives, :highlight_code)
25
+ self.for(path).slice(:highlight_code)
27
26
  end
28
27
  end
29
28
  end
@@ -3,27 +3,44 @@
3
3
  require 'cgi/escape'
4
4
 
5
5
  module Raindown
6
- # Content directives, like a node's directive and end tag or an endpoint's, are kept in the HTML:
7
- # in bold by default, or as HTML comments with the hide_directives setting.
8
- # Until then each is a comment marker, which ends the Markdown block before it and starts nothing itself,
9
- # so a node's list stays its own list.
10
- # Template directives, like for: and if:, are always invisible, since they often sit inside tables and lists.
6
+ # How a content directive is written says what it does, and how it shows in the HTML:
7
+ # toc: bare, it writes its output into the file, and shows in bold
8
+ # <!-- toc: --> <!-- :toc --> as comments, it writes its output into the file, and is left out of the HTML
9
+ # <{ toc: }> <{ :toc }> in Antlers' delimiters, it only renders, and is left out of the HTML
10
+ # Template directives, like for: and if:, are always in Antlers' delimiters, and always hidden.
11
+ # Until the HTML's done each is a comment marker, which ends the Markdown block before it and starts nothing itself,
12
+ # so a node's list stays its own list. A hidden one is left out of the HTML altogether.
11
13
  module Directives
12
14
  MARKER = /<!-- raindown:directive (.*?) -->/
15
+ HIDDEN = /<!-- raindown:hidden .*? -->\n?/
16
+ # A directive in an HTML comment, which writes back, or in Antlers' delimiters, which only renders.
17
+ COMMENT = ['<!--', '-->'].freeze
18
+ DELIMITERS = ['<{', '}>'].freeze
13
19
 
14
20
  module_function
15
21
 
16
- # A directive like "files: folder: 'notes'", or a whole line like <{ :files }>, as a marker.
17
- def marker(directive)
18
- text = directive.strip.delete_prefix('<{').delete_suffix('}>').strip.gsub('--', '- -')
22
+ # A directive's line without its delimiters, like "files: folder: 'notes'" for <!-- files: folder: 'notes' -->.
23
+ def text(line)
24
+ line = line.strip
25
+ [COMMENT, DELIMITERS].each do |open, close|
26
+ return line.delete_prefix(open).delete_suffix(close).strip if line.start_with?(open) && line.end_with?(close)
27
+ end
28
+ line
29
+ end
30
+
31
+ # Whether the directive's line is bare, without delimiters, so it shows in bold.
32
+ def bare?(line) = text(line) == line.strip
33
+
34
+ # A directive's line as a marker: in bold when it's shown, like a bare directive, or else hidden.
35
+ def marker(line, shown: bare?(line))
36
+ text = text(line).gsub('--', '- -')
37
+ return "<!-- raindown:hidden #{text} -->" unless shown
38
+
19
39
  "<!-- raindown:directive #{text} -->"
20
40
  end
21
41
 
22
- def render(html, hide: false)
23
- html.gsub(MARKER) do
24
- text = ::Regexp.last_match(1)
25
- hide ? "<!-- #{text} -->" : "<p><strong>#{CGI.escapeHTML(text.to_s)}</strong></p>"
26
- end
42
+ def render(html)
43
+ html.gsub(HIDDEN, '').gsub(MARKER) { "<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s)}</strong></p>" }
27
44
  end
28
45
  end
29
46
  end