hiiro 0.1.351 → 0.1.353

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: '0586a9cc0e070785c9a0ef12282146ed71172875e706480d3dca6d663f5a46e8'
4
- data.tar.gz: ffec55f5b2ce8882cb06189dedf5e29ccb75a3b8214a76fc72e7dc460851f2cd
3
+ metadata.gz: bbd7598e65b8521905ec67a960f09700d7260c5329e066e6cfcbb4661c180c99
4
+ data.tar.gz: 189ca275fa4cc2af210c1d67c8976bc4b90343e68eb4a0fe86ebedea1421bb9f
5
5
  SHA512:
6
- metadata.gz: e40c167924bce7ac225b17e0fd7c82855c0d6034e66814c4b3e4897ee00dc80b9214b52dafe4d3a98c7916b0194c0a7d4b234e9f55a5a51fd0d73329315132ee
7
- data.tar.gz: 994e44431c7a4dc5a15a2eaaa0ffa6c9b0382229b5d84dac1521c56129b599c08316e124511e6f5cf830e7189ab326672e599c228c11b7ddb7776be42c86bfdc
6
+ metadata.gz: 50b6f3d077471531303d04f9ef234ff4ade40dbea85b52f575e6c9e3c48088ba1318dd0798eaeef15acdeb4ce0ca8fcf13100d4963fb5174dccdefaf87de228f
7
+ data.tar.gz: 4c852f5cea47667811dd2dd461b5c1bdfe3935cc7fecad2c95c7d3d43f28d3c7aea0554ea415cfda812e776b047bf4dee9ef77b34fdb0ffc5b806170864e2705
data/AGENTS.md ADDED
@@ -0,0 +1 @@
1
+ CLAUDE.md
data/CHANGELOG.md CHANGED
@@ -2,9 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.1.353] - 2026-04-26
6
+
7
+ ### Added
8
+ - `Hiiro::Tui::ListScreen` for building keyboard-driven full-screen list interfaces
9
+ - `Hiiro::Config.load_yaml` and `Hiiro::Config.yaml_dig` helpers for YAML file loading with nested key access
10
+
11
+ ## [0.1.352] - 2026-04-24
12
+
13
+ ### Added
14
+ - `h claude all|agents|commands|skills` now support `--absolute/-a` to print absolute tool paths; skill matches resolve to `SKILL.md`
15
+
5
16
  ### Fixed
6
17
  - `h capture path <num>` now prints the path of the Nth most recent capture (was always printing the captures dir regardless of args)
7
18
  - `h capture new` / `h capture file` now record to the DB even when interrupted (Ctrl-C, exception) so partial captures show up in `h capture ls`. Interrupted captures display with the existing `?` glyph and `(exit interrupted)` message.
19
+ - `h pane splitv/splith` subcommand mappings corrected
20
+ - Corrected `REPO_PATH` constant from `.bare` to `.git`
8
21
 
9
22
  ## [0.1.350] - 2026-04-18
10
23
 
data/README.md CHANGED
@@ -8,6 +8,7 @@ A lightweight, extensible CLI framework for Ruby. Build your own multi-command t
8
8
  - **Abbreviation matching** - Type `h ex hel` instead of `h example hello`
9
9
  - **Plugin system** - Extend functionality with reusable modules
10
10
  - **Per-command storage** - Each command gets its own pin/config namespace
11
+ - **TUI helpers** - Build keyboard-driven list screens with `Hiiro::Tui::ListScreen`
11
12
 
12
13
  See [docs/](docs/) for detailed documentation on all subcommands.
13
14
 
@@ -93,6 +94,8 @@ h ping
93
94
  | `h window` | Tmux window management |
94
95
  | `h wtree` | Git worktree management |
95
96
 
97
+ `h claude agents|commands|skills -a` prints the absolute file path for each matching `.claude` tool, including `SKILL.md` for skills.
98
+
96
99
  ## Abbreviations
97
100
 
98
101
  Any subcommand can be abbreviated as long as the prefix uniquely matches:
@@ -152,6 +155,10 @@ h example hello # => Hi!
152
155
  h example bye # => Goodbye!
