try-cli 1.7.1 → 1.9.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 (8) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -6
  3. data/VERSION +1 -1
  4. data/bin/try +2 -1
  5. data/lib/fuzzy.rb +13 -9
  6. data/lib/tui.rb +56 -113
  7. data/try.rb +262 -62
  8. metadata +9 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6b118821db0d2fc53ac1fdb38f5574d13bee02e25b39d7362f51ab7aa128976
4
- data.tar.gz: a3eb513bf97a2a9fe0e082d1b3166049c320739261158768a40880aa9d04ca54
3
+ metadata.gz: 00a24afe865c51ab7042ceb3800d665c9446524c48aca062463fad9aeb54a894
4
+ data.tar.gz: 3433f233d6191fdd578d37e09ac2f229ee7fe030a13c0ec5ab4a1bce60e5821e
5
5
  SHA512:
6
- metadata.gz: bbcc44f8afa56ae146d95a03dadec98bea06b0726fafed760926d9662c30321f19632c171b766956878c88b0b5e318f201c31894a73be571509d0ec0eda91ebf
7
- data.tar.gz: 6759723862c35789b434bb0339397334b25cb046d06bba86632b8639eeaf2bb593cc65ff82aef891e122bc966d38d1c98c48357807a4a63e643f985944514442
6
+ metadata.gz: 89ee4cecc2f8f7d05ba6ac56037db149b43b52efb111615753d975fea0a835e04adb9905c0864ccc3242a3f080510365d1a25b89fb077c04aa958a767a9b91b5
7
+ data.tar.gz: 1adc18aa91717d0984dfc468c7844ffe95357d50a1314bea7c2143b38666f7d7531cd933a3d8533e2b22a4f90359ee367580852c08229dfbb99a9dea629148ba
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # try - fresh directories for every vibe
2
2
 
