Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,8 @@ install(FILES src/nchat.1 DESTINATION "${CMAKE_INSTALL_MANDIR}/man1")
# Utils
configure_file(src/compose ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_INSTALL_LIBEXECDIR}/nchat/compose COPYONLY)
install(PROGRAMS ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_INSTALL_LIBEXECDIR}/nchat/compose DESTINATION ${CMAKE_INSTALL_LIBEXECDIR}/nchat)
configure_file(src/describe ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_INSTALL_LIBEXECDIR}/nchat/describe COPYONLY)
install(PROGRAMS ${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_INSTALL_LIBEXECDIR}/nchat/describe DESTINATION ${CMAKE_INSTALL_LIBEXECDIR}/nchat)

# Uninstall
if(HAS_SHARED_LIBS)
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ This configuration file holds general user interface settings. Default content:
typing_status_share=1
undo_clear_input=1
unread_indicator=*
vim_mode=0

### attachment_indicator

Expand Down Expand Up @@ -850,6 +851,33 @@ is enabled.

Specifies the character to suffix chats with unread messages in the chat list.

### vim_mode

Specifies whether to enable vim-style modal editing in the message compose
entry. Default `0` (disabled). When enabled, the entry starts in insert mode;
press `Esc` for normal mode. The current mode is shown as a colored badge in
the status bar (configurable via the `vim_*_color` / `vim_*_attr` keys in
`color.conf`) and as a cursor shape (bar in insert, block in normal).

Supported commands in normal mode:

| Group | Commands |
| ----- | -------- |
| Motions | `h l 0 ^ $`, `w e b W E B`, `( )` (sentence), `{ }` (paragraph), `j k`, `gg G`, `f F t T` |
| Operators | `d c y` + any motion; `dd cc yy` (line); `D C` (to end of line) |
| Edit | `x X`, `s S` (substitute), `o O` (open line), `p P` (paste) |
| Modes | `i a A I` (insert), `v` (visual), `Esc` (normal) |
| Counts | e.g. `3w`, `d3w`, `2dd` |

Visual mode (`v`) highlights the selection and applies `d c y x` to it.
When `vim_mode=0` there is no behavioral change and zero overhead.

### send_on_double_enter

Set `send_on_double_enter=1` in `ui.conf` (default `0`) to make a single
`Enter` insert a newline and a second consecutive `Enter` send the message
(dropping the just-typed newline). The existing `send_msg` key still works.

~/.config/nchat/key.conf
------------------------
This configuration file holds user interface key bindings. Default content:
Expand Down Expand Up @@ -986,6 +1014,16 @@ This configuration file holds user interface color settings. Default content:
top_color_bg=
top_color_fg=

Vim mode badge colors (`vim_*_color_bg`, `vim_*_color_fg`, `vim_*_attr`):

| Prefix | Default badge |
| ------ | ------------- |
| `vim_normal_*` | bright-blue bg / black fg (bold) |
| `vim_insert_*` | bright-green bg / black fg (bold) |
| `vim_visual_*` | bright-yellow bg / black fg (bold) |

Conventional vim/airline mode colors. Configurable via `vim_normal_*` / `vim_insert_*` / `vim_visual_*` in `color.conf`.

Supported text attributes `_attr` (defaults to `normal` if not specified):

normal
Expand Down
183 changes: 183 additions & 0 deletions src/describe
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/env python3

# Copyright (c) 2025 Kristofer Berggren
# All rights reserved.
#
# nchat is distributed under the MIT license, see LICENSE for details.

import os
import sys
import base64
import json
import argparse
import urllib.request
import urllib.error

SUPPORTED_MIME_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
}

def get_api_key(api_key_env: str | None, service: str) -> str | None:
if api_key_env is None:
return None
api_key = os.getenv(api_key_env)
if not api_key:
print(f"Error: Please set the {api_key_env} environment variable for the '{service}' service.", file=sys.stderr)
sys.exit(1)
return api_key

def get_image_mime_type(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
mime = SUPPORTED_MIME_TYPES.get(ext)
if not mime:
print(f"Error: Unsupported image type '{ext}'. Supported: {', '.join(SUPPORTED_MIME_TYPES.keys())}", file=sys.stderr)
sys.exit(1)
return mime

def encode_image(path: str) -> str:
try:
with open(path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except FileNotFoundError:
print(f"Error: Image file '{path}' not found.", file=sys.stderr)
sys.exit(1)

def build_payload(model: str, image_path: str, prompt: str, temperature: float | None, max_tokens: int | None) -> dict:
mime_type = get_image_mime_type(image_path)
b64_data = encode_image(image_path)
data_url = f"data:{mime_type};base64,{b64_data}"

payload = {
"model": model,
"messages": [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": data_url},
},
{
"type": "text",
"text": prompt,
},
],
}
],
}

