Skip to content
Merged
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ FLASH (**F**ast **L**ocal **A**gent **SH**ell) CLI is an AI-powered command-line
- AI can use a `shell` tool to execute commands and see their output.
- Manually execute shell commands using the `!` prefix.
- **`flash://` Links**: Open Flash from a browser or another app with a prompt ready to go (`flash://?prompt=What+is+Python`).
- **Image Recognition**: Send a local image to a vision-capable model with `/image <path> [prompt]`.
- **Context Management**: Automatic history trimming to stay within token limits.
- **Markdown Support**: Rich formatting for AI responses in the terminal.

Expand Down Expand Up @@ -128,10 +129,28 @@ python run.py
- `/help` or `/?`: Display the help message.
- `/model`: Show the currently active model and Ollama host.
- `/clear`: Clear the conversation history.
- `/image <path> [prompt]`: Send a local image to the model.
- `/version`: Show the current version and check GitHub for updates.
- `/update`: Update Flash to the latest version (pipx installs only).
- `/bye`: Exit the application.

### Image Recognition

`/image <path> [prompt]` attaches a local image (`.png`, `.jpg`, `.jpeg`,
`.webp`, `.gif`, `.bmp`) to your next message and sends both to the model.
If you leave off the prompt, Flash asks it to describe the image. This
requires a vision-capable model — text-only models will ignore the image
or error. Pull one and switch to it first, e.g.:

```bash
ollama pull llama3.2-vision
```

```
/model llama3.2-vision
/image ~/Pictures/screenshot.png What's going on in this UI?
```

### Updates

