openclacky 0.6.0 → 0.6.2

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +39 -88
  4. data/homebrew/README.md +96 -0
  5. data/homebrew/openclacky.rb +24 -0
  6. data/lib/clacky/agent.rb +139 -67
  7. data/lib/clacky/cli.rb +105 -6
  8. data/lib/clacky/tools/file_reader.rb +135 -2
  9. data/lib/clacky/tools/glob.rb +2 -2
  10. data/lib/clacky/tools/grep.rb +2 -2
  11. data/lib/clacky/tools/run_project.rb +5 -5
  12. data/lib/clacky/tools/safe_shell.rb +140 -17
  13. data/lib/clacky/tools/shell.rb +69 -2
  14. data/lib/clacky/tools/todo_manager.rb +50 -3
  15. data/lib/clacky/tools/trash_manager.rb +1 -1
  16. data/lib/clacky/tools/web_fetch.rb +2 -2
  17. data/lib/clacky/tools/web_search.rb +2 -2
  18. data/lib/clacky/ui2/components/common_component.rb +14 -5
  19. data/lib/clacky/ui2/components/input_area.rb +300 -89
  20. data/lib/clacky/ui2/components/message_component.rb +7 -3
  21. data/lib/clacky/ui2/components/todo_area.rb +38 -45
  22. data/lib/clacky/ui2/components/welcome_banner.rb +10 -0
  23. data/lib/clacky/ui2/layout_manager.rb +180 -50
  24. data/lib/clacky/ui2/markdown_renderer.rb +80 -0
  25. data/lib/clacky/ui2/screen_buffer.rb +26 -7
  26. data/lib/clacky/ui2/themes/base_theme.rb +32 -46
  27. data/lib/clacky/ui2/themes/hacker_theme.rb +4 -2
  28. data/lib/clacky/ui2/themes/minimal_theme.rb +4 -2
  29. data/lib/clacky/ui2/ui_controller.rb +150 -32
  30. data/lib/clacky/ui2/view_renderer.rb +21 -4
  31. data/lib/clacky/ui2.rb +0 -1
  32. data/lib/clacky/utils/arguments_parser.rb +7 -2
  33. data/lib/clacky/utils/file_processor.rb +201 -0
  34. data/lib/clacky/version.rb +1 -1
  35. data/scripts/install.sh +249 -0
  36. data/scripts/uninstall.sh +146 -0
  37. metadata +21 -2
  38. data/lib/clacky/ui2/components/output_area.rb +0 -112
@@ -10,7 +10,7 @@ module Clacky
10
10
  attr_accessor :height
11
11
  attr_reader :todos
12
12
 
13
- MAX_DISPLAY_TASKS = 2 # Show at most 2 tasks (Next + After)
13
+ MAX_DISPLAY_TASKS = 3 # Show current + next 2 tasks
14
14
 
15
15
  def initialize
16
16
  @todos = []
@@ -27,12 +27,11 @@ module Clacky
27
27
  @completed_count = @todos.count { |t| t[:status] == "completed" }
28
28
  @total_count = @todos.size
29
29
 
30
- # Height: 1 line for header + min(pending_count, MAX_DISPLAY_TASKS) lines for tasks
31
- if @pending_todos.empty? && @completed_count == 0
30
+ # Calculate height: 0 if no pending, otherwise 1 line per task (up to MAX_DISPLAY_TASKS)
31
+ if @pending_todos.empty?
32
32
  @height = 0
33
33
  else
34
- display_count = [@pending_todos.size, MAX_DISPLAY_TASKS].min
35
- @height = 1 + display_count
34
+ @height = [@pending_todos.size, MAX_DISPLAY_TASKS].min
36
35
  end
37
36
  end
38
37
 
@@ -48,21 +47,37 @@ module Clacky
48
47
 
49
48
  update_width
50
49
 