if temperature is not None:
payload["temperature"] = temperature

if max_tokens is not None:
payload["max_tokens"] = max_tokens

return payload

def send_request(payload: dict, api_url: str, api_key: str | None, verbose: bool, timeout: int) -> str:
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"

if verbose:
safe = json.loads(json.dumps(payload))
for msg in safe.get("messages", []):
for part in msg.get("content", []):
if part.get("type") == "image_url":
url = part["image_url"]["url"]
part["image_url"]["url"] = url[:50] + "...[truncated]"
print("=== Request ===", file=sys.stderr)
print(json.dumps(safe, ensure_ascii=False, indent=2), file=sys.stderr)

req = urllib.request.Request(
api_url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
)

try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8")
result = json.loads(body)
except urllib.error.HTTPError as e:
try:
err_json = json.loads(e.read().decode("utf-8"))
msg = err_json.get("error", {}).get("message") or str(err_json)
except Exception:
msg = f"{e.code} {e.reason}"
print(f"HTTP Error: {msg}", file=sys.stderr)
sys.exit(1)
except urllib.error.URLError as e:
print(f"URL Error: {e.reason}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError:
print("Error: Response was not valid JSON.", file=sys.stderr)
sys.exit(1)

try:
return result["choices"][0]["message"]["content"]
except (KeyError, IndexError):
if verbose:
print("=== Raw Response ===", file=sys.stderr)
print(json.dumps(result, ensure_ascii=False, indent=2), file=sys.stderr)
print("Error: Unexpected response structure.", file=sys.stderr)
sys.exit(1)

def main():
parser = argparse.ArgumentParser(
description="Describe an image using AI vision (OpenAI/Gemini or OpenAI-compatible server)."
)
parser.add_argument("-i", "--image", required=True,
help="Path to image file (jpg, jpeg, png, gif, webp).")
parser.add_argument("-s", "--service", default="openai",
help="Service: openai (default), gemini, ollama, or host[:port]/URL for OpenAI-compatible server.")
parser.add_argument("-m", "--model", default=None,
help="Model name. Defaults: openai=gpt-4o-mini, gemini=gemini-2.0-flash.")
parser.add_argument("-M", "--max-tokens", type=int,
help="Maximum number of output tokens.")
parser.add_argument("-p", "--prompt", default="Describe this image in detail. If there is any text visible in the image, transcribe it exactly.",
help="Instruction prompt for the vision model.")
parser.add_argument("-t", "--temperature", type=float,
help="Sampling temperature (e.g., 0.2).")
parser.add_argument("-v", "--verbose", action="store_true",
help="Print request payload to stderr.")
parser.add_argument("-T", "--timeout", type=int, default=30,
help="Network timeout in seconds (default: 30).")
args = parser.parse_args()

if args.timeout <= 0:
print("Error: -T / --timeout must be > 0 seconds.", file=sys.stderr)
sys.exit(1)

service = args.service.strip().lower()
if service == "openai":
api_url = "https://api.openai.com/v1/chat/completions"
api_key_env = "OPENAI_API_KEY"
service_default_model = "gpt-4o-mini"
elif service == "gemini":
api_url = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
api_key_env = "GEMINI_API_KEY"
service_default_model = "gemini-2.0-flash"
elif service == "ollama":
api_url = "http://localhost:11434/v1/chat/completions"
api_key_env = None
service_default_model = "llava"
else:
base = service if service.startswith(("http://", "https://")) else f"http://{service}"
api_url = base.rstrip("/") + "/v1/chat/completions"
api_key_env = None
service_default_model = "gpt-4o-mini"

api_key = get_api_key(api_key_env, service)
model = args.model if args.model else service_default_model

payload = build_payload(model, args.image, args.prompt, args.temperature, args.max_tokens)
description = send_request(payload, api_url, api_key, verbose=args.verbose, timeout=args.timeout)
print(description)

if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions src/ui.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ void Ui::Init()
cbreak();
UiConfig::GetBool("linefeed_on_enter") ? nl() : nonl();
keypad(stdscr, TRUE);
set_escdelay(25); // minimize lag on bare ESC (vim_mode mode switch)
curs_set(0);
timeout(0);
EmojiList::Init();
Expand Down
10 changes: 10 additions & 0 deletions src/uicolorconfig.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ void UiColorConfig::Init()
{ "status_attr", "reverse" },
{ "status_color_bg", "" },
{ "status_color_fg", "" },
// Conventional vim/airline mode colors (NORMAL blue, INSERT green, VISUAL yellow).
{ "vim_normal_attr", "bold" },
{ "vim_normal_color_bg", "bright_blue" },
{ "vim_normal_color_fg", "black" },
{ "vim_insert_attr", "bold" },
{ "vim_insert_color_bg", "bright_green" },
{ "vim_insert_color_fg", "black" },
{ "vim_visual_attr", "bold" },
{ "vim_visual_color_bg", "bright_yellow" },
{ "vim_visual_color_fg", "black" },
{ "list_attr", "" },
{ "list_attr_selected", "reverse" },
{ "list_color_bg", "" },
Expand Down
2 changes: 2 additions & 0 deletions src/uiconfig.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ void UiConfig::Init()
{ "online_status_share", "1" },
{ "online_status_dynamic", "1" },
{ "phone_number_indicator", "" },
{ "send_on_double_enter", "0" },
{ "pinned_indicator", "\xe2\x9a\xb2" },
{ "proxy_indicator", "\xF0\x9F\x94\x92" },
{ "read_indicator", "\xe2\x9c\x93" },
Expand All @@ -80,6 +81,7 @@ void UiConfig::Init()
{ "typing_status_share", "1" },
{ "undo_clear_input", "1" },
{ "unread_indicator", "*" },
{ "vim_mode", "0" },
};

