git-time-machine/gtm

1505 lines
61 KiB
Python
Executable File

#!/usr/bin/env python3
import curses
import difflib
import os
import subprocess
import sys
import argparse
from dataclasses import dataclass, field, replace
from typing import List, Optional, Tuple
VERSION = "2025-06-07.3"
# --- Data Fetching & Utility Functions (Pure) ---
def is_file_tracked_by_git(filename):
"""Check if a file is tracked by git."""
cmd = ['git', 'ls-files', '--error-unmatch', filename]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
def get_commits(filename):
cmd = ['git', 'log', '--pretty=format:%h %ad %s', '--date=format:%Y-%m-%d %H:%M', '--', filename]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.splitlines()
def get_commit_details(commit_hash):
"""Get detailed information about a specific commit."""
# Get commit author (name only, no email)
author_cmd = ['git', 'show', '-s', '--format=%an', commit_hash]
author_result = subprocess.run(author_cmd, capture_output=True, text=True)
author = author_result.stdout.strip()
# Get branch information
branch_cmd = ['git', 'branch', '--contains', commit_hash]
branch_result = subprocess.run(branch_cmd, capture_output=True, text=True)
branches = [b.strip() for b in branch_result.stdout.splitlines()]
# Find the current branch (marked with *)
current_branch = "detached"
for branch in branches:
if branch.startswith('*'):
current_branch = branch[1:].strip()
break
# Get full commit message
message_cmd = ['git', 'show', '-s', '--format=%B', commit_hash]
message_result = subprocess.run(message_cmd, capture_output=True, text=True)
message = message_result.stdout.strip()
return {
'author': author,
'branch': current_branch,
'message': message
}
def get_file_at_commit(commit_hash, filename):
cmd = ['git', 'show', f'{commit_hash}:{filename}']
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.splitlines()
def find_best_matching_line(reference_line, file_lines, max_lines=None):
"""Find the best matching line in file_lines that matches reference_line.
Returns the line index or None if no good match is found."""
if not reference_line or not file_lines:
return None
# First try exact match
for i, line in enumerate(file_lines):
if line == reference_line:
return i
# If no exact match, use difflib to find the most similar line
# Only search through a reasonable number of lines for performance
search_lines = file_lines[:max_lines] if max_lines else file_lines
best_match_idx = None
best_ratio = 0.0
# Create a SequenceMatcher for the reference line, but reuse it for efficiency
s = difflib.SequenceMatcher(None, reference_line)
for i, line in enumerate(search_lines):
s.set_seq2(line)
ratio = s.ratio()
if ratio > best_ratio:
best_ratio = ratio
best_match_idx = i
# Only return a match if it's reasonably good (e.g., ratio > 0.6)
if best_ratio > 0.6:
return best_match_idx
return None
def get_diff_info(current_commit, prev_commit, filename):
"""Get diff information between two commits for a file"""
if not prev_commit:
return [], []
cmd = ['git', 'diff', prev_commit, current_commit, '--', filename]
result = subprocess.run(cmd, capture_output=True, text=True)
lines = result.stdout.splitlines()
added_lines = []
deleted_lines = []
current_line_num = 0
i = 0
while i < len(lines):
line = lines[i]
if line.startswith('@@'):
parts = line.split()
if len(parts) >= 3:
add_part = parts[2][1:]
if ',' in add_part:
current_line_num, _ = map(int, add_part.split(','))
else:
current_line_num = int(add_part)
elif line.startswith('+') and not line.startswith('+++'):
added_lines.append((current_line_num, line[1:]))
current_line_num += 1
elif line.startswith('-') and not line.startswith('---'):
deleted_lines.append((current_line_num, line[1:]))
elif not line.startswith('+') and not line.startswith('-') and not line.startswith('@@'):
current_line_num += 1
i += 1
return added_lines, deleted_lines
@dataclass
class StatusBarLayout:
"""Encapsulates all status bar layout calculations"""
total_height: int
start_y: int
main_status_y: int
commit_detail_start_y: int
commit_detail_end_y: int
available_commit_lines: int
screen_width: int
@classmethod
def create(cls, screen_height: int, screen_width: int, status_bar_height: int):
start_y = screen_height - status_bar_height
return cls(
total_height=status_bar_height,
start_y=start_y,
main_status_y=start_y, # Top line is main status
commit_detail_start_y=start_y + 1, # Commit details start below
commit_detail_end_y=screen_height - 1, # Bottom line
available_commit_lines=max(0, status_bar_height - 1),
screen_width=screen_width
)
@dataclass
class AppState:
filename: str
width: int
height: int
show_additions: bool
show_deletions: bool
enable_mouse: bool
show_sidebar: bool = True
commits: List[str] = field(default_factory=list)
file_lines: List[str] = field(default_factory=list)
added_lines: List[Tuple[int, str]] = field(default_factory=list)
deleted_lines: List[Tuple[int, str]] = field(default_factory=list)
focus: str = "left"
divider_col: int = 40
selected_commit_idx: int = 0
left_scroll_offset: int = 0
right_scroll_offset: int = 0
dragging_divider: bool = False
should_exit: bool = False
is_selecting: bool = False
selection_start_coord: Optional[Tuple[int, int]] = None
selection_end_coord: Optional[Tuple[int, int]] = None
click_position: Optional[Tuple[int, int]] = None
last_bstate: int = 0
mouse_x: int = -1
mouse_y: int = -1
# Line wrapping settings
wrap_lines: bool = True
# Line numbers settings
show_line_numbers: bool = False
# Change navigation
change_blocks: List[Tuple[int, int]] = field(default_factory=list) # (start_line, end_line) of each change block
current_change_idx: int = -1 # -1 means no change is selected
# Search functionality
search_mode: bool = False
search_query: str = ""
search_matches: List[int] = field(default_factory=list) # Line numbers of matches
current_match_idx: int = -1 # Index in search_matches
# Help popup
show_help: bool = False
# Commit details
commit_hash: str = ""
commit_message: str = ""
commit_author: str = ""
commit_branch: str = ""
# Status bar settings
status_bar_height: int = 2 # Default height for the status bar (2 lines)
dragging_status_bar: bool = False # Flag to track if user is resizing the status bar
# --- Actions (Controller) ---
def calculate_change_blocks(state: AppState) -> AppState:
"""Calculate positions of change blocks (consecutive changes) in the file."""
if not state.added_lines and not state.deleted_lines:
return replace(state, change_blocks=[], current_change_idx=-1)
# Collect all change positions
all_changes = []
for line_num, _ in state.added_lines:
all_changes.append(line_num)
for line_num, _ in state.deleted_lines:
all_changes.append(line_num)
# Sort all positions
all_changes.sort()
if not all_changes:
return replace(state, change_blocks=[], current_change_idx=-1)
# Group into blocks (changes within 3 lines of each other are considered one block)
blocks = []
current_block_start = all_changes[0]
current_block_end = all_changes[0]
for pos in all_changes[1:]:
if pos <= current_block_end + 3: # Within 3 lines of previous change
current_block_end = pos
else:
# Start a new block
blocks.append((current_block_start, current_block_end))
current_block_start = pos
current_block_end = pos
# Add the last block
blocks.append((current_block_start, current_block_end))
# Add debug output to a log file
with open("/tmp/gtm_debug.log", "a") as f:
f.write(f"Found {len(blocks)} change blocks\n")
for i, (start, end) in enumerate(blocks):
f.write(f" Block {i+1}: lines {start}-{end}\n")
f.write(f"Added lines: {state.added_lines}\n")
f.write(f"Deleted lines: {state.deleted_lines}\n")
return replace(state, change_blocks=blocks, current_change_idx=-1)
def load_commit_content(state: AppState) -> AppState:
if not state.commits:
return state
reference_line = None
scroll_percentage = 0
if len(state.file_lines) > 0:
max_scroll_old = max(0, len(state.file_lines) - (state.height - state.status_bar_height))
if max_scroll_old > 0:
scroll_percentage = state.right_scroll_offset / max_scroll_old
if state.right_scroll_offset < len(state.file_lines):
reference_line = state.file_lines[state.right_scroll_offset]
commit_hash = state.commits[state.selected_commit_idx].split()[0]
file_lines = get_file_at_commit(commit_hash, state.filename)
prev_commit_hash = None
if state.selected_commit_idx < len(state.commits) - 1:
prev_commit_hash = state.commits[state.selected_commit_idx + 1].split()[0]
if state.show_additions or state.show_deletions:
added_lines, deleted_lines = get_diff_info(commit_hash, prev_commit_hash, state.filename)
else:
added_lines, deleted_lines = [], []
# Get commit details
commit_details = get_commit_details(commit_hash)
# Get commit message from the commit line
commit_line = state.commits[state.selected_commit_idx]
parts = commit_line.split(' ', 3) # Split into hash, date, time, message
short_message = parts[3] if len(parts) >= 4 else ""
# If commit_details['message'] is empty, use short_message as fallback
if not commit_details['message'].strip():
commit_details['message'] = short_message
# Ensure we have some commit message to display
if not commit_details['message']:
commit_details['message'] = short_message if short_message else "No commit message"
max_scroll_new = max(0, len(file_lines) - (state.height - state.status_bar_height))
right_scroll_offset = state.right_scroll_offset
if reference_line:
matching_line_idx = find_best_matching_line(reference_line, file_lines, 1000)
if matching_line_idx is not None:
right_scroll_offset = matching_line_idx
else:
right_scroll_offset = int(scroll_percentage * max_scroll_new)
else:
right_scroll_offset = int(scroll_percentage * max_scroll_new)
right_scroll_offset = max(0, min(right_scroll_offset, max_scroll_new))
new_state = replace(state,
file_lines=file_lines,
added_lines=added_lines,
deleted_lines=deleted_lines,
right_scroll_offset=right_scroll_offset,
commit_hash=commit_hash,
commit_message=commit_details['message'],
commit_author=commit_details['author'],
commit_branch=commit_details['branch']
)
# Calculate change blocks for navigation
return calculate_change_blocks(new_state)
def update_dimensions(state: AppState, height: int, width: int) -> AppState:
return replace(state, height=height, width=width)
def toggle_sidebar(state: AppState) -> AppState:
new_show_sidebar = not state.show_sidebar
new_focus = state.focus
if not new_show_sidebar:
new_focus = "right"
return replace(state, show_sidebar=new_show_sidebar, focus=new_focus)
def move_commit_selection(state: AppState, delta: int) -> AppState:
new_idx = state.selected_commit_idx + delta
if 0 <= new_idx < len(state.commits):
new_state = replace(state, selected_commit_idx=new_idx)
return load_commit_content(new_state)
return state
def page_commit_selection(state: AppState, direction: int) -> AppState:
page_size = state.height - 1
delta = page_size * direction
return move_commit_selection(state, delta)
def scroll_right_pane(state: AppState, delta: int) -> AppState:
max_scroll = max(0, len(state.file_lines) - (state.height - 1))
new_offset = state.right_scroll_offset + delta
new_offset = max(0, min(new_offset, max_scroll))
return replace(state, right_scroll_offset=new_offset)
def page_right_pane(state: AppState, direction: int) -> AppState:
page_size = state.height - 1
delta = page_size * direction
return scroll_right_pane(state, delta)
def update_divider(state: AppState, mx: int) -> AppState:
min_col = 10
max_col = state.width - 20
new_divider_col = max(min_col, min(mx, max_col))
return replace(state, divider_col=new_divider_col)
# --- Rendering Functions (View) ---
def draw_left_pane(stdscr, state):
if not state.show_sidebar:
return
if state.selected_commit_idx < state.left_scroll_offset:
state.left_scroll_offset = state.selected_commit_idx
elif state.selected_commit_idx >= state.left_scroll_offset + state.height - state.status_bar_height:
state.left_scroll_offset = state.selected_commit_idx - (state.height - state.status_bar_height - 1)
visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - state.status_bar_height]
for i, line in enumerate(visible_commits):
display_index = i + state.left_scroll_offset
if display_index == state.selected_commit_idx:
stdscr.attron(curses.A_REVERSE)
stdscr.addnstr(i, 0, line, state.divider_col - 1)
if display_index == state.selected_commit_idx:
stdscr.attroff(curses.A_REVERSE)
def jump_to_next_change(state: AppState) -> AppState:
"""Jump to the next change block after the current scroll position."""
if not state.change_blocks:
return state
# Find the next change block after current position or current change index
current_pos = state.right_scroll_offset
current_idx = state.current_change_idx
# If we're already on a change block, try to move to the next one
if current_idx != -1:
next_idx = (current_idx + 1) % len(state.change_blocks)
else:
# Otherwise find the next change block after current scroll position
next_idx = -1
for i, (start_pos, _) in enumerate(state.change_blocks):
if start_pos > current_pos:
next_idx = i
break
# If no next change found, wrap to the first change
if next_idx == -1 and state.change_blocks:
next_idx = 0
if next_idx != -1:
# Get the start position of the change block
new_scroll = max(0, state.change_blocks[next_idx][0] - 3) # Show 3 lines of context above
return replace(state, right_scroll_offset=new_scroll, current_change_idx=next_idx)
return state
def jump_to_prev_change(state: AppState) -> AppState:
"""Jump to the previous change block before the current scroll position."""
if not state.change_blocks:
return state
# Find the previous change block before current position or current change index
current_pos = state.right_scroll_offset
current_idx = state.current_change_idx
# If we're already on a change block, try to move to the previous one
if current_idx != -1:
prev_idx = (current_idx - 1) % len(state.change_blocks)
else:
# Otherwise find the previous change block before current scroll position
prev_idx = -1
for i in range(len(state.change_blocks) - 1, -1, -1):
if state.change_blocks[i][0] < current_pos:
prev_idx = i
break
# If no previous change found, wrap to the last change
if prev_idx == -1 and state.change_blocks:
prev_idx = len(state.change_blocks) - 1
if prev_idx != -1:
# Get the start position of the change block
new_scroll = max(0, state.change_blocks[prev_idx][0] - 3) # Show 3 lines of context above
return replace(state, right_scroll_offset=new_scroll, current_change_idx=prev_idx)
return state
def perform_search(state: AppState) -> AppState:
"""Search for the query in the file content and update matches."""
if not state.search_query:
return replace(state, search_matches=[], current_match_idx=-1)
matches = []
query = state.search_query.lower() # Case-insensitive search
for i, line in enumerate(state.file_lines):
if query in line.lower():
matches.append(i)
new_state = replace(state, search_matches=matches)
# If we have matches, select the first one
if matches:
new_state = replace(new_state, current_match_idx=0)
# Scroll to the first match
new_state = replace(new_state, right_scroll_offset=max(0, matches[0] - 3))
else:
new_state = replace(new_state, current_match_idx=-1)
return new_state
def jump_to_next_match(state: AppState) -> AppState:
"""Jump to the next search match."""
if not state.search_matches:
return state
next_idx = (state.current_match_idx + 1) % len(state.search_matches)
new_state = replace(state, current_match_idx=next_idx)
# Scroll to the match
match_line = state.search_matches[next_idx]
return replace(new_state, right_scroll_offset=max(0, match_line - 3))
def jump_to_prev_match(state: AppState) -> AppState:
"""Jump to the previous search match."""
if not state.search_matches:
return state
prev_idx = (state.current_match_idx - 1) % len(state.search_matches)
new_state = replace(state, current_match_idx=prev_idx)
# Scroll to the match
match_line = state.search_matches[prev_idx]
return replace(new_state, right_scroll_offset=max(0, match_line - 3))
def draw_right_pane(stdscr, state):
# If sidebar is hidden, right pane starts at column 0
right_start = 0 if not state.show_sidebar else state.divider_col + 2
# Calculate line number width if enabled
line_num_width = 0
if state.show_line_numbers:
# Width based on the number of digits in the largest line number
max_line_num = len(state.file_lines)
line_num_width = len(str(max_line_num)) + 1 # +1 for spacing
# Adjust right pane start position and width for line numbers
if state.show_line_numbers:
right_start += line_num_width
right_width = state.width - right_start - 1
max_scroll = max(0, len(state.file_lines) - (state.height - state.status_bar_height))
state.right_scroll_offset = min(state.right_scroll_offset, max_scroll)
visible_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + state.height - state.status_bar_height]
display_lines = []
deleted_line_map = {}
if state.show_deletions:
for del_line_num, del_content in state.deleted_lines:
if del_line_num not in deleted_line_map:
deleted_line_map[del_line_num] = []
deleted_line_map[del_line_num].append(del_content)
for i, line in enumerate(visible_lines):
line_num = i + state.right_scroll_offset + 1
if state.show_deletions and line_num in deleted_line_map:
for del_content in deleted_line_map[line_num]:
# For deleted lines, use the same line number
display_lines.append({'type': 'deleted', 'content': del_content, 'line_num': line_num})
is_added = False
if state.show_additions:
for added_line_num, _ in state.added_lines:
if added_line_num == line_num:
is_added = True
break
display_lines.append({'type': 'added' if is_added else 'regular', 'content': line, 'line_num': line_num})
display_row = 0
for line_info in display_lines:
if display_row >= state.height - 2:
break
line_type = line_info['type']
content = line_info['content']
line_num = line_info.get('line_num', 0)
# Check if this is a search match
is_search_match = False
is_current_match = False
if state.search_matches and line_num - 1 in state.search_matches: # Adjust for 0-based indexing
match_idx = state.search_matches.index(line_num - 1)
is_search_match = True
is_current_match = (match_idx == state.current_match_idx)
# Draw line number if enabled
if state.show_line_numbers:
line_num_str = str(line_num).rjust(line_num_width - 1) + " "
line_num_pos = (0 if not state.show_sidebar else state.divider_col + 2)
# Use different color for line numbers
if line_type == 'added':
stdscr.addstr(display_row, line_num_pos, line_num_str, curses.color_pair(5))
elif line_type == 'deleted':
stdscr.addstr(display_row, line_num_pos, line_num_str, curses.color_pair(6))
else:
stdscr.addstr(display_row, line_num_pos, line_num_str, curses.color_pair(7))
# Determine prefix based on line type and diff mode
prefix = ""
if line_type == 'added':
prefix = "+ "
attr = curses.color_pair(3)
elif line_type == 'deleted':
prefix = "- "
attr = curses.color_pair(4)
else:
if state.show_additions or state.show_deletions:
prefix = " "
attr = curses.A_NORMAL
# If this is a search match, override the attribute
if is_search_match:
if is_current_match:
attr = curses.A_REVERSE | curses.A_BOLD
else:
attr = curses.A_UNDERLINE
# Calculate available width for content
content_start = right_start + len(prefix)
available_width = right_width - len(prefix)
# Handle line wrapping
if len(content) <= available_width or not state.wrap_lines:
# Line fits or wrapping is disabled
if prefix:
stdscr.addstr(display_row, right_start, prefix, attr)
# If wrapping is disabled, just show what fits in the available width
display_content = content
if not state.wrap_lines and len(content) > available_width:
display_content = content[:available_width]
# If this is a search match, highlight just the matching part
if is_search_match and state.search_query:
# Find all occurrences of the search query in the content (case-insensitive)
query = state.search_query.lower()
content_lower = display_content.lower()
pos = 0
while pos < len(content_lower):
match_pos = content_lower.find(query, pos)
if match_pos == -1:
break
# Draw the matching part with highlighting
match_attr = attr
if match_pos + len(query) <= len(display_content):
# Draw text before match
if match_pos > 0:
stdscr.addnstr(display_row, content_start, display_content[:match_pos], match_pos)
# Draw the match with highlighting
match_text = display_content[match_pos:match_pos + len(query)]
stdscr.addnstr(display_row, content_start + match_pos, match_text, len(query), match_attr)
# Draw text after match
if match_pos + len(query) < len(display_content):
remaining_text = display_content[match_pos + len(query):]
stdscr.addnstr(display_row, content_start + match_pos + len(query),
remaining_text, len(remaining_text))
pos = match_pos + 1
display_row += 1
else:
# Normal display without search highlighting
stdscr.addnstr(display_row, content_start, display_content, available_width, attr)
display_row += 1
else:
# Line needs wrapping and wrapping is enabled
remaining = content
first_line = True
while remaining and display_row < state.height - 2:
# For first line, add the prefix
if first_line:
if prefix:
stdscr.addstr(display_row, right_start, prefix, attr)
chunk = remaining[:available_width]
stdscr.addnstr(display_row, content_start, chunk, available_width, attr)
remaining = remaining[available_width:]
first_line = False
else:
# For continuation lines, indent by 2 spaces
indent = " "
indent_start = right_start
content_start = indent_start + len(indent)
wrap_width = right_width - len(indent)
stdscr.addstr(display_row, indent_start, indent)
chunk = remaining[:wrap_width]
stdscr.addnstr(display_row, content_start, chunk, wrap_width, attr)
remaining = remaining[wrap_width:]
display_row += 1
def draw_divider(stdscr, state):
if not state.show_sidebar:
return
divider_char = "" if state.dragging_divider else ""
for y in range(state.height - state.status_bar_height): # Don't draw through the status bars
try:
stdscr.addch(y, state.divider_col, divider_char)
except curses.error:
pass
def draw_selection(stdscr, state):
if not state.is_selecting or not state.selection_start_coord:
return
start_x, start_y = state.selection_start_coord
end_x, end_y = state.selection_end_coord
# Determine pane boundaries based on sidebar visibility
if state.show_sidebar:
# Determine pane from where selection started
pane = 'left' if start_x < state.divider_col else 'right'
if pane == 'left':
pane_x1, pane_x2 = 0, state.divider_col - 1
else: # right
pane_x1, pane_x2 = state.divider_col + 2, state.width - 1
else:
# When sidebar is hidden, there's only the right pane
pane = 'right'
pane_x1, pane_x2 = 0, state.width - 1
# Determine drag direction to handle multi-line selection correctly
if start_y < end_y or (start_y == end_y and start_x <= end_x):
drag_start_x, drag_start_y = start_x, start_y
drag_end_x, drag_end_y = end_x, end_y
else: # upward drag or right-to-left on same line
drag_start_x, drag_start_y = end_x, end_y
drag_end_x, drag_end_y = start_x, start_y
for y in range(drag_start_y, drag_end_y + 1):
x1, x2 = -1, -1
if drag_start_y == drag_end_y: # single line selection
x1, x2 = drag_start_x, drag_end_x
elif y == drag_start_y: # first line of multi-line selection
x1, x2 = drag_start_x, pane_x2
elif y == drag_end_y: # last line of multi-line selection
x1, x2 = pane_x1, drag_end_x
else: # middle line of multi-line selection
x1, x2 = pane_x1, pane_x2
# Clamp selection to pane boundaries
x1 = max(x1, pane_x1)
x2 = min(x2, pane_x2)
try:
if x1 < state.width and x1 <= x2:
length = x2 - x1 + 1
if length > 0:
stdscr.chgat(y, x1, length, curses.A_REVERSE)
except curses.error:
pass
def draw_help_popup(stdscr, state):
"""Draw a popup with keyboard shortcut help."""
if not state.show_help:
return
# Define help content
help_items = [
("Navigation", ""),
("j / Down", "Scroll down"),
("k / Up", "Scroll up"),
("Space / Page Down", "Page down"),
("b / Page Up", "Page up"),
("h / Left", "Focus left pane"),
("l / Right", "Focus right pane"),
("", ""),
("Features", ""),
("/", "Search in file"),
("n", "Next search match"),
("N", "Previous search match"),
("c", "Next change"),
("C", "Previous change"),
("d", "Toggle diff mode"),
("a", "Toggle diff additions"),
("x", "Toggle diff deletions"),
("s", "Toggle sidebar"),
("w", "Toggle line wrapping"),
("L", "Toggle line numbers"),
("q / Esc", "Quit"),
("?", "Show/hide this help")
]
# Calculate popup dimensions and position
popup_width = 60
# Calculate height based on number of help items plus margins
popup_height = len(help_items) + 4 # 4 extra lines for borders, title, and footer
popup_x = max(0, (state.width - popup_width) // 2)
popup_y = max(0, (state.height - popup_height) // 2)
# Fill the entire popup area with spaces to clear background
for y in range(popup_y, popup_y + popup_height):
for x in range(popup_x, popup_x + popup_width):
try:
stdscr.addch(y, x, ' ')
except curses.error:
pass
# Draw popup border
for y in range(popup_y, popup_y + popup_height):
for x in range(popup_x, popup_x + popup_width):
if y == popup_y or y == popup_y + popup_height - 1:
# Top and bottom borders
try:
stdscr.addch(y, x, curses.ACS_HLINE)
except curses.error:
pass
elif x == popup_x or x == popup_x + popup_width - 1:
# Left and right borders
try:
stdscr.addch(y, x, curses.ACS_VLINE)
except curses.error:
pass
# Draw corners
try:
stdscr.addch(popup_y, popup_x, curses.ACS_ULCORNER)
stdscr.addch(popup_y, popup_x + popup_width - 1, curses.ACS_URCORNER)
stdscr.addch(popup_y + popup_height - 1, popup_x, curses.ACS_LLCORNER)
stdscr.addch(popup_y + popup_height - 1, popup_x + popup_width - 1, curses.ACS_LRCORNER)
except curses.error:
pass
# Draw title
title = " Keyboard Shortcuts "
title_x = popup_x + (popup_width - len(title)) // 2
try:
stdscr.addstr(popup_y, title_x, title, curses.A_BOLD)
except curses.error:
pass
# Draw help content
for i, (key, desc) in enumerate(help_items):
y = popup_y + 2 + i
if y < popup_y + popup_height - 2:
if key == "Navigation" or key == "Features":
# Section headers
try:
stdscr.addstr(y, popup_x + 2, key, curses.A_BOLD | curses.A_UNDERLINE)
except curses.error:
pass
elif key:
# Key and description
try:
stdscr.addstr(y, popup_x + 2, key, curses.A_BOLD)
stdscr.addstr(y, popup_x + 22, desc)
except curses.error:
pass
# Draw footer
footer = " Press any key to close "
footer_x = popup_x + (popup_width - len(footer)) // 2
try:
stdscr.addstr(popup_y + popup_height - 1, footer_x, footer, curses.A_BOLD)
except curses.error:
pass
def draw_status_bar_background(stdscr, layout: StatusBarLayout):
"""Fill the entire status bar area with reverse video background"""
for y in range(layout.start_y, layout.start_y + layout.total_height):
try:
# Fill the entire line with spaces in reverse video
# This is more efficient than adding characters one by one
stdscr.addstr(y, 0, ' ' * layout.screen_width, curses.A_REVERSE)
except curses.error:
# If we can't fill the entire line at once, try character by character
for x in range(layout.screen_width):
try:
stdscr.addch(y, x, ' ', curses.A_REVERSE)
except curses.error:
pass
def draw_main_status_line(stdscr, state: AppState, layout: StatusBarLayout):
"""Draw the main status line (percentages, indicators, etc.)"""
visible_height = layout.start_y
# Add indicators to status bar
wrap_indicator = "" if state.wrap_lines else "[NW] "
change_indicator = ""
if state.change_blocks and state.current_change_idx != -1:
change_indicator = f"[Change {state.current_change_idx + 1}/{len(state.change_blocks)}] "
search_indicator = ""
if state.search_matches:
search_indicator = f"[Search {state.current_match_idx + 1}/{len(state.search_matches)}] "
status_indicators = wrap_indicator + change_indicator + search_indicator
# Status bar percentages
if len(state.file_lines) > 0:
last_visible_line = state.right_scroll_offset + visible_height
right_percent = int((last_visible_line / len(state.file_lines)) * 100)
right_percent = min(100, right_percent)
else:
right_percent = 0
right_status = f"{right_percent}%"
if len(state.commits) > 0 and state.show_sidebar:
last_visible_commit = state.left_scroll_offset + visible_height
left_percent = int((last_visible_commit / len(state.commits)) * 100)
left_percent = min(100, left_percent)
else:
left_percent = 0
left_status = f"{left_percent}%"
# Use terminal's default colors for the status bar
status_attr = curses.A_NORMAL
# Add left percentage indicator (only if sidebar is visible)
if state.show_sidebar:
try:
# Use normal video if left pane is active (since status bar is already reverse)
left_attr = status_attr if state.focus == "left" else curses.A_REVERSE
# Add a space before and after the percentage with the same highlighting
stdscr.addstr(layout.main_status_y, 0, f" {left_status} ", left_attr)
except curses.error:
pass
# Add status indicators in the middle
if status_indicators:
# Calculate available space
left_margin = len(left_status) + 3 if state.show_sidebar else 1 # +3 for the spaces around percentage
right_margin = len(right_status) + 3 # +3 for the spaces around percentage
available_width = layout.screen_width - left_margin - right_margin
# Truncate if needed
if len(status_indicators) > available_width:
status_indicators = status_indicators[:available_width-3] + "..."
try:
stdscr.addstr(layout.main_status_y, left_margin, status_indicators, curses.A_REVERSE)
except curses.error:
pass
# Add right percentage indicator with highlighting for active pane
try:
# Include spaces in the highlighted area
padded_right_status = f" {right_status} "
right_x = layout.screen_width - len(padded_right_status)
if right_x >= 0:
# Use normal video if right pane is active (since status bar is already reverse)
right_attr = status_attr if state.focus == "right" else curses.A_REVERSE
stdscr.addstr(layout.main_status_y, right_x, padded_right_status, right_attr)
except curses.error:
pass
def draw_commit_details(stdscr, state: AppState, layout: StatusBarLayout):
"""Draw commit hash, message, author in the available space"""
if not state.commit_hash:
return
# Get the commit message (potentially multi-line)
message_lines = state.commit_message.splitlines()
if not message_lines:
message_lines = [""]
# Draw on the second line of the status bar (layout.commit_detail_start_y)
second_line = layout.commit_detail_start_y
# Draw the commit hash with bold at the start
commit_info = f" {state.commit_hash} "
stdscr.addstr(second_line, 0, commit_info, curses.A_REVERSE | curses.A_BOLD)
# Calculate the author and branch info, ensuring it always fits
author_branch_info = f" {state.commit_author} [{state.commit_branch}] "
# Reserve space for author/branch info and ensure it fits
min_author_space = 20 # Minimum space to reserve for author info
max_author_width = max(min_author_space, len(author_branch_info))
# If the author info is too long, truncate it but keep the branch
if len(author_branch_info) > layout.screen_width // 3: # Don't let it take more than 1/3 of screen
max_author_name_width = (layout.screen_width // 3) - len(f" [{state.commit_branch}] ")
if max_author_name_width > 5: # Only truncate if we have reasonable space
truncated_author = state.commit_author[:max_author_name_width-3] + "..."
author_branch_info = f" {truncated_author} [{state.commit_branch}] "
# Calculate positions
right_x = layout.screen_width - len(author_branch_info)
# Calculate the message area boundaries
message_start_x = len(commit_info)
message_end_x = right_x - 1
message_width = message_end_x - message_start_x - 2 # -2 for the pipe characters
# Always draw the author/branch info (it should fit now)
if right_x >= len(commit_info):
try:
stdscr.addstr(second_line, right_x, author_branch_info, curses.A_REVERSE)
except curses.error:
pass
# Draw left pipe character for message area
try:
stdscr.addstr(second_line, message_start_x, "|", curses.A_REVERSE)
except curses.error:
pass
# Draw the commit message in the remaining space
first_line = message_lines[0] if message_lines else ""
if message_width > 0:
if len(first_line) > message_width:
first_line = first_line[:message_width-3] + "..."
try:
stdscr.addstr(second_line, message_start_x + 1, first_line, curses.A_REVERSE)
except curses.error:
pass # Silently fail if we can't draw the message
# Draw right pipe character for message area
try:
stdscr.addstr(second_line, message_end_x, "|", curses.A_REVERSE)
except curses.error:
pass
# Draw additional lines of the commit message if we have more space
for i, line in enumerate(message_lines[1:], 1):
line_y = layout.commit_detail_start_y + i
if line_y >= layout.start_y + layout.total_height:
break # No more space
# For continuation lines, align them with the first line of the message
# and add pipe characters on both sides
try:
stdscr.addstr(line_y, message_start_x, "|", curses.A_REVERSE)
except curses.error:
pass
if message_width > 0:
if len(line) > message_width:
line = line[:message_width-3] + "..."
try:
stdscr.addstr(line_y, message_start_x + 1, line, curses.A_REVERSE)
except curses.error:
pass
try:
stdscr.addstr(line_y, message_end_x, "|", curses.A_REVERSE)
except curses.error:
pass
def draw_search_mode(stdscr, state: AppState, layout: StatusBarLayout):
"""Draw search input interface"""
# Draw search prompt on the main status line
search_prompt = "Search: "
try:
stdscr.addstr(layout.main_status_y, 0, search_prompt)
# Draw the search query
stdscr.addstr(layout.main_status_y, len(search_prompt), state.search_query)
# Draw cursor at the end of the query
try:
stdscr.move(layout.main_status_y, len(search_prompt) + len(state.search_query))
except curses.error:
pass
# If we have search results, show count
if state.search_matches:
match_info = f" [{state.current_match_idx + 1}/{len(state.search_matches)}]"
stdscr.addstr(layout.main_status_y, layout.screen_width - len(match_info) - 1, match_info)
except curses.error:
pass
def draw_status_bars(stdscr, state):
layout = StatusBarLayout.create(state.height, state.width, state.status_bar_height)
draw_status_bar_background(stdscr, layout)
if state.search_mode:
draw_search_mode(stdscr, state, layout)
# Still show commit details in search mode if we have space
if layout.total_height > 1:
draw_commit_details(stdscr, state, layout)
else:
draw_main_status_line(stdscr, state, layout)
draw_commit_details(stdscr, state, layout)
def draw_ui(stdscr, state):
stdscr.erase()
draw_left_pane(stdscr, state)
draw_right_pane(stdscr, state)
draw_divider(stdscr, state)
draw_status_bars(stdscr, state)
draw_selection(stdscr, state)
draw_help_popup(stdscr, state)
stdscr.refresh()
# --- Input Handling Functions (Controller) ---
def copy_selection_to_clipboard(stdscr, state):
if not state.selection_start_coord or not state.selection_end_coord:
return
start_x, start_y = state.selection_start_coord
end_x, end_y = state.selection_end_coord
# Determine pane from where selection started
if state.show_sidebar:
pane = 'left' if start_x < state.divider_col else 'right'
else:
pane = 'right' # When sidebar is hidden, there's only the right pane
# Determine drag direction to handle multi-line selection correctly
if start_y > end_y or (start_y == end_y and start_x > end_x):
# Swap coordinates if selection is bottom-to-top or right-to-left
start_x, start_y, end_x, end_y = end_x, end_y, start_x, start_y
# Get the selected text from the data model
selected_text_parts = []
if pane == 'left' and state.show_sidebar:
# Selection in the commits pane
visible_commits = state.commits[state.left_scroll_offset:state.left_scroll_offset + state.height - 1]
for i in range(start_y, min(end_y + 1, len(visible_commits))):
line_idx = i + state.left_scroll_offset
if 0 <= line_idx < len(state.commits):
line = state.commits[line_idx]
# Calculate character positions
start_char = 0 if i > start_y else start_x
end_char = len(line) if i < end_y else end_x + 1
# Get the substring
if start_char < len(line):
selected_text_parts.append(line[start_char:min(end_char, len(line))])
else:
# Selection in the file content pane
# First, build a list of all visible lines including deleted lines if shown
visible_lines = []
deleted_line_map = {}
if state.show_deletions:
for del_line_num, del_content in state.deleted_lines:
if del_line_num not in deleted_line_map:
deleted_line_map[del_line_num] = []
deleted_line_map[del_line_num].append(del_content)
# Build the list of visible lines with their types
file_lines = state.file_lines[state.right_scroll_offset:state.right_scroll_offset + state.height - 1]
display_lines = []
for i, line in enumerate(file_lines):
line_num = i + state.right_scroll_offset + 1
# Add deleted lines if they should be shown
if (state.show_whole_diff or state.show_deletions) and line_num in deleted_line_map:
for del_content in deleted_line_map[line_num]:
display_lines.append({'type': 'deleted', 'content': del_content})
# Determine if this is an added line
is_added = False
if state.show_whole_diff or state.show_additions:
for added_line_num, _ in state.added_lines:
if added_line_num == line_num:
is_added = True
break
display_lines.append({'type': 'added' if is_added else 'regular', 'content': line})
# Now extract the selected text from these lines
right_start = 0 if not state.show_sidebar else state.divider_col + 2
for i in range(start_y, min(end_y + 1, len(display_lines))):
if i < 0 or i >= len(display_lines):
continue
line_info = display_lines[i]
content = line_info['content']
line_type = line_info['type']
# Add prefix based on line type if in diff mode
prefix = ""
if line_type == 'added' and state.show_additions:
prefix = "+ "
elif line_type == 'deleted' and state.show_deletions:
prefix = "- "
elif state.show_additions or state.show_deletions:
prefix = " "
# Calculate character positions, accounting for the prefix
content_with_prefix = prefix + content
# Adjust start_x and end_x to account for the right pane offset
adjusted_start_x = max(0, start_x - right_start) if i == start_y else 0
adjusted_end_x = end_x - right_start if i == end_y else len(content_with_prefix)
# Ensure we don't go out of bounds
adjusted_start_x = min(adjusted_start_x, len(content_with_prefix))
adjusted_end_x = min(adjusted_end_x, len(content_with_prefix))
if adjusted_start_x < adjusted_end_x:
selected_text_parts.append(content_with_prefix[adjusted_start_x:adjusted_end_x])
# Join the selected text parts and copy to clipboard
if selected_text_parts:
text_to_copy = "\n".join(selected_text_parts)
if text_to_copy.strip():
try:
subprocess.run(['pbcopy'], input=text_to_copy, text=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
try:
# Try xclip for Linux systems
subprocess.run(['xclip', '-selection', 'clipboard'], input=text_to_copy, text=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
try:
# Try clip.exe for Windows
subprocess.run(['clip.exe'], input=text_to_copy, text=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
pass # Silently fail if no clipboard command is available
def update_status_bar_height(state: AppState, my: int) -> AppState:
"""Update the status bar height based on mouse position."""
# Calculate the minimum and maximum allowed heights
min_height = 2 # Minimum 2 lines for the status bar
max_height = min(10, state.height // 2) # Maximum 10 lines or half the screen
# Calculate the new height based on mouse position
# When dragging up, we want to increase the status bar height
new_height = state.height - my
# Clamp to allowed range
new_height = max(min_height, min(new_height, max_height))
return replace(state, status_bar_height=new_height)
def handle_mouse_input(stdscr, state: AppState) -> AppState:
try:
_, mx, my, _, bstate = curses.getmouse()
state = replace(state, last_bstate=bstate, mouse_x=mx, mouse_y=my)
# Status bar position
status_bar_start = state.height - state.status_bar_height
# Handle mouse button press
if bstate & curses.BUTTON1_PRESSED:
if state.show_sidebar and abs(mx - state.divider_col) <= 1:
return replace(state, dragging_divider=True)
elif my == status_bar_start:
# Clicked anywhere on the top line of the status bar
return replace(state, dragging_status_bar=True)
else:
focus = "right"
if state.show_sidebar:
focus = "left" if mx < state.divider_col else "right"
return replace(state,
focus=focus,
is_selecting=True,
selection_start_coord=(mx, my),
selection_end_coord=(mx, my),
click_position=(mx, my))
# Handle mouse button release
if bstate & curses.BUTTON1_RELEASED:
if state.dragging_divider:
return replace(state, dragging_divider=False)
elif state.dragging_status_bar:
return replace(state, dragging_status_bar=False)
if state.is_selecting:
# Check if this was a click (press and release at same position)
# or very close to the same position (within 1-2 pixels)
if (state.click_position and
abs(state.click_position[0] - mx) <= 2 and
abs(state.click_position[1] - my) <= 2): # TODO don't do this <= 2 thing
# This was a click, so switch panes (only if sidebar is visible)
focus = "right"
if state.show_sidebar:
focus = "left" if mx < state.divider_col else "right"
new_state = replace(state, focus=focus)
# If clicking in the left pane on a commit entry, select that commit
if state.show_sidebar and mx < state.divider_col and my < min(status_bar_start, len(state.commits) - state.left_scroll_offset):
new_commit_idx = my + state.left_scroll_offset
if 0 <= new_commit_idx < len(state.commits):
new_state = replace(new_state, selected_commit_idx=new_commit_idx)
new_state = load_commit_content(new_state)
# Reset selection state
return replace(new_state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None)
elif state.selection_start_coord and state.selection_end_coord:
# This was a drag selection, copy the text
copy_selection_to_clipboard(stdscr, state)
# Reset selection state
return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None, click_position=None)
# Handle mouse movement during drag operations
if state.dragging_divider:
return update_divider(state, mx)
elif state.dragging_status_bar:
return update_status_bar_height(state, my)
elif state.is_selecting:
return replace(state, selection_end_coord=(mx, my))
except curses.error:
pass
return state
def handle_keyboard_input(key, state: AppState) -> AppState:
# If help popup is open, any key closes it
if state.show_help:
return replace(state, show_help=False)
# If in search mode, handle search input
if state.search_mode:
if key == 27: # Escape key - exit search mode
return replace(state, search_mode=False)
elif key == 10 or key == 13: # Enter key - perform search
new_state = perform_search(state)
return replace(new_state, search_mode=False)
elif key == 8 or key == 127 or key == curses.KEY_BACKSPACE: # Backspace
if state.search_query:
return replace(state, search_query=state.search_query[:-1])
elif key == curses.KEY_DC: # Delete key
if state.search_query:
return replace(state, search_query=state.search_query[:-1])
elif 32 <= key <= 126: # Printable ASCII characters
return replace(state, search_query=state.search_query + chr(key))
return state
# Normal mode key handling
if key in [ord('q')]:
return replace(state, should_exit=True)
elif key == 27: # Escape key
if state.show_help:
return replace(state, show_help=False)
elif state.is_selecting:
return replace(state, is_selecting=False, selection_start_coord=None, selection_end_coord=None)
else:
return replace(state, should_exit=True)
elif key == ord('?'): # Toggle help popup
return replace(state, show_help=not state.show_help)
elif key == ord('/'): # Start search
return replace(state, search_mode=True, search_query="")
elif key == ord('s'):
return toggle_sidebar(state)
elif key == ord('d'):
# 'd' toggles both additions and deletions together
new_state = replace(state,
show_additions=not (state.show_additions and state.show_deletions),
show_deletions=not (state.show_additions and state.show_deletions))
return load_commit_content(new_state)
elif key == ord('a'):
# 'a' toggles just additions
new_state = replace(state, show_additions=not state.show_additions)
return load_commit_content(new_state)
elif key == ord('x'):
# 'x' toggles just deletions
new_state = replace(state, show_deletions=not state.show_deletions)
return load_commit_content(new_state)
elif key == ord('w'):
return replace(state, wrap_lines=not state.wrap_lines)
elif key == ord('L'):
return replace(state, show_line_numbers=not state.show_line_numbers)
elif key in [110, ord('n')]: # ASCII code for 'n'
if state.search_matches:
return jump_to_next_match(state)
elif state.show_whole_diff or state.show_additions or state.show_deletions:
return jump_to_next_change(state)
elif key in [78, ord('N')]: # ASCII code for 'N' (uppercase)
if state.search_matches:
return jump_to_prev_match(state)
elif key in [99, ord('c')]: # ASCII code for 'c'
if state.show_additions or state.show_deletions:
return jump_to_next_change(state)
elif key in [67, ord('C')]: # ASCII code for 'C' (uppercase)
if state.show_additions or state.show_deletions:
return jump_to_prev_change(state)
elif key in [112, ord('p')]: # ASCII code for 'p'
if state.show_additions or state.show_deletions:
return jump_to_prev_change(state)
elif key in [curses.KEY_LEFT, ord('h')]:
if state.show_sidebar:
return replace(state, focus="left")
elif key in [curses.KEY_RIGHT, ord('l')]:
return replace(state, focus="right")
elif state.focus == "left":
if key in [curses.KEY_DOWN, ord('j')]:
return move_commit_selection(state, 1)
elif key in [curses.KEY_UP, ord('k')]:
return move_commit_selection(state, -1)
elif key in [curses.KEY_NPAGE, ord(' ')]:
return page_commit_selection(state, 1)
elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]:
return page_commit_selection(state, -1)
elif state.focus == "right":
if key in [curses.KEY_DOWN, ord('j')]:
return scroll_right_pane(state, 1)
elif key in [curses.KEY_UP, ord('k')]:
return scroll_right_pane(state, -1)
elif key in [curses.KEY_NPAGE, ord(' ')]:
return page_right_pane(state, 1)
elif key in [curses.KEY_PPAGE, 8, 127, curses.KEY_SR]:
return page_right_pane(state, -1)
return state
# --- Main Application ---
def main(stdscr, filename, show_diff, show_add, show_del, mouse, wrap_lines=True, show_line_numbers=False):
try:
curses.curs_set(0)
except:
pass
stdscr.keypad(True)
stdscr.timeout(50) # Less aggressive timeout (50ms instead of 10ms)
if curses.has_colors():
curses.use_default_colors()
# Use a safe background color (7 or less) to avoid "Color number is greater than COLORS-1" error
curses.init_pair(3, curses.COLOR_GREEN, -1) # Added lines
curses.init_pair(4, curses.COLOR_RED, -1) # Deleted lines
curses.init_pair(5, curses.COLOR_GREEN, -1) # Line numbers for added lines
curses.init_pair(6, curses.COLOR_RED, -1) # Line numbers for deleted lines
curses.init_pair(7, 7, -1) # Regular line numbers
height, width = stdscr.getmaxyx()
# If show_diff is true, enable both additions and deletions
if show_diff:
show_add = True
show_del = True
state = AppState(
filename=filename, width=width, height=height,
show_additions=show_add,
show_deletions=show_del, enable_mouse=mouse,
commits=get_commits(filename),
wrap_lines=wrap_lines,
show_line_numbers=show_line_numbers
)
if state.enable_mouse:
curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION)
# Enable mouse motion events for better drag tracking
# This escape code is not supported by all terminals (e.g., nsterm)
if os.environ.get("TERM") != "nsterm":
try:
print("\033[?1003h", end="", flush=True)
except Exception:
pass
# Load initial commit content so commit details are available immediately
if state.commits:
state = load_commit_content(state)
# Initial draw before the main loop starts
draw_ui(stdscr, state)
while not state.should_exit:
try:
key = stdscr.getch()
# Process input and update the application state
if key == -1: # No input available (timeout)
pass # Just redraw the UI and continue
elif key == curses.KEY_RESIZE:
h, w = stdscr.getmaxyx()
state = update_dimensions(state, h, w)
elif state.enable_mouse and key == curses.KEY_MOUSE:
state = handle_mouse_input(stdscr, state)
else:
state = handle_keyboard_input(key, state)
# After every action, redraw the UI to reflect changes immediately.
# This is crucial for real-time feedback during mouse drags.
draw_ui(stdscr, state)
except Exception as e:
# Prevent crashes by catching exceptions
# Uncomment for debugging:
# with open("/tmp/gtm_error.log", "a") as f:
# f.write(f"{str(e)}\n")
pass
# Disable mouse motion events when exiting
if state.enable_mouse:
# This escape code is not supported by all terminals (e.g., nsterm)
if os.environ.get("TERM") != "nsterm":
try:
print("\033[?1003l", end="", flush=True)
except Exception:
pass
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="A \"Git Time Machine\" for viewing file history")
parser.add_argument("-d", "--diff", action="store_true", help="Highlight newly added and deleted lines")
parser.add_argument("--diff-additions", action="store_true", help="Highlight newly added lines in green")
parser.add_argument("--diff-deletions", action="store_true", help="Show deleted lines in red")
parser.add_argument("--no-mouse", action="store_true", help="Disable mouse support")
parser.add_argument("--no-wrap", action="store_true", help="Disable line wrapping")
parser.add_argument("--line-numbers", action="store_true", help="Show line numbers")
parser.add_argument("-v", "--version", action="store_true", help="Show version number")
parser.add_argument("filename", nargs="?", help="File to view history for")
args = parser.parse_args()
if args.version:
print(f"gtm version {VERSION}")
sys.exit(0)
elif not args.filename:
parser.print_help()
sys.exit(1)
filename = args.filename
if not os.path.isfile(filename):
print(f"Error: File '{filename}' does not exist")
sys.exit(1)
if not is_file_tracked_by_git(filename):
print(f"Error: File '{filename}' is not tracked by git")
print("Only files that are tracked in the git repository can be viewed.")
print("Try adding and committing the file first:")
print(f" git add {filename}")
print(f" git commit -m 'Add {os.path.basename(filename)}'")
sys.exit(1)
curses.wrapper(main, filename, args.diff, args.diff_additions, args.diff_deletions, not args.no_mouse, not args.no_wrap, args.line_numbers)