51
- # Render header: [##] Tasks [0/4]: ████
52
- move_cursor(start_row, 0)
53
- clear_line
54
- header = render_header
55
- print header
56
-
57
- # Render tasks (Next and After)
58
- @pending_todos.take(MAX_DISPLAY_TASKS).each_with_index do |todo, i|
59
- move_cursor(start_row + i + 1, 0)
60
- clear_line
61
-
62
- label = i == 0 ? "Next" : "After"
63
- task_text = truncate_text("##{todo[:id]} - #{todo[:task]}", @width - 12)
64
- line = " #{@pastel.dim("->")} #{@pastel.yellow(label)}: #{task_text}"
65
- print line
50
+ # Render each task on separate line
51
+ tasks_to_show = @pending_todos.take(MAX_DISPLAY_TASKS)
52
+
53
+ tasks_to_show.each_with_index do |task, index|
54
+ move_cursor(start_row + index, 0)
55
+
56
+ # Build the line content
57
+ line_content = if index == 0
58
+ # First line: Task [2/4]: #3 - Current task description
59
+ progress = "#{@completed_count}/#{@total_count}"
60
+ prefix = "Task [#{progress}]: "
61
+ task_text = "##{task[:id]} - #{task[:task]}"
62
+ available_width = @width - prefix.length - 2
63
+ truncated_task = truncate_text(task_text, available_width)
64
+
65
+ "#{@pastel.cyan(prefix)}#{truncated_task}"
66
+ else
67
+ # Subsequent lines: -> Next: #4 - Next task description
68
+ label = index == 1 ? "Next" : "After"
69
+ prefix = "-> #{label}: "
70
+ task_text = "##{task[:id]} - #{task[:task]}"
71
+ available_width = @width - prefix.length - 2
72
+ truncated_task = truncate_text(task_text, available_width)
73
+
74
+ "#{@pastel.dim(prefix)}#{@pastel.dim(truncated_task)}"
75
+ end
76
+
77
+ # Use carriage return and print content directly (overwrite existing content)
78
+ print "\r#{line_content}"
79
+ # Clear any remaining characters from previous render if line is shorter
80
+ clear_to_end_of_line
66
81
  end
67
82
 
68
83
  flush
@@ -79,28 +94,6 @@ module Clacky
79
94
 
80
95
  private
81
96
 
