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 +4 -4
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +29 -0
- data/README.md +6 -11
- data/lib/sight/annotation.rb +5 -0
- data/lib/sight/annotation_formatter.rb +30 -0
- data/lib/sight/app.rb +110 -30
- data/lib/sight/cli.rb +10 -3
- data/lib/sight/git.rb +1 -4
- data/lib/sight/version.rb +1 -1
- data/lib/sight.rb +2 -0
- metadata +7 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e488680c7d4ecc42d8cd02b8d2731a315755b35df2a9cf319ed3f0d413e2809
|
|
4
|
+
data.tar.gz: 375af2bad773014e984f0b89c3333c839dd84bfc8e8cd610357c78bcfd052962
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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`
|
|
24
|
-
| `k`
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
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,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
|
-
|
|
87
|
-
win.attron(
|
|
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"
|
|
139
|
-
when "k"
|
|
140
|
-
when "
|
|
141
|
-
when "
|
|
142
|
-
when "
|
|
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
|
|
155
|
-
["k
|
|
156
|
-
["
|
|
157
|
-
["
|
|
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
|
|
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)
|
|
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
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.
|
|
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:
|
|
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,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:
|
|
76
|
+
summary: TUI for closing the loop on AI-generated code changes
|
|
74
77
|
test_files: []
|