N/A
This commit is contained in:
BIN
src/ai/__pycache__/tui.cpython-313.pyc
Normal file
BIN
src/ai/__pycache__/tui.cpython-313.pyc
Normal file
Binary file not shown.
142
src/ai/tui.py
142
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
|
||||
|
||||
Reference in New Issue
Block a user