sight 0.1.0 → 0.3.0

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: 05abdb72bec3313b413b826f36a5f5a5590374d7d61640460a41eddd75a57ab6
4
- data.tar.gz: 27d5fb5d877eebf0152ffa070201e2bb0755256cb466027bc3f61c26bccd4a16
3
+ metadata.gz: 6e488680c7d4ecc42d8cd02b8d2731a315755b35df2a9cf319ed3f0d413e2809
4
+ data.tar.gz: 375af2bad773014e984f0b89c3333c839dd84bfc8e8cd610357c78bcfd052962
5
5
  SHA512:
6
- metadata.gz: 41a607fda53ab9822d968c8d2af280b71db14bb3edf96752dfd8ce26450277d44e19ad85f1677940506b75f80503f6511201291795d031d42aab10beb598bbe5
7
- data.tar.gz: 254d6c49a5abb2ead9130daad53fc874523a282b1b69c50b9df0f015280ea7fb47ff8a099f3b21badd10b074c0b39941749c7b200f913a358a7e6635029954dd
6
+ metadata.gz: 76d83afcd3ac25b084fc22661d9561fd75bd16ef662237b5cf2e1d784225a442ac682740a312a711ae169e16eac1f8d997ff3e68b44fb1222a00a9fd3e077c6a
7
+ data.tar.gz: 934ab59d60fb58ac4029718705d1551fd6ede47ff0dfcadfefdf45fdd30eddc2060dcc815988060a796cf55cf993e0b9ba82b86426b1bc8043ed974631006099
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-03-04
4
+
5
+ ### Added
6
+
7
+ - Comment on hunks with `c` — annotations are printed to stdout on quit as markdown
8
+
9
+ ### Changed
10
+
11
+ - Removed initial-commit diff fallback from `Git.diff`
12
+
13
+ ## [0.2.0] - 2026-03-04
14
+
15
+ ### Changed
16
+
17
+ - Navigation is now hunk-based: `j`/`k` jump between hunks
18
+ - Active hunk is highlighted; inactive hunks are dimmed
19
+ - Removed line-scrolling keys (`f`/`b`/`d`/`u`/`g`/`G`) and arrow keys
20
+
3
21
  ## [0.1.0] - 2026-03-04
4
22
 
5
23
  ### Added
data/CLAUDE.md ADDED
@@ -0,0 +1,29 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ - `bundle exec rake test` — run all tests
8
+ - `ruby -Ilib:test test/test_cli.rb` — run a single test file
9
+ - `ruby -Ilib:test test/test_cli.rb -n test_help_flag` — run a single test method
10
+ - `bundle exec standardrb` — lint
11
+ - `bundle exec standardrb --fix` — lint and auto-fix
12
+ - `bundle exec rake` — run tests + lint (default task)
13
+
14
+ ## Architecture
15
+
16
+ TUI for closing the loop on AI-generated code changes — browse diffs, jump between hunks, and annotate them. Entry point: `exe/sight` → `CLI.run` → `App.new(files).run`.
17
+
18
+ **Data flow**: `Git.diff` (raw string) → `DiffParser.parse` (returns `DiffFile[]`) → `App` (curses TUI)
19
+
20
+ **Key structs** (all in `diff_parser.rb`): `DiffFile(path, hunks)`, `Hunk(context, lines)`, `DiffLine(type, content, lineno, old_lineno)`. `DisplayLine(type, text, lineno)` is the render-side equivalent in `display_line.rb`.
21
+
22
+ **App** renders per-file views with hunk-based navigation (j/k). Active hunk is highlighted; inactive hunks render in dark gray (color pair 5, color 240).
23
+
24
+ ## Conventions
25
+
26
+ - Ruby >= 3.2, uses `frozen_string_literal` in all files
27
+ - Linter: StandardRB (ruby_version: 3.4 in `.standard.yml`)
28
+ - Tests: Minitest with stubs/mocks, no test framework beyond minitest
29
+ - Single runtime dependency: `curses ~1.4`
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sight
2
2
 
3
- Terminal UI for browsing git diffs interactively with colors, scrolling, and file navigation.
3
+ TUI for closing the loop on AI-generated code changes.
4
4
 
5
5
  ## Installation
6
6
 
@@ -20,17 +20,12 @@ sight
20
20
 
21
21
  | Key | Action |
22
22
  |-----|--------|
