sight 0.2.0 → 0.4.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: 24b700f118e81b323388d9dc1413bcb493a8c8625664099fcff2eeb524fbe4e7
4
- data.tar.gz: c0b1e78505b39f82909607431c5c7cf27a56545292abb0967887f2a60be04420
3
+ metadata.gz: ff89f4bccb463b21835b299946bcf7a54b43f9792eb0b262954812c79633da19
4
+ data.tar.gz: 7dab4e4911a9bf145a5b0542ac993c6b5d3ce25af54b345e537291c5567db681
5
5
  SHA512:
6
- metadata.gz: b3e9f75deef46a4da613023322b833f0bbf86c7f2cc5f3ccde44f30fd7bd0cb1712d4925c5ed18a865e4ff6fe488d787eaba38939c52cea2e0a9d1f62fec4a97
7
- data.tar.gz: 6530759983f83fed70bab30adb12fa6bae9990f5554e91170e5547cbeb58514759eb649fe322b1857d895ec0051017ab53ee2b9cbc35bb918b66199c9fbfbf51
6
+ metadata.gz: c591ba467ae6d6a64d1d54c188513a2afe743c40e767bca6892f9f30e6ff45ecfb1f134fcef62c420b4e7fb8c13a77fcd9c69e8f830c6cf1a7c79f8d4d61b82f
7
+ data.tar.gz: 131fd548a595de6b14288b2ae536b2275eb66fb50f6e08182875866bdee44c0acfbb8194a0ee515e510472bb72f38eeae34fa7c64f64e0a97b2e4e4433867775
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-03-06
4
+
5
+ ### Added
6
+
7
+ - Untracked file support with file status badges (new, modified, deleted)
8
+ - Color-coded status badges in file header
9
+ - Ctrl-f/b/d/u scroll navigation
10
+ - Scroll percentage indicator replacing line counter
11
+ - Hook system for AI agent integration (`sight install-hook <agent>`)
12
+ - Claude Code hook via `UserPromptSubmit`
13
+ - Cursor hook via `beforeSubmitPrompt`
14
+ - Highlighted commented hunks in gutter and status bar
15
+
16
+ ## [0.3.0] - 2026-03-04
17
+
18
+ ### Added
19
+
20
+ - Comment on hunks with `c` — annotations are printed to stdout on quit as markdown
21
+
22
+ ### Changed
23
+
24
+ - Removed initial-commit diff fallback from `Git.diff`
25
+
3
26
  ## [0.2.0] - 2026-03-04
4
27
 
5
28
  ### Changed
data/CLAUDE.md ADDED
@@ -0,0 +1,37 @@
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
+ Untracked files are added via `Git.untracked_files` → `DiffParser.build_untracked`.
20
+
21
+ **Key structs** (all in `diff_parser.rb`): `DiffFile(path, hunks, status)`, `Hunk(context, lines)`, `DiffLine(type, content, lineno, old_lineno)`.
22
+ `DisplayLine(type, text, lineno)` is the render-side equivalent in `display_line.rb`.
23
+ `Annotation(file_path, type, hunk, comment)` in `annotation.rb` stores per-hunk comments; `AnnotationFormatter` serializes them for output.
24
+
25
+ **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).
26
+
27
+ **Hook system**: Unified via `sight install-hook <agent>` / `sight uninstall-hook <agent>` (agent: `claude` or `cursor`).
28
+ `ClaudeHookInstaller` manages a Claude Code `UserPromptSubmit` hook in `~/.config/claude/settings.json`.
29
+ `CursorHookInstaller` manages a Cursor `beforeSubmitPrompt` hook in `~/.cursor/hooks.json`.
30
+ Both read `.git/sight/pending-review`, output annotations, and delete the file. Hidden subcommands: `hook-run`, `cursor-hook-run`.
31
+
32
+ ## Conventions
33
+
34
+ - Ruby >= 3.2, uses `frozen_string_literal` in all files
35
+ - Linter: StandardRB (ruby_version: 3.4 in `.standard.yml`)
36
+ - Tests: Minitest with stubs/mocks, no test framework beyond minitest
37
+ - 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, hunk navigation, and file navigation.
3
+ TUI for closing the loop on AI-generated code changes.
4
4
 