153
156
  ```
154
157
 
158
+ ### Building TUIs
159
+
160
+ Use `Hiiro::Tui::ListScreen` for simple full-screen list interfaces. Subclass it, override `header_lines`, `format_row`, and `handle_key`, then run it from a normal `Hiiro.run` subcommand.
161
+
155
162
  ### Method 2: Inline Subcommands
156
163
 
157
164
  Modify `exe/h` directly to add subcommands to the base `h` command:
data/bin/h-claude CHANGED
@@ -17,11 +17,26 @@ opts = Hiiro::Options.setup {
17
17
 
18
18
  tool_opts = Hiiro::Options.setup {
19
19
  flag(:full, short: :f, default: false, desc: 'fulltext search - full grep file or dir')
20
+ flag(:absolute, short: :a, default: false, desc: 'print absolute path to matching tool file')
20
21
  flag(:verbose, short: :v, default: false, desc: 'display claude dirs in stderr')
21
22
  flag(:veryverbose, short: :V, default: false, desc: 'display all stderr messages')
22
23
  }
23
24
 
24
- def glob_path(glob, bname=nil, opts:)
25
+ def tool_file(path, skill: false)
26
+ return path unless skill
27
+
28
+ path / 'SKILL.md'
29
+ end
30
+
31
+ def display_tool_path(path, bname: nil, opts:, skill: false)
32
+ target = tool_file(path, skill: skill)
33
+ return target if opts.absolute
34
+ return path.basename if skill
35
+
36
+ target.basename(bname.to_s)
37
+ end
38
+
39
+ def glob_path(glob, bname=nil, opts:, skill: false)
25
40
  filters = opts.args
26
41
  fulltext = opts.full
27
42
  bname ||= ''
@@ -46,6 +61,7 @@ def glob_path(glob, bname=nil, opts:)
46
61
  cmd = ['egrep', *more_args, '-m1', re_s, f.to_s]
47
62
  kwcmd = { out: File.open('/dev/null', 'w') }
48
63
  base_f = f.basename(bname)
64
+ display_f = display_tool_path(f, bname: bname, opts: opts, skill: skill)
49
65
 
50
66
  matches =
51
67
  if fulltext
@@ -55,7 +71,7 @@ def glob_path(glob, bname=nil, opts:)
55
71
  end
56
72
 
57
73
  if matches
58
- puts base_f
74
+ puts display_f
59
75
  else
60
76
  $stderr.puts "SKIPPING #{base_f}" if opts.veryverbose
61
77
  end
@@ -74,7 +90,7 @@ def collect_files(glob, bname=nil, opts:, skill: false)
74
90
  claude_paths.flat_map { |path| path.glob(glob) }.filter_map do |f|
75
91
  base_f = f.basename(bname.to_s)
76
92
  next unless base_f.to_s.match?(re)
77
- target = skill ? f / 'SKILL.md' : f
93
+ target = tool_file(f, skill: skill)
78
94
  target if target.exist?
79
95
  end
80
96
  end
@@ -171,7 +187,7 @@ add_subcmd(:env) { |*args|
171
187
  opts = tool_opts.parse(args)
172
188
  glob_path('agents/*.md', '.md', opts: opts)
173
189
  glob_path('commands/*.md', '.md', opts: opts)
174
- glob_path('skills/*/', opts: opts)
190
+ glob_path('skills/*/', opts: opts, skill: true)
175
191
  }
176
192
 
177
193
  add_subcmd(:agents) { |*args|
@@ -186,7 +202,7 @@ add_subcmd(:env) { |*args|
186
202
 
187
203
  add_subcmd(:skills) { |*args|
188
204
  opts = tool_opts.parse(args)
189
- glob_path('skills/*/', opts: opts)
205
+ glob_path('skills/*/', opts: opts, skill: true)
190
206
  }
191
207
 
192
208
  add_subcmd(:vim) { |*args|
data/bin/h-pane CHANGED
@@ -61,7 +61,7 @@ Hiiro.run(*ARGV, plugins: [Pins]) do
61
61
  system('tmux', 'split-window', *args)
62
62
  }
63
63
 
64
- add_subcmd(:splitv) { |*args|
64
+ add_subcmd(:hsplit, :splith) { |*args|
65
65
  if args.empty?
66
66
  tmux.split_window(horizontal: false)
67
67
  else
@@ -69,7 +69,7 @@ Hiiro.run(*ARGV, plugins: [Pins]) do
69
69
  end
70
70
  }
71
71
 
72
- add_subcmd(:splith) { |*args|
72
+ add_subcmd(:vsplit, :splitv) { |*args|
73
73
  if args.empty?
74
74
  tmux.split_window(horizontal: true)
75
75
  else
data/docs/h-claude.md CHANGED
@@ -77,6 +77,7 @@ List agent, command, and skill files found in `.claude/` directories from the cu
77
77
  | Flag | Short | Description |
78
78
  |------|-------|-------------|
79
79
  | `--full` | `-f` | Full text search (grep inside files) |
80
+ | `--absolute` | `-a` | Print the absolute path to each matching tool file (`SKILL.md` for skills) |
80
81
  | `--verbose` | `-v` | Show `.claude` paths being searched |
81
82
  | `--veryverbose` | `-V` | Show all paths including skipped |
82
83
 
@@ -85,7 +86,9 @@ List agent, command, and skill files found in `.claude/` directories from the cu
85
86
  ```bash