3
+ **[Website](https://pages.tobi.lutke.com/try/)** · **[RubyGems](https://rubygems.org/gems/try-cli)** · **[GitHub](https://github.com/tobi/try)**
4
+
3
5
  *Your experiments deserve a home.* 🏠
4
6
 
5
7
  > For everyone who constantly creates new projects for little experiments, a one-file Ruby script to quickly manage and navigate to keep them somewhat organized
@@ -20,7 +22,25 @@ Instantly navigate through all your experiment directories with:
20
22
  - **Auto-dating** - creates directories like `2025-08-17-redis-experiment`
21
23
  - **Zero config** - just one Ruby file, no dependencies
22
24
 
23
- ## Quick Start
25
+ ## Installation
26
+
27
+ ### RubyGems (Recommended)
28
+
29
+ ```bash
30
+ gem install try-cli
31
+ ```
32
+
33
+ Then add to your shell:
34
+
35
+ ```bash
36
+ # Bash/Zsh - add to .zshrc or .bashrc
37
+ eval "$(try init)"
38
+
39
+ # Fish - add to config.fish
40
+ try init | source
41
+ ```
42
+
43
+ ### Quick Start (Manual)
24
44
 
25
45
  ```bash
26
46
  curl -sL https://raw.githubusercontent.com/tobi/try/refs/heads/main/try.rb > ~/.local/try.rb
@@ -32,7 +52,7 @@ chmod +x ~/.local/try.rb
32
52
  echo 'eval "$(ruby ~/.local/try.rb init ~/src/tries)"' >> ~/.zshrc
33
53
 
34
54
  # for fish shell users
35
- echo 'eval (~/.local/try.rb init ~/src/tries | string collect)' >> ~/.config/fish/config.fish
55
+ echo '~/.local/try.rb init ~/src/tries | source' >> ~/.config/fish/config.fish
36
56
  ```
37
57
 
38
58
  ## The Problem
@@ -92,9 +112,9 @@ Not just substring matching - it's smart:
92
112
  - Fish:
93
113
 
94
114
  ```fish
95
- eval (~/.local/try.rb init | string collect)
115
+ ~/.local/try.rb init | source
96
116
  # or pick a path
97
- eval (~/.local/try.rb init ~/src/tries | string collect)
117
+ ~/.local/try.rb init ~/src/tries | source
98
118
  ```
99
119
 
100
120
  Notes:
@@ -212,9 +232,9 @@ After installation, add to your shell:
212
232
  - Fish:
213
233
 
214
234
  ```fish
215
- eval "(try init | string collect)"
235
+ try init | source
216
236
  # or pick a path
217
- eval "(try init ~/src/tries | string collect)"
237
+ try init ~/src/tries | source
218
238
  ```
219
239
 
220
240
  ## Why Ruby?
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.7.1
1
+ 1.9.2
data/bin/try CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require_relative '../try'
4
+ $0 = File.expand_path('../try.rb', __dir__)
5
+ load $0
data/lib/fuzzy.rb CHANGED
@@ -68,19 +68,23 @@ class Fuzzy
68
68
  results << [entry.data, positions, score]
69
69
  end
70
70
 
71
- # Sort by score descending
72
- results.sort_by! { |_, _, score| -score }
73
-
74
- # Apply limit
75
- results = results.first(@limit) if @limit
71
+ if @limit && @limit < results.length
72
+ # Partial sort: O(n log k) via heap selection instead of full O(n log n) sort
73
+ results = results.max_by(@limit) { |_, _, score| score }
74
+ else
75
+ results.sort_by! { |_, _, score| -score }
76
+ end
76
77
 
77
78
  results.each(&block)
78
79
  end
79
80
 
80
81
  private
81
82
 
82
- # Pre-computed sqrt values for proximity bonus (gap 0-15)
83
- SQRT_TABLE = (0..16).map { |n| 2.0 / Math.sqrt(n + 1) }.freeze
83
+ # Pre-compiled regex for word boundary detection
84
+ WORD_BOUNDARY_RE = /[^a-z0-9]/
85
+
86
+ # Pre-computed sqrt values for proximity bonus (gap 0-63)
87
+ SQRT_TABLE = (0..64).map { |n| 2.0 / Math.sqrt(n + 1) }.freeze
84
88
 
85
89
  def calculate_match(entry)
86
90
  positions = []
@@ -107,14 +111,14 @@ class Fuzzy
107
111
  score += 1.0
108
112
 
109
113
  # Word boundary bonus (start of string or after non-alphanumeric)
110
- if found == 0 || text[found - 1].match?(/[^a-z0-9]/)
114
+ if found == 0 || text[found - 1].match?(WORD_BOUNDARY_RE)
111
115
  score += 1.0
112
116
  end
113
117
 
114
118
  # Proximity bonus (consecutive chars score higher)
115
119
  if last_pos >= 0
116
120
  gap = found - last_pos - 1
117
- score += gap < 16 ? SQRT_TABLE[gap] : (2.0 / Math.sqrt(gap + 1))
121
+ score += gap < 64 ? SQRT_TABLE[gap] : (2.0 / Math.sqrt(gap + 1))
118
122
  end
119
123
 
120
124
  last_pos = found
data/lib/tui.rb CHANGED
@@ -40,6 +40,10 @@ module Tui
40
40
  end
41
41
  end
42
42
 
43
+ # Precompiled regexes used in hot paths
44
+ ANSI_STRIP_RE = /\e\[[0-9;]*[A-Za-z]/
45
+ ESCAPE_TERMINATOR_RE = /[A-Za-z]/
46
+
43
47
  module ANSI
44
48
  CLEAR_EOL = "\e[K"
45
49
  CLEAR_EOS = "\e[J"
@@ -77,6 +81,10 @@ module Tui
77
81
  joined = codes.flatten.join(";")
78
82
  "\e[#{joined}m"
79
83
  end
84
+
85
+ def set_title(t)
86
+ "\e]2;#{t}\a"
87
+ end
80
88
  end
81
89
 
82
90
  module Palette
@@ -98,13 +106,15 @@ module Tui
98
106
 
99
107
  # Optimized width calculation - avoids per-character method calls
100
108
  def visible_width(text)
109
+ has_escape = text.include?("\e")
110
+
101
111
  # Fast path: pure ASCII with no escapes
102
- if text.bytesize == text.length && !text.include?("\e")
112
+ if !has_escape && text.bytesize == text.length
103
113
  return text.length
104
114
  end
105
115
 
106
116
  # Strip ANSI escapes only if present
107
- stripped = text.include?("\e") ? text.gsub(/\e\[[0-9;]*[A-Za-z]/, '') : text
117
+ stripped = has_escape ? text.gsub(ANSI_STRIP_RE, '') : text
108
118
 
109
119
  # Fast path after stripping: pure ASCII
110
120
  if stripped.bytesize == stripped.length
@@ -156,9 +166,9 @@ module Tui
156
166
  text.each_char do |ch|
157
167
  if in_escape
158
168
  escape_buf << ch
159
- if ch.match?(/[A-Za-z]/)
169
+ if ch.match?(ESCAPE_TERMINATOR_RE)
160
170
  truncated << escape_buf
161
- escape_buf = String.new
171
+ escape_buf.clear
162
172
  in_escape = false
163
173
  end
164
174
  next
@@ -166,7 +176,8 @@ module Tui
166
176
 
167
177
  if ch == "\e"
168
178
  in_escape = true
169
- escape_buf = ch
179
+ escape_buf.clear
180
+ escape_buf << ch
170
181
  next
171
182
  end
172
183
 
@@ -190,20 +201,19 @@ module Tui
190
201
  leading_escapes = String.new
191
202
  in_escape = false
192
203
  escape_buf = String.new
193
- text_start = 0
194
204
 
195
- text.each_char.with_index do |ch, i|
205
+ text.each_char do |ch|
196
206
  if in_escape
197
207
  escape_buf << ch
198
- if ch.match?(/[A-Za-z]/)
208
+ if ch.match?(ESCAPE_TERMINATOR_RE)
199
209
  leading_escapes << escape_buf
200
- escape_buf = String.new
210
+ escape_buf.clear
201
211
  in_escape = false
202
- text_start = i + 1
203
212
  end
204
213
  elsif ch == "\e"
205
214
  in_escape = true
206
- escape_buf = ch
215
+ escape_buf.clear
216
+ escape_buf << ch
207
217
  else
208
218
  # First non-escape character, stop collecting leading escapes
209
219
  break
@@ -219,7 +229,7 @@ module Tui
219
229
  text.each_char do |ch|
220
230
  if in_escape
221
231
  result << ch if skipped >= chars_to_skip
222
- in_escape = false if ch.match?(/[A-Za-z]/)
232
+ in_escape = false if ch.match?(ESCAPE_TERMINATOR_RE)
223
233
  next
224
234
  end
225
235
 
@@ -375,10 +385,9 @@ module Tui
375
385
 
376
386
  def flush
377
387
  refresh_size
378
- begin
379
- @io.write(ANSI::HOME)
380
- rescue IOError
381
- end
388
+
389
+ # Build entire frame in a single buffer to avoid flicker from partial writes
390
+ buf = String.new(ANSI::HOME)
382
391
 
383
392
  cursor_row = nil
384
393
  cursor_col = nil
@@ -390,7 +399,7 @@ module Tui
390
399
  cursor_row = current_row + 1
391
400
  cursor_col = line.cursor_column(@input_field, @width)
392
401
  end
393
- line.render(@io, @width)
402
+ line.render(buf, @width)
394
403
  current_row += 1
395
404
  end
396
405
 
@@ -406,7 +415,7 @@ module Tui
406
415
  cursor_row = current_row + 1
407
416
  cursor_col = line.cursor_column(@input_field, @width)
408
417
  end
409
- line.render(@io, @width)
418
+ line.render(buf, @width)
410
419
  current_row += 1
411
420
  body_rendered += 1
412
421
  end
@@ -419,9 +428,9 @@ module Tui
419
428
  gap.times do |i|
420
429
  # Last gap line without newline if no footer follows
421
430
  if i == gap - 1 && @footer.lines.empty?
422
- @io.write(blank_line_no_newline)
431
+ buf << blank_line_no_newline
423
432
  else
424
- @io.write(blank_line)
433
+ buf << blank_line
425
434
  end
426
435
  current_row += 1
427
436
  end
@@ -434,23 +443,29 @@ module Tui
434
443
  end
435
444
  # Last line: don't write \n to avoid scrolling
436
445
  if idx == footer_lines - 1
437
- line.render_no_newline(@io, @width)
446
+ line.render_no_newline(buf, @width)
438
447
  else
439
- line.render(@io, @width)
448
+ line.render(buf, @width)
440
449
  end
441
450
  current_row += 1
442
451
  end
443
452
 
444
453
  # Position cursor at input field if present, otherwise hide cursor
445
454
  if cursor_row && cursor_col && @input_field
446
- @io.write("\e[#{cursor_row};#{cursor_col}H")
447
- @io.write(ANSI::SHOW)
455
+ buf << "\e[#{cursor_row};#{cursor_col}H"
456
+ buf << ANSI::SHOW
448
457
  else
449
- @io.write(ANSI::HIDE)
458
+ buf << ANSI::HIDE
450
459
  end
451
460
 
452
- @io.write(ANSI::RESET)
453
- @io.flush
461
+ buf << ANSI::RESET
462
+
463
+ # Single write for the entire frame - eliminates flicker
464
+ begin
465
+ @io.write(buf)
466
+ @io.flush
467
+ rescue IOError
468
+ end
454
469
  ensure
455
470
  clear
456
471
  end
@@ -528,6 +543,16 @@ module Tui
528
543
  end
529
544
 
530
545
  def render(io, width)
546
+ render_line(io, width, trailing_newline: true)
547
+ end
548
+
549
+ def render_no_newline(io, width)
550
+ render_line(io, width, trailing_newline: false)
551
+ end
552
+
553
+ private
554
+
555
+ def render_line(io, width, trailing_newline:)
531
556
  buffer = String.new
532
557
  buffer << "\r"
533
558
  buffer << ANSI::CLEAR_EOL # Clear line before rendering to remove stale content
@@ -604,84 +629,9 @@ module Tui
604
629
  end
605
630
 
606
631
  buffer << ANSI::RESET
607
- buffer << "\n"
608
-
609
- io.write(buffer)
610
- end
611
-
612
- def render_no_newline(io, width)
613
- buffer = String.new
614
- buffer << "\r"
615
- buffer << ANSI::CLEAR_EOL
616
-
617
- buffer << background if background && Tui.colors_enabled?
618
-
619
- max_content = width - 1
620
- content_width = [width, 1].max
621
-
622
- left_text = @left.to_s(width: content_width)
623
- center_text = @center ? @center.to_s(width: content_width) : ""
624
- right_text = @right ? @right.to_s(width: content_width) : ""
625
-
626
- # Truncate left to fit line
627
- left_text = Metrics.truncate(left_text, max_content) if @truncate && !left_text.empty?
628
- left_width = left_text.empty? ? 0 : Metrics.visible_width(left_text)
629
-
630
- # Truncate center text to available space (never wrap)
631
- unless center_text.empty?
632
- max_center = max_content - left_width - 4
633
- if max_center > 0
634
- center_text = Metrics.truncate(center_text, max_center)
635
- else
636
- center_text = ""
637
- end
638
- end
639
- center_width = center_text.empty? ? 0 : Metrics.visible_width(center_text)
640
-
641
- # Calculate available space for right (need at least 1 space gap)
642
- used_by_left_center = left_width + center_width + (center_width > 0 ? 2 : 0)
643
- available_for_right = max_content - used_by_left_center - 1
644
-
645
- # Truncate right from the LEFT if needed (show trailing portion)
646
- right_width = 0
647
- unless right_text.empty?
648
- right_width = Metrics.visible_width(right_text)
649
- if available_for_right <= 0
650
- right_text = ""
651
- right_width = 0
652
- elsif right_width > available_for_right
653
- right_text = Metrics.truncate_from_start(right_text, available_for_right)
654
- right_width = Metrics.visible_width(right_text)
655
- end
656
- end
657
-
658
- # Calculate positions
659
- center_col = center_text.empty? ? 0 : [(max_content - center_width) / 2, left_width + 1].max
660
- right_col = right_text.empty? ? max_content : (max_content - right_width)
661
-
662
- buffer << left_text unless left_text.empty?
663
- current_pos = left_width
664
-
665
- unless center_text.empty?
666
- gap_to_center = center_col - current_pos
667
- buffer << (" " * gap_to_center) if gap_to_center > 0
668
- buffer << center_text
669
- current_pos = center_col + center_width
670
- end
671
-
672
- fill_end = right_text.empty? ? max_content : right_col
673
- gap = fill_end - current_pos
674
- buffer << (" " * gap) if gap > 0
675
-
676
- unless right_text.empty?
677
- buffer << right_text
678
- buffer << ANSI::RESET_FG
679
- end
680
-
681
- buffer << ANSI::RESET
682
- # No newline at end
632
+ buffer << "\n" if trailing_newline
683
633
 
684
- io.write(buffer)
634
+ io << buffer
685
635
  end
686
636
  end
687
637
 
@@ -792,15 +742,8 @@ module Tui
792
742
 
793
743
  # Fast width calculation using precomputed emoji widths
794
744
  def visible_width(rendered_str)
795
- if @has_wide
796
- # Has emoji - use delta: string length + extra width from wide chars
797
- stripped = rendered_str.include?("\e") ? rendered_str.gsub(/\e\[[0-9;]*[A-Za-z]/, '') : rendered_str
798
- stripped.length + @width_delta
799
- else
800
- # Pure ASCII - just string length
801
- stripped = rendered_str.include?("\e") ? rendered_str.gsub(/\e\[[0-9;]*[A-Za-z]/, '') : rendered_str
802
- stripped.length
803
- end
745
+ stripped = rendered_str.include?("\e") ? rendered_str.gsub(ANSI_STRIP_RE, '') : rendered_str
746
+ @has_wide ? stripped.length + @width_delta : stripped.length
804
747
  end
805
748
 
806
749
  def empty?
data/try.rb CHANGED
@@ -3,12 +3,18 @@
3
3
  require 'io/console'
4
4
  require 'time'
5
5
  require 'fileutils'
6
+ require 'set'
6
7
  require_relative 'lib/tui'
7
8
  require_relative 'lib/fuzzy'
8
9
 
9
10
  class TrySelector
10
11
  include Tui::Helpers
11
12
  TRY_PATH = ENV['TRY_PATH'] || File.expand_path("~/src/tries")
13
+ TRY_PROJECTS = ENV['TRY_PROJECTS']
14
+
15
+ # Precompiled regex constants
16
+ INPUT_CHAR_RE = /[a-zA-Z0-9\-\_\. ]/
17
+ WORD_CHAR_RE = /[a-zA-Z0-9]/
12
18
 
13
19
  def initialize(search_term = "", base_path: TRY_PATH, initial_input: nil, test_render_once: false, test_no_cls: false, test_keys: nil, test_confirm: nil)
14
20
  @search_term = search_term.gsub(/\s+/, '-')
@@ -68,10 +74,7 @@ class TrySelector
68
74
  def setup_terminal
69
75
  unless @test_no_cls
70
76
  # Switch to alternate screen buffer (like vim, less, etc.)
71
- STDERR.print(Tui::ANSI::ALT_SCREEN_ON)
72
- STDERR.print(Tui::ANSI::CLEAR_SCREEN)
73
- STDERR.print(Tui::ANSI::HOME)
74
- STDERR.print(Tui::ANSI::CURSOR_BLINK)
77
+ STDERR.print("#{Tui::ANSI::ALT_SCREEN_ON}#{Tui::ANSI.set_title("try")}#{Tui::ANSI::CURSOR_BLINK}")
75
78
  end
76
79
 
77
80
  @old_winch_handler = Signal.trap('WINCH') { @needs_redraw = true }
@@ -98,7 +101,11 @@ class TrySelector
98
101
  next if entry.start_with?('.')
99
102
 
100
103
  path = File.join(@base_path, entry)
101
- stat = File.stat(path)
104
+ begin
105
+ stat = File.stat(path)
106
+ rescue Errno::ENOENT, Errno::EACCES
107
+ next
108
+ end
102
109
 
103
110
  # Only include directories
104
111
  next unless stat.directory?
@@ -111,11 +118,14 @@ class TrySelector
111
118
  # Bonus for date-prefixed directories
112
119
  base_score += 2.0 if entry.match?(/^\d{4}-\d{2}-\d{2}-/)
113
120
 
121
+ is_symlink = File.symlink?(path)
122
+
114
123
  tries << {
115
124
  text: entry,
116
125
  basename: entry,
117
- path: path,
126
+ path: is_symlink ? File.realpath(path) : path,
118
127
  is_new: false,
128
+ is_symlink: is_symlink,
119
129
  ctime: stat.ctime,
120
130
  mtime: mtime,
121
131
  base_score: base_score
@@ -148,11 +158,19 @@ class TrySelector
148
158
  load_all_tries
149
159
  @fuzzy ||= Fuzzy.new(@all_tries)
150
160
 
161
+ # Cache results - only re-match when query changes
162
+ if @last_query == @input_buffer && @cached_results
163
+ return @cached_results
164
+ end
165
+
166
+ @last_query = @input_buffer
167
+ height = IO.console&.winsize&.first || 24
168
+ max_results = [height - 6, 3].max
151
169
  results = []
152
- @fuzzy.match(@input_buffer).each do |entry, positions, score|
170
+ @fuzzy.match(@input_buffer).limit(max_results).each do |entry, positions, score|
153
171
  results << TryEntry.new(entry, score, positions)
154
172
  end
155
- results
173
+ @cached_results = results
156
174
  end
157
175
 
158
176
  def main_loop
@@ -192,9 +210,9 @@ class TrySelector
192
210
  # Do nothing
193
211
  when "\e[D" # Left arrow - ignore
194
212
  # Do nothing
195
- when "\x7F", "\b" # Backspace
213
+ when "\x7F", "\b" # Backspace (DEL and BS)
196
214
  if @input_cursor_pos > 0
197
- @input_buffer = @input_buffer[0...(@input_cursor_pos-1)] + @input_buffer[@input_cursor_pos..-1]
215
+ @input_buffer = @input_buffer[0...(@input_cursor_pos-1)] + @input_buffer[@input_cursor_pos..]
198
216
  @input_cursor_pos -= 1
199
217
  end
200
218
  @cursor_pos = 0 # Reset list selection when typing
@@ -206,32 +224,12 @@ class TrySelector
206
224
  @input_cursor_pos = [@input_cursor_pos - 1, 0].max
207
225
  when "\x06" # Ctrl-F - forward char
208
226
  @input_cursor_pos = [@input_cursor_pos + 1, @input_buffer.length].min
209
- when "\x08" # Ctrl-H - backward delete char (same as backspace)
210
- if @input_cursor_pos > 0
211
- @input_buffer = @input_buffer[0...(@input_cursor_pos-1)] + @input_buffer[@input_cursor_pos..-1]
212
- @input_cursor_pos -= 1
213
- end
214
- @cursor_pos = 0
215
227
  when "\x0B" # Ctrl-K - kill to end of line
216
228
  @input_buffer = @input_buffer[0...@input_cursor_pos]
217
229
  when "\x17" # Ctrl-W - delete word backward (alphanumeric)
218
230
  if @input_cursor_pos > 0
219
- # Start from cursor position and move backward
220
- pos = @input_cursor_pos - 1
221
-
222
- # Skip trailing non-alphanumeric
223
- while pos >= 0 && @input_buffer[pos] !~ /[a-zA-Z0-9]/
224
- pos -= 1
225
- end
226
-
227
- # Skip backward over alphanumeric chars
228
- while pos >= 0 && @input_buffer[pos] =~ /[a-zA-Z0-9]/
229
- pos -= 1
230
- end
231
-
232
- # Delete from pos+1 to cursor
233
- new_pos = pos + 1
234
- @input_buffer = @input_buffer[0...new_pos] + @input_buffer[@input_cursor_pos..-1]
231
+ new_pos = word_boundary_backward(@input_buffer, @input_cursor_pos)
232
+ @input_buffer = @input_buffer[0...new_pos] + @input_buffer[@input_cursor_pos..]
235
233
  @input_cursor_pos = new_pos
236
234
  end
237
235
  when "\x04" # Ctrl-D - toggle mark for deletion
@@ -254,6 +252,11 @@ class TrySelector
254
252
  run_rename_dialog(tries[@cursor_pos])
255
253
  break if @selected
256
254
  end
255
+ when "\x07" # Ctrl-G - graduate/ascend selected entry
256
+ if @cursor_pos < tries.length
257
+ run_ascend_dialog(tries[@cursor_pos])
258
+ break if @selected
259
+ end
257
260
  when "\x03", "\e" # Ctrl-C or ESC
258
261
  if @delete_mode
259
262
  # Exit delete mode, clear marks
@@ -265,8 +268,8 @@ class TrySelector
265
268
  end
266
269
  when String
267
270
  # Only accept printable characters, not escape sequences
268
- if key.length == 1 && key =~ /[a-zA-Z0-9\-\_\. ]/
269
- @input_buffer = @input_buffer[0...@input_cursor_pos] + key + @input_buffer[@input_cursor_pos..-1]
271
+ if key.length == 1 && key.match?(INPUT_CHAR_RE)
272
+ @input_buffer = @input_buffer[0...@input_cursor_pos] + key + @input_buffer[@input_cursor_pos..]
270
273
  @input_cursor_pos += 1
271
274
  @cursor_pos = 0 # Reset list selection when typing
272
275
  end
@@ -300,8 +303,12 @@ class TrySelector
300
303
  return nil if input.nil?
301
304
 
302
305
  if input == "\e"
303
- input << STDIN.read_nonblock(3) rescue ""
304
- input << STDIN.read_nonblock(2) rescue ""
306
+ begin
307
+ input << STDIN.read_nonblock(3)
308
+ input << STDIN.read_nonblock(2)
309
+ rescue IO::WaitReadable, EOFError
310
+ # No more escape sequence data available
311
+ end
305
312
  end
306
313
 
307
314
  input
@@ -346,7 +353,7 @@ class TrySelector
346
353
  end
347
354
  else
348
355
  screen.footer.add_line do |line|
349
- line.center.write_dim("↑/↓: Navigate Enter: Select ^R: Rename ^D: Delete Esc: Cancel")
356
+ line.center.write_dim("↑/↓: Navigate Enter: Select ^R: Rename ^G: Graduate ^D: Delete Esc: Cancel")
350
357
  end
351
358
  end
352
359
 
@@ -391,7 +398,14 @@ class TrySelector
391
398
 
392
399
  line = screen.body.add_line(background: background)
393
400
  line.write << (is_selected ? Tui::Text.highlight("→ ") : " ")
394
- line.write << (is_marked ? emoji("🗑️") : emoji("📁")) << " "
401
+ icon = if is_marked
402
+ emoji("🗑️")
403
+ elsif entry[:is_symlink]
404
+ emoji("🔗")
405
+ else
406
+ emoji("📁")
407
+ end
408
+ line.write << icon << " "
395
409
 
396
410
  plain_name, rendered_name = formatted_entry_name(entry)
397
411
  prefix_width = 5
@@ -444,17 +458,34 @@ class TrySelector
444
458
  end
445
459
 
446
460
  def highlight_with_positions(text, positions, offset)
447
- result = ""
448
- text.chars.each_with_index do |char, i|
449
- if positions.include?(i + offset)
450
- result += Tui::Text.highlight(char)
461
+ pos_set = positions.is_a?(Set) ? positions : positions.to_set
462
+ result = String.new
463
+ chars = text.chars
464
+ i = 0
465
+ while i < chars.length
466
+ if pos_set.include?(i + offset)
467
+ # Batch consecutive highlighted characters
468
+ batch_start = i
469
+ i += 1
470
+ i += 1 while i < chars.length && pos_set.include?(i + offset)
471
+ result << Tui::Text.highlight(chars[batch_start...i].join)
451
472
  else
452
- result += char
473
+ result << chars[i]
474
+ i += 1
453
475
  end
454
476
  end
455
477
  result
456
478
  end
457
479
 
480
+ # Find the position of the previous word boundary for Ctrl-W deletion.
481
+ # Skips non-alphanumeric chars, then skips alphanumeric chars.
482
+ def word_boundary_backward(buffer, cursor)
483
+ pos = cursor - 1
484
+ pos -= 1 while pos >= 0 && !buffer[pos].match?(WORD_CHAR_RE)
485
+ pos -= 1 while pos >= 0 && buffer[pos].match?(WORD_CHAR_RE)
486
+ pos + 1
487
+ end
488
+
458
489
  def format_relative_time(time)
459
490
  return "?" unless time
460
491
 
@@ -542,11 +573,9 @@ class TrySelector
542
573
  rename_error = nil
543
574
  when "\x17" # Ctrl-W - delete word backward
544
575
  if rename_cursor > 0
545
- pos = rename_cursor - 1
546
- pos -= 1 while pos > 0 && rename_buffer[pos] !~ /[a-zA-Z0-9]/
547
- pos -= 1 while pos > 0 && rename_buffer[pos - 1] =~ /[a-zA-Z0-9]/
548
- rename_buffer = rename_buffer[0...pos] + rename_buffer[rename_cursor..].to_s
549
- rename_cursor = pos
576
+ new_pos = word_boundary_backward(rename_buffer, rename_cursor)
577
+ rename_buffer = rename_buffer[0...new_pos] + rename_buffer[rename_cursor..].to_s
578
+ rename_cursor = new_pos
550
579
  end
551
580
  rename_error = nil
552
581
  when String
@@ -612,6 +641,142 @@ class TrySelector
612
641
  true
613
642
  end
614
643
 
644
+ # Ascend dialog - promote a try to a permanent project directory
645
+ def run_ascend_dialog(entry)
646
+ @delete_mode = false
647
+ @marked_for_deletion.clear
648
+
649
+ current_name = entry[:basename]
650
+
651
+ # Strip date prefix for the default project name
652
+ project_name = current_name.sub(/^\d{4}-\d{2}-\d{2}-/, '')
653
+
654
+ # Compute default destination directory
655
+ projects_dir = if TRY_PROJECTS
656
+ File.expand_path(TRY_PROJECTS)
657
+ else
658
+ File.dirname(@base_path)
659
+ end
660
+
661
+ ascend_buffer = File.join(projects_dir, project_name)
662
+ ascend_cursor = ascend_buffer.length
663
+ ascend_error = nil
664
+
665
+ loop do
666
+ render_ascend_dialog(current_name, ascend_buffer, ascend_cursor, ascend_error, projects_dir)
667
+
668
+ ch = read_key
669
+ case ch
670
+ when "\r" # Enter - confirm
671
+ result = finalize_ascend(entry, ascend_buffer)
672
+ if result == true
673
+ break
674
+ else
675
+ ascend_error = result
676
+ end
677
+ when "\e", "\x03" # ESC or Ctrl-C - cancel
678
+ break
679
+ when "\x7F", "\b" # Backspace
680
+ if ascend_cursor > 0
681
+ ascend_buffer = ascend_buffer[0...(ascend_cursor - 1)] + ascend_buffer[ascend_cursor..].to_s
682
+ ascend_cursor -= 1
683
+ end
684
+ ascend_error = nil
685
+ when "\x01" # Ctrl-A - start of line
686
+ ascend_cursor = 0
687
+ when "\x05" # Ctrl-E - end of line
688
+ ascend_cursor = ascend_buffer.length
689
+ when "\x02" # Ctrl-B - back one char
690
+ ascend_cursor = [ascend_cursor - 1, 0].max
691
+ when "\x06" # Ctrl-F - forward one char
692
+ ascend_cursor = [ascend_cursor + 1, ascend_buffer.length].min
693
+ when "\x0B" # Ctrl-K - kill to end
694
+ ascend_buffer = ascend_buffer[0...ascend_cursor]
695
+ ascend_error = nil
696
+ when "\x17" # Ctrl-W - delete word backward
697
+ if ascend_cursor > 0
698
+ new_pos = word_boundary_backward(ascend_buffer, ascend_cursor)
699
+ ascend_buffer = ascend_buffer[0...new_pos] + ascend_buffer[ascend_cursor..].to_s
700
+ ascend_cursor = new_pos
701
+ end
702
+ ascend_error = nil
703
+ when String
704
+ if ch.length == 1 && ch =~ /[a-zA-Z0-9\-_\.\s\/~]/
705
+ ascend_buffer = ascend_buffer[0...ascend_cursor] + ch + ascend_buffer[ascend_cursor..].to_s
706
+ ascend_cursor += 1
707
+ ascend_error = nil
708
+ end
709
+ end
710
+ end
711
+
712
+ @needs_redraw = true
713
+ end
714
+
715
+ def render_ascend_dialog(current_name, ascend_buffer, ascend_cursor, ascend_error, projects_dir)
716
+ screen = Tui::Screen.new(io: STDERR)
717
+
718
+ screen.header.add_line do |line|
719
+ line.center << emoji("🚀") << Tui::Text.accent(" Graduate try to project")
720
+ end
721
+ screen.header.add_line { |line| line.write.write_dim(fill("─")) }
722
+
723
+ screen.body.add_line do |line|
724
+ line.write << emoji("📁") << " #{current_name}"
725
+ end
726
+ screen.body.add_line
727
+
728
+ env_hint = TRY_PROJECTS ? "$TRY_PROJECTS" : "parent of $TRY_PATH"
729
+ screen.body.add_line do |line|
730
+ line.center.write_dim("Destination (#{env_hint}: #{projects_dir})")
731
+ end
732
+
733
+ screen.body.add_line do |line|
734
+ prefix = "Move to: "
735
+ line.center.write_dim(prefix)
736
+ line.center << screen.input("", value: ascend_buffer, cursor: ascend_cursor).to_s
737
+ input_width = [ascend_buffer.length, ascend_cursor + 1].max
738
+ prefix_width = Tui::Metrics.visible_width(prefix)
739
+ max_content = screen.width - 1
740
+ center_start = (max_content - prefix_width - input_width) / 2
741
+ line.mark_has_input(center_start + prefix_width)
742
+ end
743
+
744
+ screen.body.add_line
745
+ screen.body.add_line do |line|
746
+ line.center.write_dim("A symlink will be left in the tries directory")
747
+ end
748
+
749
+ if ascend_error
750
+ screen.body.add_line
751
+ screen.body.add_line { |line| line.center.write_bold(ascend_error) }
752
+ end
753
+
754
+ screen.footer.add_line { |line| line.write.write_dim(fill("─")) }
755
+ screen.footer.add_line { |line| line.center.write_dim("Enter: Confirm Esc: Cancel") }
756
+
757
+ screen.flush
758
+ end
759
+
760
+ def finalize_ascend(entry, ascend_buffer)
761
+ dest = ascend_buffer.strip
762
+ dest = File.expand_path(dest)
763
+
764
+ return "Destination cannot be empty" if dest.empty?
765
+ return "Destination already exists: #{dest}" if File.exist?(dest)
766
+
767
+ parent = File.dirname(dest)
768
+ return "Parent directory does not exist: #{parent}" unless Dir.exist?(parent)
769
+
770
+ @selected = {
771
+ type: :ascend,
772
+ source: entry[:path],
773
+ dest: dest,
774
+ basename: entry[:basename],
775
+ base_path: @base_path
776
+ }
777
+ true
778
+ end
779
+
615
780
  def handle_selection(try_dir)
616
781
  # Select existing try directory
617
782
  @selected = { type: :cd, path: try_dir[:path] }
@@ -772,6 +937,8 @@ class TrySelector
772
937
  @delete_status = "Deleted: #{names}"
773
938
  @all_tries = nil # Clear cache
774
939
  @fuzzy = nil
940
+ @cached_results = nil
941
+ @last_query = nil
775
942
  @marked_for_deletion.clear
776
943
  @delete_mode = false
777
944
  rescue => e
@@ -788,7 +955,7 @@ end
788
955
  # Main execution with OptionParser subcommands
789
956
  if __FILE__ == $0
790
957
 
791
- VERSION = "1.7.1"
958
+ VERSION = "1.9.2"
792
959
 
793
960
  def print_global_help
794
961
  text = <<~HELP
@@ -822,11 +989,20 @@ if __FILE__ == $0
822
989
  Manual mode (without alias):
823
990
  try exec [query] Output shell script to eval
824
991
 
825
- Defaults:
826
- Default path: ~/src/tries
827
- Current: #{TrySelector::TRY_PATH}
992
+ Environment:
993
+ TRY_PATH Tries directory (default: ~/src/tries)
994
+ TRY_PROJECTS Graduate destination (default: parent of TRY_PATH)
995
+
996
+ Keyboard:
997
+ ↑/↓, Ctrl-P/N Navigate
998
+ Enter Select / Create new
999
+ Ctrl-R Rename
1000
+ Ctrl-G Graduate (promote try to project)
1001
+ Ctrl-D Mark for deletion
1002
+ Ctrl-T Create new try
1003
+ Esc Cancel
828
1004
  HELP
829
- STDOUT.print(text)
1005
+ STDERR.print(text)
830
1006
  end
831
1007
 
832
1008
  # Process color-related flags early
@@ -844,7 +1020,7 @@ if __FILE__ == $0
844
1020
 
845
1021
  # Version flag
846
1022
  if ARGV.include?("--version") || ARGV.include?("-v")
847
- puts "try #{VERSION}"
1023
+ STDERR.puts "try #{VERSION}"
848
1024
  exit 0
849
1025
  end
850
1026
 
@@ -940,6 +1116,7 @@ if __FILE__ == $0
940
1116
  when 'CTRL-D', 'CTRLD' then keys << "\x04"
941
1117
  when 'CTRL-E', 'CTRLE' then keys << "\x05"
942
1118
  when 'CTRL-F', 'CTRLF' then keys << "\x06"
1119
+ when 'CTRL-G', 'CTRLG' then keys << "\x07"
943
1120
  when 'CTRL-H', 'CTRLH' then keys << "\x08"
944
1121
  when 'CTRL-K', 'CTRLK' then keys << "\x0B"
945
1122
  when 'CTRL-N', 'CTRLN' then keys << "\x0E"
@@ -947,8 +1124,8 @@ if __FILE__ == $0
947
1124
  when 'CTRL-R', 'CTRLR' then keys << "\x12"
948
1125
  when 'CTRL-T', 'CTRLT' then keys << "\x14"
949
1126
  when 'CTRL-W', 'CTRLW' then keys << "\x17"
950
- when /^TYPE=(.*)$/
951
- $1.each_char { |ch| keys << ch }
1127
+ when /^TYPE=/i
1128
+ tok.sub(/^TYPE=/i, '').each_char { |ch| keys << ch }
952
1129
  else
953
1130
  keys << tok if tok.length == 1
954
1131
  end
@@ -995,11 +1172,13 @@ if __FILE__ == $0
995
1172
  def cmd_init!(args, tries_path)
996
1173
  script_path = File.expand_path($0)
997
1174
 
998
- if args[0] && args[0].start_with?('/')
999
- tries_path = File.expand_path(args.shift)
1175
+ explicit_path = if args[0] && args[0].start_with?('/')
1176
+ File.expand_path(args.shift)
1000
1177
  end
1001
1178
 
1002
- path_arg = tries_path ? " --path '#{tries_path}'" : ""
1179
+ # Priority: explicit init argument > $TRY_PATH (runtime) > default
1180
+ default_path = tries_path || File.expand_path("~/src/tries")
1181
+ path_arg = explicit_path ? " --path '#{explicit_path}'" : " --path \"${TRY_PATH:-#{default_path}}\""
1003
1182
  bash_or_zsh_script = <<~SHELL
1004
1183
  try() {
1005
1184
  local out
@@ -1012,10 +1191,11 @@ if __FILE__ == $0
1012
1191
  }
1013
1192
  SHELL
1014
1193
 
1194
+ fish_path_arg = explicit_path ? " --path '#{explicit_path}'" : " --path (if set -q TRY_PATH; echo \"$TRY_PATH\"; else; echo '#{default_path}'; end)"
1015
1195
  fish_script = <<~SHELL
1016
1196
  function try
1017
- set -l out (/usr/bin/env ruby '#{script_path}' exec#{path_arg} $argv 2>/dev/tty | string collect)
1018
- if test $status -eq 0
1197
+ set -l out (/usr/bin/env ruby '#{script_path}' exec#{fish_path_arg} $argv 2>/dev/tty | string collect)
1198
+ if test $pipestatus[1] -eq 0
1019
1199
  eval $out
1020
1200
  else
1021
1201
  echo $out
@@ -1093,6 +1273,8 @@ if __FILE__ == $0
1093
1273
  script_mkdir_cd(result[:path])
1094
1274
  when :rename
1095
1275
  script_rename(result[:base_path], result[:old], result[:new])
1276
+ when :ascend
1277
+ script_ascend(result[:source], result[:dest], result[:basename], result[:base_path])
1096
1278
  else
1097
1279
  script_cd(result[:path])
1098
1280
  end
@@ -1147,10 +1329,28 @@ if __FILE__ == $0
1147
1329
  def script_delete(paths, base_path)
1148
1330
  cmds = ["cd #{q(base_path)}"]
1149
1331
  paths.each { |item| cmds << "test -d #{q(item[:basename])} && rm -rf #{q(item[:basename])}" }
1150
- cmds << "( cd #{q(Dir.pwd)} 2>/dev/null || cd \"$HOME\" )"
1332
+ cmds << "cd #{q(Dir.pwd)} 2>/dev/null || cd #{q(base_path)}"
1151
1333
  cmds
1152
1334
  end
1153
1335
 
1336
+ def script_ascend(source, dest, basename, base_path)
1337
+ symlink_path = File.join(base_path, basename)
1338
+ # Check if source is a git worktree (has .git file, not directory)
1339
+ git_file = File.join(source, '.git')
1340
+ is_worktree = File.file?(git_file)
1341
+
1342
+ cmds = []
1343
+ if is_worktree
1344
+ # Use git worktree move for proper bookkeeping
1345
+ cmds << "git worktree move #{q(source)} #{q(dest)}"
1346
+ else
1347
+ cmds << "mv #{q(source)} #{q(dest)}"
1348
+ end
1349
+ cmds << "ln -s #{q(dest)} #{q(symlink_path)}"
1350
+ cmds << "echo #{q("Graduated: #{basename} → #{dest}")}"
1351
+ cmds + script_cd(dest)
1352
+ end
1353
+
1154
1354
  def script_rename(base_path, old_name, new_name)
1155
1355
  new_path = File.join(base_path, new_name)
1156
1356
  [
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: try-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.1
4
+ version: 1.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tobi Lutke
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2026-01-16 00:00:00.000000000 Z
11
+ date: 2026-03-10 00:00:00.000000000 Z
11
12
  dependencies: []
12
13
  description: A CLI tool for managing experimental projects. Creates dated directories
13
14
  for your tries, with fuzzy search and easy navigation.
@@ -25,13 +26,15 @@ files:
25
26
  - lib/fuzzy.rb
26
27
  - lib/tui.rb
27
28
  - try.rb
28
- homepage: https://github.com/tobi/try
29
+ homepage: https://pages.tobi.lutke.com/try/
29
30
  licenses:
30
31
  - MIT
31
32
  metadata:
32
- homepage_uri: https://github.com/tobi/try
33
+ homepage_uri: https://pages.tobi.lutke.com/try/
33
34
  source_code_uri: https://github.com/tobi/try
35
+ documentation_uri: https://pages.tobi.lutke.com/try/
34
36
  changelog_uri: https://github.com/tobi/try/releases
37
+ post_install_message:
35
38
  rdoc_options: []
36
39
  require_paths:
37
40
  - lib
@@ -47,7 +50,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
47
50
  - !ruby/object:Gem::Version
48
51
  version: '0'
49
52
  requirements: []
50
- rubygems_version: 3.6.2
53
+ rubygems_version: 3.5.22
54
+ signing_key:
51
55
  specification_version: 4
52
56
  summary: Experiments deserve a home
53
57
  test_files: []