hiiro 0.1.85 → 0.1.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 711ddf464139bf62e19c852705b3bc4663f563d5513ad5d254d82225ed18622a
4
- data.tar.gz: 407f809d3cd61e38badea49d6a0aa07da9e37bea82a2b51a8490fe38c380b8b4
3
+ metadata.gz: 2da3861933a1b2b03e5ff7cd9fa87d700e7f3f1ed8e6eb62463ec93c783e2a86
4
+ data.tar.gz: 9c5957829cae479530f68cccae440bc683285a93e1695db75c4a44dfa1a02503
5
5
  SHA512:
6
- metadata.gz: 6c3d6e03f0d0fecc10ee37035c445a4f5cd127c8c657e741a269a1901ab947aee813a6d71eb73e164cef6232fe5fd939362c7c8d503c1db30adbcd1834265367
7
- data.tar.gz: c5b40ea4b56ad100bca2d0670d3338187ff1effcde9c24b63695652b46056e5704366f5fb16364fa01906e945412e478bd35fd69dd0eec6988ac95c067e0119f
6
+ metadata.gz: 6c0c41355a0c33056574f1e4c61d937087e7b8fedb1db2fe864f41f571264c44c8c7c46af14b4e238d17f202f061f599fb982fa7fdcfddc7829051802462e7f8
7
+ data.tar.gz: 07a5888842492bf0aa9b9ed5444b9f9b12bb15ea14615b70eaeb44b4c0e41d9319e1ffdbab80eba422108a91cb68dfb5c0c20edc9f76500364eaf67ee5671d25
data/bin/h-branch CHANGED
@@ -14,6 +14,18 @@ class BranchManager
14
14
  @hiiro = hiiro
15
15
  end
16
16
 
17
+ def help
18
+ puts "Usage: h branch <subcommand> [args]"
19
+ puts
20
+ puts "Subcommands:"
21
+ puts " save Record current branch for this task"
22
+ puts " history List branch history (oldest to newest)"
23
+ puts " history --worktree=X Filter by worktree"
24
+ puts " history --task=X Filter by task"
25
+ puts " history --session=X Filter by tmux session"
26
+ puts " current Show current branch info"
27
+ end
28
+
17
29
  def save
18
30
  branch_name = current_branch
19
31
  unless branch_name
@@ -50,6 +62,35 @@ class BranchManager
50
62
  true
51
63
  end
52
64
 
65
+ def history(args = [])
66
+ filters = parse_filters(args)
67
+ data = load_data
68
+ branches = data['branches'] || []
69
+
70
+ if branches.empty?
71
+ puts "No branches recorded."
72
+ puts "Use 'h branch save' to record the current branch."
73
+ return
74
+ end
75
+
76
+ # Apply filters
77
+ branches = filter_entries(branches, filters)
78
+
79
+ if branches.empty?
80
+ puts "No branches match the given filters."
81
+ return
82
+ end
83
+
84
+ # Sort by created_at (oldest first)
85
+ branches = branches.sort_by { |b| b['created_at'] || '' }
86
+
87
+ puts "Branch history (oldest to newest):"
88
+ puts
89
+ branches.each_with_index do |branch, idx|
90
+ puts format_entry(branch, idx + 1)
91
+ end
92
+ end
93
+
53
94
  def current
54
95
  branch_name = current_branch
55
96
  unless branch_name
@@ -91,6 +132,48 @@ class BranchManager
91
132
  }
92
133
  end
93
134
 
135
+ def parse_filters(args)
136
+ filters = {}
137
+ args.each do |arg|
138
+ case arg
139
+ when /^--worktree=(.+)$/
140
+ filters[:worktree] = $1
141
+ when /^--task=(.+)$/
142
+ filters[:task] = $1
143
+ when /^--session=(.+)$/
144
+ filters[:session] = $1
145
+ when /^-w(.+)$/
146
+ filters[:worktree] = $1
147
+ when /^-t(.+)$/
148
+ filters[:task] = $1
149
+ when /^-s(.+)$/
150
+ filters[:session] = $1
151
+ end
152
+ end
153
+ filters
154
+ end
155
+
156
+ def filter_entries(entries, filters)
157
+ entries.select do |entry|
158
+ next false if filters[:worktree] && !entry['worktree']&.include?(filters[:worktree])
159
+ next false if filters[:task] && !entry['task']&.include?(filters[:task])
160
+ next false if filters[:session] && entry.dig('tmux', 'session') != filters[:session]
161
+ true
162
+ end
163
+ end
164
+
165
+ def format_entry(entry, num)
166
+ lines = []
167
+ lines << "#{num}. #{entry['name']}"
168
+ lines << " Task: #{entry['task'] || '(none)'}"
169
+ lines << " Worktree: #{entry['worktree'] || '(none)'}"
170
+ if entry['tmux']
171
+ lines << " Tmux: #{entry['tmux']['session']}/#{entry['tmux']['window']}"
172
+ end
173
+ lines << " Created: #{entry['created_at']}"
174
+ lines.join("\n")
175
+ end
176
+
94
177
  def show_entry(entry)
95
178
  puts " Branch: #{entry[:name]}"
96
179
  puts " Task: #{entry[:task] || '(none)'}"
@@ -122,8 +205,7 @@ manager = BranchManager.new(hiiro)
122
205
  hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
