- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| chatgpt_local | ||
| tests | ||
| .gitignore | ||
| main.py | ||
| README.md | ||
| task.txt | ||
✦ 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+Jand (in most cases)Shift+Enter.Entertherefore sends the message; useAlt+Enterfor a new line. Terminals that emit the kitty/modifyOtherKeyssequences (kitty, WezTerm, iTerm2 with CSI-u) get realShift+Entersupport automatically. Ctrl+MandCtrl+Hcannot be used as app shortcuts: terminals deliver them asEnterandBackspace. Model selection is therefore onCtrl+O(andF2), help onF1. The app also switches IXON/IXOFF off on its tty so thatCtrl+Qis 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, onlyhttp/https/mailto/ftpURLs 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
/sessionsselector, 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
TERMisdumb/unset, or when you pass--glyphs ascii(alsoCHATGPT_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_widthso tables, code frames and the sidebar stay aligned; combining marks and variation selectors count as zero columns. - Layout is recomputed from
curses.LINES/curses.COLSon 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, soaddwstr() returned ERRcannot 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_countfrom 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 (pressCtrl+K, thens, 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.
/sessionslists what is on disk (name, last modified, model, message count,think off) in a Unicode overlay with keyboard navigation, scrolling and mouse selection;Enterloads it. From the shell, use--resume(newest) or--resume <id>, or--list-sessionsto see the ids. - Delete. In the same overlay,
d/Delete/Backspaceremoves the highlighted file immediately (no confirmation). Deleting the session you are in also saves nothing and starts a fresh one. - Never destructive.
/newsaves 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 asunreadableand 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 |