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
@@ -13,6 +13,16 @@ module Clacky
13
13
  class InputArea
14
14
  include LineEditor
15
15
 
16
+ # User tips pool - can be extended with more tips over time
17
+ USER_TIPS = [
18
+ "Shift+Tab to toggle permission mode (confirm_safes ⇄ auto_approve)",
19
+ "Ctrl+C to interrupt AI execution or clear input",
20
+ "Shift+Enter to create multi-line input",
21
+ "Ctrl+V to paste images (supports up to 3 images)",
22
+ "Ctrl+D to delete pasted images",
23
+ "Use /clear to restart session, /help for commands"
24
+ ].freeze
25
+
16
26
  attr_accessor :row
17
27
  attr_reader :cursor_position, :line_index, :images, :tips_message, :tips_type
18
28
 
@@ -36,6 +46,11 @@ module Clacky
36
46
  @tips_timer = nil
37
47
  @last_render_row = nil
38
48
 
49
+ # User tip (usage suggestion) - separate from system tips
50
+ @user_tip = nil
51
+ @user_tip_timer = nil
52
+ @user_tip_count = 0
53
+
39
54
  # Paused state - when InlineInput is active
40
55
  @paused = false
41
56
 
@@ -69,8 +84,15 @@ module Clacky
69
84
  # When paused (InlineInput active), don't take up any space
70
85
  return 0 if @paused
71
86
 
72
- height = 1 # Session bar (top)
73
- height += 1 # Separator after session bar
87
+ height = 0
88
+
89
+ # Session bar - calculate actual wrapped height
90
+ height += calculate_sessionbar_height
91
+
92
+ # Separator after session bar
93
+ height += 1
94
+
95
+ # Images
74
96
  height += @images.size
75
97
 
76
98
  # Calculate height considering wrapped lines
@@ -86,8 +108,13 @@ module Clacky
86
108
  height += wrapped_segments.size
87
109
  end
88
110
 
89
- height += 1 # Bottom separator
111
+ # Bottom separator
112
+ height += 1
113
+
114
+ # Tips and user tips
90
115
  height += 1 if @tips_message
116
+ height += 1 if @user_tip
117
+
91
118
  height
92
119
  end
93
120
 
@@ -186,49 +213,7 @@ module Clacky
186
213
  end
187
214
 
188
215
  # Input lines with auto-wrap support
189
- @lines.each_with_index do |line, idx|
190
- prefix = if idx == 0
191
- prompt_text = theme.format_symbol(:user) + " "
192
- prompt_text
193
- else
194
- " " * prompt.length
195
- end
196
-
197
- # Calculate available width for text (excluding prefix)
198
- prefix_width = calculate_display_width(strip_ansi_codes(prefix))
199
- available_width = @width - prefix_width
200
-
201
- # Wrap line if needed
202
- wrapped_segments = wrap_line(line, available_width)
203
-
204
- wrapped_segments.each_with_index do |segment_info, wrap_idx|
205
- move_cursor(current_row, 0)
206
-
207
- segment_text = segment_info[:text]
208
- segment_start = segment_info[:start]
209
- segment_end = segment_info[:end]
210
-
211
- content = if wrap_idx == 0
212
- # First wrapped line includes prefix
213
- if idx == @line_index
214
- "#{prefix}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
215
- else
216
- "#{prefix}#{theme.format_text(segment_text, :user)}"
217
- end
218
- else
219
- # Continuation lines have indent matching prefix width
220
- continuation_indent = " " * prefix_width
221
- if idx == @line_index
222
- "#{continuation_indent}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
223
- else
224
- "#{continuation_indent}#{theme.format_text(segment_text, :user)}"
225
- end
226
- end
227
-
228
- print_with_padding(content)
229
- current_row += 1
230
- end
231
- end
216
+ current_row = render_input_lines(current_row)
232
217
 
233
218
  # Bottom separator
234
219
  render_separator(current_row)
@@ -242,6 +227,14 @@ module Clacky
242
227
  current_row += 1
243
228
  end
244
229
 
230
+ # User tip (if any)
231
+ if @user_tip
232
+ move_cursor(current_row, 0)
233
+ content = format_user_tip(@user_tip)
234
+ print_with_padding(content)
235
+ current_row += 1
236
+ end
237
+
245
238
  # Position cursor at current edit position
246
239
  position_cursor(start_row)
247
240
  flush
@@ -334,6 +327,58 @@ module Clacky
334
327
  @tips_message = nil
335
328
  end
336
329
 