Flash checks `main` on GitHub for a newer version on startup and shows it
Expand Down
141 changes: 117 additions & 24 deletions flash/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import os
import re
import shlex
import shutil
import sys
import threading
Expand All @@ -14,6 +15,7 @@
from dotenv import load_dotenv
from ollama import ResponseError
from rich.console import Console, Group
from rich.live import Live
from rich.markdown import Markdown
from rich.panel import Panel
from rich.text import Text
Expand All @@ -23,11 +25,12 @@
from .memory import forget_memory, list_memory
from .notify import notify_reply_ready
from .paths import ENV_PATH
from .repl_input import COMMANDS, read_line
from .repl_input import COMMANDS, IMAGE_EXTENSIONS, read_line
from .theme import (
ACCENT,
ACCENT_ANSI,
CHEVRON,
CURSOR,
DIM,
DIM_ANSI,
ELLIPSIS,
Expand Down Expand Up @@ -59,6 +62,7 @@

OLLAMA_HOST_DEFAULT = "http://localhost:11434"
ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
DEFAULT_IMAGE_PROMPT = "Describe this image in detail."

load_dotenv(dotenv_path=ENV_PATH)

Expand Down Expand Up @@ -189,8 +193,15 @@ def banner(
print()


def _message(role: str, text: str) -> dict:
return {"role": role, "content": text}
def _message(
role: str,
text: str,
images: Union[list[str], None] = None, # noqa: UP007, RUF100
) -> dict:
message: dict = {"role": role, "content": text}
if images:
message["images"] = images
return message


def _message_text(message: dict) -> str:
Expand Down Expand Up @@ -292,22 +303,30 @@ def _chat(client: "ollama.Client", messages: list, tools_arg=None):
)


def _load_thinking_states() -> list[str]:
def _load_states(key: str, fallback: list[str]) -> list[str]:
try:
p = Path(__file__).parent / "thinking_states.json"
data = json.loads(p.read_text(encoding="utf-8"))
states = list(data.get("states", []))
states = list(data.get(key, []))
if not states:
raise ValueError("no states")
return states
except ValueError:
return [
"Thinking",
"Pondering",
"Analyzing",
"Considering",
"Reflecting",
]
return fallback


def _load_thinking_states() -> list[str]:
return _load_states(
"states",
["Thinking", "Pondering", "Analyzing", "Considering", "Reflecting"],
)


def _load_image_thinking_states() -> list[str]:
return _load_states(
"image_states",
["Examining the image", "Analyzing the image", "Looking closely"],
)


_thinking_state_index = 0
Expand Down Expand Up @@ -353,9 +372,16 @@ def _chat_with_retries(


def _try_chat(
client: "ollama.Client", messages: list, status, tools_arg=None
client: "ollama.Client",
messages: list,
status,
tools_arg=None,
*,
is_image: bool = False,
) -> tuple[Union[object, None], Union[str, None]]: # noqa: UP007, RUF100
state = _next_thinking_state(_load_thinking_states())
states = _load_image_thinking_states() if is_image \
else _load_thinking_states()
state = _next_thinking_state(states)
word = f"{state}{ELLIPSIS}"
period = len(word) + 2 * GLIMMER_SPREAD
stop_event = threading.Event()
Expand Down Expand Up @@ -390,27 +416,58 @@ def _chat_with_status(
client: "ollama.Client",
messages: list,
tools_arg=None,
*,
is_image: bool = False,
) -> tuple[Union[object, None], Union[str, None]]: # noqa: UP007, RUF100
with console.status(
f"[bold {ACCENT}]Thinking{ELLIPSIS}", spinner="dots",
spinner_style=ACCENT
) as status:
return _try_chat(client, messages, status, tools_arg)
return _try_chat(
client, messages, status, tools_arg, is_image=is_image
)


def _print_backend_error(detail: str) -> None:
show_error(f"Ollama backend error: {detail}")


STREAM_CPS = 200.0 # simulated characters-per-second reveal rate
STREAM_MIN_DURATION = 0.25
STREAM_MAX_DURATION = 2.0
STREAM_FRAME_SECONDS = 0.04


def _render_markdown(console: Console, text: str, *, end: str = "\n") -> None:
console.print(
Markdown(
text,
code_theme="monokai",
hyperlinks=True
),
end=end
"""Render `text` as Markdown, revealing it progressively with a
trailing cursor dot -- the full reply already arrived in one shot, so
this is a paced typewriter effect rather than real token streaming."""

def render(body: str) -> Markdown:
return Markdown(body, code_theme="monokai", hyperlinks=True)

if not text.strip() or not console.is_terminal:
console.print(render(text), end=end)
return

duration = max(
STREAM_MIN_DURATION, min(STREAM_MAX_DURATION, len(text) / STREAM_CPS)
)
steps = max(1, int(duration / STREAM_FRAME_SECONDS))
chunk = max(1, (len(text) + steps - 1) // steps)

with Live(
render(CURSOR), console=console,
refresh_per_second=int(1 / STREAM_FRAME_SECONDS), transient=True,
) as live:
cut = 0
while cut < len(text):
cut = min(len(text), cut + chunk)
partial = text[:cut] + (f" {CURSOR}" if cut < len(text) else "")
live.update(render(partial))
time.sleep(STREAM_FRAME_SECONDS)

console.print(render(text), end=end)


def _handle_scheme_flags(args) -> None:
Expand Down Expand Up @@ -551,6 +608,10 @@ def main() -> None:

while True:
try:
pending_images: Union[ # noqa: UP007, RUF100
list[str], None
] = None

if pending:
uin = pending.pop(0)
if not _confirm_url_prompt(uin):
Expand Down Expand Up @@ -698,6 +759,37 @@ def main() -> None:
_run_update()
continue

if uin == "/image" or uin.startswith("/image "):
arg = uin[len("/image"):].strip()
if not arg:
warn("Usage: /image <path> [prompt]")
continue
try:
parts = shlex.split(arg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve Windows backslashes when parsing image paths

On Windows, unquoted paths normally contain backslashes, but shlex.split defaults to POSIX escaping, so an input like /image C:\Users\me\Pictures\cat.png describe is parsed as C:UsersmePicturescat.png and then fails the is_file() check. This makes the new image command unusable for ordinary Windows paths unless users quote or double-escape them; parse with Windows-aware rules or split the first argument without consuming backslashes.

Useful? React with 👍 / 👎.

except ValueError as exc:
warn(f"Could not parse path: {exc}")
continue
if not parts:
warn("Usage: /image <path> [prompt]")
continue

image_path = Path(parts[0]).expanduser()
if not image_path.is_file():
show_error(f"Image not found: {image_path}")
continue
if image_path.suffix.lower() not in IMAGE_EXTENSIONS:
show_error(
f"Unsupported image type '{image_path.suffix}'. "
"Supported: "
+ ", ".join(sorted(IMAGE_EXTENSIONS))
)
continue

# Fall through to the normal send path below with UIN
# replaced by the prompt and PENDING_IMAGES attached.
uin = " ".join(parts[1:]).strip() or DEFAULT_IMAGE_PROMPT
pending_images = [str(image_path)]

direct_command = _direct_shell_command(uin)
if direct_command:
print(
Expand Down Expand Up @@ -731,11 +823,12 @@ def main() -> None:
)
continue

messages.append(_message("user", uin))
messages.append(_message("user", uin, pending_images))
_trim_history(messages)

res, err = _chat_with_status(
console, client, [system_message] + messages, tools
console, client, [system_message] + messages, tools,
is_image=bool(pending_images),
Comment on lines 829 to +831

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable tool calling for image-only models

When /image sets pending_images, this call still sends the full tools list to Ollama. Common vision models such as the documented llama3.2-vision are image-capable but not tool-capable, so Ollama rejects the request before the model can answer; image recognition will fail for the recommended setup unless tools are omitted or gated for image turns.

Useful? React with 👍 / 👎.

)
if err:
_print_backend_error(err)
Expand Down
Loading
Loading