P7MJ's Ollama Chat
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-21 22:13:49 -04:00
chatgpt_local A-2=i 2026-09-21 22:13:49 -04:00
tests A-2=i 2026-09-21 22:13:49 -04:00
.gitignore A-1-i 2026-09-21 20:07:19 -04:00
main.py A-1-i 2026-09-21 20:07:19 -04:00
README.md A-2=i 2026-09-21 22:13:49 -04:00
task.txt A-1-i 2026-09-21 20:07:19 -04:00

✦ ChatGPT Local — a terminal ChatGPT client for Ollama

A polished, curses-based ChatGPT-style client that talks to your local Ollama server. Three-part layout, streaming Markdown responses, clickable links, collapsible thinking blocks, a live context/token sidebar, persistent timestamped chat sessions and a / command palette that mirrors every keyboard shortcut.

╭─ ✦ ChatGPT Local ────────────────────────────── explain pointers in C ──╮
│ ● Connected  │  12,482 / 32,768 38%  │  qwen3:8b  │                     │
╰─────────────────────────────────────────────────────────────────────────╯
╭─ ❯ You ─────────────────────────────────────────────────────────────────╮
│ explain pointers in C                                                   │
╰─────────────────────────────────────────────────────────────────────────╯
╭─ ✦ Assistant ───────────────────────────────────────────────────────────╮
│ ▸ Show thinking   ◌ 412 tok                                             │
│                                                                         │
│ A pointer stores the **address** of another object:                     │
│                                                                         │
│ ╭─ c ──────────────────────────────────────────────────────────────────╮│
│ │ int x = 42;                                                          ││
│ │ int *p = &x;                                                         ││
│ ╰──────────────────────────────────────────────────────────────────────╯│
│                                                                         │
│ ┌────────┬─────┬─────────┐   more: [cppreference ↗]                     │
│ │ Name   │ Age │ OS      │                                              │
│ ├────────┼─────┼─────────┤                                              │
│ │ Alice  │ 20  │ Linux   │                                              │
│ └────────┴─────┴─────────┘                                              │
╰─────────────────────────────────────────────────────────────────────────╯
╭─ Message ───────────────────────────────────────────────────────────────╮
│ ❯ Type your message…                                                    │
╰─────────────────────────────────────────────────────────────────────────╯
╭─ ↵ Send │ ⌃T Thinking │ ⌃O Model │ ⌃K Context │ ⌃Q Quit │ F1 Help │ / ──╮
╰─────────────────────────────────────────────────────────────────────────╯

Every control is also a / command, and every chat is a file on disk - see Commands and Sessions.

Requirements

Requirement Notes
Python 3.9+ (python3 --version)
Ollama running locally, default endpoint http://localhost:11434
Terminal any UTF-8 terminal (xterm, kitty, alacritty, iTerm2, Windows Terminal, tmux, …)
Third-party packages none — standard library only

On Windows, curses comes from the windows-curses package (pip install windows-curses); everything else is stdlib.

Setup

# 1. install and start Ollama (https://ollama.com/download)
ollama serve            # usually already running as a service

# 2. pull at least one chat model
ollama pull qwen3:8b    # the default

# 3. run the client
python3 main.py

The app starts in the chat interface immediately — no configuration files, no editing source. Everything else is optional CLI flags:

python3 main.py --help
python3 main.py --model qwen3:14b
python3 main.py --host http://192.168.1.20:11434
python3 main.py --glyphs ascii            # force the ASCII UI
python3 main.py --max-context 8192        # cap the context window we request
Flag Default Meaning
--host http://localhost:11434 Ollama base URL
-m, --model qwen3:8b model to start with (changeable at runtime)
--system-prompt helpful-assistant prompt system prompt (never shown as a message)
--temperature 0.7 sampling temperature
--num-ctx model metadata override the num_ctx sent to Ollama
--max-context 32768 upper bound for the KV cache we are willing to request
--reserve 1024 tokens kept free for the answer when deciding to compact
--glyphs auto unicode, ascii or auto (detects the locale/TERM)
--sessions-dir ~/.local/share/pchat/sessions where session files live
--resume [ID] off resume a saved session (newest when no id is given)
--list-sessions print the saved sessions and exit
--no-save do not read or write session files (ephemeral chat)