5
5
  ## Installation
6
6
 
@@ -24,9 +24,30 @@ sight
24
24
  | `k` | Previous hunk |
25
25
  | `n` | Next file |
26
26
  | `p` | Previous file |
27
+ | `Ctrl-F` / `Ctrl-B` | Scroll full page down / up |
28
+ | `Ctrl-D` / `Ctrl-U` | Scroll half page down / up |
27
29
  | `?` | Toggle help |
30
+ | `c` | Comment on hunk |
28
31
  | `q` / `Esc` | Quit |
29
32
 
33
+ ### Agent Integration
34
+
35
+ Install a hook so annotations are automatically fed as context in your next message:
36
+
37
+ ```bash
38
+ sight install-hook claude # Claude Code (~/.config/claude/settings.json)
39
+ sight install-hook cursor # Cursor (~/.cursor/hooks.json)
40
+ ```
41
+
42
+ When you quit sight after annotating, the next message you send will include your annotations.
43
+
44
+ To remove:
45
+
46
+ ```bash
47
+ sight uninstall-hook claude
48
+ sight uninstall-hook cursor
49
+ ```
50
+
30
51
  ## Development
31
52
 
32
53
  ```bash
@@ -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,7 +4,7 @@ require "curses"
4
4
 
5
5
  module Sight
6
6
  class App
7
- attr_reader :files, :file_lines
7
+ attr_reader :files, :file_lines, :annotations
8
8
  attr_accessor :file_idx, :offset, :hunk_idx
9
9
 
10
10
  def initialize(files)
@@ -14,6 +14,7 @@ module Sight
14
14
  @offset = 0
15
15
  @hunk_idx = 0
16
16
  @hunk_offsets_cache = {}
17
+ @annotations = []
17
18
  end
18
19
 
19
20
  def run
@@ -52,6 +53,7 @@ module Sight
52
53
  Curses.init_pair(3, Curses::COLOR_CYAN, -1)
53
54
  Curses.init_pair(4, Curses::COLOR_YELLOW, -1)
54
55
  Curses.init_pair(5, 240, -1)
56
+ Curses.init_pair(6, Curses::COLOR_MAGENTA, -1)
55
57
  end
56
58
 
57
59
  def lines
@@ -69,11 +71,19 @@ module Sight
69
71
  end
70
72
 
71
73
  def render_header(win, width)
72
- path = files[file_idx].path
74
+ file = files[file_idx]
75
+ badge = "[#{file.status || :modified}]"
76
+ path = file.path
77
+ gap = width - path.length - badge.length
73
78
  win.setpos(0, 0)
74
- win.attron(color_for(:header)) { win.addstr(path[0, width]) }
79
+ if gap >= 1
80
+ win.attron(color_for(:header)) { win.addstr("#{path}#{" " * gap}") }
81
+ win.attron(badge_color(file.status)) { win.addstr(badge) }
82
+ else
83
+ win.attron(color_for(:header)) { win.addstr(path[0, width]) }
84
+ end
75
85
  win.setpos(1, 0)
76
- win.attron(color_for(:header)) { win.addstr("\u2500" * width) }
86
+ win.attron(Curses.color_pair(0) | Curses::A_BOLD) { win.addstr("\u2500" * width) }
77
87
  end
78
88
 
79
89
  def render_content(win, width)
@@ -88,13 +98,21 @@ module Sight
88
98
  lines.size
89
99
  end
90
100
 
101
+ commented_lines = commented_hunk_lines
102
+
91
103
  scroll_height.times do |row|
92
104
  idx = offset + row
93
105
  break if idx >= lines.size
94
106
  line = lines[idx]
95
107
  win.setpos(row + 2, 0)
96
108
  active = idx >= selected_start && idx < selected_end
97
- win.attron(dim) { win.addstr("#{format_gutter(line.type, line.lineno, gutter)} \u2502 ") }
109
+ gutter_str = format_gutter(line.type, line.lineno, gutter)
110
+ commented = commented_lines.include?(idx)
111
+ separator = commented ? "\u2503" : "\u2502"
112
+ sep_attr = commented ? Curses.color_pair(4) : dim
113
+ win.attron(dim) { win.addstr("#{gutter_str} ") }
114
+ win.attron(sep_attr) { win.addstr(separator) }
115
+ win.attron(dim) { win.addstr(" ") }
98
116
  attr = active ? color_for(line.type) : Curses.color_pair(5)
99
117
  win.attron(attr) { win.addstr(line.text[0, content_width]) }
100
118
  end
@@ -103,7 +121,13 @@ module Sight
103
121
  def render_status_bar(win, width)
104
122
  win.setpos(Curses.lines - 1, 0)
105
123
  win.attron(Curses.color_pair(4) | Curses::A_REVERSE) do
106
- status = " File #{file_idx + 1}/#{files.size} | Hunk #{hunk_idx + 1}/#{hunk_offsets.size} | Line #{offset + 1}/#{lines.size} "
124
+ percent = if lines.size <= scroll_height
125
+ 100
126
+ else
127
+ ((offset + scroll_height) * 100.0 / lines.size).ceil.clamp(0, 100)
128
+ end
129
+ commented = hunk_commented?(file_idx, hunk_idx) ? " [commented]" : ""
130
+ status = " File #{file_idx + 1}/#{files.size} | Hunk #{hunk_idx + 1}/#{hunk_offsets.size}#{commented} | #{percent}% "
107
131
  win.addstr(status.ljust(width))
108
132
  end
109
133
  end
@@ -122,11 +146,20 @@ module Sight
122
146
  case type
123
147
  when :add then Curses.color_pair(1)
124
148
  when :del then Curses.color_pair(2)
125
- when :header then Curses.color_pair(4) | Curses::A_BOLD
149
+ when :header then Curses.color_pair(0) | Curses::A_BOLD
126
150
  else Curses.color_pair(0)
127
151
  end
128
152
  end
129
153
 
154
+ def badge_color(status)
155
+ case status
156
+ when :added then Curses.color_pair(1) | Curses::A_BOLD
157
+ when :deleted then Curses.color_pair(2) | Curses::A_BOLD
158
+ when :untracked then Curses.color_pair(6) | Curses::A_BOLD
159
+ else Curses.color_pair(4) | Curses::A_BOLD
160
+ end
161
+ end
162
+
130
163
  def scroll_height
131
164
  Curses.lines - 3
132
165
  end
@@ -146,6 +179,11 @@ module Sight
146
179
  when "k" then jump_hunk(-1)
147
180
  when "n" then jump_file(1)
148
181
  when "p" then jump_file(-1)
182
+ when 6 then scroll(scroll_height)
183
+ when 2 then scroll(-scroll_height)
184
+ when 4 then scroll(scroll_height / 2)
185
+ when 21 then scroll(-scroll_height / 2)
186
+ when "c" then annotate_hunk
149
187
  when "?" then show_help
150
188
  end
151
189
  true
@@ -154,9 +192,14 @@ module Sight
154
192
  HELP_KEYS = [
155
193
  ["j", "Next hunk"],
156
194
  ["k", "Previous hunk"],
195
+ ["C-f", "Page down"],
196
+ ["C-b", "Page up"],
197
+ ["C-d", "Half page down"],
198
+ ["C-u", "Half page up"],
157
199
  ["n", "Next file"],
158
200
  ["p", "Previous file"],
159
201
  ["q / Esc", "Quit"],
202
+ ["c", "Comment on hunk"],
160
203
  ["?", "Toggle this help"]
161
204
  ].freeze
162
205
 
@@ -196,6 +239,66 @@ module Sight
196
239
  end
197
240
  end
198
241
 
242
+ def annotate_hunk
243
+ hunk = files[file_idx].hunks[hunk_idx]
244
+ return unless hunk
245
+ comment = prompt_comment("Comment on hunk")
246
+ return unless comment
247
+ @annotations << Annotation.new(
248
+ file_path: files[file_idx].path,
249
+ type: :hunk,
250
+ hunk: hunk,
251
+ comment: comment
252
+ )
253
+ end
254
+
255
+ def prompt_comment(title)
256
+ win = Curses.stdscr
257
+ max_width = 80
258
+ width = [Curses.cols * 2 / 3, 50].max
259
+ width = [width, max_width].min
260
+ height = 5
261
+ top = (Curses.lines - height) / 2
262
+ left = (Curses.cols - width) / 2
263
+
264
+ draw_box(win, top, left, width, height, title, [""])
265
+ win.setpos(top + 3, left + 3)
266
+ win.refresh
267
+
268
+ Curses.curs_set(1)
269
+ text = ""
270
+ field_width = width - 6
271
+ redraw_field = -> {
272
+ win.setpos(top + 3, left + 3)
273
+ win.addstr(" " * field_width)
274
+ visible = (text.length > field_width) ? text[-field_width..] : text
275
+ win.setpos(top + 3, left + 3)
276
+ win.addstr(visible)
277
+ }
278
+ loop do
279
+ ch = Curses.getch
280
+ case ch
281
+ when 10, 13, Curses::KEY_ENTER
282
+ break
283
+ when 27
284
+ text = nil
285
+ break
286
+ when Curses::KEY_BACKSPACE, 127, 8
287
+ unless text.empty?
288
+ text = text[0..-2]
289
+ redraw_field.call
290
+ end
291
+ else
292
+ text += ch.chr if ch.is_a?(Integer) && ch >= 32 && ch < 127
293
+ text += ch if ch.is_a?(String) && ch.length == 1
294
+ redraw_field.call
295
+ end
296
+ end
297
+ Curses.curs_set(0)
298
+
299
+ text&.strip&.empty? ? nil : text&.strip
300
+ end
301
+
199
302
  def hunk_offsets
200
303
  @hunk_offsets_cache[file_idx] ||= begin
201
304
  offsets = []
@@ -208,6 +311,27 @@ module Sight
208
311
  end
209
312
  end
210
313
 
314
+ def hunk_commented?(file_index, hunk_index)
315
+ path = files[file_index].path
316
+ hunk = files[file_index].hunks[hunk_index]
317
+ annotations.any? { |a| a.file_path == path && a.hunk.equal?(hunk) }
318
+ end
319
+
320
+ def commented_hunk_lines
321
+ result = []
322
+ hunk_offsets.each_with_index do |offset, hunk_index|
323
+ next unless hunk_commented?(file_idx, hunk_index)
324
+ hunk_end = (hunk_index + 1 < hunk_offsets.size) ? hunk_offsets[hunk_index + 1] : lines.size
325
+ (offset...hunk_end).each { |i| result << i }
326
+ end
327
+ result
328
+ end
329
+
330
+ def scroll(delta)
331
+ max = [0, lines.size - scroll_height].max
332
+ self.offset = (offset + delta).clamp(0, max)
333
+ end
334
+
211
335
  def jump_hunk(delta)
212
336
  return if hunk_offsets.empty?
213
337
  self.hunk_idx = (hunk_idx + delta).clamp(0, hunk_offsets.size - 1)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Sight
7
+ module ClaudeHookInstaller
8
+ HOOK_COMMAND = "sight hook-run"
9
+
10
+ module_function
11
+
12
+ def settings_path
13
+ File.join(Dir.home, ".config", "claude", "settings.json")
14
+ end
15
+
16
+ def install(path: settings_path)
17
+ settings = if File.exist?(path)
18
+ JSON.parse(File.read(path))
19
+ else
20
+ {}
21
+ end
22
+
23
+ hooks = settings["hooks"] ||= {}
24
+ prompt_hooks = hooks["UserPromptSubmit"] ||= []
25
+
26
+ if prompt_hooks.any? { |h| hook_is_sight?(h) }
27
+ puts "sight hook already installed"
28
+ return
29
+ end
30
+
31
+ prompt_hooks << {
32
+ "matcher" => "*",
33
+ "hooks" => [{"type" => "command", "command" => HOOK_COMMAND}]
34
+ }
35
+
36
+ FileUtils.mkdir_p(File.dirname(path))
37
+ File.write(path, JSON.pretty_generate(settings) + "\n")
38
+ puts "Installed sight hook into #{path}"
39
+ end
40
+
41
+ def uninstall(path: settings_path)
42
+ unless File.exist?(path)
43
+ puts "No settings file found"
44
+ return
45
+ end
46
+
47
+ settings = JSON.parse(File.read(path))
48
+ prompt_hooks = settings.dig("hooks", "UserPromptSubmit")
49
+
50
+ unless prompt_hooks&.any? { |h| hook_is_sight?(h) }
51
+ puts "No sight hook found"
52
+ return
53
+ end
54
+
55
+ prompt_hooks.reject! { |h| hook_is_sight?(h) }
56
+ File.write(path, JSON.pretty_generate(settings) + "\n")
57
+ puts "Uninstalled sight hook"
58
+ end
59
+
60
+ def hook_is_sight?(entry)
61
+ Array(entry["hooks"]).any? { |h| h["command"]&.include?("sight") }
62
+ end
63
+ end
64
+ end
data/lib/sight/cli.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Sight
4
6
  module CLI
5
7
  module_function
@@ -9,7 +11,11 @@ module Sight
9
11
  puts "Usage: sight"
10
12
  puts "Interactive git diff viewer (staged + unstaged)"
11
13
  puts
12
- puts "Keys: j/k hunks, n/p files, ? help, q quit"
14
+ puts "Keys: j/k hunks, n/p files, c comment, ? help, q quit"
15
+ puts
16
+ puts "Subcommands:"
17
+ puts " install-hook <agent> Install hook (claude, cursor)"
18
+ puts " uninstall-hook <agent> Remove hook (claude, cursor)"
13
19
  return
14
20
  end
15
21
 
@@ -18,14 +24,103 @@ module Sight
18
24
  return
19
25
  end
20
26
 
21
- raw = Git.diff
22
- if raw.empty?
23
- warn "No diff output"
27
+ if argv[0] == "install-hook"
28
+ install_hook(argv[1])
29
+ return
30
+ end
31
+
32
+ if argv[0] == "uninstall-hook"
33
+ uninstall_hook(argv[1])
24
34
  return
25
35
  end
26
36
 
37
+ if argv.include?("hook-run")
38
+ run_hook
39
+ return
40
+ end
41
+
42
+ if argv.include?("cursor-hook-run")
43
+ run_cursor_hook
44
+ return
45
+ end
46
+
47
+ Git.clear_pending_review
48
+
49
+ raw = Git.diff
27
50
  files = DiffParser.parse(raw)
28
- App.new(files).run
51
+
52
+ Git.untracked_files.each do |path|
53
+ content = Git.file_content(path)
54
+ next unless content.valid_encoding? && !content.include?("\x00")
55
+ files << DiffParser.build_untracked(path, content)
56
+ end
57
+
58
+ if files.empty?
59
+ warn "No changes"
60
+ return
61
+ end
62
+
63
+ app = App.new(files)
64
+ app.run
65
+
66
+ unless app.annotations.empty?
67
+ formatted = AnnotationFormatter.format(app.annotations)
68
+ puts formatted
69
+ Git.save_pending_review(formatted)
70
+ end
71
+ end
72
+
73
+ AGENTS = {
74
+ "claude" => ClaudeHookInstaller,
75
+ "cursor" => CursorHookInstaller
76
+ }.freeze
77
+
78
+ def install_hook(agent)
79
+ installer = AGENTS[agent]
80
+ unless installer
81
+ warn "Unknown agent: #{agent.inspect}. Use: claude, cursor"
82
+ return
83
+ end
84
+ installer.install
85
+ end
86
+
87
+ def uninstall_hook(agent)
88
+ installer = AGENTS[agent]
89
+ unless installer
90
+ warn "Unknown agent: #{agent.inspect}. Use: claude, cursor"
91
+ return
92
+ end
93
+ installer.uninstall
94
+ end
95
+
96
+ def run_hook
97
+ git_dir = Git.repo_dir
98
+ rescue Error
99
+ nil
100
+ else
101
+ file = File.join(git_dir, "sight", "pending-review")
102
+ return unless File.exist?(file)
103
+
104
+ content = File.read(file)
105
+ puts "The user has just finished reviewing your code changes in sight. Here are their annotations:"
106
+ puts
107
+ puts content
108
+ File.delete(file)
109
+ end
110
+
111
+ def run_cursor_hook
112
+ $stdin.read
113
+ git_dir = Git.repo_dir
114
+ rescue Error
115
+ nil
116
+ else
117
+ file = File.join(git_dir, "sight", "pending-review")
118
+ return unless File.exist?(file)
119
+
120
+ content = File.read(file)
121
+ message = "The user has just finished reviewing your code changes in sight. Here are their annotations:\n\n#{content}"
122
+ puts JSON.generate(user_message: message)
123
+ File.delete(file)
29
124
  end
30
125
  end
31
126
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Sight
7
+ module CursorHookInstaller
8
+ HOOK_COMMAND = "sight cursor-hook-run"
9
+
10
+ module_function
11
+
12
+ def hooks_path
13
+ File.join(Dir.home, ".cursor", "hooks.json")
14
+ end
15
+
16
+ def install(path: hooks_path)
17
+ config = if File.exist?(path)
18
+ JSON.parse(File.read(path))
19
+ else
20
+ {"version" => 1, "hooks" => {}}
21
+ end
22
+
23
+ config["version"] ||= 1
24
+ hooks = config["hooks"] ||= {}
25
+ prompt_hooks = hooks["beforeSubmitPrompt"] ||= []
26
+
27
+ if prompt_hooks.any? { |h| hook_is_sight?(h) }
28
+ puts "sight hook already installed"
29
+ return
30
+ end
31
+
32
+ prompt_hooks << {"command" => HOOK_COMMAND}
33
+
34
+ FileUtils.mkdir_p(File.dirname(path))
35
+ File.write(path, JSON.pretty_generate(config) + "\n")
36
+ puts "Installed sight hook into #{path}"
37
+ end
38
+
39
+ def uninstall(path: hooks_path)
40
+ unless File.exist?(path)
41
+ puts "No hooks file found"
42
+ return
43
+ end
44
+
45
+ config = JSON.parse(File.read(path))
46
+ prompt_hooks = config.dig("hooks", "beforeSubmitPrompt")
47
+
48
+ unless prompt_hooks&.any? { |h| hook_is_sight?(h) }
49
+ puts "No sight hook found"
50
+ return
51
+ end
52
+
53
+ prompt_hooks.reject! { |h| hook_is_sight?(h) }
54
+ File.write(path, JSON.pretty_generate(config) + "\n")
55
+ puts "Uninstalled sight hook"
56
+ end
57
+
58
+ def hook_is_sight?(entry)
59
+ entry["command"]&.include?("sight")
60
+ end
61
+ end
62
+ end
@@ -3,7 +3,7 @@
3
3
  module Sight
4
4
  DiffLine = Struct.new(:type, :content, :lineno, :old_lineno, keyword_init: true)
5
5
  Hunk = Struct.new(:context, :lines, keyword_init: true)
6
- DiffFile = Struct.new(:path, :hunks, keyword_init: true)
6
+ DiffFile = Struct.new(:path, :hunks, :status, keyword_init: true)
7
7
 
8
8
  module DiffParser
9
9
  module_function
@@ -19,10 +19,14 @@ module Sight
19
19
  if line.start_with?("diff --git ")
20
20
  current_hunk = nil
21
21
  path = line.split(" b/", 2).last
22
- current_file = DiffFile.new(path: path, hunks: [])
22
+ current_file = DiffFile.new(path: path, hunks: [], status: :modified)
23
23
  files << current_file
24
24
  elsif current_file.nil?
25
25
  next
26
+ elsif line.start_with?("new file mode")
27
+ current_file.status = :added
28
+ elsif line.start_with?("deleted file mode")
29
+ current_file.status = :deleted
26
30
  elsif line.start_with?("@@ ")
27
31
  context, new_start, old_start = parse_hunk_header(line)
28
32
  new_lineno = new_start
@@ -48,6 +52,14 @@ module Sight
48
52
  files
49
53
  end
50
54
 
55
+ def build_untracked(path, content)
56
+ lines = content.lines(chomp: true).each_with_index.map do |text, i|
57
+ DiffLine.new(type: :add, content: "+#{text}", lineno: i + 1, old_lineno: nil)
58
+ end
59
+ hunk = Hunk.new(context: nil, lines: lines)
60
+ DiffFile.new(path: path, hunks: [hunk], status: :untracked)
61
+ end
62
+
51
63
  def parse_hunk_header(line)
52
64
  match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)/)
53
65
  return [nil, 1, 1] unless match
data/lib/sight/git.rb CHANGED
@@ -1,18 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  module Sight
4
6
  module Git
5
7
  module_function
6
8
 
7
9
  def diff
8
10
  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
11
+ raise Error, "git diff failed" unless success
13
12
  output
14
13
  end
15
14
 
15
+ def untracked_files
16
+ output, success = run_cmd(["git", "ls-files", "--others", "--exclude-standard"])
17
+ return [] unless success
18
+ output.lines(chomp: true).reject(&:empty?)
19
+ end
20
+
21
+ def file_content(path)
22
+ File.read(path, mode: "rb")
23
+ end
24
+
25
+ def repo_dir
26
+ output, success = run_cmd(["git", "rev-parse", "--git-dir"])
27
+ raise Error, "not a git repository" unless success
28
+ output.strip
29
+ end
30
+
31
+ def save_pending_review(content)
32
+ dir = File.join(repo_dir, "sight")
33
+ FileUtils.mkdir_p(dir)
34
+ File.write(File.join(dir, "pending-review"), content)
35
+ end
36
+
37
+ def clear_pending_review
38
+ path = File.join(repo_dir, "sight", "pending-review")
39
+ File.delete(path) if File.exist?(path)
40
+ end
41
+
16
42
  def run_cmd(cmd, err: IO::NULL)
17
43
  output = IO.popen(cmd, err: err, &:read)
18
44
  [output, $?.success?]
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.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/sight.rb CHANGED
@@ -4,6 +4,10 @@ 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"
9
+ require_relative "sight/claude_hook_installer"
10
+ require_relative "sight/cursor_hook_installer"
7
11
  require_relative "sight/cli"
8
12
  require_relative "sight/app"
9
13
 
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.2.0
4
+ version: 0.4.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,14 +33,19 @@ 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
46
+ - lib/sight/claude_hook_installer.rb
43
47
  - lib/sight/cli.rb
48
+ - lib/sight/cursor_hook_installer.rb
44
49
  - lib/sight/diff_parser.rb
45
50
  - lib/sight/display_line.rb
46
51
  - lib/sight/git.rb
@@ -70,5 +75,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
70
75
  requirements: []
71
76
  rubygems_version: 4.0.7
72
77
  specification_version: 4
73
- summary: Interactive git diff viewer for the terminal
78
+ summary: TUI for closing the loop on AI-generated code changes
74
79
  test_files: []