23
- | `j` / `↓` | Scroll down |
24
- | `k` / `↑` | Scroll up |
25
- | `d` | Half page down |
26
- | `u` | Half page up |
27
- | `f` | Full page down |
28
- | `b` | Full page up |
29
- | `g` | Go to top |
30
- | `G` | Go to bottom |
31
- | `n` / `→` | Next file |
32
- | `p` / `←` | Previous file |
23
+ | `j` | Next hunk |
24
+ | `k` | Previous hunk |
25
+ | `n` | Next file |
26
+ | `p` | Previous file |
33
27
  | `?` | Toggle help |
28
+ | `c` | Comment on hunk |
34
29
  | `q` / `Esc` | Quit |
35
30
 
36
31
  ## Development
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sight
4
+ Annotation = Struct.new(:file_path, :type, :hunk, :comment, keyword_init: true)
5
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sight
4
+ module AnnotationFormatter
5
+ module_function
6
+
7
+ def format(annotations)
8
+ return "" if annotations.empty?
9
+
10
+ grouped = annotations.group_by(&:file_path)
11
+ grouped.map { |path, anns| format_file(path, anns) }.join("\n")
12
+ end
13
+
14
+ def format_file(path, annotations)
15
+ out = "## File: #{path}\n\n"
16
+ annotations.each do |ann|
17
+ context = ann.hunk.context ? " #{ann.hunk.context}" : ""
18
+ out += "Hunk (@@#{context}):\n"
19
+ out += "```diff\n"
20
+ ann.hunk.lines.each do |line|
21
+ next unless %i[add del].include?(line.type)
22
+ out += "#{line.content}\n"
23
+ end
24
+ out += "```\n"
25
+ out += "> #{ann.comment}\n\n"
26
+ end
27
+ out
28
+ end
29
+ end
30
+ end
data/lib/sight/app.rb CHANGED
@@ -4,14 +4,17 @@ require "curses"
4
4
 
5
5
  module Sight
6
6
  class App
7
- attr_reader :files, :file_lines
8
- attr_accessor :file_idx, :offset
7
+ attr_reader :files, :file_lines, :annotations
8
+ attr_accessor :file_idx, :offset, :hunk_idx
9
9
 
10
10
  def initialize(files)
11
11
  @files = files
12
12
  @file_lines = files.map { build_file_lines(it) }
13
13
  @file_idx = 0
14
14
  @offset = 0
15
+ @hunk_idx = 0
16
+ @hunk_offsets_cache = {}
17
+ @annotations = []
15
18
  end
16
19
 
17
20
  def run
@@ -49,6 +52,7 @@ module Sight
49
52
  Curses.init_pair(2, Curses::COLOR_RED, -1)
50
53
  Curses.init_pair(3, Curses::COLOR_CYAN, -1)
51
54
  Curses.init_pair(4, Curses::COLOR_YELLOW, -1)
55
+ Curses.init_pair(5, 240, -1)
52
56
  end
53
57
 
54
58
  def lines
@@ -78,20 +82,29 @@ module Sight
78
82
  dim = Curses.color_pair(0) | Curses::A_DIM
79
83
  content_width = width - gutter - 3
80
84
 
85
+ selected_start = hunk_offsets[hunk_idx]
86
+ selected_end = if hunk_idx + 1 < hunk_offsets.size
87
+ hunk_offsets[hunk_idx + 1]
88
+ else
89
+ lines.size
90
+ end
91
+
81
92
  scroll_height.times do |row|
82
93
  idx = offset + row
83
94
  break if idx >= lines.size
84
95
  line = lines[idx]
85
96
  win.setpos(row + 2, 0)
86
- win.attron(dim) { win.addstr("#{format_gutter(line.type, line.lineno, gutter)} ") }
87
- win.attron(color_for(line.type)) { win.addstr(line.text[0, content_width]) }
97
+ active = idx >= selected_start && idx < selected_end
98
+ win.attron(dim) { win.addstr("#{format_gutter(line.type, line.lineno, gutter)} \u2502 ") }
99
+ attr = active ? color_for(line.type) : Curses.color_pair(5)
100
+ win.attron(attr) { win.addstr(line.text[0, content_width]) }
88
101
  end
89
102
  end
90
103
 
91
104
  def render_status_bar(win, width)
92
105
  win.setpos(Curses.lines - 1, 0)
93
106
  win.attron(Curses.color_pair(4) | Curses::A_REVERSE) do
94
- status = " File #{file_idx + 1}/#{files.size} | Line #{offset + 1}/#{lines.size} "
107
+ status = " File #{file_idx + 1}/#{files.size} | Hunk #{hunk_idx + 1}/#{hunk_offsets.size} | Line #{offset + 1}/#{lines.size} "
95
108
  win.addstr(status.ljust(width))
