sepia-cli 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/PKG-INFO +12 -11
  2. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/README.md +11 -10
  3. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/pyproject.toml +1 -1
  4. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/pyproject.toml.orig +1 -1
  5. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/__init__.py +1 -1
  6. sepia_cli-0.2.0/src/sepia/chatbar.py +247 -0
  7. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/director.py +47 -4
  8. sepia_cli-0.2.0/src/sepia/glitch.py +117 -0
  9. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/launcher.py +10 -2
  10. sepia_cli-0.2.0/src/sepia/mouse.py +66 -0
  11. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/painter.py +55 -21
  12. sepia_cli-0.2.0/src/sepia/picker.py +95 -0
  13. sepia_cli-0.2.0/src/sepia/prompts/director.md +21 -0
  14. sepia_cli-0.2.0/src/sepia/reference.py +162 -0
  15. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/repl.py +108 -76
  16. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/scene.py +13 -2
  17. sepia_cli-0.2.0/src/sepia/ui.py +299 -0
  18. sepia_cli-0.2.0/src/sepia/usage.py +63 -0
  19. sepia_cli-0.2.0/src/sepia/viewer.py +246 -0
  20. sepia_cli-0.1.0/src/sepia/prompts/director.md +0 -17
  21. sepia_cli-0.1.0/src/sepia/ui.py +0 -220
  22. sepia_cli-0.1.0/src/sepia/viewer.py +0 -167
  23. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/LICENSE +0 -0
  24. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/__main__.py +0 -0
  25. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/ascii_art.py +0 -0
  26. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/cli.py +0 -0
  27. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/config.py +0 -0
  28. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/ipc.py +0 -0
  29. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/prompts/request.md +0 -0
  30. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/py.typed +0 -0
  31. {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/raster.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sepia-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Describe a picture and watch it appear as colored text in a second terminal window.
5
5
  Keywords: ascii-art,cli,terminal,claude,image-generation,flux
6
6
  Author: Yiğit Erdoğan
@@ -50,7 +50,7 @@ Type a short request in your terminal and get a photograph painted in colored te
50
50
  pollinations · 5.8s
51
51
  ```
52
52
 
53
- Follow-up requests build on the same picture: "mountains" after "waterfall" gives the same waterfall with mountains behind it. `/new` starts over. sepia only makes pictures; it is not a chat assistant.
53
+ Every request starts a new picture. To build on the current one, use `/add`: "waterfall", then `/add mountains`, gives the same waterfall with mountains behind it. sepia only makes pictures; it is not a chat assistant.
54
54
 
55
55
  ## How it works
56
56
 
@@ -70,7 +70,7 @@ Pollinations web API ---- fails or rate limited ----> FLUX.2 [klein] 4B on your
70
70
  +--- path over a loopback socket ---> photo -> colored characters
71
71
  ```
72
72
 
73
- 1. **Direct.** Claude (`claude-haiku-5-5` at low effort by default) turns the request and the scene so far into a detailed English photo prompt written for symbol rendering: one clear subject, strong light, saturated color, no text. It also decides whether the request edits the current picture or starts a new one, and writes a one-sentence reply in the language you typed in. The reply comes back as structured output (JSON with a fixed schema).
73
+ 1. **Direct.** Claude (`claude-haiku-5-5` at low effort by default) turns the request and the scene so far into a detailed English photo prompt written for symbol rendering: one clear subject, strong light, saturated color, no text. It also decides whether the request edits the current picture or starts a new one, and writes a one-sentence reply. The reply comes back as structured output (JSON with a fixed schema). The image models know no names, so when a request names a specific character, creature, product or landmark ("yunara lol"), Claude searches the web and links the subject's wiki page; sepia takes that page's preview picture and the image model paints from it, so the subject keeps its real look. Reference pictures work with a Pollinations key and with the local model; the keyless API ignores them.
74
74
  2. **Paint.** The prompt goes to [Pollinations](https://pollinations.ai) first. If the API fails or rate limits, sepia paints the picture locally with [FLUX.2 [klein] 4B](https://huggingface.co/black-forest-labs/FLUX.2-klein-4B) (Apache 2.0), if you installed the local extra. The picture is painted at the viewer window's aspect ratio.
75
75
  3. **Show.** The picture is saved as a PNG, and the viewer window is told where it is. The viewer stretches contrast, boosts saturation, crops the picture to the window, and draws every cell as one character whose density matches the brightness, in 24-bit color where the terminal supports it (256 colors otherwise). Resizing the window redraws the picture without new API calls.
76
76
 
@@ -112,13 +112,17 @@ On the first run sepia asks for your Anthropic API key (hidden input) and saves
112
112
 
113
113
  | Command | What it does |
114
114
  | --- | --- |
115
- | `/new` | Start a fresh picture. The next request does not build on the old one. |
116
- | `/style [ink\|fill]` | `ink` colors the characters on the terminal's own background (made for dark backgrounds); `fill` also paints each cell's background, which works on any theme. Without an argument it switches to the other style. |
117
- | `/charset [classic\|symbols]` | `classic` uses 31 density levels of the ASCII-art alphabet (letters, digits, punctuation); `symbols` uses punctuation only. Without an argument it switches to the other set. |
115
+ | `/add <text>` | Add to the current picture instead of starting a new one. |
116
+ | `/clear` | Clear the screen and start a fresh picture. |
117
+ | `/style [ink\|fill]` | `ink` colors the characters on the terminal's own background (made for dark backgrounds); `fill` also paints each cell's background, which works on any theme. Without an argument it opens a menu: arrow keys and Enter (or the option's number) pick, Esc cancels. |
118
+ | `/charset [classic\|symbols]` | `classic` uses 31 density levels of the ASCII-art alphabet (letters, digits, punctuation); `symbols` uses punctuation only. Without an argument it opens a menu, like `/style`. |
119
+ | `/usage` | Show what this session used: Claude's tokens, web searches and estimated cost, and the pictures painted per source. |
118
120
  | `/help` | List the commands. |
119
121
  | `/exit` | Close sepia and the viewer window. `/quit`, `/q` and Ctrl+D do the same. |
120
122
 
121
- Up and down arrows walk the request history, and typing `/` completes commands. The bottom bar shows the model, the current style and charset, and whether the viewer window is connected (`●`) or not (`○`). If you close the viewer, the next request opens it again.
123
+ Move the mouse over the picture window to glitch the picture around the pointer. It heals when the pointer stops. Windows Terminal and iTerm2 report pointer movement; macOS Terminal.app may not.
124
+
125
+ Up and down arrows walk the request history, and typing `/` completes commands; Enter on a partial command runs the suggested one (`/us` opens `/usage`). The bottom bar shows the model, the current style and charset, and whether the viewer window is connected (`●`) or not (`○`). If you close the viewer, the next request opens it again.
122
126
 
123
127
  Command-line flags:
124
128
 
@@ -128,8 +132,6 @@ Command-line flags:
128
132
  | `--version` | Print the version. |
129
133
  | `--help` | Print usage. |
130
134
 
131
- The interface speaks English and Turkish. It follows the system language unless `SEPIA_LANG` is set.
132
-
133
135
  ## Configuration
134
136
 
135
137
  sepia reads settings from the environment, then from `.env` in the current directory, then from `.env` in the user config folder. A value that is already set is never overridden by a later source. See [.env.example](.env.example).
@@ -143,7 +145,6 @@ sepia reads settings from the environment, then from `.env` in the current direc
143
145
  | `SEPIA_STYLE` | `ink` | Starting style: `ink` or `fill`. |
144
146
  | `SEPIA_CHARSET` | `classic` | Starting character set: `classic` or `symbols`. |
145
147
  | `SEPIA_IMAGE_MODEL` | `black-forest-labs/FLUX.2-klein-4B` | Hugging Face id of the local model. |
146
- | `SEPIA_LANG` | system language | Interface language: `en` or `tr`. Set it in the shell environment; it is read before the `.env` files load. |
147
148
 
148
149
  An invalid value for `SEPIA_EFFORT`, `SEPIA_STYLE` or `SEPIA_CHARSET` stops sepia at startup.
149
150
 
@@ -179,7 +180,7 @@ The model loads on the first local picture, so that picture takes longer. Edits
179
180
 
180
181
  ## Platform notes
181
182
 
182
- **Windows.** The viewer opens as a new Windows Terminal window in focus mode, so there is no tab bar or title bar. It uses a `sepia` profile (6 pt Cascadia Mono, black background, no padding) that sepia writes as a Windows Terminal fragment to `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\sepia\sepia.json`. Your own `settings.json` is not touched; delete that folder to remove the profile. The small font fits several times more cells into the window, which gives the picture more detail. Without Windows Terminal, the viewer falls back to a plain console window.
183
+ **Windows.** The viewer opens as a new Windows Terminal window with a tab named `sepia` in the theme's pinkish purple; drag the tab row to move the window. It uses a `sepia` profile (6 pt Cascadia Mono, black background, no padding) that sepia writes as a Windows Terminal fragment to `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\sepia\sepia.json`. Your own `settings.json` is not touched; delete that folder to remove the profile. The small font fits several times more cells into the window, which gives the picture more detail. Without Windows Terminal, the viewer falls back to a plain console window.
183
184
 
184
185
  **macOS.** The viewer opens in a new Terminal.app window through `osascript`. The first time, macOS asks whether your terminal may control Terminal.app (Automation permission); allow it. Terminal.app cannot hide its title bar. Press ⌘- in the viewer window to shrink the font: the picture redraws with more cells and more detail.
185
186
 
@@ -13,7 +13,7 @@ Type a short request in your terminal and get a photograph painted in colored te
13
13
  pollinations · 5.8s
14
14
  ```
15
15
 
16
- Follow-up requests build on the same picture: "mountains" after "waterfall" gives the same waterfall with mountains behind it. `/new` starts over. sepia only makes pictures; it is not a chat assistant.
16
+ Every request starts a new picture. To build on the current one, use `/add`: "waterfall", then `/add mountains`, gives the same waterfall with mountains behind it. sepia only makes pictures; it is not a chat assistant.
17
17
 
18
18
  ## How it works
19
19
 
@@ -33,7 +33,7 @@ Pollinations web API ---- fails or rate limited ----> FLUX.2 [klein] 4B on your
33
33
  +--- path over a loopback socket ---> photo -> colored characters
34
34
  ```
35
35
 
36
- 1. **Direct.** Claude (`claude-haiku-5-5` at low effort by default) turns the request and the scene so far into a detailed English photo prompt written for symbol rendering: one clear subject, strong light, saturated color, no text. It also decides whether the request edits the current picture or starts a new one, and writes a one-sentence reply in the language you typed in. The reply comes back as structured output (JSON with a fixed schema).
36
+ 1. **Direct.** Claude (`claude-haiku-5-5` at low effort by default) turns the request and the scene so far into a detailed English photo prompt written for symbol rendering: one clear subject, strong light, saturated color, no text. It also decides whether the request edits the current picture or starts a new one, and writes a one-sentence reply. The reply comes back as structured output (JSON with a fixed schema). The image models know no names, so when a request names a specific character, creature, product or landmark ("yunara lol"), Claude searches the web and links the subject's wiki page; sepia takes that page's preview picture and the image model paints from it, so the subject keeps its real look. Reference pictures work with a Pollinations key and with the local model; the keyless API ignores them.
37
37
  2. **Paint.** The prompt goes to [Pollinations](https://pollinations.ai) first. If the API fails or rate limits, sepia paints the picture locally with [FLUX.2 [klein] 4B](https://huggingface.co/black-forest-labs/FLUX.2-klein-4B) (Apache 2.0), if you installed the local extra. The picture is painted at the viewer window's aspect ratio.
38
38
  3. **Show.** The picture is saved as a PNG, and the viewer window is told where it is. The viewer stretches contrast, boosts saturation, crops the picture to the window, and draws every cell as one character whose density matches the brightness, in 24-bit color where the terminal supports it (256 colors otherwise). Resizing the window redraws the picture without new API calls.
39
39
 
@@ -75,13 +75,17 @@ On the first run sepia asks for your Anthropic API key (hidden input) and saves
75
75
 
76
76
  | Command | What it does |
77
77
  | --- | --- |
78
- | `/new` | Start a fresh picture. The next request does not build on the old one. |
79
- | `/style [ink\|fill]` | `ink` colors the characters on the terminal's own background (made for dark backgrounds); `fill` also paints each cell's background, which works on any theme. Without an argument it switches to the other style. |
80
- | `/charset [classic\|symbols]` | `classic` uses 31 density levels of the ASCII-art alphabet (letters, digits, punctuation); `symbols` uses punctuation only. Without an argument it switches to the other set. |
78
+ | `/add <text>` | Add to the current picture instead of starting a new one. |
79
+ | `/clear` | Clear the screen and start a fresh picture. |
80
+ | `/style [ink\|fill]` | `ink` colors the characters on the terminal's own background (made for dark backgrounds); `fill` also paints each cell's background, which works on any theme. Without an argument it opens a menu: arrow keys and Enter (or the option's number) pick, Esc cancels. |
81
+ | `/charset [classic\|symbols]` | `classic` uses 31 density levels of the ASCII-art alphabet (letters, digits, punctuation); `symbols` uses punctuation only. Without an argument it opens a menu, like `/style`. |
82
+ | `/usage` | Show what this session used: Claude's tokens, web searches and estimated cost, and the pictures painted per source. |
81
83
  | `/help` | List the commands. |
82
84
  | `/exit` | Close sepia and the viewer window. `/quit`, `/q` and Ctrl+D do the same. |
83
85
 
84
- Up and down arrows walk the request history, and typing `/` completes commands. The bottom bar shows the model, the current style and charset, and whether the viewer window is connected (`●`) or not (`○`). If you close the viewer, the next request opens it again.
86
+ Move the mouse over the picture window to glitch the picture around the pointer. It heals when the pointer stops. Windows Terminal and iTerm2 report pointer movement; macOS Terminal.app may not.
87
+
88
+ Up and down arrows walk the request history, and typing `/` completes commands; Enter on a partial command runs the suggested one (`/us` opens `/usage`). The bottom bar shows the model, the current style and charset, and whether the viewer window is connected (`●`) or not (`○`). If you close the viewer, the next request opens it again.
85
89
 
86
90
  Command-line flags:
87
91
 
@@ -91,8 +95,6 @@ Command-line flags:
91
95
  | `--version` | Print the version. |
92
96
  | `--help` | Print usage. |
93
97
 
94
- The interface speaks English and Turkish. It follows the system language unless `SEPIA_LANG` is set.
95
-
96
98
  ## Configuration
97
99
 
98
100
  sepia reads settings from the environment, then from `.env` in the current directory, then from `.env` in the user config folder. A value that is already set is never overridden by a later source. See [.env.example](.env.example).
@@ -106,7 +108,6 @@ sepia reads settings from the environment, then from `.env` in the current direc
106
108
  | `SEPIA_STYLE` | `ink` | Starting style: `ink` or `fill`. |
107
109
  | `SEPIA_CHARSET` | `classic` | Starting character set: `classic` or `symbols`. |
108
110
  | `SEPIA_IMAGE_MODEL` | `black-forest-labs/FLUX.2-klein-4B` | Hugging Face id of the local model. |
109
- | `SEPIA_LANG` | system language | Interface language: `en` or `tr`. Set it in the shell environment; it is read before the `.env` files load. |
110
111
 
111
112
  An invalid value for `SEPIA_EFFORT`, `SEPIA_STYLE` or `SEPIA_CHARSET` stops sepia at startup.
112
113
 
@@ -142,7 +143,7 @@ The model loads on the first local picture, so that picture takes longer. Edits
142
143
 
143
144
  ## Platform notes
144
145
 
145
- **Windows.** The viewer opens as a new Windows Terminal window in focus mode, so there is no tab bar or title bar. It uses a `sepia` profile (6 pt Cascadia Mono, black background, no padding) that sepia writes as a Windows Terminal fragment to `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\sepia\sepia.json`. Your own `settings.json` is not touched; delete that folder to remove the profile. The small font fits several times more cells into the window, which gives the picture more detail. Without Windows Terminal, the viewer falls back to a plain console window.
146
+ **Windows.** The viewer opens as a new Windows Terminal window with a tab named `sepia` in the theme's pinkish purple; drag the tab row to move the window. It uses a `sepia` profile (6 pt Cascadia Mono, black background, no padding) that sepia writes as a Windows Terminal fragment to `%LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\sepia\sepia.json`. Your own `settings.json` is not touched; delete that folder to remove the profile. The small font fits several times more cells into the window, which gives the picture more detail. Without Windows Terminal, the viewer falls back to a plain console window.
146
147
 
147
148
  **macOS.** The viewer opens in a new Terminal.app window through `osascript`. The first time, macOS asks whether your terminal may control Terminal.app (Automation permission); allow it. Terminal.app cannot hide its title bar. Press ⌘- in the viewer window to shrink the font: the picture redraws with more cells and more detail.
148
149
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sepia-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Describe a picture and watch it appear as colored text in a second terminal window."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sepia-cli"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Describe a picture and watch it appear as colored text in a second terminal window."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """sepia: prompts become colored symbol paintings in your terminal."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,247 @@
1
+ """The chat bar: an input line between two rules, pinned to the bottom rows.
2
+
3
+ `ChatBar` draws the whole bar as one block: top rule, the input, bottom rule and a
4
+ status line. (A `PromptSession` puts its bottom toolbar on the screen's last row,
5
+ which split the block in two.)
6
+
7
+ `BottomBar` keeps that block on the window's last rows. A terminal scroll region
8
+ covers every row above it, so the transcript starts under the welcome box and, once
9
+ it reaches the bar, scrolls the welcome box out of view while the bar never moves.
10
+ """
11
+
12
+ import io
13
+ import shutil
14
+ from collections.abc import Callable, Sequence
15
+
16
+ from prompt_toolkit.application import Application
17
+ from prompt_toolkit.application.current import get_app_session
18
+ from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
19
+ from prompt_toolkit.buffer import Buffer
20
+ from prompt_toolkit.data_structures import Size
21
+ from prompt_toolkit.document import Document
22
+ from prompt_toolkit.filters import Condition
23
+ from prompt_toolkit.formatted_text import (
24
+ FormattedText,
25
+ StyleAndTextTuples,
26
+ fragment_list_width,
27
+ )
28
+ from prompt_toolkit.history import History
29
+ from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
30
+ from prompt_toolkit.layout import FormattedTextControl, HSplit, Layout, Window
31
+ from prompt_toolkit.layout.controls import BufferControl
32
+ from prompt_toolkit.layout.processors import (
33
+ AfterInput,
34
+ AppendAutoSuggestion,
35
+ BeforeInput,
36
+ ConditionalProcessor,
37
+ )
38
+ from prompt_toolkit.output.vt100 import Vt100_Output
39
+ from prompt_toolkit.shortcuts import print_formatted_text
40
+ from prompt_toolkit.styles import Style
41
+
42
+ from sepia import ui
43
+
44
+ BAR_ROWS = 4
45
+ """Top rule, input line, bottom rule, status line."""
46
+
47
+ STYLE = Style.from_dict(
48
+ {
49
+ "frame": ui.FRAME,
50
+ "prompt": f"bold {ui.ACCENT}",
51
+ "placeholder": ui.MUTED,
52
+ "auto-suggestion": ui.MUTED,
53
+ "status": ui.MUTED,
54
+ }
55
+ )
56
+
57
+
58
+ class BottomBar:
59
+ """Keeps the chat bar on the bottom rows while the transcript scrolls above it."""
60
+
61
+ SAVE, RESTORE, RESET = "\x1b7", "\x1b8", "\x1b[r"
62
+
63
+ def __init__(self, write: Callable[[str], None], rows: Callable[[], int]) -> None:
64
+ self._write = write
65
+ self._rows = rows
66
+
67
+ def start(self) -> None:
68
+ """Set the region; this also homes the cursor, where the transcript begins."""
69
+ self._write(self._region())
70
+
71
+ def to_bar(self) -> None:
72
+ """Remember the transcript position and move to the bar's first row."""
73
+ # Re-applying the region follows window resizes; it homes the cursor, so
74
+ # the transcript position is saved first and put back afterwards.
75
+ self._write(self.SAVE + self._region() + self.RESTORE + self.SAVE)
76
+ self._write(f"\x1b[{self._top()};1H")
77
+
78
+ def hold(self, rows: Sequence[str]) -> None:
79
+ """Draw a still copy of the bar on its rows, one finished row at a time."""
80
+ top = self._top()
81
+ for offset, row in enumerate(rows):
82
+ self._write(f"\x1b[{top + offset};1H\x1b[2K{row}")
83
+
84
+ def to_transcript(self) -> None:
85
+ self._write(self.RESTORE)
86
+
87
+ def stop(self) -> None:
88
+ """Give the whole screen back and clear the bar, leaving the cursor there."""
89
+ self._write(f"{self.RESET}\x1b[{self._top()};1H\x1b[J")
90
+
91
+ def _top(self) -> int:
92
+ return max(self._rows() - BAR_ROWS + 1, 1)
93
+
94
+ def _region(self) -> str:
95
+ return f"\x1b[1;{max(self._rows() - BAR_ROWS, 1)}r"
96
+
97
+
98
+ class CommandSuggest(AutoSuggest):
99
+ """Completes a slash command in grey as it is typed; Tab or → accepts it.
100
+
101
+ An inline hint instead of a menu, because the bar sits on the last rows and a
102
+ menu below it would fall off the screen.
103
+ """
104
+
105
+ def __init__(self, names: Sequence[str]) -> None:
106
+ self._names = names
107
+
108
+ def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None:
109
+ text = document.text
110
+ if not text.startswith("/") or " " in text:
111
+ return None
112
+ for name in self._names:
113
+ if name.startswith(text) and name != text:
114
+ return Suggestion(name[len(text) :])
115
+ return None
116
+
117
+
118
+ class ChatBar:
119
+ """Reads one line at a time inside the framed box."""
120
+
121
+ def __init__(
122
+ self,
123
+ history: History,
124
+ commands: Sequence[tuple[str, str]],
125
+ placeholder: str,
126
+ status: Callable[[], str],
127
+ ) -> None:
128
+ self._history = history
129
+ self._suggest = CommandSuggest([usage.split()[0] for usage, _ in commands])
130
+ self._placeholder = placeholder
131
+ self._status = status
132
+
133
+ def read(self) -> str:
134
+ """The submitted line. Raises KeyboardInterrupt on Ctrl+C and EOFError on
135
+ Ctrl+D in an empty bar, like `input()`."""
136
+ result: str = self._application().run()
137
+ return result
138
+
139
+ def still(self, width: int) -> list[str]:
140
+ """The empty bar as rows of text with escape codes, `width` columns wide.
141
+
142
+ The live bar erases itself when a line is submitted; drawing this copy in
143
+ its place keeps the bar on screen while sepia works on the request.
144
+ """
145
+ room = max(width - len(ui.PROMPT), 0)
146
+ rows: list[StyleAndTextTuples] = [
147
+ [("class:frame", ui.bar_rule(width))],
148
+ [
149
+ ("class:prompt", ui.PROMPT),
150
+ ("class:placeholder", self._placeholder[:room]),
151
+ ],
152
+ [("class:frame", ui.bar_rule(width))],
153
+ [("class:status", self._status()[:width])],
154
+ ]
155
+ return [_escaped(row) for row in rows]
156
+
157
+ def _application(self) -> Application[str]:
158
+ buffer = Buffer(
159
+ history=self._history,
160
+ auto_suggest=self._suggest,
161
+ multiline=False,
162
+ accept_handler=lambda buf: _accept(buf),
163
+ )
164
+ empty = Condition(lambda: not buffer.text)
165
+ field = Window(
166
+ BufferControl(
167
+ buffer,
168
+ input_processors=[
169
+ BeforeInput(ui.PROMPT, style="class:prompt"),
170
+ AppendAutoSuggestion(),
171
+ ConditionalProcessor(
172
+ AfterInput(self._placeholder, style="class:placeholder"),
173
+ empty,
174
+ ),
175
+ ],
176
+ ),
177
+ height=1,
178
+ dont_extend_height=True,
179
+ )
180
+ body = HSplit(
181
+ [
182
+ _line(ui.bar_rule, "class:frame"),
183
+ field,
184
+ _line(ui.bar_rule, "class:frame"),
185
+ _line(lambda _: self._status(), "class:status"),
186
+ ]
187
+ )
188
+
189
+ def _accept(buf: Buffer) -> bool:
190
+ # Enter on a partial command runs the one suggested for it: /us -> /usage.
191
+ suggestion = self._suggest.get_suggestion(buf, buf.document)
192
+ if suggestion is not None:
193
+ buf.text += suggestion.text
194
+ app.exit(result=buf.text)
195
+ return True # keep the text out of the buffer reset; history still saves
196
+
197
+ keys = KeyBindings()
198
+
199
+ @keys.add("c-c")
200
+ def _interrupt(event: KeyPressEvent) -> None:
201
+ event.app.exit(exception=KeyboardInterrupt())
202
+
203
+ @keys.add("c-d", filter=empty)
204
+ def _end(event: KeyPressEvent) -> None:
205
+ event.app.exit(exception=EOFError())
206
+
207
+ @keys.add("tab")
208
+ def _complete(event: KeyPressEvent) -> None:
209
+ # Computed here rather than read from buffer.suggestion, which is
210
+ # filled in the background and can lag behind fast typing.
211
+ current = event.current_buffer
212
+ suggestion = self._suggest.get_suggestion(current, current.document)
213
+ if suggestion is not None:
214
+ current.insert_text(suggestion.text)
215
+
216
+ app: Application[str] = Application(
217
+ layout=Layout(body, focused_element=field),
218
+ key_bindings=keys,
219
+ style=STYLE,
220
+ full_screen=False,
221
+ erase_when_done=True,
222
+ mouse_support=False,
223
+ )
224
+ return app
225
+
226
+
227
+ def _line(text: Callable[[int], str], style: str) -> Window:
228
+ """A one-row window whose text is rebuilt for the current width on every draw."""
229
+
230
+ def render() -> FormattedText:
231
+ return FormattedText([(style, text(shutil.get_terminal_size().columns))])
232
+
233
+ return Window(FormattedTextControl(render), height=1, dont_extend_height=True)
234
+
235
+
236
+ def _escaped(fragments: StyleAndTextTuples) -> str:
237
+ """Styled fragments as text with escape codes, in the live bar's colors."""
238
+ text = io.StringIO()
239
+ width = fragment_list_width(fragments)
240
+ print_formatted_text(
241
+ FormattedText(fragments),
242
+ end="",
243
+ style=STYLE,
244
+ output=Vt100_Output(text, lambda: Size(rows=1, columns=width)),
245
+ color_depth=get_app_session().output.get_default_color_depth(),
246
+ )
247
+ return text.getvalue()
@@ -8,7 +8,11 @@ from string import Template
8
8
  from typing import Any
9
9
 
10
10
  import anthropic
11
- from anthropic.types.beta import BetaOutputConfigParam
11
+ from anthropic.types.beta import (
12
+ BetaOutputConfigParam,
13
+ BetaUsage,
14
+ BetaWebSearchTool20260209Param,
15
+ )
12
16
  from pydantic import BaseModel, Field, ValidationError
13
17
 
14
18
  from sepia.config import Effort
@@ -24,21 +28,35 @@ FALLBACK_MODELS = frozenset(
24
28
  {"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
25
29
  )
26
30
  """Models that accept `fallbacks: "default"`; Haiku has no server-side fallback."""
31
+ WEB_SEARCH: BetaWebSearchTool20260209Param = {
32
+ "type": "web_search_20260209",
33
+ "name": "web_search",
34
+ "max_uses": 2,
35
+ }
36
+ """Lets Claude look up what an unfamiliar name looks like; about $0.01 a search."""
37
+ MAX_REFERENCES = 3
38
+ """Links tried for a reference picture; the first usable one wins."""
27
39
 
28
40
  REPLY_SCHEMA: dict[str, Any] = {
29
41
  "type": "object",
30
42
  "properties": {
31
43
  "message": {
32
44
  "type": "string",
33
- "description": "One short sentence in the language of the latest request.",
45
+ "description": "One short sentence in English.",
34
46
  },
35
47
  "prompt": {"type": "string", "description": "The English image prompt."},
36
48
  "edit": {
37
49
  "type": "boolean",
38
50
  "description": "True to repaint the previous picture, false for a new one.",
39
51
  },
52
+ "references": {
53
+ "type": "array",
54
+ "items": {"type": "string"},
55
+ "description": "Up to three links that show the named subject, best "
56
+ "first; empty when the request names nothing specific.",
57
+ },
40
58
  },
41
- "required": ["message", "prompt", "edit"],
59
+ "required": ["message", "prompt", "edit", "references"],
42
60
  "additionalProperties": False,
43
61
  }
44
62
 
@@ -68,6 +86,7 @@ class _Reply(BaseModel):
68
86
  message: str = Field(min_length=1)
69
87
  prompt: str = Field(min_length=1)
70
88
  edit: bool
89
+ references: list[str] = Field(default_factory=list)
71
90
 
72
91
 
73
92
  @dataclass(frozen=True)
@@ -75,6 +94,11 @@ class Direction:
75
94
  message: str
76
95
  prompt: str
77
96
  edit: bool
97
+ references: tuple[str, ...]
98
+ """Links to a picture of the subject, for `reference.find_reference`."""
99
+ model: str
100
+ """The model that answered; a server-side fallback can differ from the one asked."""
101
+ searches: int
78
102
  input_tokens: int
79
103
  output_tokens: int
80
104
 
@@ -103,6 +127,7 @@ class Director:
103
127
  betas=[FALLBACK_BETA] if fallback else anthropic.omit,
104
128
  fallbacks="default" if fallback else anthropic.omit,
105
129
  system=system_prompt(),
130
+ tools=[WEB_SEARCH],
106
131
  output_config=output_config,
107
132
  messages=[{"role": "user", "content": build_request(request, scene)}],
108
133
  ) as stream:
@@ -122,7 +147,9 @@ class Director:
122
147
  raise DirectorError(Failure.REFUSED)
123
148
  if message.stop_reason == "max_tokens":
124
149
  raise DirectorError(Failure.TRUNCATED)
125
- text = "".join(block.text for block in message.content if block.type == "text")
150
+ # The JSON is the last text block; a search can put remarks before it.
151
+ texts = [block.text for block in message.content if block.type == "text"]
152
+ text = texts[-1] if texts else ""
126
153
  try:
127
154
  reply = _Reply.model_validate_json(text)
128
155
  except ValidationError as exc:
@@ -131,11 +158,27 @@ class Director:
131
158
  message=reply.message.strip(),
132
159
  prompt=reply.prompt.strip(),
133
160
  edit=reply.edit and not scene.is_empty,
161
+ references=_links(reply.references),
162
+ model=message.model,
163
+ searches=_searches(message.usage),
134
164
  input_tokens=message.usage.input_tokens,
135
165
  output_tokens=message.usage.output_tokens,
136
166
  )
137
167
 
138
168
 
169
+ def _searches(usage: BetaUsage) -> int:
170
+ """Web searches the request ran; None when it ran no server tool at all."""
171
+ server = usage.server_tool_use
172
+ return server.web_search_requests if server is not None else 0
173
+
174
+
175
+ def _links(raw: list[str]) -> tuple[str, ...]:
176
+ """The first few https links, trimmed; anything else is dropped."""
177
+ links = [link.strip() for link in raw]
178
+ web = [link for link in links if link.startswith("https://")]
179
+ return tuple(web[:MAX_REFERENCES])
180
+
181
+
139
182
  @cache
140
183
  def system_prompt() -> str:
141
184
  return _prompt("director.md")
@@ -0,0 +1,117 @@
1
+ """A glitch around the mouse pointer: torn rows, corrupted glyphs, split colors.
2
+
3
+ Only the cells around the pointer are redrawn, straight as ANSI escapes, so the
4
+ effect can run at 30 frames a second on a picture of tens of thousands of cells.
5
+ """
6
+
7
+ import random
8
+
9
+ from sepia.ascii_art import SymbolArt
10
+
11
+ RADIUS_X = 13
12
+ RADIUS_Y = 6
13
+ """Half-size of the glitched area in cells; cells are about twice as tall as wide,
14
+ so this reads as roughly round."""
15
+ DURATION = 0.9
16
+ """Seconds the glitch lingers and fades after the pointer stops."""
17
+ FRAME_SECONDS = 1 / 30
18
+ MAX_SHIFT = 5
19
+ """How far, in cells, a torn row segment slides sideways."""
20
+ RIM_JITTER = 0.2
21
+ """How ragged the round edge is, as a share of the radius."""
22
+
23
+ # How often each kind of damage happens at full strength, per row or per cell.
24
+ TEAR_RATE = 0.85
25
+ """A row segment slides sideways."""
26
+ JUMP_RATE = 0.3
27
+ """A row shows its neighbor above or below instead, so the area judders."""
28
+ NOISE_RATE = 0.7
29
+ """A glyph turns into noise."""
30
+ SPLIT_RATE = 0.8
31
+ """A cell's color splits: red from the left neighbor, green and blue from the right."""
32
+ FLASH_RATE = 0.25
33
+ """A cell flashes magenta, cyan or white."""
34
+
35
+ NOISE = "░▒▓█▚▞#@%&$!?/\\|<>"
36
+ SPLIT_COLORS = ((255, 0, 170), (0, 255, 255), (255, 255, 255))
37
+ """Magenta, cyan and white flashes, the usual colors of a broken signal."""
38
+
39
+ Cell = tuple[str, tuple[int, int, int], tuple[int, int, int] | None]
40
+
41
+
42
+ def area(art: SymbolArt, col: int, row: int) -> tuple[int, int, int, int]:
43
+ """Rows [top, bottom) and columns [left, right) the glitch touches."""
44
+ top, bottom = max(row - RADIUS_Y, 0), min(row + RADIUS_Y + 1, art.rows)
45
+ left, right = max(col - RADIUS_X, 0), min(col + RADIUS_X + 1, art.cols)
46
+ return top, bottom, left, right
47
+
48
+
49
+ def glitch_ansi(
50
+ art: SymbolArt, col: int, row: int, strength: float, rng: random.Random
51
+ ) -> str:
52
+ """One frame of the glitch centered on (col, row); `strength` runs 1 -> 0."""
53
+ top, bottom, left, right = area(art, col, row)
54
+ out: list[str] = []
55
+ for y in range(top, bottom):
56
+ dy = (y - row) / RADIUS_Y
57
+ shift = 0
58
+ if rng.random() < TEAR_RATE * strength:
59
+ shift = rng.randint(-MAX_SHIFT, MAX_SHIFT)
60
+ source_row = y
61
+ if rng.random() < JUMP_RATE * strength:
62
+ source_row = min(max(y + rng.choice((-1, 1)), 0), art.rows - 1)
63
+ cells: list[Cell] = []
64
+ for x in range(left, right):
65
+ dx = (x - col) / RADIUS_X
66
+ distance = dx * dx + dy * dy
67
+ if distance > 1.0 + rng.uniform(-RIM_JITTER, RIM_JITTER):
68
+ # Outside the circle: the untouched cell, so the corners of the
69
+ # redrawn box never show and the glitch reads as round.
70
+ cells.append(_cell(art, y, x))
71
+ continue
72
+ # Strongest at the pointer, fading toward the rim.
73
+ chance = max(0.0, 1.0 - distance) ** 0.6 * strength
74
+ source = min(max(x - shift, 0), art.cols - 1)
75
+ char, fg, bg = _cell(art, source_row, source)
76
+ if rng.random() < chance * NOISE_RATE:
77
+ char = rng.choice(NOISE)
78
+ if rng.random() < chance * SPLIT_RATE:
79
+ red = _cell(art, source_row, max(source - 1, 0))[1][0]
80
+ right_fg = _cell(art, source_row, min(source + 1, art.cols - 1))[1]
81
+ fg = (red, right_fg[1], right_fg[2])
82
+ if rng.random() < chance * FLASH_RATE:
83
+ fg = rng.choice(SPLIT_COLORS)
84
+ cells.append((char, fg, bg))
85
+ out.append(_row_ansi(y, left, cells))
86
+ return "".join(out) + "\x1b[0m"
87
+
88
+
89
+ def restore_ansi(art: SymbolArt, col: int, row: int) -> str:
90
+ """The untouched cells of the area around (col, row)."""
91
+ top, bottom, left, right = area(art, col, row)
92
+ out = [
93
+ _row_ansi(y, left, [_cell(art, y, x) for x in range(left, right)])
94
+ for y in range(top, bottom)
95
+ ]
96
+ return "".join(out) + "\x1b[0m"
97
+
98
+
99
+ def _cell(art: SymbolArt, y: int, x: int) -> Cell:
100
+ red, green, blue = (int(v) for v in art.fg[y, x])
101
+ bg = None
102
+ if art.bg is not None:
103
+ bg_red, bg_green, bg_blue = (int(v) for v in art.bg[y, x])
104
+ bg = (bg_red, bg_green, bg_blue)
105
+ return str(art.chars[y, x]), (red, green, blue), bg
106
+
107
+
108
+ def _row_ansi(y: int, left: int, cells: list[Cell]) -> str:
109
+ parts = [f"\x1b[{y + 1};{left + 1}H"]
110
+ for char, (red, green, blue), bg in cells:
111
+ parts.append(f"\x1b[38;2;{red};{green};{blue}m")
112
+ if bg is not None:
113
+ parts.append(f"\x1b[48;2;{bg[0]};{bg[1]};{bg[2]}m")
114
+ else:
115
+ parts.append("\x1b[49m")
116
+ parts.append(char)
117
+ return "".join(parts)