Commands

Type / at the start of the input to run a command; the status bar suggests matching ones as you type, and /help lists them next to the keyboard shortcuts. Commands are handled locally - they are never sent to the model, and anything that does not start with / is a normal message.

Command What it does
/help commands and the keyboard shortcuts in one overlay (/?, /h)
/sessions [id] browse saved sessions, or resume one directly (/resume, /ls)
/new start a fresh session; the current one is saved first
/compact run the compaction/summarization pass now (same path as Ctrl+B)
/think [on|off] toggle reasoning; the state is in the UI, the request and the session file
/model [name] model selector, or switch straight to a model (/model qwen3:14b)
/context the context/token overlay (same as Ctrl+K)
/clear clear the conversation, keeping the session and its file
/refresh re-read the model metadata and context window from Ollama
/quit save and leave (same as Ctrl+Q)

Unknown commands (and bad arguments, e.g. /think banana) produce a normal in-chat notice - they never crash the app.

Keyboard controls

Key Action
Enter send the message (or open the focused link)
Alt+Enter insert a new line (also Shift+Enter on terminals that report it)
Ctrl+T toggle reasoning on/off (the same switch as /think)
Ctrl+O / F2 model selector (list comes from Ollama)
Ctrl+K context details (c compacts, s shows the stored summary)
Ctrl+B / F5 compact the conversation now
Ctrl+N new session (the current one is saved first)
Ctrl+L clear the conversation (session kept)
Esc / Ctrl+C cancel generation (with an empty input Ctrl+C clears it)
Tab / Shift+Tab focus next / previous link
PageUp / PageDown scroll the conversation
↑ / ↓ scroll (or move inside a multiline message)
Home / End start/end of the input, or top/bottom of the conversation
Ctrl+W delete the previous word
F1 help
Ctrl+Q quit

Two notes about terminal quirks:

  • Enter vs. a new line. Terminals send the same bytes for Enter, Ctrl+J and (in most cases) Shift+Enter. Enter therefore sends the message; use Alt+Enter for a new line. Terminals that emit the kitty/modifyOtherKeys sequences (kitty, WezTerm, iTerm2 with CSI-u) get real Shift+Enter support automatically.
  • Ctrl+M and Ctrl+H cannot be used as app shortcuts: terminals deliver them as Enter and Backspace. Model selection is therefore on Ctrl+O (and F2), help on F1. The app also switches IXON/IXOFF off on its tty so that Ctrl+Q is not swallowed as XON flow control.

Mouse controls

Mouse support is enabled automatically where the terminal reports it:

  • Click a link → opens it in the default browser (webbrowser.open, only http/https/mailto/ftp URLs are ever opened — never a shell command).
  • Click ▸ Show thinking / ▾ Hide thinking → expands or collapses the reasoning block.
  • Mouse wheel → scrolls the conversation.
  • In the model selector, clicking a row selects that model.
  • In the /sessions selector, clicking a row resumes that session.

Link hitboxes are recomputed on every render, so they stay correct while streaming, scrolling and resizing. If a terminal has no mouse support, links stay highlighted and Tab + Enter opens them.

Unicode / terminal compatibility

  • The UI is built from box-drawing characters, and the app degrades to ASCII when the locale is not UTF-8, when TERM is dumb/unset, or when you pass --glyphs ascii (also CHATGPT_LOCAL_GLYPHS=ascii). Nothing crashes on terminals with incomplete fonts — every drawing operation is width-checked and clipped.
  • Wide characters (CJK, emoji) are measured with east_asian_width so tables, code frames and the sidebar stay aligned; combining marks and variation selectors count as zero columns.
  • Layout is recomputed from curses.LINES/curses.COLS on every frame, and the tty size is also polled as a safety net, so SIGWINCH/resizes never crash and never need a restart. Markdown wrapping, table column widths and link hitboxes are all recalculated for the new size, so a table re-wraps to the new pane instead of running under the sidebar.
  • A conversation is never drawn outside its window: lines are clipped to the available columns before every addstr, so addwstr() returned ERR cannot happen.

