diff --git a/src/ai/__pycache__/tui.cpython-313.pyc b/src/ai/__pycache__/tui.cpython-313.pyc new file mode 100644 index 0000000..662b89a Binary files /dev/null and b/src/ai/__pycache__/tui.cpython-313.pyc differ diff --git a/src/ai/tui.py b/src/ai/tui.py index 15ba718..c0a8876 100644 --- a/src/ai/tui.py +++ b/src/ai/tui.py @@ -166,7 +166,7 @@ class App: attr = curses.color_pair(1 if role == "user" else 2) if curses.has_colors() else 0 screen.addnstr(y, 0, line, max(0, width), attr) - # Draw a cursor in history when history has focus. + # Draw history cursor and character-level block selection. if self.focus == "history" and chat: self.history_cursor = max(0, min(self.history_cursor, len(chat) - 1)) row = self.history_cursor - start + 1 @@ -174,7 +174,18 @@ class App: line = chat[self.history_cursor][1] col = max(0, min(self.history_col, len(line))) try: - screen.chgat(row, 0, min(width, max(1, len(line))), curses.A_REVERSE) + if self.history_mark is not None: + a = self.history_mark + b = (self.history_cursor, self.history_col) + if a > b: + a, b = b, a + for r in range(a[0], min(b[0] + 1, len(chat))): + if r < start or r >= start + chat_available: + continue + lo = a[1] if r == a[0] else 0 + hi = b[1] if r == b[0] else len(chat[r][1]) + if hi > lo: + screen.chgat(r - start + 1, lo, min(width - lo, hi - lo), curses.A_REVERSE) screen.move(row, min(width - 1, col)) except curses.error: pass @@ -267,11 +278,55 @@ class App: def move_input(self, direction): if direction == "left" and self.cursor > 0: self.cursor -= 1 - elif direction == "right" and self.cursor < len(self.input): + return + if direction == "right" and self.cursor < len(self.input): self.cursor += 1 - elif direction in ("up", "down"): - line, col = self.line_col() - self.cursor = self.cursor_from_line_col(line + (1 if direction == "down" else -1), col) + return + if direction not in ("up", "down"): + return + + # Move by *visual* terminal lines, not logical newline-separated + # lines. This makes Up/Down behave like a normal terminal editor: + # one press moves roughly one screen width while keeping the column. + width = max(1, self.s.getmaxyx()[1]) + rendered, (row, col) = self.input_lines(width) + target = row + (-1 if direction == "up" else 1) + if target < 0 or target >= len(rendered): + return + + # All input lines use a two-character prefix ("> " / " "). + # Convert the visual column back to a character offset in that line. + prefix_len = len(rendered[target][0]) + target_text_col = max(0, col - prefix_len) + + # Walk the input using the same wrapping rules as input_lines(). + # Build visual chunks and their character ranges. + chunks = [] + pos = 0 + logical_lines = self.input.split("\n") + first_width = max(1, width - 2) + rest_width = max(1, width - 2) + for li, logical in enumerate(logical_lines): + if logical == "": + chunks.append((li, pos, pos, 2)) + else: + remaining = logical + local = pos + first = True + while remaining: + n = first_width if first else rest_width + piece = remaining[:n] + chunks.append((li, local, local + len(piece), 2)) + local += len(piece) + remaining = remaining[len(piece):] + first = False + pos += len(logical) + 1 + + if target >= len(chunks): + self.cursor = len(self.input) + return + _, start, end, _ = chunks[target] + self.cursor = min(end, start + target_text_col) def insert(self, text): self.input = self.input[:self.cursor] + text + self.input[self.cursor:] @@ -338,19 +393,40 @@ class App: lines = self.chat_lines(self.display_messages(), max(1, self.s.getmaxyx()[1])) if not lines: return + row = self.history_cursor + col = self.history_col if key == curses.KEY_UP: - self.history_cursor = max(0, self.history_cursor - 1) + if row > 0: + row -= 1 + col = min(col, len(lines[row][1])) elif key == curses.KEY_DOWN: - self.history_cursor = min(len(lines) - 1, self.history_cursor + 1) + if row < len(lines) - 1: + row += 1 + col = min(col, len(lines[row][1])) elif key == curses.KEY_LEFT: - self.history_col = max(0, self.history_col - 1) + if col > 0: + col -= 1 + elif row > 0: + row -= 1 + col = len(lines[row][1]) elif key == curses.KEY_RIGHT: - self.history_col = min(len(lines[self.history_cursor][1]), self.history_col + 1) + if col < len(lines[row][1]): + col += 1 + elif row < len(lines) - 1: + row += 1 + col = 0 elif key == curses.KEY_PPAGE: - self.history_cursor = max(0, self.history_cursor - 5) + row = max(0, row - 5) + col = min(col, len(lines[row][1])) elif key == curses.KEY_NPAGE: - self.history_cursor = min(len(lines) - 1, self.history_cursor + 5) - self.scroll = max(0, self.history_cursor - 2) + row = min(len(lines) - 1, row + 5) + col = min(col, len(lines[row][1])) + self.history_cursor = row + self.history_col = col + height, width = self.s.getmaxyx() + input_height = min(max(1, height - 4), self.input_visual_height(max(1, width))) + available = max(1, height - 3 - input_height) + self.scroll = max(0, min(self.history_cursor, max(0, len(lines) - available))) def history_mark_start(self): self.history_mark = (self.history_cursor, self.history_col) @@ -379,12 +455,34 @@ class App: self.send_osc52(self.kill_buffer) def send_osc52(self, text): - # OSC 52 lets compatible terminals copy the selected history text - # to the system clipboard. Unsupported terminals simply ignore it. + # Keep a persistent clipboard for local/embedded terminals and also + # offer the terminal emulator clipboard through OSC 52 when supported. import base64 + import os + import shutil + import subprocess + + try: + clip = Path.home() / ".ai" / "clipboard" + clip.parent.mkdir(parents=True, exist_ok=True) + clip.write_text(text, encoding="utf-8") + except Exception: + pass + + for command in (("wl-copy",), ("xclip", "-selection", "clipboard"), + ("xsel", "--clipboard", "--input")): + if shutil.which(command[0]): + try: + subprocess.run(command, input=text, text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=False) + break + except Exception: + pass + data = base64.b64encode(text.encode("utf-8")).decode("ascii") try: - print(f"\033]52;c;{data}\a", end="", flush=True) + os.write(1, f"\033]52;c;{data}\a".encode("ascii")) except Exception: pass @@ -443,11 +541,13 @@ class App: def handle_key(self, key): if self.prefix_ctrl_b: self.prefix_ctrl_b = False - if key == curses.KEY_UP: - self.history_focus("up") - return - if key == curses.KEY_DOWN: - self.history_focus("down") + if key in (curses.KEY_UP, curses.KEY_DOWN): + if self.focus == "history": + # Ctrl-B + arrow returns to the input field. Ctrl-B + # + arrow from the input enters history. + self.return_to_input() + else: + self.history_focus("up" if key == curses.KEY_UP else "down") return if key == "b": self.prefix_ctrl_b = True