330
+ # Show a random user tip with probability and auto-rotation (max 3 tips)
331
+ # @param probability [Float] Probability of showing tip (0.0 to 1.0, default: 0.4)
332
+ # @param rotation_interval [Integer] Seconds between tip rotation (default: 12)
333
+ # @param max_tips [Integer] Maximum number of tips to show before stopping (default: 3)
334
+ def show_user_tip(probability: 0.4, rotation_interval: 12, max_tips: 3)
335
+ # Random chance to show tip
336
+ return unless rand < probability
337
+
338
+ # Stop existing timer if any
339
+ stop_user_tip_timer
340
+
341
+ # Reset counter and pick first random tip
342
+ @user_tip_count = 1
343
+ @user_tip = USER_TIPS.sample
344
+
345
+ # Start rotation timer (will show max_tips total)
346
+ @user_tip_timer = Thread.new do
347
+ while @user_tip_count < max_tips
348
+ sleep rotation_interval
349
+ @user_tip_count += 1
350
+
351
+ # Pick a different tip
352
+ old_tip = @user_tip
353
+ loop do
354
+ @user_tip = USER_TIPS.sample
355
+ break if @user_tip != old_tip || USER_TIPS.size == 1
356
+ end
357
+ end
358
+
359
+ # After showing max_tips, wait then clear
360
+ sleep rotation_interval
361
+ @user_tip = nil
362
+ @user_tip_count = 0
363
+ rescue => e
364
+ # Silently handle thread errors
365
+ end
366
+ end
367
+
368
+ # Clear user tip and stop rotation
369
+ def clear_user_tip
370
+ stop_user_tip_timer
371
+ @user_tip = nil
372
+ @user_tip_count = 0
373
+ end
374
+
375
+ private def stop_user_tip_timer
376
+ if @user_tip_timer&.alive?
377
+ @user_tip_timer.kill
378
+ @user_tip_timer = nil
379
+ end
380
+ end
381
+
337
382
  # Pause input area (when InlineInput is active)
338
383
  def pause
339
384
  @paused = true
@@ -485,6 +530,73 @@ module Clacky
485
530
 
486
531
  private
487
532
 
533
+ # Render all input lines with auto-wrap support
534
+ # @param start_row [Integer] Starting row position
535
+ # @return [Integer] Next available row after rendering all lines
536
+ def render_input_lines(start_row)
537
+ current_row = start_row
538
+
539
+ @lines.each_with_index do |line, line_idx|
540
+ prefix = calculate_line_prefix(line_idx)
541
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
542
+ available_width = @width - prefix_width
543
+ wrapped_segments = wrap_line(line, available_width)
544
+
545
+ wrapped_segments.each_with_index do |segment_info, wrap_idx|
546
+ content = render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
547
+ move_cursor(current_row, 0)
548
+ print_with_padding(content)
549
+ current_row += 1
550
+ end
551
+ end
552
+
553
+ current_row
554
+ end
555
+
556
+ # Calculate the prefix (prompt or indent) for a given line index
557
+ # @param line_idx [Integer] Index of the line
558
+ # @return [String] Prefix string (with formatting)
559
+ private def calculate_line_prefix(line_idx)
560
+ if line_idx == 0
561
+ theme.format_symbol(:user) + " "
562
+ else
563
+ " " * prompt.length
564
+ end
565
+ end
566
+
567
+ # Render a single segment of a line (handling cursor and wrapping)
568
+ # @param line [String] Full line text
569
+ # @param line_idx [Integer] Index of the line in @lines
570
+ # @param segment_info [Hash] Segment information from wrap_line
571
+ # @param wrap_idx [Integer] Index of this segment in wrapped segments
572
+ # @param prefix [String] Line prefix (prompt or indent)
573
+ # @param prefix_width [Integer] Display width of the prefix
574
+ # @return [String] Formatted content for this segment
575
+ private def render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
576
+ segment_text = segment_info[:text]
577
+ segment_start = segment_info[:start]
578
+ segment_end = segment_info[:end]
579
+
580
+ is_current_line = (line_idx == @line_index)
581
+ is_first_segment = (wrap_idx == 0)
582
+
583
+ # Determine the line prefix
584
+ line_prefix = if is_first_segment
585
+ prefix
586
+ else
587
+ " " * prefix_width # Continuation indent
588
+ end
589
+
590
+ # Render the segment content (with or without cursor)
591
+ segment_content = if is_current_line
592
+ render_line_segment_with_cursor(line, segment_start, segment_end)
593
+ else
594
+ theme.format_text(segment_text, :user)
595
+ end
596
+
597
+ "#{line_prefix}#{segment_content}"
598
+ end
599
+
488
600
  # Wrap a line into multiple segments based on available width