123
206
  hiiro.add_subcmd(:save) { manager.save }
124
207
  hiiro.add_subcmd(:history) { |*args| manager.history(args) }
125
- hiiro.add_subcmd(:current) { print `git branch --show-current` }
126
- hiiro.add_subcmd(:info) { manager.current }
208
+ hiiro.add_subcmd(:current) { manager.current }
127
209
  hiiro.add_subcmd(:select) do |*args|
128
210
  branches = hiiro.git.branches(sort_by: 'authordate', ignore_case: true)
129
211
 
@@ -259,36 +341,6 @@ hiiro.add_subcmd(:diff) { |*args|
259
341
  system('git', 'log', '--oneline', '--decorate', range)
260
342
  }
261
343
 
262
- hiiro.add_subcmd(:changed) { |*args|
263
- show_all = args.delete('-a') || args.delete('--all')
264
- upstream = args.first
265
-
266
- upstream ||= %w[origin/master master origin/main main].find { |ref|
267
- system("git rev-parse --verify #{ref} >/dev/null 2>&1")
268
- }
269
-
270
- unless upstream
271
- puts "Cannot find master or origin/master"
272
- next
273
- end
274
-
275
- forkpoint = `git merge-base --fork-point #{upstream} HEAD 2>/dev/null`.strip
276
- if forkpoint.empty?
277
- forkpoint = `git merge-base #{upstream} HEAD 2>/dev/null`.strip
278
- end
279
-
280
- if forkpoint.empty?
281
- puts "Could not find fork point"
282
- next
283
- end
284
-
285
- if show_all
286
- system('git', 'diff', '--name-only', "#{forkpoint}...HEAD")
287
- else
288
- system('git', 'diff', '--name-only', '--relative', "#{forkpoint}...HEAD")
289
- end
290
- }
291
-
292
344
  hiiro.add_subcmd(:ahead) { |*args|
293
345
  case args.length
294
346
  when 0
@@ -323,115 +375,6 @@ hiiro.add_subcmd(:behind) { |*args|
323
375
  puts "#{branch} is #{count} commit(s) behind #{behind_of}"
324
376
  }
325
377
 
326
- hiiro.add_subcmd(:log) { |upstream = nil|
327
- upstream ||= %w[origin/master master origin/main main].find { |ref|
328
- system("git rev-parse --verify #{ref} >/dev/null 2>&1")
329
- }
330
-
331
- unless upstream
332
- puts "Cannot find master or origin/master"
333
- next
334
- end
335
-
336
- forkpoint = `git merge-base --fork-point #{upstream} HEAD 2>/dev/null`.strip
337
- if forkpoint.empty?
338
- forkpoint = `git merge-base #{upstream} HEAD 2>/dev/null`.strip
339
- end
340
-
341
- if forkpoint.empty?
342
- puts "Could not find fork point"
343
- next
344
- end
345
-
346
- system('git', 'log', '--oneline', '--decorate', "#{forkpoint}..HEAD")
347
- }
348
-
349
- hiiro.add_subcmd(:forkpoint) { |upstream = nil, branch = nil|
350
- branch ||= 'HEAD'
351
- upstream ||= %w[origin/master master origin/main main].find { |ref|
352
- system("git rev-parse --verify #{ref} >/dev/null 2>&1")
353
- }
354
-
355
- unless upstream
356
- puts "Cannot find master or origin/master"
357
- next
358
- end
359
-
360
- forkpoint = `git merge-base --fork-point #{upstream} #{branch} 2>/dev/null`.strip
361
-
362
- if forkpoint.empty?
363
- # Fallback to regular merge-base if fork-point fails
364
- forkpoint = `git merge-base #{upstream} #{branch} 2>/dev/null`.strip
365
- end
366
-
367
- if forkpoint.empty?
368
- puts "Could not find fork point between #{upstream} and #{branch}"
369
- else
370
- puts forkpoint
371
- end
372
- }
373
-
374
- hiiro.add_subcmd(:changed) { |*args|
375
- show_all = args.delete('-a') || args.delete('--all')
376
- upstream = args.first
377
-
378
- upstream ||= %w[origin/master master origin/main main].find { |ref|
379
- system("git rev-parse --verify #{ref} >/dev/null 2>&1")
380
- }
381
-
382
- unless upstream
383
- puts "Cannot find master or origin/master"
384
- next
385
- end
386
-
387
- forkpoint = `git merge-base --fork-point #{upstream} HEAD 2>/dev/null`.strip
388
- if forkpoint.empty?
389
- forkpoint = `git merge-base #{upstream} HEAD 2>/dev/null`.strip
390
- end
391
-
392
- if forkpoint.empty?
393
- puts "Could not find fork point"
394
- next
395
- end
396
-
397
- if show_all
398
- system('git', 'diff', '--name-only', "#{forkpoint}...HEAD")
399
- else
400
- system('git', 'diff', '--name-only', '--relative', "#{forkpoint}...HEAD")
401
- end
402
- }
403
-
404
-
405
- hiiro.add_subcmd(:changed) { |*args|
406
- show_all = args.delete('-a') || args.delete('--all')
407
- upstream = args.first
408
-
409
- upstream ||= %w[origin/master master origin/main main].find { |ref|
410
- system("git rev-parse --verify #{ref} >/dev/null 2>&1")
411
- }
412
-
413
- unless upstream
414
- puts "Cannot find master or origin/master"
415
- next
416
- end
417
-
418
- forkpoint = `git merge-base --fork-point #{upstream} HEAD 2>/dev/null`.strip
419
- if forkpoint.empty?
420
- forkpoint = `git merge-base #{upstream} HEAD 2>/dev/null`.strip
421
- end
422
-
423
- if forkpoint.empty?
424
- puts "Could not find fork point"
425
- next
426
- end
427
-
428
- if show_all
429
- system('git', 'diff', '--name-only', "#{forkpoint}...HEAD")
430
- else
431
- system('git', 'diff', '--name-only', '--relative', "#{forkpoint}...HEAD")
432
- end
433
- }
434
-
435
378
  hiiro.add_subcmd(:ancestor) { |*args|
436
379
  case args.length
437
380
  when 0
@@ -458,4 +401,6 @@ hiiro.add_subcmd(:ancestor) { |*args|
458
401
  end
459
402
  }
460
403
 
404
+ hiiro.add_default { manager.help }
405
+
461
406
  hiiro.run
data/bin/h-pane CHANGED
@@ -60,6 +60,17 @@ o.add_subcmd(:resize) { |*args|
60
60
  system('tmux', 'resize-pane', *args)
61
61
  }
62
62
 
63
+ o.add_subcmd(:history) { |pane_id=nil|
64
+ entries = Hiiro::History.by_pane(pane_id)
65
+ if entries.empty?
66
+ puts "No history for this pane"
67
+ else
68
+ puts "History for pane: #{pane_id || ENV['TMUX_PANE'] || 'current'}"
69
+ puts
70
+ entries.last(20).each_with_index { |e, i| puts e.oneline(i + 1) }
71
+ end
72
+ }
73
+
63
74
  o.add_subcmd(:select) do |*args|
64
75
  # Get all panes with details
65
76
  output = `tmux list-panes -a -F '\#{session_name}:\#{window_index}.\#{pane_index} [\#{pane_current_command}] \#{pane_current_path}'`.strip
data/bin/h-pr CHANGED
@@ -21,6 +21,10 @@ class PRManager
21
21
  puts
22
22
  puts "Subcommands:"
23
23
  puts " save [PR_NUMBER] Record PR for this task (auto-detects if omitted)"
24
+ puts " history List PR history (oldest to newest)"
25
+ puts " history --worktree=X Filter by worktree"
26
+ puts " history --task=X Filter by task"
27
+ puts " history --session=X Filter by tmux session"
24
28
  puts " current Show current branch's PR info"
25
29
  puts " open [PR_NUMBER] Open PR in browser"
26
30
  puts " view [PR_NUMBER] View PR details in terminal"
@@ -63,6 +67,35 @@ class PRManager
63
67
  true
64
68
  end
65
69
 
70
+ def history(args = [])
71
+ filters = parse_filters(args)
72
+ data = load_data
73
+ prs = data['prs'] || []
74
+
75
+ if prs.empty?
76
+ puts "No PRs recorded."
77
+ puts "Use 'h pr save' to record the current PR."
78
+ return
79
+ end
80
+
81
+ # Apply filters
82
+ prs = filter_entries(prs, filters)
83
+
84
+ if prs.empty?
85
+ puts "No PRs match the given filters."
86
+ return
87
+ end
88
+
89
+ # Sort by created_at (oldest first)
90
+ prs = prs.sort_by { |p| p['created_at'] || '' }
91
+
92
+ puts "PR history (oldest to newest):"
93
+ puts
94
+ prs.each_with_index do |pr, idx|
95
+ puts format_entry(pr, idx + 1)
96
+ end
97
+ end
98
+
66
99
  def current
67
100
  pr_info = fetch_pr_info
68
101
  unless pr_info
@@ -136,6 +169,36 @@ class PRManager
136
169
  }
137
170
  end
138
171
 
172
+ def parse_filters(args)
173
+ filters = {}
174
+ args.each do |arg|
175
+ case arg
176
+ when /^--worktree=(.+)$/
177
+ filters[:worktree] = $1
178
+ when /^--task=(.+)$/
179
+ filters[:task] = $1
180
+ when /^--session=(.+)$/
181
+ filters[:session] = $1
182
+ when /^-w(.+)$/
183
+ filters[:worktree] = $1
184
+ when /^-t(.+)$/
185
+ filters[:task] = $1
186
+ when /^-s(.+)$/
187
+ filters[:session] = $1
188
+ end
189
+ end
190
+ filters
191
+ end
192
+
193
+ def filter_entries(entries, filters)
194
+ entries.select do |entry|
195
+ next false if filters[:worktree] && !entry['worktree']&.include?(filters[:worktree])
196
+ next false if filters[:task] && !entry['task']&.include?(filters[:task])
197
+ next false if filters[:session] && entry.dig('tmux', 'session') != filters[:session]
198
+ true
199
+ end
200
+ end
201
+
139
202
  def format_entry(entry, num)
140
203
  lines = []
141
204
  lines << "#{num}. PR ##{entry['number']}: #{entry['title']}"
@@ -478,6 +541,7 @@ hiiro.add_subcmd(:link) { |*args|
478
541
 
479
542
  hiiro.add_subcmd(:edit) { system(ENV['EDITOR'] || 'nvim', __FILE__) }
480
543
  hiiro.add_subcmd(:save) { |pr_number=nil| manager.save(pr_number) }
544
+ hiiro.add_subcmd(:history) { |*args| manager.history(args) }
481
545
  hiiro.add_subcmd(:current) { manager.current }
482
546
  hiiro.add_subcmd(:open) { |pr_number=nil| manager.open(pr_number) }
483
547
  hiiro.add_subcmd(:view) { |pr_number=nil| manager.view(pr_number) }
data/bin/h-session CHANGED
@@ -60,4 +60,16 @@ o.add_subcmd(:info) { |*args|
60
60
  system('tmux', 'display-message', '-p', '#{session_name}: #{session_windows} windows, #{session_attached} attached', *args)
61
61
  }
62
62
 
63
+ o.add_subcmd(:history) { |session_name=nil|
64
+ session_name ||= `tmux display-message -p '#S'`.strip if ENV['TMUX']
65
+ entries = Hiiro::History.by_session(session_name)
66
+ if entries.empty?
67
+ puts "No history for this session"
68
+ else
69
+ puts "History for session: #{session_name || 'current'}"
70
+ puts
71
+ entries.last(20).each_with_index { |e, i| puts e.oneline(i + 1) }
72
+ end
73
+ }
74
+
63
75
  o.run
data/bin/h-window CHANGED
@@ -56,6 +56,18 @@ o.add_subcmd(:unlink) { |*args|
56
56
  system('tmux', 'unlink-window', *args)
57
57
  }
58
58
 
59
+ o.add_subcmd(:history) { |window_id=nil|
60
+ window_id ||= `tmux display-message -p '#I'`.strip if ENV['TMUX']
61
+ entries = Hiiro::History.by_window(window_id)
62
+ if entries.empty?
63
+ puts "No history for this window"
64
+ else
65
+ puts "History for window: #{window_id || 'current'}"
66
+ puts
67
+ entries.last(20).each_with_index { |e, i| puts e.oneline(i + 1) }
68
+ end
69
+ }
70
+
59
71
  o.add_subcmd(:select) do |*args|
60
72
  # Get all windows with details
61
73
  output = `tmux list-windows -a -F '\#{session_name}:\#{window_index} \#{window_name} [\#{window_panes} panes]'`.strip
@@ -0,0 +1,136 @@
1
+ class Hiiro
2
+ class History
3
+ class Entry
4
+ FIELDS = %i[
5
+ id timestamp source cmd description pwd
6
+ tmux_session tmux_window tmux_pane
7
+ git_branch git_sha git_origin_sha git_worktree
8
+ task subtask app
9
+ ].freeze
10
+
11
+ attr_reader(*FIELDS)
12
+
13
+ def initialize(data)
14
+ data ||= {}
15
+ @id = data['id']
16
+ @timestamp = data['timestamp']
17
+ @source = data['source']
18
+ @cmd = data['cmd']
19
+ @description = data['description']
20
+ @pwd = data['pwd']
21
+ @tmux_session = data['tmux_session']
22
+ @tmux_window = data['tmux_window']
23
+ @tmux_pane = data['tmux_pane']
24
+ @git_branch = data['git_branch']
25
+ @git_sha = data['git_sha']
26
+ @git_origin_sha = data['git_origin_sha']
27
+ @git_worktree = data['git_worktree']
28
+ @task = data['task']
29
+ @subtask = data['subtask']
30
+ @app = data['app']
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ 'id' => id,
36
+ 'timestamp' => timestamp,
37
+ 'source' => source,
38
+ 'cmd' => cmd,
39
+ 'description' => description,
40
+ 'pwd' => pwd,
41
+ 'tmux_session' => tmux_session,
42
+ 'tmux_window' => tmux_window,
43
+ 'tmux_pane' => tmux_pane,
44
+ 'git_branch' => git_branch,
45
+ 'git_sha' => git_sha,
46
+ 'git_origin_sha' => git_origin_sha,
47
+ 'git_worktree' => git_worktree,
48
+ 'task' => task,
49
+ 'subtask' => subtask,
50
+ 'app' => app,
51
+ }.compact
52
+ end
53
+
54
+ # Data used for change detection (excludes timestamp, id, cmd)
55
+ def state_key
56
+ {
57
+ 'pwd' => pwd,
58
+ 'tmux_session' => tmux_session,
59
+ 'tmux_window' => tmux_window,
60
+ 'tmux_pane' => tmux_pane,
61
+ 'git_branch' => git_branch,
62
+ 'git_sha' => git_sha,
63
+ 'git_origin_sha' => git_origin_sha,
64
+ 'git_worktree' => git_worktree,
65
+ 'task' => task,
66
+ 'subtask' => subtask,
67
+ 'app' => app,
68
+ }.compact
69
+ end
70
+
71
+ def state_fingerprint
72
+ Digest::SHA256.hexdigest(state_key.to_yaml)[0..15]
73
+ end
74
+
75
+ def short_line(index)
76
+ time_str = timestamp ? Time.parse(timestamp).strftime('%Y-%m-%d %H:%M') : Time.now.strftime('%Y-%m-%d %H:%M')
77
+ desc = description || cmd || '(no description)'
78
+ desc = desc[0..50] + '...' if desc.length > 53
79
+ [
80
+ format('%3d %s %s', index, time_str, desc),
81
+ format(' %s @ %s', git_branch || '(no branch)', task || '(no task)'),
82
+ ].join("\n")
83
+ end
84
+
85
+ def oneline(index = nil)
86
+ time_str = timestamp ? Time.parse(timestamp).strftime('%m/%d %H:%M') : ''
87
+ prefix = index ? format('%3d ', index) : ''
88
+ sha_str = git_sha ? git_sha[0..6] : '-------'
89
+ branch_str = git_branch ? "[#{git_branch}]" : ''
90
+ task_str = task ? "(#{task})" : ''
91
+ cmd_str = cmd || description || ''
92
+ cmd_str = cmd_str[0..35] + '...' if cmd_str.length > 38
93
+
94
+ "#{prefix}#{time_str} #{sha_str} #{branch_str.ljust(20)} #{task_str.ljust(15)} #{cmd_str}"
95
+ end
96
+
97
+ def full_display
98
+ lines = []
99
+ lines << "ID: #{id}"
100
+ lines << "Timestamp: #{timestamp}"
101
+ lines << "Description: #{description}" if description
102
+ lines << "Command: #{cmd}" if cmd
103
+ lines << "PWD: #{pwd}" if pwd
104
+ lines << "Source: #{source}" if source
105
+ lines << ""
106
+ lines << "Tmux:"
107
+ lines << " Session: #{tmux_session}" if tmux_session
108
+ lines << " Window: #{tmux_window}" if tmux_window
109
+ lines << " Pane: #{tmux_pane}" if tmux_pane
110
+ lines << ""
111
+ lines << "Git:"
112
+ lines << " Worktree: #{git_worktree}" if git_worktree
113
+ lines << " Branch: #{git_branch}" if git_branch
114
+ lines << " SHA: #{git_sha}" if git_sha
115
+ lines << " Origin: #{git_origin_sha}" if git_origin_sha
116
+ lines << ""
117
+ lines << "Task:"
118
+ lines << " Task: #{task}" if task
119
+ lines << " Subtask: #{subtask}" if subtask
120
+ lines << " App: #{app}" if app
121
+ lines.join("\n")
122
+ end
123
+
124
+ # Filter matching
125
+ def matches?(filters)
126
+ filters.all? do |key, value|
127
+ entry_value = send(key) rescue nil
128
+ next true if value.nil?
129
+ next entry_value == value if value.is_a?(String)
130
+ next value.include?(entry_value) if value.is_a?(Array)
131
+ true
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,454 @@
1
+ require 'yaml'
2
+ require 'time'
3
+ require 'fileutils'
4
+ require 'digest'
5
+ require_relative 'history/entry'
6
+
7
+ class Hiiro
8
+ class History
9
+ HISTORY_DIR = File.join(Dir.home, '.config/hiiro/history')
10
+ HISTORY_FILE = File.join(HISTORY_DIR, 'entries.yml')
11
+ LAST_STATE_FILE = File.join(HISTORY_DIR, 'last_state.yml')
12
+
13
+ class << self
14
+ def load(hiiro)
15
+ history = new
16
+
17
+ hiiro.add_subcmd(:history) do |*args|
18
+ subcmd, *rest = args
19
+ case subcmd
20
+ when 'ls', 'list', nil
21
+ history.list(limit: 20)
22
+ when 'all'
23
+ history.list(limit: nil)
24
+ when 'show'
25
+ history.show(rest.first)
26
+ when 'goto'
27
+ history.goto(rest.first)
28
+ when 'add'
29
+ history.add_manual(rest.join(' '), hiiro: hiiro)
30
+ when 'by'
31
+ # h history by pane|window|session|task|branch [value]
32
+ dimension, value = rest
33
+ history.list_by(dimension, value)
34
+ when 'clear'
35
+ history.clear!
36
+ puts "History cleared."
37
+ else
38
+ puts "Unknown history subcommand: #{subcmd}"
39
+ puts "Available: ls, all, show <id>, goto <id>, add <description>, by <dimension> [value], clear"
40
+ false
41
+ end
42
+ end
43
+ end
44
+
45
+ # Called automatically when a command runs (if in a task context)
46
+ def track(cmd:, hiiro: nil)
47
+ new.track_command(cmd: cmd, hiiro: hiiro)
48
+ end
49
+
50
+ def log(description: nil, pwd: Dir.pwd, cmd: nil, source: nil, task: nil, subtask: nil)
51
+ new.add(
52
+ description: description,
53
+ cmd: cmd,
54
+ pwd: pwd,
55
+ source: source,
56
+ task: task,
57
+ subtask: subtask
58
+ )
59
+ end
60
+
61
+ # Query helpers for other commands (e.g., h pane history)
62
+ def by_pane(pane_id = nil)
63
+ pane_id ||= current_tmux_pane
64
+ new.filter(tmux_pane: pane_id)
65
+ end
66
+
67
+ def by_window(window_id = nil)
68
+ window_id ||= current_tmux_window
69
+ new.filter(tmux_window: window_id)
70
+ end
71
+
72
+ def by_session(session_name = nil)
73
+ session_name ||= current_tmux_session
74
+ new.filter(tmux_session: session_name)
75
+ end
76
+
77
+ def by_task(task_name)
78
+ new.filter(task: task_name)
79
+ end
80
+
81
+ def by_branch(branch_name)
82
+ new.filter(git_branch: branch_name)
83
+ end
84
+
85
+ def by_worktree(worktree_path)
86
+ new.filter(git_worktree: worktree_path)
87
+ end
88
+
89
+ def by_app(app_name)
90
+ new.filter(app: app_name)
91
+ end
92
+
93
+ private
94
+
95
+ def current_tmux_pane
96
+ return nil unless ENV['TMUX']
97
+ `tmux display-message -p '#D'`.strip rescue nil
98
+ end
99
+
100
+ def current_tmux_window
101
+ return nil unless ENV['TMUX']
102
+ `tmux display-message -p '#S:#I'`.strip rescue nil
103
+ end
104
+
105
+ def current_tmux_session
106
+ return nil unless ENV['TMUX']
107
+ `tmux display-message -p '#S'`.strip rescue nil
108
+ end
109
+ end
110
+
111
+ def initialize
112
+ ensure_history_dir
113
+ end
114
+
115
+ def entries
116
+ @entries ||= load_entries
117
+ end
118
+
119
+ def reload
120
+ @entries = nil
121
+ entries
122
+ end
123
+
124
+ def filter(filters = {})
125
+ entries.select { |e| e.matches?(filters) }
126
+ end
127
+
128
+ def list(limit: 20)
129
+ items = entries.reverse
130
+ items = items.first(limit) if limit
131
+
132
+ if items.empty?
133
+ puts "No history entries."
134
+ return true
135
+ end
136
+
137
+ items.reverse.each_with_index do |entry, idx|
138
+ puts entry.oneline(idx + 1)
139
+ end
140
+ true
141
+ end
142
+
143
+ def list_by(dimension, value = nil)
144
+ dimension_map = {
145
+ 'pane' => :tmux_pane,
146
+ 'window' => :tmux_window,
147
+ 'session' => :tmux_session,
148
+ 'task' => :task,
149
+ 'subtask' => :subtask,
150
+ 'branch' => :git_branch,
151
+ 'worktree' => :git_worktree,
152
+ 'app' => :app,
153
+ }
154
+
155
+ field = dimension_map[dimension]
156
+ unless field
157
+ puts "Unknown dimension: #{dimension}"
158
+ puts "Available: #{dimension_map.keys.join(', ')}"
159
+ return false
160
+ end
161
+
162
+ # If no value specified, use current context
163
+ value ||= current_value_for(field)
164
+
165
+ if value.nil?
166
+ puts "No current #{dimension} detected. Please specify a value."
167
+ return false
168
+ end
169
+
170
+ filtered = filter(field => value)
171
+
172
+ if filtered.empty?
173
+ puts "No history for #{dimension}=#{value}"
174
+ return true
175
+ end
176
+
177
+ puts "History for #{dimension}: #{value}"
178
+ puts
179
+ filtered.last(20).each_with_index do |entry, idx|
180
+ puts entry.oneline(idx + 1)
181
+ end
182
+ true
183
+ end
184
+
185
+ def show(ref)
186
+ entry = find_entry(ref)
187
+ unless entry
188
+ puts "Entry not found: #{ref}"
189
+ return false
190
+ end
191
+
192
+ puts entry.full_display
193
+ true
194
+ end
195
+
196
+ def goto(ref)
197
+ entry = find_entry(ref)
198
+ unless entry
199
+ puts "Entry not found: #{ref}"
200
+ return false
201
+ end
202
+
203
+ unless entry.tmux_session
204
+ puts "No tmux session recorded for this entry"
205
+ return false
206
+ end
207
+
208
+ target = entry.tmux_session
209
+ target += ":#{entry.tmux_window}" if entry.tmux_window
210
+ target += ".#{entry.tmux_pane}" if entry.tmux_pane
211
+
212
+ system('tmux', 'switch-client', '-t', target)
213
+ end
214
+
215
+ def add_manual(description, hiiro: nil)
216
+ add(
217
+ description: description,
218
+ source: 'manual',
219
+ cmd: hiiro&.full_command,
220
+ )
221
+ true
222
+ end
223
+
224
+ # Main tracking method - gathers all context and saves if changed
225
+ def track_command(cmd:, hiiro: nil, force: false)
226
+ context = gather_context
227
+ return nil unless context[:task] || force # Only track if in a task context (unless forced)
228
+
229
+ context[:cmd] = cmd
230
+ context[:source] = 'auto'
231
+
232
+ # Check if state changed
233
+ unless force
234
+ new_state = build_state_key(context)
235
+ return nil if state_unchanged?(new_state)
236
+ save_last_state(new_state)
237
+ end
238
+
239
+ add(**context)
240
+ end
241
+
242
+ def add(
243
+ description: nil, cmd: nil, source: nil, pwd: nil,
244
+ tmux_session: nil, tmux_window: nil, tmux_pane: nil,
245
+ git_branch: nil, git_sha: nil, git_origin_sha: nil, git_worktree: nil,
246
+ task: nil, subtask: nil, app: nil
247
+ )
248
+ context = gather_context
249
+
250
+ entry_data = {
251
+ 'id' => generate_id,
252
+ 'timestamp' => Time.now.iso8601,
253
+ 'description' => description,
254
+ 'cmd' => cmd,
255
+ 'pwd' => pwd || context[:pwd],
256
+ 'source' => source,
257
+ 'tmux_session' => tmux_session || context[:tmux_session],
258
+ 'tmux_window' => tmux_window || context[:tmux_window],
259
+ 'tmux_pane' => tmux_pane || context[:tmux_pane],
260
+ 'git_branch' => git_branch || context[:git_branch],
261
+ 'git_sha' => git_sha || context[:git_sha],
262
+ 'git_origin_sha' => git_origin_sha || context[:git_origin_sha],
263
+ 'git_worktree' => git_worktree || context[:git_worktree],
264
+ 'task' => task || context[:task],
265
+ 'subtask' => subtask || context[:subtask],
266
+ 'app' => app || context[:app],
267
+ }.compact
268
+
269
+ all = load_raw_entries
270
+ all << entry_data
271
+ save_entries(all)
272
+
273
+ Entry.new(entry_data)
274
+ end
275
+
276
+ def clear!
277
+ save_entries([])
278
+ File.delete(LAST_STATE_FILE) if File.exist?(LAST_STATE_FILE)
279
+ @entries = nil
280
+ end
281
+
282
+ private
283
+
284
+ def gather_context
285
+ {
286
+ pwd: Dir.pwd,
287
+ tmux_session: current_tmux_session,
288
+ tmux_window: current_tmux_window,
289
+ tmux_pane: current_tmux_pane,
290
+ git_branch: current_git_branch,
291
+ git_sha: current_git_sha,
292
+ git_origin_sha: current_git_origin_sha,
293
+ git_worktree: current_git_worktree,
294
+ task: current_task_name,
295
+ subtask: current_subtask_name,
296
+ app: current_app_name,
297
+ }
298
+ end
299
+
300
+ def build_state_key(context)
301
+ context.slice(
302
+ :pwd, :tmux_session, :tmux_window, :tmux_pane,
303
+ :git_branch, :git_sha, :git_origin_sha, :git_worktree,
304
+ :task, :subtask, :app
305
+ ).compact
306
+ end
307
+
308
+ def state_unchanged?(new_state)
309
+ return false unless File.exist?(LAST_STATE_FILE)
310
+ last_state = YAML.load_file(LAST_STATE_FILE) rescue {}
311
+ last_state == new_state.transform_keys(&:to_s)
312
+ end
313
+
314
+ def save_last_state(state)
315
+ File.write(LAST_STATE_FILE, state.transform_keys(&:to_s).to_yaml)
316
+ end
317
+
318
+ def current_value_for(field)
319
+ context = gather_context
320
+ context[field]
321
+ end
322
+
323
+ def find_entry(ref)
324
+ return nil unless ref
325
+
326
+ if ref.to_s.match?(/^\d+$/)
327
+ idx = ref.to_i - 1
328
+ reversed = entries.reverse
329
+ return reversed[idx] if idx >= 0 && idx < reversed.length
330
+ end
331
+
332
+ entries.find { |e| e.id == ref.to_s }
333
+ end
334
+
335
+ def generate_id
336
+ Time.now.strftime('%Y%m%d%H%M%S') + '-' + rand(10000).to_s.rjust(4, '0')
337
+ end
338
+
339
+ def ensure_history_dir
340
+ FileUtils.mkdir_p(HISTORY_DIR) unless Dir.exist?(HISTORY_DIR)
341
+
342
+ unless File.exist?(HISTORY_FILE)
343
+ File.write(HISTORY_FILE, [].to_yaml)
344
+ end
345
+ end
346
+
347
+ def load_raw_entries
348
+ YAML.load_file(HISTORY_FILE) || []
349
+ rescue => e
350
+ []
351
+ end
352
+
353
+ def load_entries
354
+ load_raw_entries.map { |data| Entry.new(data) }
355
+ end
356
+
357
+ def save_entries(entries_data)
358
+ # Keep only last 1000 entries to prevent unbounded growth
359
+ entries_data = entries_data.last(1000) if entries_data.length > 1000
360
+ File.write(HISTORY_FILE, entries_data.to_yaml)
361
+ end
362
+
363
+ # Tmux context
364
+ def current_tmux_session
365
+ return nil unless ENV['TMUX']
366
+ `tmux display-message -p '#S'`.strip rescue nil
367
+ end
368
+
369
+ def current_tmux_window
370
+ return nil unless ENV['TMUX']
371
+ `tmux display-message -p '#I'`.strip rescue nil
372
+ end
373
+
374
+ def current_tmux_pane
375
+ return nil unless ENV['TMUX']
376
+ ENV['TMUX_PANE'] || `tmux display-message -p '#P'`.strip rescue nil
377
+ end
378
+
379
+ # Git context
380
+ def current_git_branch
381
+ git_helper.branch_current
382
+ end
383
+
384
+ def current_git_sha
385
+ git_helper.commit('HEAD')
386
+ end
387
+
388
+ def current_git_origin_sha
389
+ branch = current_git_branch
390
+ return nil unless branch
391
+ git_helper.commit("origin/#{branch}")
392
+ end
393
+
394
+ def current_git_worktree
395
+ git_helper.root
396
+ end
397
+
398
+ def git_helper
399
+ @git_helper ||= Git.new(nil, Dir.pwd)
400
+ end
401
+
402
+ # Task context
403
+ def current_task_name
404
+ env = environment
405
+ return nil unless env
406
+
407
+ task = env.task
408
+ return nil unless task
409
+
410
+ task.subtask? ? task.parent_name : task.name
411
+ end
412
+
413
+ def current_subtask_name
414
+ env = environment
415
+ return nil unless env
416
+
417
+ task = env.task
418
+ return nil unless task
419
+ return nil unless task.subtask?
420
+
421
+ task.short_name
422
+ end
423
+
424
+ def current_app_name
425
+ env = environment
426
+ return nil unless env
427
+
428
+ pwd = Dir.pwd
429
+ task = env.task
430
+ return nil unless task
431
+
432
+ tree = env.find_tree(task.tree_name)
433
+ return nil unless tree
434
+
435
+ # Check if pwd is in an app directory
436
+ env.all_apps.each do |app|
437
+ app_path = app.resolve(tree.path)
438
+ if pwd == app_path || pwd.start_with?(app_path + '/')
439
+ return app.name
440
+ end
441
+ end
442
+
443
+ nil
444
+ end
445
+
446
+ def environment
447
+ @environment ||= begin
448
+ Environment.current
449
+ rescue NameError
450
+ nil
451
+ end
452
+ end
453
+ end
454
+ end
@@ -0,0 +1,19 @@
1
+ require 'open3'
2
+
3
+ class Hiiro
4
+ class Shell
5
+ def self.pipe_lines(lines, *command)
6
+ content = lines.is_a?(Array) ? lines.join("\n") : lines.to_s
7
+
8
+ pipe(content, *command)
9
+ end
10
+
11
+ def self.pipe(content, *command)
12
+ selected, status = Open3.capture2(*command, stdin_data: content)
13
+
14
+ return nil unless status.success?
15
+
16
+ selected&.chomp
17
+ end
18
+ end
19
+ end
data/lib/hiiro/sk.rb CHANGED
@@ -1,17 +1,10 @@
1
1
  require 'open3'
2
+ require_relative 'shell'
2
3
 
3
4
  class Hiiro
4
5
  class Sk
5
6
  def self.select(lines)
6
- input = lines.is_a?(Array) ? lines.join("\n") : lines.to_s
7
- selected, status = Open3.capture2('sk', stdin_data: input)
8
-
9
- return nil unless status.success?
10
-
11
- result = selected.strip
12
- return nil if result.empty?
13
-
14
- result
7
+ Shell.pipe_lines(lines, 'sk')
15
8
  end
16
9
 
17
10
  def self.map_select(mapping)
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.85"
2
+ VERSION = "0.1.86"
3
3
  end
data/lib/hiiro.rb CHANGED
@@ -6,6 +6,7 @@ require "pry"
6
6
  require_relative "hiiro/version"
7
7
  require_relative "hiiro/prefix_matcher"
8
8
  require_relative "hiiro/git"
9
+ require_relative "hiiro/history"
9
10
  require_relative "hiiro/options"
10
11
  require_relative "hiiro/notification"
11
12
  require_relative "hiiro/sk"
@@ -38,6 +39,7 @@ class Hiiro
38
39
  bin_name = values[:bin_name] || $0
39
40
 
40
41
  new(bin_name, *args, logging: logging, **values).tap do |hiiro|
42
+ History.load(hiiro)
41
43
  hiiro.load_plugins(*plugins)
42
44
 
43
45
  hiiro.add_subcommand(:pry) { |*args|
@@ -102,9 +104,16 @@ class Hiiro
102
104
  @todo_manager ||= TodoManager.new
103
105
  end
104
106
 
107
+ def history
108
+ @history ||= History.new
109
+ end
110
+
105
111
  def run
106
112
  result = runner.run(*args)
107
113
 
114
+ # Track command after running (only saves if state changed and in task context)
115
+ History.track(cmd: full_command, hiiro: self)
116
+
108
117
  handle_result(result)
109
118
 
110
119
  exit 1
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hiiro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.85
4
+ version: 0.1.86
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-02-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: pry
@@ -101,9 +100,12 @@ files:
101
100
  - lib/hiiro/git/remote.rb
102
101
  - lib/hiiro/git/worktree.rb
103
102
  - lib/hiiro/git/worktrees.rb
103
+ - lib/hiiro/history.rb
104
+ - lib/hiiro/history/entry.rb
104
105
  - lib/hiiro/notification.rb
105
106
  - lib/hiiro/options.rb
106
107
  - lib/hiiro/prefix_matcher.rb
108
+ - lib/hiiro/shell.rb
107
109
  - lib/hiiro/sk.rb
108
110
  - lib/hiiro/todo.rb
109
111
  - lib/hiiro/version.rb
@@ -126,7 +128,6 @@ metadata:
126
128
  homepage_uri: https://github.com/unixsuperhero/hiiro
127
129
  source_code_uri: https://github.com/unixsuperhero/hiiro
128
130
  changelog_uri: https://github.com/unixsuperhero/hiiro/blob/main/CHANGELOG.md
129
- post_install_message:
130
131
  rdoc_options: []
131
132
  require_paths:
132
133
  - lib
@@ -141,8 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
142
  - !ruby/object:Gem::Version
142
143
  version: '0'
143
144
  requirements: []
144
- rubygems_version: 3.3.7
145
- signing_key:
145
+ rubygems_version: 4.0.3
146
146
  specification_version: 4
147
147
  summary: A lightweight CLI framework for Ruby
148
148
  test_files: []