82
- # Render header line with progress bar
83
- def render_header
84
- progress = "#{@completed_count}/#{@total_count}"
85
- progress_bar = render_progress_bar(@completed_count, @total_count)
86
-
87
- "#{@pastel.cyan("[##]")} Tasks [#{progress}]: #{progress_bar}"
88
- end
89
-
90
- # Render a simple progress bar
91
- def render_progress_bar(completed, total)
92
- return "" if total == 0
93
-
94
- bar_width = 10
95
- filled = total > 0 ? (completed.to_f / total * bar_width).round : 0
96
- empty = bar_width - filled
97
-
98
- filled_bar = @pastel.green("█" * filled)
99
- empty_bar = @pastel.dim("░" * empty)
100
-
101
- "#{filled_bar}#{empty_bar}"
102
- end
103
-
104
97
  # Truncate text to fit width
105
98
  def truncate_text(text, max_width)
106
99
  return "" if text.nil?
@@ -122,9 +115,9 @@ module Clacky
122
115
  print "\e[#{row + 1};#{col + 1}H"
123
116
  end
124
117
 
125
- # Clear current line
126
- def clear_line
127
- print "\e[2K"
118
+ # Clear from cursor to end of line
119
+ def clear_to_end_of_line
120
+ print "\e[0K"
128
121
  end
129
122
 
130
123
  # Flush output
@@ -29,6 +29,16 @@ module Clacky
29
29
  @pastel = Pastel.new
30
30
  end
31
31
 
32
+ # Render only the logo (ASCII art)
33
+ # @return [String] Formatted logo only
34
+ def render_logo
35
+ lines = []
36
+ lines << ""
37
+ lines << @pastel.bright_green(LOGO)
38
+ lines << ""
39
+ lines.join("\n")
40
+ end
41
+
32
42
  # Render startup banner
33
43
  # @return [String] Formatted startup banner
34
44
  def render_startup
@@ -6,11 +6,10 @@ module Clacky
6
6
  module UI2
7
7
  # LayoutManager manages screen layout with split areas (output area on top, input area on bottom)
8
8
  class LayoutManager
9
- attr_reader :screen, :output_area, :input_area, :todo_area
9
+ attr_reader :screen, :input_area, :todo_area
10
10
 
11
- def initialize(output_area:, input_area:, todo_area: nil)
11
+ def initialize(input_area:, todo_area: nil)
12
12
  @screen = ScreenBuffer.new
13
- @output_area = output_area
14
13
  @input_area = input_area
15
14
  @todo_area = todo_area
16
15
  @render_mutex = Mutex.new
@@ -35,7 +34,6 @@ module Clacky
35
34
  @input_row = @todo_row + todo_height
36
35
 
37
36
  # Update component dimensions
38
- @output_area.height = @output_height
39
37
  @input_area.row = @input_row
40
38
  end
41
39
 
@@ -93,10 +91,13 @@ module Clacky
93
91
  def position_inline_input_cursor(inline_input)
94
92
  return unless inline_input
95
93
 
96
- # InlineInput renders its own visual cursor via render_line_with_cursor
97
- # (white background on cursor character), so we don't need terminal cursor.
98
- # Just hide the terminal cursor to avoid showing two cursors.
99
- screen.hide_cursor
94
+ # Calculate the actual terminal cursor position considering multi-byte characters
95
+ # InlineInput is on the last output line (@output_row - 1)
96
+ cursor_row = @output_row - 1
97
+ cursor_col = inline_input.cursor_col # This already considers display width
98
+
99
+ # Move terminal cursor to the correct position
100
+ screen.move_cursor(cursor_row, cursor_col)
100
101
  screen.flush
101
102
  end
102
103
 
@@ -159,14 +160,16 @@ module Clacky
159
160
  # Clear output area (for /clear command)
160
161
  def clear_output
161
162
  @render_mutex.synchronize do
162
- # Clear all lines in output area (from 0 to fixed_area_start - 1)
163
- max_output_row = fixed_area_start_row
164
- (0...max_output_row).each do |row|
163
+ # Clear all lines in output area (from 0 to where fixed area starts)
164
+ max_row = fixed_area_start_row
165
+ (0...max_row).each do |row|
165
166
  screen.move_cursor(row, 0)
166
167
  screen.clear_line
167
168
  end
168
- # Reset output row position to start
169
+
170
+ # Reset output position to beginning
169
171
  @output_row = 0
172
+
170
173
  # Re-render fixed areas to ensure they stay in place
171
174
  render_fixed_areas
172
175
  screen.flush
@@ -174,32 +177,24 @@ module Clacky
174
177
  end
175
178
 
176
179
  # Append content to output area
177
- # Track current row, scroll when reaching fixed area
178
- # @param content [String] Content to append
180
+ # This is the main output method - handles scrolling and fixed area preservation
181
+ # @param content [String] Content to append (can be multi-line)
179
182
  def append_output(content)
180
- return if content.nil? || content.empty?
183
+ return if content.nil?
181
184
 
182
185
  @render_mutex.synchronize do
183
- max_output_row = fixed_area_start_row - 1
184
-
185
- content.split("\n").each do |line|
186
- # If at max row, need to scroll before outputting
187
- if @output_row > max_output_row
188
- # Move to bottom of screen and print newline to trigger scroll
189
- screen.move_cursor(screen.height - 1, 0)
190
- print "\n"
191
- # Stay at max_output_row for next output
192
- @output_row = max_output_row
186
+ lines = content.split("\n", -1) # -1 to keep trailing empty strings
187
+
188
+ lines.each_with_index do |line, index|
189
+ # Wrap long lines to prevent display issues
190
+ wrapped_lines = wrap_long_line(line)
191
+
192
+ wrapped_lines.each do |wrapped_line|
193
+ write_output_line(wrapped_line)
193
194
  end
194
-
195
- # Output line at current position
196
- screen.move_cursor(@output_row, 0)
197
- screen.clear_line
198
- output_area.append(line)
199
- @output_row += 1
200
195
  end
201
196
 
202
- # Re-render fixed areas at screen bottom
197
+ # Re-render fixed areas to ensure they stay at bottom
203
198
  render_fixed_areas
204
199
  screen.flush
205
200
  end
@@ -209,40 +204,52 @@ module Clacky
209
204
  # @param content [String] Content to update
210
205
  def update_last_line(content)
211
206
  @render_mutex.synchronize do
212
- # Last output line is at @output_row - 1
213
- last_row = [@output_row - 1, 0].max
207
+ return if @output_row == 0 # No output yet
208
+
209
+ # Last written line is at @output_row - 1
210
+ last_row = @output_row - 1
214
211
  screen.move_cursor(last_row, 0)
215
212
  screen.clear_line
216
- output_area.append(content)
217
- render_fixed_areas
213
+ print content
214
+
215
+ # Hide terminal cursor to avoid showing two cursors
216
+ # InlineInput uses visual cursor (white background) which is better for multi-byte chars
217
+ screen.hide_cursor
218
218
  screen.flush
219
+
220
+ # Don't re-render fixed areas - we're just updating existing content
219
221
  end
220
222
  end
221
223
 
222
224
  # Remove the last line from output area
223
225
  def remove_last_line
224
226
  @render_mutex.synchronize do
225
- last_row = [@output_row - 1, 0].max
227
+ return if @output_row == 0 # No output to remove
228
+
229
+ # Clear the last written line
230
+ last_row = @output_row - 1
226
231
  screen.move_cursor(last_row, 0)
227
232
  screen.clear_line
228
- @output_row = last_row if @output_row > 0
233
+
234
+ # Move output row back
235
+ @output_row = last_row
236
+
237
+ # Re-render fixed areas to ensure consistency
229
238
  render_fixed_areas
230
239
  screen.flush
231
240
  end
232
241
  end
233
242
 
234
- # Scroll output area up
243
+ # Scroll output area up (legacy no-op)
235
244
  # @param lines [Integer] Number of lines to scroll
236
245
  def scroll_output_up(lines = 1)
237
- output_area.scroll_up(lines)
238
- render_output
246
+ # No-op - terminal handles scrolling natively
239
247
  end
240
248
 
241
- # Scroll output area down
249
+ # Scroll output area down (legacy no-op)
242
250
  # @param lines [Integer] Number of lines to scroll
243
251
  def scroll_output_down(lines = 1)
244
- output_area.scroll_down(lines)
245
- render_output
252
+ # No-op - terminal handles scrolling natively
246
253
  end
247
254
 
248
255
  # Handle window resize
@@ -252,12 +259,14 @@ module Clacky
252
259
  screen.update_dimensions
253
260
  calculate_layout
254
261
 
255
- # Adjust output_row if it exceeds new max
256
- max_row = fixed_area_start_row - 1
257
- @output_row = [@output_row, max_row].min
262
+ # Adjust @output_row if it exceeds new layout
263
+ # After resize, @output_row should not exceed fixed_area_start_row
264
+ max_allowed = fixed_area_start_row
265
+ @output_row = [@output_row, max_allowed].min
258
266
 
259
- # Clear old fixed area lines
260
- ([old_gap_row, 0].max...screen.height).each do |row|
267
+ # Clear old fixed area and some lines above (terminal may have wrapped content)
268
+ clear_start = [old_gap_row - 5, 0].max
269
+ (clear_start...screen.height).each do |row|
261
270
  screen.move_cursor(row, 0)
262
271
  screen.clear_line
263
272
  end
@@ -268,6 +277,127 @@ module Clacky
268
277
 
269
278
  private
270
279
 
280
+ # Write a single line to output area
281
+ # Handles scrolling when reaching fixed area
282
+ # @param line [String] Single line to write (should not contain newlines)
283
+ def write_output_line(line)
284
+ # Calculate where fixed area starts (this is where output area ends)
285
+ max_output_row = fixed_area_start_row
286
+
287
+ # If we're about to write into the fixed area, scroll first
288
+ if @output_row >= max_output_row
289
+ # Trigger terminal scroll by printing newline at bottom
290
+ screen.move_cursor(screen.height - 1, 0)
291
+ print "\n"
292
+
293
+ # After scroll, position to write at the last row of output area
294
+ @output_row = max_output_row - 1
295
+
296
+ # Important: Re-render fixed areas after scroll to prevent corruption
297
+ render_fixed_areas
298
+ end
299
+
300
+ # Now write the line at current position
301
+ screen.move_cursor(@output_row, 0)
302
+ screen.clear_line
303
+ print line
304
+
305
+ # Move to next row for next write
306
+ @output_row += 1
307
+ end
308
+
309
+ # Wrap a long line into multiple lines based on terminal width
310
+ # Considers display width of multi-byte characters (e.g., Chinese characters)
311
+ # @param line [String] Line to wrap
312
+ # @return [Array<String>] Array of wrapped lines
313
+ def wrap_long_line(line)
314
+ return [""] if line.nil? || line.empty?
315
+
316
+ max_width = screen.width
317
+ return [line] if max_width <= 0
318
+
319
+ # Strip ANSI codes for width calculation
320
+ visible_line = line.gsub(/\e\[[0-9;]*m/, '')
321
+
322
+ # Check if line needs wrapping
323
+ display_width = calculate_display_width(visible_line)
324
+ return [line] if display_width <= max_width
325
+
326
+ # Line needs wrapping - split by considering display width
327
+ wrapped = []
328
+ current_line = ""
329
+ current_width = 0
330
+ ansi_codes = [] # Track ANSI codes to carry over
331
+
332
+ # Extract ANSI codes and text segments
333
+ segments = line.split(/(\e\[[0-9;]*m)/)
334
+
335
+ segments.each do |segment|
336
+ if segment =~ /^\e\[[0-9;]*m$/
337
+ # ANSI code - add to current codes
338
+ ansi_codes << segment
339
+ current_line += segment
340
+ else
341
+ # Text segment - process character by character
342
+ segment.each_char do |char|
343
+ char_width = char_display_width(char)
344
+
345
+ if current_width + char_width > max_width && !current_line.empty?
346
+ # Complete current line
347
+ wrapped << current_line
348
+ # Start new line with carried-over ANSI codes
349
+ current_line = ansi_codes.join
350
+ current_width = 0
351
+ end
352
+
353
+ current_line += char
354
+ current_width += char_width
355
+ end
356
+ end
357
+ end
358
+
359
+ # Add remaining content
360
+ wrapped << current_line unless current_line.empty? || current_line == ansi_codes.join
361
+
362
+ wrapped.empty? ? [""] : wrapped
363
+ end
364
+
365
+ # Calculate display width of a single character
366
+ # @param char [String] Single character
367
+ # @return [Integer] Display width (1 or 2)
368
+ def char_display_width(char)
369
+ code = char.ord
370
+ # East Asian Wide and Fullwidth characters take 2 columns
371
+ if (code >= 0x1100 && code <= 0x115F) ||
372
+ (code >= 0x2329 && code <= 0x232A) ||
373
+ (code >= 0x2E80 && code <= 0x303E) ||
374
+ (code >= 0x3040 && code <= 0xA4CF) ||
375
+ (code >= 0xAC00 && code <= 0xD7A3) ||
376
+ (code >= 0xF900 && code <= 0xFAFF) ||
377
+ (code >= 0xFE10 && code <= 0xFE19) ||
378
+ (code >= 0xFE30 && code <= 0xFE6F) ||
379
+ (code >= 0xFF00 && code <= 0xFF60) ||
380
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
381
+ (code >= 0x1F300 && code <= 0x1F9FF) ||
382
+ (code >= 0x20000 && code <= 0x2FFFD) ||
383
+ (code >= 0x30000 && code <= 0x3FFFD)
384
+ 2
385
+ else
386
+ 1
387
+ end
388
+ end
389
+
390
+ # Calculate display width of a string (considering multi-byte characters)
391
+ # @param text [String] Text to calculate
392
+ # @return [Integer] Display width
393
+ def calculate_display_width(text)
394
+ width = 0
395
+ text.each_char do |char|
396
+ width += char_display_width(char)
397
+ end
398
+ width
399
+ end
400
+
271
401
  # Calculate fixed area height (gap + todo + input)
272
402
  def fixed_area_height
273
403
  todo_height = @todo_area&.height || 0
@@ -306,7 +436,7 @@ module Clacky
306
436
 
307
437
  # Internal render all (without mutex)
308
438
  def render_all_internal
309
- output_area.render(start_row: 0)
439
+ # Output flows naturally, just render fixed areas
310
440
  render_fixed_areas
311
441
  screen.flush
312
442
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-markdown"
4
+ require_relative "theme_manager"
5
+
6
+ module Clacky
7
+ module UI2
8
+ # MarkdownRenderer handles rendering Markdown content with syntax highlighting
9
+ module MarkdownRenderer
10
+ class << self
11
+ # Render markdown content with theme-aware colors
12
+ # @param content [String] Markdown content to render
13
+ # @return [String] Rendered content with ANSI colors
14
+ def render(content)
15
+ return content if content.nil? || content.empty?
16
+
17
+ # Get current theme colors
18
+ theme = ThemeManager.current_theme
19
+
20
+ # Configure tty-markdown colors based on current theme
21
+ # tty-markdown uses Pastel internally, we can configure symbols
22
+ parsed = TTY::Markdown.parse(content,
23
+ colors: theme_colors,
24
+ width: TTY::Screen.width - 4 # Leave some margin
25
+ )
26
+
27
+ parsed
28
+ rescue StandardError => e
29
+ # Fallback to plain content if rendering fails
30
+ content
31
+ end
32
+
33
+ # Check if content looks like markdown
34
+ # @param content [String] Content to check
35
+ # @return [Boolean] true if content appears to be markdown
36
+ def markdown?(content)
37
+ return false if content.nil? || content.empty?
38
+
39
+ # Check for common markdown patterns
40
+ content.match?(/^#+ /) || # Headers
41
+ content.match?(/```/) || # Code blocks
42
+ content.match?(/^\s*[-*+] /) || # Unordered lists
43
+ content.match?(/^\s*\d+\. /) || # Ordered lists
44
+ content.match?(/\[.+\]\(.+\)/) || # Links
45
+ content.match?(/^\s*> /) || # Blockquotes
46
+ content.match?(/\*\*.+\*\*/) || # Bold
47
+ content.match?(/`.+`/) || # Inline code
48
+ content.match?(/^\s*\|.+\|/) || # Tables
49
+ content.match?(/^---+$/) # Horizontal rules
50
+ end
51
+
52
+ private
53
+
54
+ # Get theme-aware colors for markdown rendering
55
+ # @return [Hash] Color configuration for tty-markdown
56
+ def theme_colors
57
+ theme = ThemeManager.current_theme
58
+
59
+ # Map our theme colors to tty-markdown's expected format
60
+ {
61
+ # Headers use info color (cyan/blue)
62
+ header: theme.colors[:info],
63
+ # Code blocks use dim color
64
+ code: theme.colors[:thinking],
65
+ # Links use success color (green)
66
+ link: theme.colors[:success],
67
+ # Lists use default text color
68
+ list: :bright_white,
69
+ # Strong/bold use bright white
70
+ strong: :bright_white,
71
+ # Emphasis/italic use white
72
+ em: :white,
73
+ # Note/blockquote use dim color
74
+ note: theme.colors[:thinking],
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -171,14 +171,33 @@ module Clacky
171
171
  buffer.force_encoding('UTF-8')
172
172
 
173
173
  # Keep reading available characters
174
+ loop_count = 0
175
+ empty_checks = 0
176
+
174
177
  loop do
175
- break unless IO.select([$stdin], nil, nil, 0)
176
-
177
- next_char = $stdin.getc
178
- break unless next_char
179
-
180
- next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
181
- buffer << next_char
178
+ # Check if there's data available immediately
179
+ has_data = IO.select([$stdin], nil, nil, 0)
180
+
181
+ if has_data
182
+ next_char = $stdin.getc
183
+ break unless next_char
184
+
185
+ next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
186
+ buffer << next_char
187
+ loop_count += 1
188
+ empty_checks = 0 # Reset empty check counter
189
+ else
190
+ # No immediate data, but wait a bit to see if more is coming
191
+ # This handles the case where paste data arrives in chunks
192
+ empty_checks += 1
193
+ if empty_checks == 1
194
+ # First empty check - wait 10ms for more data
195
+ sleep 0.01
196
+ else
197
+ # Second empty check - really no more data
198
+ break
199
+ end
200
+ end
182
201
  end
183
202
 
184
203
  # If we buffered multiple characters or newlines, treat as rapid input (paste)