Context, tokens and automatic compaction

The right-hand sidebar tracks the conversation. It is 24 columns wide on terminals ≥ 104 columns, 12 columns compact on ≥ 88, and hidden below that — where the header keeps showing ≈18.4k/32.8k 56%:

╭─ ✦ Context ──────────╮
│                      │
│ 12,482 / 32,768      │
│ ████████░░░░░░ 38%   │
│                      │
│ Input    9,821       │
│ Output   2,661       │
│ Messages 14          │
│ System   42          │
│                      │
│ Model                │
│ qwen3:8b             │
│                      │
│ ✓ Context healthy    │
│ ctx source: model    │
╰──────────────────────╯
  • The context window comes from Ollama's model metadata (/api/show), not from a hard-coded number, and it adapts when you switch models.

  • Known vs. estimated counts. prompt_eval_count / eval_count from Ollama's response are authoritative, so the sidebar shows plain numbers (12,482) once a request completed. Anything appended since then can only be estimated, and is marked with ≈ (≈ 18.4k / 32.8k). The sidebar also shows where the window size came from (model, params, assumed, capped).

  • Warnings escalate as the window fills: ✓ Context healthy (< 70 %), ⚠ Context getting full (70–85 %), ⚠ Context nearly full (85–95 %), ⚠ Context limit (≥ 95 %).

  • Compaction. Before sending, the app checks that the prompt plus the reserved output budget still fits. If it does not, it shows ⚠ Context limit reached, summarizes the older messages with the same model (preserving the system prompt and the newest turns) and continues with the summary stored as internal history:

    ╭─ ✦ Context compacted ─────────────────────────────╮
    │ 31,204 tokens → 14,882 tokens                     │
    │ Older conversation history has been summarized.   │
    ╰───────────────────────────────────────────────────╯
    

    The summary is sent to Ollama as a [Conversation summary] system message; it is never shown as a user/assistant bubble (press Ctrl+K, then s, to read it). If summarization is unavailable the oldest history is dropped instead — and the notice says so. Either way the request continues instead of failing with an unexplained context-length error.

Sessions

Every chat is a file under ~/.local/share/pchat/sessions/ (or $XDG_DATA_HOME/pchat/sessions, or $PCHAT_DATA_HOME/sessions), named after the moment it started:

~/.local/share/pchat/sessions/
├── 2026-09-21_20-14-37_a1b2c3d4.json
└── 2026-09-21_21-02-11_7f3c9e01.json
  • Autosave. Messages are written as they are added - a debounced background writer (750 ms, newest snapshot wins) keeps the UI at full speed, and the file is written synchronously before switching sessions, creating one, or quitting. Writes are atomic (os.replace), so an unexpected exit cannot leave a half-written session behind.
  • Restore. Each file carries everything needed: session id, creation and modification timestamps, model, thinking state, system prompt and config, the messages in order (including reasoning and notices) and the token/context metadata PChat knew about. The JSON is indented and readable.
  • Resume. /sessions lists what is on disk (name, last modified, model, message count, think off) in a Unicode overlay with keyboard navigation, scrolling and mouse selection; Enter loads it. From the shell, use --resume (newest) or --resume <id>, or --list-sessions to see the ids.
  • Delete. In the same overlay, d / Delete / Backspace removes the highlighted file immediately (no confirmation). Deleting the session you are in also saves nothing and starts a fresh one.
  • Never destructive. /new saves the current session before starting a new one, and the writer refuses to overwrite a file that belongs to a different session id (it renames itself instead). Malformed or hand-edited files are listed as unreadable and reported when selected instead of crashing; the rest of the directory keeps working.

Architecture