96
109
  end
97
110
  end
@@ -126,42 +139,27 @@ module Sight
126
139
  end
127
140
  end
128
141
 
129
- def scroll_to(delta)
130
- max = [0, lines.size - scroll_height].max
131
- self.offset = (offset + delta).clamp(0, max)
132
- end
133
-
134
142
  def handle_input
135
143
  key = Curses.getch
136
144
  case key
137
145
  when "q", 27 then return false
138
- when "j", Curses::KEY_DOWN then scroll_to(1)
139
- when "k", Curses::KEY_UP then scroll_to(-1)
140
- when "f" then scroll_to(scroll_height)
141
- when "b" then scroll_to(-scroll_height)
142
- when "d" then scroll_to(scroll_height / 2)
143
- when "u" then scroll_to(-scroll_height / 2)
144
- when "g" then self.offset = 0
145
- when "G" then scroll_to(lines.size)
146
- when "n", Curses::KEY_RIGHT then jump_file(1)
147
- when "p", Curses::KEY_LEFT then jump_file(-1)
146
+ when "j" then jump_hunk(1)
147
+ when "k" then jump_hunk(-1)
148
+ when "n" then jump_file(1)
149
+ when "p" then jump_file(-1)
150
+ when "c" then annotate_hunk
148
151
  when "?" then show_help
149
152
  end
150
153
  true
151
154
  end
152
155
 
153
156
  HELP_KEYS = [
154
- ["j / ↓", "Scroll down"],
155
- ["k / ↑", "Scroll up"],
156
- ["d", "Half Page down"],
157
- ["u", "Half page up"],
158
- ["f", "Full page down"],
159
- ["b", "Full page up"],
160
- ["g", "Go to top"],
161
- ["G", "Go to bottom"],
162
- ["n / →", "Next file"],
163
- ["p / ←", "Previous file"],
157
+ ["j", "Next hunk"],
158
+ ["k", "Previous hunk"],
159
+ ["n", "Next file"],
160
+ ["p", "Previous file"],
164
161
  ["q / Esc", "Quit"],
162
+ ["c", "Comment on hunk"],
165
163
  ["?", "Toggle this help"]
166
164
  ].freeze
167
165
 
@@ -201,11 +199,93 @@ module Sight
201
199
  end
202
200
  end
203
201
 
202
+ def annotate_hunk
203
+ hunk = files[file_idx].hunks[hunk_idx]
204
+ return unless hunk
205
+ comment = prompt_comment("Comment on hunk")
206
+ return unless comment
207
+ @annotations << Annotation.new(
208
+ file_path: files[file_idx].path,
209
+ type: :hunk,
210
+ hunk: hunk,
211
+ comment: comment
212
+ )
213
+ end
214
+
215
+ def prompt_comment(title)
216
+ win = Curses.stdscr
217
+ max_width = 80
218
+ width = [Curses.cols * 2 / 3, 50].max
219
+ width = [width, max_width].min
220
+ height = 5
221
+ top = (Curses.lines - height) / 2
222
+ left = (Curses.cols - width) / 2
223
+
224
+ draw_box(win, top, left, width, height, title, [""])
225
+ win.setpos(top + 3, left + 3)
226
+ win.refresh
227
+
228
+ Curses.curs_set(1)
229
+ text = ""
230
+ field_width = width - 6
231
+ redraw_field = -> {
232
+ win.setpos(top + 3, left + 3)
233
+ win.addstr(" " * field_width)
234
+ visible = (text.length > field_width) ? text[-field_width..] : text
235
+ win.setpos(top + 3, left + 3)
236
+ win.addstr(visible)
237
+ }
238
+ loop do
239
+ ch = Curses.getch
240
+ case ch
241
+ when 10, 13, Curses::KEY_ENTER
242
+ break
243
+ when 27
244
+ text = nil
245
+ break
246
+ when Curses::KEY_BACKSPACE, 127, 8
247
+ unless text.empty?
248
+ text = text[0..-2]
249
+ redraw_field.call
250
+ end
251
+ else
252
+ text += ch.chr if ch.is_a?(Integer) && ch >= 32 && ch < 127
253
+ text += ch if ch.is_a?(String) && ch.length == 1
254
+ redraw_field.call
255
+ end
256
+ end
257
+ Curses.curs_set(0)
258
+
259
+ text&.strip&.empty? ? nil : text&.strip
260
+ end
261
+
262
+ def hunk_offsets
263
+ @hunk_offsets_cache[file_idx] ||= begin
264
+ offsets = []
265
+ line_idx = 0
266
+ files[file_idx].hunks.each do |hunk|
267
+ offsets << line_idx
268
+ line_idx += hunk.lines.size
269
+ end
270
+ offsets
271
+ end
272
+ end
273
+
274
+ def jump_hunk(delta)
275
+ return if hunk_offsets.empty?
276
+ self.hunk_idx = (hunk_idx + delta).clamp(0, hunk_offsets.size - 1)
277
+ target = hunk_offsets[hunk_idx]
278
+ margin = [2, scroll_height / 4].min
279
+ max = [0, lines.size - scroll_height].max
280
+ self.offset = [target - margin, 0].max.clamp(0, max)
281
+ end
282
+
204
283
  def jump_file(direction)
