diff --git a/README.md b/README.md index d289da8..6b36ecc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ai 0.3.3 +# ai 0.3.4 -Kompakt terminalklient för OpenRouter, optimerad för PicoCalc/Lyra. +Terminal AI-klient för OpenRouter, anpassad för Lyra/PicoCalc. ## Installation @@ -8,12 +8,40 @@ Kompakt terminalklient för OpenRouter, optimerad för PicoCalc/Lyra. ./install ``` -På system med `/data` används automatiskt: +På Lyra används `/data/tmp` och `/data/pip-cache` automatiskt om `/data` finns. -```text -/data/tmp -/data/pip-cache -``` +## Tangentbeteende + +### Input + +- Enter = ny rad +- Enter två gånger = skicka +- När prompten fortfarande ryms på en visuell rad används piltangenterna för att scrolla historiken. +- När prompten blivit flera visuella rader används piltangenterna för att navigera i prompten. +- Home/End, Backspace och Delete fungerar i editorn. + +### Historik + +`Ctrl-B` följt av `↑` eller `↓` flyttar fokus till historiken. När historiken har fokus används alla piltangenter för att flytta markören i historiken. + +`Ctrl-Q` återgår till inputfältet. + +### Joe-liknande blockkommandon + +`Ctrl-K B` börjar markera från aktuell cursorposition. + +`Ctrl-K K` avslutar markeringen. I historiken kopieras markeringen även till terminalens clipboard via OSC 52 om terminalen stöder det. + +Övriga grundkommandon: + +- `Ctrl-K C` = kopiera markerad text i input +- `Ctrl-K X` = klipp ut markerad text i input +- `Ctrl-K Y` = klistra in intern kopia +- `Ctrl-K U` = ta bort markering + +## AI-streaming + +AI-svaret visas medan det genereras. PgUp/PgDn kan användas för att läsa tidigare text medan svaret fortfarande strömmar. ## Konfiguration @@ -25,57 +53,4 @@ model: openrouter/auto api_key: sk-or-v1-... ``` -eller: - -```bash -export OPENROUTER_API_KEY='sk-or-v1-...' -``` - -`.ai` ärvs upp genom katalogträdet. - -## Chattar - -```bash -ai -ai 2026-10-13-vinterdack.md -``` - -En ny chat sparas inte förrän den har passerat titeltröskeln: - -- minst två användarmeddelanden, eller -- ett användarmeddelande på minst 100 tecken. - -Titelmodellen får returnera `UNTITLED`. - -Chatten sparas i aktuell katalog, inte i `.ai/chats`. - -## Editor - -Inputfältet är flerradigt. - -- Enter = ny rad -- Enter två gånger = skicka -- ←/→ = flytta markören -- ↑/↓ = flytta mellan rader -- Home/End = början/slut av aktuell rad -- Backspace -- Delete - -Visuell word wrapping påverkar inte den faktiska texten. - -## AI-streaming - -AI-svaret visas medan det genereras. - -- PgUp = scrolla upp -- PgDn = scrolla ned -- När du är längst ned följer visningen automatiskt det nya svaret. -- Om du scrollar upp stannar visningen där även medan AI fortsätter generera. - -## Layout - -- `You:` och `AI:` på samma rad som texten -- fortsättningsrader har två teckens indrag -- hela You-meddelandet har You-färg -- hela AI-meddelandet har AI-färg -- faktiska radbrytningar bevaras +En lokal `.ai/config.yaml` i en underkatalog övertrumfar den globala konfigurationen. diff --git a/src/ai/tui.py b/src/ai/tui.py index 114d130..95e5b43 100644 --- a/src/ai/tui.py +++ b/src/ai/tui.py @@ -22,27 +22,33 @@ class App: self.title = "Ny chat" self.messages = [] - # Multiline editor. self.input = "" self.cursor = 0 - self.preferred_column = None self.last_was_enter = False + self.input_mark = None + self.kill_buffer = "" - # Streaming worker. self.events = queue.Queue() self.streaming = False self.stream_answer = "" self.stream_error = None - # Viewport. 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) @@ -50,249 +56,160 @@ class App: 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) # You - curses.init_pair(2, curses.COLOR_GREEN, -1) # AI - curses.init_pair(3, curses.COLOR_YELLOW, -1) # status + 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 [""] - ) - + 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: " - continuation = " " - - first_width = max(1, width - len(prefix)) - rest_width = max(1, width - len(continuation)) - - wrapped = self.wrap_text(message["content"], first_width) - + wrapped = self.wrap_text(message["content"], max(1, width - len(prefix))) if not wrapped: wrapped = [""] - - lines.append( - (message["role"], prefix + wrapped[0]) - ) - + lines.append((message["role"], prefix + wrapped[0])) for line in wrapped[1:]: - lines.append( - (message["role"], continuation + line) - ) - + lines.append((message["role"], " " + line)) return lines def input_lines(self, width): prefix = "> " continuation = " " - available_first = max(1, width - len(prefix)) - available_rest = max(1, width - len(continuation)) - - logical_lines = self.input.split("\n") - if not logical_lines: - logical_lines = [""] - + first_width = max(1, width - len(prefix)) + rest_width = max(1, width - len(continuation)) rendered = [] - cursor_locations = [] - - cursor_pos = self.cursor + cursor_location = (0, len(prefix)) absolute = 0 - for logical in logical_lines: - if absolute + len(logical) < cursor_pos: - absolute += len(logical) + 1 - continue - - local_cursor = cursor_pos - absolute - local_cursor = max(0, min(local_cursor, len(logical))) + 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, "")) - cursor_locations.append( - (len(rendered) - 1, len(prefix) + local_cursor) - ) + if local_cursor == 0: + cursor_location = (len(rendered) - 1, len(prefix)) else: while remaining: - width_for_line = available_first if first else available_rest - piece = remaining[:width_for_line] - remaining = remaining[width_for_line:] - - rendered.append( - ( - prefix if first else continuation, - piece, - ) - ) - - if first and local_cursor <= len(piece): - cursor_locations.append( - (len(rendered) - 1, len(prefix) + local_cursor) - ) + 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 not first and local_cursor >= 0: - if local_cursor <= len(piece): - cursor_locations.append( - (len(rendered) - 1, len(continuation) + local_cursor) - ) - local_cursor = -1 - else: - local_cursor -= len(piece) - + elif local_cursor > len(piece): + local_cursor -= len(piece) first = False absolute += len(logical) + 1 - if self.cursor == len(self.input) and not cursor_locations: - # Cursor at the end. - last = len(rendered) - 1 if rendered else 0 - text = rendered[last][1] if rendered else "" - prefix_len = len(rendered[last][0]) if rendered else len(prefix) - cursor_locations = [(last, prefix_len + len(text))] + 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 - return rendered, cursor_locations[0] if cursor_locations else (0, len(prefix)) + def input_visual_height(self, width): + return max(1, len(self.input_lines(width)[0])) - def total_input_height(self, width): - rendered, _ = self.input_lines(width) - return max(1, len(rendered)) + 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 - 1), - curses.A_REVERSE, - ) + screen.addnstr(0, 0, f" AI · {self.title} ", max(0, width), curses.A_REVERSE) - visible_messages = self.messages if messages is None else messages + 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.total_input_height(max(1, width)), - ) - - separator_row = height - 2 - input_height - chat_top = 1 - chat_available = max(0, separator_row - chat_top) - + 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: - start = max_scroll + if self.follow_stream and self.focus == "input": self.scroll = max_scroll else: - start = max(0, min(self.scroll, max_scroll)) + self.scroll = max(0, min(self.scroll, max_scroll)) - for y, (role, line) in enumerate( - chat[start:start + chat_available], - chat_top, - ): - if role == "user": - attr = curses.color_pair(1) if curses.has_colors() else 0 - else: - attr = curses.color_pair(2) if curses.has_colors() else 0 + 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) - 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_row, - 0, - curses.ACS_HLINE, - width, - ) + screen.hline(separator, 0, curses.ACS_HLINE, width) - input_rendered, cursor_location = self.input_lines(max(1, 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) - # Only show the last input lines if the editor is taller than the - # available terminal area. - input_rendered = input_rendered[-input_height:] - - first_input_row = separator_row + 1 - - for index, (prefix, text) in enumerate(input_rendered): - row = first_input_row + index - screen.addnstr( - row, - 0, - prefix + text, - max(0, width), - ) - - # Status is shown only when there is room below the input. - if status and height > 1: - screen.addnstr( - height - 1, - 0, - status, - max(0, width), - curses.color_pair(3) if curses.has_colors() else 0, - ) - - cursor_row, cursor_col = cursor_location - visible_first = max(0, len(self.input_lines(max(1, width))[0]) - input_height) - cursor_row -= visible_first - cursor_row = max(0, min(input_height - 1, cursor_row)) - - screen.move( - min(height - 1, first_input_row + cursor_row), - min(max(0, width - 1), cursor_col), - ) + 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" - ] - - if len(users) >= 2: - return True - - return bool(users and len(users[0]) >= 100) + 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 @@ -303,34 +220,20 @@ class App: 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): - changed = False - while True: try: event, value = self.events.get_nowait() except queue.Empty: break - - changed = True - if event == "chunk": self.stream_answer += value - elif event == "done": if self.stream_answer: - self.messages.append( - { - "role": "assistant", - "content": 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) @@ -339,61 +242,46 @@ class App: self.path = chat_path(Path.cwd(), title) except Exception: pass - if self.path: - save( - self.path, - self.title, - self.messages, - self.cfg["model"], - ) - + save(self.path, self.title, self.messages, self.cfg["model"]) elif event == "error": self.stream_error = str(value) self.streaming = False - return changed - - def display_messages(self): - if self.streaming and self.stream_answer: - return self.messages + [ - {"role": "assistant", "content": self.stream_answer} - ] - - return self.messages - - # ---------- Multiline editor ---------- - + # ---------- input editor ---------- def line_col(self): before = self.input[:self.cursor] line = before.count("\n") - last_newline = before.rfind("\n") - col = self.cursor if last_newline < 0 else self.cursor - last_newline - 1 + last = before.rfind("\n") + col = self.cursor if last < 0 else self.cursor - last - 1 return line, col - def cursor_from_line_col(self, target_line, target_col): + def cursor_from_line_col(self, line, col): lines = self.input.split("\n") - target_line = max(0, min(target_line, len(lines) - 1)) - pos = sum(len(x) + 1 for x in lines[:target_line]) - return min(pos + target_col, pos + len(lines[target_line])) + 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_left(self): - if self.cursor > 0: + 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 move_right(self): + def delete(self): if self.cursor < len(self.input): - self.cursor += 1 - - def move_vertical(self, direction): - line, col = self.line_col() - target = line + direction - lines = self.input.split("\n") - - if target < 0 or target >= len(lines): - return - - self.cursor = self.cursor_from_line_col(target, col) + self.input = self.input[:self.cursor] + self.input[self.cursor + 1:] def home(self): line, _ = self.line_col() @@ -404,88 +292,253 @@ class App: lines = self.input.split("\n") self.cursor = self.cursor_from_line_col(line, len(lines[line])) - def insert(self, text): - self.input = self.input[:self.cursor] + text + self.input[self.cursor:] - self.cursor += len(text) + def editor_mark_start(self): + self.input_mark = self.cursor - def backspace(self): - if self.cursor > 0: - self.input = self.input[:self.cursor - 1] + self.input[self.cursor:] - self.cursor -= 1 + 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(self): - if self.cursor < len(self.input): - self.input = self.input[:self.cursor] + self.input[self.cursor + 1:] + 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" - # Scroll the chat while AI is streaming or idle. + 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) - if self.scroll == 0: - self.follow_stream = True + self.follow_stream = self.scroll == 0 return - - # Arrow keys are normally editor navigation. When Ctrl is used, - # curses may expose them differently on various terminals. if key == curses.KEY_LEFT: - self.move_left() + self.move_input("left") return - if key == curses.KEY_RIGHT: - self.move_right() + self.move_input("right") return - if key == curses.KEY_UP: - self.move_vertical(-1) + self.move_input("up") return - if key == curses.KEY_DOWN: - self.move_vertical(1) + 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): - # Double Enter sends. The first Enter inserts a newline. if self.last_was_enter and self.input.endswith("\n"): self.input = self.input[:-1] self.cursor = min(self.cursor, len(self.input)) @@ -494,7 +547,6 @@ class App: self.insert("\n") self.last_was_enter = True return - if isinstance(key, str) and key.isprintable(): self.insert(key) self.last_was_enter = False @@ -506,28 +558,21 @@ class App: while True: self.process_events() - - if self.streaming: - status = "AI skriver..." - elif self.stream_error: - status = f"OpenRouter-fel: {self.stream_error}" - else: - status = "" - - self.draw(status, self.display_messages()) + 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: - # timeout() lets streaming update the display while waiting - # for keyboard input. curses raises ERR on timeout. continue - if key == -1: continue result = self.handle_key(key) - if result == "quit": return