Files
tai/src/ai/tui.py
Joakim 8256254e38 N/A
2026-10-13 23:28:50 +02:00

582 lines
21 KiB
Python

import curses
import queue
import textwrap
import threading
from pathlib import Path
from .config import load
from .history import load as load_chat, save, chat_path
from .openrouter import OpenRouter
class App:
def __init__(self, screen, chat_file):
self.s = screen
self.s.keypad(True)
self.s.timeout(50)
self.cfg, self.instructions, self.memories = load(Path.cwd())
self.api = OpenRouter(self.cfg["api_key"], self.cfg["model"])
self.path = None
self.title = "Ny chat"
self.messages = []
self.input = ""
self.cursor = 0
self.last_was_enter = False
self.input_mark = None
self.kill_buffer = ""
self.events = queue.Queue()
self.streaming = False
self.stream_answer = ""
self.stream_error = None
self.scroll = 0
self.follow_stream = True
# Focus is normally the input. History focus is entered with
# Ctrl-B followed by Up/Down, tmux-style.
self.focus = "input"
self.prefix_ctrl_b = False
self.prefix_ctrl_k = False
self.history_cursor = 0
self.history_col = 0
self.history_mark = None
if chat_file:
path = Path(chat_file).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
if path.exists():
self.path = path
self.title, self.messages = load_chat(path)
def setup_colors(self):
if not curses.has_colors():
return
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_GREEN, -1)
curses.init_pair(3, curses.COLOR_YELLOW, -1)
curses.init_pair(4, curses.COLOR_WHITE, -1)
curses.init_pair(5, curses.COLOR_BLACK, curses.COLOR_WHITE)
def wrap_text(self, text, width):
result = []
for paragraph in text.split("\n"):
if paragraph == "":
result.append("")
else:
result.extend(textwrap.wrap(
paragraph,
width=max(1, width),
break_long_words=True,
break_on_hyphens=False,
) or [""])
return result
def chat_lines(self, messages, width):
lines = []
for message in messages:
prefix = "You: " if message["role"] == "user" else "AI: "
wrapped = self.wrap_text(message["content"], max(1, width - len(prefix)))
if not wrapped:
wrapped = [""]
lines.append((message["role"], prefix + wrapped[0]))
for line in wrapped[1:]:
lines.append((message["role"], " " + line))
return lines
def input_lines(self, width):
prefix = "> "
continuation = " "
first_width = max(1, width - len(prefix))
rest_width = max(1, width - len(continuation))
rendered = []
cursor_location = (0, len(prefix))
absolute = 0
for logical in self.input.split("\n"):
local_cursor = self.cursor - absolute
if local_cursor < 0:
local_cursor = -1
if local_cursor > len(logical):
local_cursor = len(logical)
first = True
remaining = logical
if remaining == "":
rendered.append((prefix if first else continuation, ""))
if local_cursor == 0:
cursor_location = (len(rendered) - 1, len(prefix))
else:
while remaining:
line_width = first_width if first else rest_width
piece = remaining[:line_width]
remaining = remaining[line_width:]
pfx = prefix if first else continuation
rendered.append((pfx, piece))
if 0 <= local_cursor <= len(piece):
cursor_location = (len(rendered) - 1, len(pfx) + local_cursor)
local_cursor = -1
elif local_cursor > len(piece):
local_cursor -= len(piece)
first = False
absolute += len(logical) + 1
if self.cursor == len(self.input) and rendered:
pfx, text = rendered[-1]
cursor_location = (len(rendered) - 1, len(pfx) + len(text))
return rendered, cursor_location
def input_visual_height(self, width):
return max(1, len(self.input_lines(width)[0]))
def display_messages(self):
if self.streaming and self.stream_answer:
return self.messages + [{"role": "assistant", "content": self.stream_answer}]
return self.messages
def draw(self, status="", messages=None):
screen = self.s
height, width = screen.getmaxyx()
screen.erase()
self.setup_colors()
screen.addnstr(0, 0, f" AI · {self.title} ", max(0, width), curses.A_REVERSE)
visible_messages = self.display_messages() if messages is None else messages
chat = self.chat_lines(visible_messages, max(1, width))
input_height = min(max(1, height - 4), self.input_visual_height(max(1, width)))
separator = height - 2 - input_height
chat_available = max(0, separator - 1)
max_scroll = max(0, len(chat) - chat_available)
if self.follow_stream and self.focus == "input":
self.scroll = max_scroll
else:
self.scroll = max(0, min(self.scroll, max_scroll))
start = self.scroll
for y, (role, line) in enumerate(chat[start:start + chat_available], 1):
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.
if self.focus == "history" and chat:
self.history_cursor = max(0, min(self.history_cursor, len(chat) - 1))
row = self.history_cursor - start + 1
if 1 <= row < separator:
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)
screen.move(row, min(width - 1, col))
except curses.error:
pass
screen.hline(separator, 0, curses.ACS_HLINE, width)
rendered, (cursor_row, cursor_col) = self.input_lines(max(1, width))
visible_input = rendered[-input_height:]
first_row = separator + 1
for i, (pfx, text) in enumerate(visible_input):
attr = curses.color_pair(1) if self.focus == "input" and curses.has_colors() else 0
screen.addnstr(first_row + i, 0, pfx + text, max(0, width), attr)
if status:
screen.addnstr(height - 1, 0, status, max(0, width),
curses.color_pair(3) if curses.has_colors() else 0)
if self.focus == "input":
offset = max(0, len(rendered) - input_height)
r = first_row + max(0, cursor_row - offset)
c = min(max(0, width - 1), cursor_col)
try:
screen.move(min(height - 1, r), c)
except curses.error:
pass
screen.refresh()
def should_request_title(self):
users = [m["content"].strip() for m in self.messages if m["role"] == "user"]
return len(users) >= 2 or bool(users and len(users[0]) >= 100)
def start_stream(self):
self.streaming = True
self.stream_answer = ""
self.stream_error = None
messages = list(self.messages)
context = self.instructions + self.memories
def worker():
try:
for chunk in self.api.stream(messages, context):
self.events.put(("chunk", chunk))
self.events.put(("done", None))
except Exception as exc:
self.events.put(("error", exc))
threading.Thread(target=worker, daemon=True).start()
def process_events(self):
while True:
try:
event, value = self.events.get_nowait()
except queue.Empty:
break
if event == "chunk":
self.stream_answer += value
elif event == "done":
if self.stream_answer:
self.messages.append({"role": "assistant", "content": self.stream_answer})
self.streaming = False
if self.path is None and self.should_request_title():
try:
title = self.api.title(self.messages)
if title and title != "UNTITLED":
self.title = title
self.path = chat_path(Path.cwd(), title)
except Exception:
pass
if self.path:
save(self.path, self.title, self.messages, self.cfg["model"])
elif event == "error":
self.stream_error = str(value)
self.streaming = False
# ---------- input editor ----------
def line_col(self):
before = self.input[:self.cursor]
line = before.count("\n")
last = before.rfind("\n")
col = self.cursor if last < 0 else self.cursor - last - 1
return line, col
def cursor_from_line_col(self, line, col):
lines = self.input.split("\n")
line = max(0, min(line, len(lines) - 1))
return min(sum(len(x) + 1 for x in lines[:line]) + col, sum(len(x) + 1 for x in lines[:line]) + len(lines[line]))
def move_input(self, direction):
if direction == "left" and self.cursor > 0:
self.cursor -= 1
elif 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)
def insert(self, text):
self.input = self.input[:self.cursor] + text + self.input[self.cursor:]
self.cursor += len(text)
def backspace(self):
if self.cursor:
self.input = self.input[:self.cursor - 1] + self.input[self.cursor:]
self.cursor -= 1
def delete(self):
if self.cursor < len(self.input):
self.input = self.input[:self.cursor] + self.input[self.cursor + 1:]
def home(self):
line, _ = self.line_col()
self.cursor = self.cursor_from_line_col(line, 0)
def end(self):
line, _ = self.line_col()
lines = self.input.split("\n")
self.cursor = self.cursor_from_line_col(line, len(lines[line]))
def editor_mark_start(self):
self.input_mark = self.cursor
def editor_mark_end(self):
if self.input_mark is None:
return
a, b = sorted((self.input_mark, self.cursor))
self.kill_buffer = self.input[a:b]
self.input_mark = None
def delete_marked(self):
if self.input_mark is None:
return
a, b = sorted((self.input_mark, self.cursor))
self.kill_buffer = self.input[a:b]
self.input = self.input[:a] + self.input[b:]
self.cursor = a
self.input_mark = None
def paste(self):
if self.kill_buffer:
self.insert(self.kill_buffer)
# ---------- history focus / selection ----------
def history_focus(self, direction):
lines = self.chat_lines(self.display_messages(), max(1, self.s.getmaxyx()[1]))
if not lines:
return
self.focus = "history"
if direction == "up":
self.history_cursor = max(0, self.scroll)
else:
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.history_cursor = min(len(lines) - 1, self.scroll + available - 1)
self.history_col = min(self.history_col, len(lines[self.history_cursor][1]))
self.follow_stream = False
def history_move(self, key):
lines = self.chat_lines(self.display_messages(), max(1, self.s.getmaxyx()[1]))
if not lines:
return
if key == curses.KEY_UP:
self.history_cursor = max(0, self.history_cursor - 1)
elif key == curses.KEY_DOWN:
self.history_cursor = min(len(lines) - 1, self.history_cursor + 1)
elif key == curses.KEY_LEFT:
self.history_col = max(0, self.history_col - 1)
elif key == curses.KEY_RIGHT:
self.history_col = min(len(lines[self.history_cursor][1]), self.history_col + 1)
elif key == curses.KEY_PPAGE:
self.history_cursor = max(0, self.history_cursor - 5)
elif key == curses.KEY_NPAGE:
self.history_cursor = min(len(lines) - 1, self.history_cursor + 5)
self.scroll = max(0, self.history_cursor - 2)
def history_mark_start(self):
self.history_mark = (self.history_cursor, self.history_col)
def history_mark_end(self):
if self.history_mark is None:
return
lines = self.chat_lines(self.display_messages(), max(1, self.s.getmaxyx()[1]))
a = self.history_mark
b = (self.history_cursor, self.history_col)
if a > b:
a, b = b, a
selected = []
for i in range(a[0], min(b[0] + 1, len(lines))):
text = lines[i][1]
if i == a[0] and i == b[0]:
selected.append(text[a[1]:b[1]])
elif i == a[0]:
selected.append(text[a[1]:])
elif i == b[0]:
selected.append(text[:b[1]])
else:
selected.append(text)
self.kill_buffer = "\n".join(selected)
self.history_mark = None
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.
import base64
data = base64.b64encode(text.encode("utf-8")).decode("ascii")
try:
print(f"\033]52;c;{data}\a", end="", flush=True)
except Exception:
pass
def return_to_input(self):
self.focus = "input"
self.follow_stream = self.scroll == 0
def submit(self):
text = self.input.strip()
if not text:
return
self.input = ""
self.cursor = 0
self.last_was_enter = False
self.messages.append({"role": "user", "content": text})
self.focus = "input"
self.follow_stream = True
self.scroll = 0
self.start_stream()
def joe_key(self, key):
# A small, useful subset of joe's ^K commands:
# ^KB = begin block, ^KK = end/copy block, ^KC = copy,
# ^KY = delete block, ^KU = unmark, ^Y = paste.
if key in ("b", "B"):
if self.focus == "input":
self.editor_mark_start()
else:
self.history_mark_start()
return True
if key in ("k", "K"):
if self.focus == "input":
self.editor_mark_end()
else:
self.history_mark_end()
return True
if key in ("c", "C"):
if self.focus == "input" and self.input_mark is not None:
a, b = sorted((self.input_mark, self.cursor))
self.kill_buffer = self.input[a:b]
return True
if key in ("y", "Y"):
if self.focus == "input":
self.paste()
return True
if key in ("x", "X"):
if self.focus == "input":
self.delete_marked()
return True
if key in ("u", "U"):
self.input_mark = None
self.history_mark = None
return True
return False
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")
return
if key == "b":
self.prefix_ctrl_b = True
return
return
if key == "\x02": # Ctrl-B, tmux-style prefix
self.prefix_ctrl_b = True
return
if key in ("\x04", "\x1b"):
return "quit"
if self.focus == "history":
if key in (curses.KEY_UP, curses.KEY_DOWN, curses.KEY_LEFT, curses.KEY_RIGHT,
curses.KEY_PPAGE, curses.KEY_NPAGE):
self.history_move(key)
return
if key == "\x11": # Ctrl-Q as a convenient escape to input
self.return_to_input()
return
if key == "\x0b": # Ctrl-K
self.prefix_ctrl_k = True
return
if getattr(self, "prefix_ctrl_k", False):
self.prefix_ctrl_k = False
self.joe_key(key)
return
# Ctrl-K handling needs to happen before ordinary input.
if key == "\x0b":
self.prefix_ctrl_k = True
return
return
# Input focus.
multiline = self.input_visual_height(max(1, self.s.getmaxyx()[1])) > 1
if key == "\x0b": # Ctrl-K joe prefix
self.prefix_ctrl_k = True
return
if getattr(self, "prefix_ctrl_k", False):
self.prefix_ctrl_k = False
if self.joe_key(key):
return
# Before the input wraps to multiple visual lines, arrows scroll history.
if not multiline and key in (curses.KEY_UP, curses.KEY_DOWN,
curses.KEY_LEFT, curses.KEY_RIGHT):
if key in (curses.KEY_UP, curses.KEY_LEFT):
self.scroll = min(
max(0, len(self.chat_lines(self.display_messages(), self.s.getmaxyx()[1]))),
self.scroll + 1,
)
else:
self.scroll = max(0, self.scroll - 1)
self.follow_stream = self.scroll == 0
return
if key == curses.KEY_PPAGE:
self.follow_stream = False
self.scroll += 5
return
if key == curses.KEY_NPAGE:
self.scroll = max(0, self.scroll - 5)
self.follow_stream = self.scroll == 0
return
if key == curses.KEY_LEFT:
self.move_input("left")
return
if key == curses.KEY_RIGHT:
self.move_input("right")
return
if key == curses.KEY_UP:
self.move_input("up")
return
if key == curses.KEY_DOWN:
self.move_input("down")
return
if key == curses.KEY_HOME:
self.home()
return
if key == curses.KEY_END:
self.end()
return
if key in (curses.KEY_BACKSPACE, 127, 8):
self.backspace()
self.last_was_enter = False
return
if key == curses.KEY_DC:
self.delete()
self.last_was_enter = False
return
if key in ("\n", "\r", curses.KEY_ENTER):
if self.last_was_enter and self.input.endswith("\n"):
self.input = self.input[:-1]
self.cursor = min(self.cursor, len(self.input))
self.submit()
else:
self.insert("\n")
self.last_was_enter = True
return
if isinstance(key, str) and key.isprintable():
self.insert(key)
self.last_was_enter = False
def run(self):
curses.curs_set(1)
self.setup_colors()
self.draw()
while True:
self.process_events()
status = "AI skriver..." if self.streaming else (f"OpenRouter-fel: {self.stream_error}" if self.stream_error else "")
if self.focus == "history":
status = "HISTORIK · Ctrl-B↓/↑ för fokusbyte · Ctrl-K B/K markerar"
elif self.prefix_ctrl_b:
status = "Ctrl-B · ↑/↓ = historik"
self.draw(status)
try:
key = self.s.get_wch()
except curses.error:
continue
if key == -1:
continue
result = self.handle_key(key)
if result == "quit":
return
def run(chat_file=None):
curses.wrapper(lambda screen: App(screen, chat_file).run())