205
284
  new_idx = (file_idx + direction).clamp(0, files.size - 1)
206
285
  return if new_idx == file_idx
207
286
  self.file_idx = new_idx
208
287
  self.offset = 0
288
+ self.hunk_idx = 0
209
289
  end
210
290
  end
211
291
  end
data/lib/sight/cli.rb CHANGED
@@ -9,7 +9,9 @@ module Sight
9
9
  puts "Usage: sight"
10
10
  puts "Interactive git diff viewer (staged + unstaged)"
11
11
  puts
12
- puts "Keys: j/k/↑/↓ scroll, f/b page, d/u half-page, g/G top/bottom, n/p/→/← next/prev file, ? help, q quit"
12
+ puts "Keys: j/k hunks, n/p files, c comment, ? help, q quit"
13
+ puts
14
+ puts "Annotations are printed to stdout on quit."
13
15
  return
14
16
  end
15
17
 
@@ -20,12 +22,17 @@ module Sight
20
22
 
21
23
  raw = Git.diff
22
24
  if raw.empty?
23
- warn "No diff output."
25
+ warn "No diff output"
24
26
  return
25
27
  end
26
28
 
27
29
  files = DiffParser.parse(raw)
28
- App.new(files).run
30
+ app = App.new(files)
31
+ app.run
32
+
33
+ unless app.annotations.empty?
34
+ puts AnnotationFormatter.format(app.annotations)
35
+ end
29
36
  end
30
37
  end
31
38
  end
data/lib/sight/git.rb CHANGED
@@ -6,10 +6,7 @@ module Sight
6
6
 
7
7
  def diff
8
8
  output, success = run_cmd(["git", "diff", "--no-color", "HEAD"])
9
- unless success
10
- output, success = run_cmd(["git", "diff", "--no-color", "--cached"], err: [:child, :out])
11
- raise Error, "git diff failed: #{output}" unless success
12
- end
9
+ raise Error, "git diff failed" unless success
13
10
  output
14
11
  end
15
12
 
data/lib/sight/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sight
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/sight.rb CHANGED
@@ -4,6 +4,8 @@ require_relative "sight/version"
4
4
  require_relative "sight/display_line"
5
5
  require_relative "sight/diff_parser"
6
6
  require_relative "sight/git"
7
+ require_relative "sight/annotation"
8
+ require_relative "sight/annotation_formatter"
7
9
  require_relative "sight/cli"
8
10
  require_relative "sight/app"
9
11
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sight
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ariel Rzezak
@@ -23,8 +23,8 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.4'
26
- description: A TUI tool to browse git diffs interactively with colors, file navigation,
27
- and scrolling.
26
+ description: Browse diffs by hunk, comment on changes, and close the feedback loop
27
+ with AI agents.
28
28
  email:
29
29
  - arzezak@gmail.com
30
30
  executables:
@@ -33,12 +33,15 @@ extensions: []
33
33
  extra_rdoc_files: []
34
34
  files:
35
35
  - CHANGELOG.md
36
+ - CLAUDE.md
36
37
  - CODE_OF_CONDUCT.md
37
38
  - LICENSE.txt
38
39
  - README.md
39
40
  - Rakefile
40
41
  - exe/sight
41
42
  - lib/sight.rb
43
+ - lib/sight/annotation.rb
44
+ - lib/sight/annotation_formatter.rb
42
45
  - lib/sight/app.rb
43
46
  - lib/sight/cli.rb
44
47
  - lib/sight/diff_parser.rb
@@ -70,5 +73,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
73
  requirements: []
71
74
  rubygems_version: 4.0.7
72
75
  specification_version: 4
73
- summary: Interactive git diff viewer for the terminal
76
+ summary: TUI for closing the loop on AI-generated code changes
74
77
  test_files: []