main.py                  launcher: `python3 main.py`
chatgpt_local/
    __init__.py
    main.py              CLI, ChatSession (worker thread), compaction logic
    ollama_client.py     /api/version, /api/tags, /api/show, /api/chat streaming
    conversation.py      message history, thinking storage, token accounting
    sessions.py          session store, JSON format, debounced background saver
    commands.py          slash command registry + parser (reusable, no UI deps)
    markdown.py          parser, wrapper, tables, code panels, links, glyph sets
    ui.py                curses layout, table-aware widths, sidebar, overlays
    input.py             multiline input buffer (cursor + word-wrapped layout)
    tokens.py            estimation/formatting helpers
tests/                   markdown, layout, commands, sessions, context, pty smoke

Session persistence and command handling are deliberately UI-agnostic: the store only knows about JSON files, and the command module only knows about syntax, so both can be reused by other P-series TUIs.

Concurrency: the UI thread owns curses and never blocks. Every request runs on a daemon worker thread that pushes events into a queue.Queue; the UI drains the queue each loop iteration (50 ms ticks) and only re-renders the messages that actually changed, so streaming stays smooth while scrolling, resizing, clicking links or cancelling still work.

Markdown support: bold, italic, inline code, fenced code blocks (with the language label, indentation preserved and continuation markers on wrapped lines), headings, nested bullet/ordered lists, blockquotes, horizontal rules, tables (box-drawing, automatic column widths, cell wrapping, right/centre alignment from :---: separators) and links (rendered as text with a ↗ marker, clickable, keyboard-focusable).

Tables are laid out against the actual conversation viewport: the terminal minus the right-hand context sidebar, minus the panel's own border and padding. A table that is too wide is never clipped - its columns are shrunk in proportion to what they need (each one keeps room for its longest word where the width allows) and the cells wrap onto extra lines, so the outer frame is always intact and nothing ever reaches the sidebar. A table with more columns than the pane can hold falls back to a stacked header value list.

Tests

No test framework required — every file is runnable directly:

python3 tests/test_markdown.py            # widths, tables, links, ASCII fallback
python3 tests/test_layout.py              # frames aligned, tables fit next to the sidebar
python3 tests/test_commands.py            # slash command parsing, aliases, errors
python3 tests/test_sessions.py            # session JSON, autosave, /new, /sessions
python3 tests/test_context.py             # token accounting + compaction units
python3 tests/test_terminal_emulator.py   # validates the pty test's emulator
python3 tests/smoke_test.py              # end-to-end: runs the app in a pty
python3 tests/run_all.py                  # all of the above

The smoke test boots the real curses app in a pseudo-terminal against a fake Ollama server (tests/fake_ollama.py) and drives it through streaming, thinking, Markdown rendering, mouse clicks, the model selector, context details, resizes, clearing, compaction and shutdown at three terminal sizes. Two further scenarios cover this release end to end: one runs the full command set in a temporary sessions directory (/think, /model, /help, /sessions, /new, unknown commands, session switching, /quit) and asserts against the JSON on disk and the payload Ollama actually received; the other relaunches the app with --resume, checks the restored conversation, compacts by hand with /compact and verifies that a hand-corrupted session file is listed as unreadable instead of breaking the app. The pty stream is decoded by a small VT emulator so assertions are made against what the user would actually see.

Troubleshooting

Symptom Fix
Header shows ○ Disconnected start Ollama (ollama serve) and check --host
⚠ Error: model "x" not found ollama pull x, or press Ctrl+O to pick an available model
Sessions are not saved you started with --no-save, or the directory is read-only; the context overlay shows the current session file and any write error
A session shows as unreadable the JSON was hand-edited or truncated; the file is left untouched and can be deleted by hand
Ctrl+Q does nothing your shell enabled XON/XOFF — the app disables it on its own tty; if a terminal multiplexer filters it, use Ctrl+C then quit, or stty -ixon
Boxes render as garbage the terminal is not UTF-8: run python3 main.py --glyphs ascii
Links are not clickable the terminal does not report mouse events; use Tab + Enter
No reasoning block appears reasoning is off (Ctrl+T / /think on); the header shows ◆ think off
Answers get cut off / context length errors lower --max-context, or let automatic compaction run; raise --reserve if the model needs room to finish