const std::string configPath(FileUtil::GetApplicationDir() + std::string("/ui.conf"));
Expand Down
44 changes: 42 additions & 2 deletions src/uientryview.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ void UiEntryView::Draw()
int cy = 0;
lines = StrUtil::WordWrap(input, m_W, false, false, false, 2, inputPos, cy, cx);

// Vim visual selection: compute anchor screen coords + selection ordering
bool selActive = m_Model->GetVimModeLocked() && m_Model->GetVimVisualLocked();
int selStartY = 0, selStartX = 0, selEndY = 0, selEndX = 0;
if (selActive)
{
int ax = 0, ay = 0;
std::vector<int> dummyJunk; (void)dummyJunk;
int anchorPos = m_Model->GetVimVisualAnchorLocked();
int tcx = 0, tcy = 0;
StrUtil::WordWrap(input, m_W, false, false, false, 2, anchorPos, tcy, tcx);
ay = tcy; ax = tcx;
// order anchor vs cursor in reading order; selection is inclusive of both cells
if ((ay < cy) || ((ay == cy) && (ax <= cx)))
{
selStartY = ay; selStartX = ax; selEndY = cy; selEndX = cx;
}
else
{
selStartY = cy; selStartX = cx; selEndY = ay; selEndX = ax;
}
}

static int colorPair = UiColorConfig::GetColorPair("entry_color");
static int attribute = UiColorConfig::GetAttribute("entry_attr");

Expand All @@ -52,11 +74,29 @@ void UiEntryView::Draw()

for (int i = 0; i < m_H; ++i)
{
if ((i + yoffs) < (int)lines.size())
int lineIdx = i + yoffs;
if (lineIdx < (int)lines.size())
{
line = lines.at(i + yoffs).c_str();
line = lines.at(lineIdx).c_str();
line.erase(std::remove(line.begin(), line.end(), EMOJI_PAD), line.end());
mvwaddwstr(m_Win, i, 0, line.c_str());

// Overdraw the selected span on this row with reverse video
if (selActive && (lineIdx >= selStartY) && (lineIdx <= selEndY))
{
int len = (int)line.size();
int a = (lineIdx == selStartY) ? selStartX : 0;
int b = (lineIdx == selEndY) ? selEndX : (len - 1);
a = std::max(0, std::min(a, len - 1));
b = std::max(0, std::min(b, len - 1));
if (len > 0 && b >= a)
{
std::wstring seg = line.substr(a, b - a + 1);
wattron(m_Win, attribute | colorPair | A_REVERSE);
mvwaddnwstr(m_Win, i, a, seg.c_str(), seg.size());
wattroff(m_Win, A_REVERSE);
}
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/uikeyconfig.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ void UiKeyConfig::Init(bool p_MapKeys)
{ "terminal_focus_out", "KEY_FOCUS_OUT" },
{ "terminal_resize", "KEY_RESIZE" },
{ "auto_compose", "\\33\\151" }, // alt/opt-i
{ "describe_image", "\\33\\165" }, // alt/opt-u
{ "select_mention", "\\33\\62" }, // alt/opt-2
{ "tab", "\\33\\11" }, // alt/opt-tab
};
Expand Down
Loading
Loading