489
601
  # Considers display width of characters (multi-byte characters like Chinese)
490
602
  # @param line [String] The line to wrap
@@ -570,12 +682,29 @@ module Clacky
570
682
  visible_content = content.gsub(/\e\[[0-9;]*m/, '')
571
683
  visible_width = calculate_display_width(visible_content)
572
684
 
573
- # Print content
574
- print content
575
-
576
- # Pad with spaces if needed to clear old content
577
- remaining = @width - visible_width
578
- print " " * remaining if remaining > 0
685
+ # IMPORTANT: If content exceeds screen width, truncate to prevent terminal auto-wrap
686
+ if visible_width > @width
687
+ # Content too long - truncate to fit (loses ANSI colors but prevents wrapping)
688
+ truncate_at = 0
689
+ current_width = 0
690
+ visible_content.each_char.with_index do |char, idx|
691
+ char_width = char_display_width(char)
692
+ break if current_width + char_width + 3 > @width # Reserve 3 for "..."
693
+ current_width += char_width
694
+ truncate_at = idx + 1
695
+ end
696
+ print visible_content[0...truncate_at]
697
+ print "..."
698
+ # Pad remaining
699
+ remaining = @width - current_width - 3
700
+ print " " * remaining if remaining > 0
701
+ else
702
+ # Content fits - print normally
703
+ print content
704
+ # Pad with spaces if needed to clear old content
705
+ remaining = @width - visible_width
706
+ print " " * remaining if remaining > 0
707
+ end
579
708
  end
580
709
 
581
710
  def handle_enter
@@ -583,18 +712,23 @@ module Clacky
583
712
 
584
713
  # Handle commands (with or without slash)
585
714
  if text.start_with?('/')
586
- case text
587
- when '/clear'
588
- clear
589
- return { action: :clear_output }
590
- when '/help'
591
- return { action: :help }
592
- when '/exit', '/quit'
593
- return { action: :exit }
594
- else
595
- set_tips("Unknown command: #{text} (Available: /clear, /help, /exit)", type: :warning)
596
- return { action: nil }
715
+ # Check if it's a command (single slash followed by English letters only)
716
+ # Paths like /xxx/xxxx should not be treated as commands
717
+ if text =~ /^\/([a-zA-Z]+)$/
718
+ case text
719
+ when '/clear'
720
+ clear
721
+ return { action: :clear_output }
722
+ when '/help'
723
+ return { action: :help }
724
+ when '/exit', '/quit'
725
+ return { action: :exit }
726
+ else
727
+ set_tips("Unknown command: #{text} (Available: /clear, /help, /exit)", type: :warning)
728
+ return { action: nil }
729
+ end
597
730
  end
731
+ # If it's not a command pattern (e.g., /xxx/xxxx), treat as normal input
598
732
  elsif text == '?'
599
733
  return { action: :help }
600
734
  elsif text == 'exit' || text == 'quit'
@@ -867,36 +1001,60 @@ module Clacky
867
1001
  end
868
1002
  end
869
1003
 
1004
+ # Render a separator line (ensures it doesn't exceed screen width)
1005
+ # @param row [Integer] Row position to render
870
1006
  def render_separator(row)
871
1007
  move_cursor(row, 0)
872
- content = @pastel.dim("─" * @width)
873
- print_with_padding(content)
1008
+ # Ensure separator doesn't exceed screen width to prevent wrapping
1009
+ separator_width = [@width, 1].max
1010
+ content = @pastel.dim("─" * separator_width)
1011
+ print content
1012
+ # Clear any remaining space
1013
+ remaining = @width - separator_width
1014
+ print " " * remaining if remaining > 0
874
1015
  end
875
1016
 
1017
+ # Render session bar with wrapping support
1018
+ # @param row [Integer] Starting row position
1019
+ # @return [Integer] Number of rows actually used
876
1020
  def render_sessionbar(row)
877
1021
  move_cursor(row, 0)
878
1022
 
879
1023
  # If no sessionbar info, just render a separator
880
1024
  unless @sessionbar_info[:working_dir]
881
- content = @pastel.dim("─" * @width)
882
- print_with_padding(content)
883
- return
1025
+ separator_width = [@width, 1].max
1026
+ content = @pastel.dim("─" * separator_width)
1027
+ print content
1028
+ remaining = @width - separator_width
1029
+ print " " * remaining if remaining > 0
1030
+ return 1
884
1031
  end
885
1032
 
1033
+ session_line = build_sessionbar_content
1034
+
1035
+ # IMPORTANT: Always use print_with_padding which handles truncation
1036
+ # to prevent terminal auto-wrap
1037
+ print_with_padding(session_line)
1038
+ 1
1039
+ end
1040
+
1041
+ # Build the session bar content string
1042
+ # @return [String] Formatted session bar content
1043
+ private def build_sessionbar_content
886
1044
  parts = []
887
1045
  separator = @pastel.dim(" │ ")
888
1046
 
889
1047
  # Workspace status with animation
890
1048
  if @sessionbar_info[:status]
891
- status_indicator = get_status_indicator(@sessionbar_info[:status])
892
- status_theme_key = status_theme_key_for(@sessionbar_info[:status])
893
- parts << "#{status_indicator} #{theme.format_text(@sessionbar_info[:status], status_theme_key)}"
1049
+ status_color = status_color_for(@sessionbar_info[:status])
1050
+ status_indicator = get_status_indicator(@sessionbar_info[:status], status_color)
1051
+ parts << "#{status_indicator} #{@pastel.public_send(status_color, @sessionbar_info[:status])}"
894
1052
  end
895
1053
 
896
1054
  # Working directory (shortened if too long)
897
1055
  if @sessionbar_info[:working_dir]
898
1056
  dir_display = shorten_path(@sessionbar_info[:working_dir])
899
- parts << @pastel.bright_cyan(dir_display)
1057
+ parts << @pastel.dim(@pastel.cyan(dir_display))
900
1058
  end
901
1059
 
902
1060
  # Permission mode
@@ -907,18 +1065,57 @@ module Clacky
907
1065
 
908
1066
  # Model
909
1067
  if @sessionbar_info[:model]
910
- parts << @pastel.bright_white(@sessionbar_info[:model])
1068
+ parts << @pastel.dim(@pastel.white(@sessionbar_info[:model]))
911
1069
  end
912
1070
 
913
1071
  # Tasks count
914
- parts << @pastel.yellow("#{@sessionbar_info[:tasks]} tasks")
1072
+ parts << @pastel.dim(@pastel.white("#{@sessionbar_info[:tasks]} tasks"))
915
1073
 
916
1074
  # Cost
917
1075
  cost_display = format("$%.1f", @sessionbar_info[:cost])
918
- parts << @pastel.yellow(cost_display)
919
-
920
- session_line = " " + parts.join(separator)
921
- print_with_padding(session_line)
1076
+ parts << @pastel.dim(@pastel.white(cost_display))
1077
+
1078
+ " " + parts.join(separator)
1079
+ end
1080
+
1081
+ # Truncate session bar content to fit within max length
1082
+ # @param content [String] Full session bar content with ANSI codes
1083
+ # @param max_length [Integer] Maximum visible length
1084
+ # @return [String] Truncated content
1085
+ private def truncate_sessionbar_content(content, max_length)
1086
+ # Strip ANSI codes to calculate visible length
1087
+ visible_content = strip_ansi_codes(content)
1088
+ visible_width = calculate_display_width(visible_content)
1089
+
1090
+ return content if visible_width <= max_length
1091
+
1092
+ # Truncate from the end with "..." indicator
1093
+ chars = visible_content.chars
1094
+ current_width = 0
1095
+ truncate_at = 0
1096
+
1097
+ chars.each_with_index do |char, idx|
1098
+ char_width = char_display_width(char)
1099
+ if current_width + char_width + 3 > max_length # Reserve 3 for "..."
1100
+ truncate_at = idx
1101
+ break
1102
+ end
1103
+ current_width += char_width
1104
+ truncate_at = idx + 1
1105
+ end
1106
+
1107
+ # For simplicity with ANSI codes, just show first part + ...
1108
+ # This is a simplified version - proper implementation would preserve ANSI codes
1109
+ visible_content[0...truncate_at] + "..."
1110
+ end
1111
+
1112
+ # Calculate how many rows the session bar will occupy
1113
+ # @return [Integer] Number of rows needed
1114
+ private def calculate_sessionbar_height
1115
+ return 1 unless @sessionbar_info[:working_dir]
1116
+
1117
+ # Session bar always renders on one line (we truncate if needed)
1118
+ 1
922
1119
  end
923
1120
 
924
1121
  def shorten_path(path)
@@ -946,30 +1143,30 @@ module Clacky
946
1143
  def mode_color_for(mode)
947
1144
  case mode.to_s
948
1145
  when /auto_approve/
949
- :bright_magenta
1146
+ :magenta
950
1147
  when /confirm_safes/
951
- :bright_yellow
1148
+ :cyan
952
1149
  when /confirm_edits/
953
- :bright_green
1150
+ :green
954
1151
  when /plan_only/
955
- :bright_blue
1152
+ :blue
956
1153
  else
957
1154
  :white
958
1155
  end
959
1156
  end
960
1157
 
961
- def status_theme_key_for(status)
1158
+ def status_color_for(status)
962
1159
  case status.to_s.downcase
963
1160
  when 'idle'
964
- :info # Use info color for idle state
1161
+ :cyan # Use darker cyan for idle state
965
1162
  when 'working'
966
- :progress # Use progress color for working state
1163
+ :yellow # Use yellow to highlight working state
967
1164
  else
968
- :info
1165
+ :cyan
969
1166
  end
970
1167
  end
971
1168
 
972
- def get_status_indicator(status)
1169
+ def get_status_indicator(status, color)
973
1170
  case status.to_s.downcase
974
1171
  when 'working'
975
1172
  # Update animation frame if enough time has passed
@@ -978,9 +1175,9 @@ module Clacky
978
1175
  @animation_frame = (@animation_frame + 1) % @working_frames.length
979
1176
  @last_animation_update = now
980
1177
  end
981
- @working_frames[@animation_frame]
1178
+ @pastel.public_send(color, @working_frames[@animation_frame])
982
1179
  else
983
- "●" # Idle indicator
1180
+ @pastel.public_send(color, "●") # Idle indicator with same color as text
984
1181
  end
985
1182
  end
986
1183
 
@@ -1012,6 +1209,20 @@ module Clacky
1012
1209
  end
1013
1210
  end
1014
1211
 
1212
+ # Format user tip (usage suggestion) with lightbulb icon
1213
+ # @param tip [String] Tip message
1214
+ # @return [String] Formatted tip with styling
1215
+ def format_user_tip(tip)
1216
+ # Limit message length to prevent line wrapping
1217
+ max_length = @width - 5 # Reserve space for icon and margins
1218
+ if tip.length > max_length
1219
+ tip = tip[0...(max_length - 3)] + "..."
1220
+ end
1221
+
1222
+ # Use lightbulb icon and dim cyan color for subtle appearance
1223
+ @pastel.dim(@pastel.cyan("💡 #{tip}"))
1224
+ end
1225
+
1015
1226
  def move_cursor(row, col)
1016
1227
  print "\e[#{row + 1};#{col + 1}H"
1017
1228
  end
@@ -13,12 +13,14 @@ module Clacky
13
13
  # - :content [String] Message content
14
14
  # - :timestamp [Time, nil] Optional timestamp
15
15
  # - :images [Array<String>] Optional image paths (for user messages)
16
+ # - :prefix_newline [Boolean] Whether to add newline before message (for system messages)
16
17
  # @return [String] Rendered message
17
18
  def render(data)
18
19
  role = data[:role]
19
20
  content = data[:content]
20
21
  timestamp = data[:timestamp]
21
22
  images = data[:images] || []
23
+ prefix_newline = data.fetch(:prefix_newline, true)
22
24
 
23
25
  case role
24
26
  when "user"
@@ -26,7 +28,7 @@ module Clacky
26
28
  when "assistant"
27
29
  render_assistant_message(content, timestamp)
28
30
  else
29
- render_system_message(content, timestamp)
31
+ render_system_message(content, timestamp, prefix_newline)
30
32
  end
31
33
  end
32
34
 
@@ -62,13 +64,15 @@ module Clacky
62
64
  # Render system message
63
65
  # @param content [String] Message content
64
66
  # @param timestamp [Time, nil] Optional timestamp
67
+ # @param prefix_newline [Boolean] Whether to add newline before message
65
68
  # @return [String] Rendered message
66
- def render_system_message(content, timestamp = nil)
69
+ private def render_system_message(content, timestamp = nil, prefix_newline = true)
67
70
  symbol = format_symbol(:info)
68
71
  text = format_text(content, :info)
69
72
  time_str = timestamp ? @pastel.dim("[#{format_timestamp(timestamp)}]") : ""
70
73
 
71
- "\n#{symbol} #{text} #{time_str}".rstrip
74
+ prefix = prefix_newline ? "\n" : ""
75
+ "#{prefix}#{symbol} #{text} #{time_str}".rstrip
72
76
  end
73
77
  end
74
78
  end