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 +4 -4
- data/CHANGELOG.md +23 -0
- data/CLAUDE.md +37 -0
- data/README.md +22 -1
- data/lib/sight/annotation.rb +5 -0
- data/lib/sight/annotation_formatter.rb +30 -0
- data/lib/sight/app.rb +131 -7
- data/lib/sight/claude_hook_installer.rb +64 -0
- data/lib/sight/cli.rb +100 -5
- data/lib/sight/cursor_hook_installer.rb +62 -0
- data/lib/sight/diff_parser.rb +14 -2
- data/lib/sight/git.rb +30 -4
- data/lib/sight/version.rb +1 -1
- data/lib/sight.rb +4 -0
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff89f4bccb463b21835b299946bcf7a54b43f9792eb0b262954812c79633da19
|
|
4
|
+
data.tar.gz: 7dab4e4911a9bf145a5b0542ac993c6b5d3ce25af54b345e537291c5567db681
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
data/lib/sight/diff_parser.rb
CHANGED
|
@@ -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
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.
|
|
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:
|
|
27
|
-
|
|
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:
|
|
78
|
+
summary: TUI for closing the loop on AI-generated code changes
|
|
74
79
|
test_files: []
|