86
87
  h claude all
87
88
  h claude agents fetch
89
+ h claude agents -a fetch
88
90
  h claude commands refactor
91
+ h claude skills -a review
89
92
  h claude skills -f "pull request"
90
93
  ```
91
94
 
@@ -333,7 +336,7 @@ h claude vadd "Write tests for foo"
333
336
 
334
337
  ### vim
335
338
 
336
- Open matching agent, command, and skill files in your editor. Uses the same filter logic as `all`.
339
+ Open matching agent, command, and skill files in your editor. Uses the same filter logic and search flags as `all`; matches are opened via their absolute file paths.
337
340
 
338
341
  **Examples**
339
342
 
data/lib/hiiro/config.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require 'yaml'
2
+
1
3
  class Hiiro
2
4
  class Config
3
5
  BASE_DIR = File.join(Dir.home, '.config/hiiro')
@@ -15,6 +17,36 @@ class Hiiro
15
17
  File.join(BASE_DIR, relpath)
16
18
  end
17
19
 
20
+ def load_yaml(relpath = 'config.yml', default: {}, permitted_classes: [Symbol])
21
+ file = path(relpath)
22
+ return default unless File.exist?(file)
23
+
24
+ YAML.safe_load_file(file, permitted_classes: permitted_classes) || default
25
+ rescue StandardError
26
+ default
27
+ end
28
+
29
+ def yaml_dig(relpath = 'config.yml', *keys, default: nil, permitted_classes: [Symbol])
30
+ value = load_yaml(relpath, default: {}, permitted_classes: permitted_classes)
31
+
32
+ keys.flatten.each do |key|
33
+ return default unless value.is_a?(Hash)
34
+
35
+ value =
36
+ if value.key?(key)
37
+ value[key]
38
+ elsif value.key?(key.to_s)
39
+ value[key.to_s]
40
+ elsif key.respond_to?(:to_sym) && value.key?(key.to_sym)
41
+ value[key.to_sym]
42
+ else
43
+ return default
44
+ end
45
+ end
46
+
47
+ value.nil? ? default : value
48
+ end
49
+
18
50
  def data_path(relpath='')
19
51
  File.join(DATA_DIR, relpath)
20
52
  end
data/lib/hiiro/tui.rb ADDED
@@ -0,0 +1,216 @@
1
+ require 'io/console'
2
+
3
+ class Hiiro
4
+ module Tui
5
+ module Terminal
6
+ def with_screen
7
+ input = $stdin
8
+ $stdout.write("\e[?1049h\e[?25l")
9
+ $stdout.flush
10
+
11
+ input.raw do
12
+ yield input
13
+ end
14
+ ensure
15
+ $stdout.write("\r\e[0m\e[?25h\e[?1049l")
16
+ $stdout.flush
17
+ end
18
+
19
+ def read_key(input)
20
+ char = input.getch
21
+ return :ctrl_c if char == "\u0003"
22
+ return :enter if char == "\r" || char == "\n"
23
+
24
+ if char == "\e"
25
+ first = read_escape_char(input)
26
+ return :escape if first.nil?
27
+ return :escape unless first == '['
28
+
29
+ case read_escape_char(input)
30
+ when 'A' then :up
31
+ when 'B' then :down
32
+ when 'C' then :right
33
+ when 'D' then :left
34
+ else :escape
35
+ end
36
+ else
37
+ char
38
+ end
39
+ end
40
+
41
+ def read_escape_char(input)
42
+ return nil unless IO.select([input], nil, nil, 0.02)
43
+
44
+ input.getch
45
+ end
46
+
47
+ def terminal_rows
48
+ env_dimension('LINES') || IO.console.winsize[0]
49
+ rescue StandardError
50
+ 24
51
+ end
52
+
53
+ def terminal_cols
54
+ env_dimension('COLUMNS') || IO.console.winsize[1]
55
+ rescue StandardError
56
+ 80
57
+ end
58
+
59
+ def terminal_line(text, cols)
60
+ truncate(text, cols) + "\e[K"
61
+ end
62
+
63
+ def center_text(text, cols)
64
+ truncated = truncate(text, cols)
65
+ return truncated if truncated.length >= cols
66
+
67
+ left_padding = [(cols - truncated.length) / 2, 0].max
68
+ (' ' * left_padding) + truncated
69
+ end
70
+
71
+ def truncate(text, cols)
72
+ return text if text.length <= cols
73
+ return text[0, cols] if cols <= 1
74
+
75
+ text[0, cols - 1] + '…'
76
+ end
77
+
78
+ def visible_text(text, cols, offset = 0)
79
+ return truncate(text, cols) if offset <= 0
80
+
81
+ clipped = text[offset..] || ''
82
+ return truncate(clipped, cols) if cols <= 1
83
+
84
+ truncate("«#{clipped}", cols)
85
+ end
86
+
87
+ def env_dimension(name)
88
+ value = ENV[name].to_i
89
+ return nil unless value.positive?
90
+
91
+ value
92
+ end
93
+ end
94
+
95
+ class ListScreen
96
+ include Terminal
97
+
98
+ attr_reader :items, :cursor, :top, :horizontal_offset
99
+
100
+ def initialize(items:, empty_message: 'No items.')
101
+ @items = items
102
+ @empty_message = empty_message
103
+ @cursor = 0
104
+ @top = 0
105
+ @horizontal_offset = 0
106
+ end
107
+
108
+ def run
109
+ if items.empty?
110
+ puts @empty_message
111
+ return false
112
+ end
113
+
114
+ with_screen do |input|
115
+ loop do
116
+ render
117
+
118
+ result = handle_key(read_key(input))
119
+ return result unless result == :continue
120
+ end
121
+ end
122
+ end
123
+
124
+ def handle_key(key)
125
+ case key
126
+ when :up, 'k'
127
+ move(-1)
128
+ when :down, 'j'
129
+ move(1)
130
+ when :left, 'h'
131
+ scroll_horizontal(-4)
132
+ when :right, 'l'
133
+ scroll_horizontal(4)
134
+ when 'q', :escape, :ctrl_c
135
+ return false
136
+ end
137
+
138
+ :continue
139
+ end
140
+
141
+ def render
142
+ rows = terminal_rows
143
+ cols = terminal_cols
144
+ line_cols = [cols - 1, 1].max
145
+ headers = header_lines
146
+ visible_rows = [rows - headers.length - footer_height, 1].max
147
+ visible = items[@top, visible_rows] || []
148
+ @horizontal_offset = [horizontal_offset, max_horizontal_offset(line_cols, visible)].min
149
+
150
+ lines = headers.map { |line| terminal_line(line, line_cols) }
151
+ visible.each_with_index do |item, idx|
152
+ lines << format_row(item, @top + idx == cursor, line_cols)
153
+ end
154
+
155
+ (visible_rows - visible.length).times { lines << terminal_line('', line_cols) }
156
+ footer_lines.each { |line| lines << terminal_line(line, line_cols) }
157
+
158
+ $stdout.write("\e[H\e[2J")
159
+ $stdout.write(lines.join("\r\n"))
160
+ $stdout.write("\r")
161
+ $stdout.flush
162
+ end
163
+
164
+ def header_lines
165
+ []
166
+ end
167
+
168
+ def footer_lines
169
+ ["Showing #{@top + 1}-#{@top + visible_items.length} of #{items.length}"]
170
+ end
171
+
172
+ def footer_height
173
+ 1
174
+ end
175
+
176
+ def format_row(item, current, line_cols)
177
+ prefix = current ? '> ' : ' '
178
+ style = current ? "\e[7m" : "\e[0m"
179
+ text = (prefix + visible_text(item.to_s, [line_cols - prefix.length, 1].max, horizontal_offset)).ljust(line_cols)
180
+
181
+ "#{style}#{text}\e[0m\e[K"
182
+ end
183
+
184
+ def move(delta)
185
+ @cursor = [[cursor + delta, 0].max, items.length - 1].min
186
+ visible_rows = body_rows_budget
187
+ @top = cursor if cursor < top
188
+ @top = cursor - visible_rows + 1 if cursor >= top + visible_rows
189
+ @horizontal_offset = [horizontal_offset, max_horizontal_offset].min
190
+ end
191
+
192
+ def scroll_horizontal(delta)
193
+ @horizontal_offset = [[horizontal_offset + delta, 0].max, max_horizontal_offset].min
194
+ end
195
+
196
+ def visible_items
197
+ items[top, body_rows_budget] || []
198
+ end
199
+
200
+ def body_rows_budget
201
+ [terminal_rows - header_lines.length - footer_height, 1].max
202
+ end
203
+
204
+ def max_horizontal_offset(line_cols = nil, visible = nil)
205
+ line_cols ||= [terminal_cols - 1, 1].max
206
+ visible ||= visible_items
207
+ return 0 if visible.empty?
208
+
209
+ longest_visible_item = visible.map { |item| item.to_s.length }.max || 0
210
+ min_visible_chars = [5, line_cols].min
211
+
212
+ [longest_visible_item - min_visible_chars, 0].max
213
+ end
214
+ end
215
+ end
216
+ end
data/lib/hiiro/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  class Hiiro
2
- VERSION = "0.1.351"
2
+ VERSION = "0.1.353"
3
3
  end
data/lib/hiiro.rb CHANGED
@@ -47,6 +47,7 @@ require_relative "hiiro/pane_home"
47
47
  require_relative "hiiro/pin_record"
48
48
  require_relative "hiiro/reminder"
49
49
  require_relative 'hiiro/registry'
50
+ require_relative 'hiiro/tui'
50
51
 
51
52
  class String
52
53
  def underscore(camel_cased_word=self)
@@ -63,7 +64,7 @@ end
63
64
 
64
65
  class Hiiro
65
66
  WORK_DIR = File.join(Dir.home, 'work')
66
- REPO_PATH = File.join(WORK_DIR, '.bare')
67
+ REPO_PATH = File.join(WORK_DIR, '.git')
67
68
 
68
69
  def self.init(*oargs, plugins: [], logging: false, tasks: false, task_scope: nil, **values, &block)
69
70
  load_env
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hiiro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.351
4
+ version: 0.1.353
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota
@@ -119,6 +119,7 @@ extra_rdoc_files: []
119
119
  files:
120
120
  - ".DS_Store"
121
121
  - ".config/.keep"
122
+ - AGENTS.md
122
123
  - CHANGELOG.md
123
124
  - CLAUDE.md
124
125
  - LICENSE
@@ -376,6 +377,7 @@ files:
376
377
  - lib/hiiro/tmux/window.rb
377
378
  - lib/hiiro/tmux/windows.rb
378
379
  - lib/hiiro/todo.rb
380
+ - lib/hiiro/tui.rb
379
381
  - lib/hiiro/version.rb
380
382
  - notes
381
383
  - obsidian_slides.md