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.
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/PKG-INFO +12 -11
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/README.md +11 -10
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/pyproject.toml +1 -1
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/pyproject.toml.orig +1 -1
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/__init__.py +1 -1
- sepia_cli-0.2.0/src/sepia/chatbar.py +247 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/director.py +47 -4
- sepia_cli-0.2.0/src/sepia/glitch.py +117 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/launcher.py +10 -2
- sepia_cli-0.2.0/src/sepia/mouse.py +66 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/painter.py +55 -21
- sepia_cli-0.2.0/src/sepia/picker.py +95 -0
- sepia_cli-0.2.0/src/sepia/prompts/director.md +21 -0
- sepia_cli-0.2.0/src/sepia/reference.py +162 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/repl.py +108 -76
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/scene.py +13 -2
- sepia_cli-0.2.0/src/sepia/ui.py +299 -0
- sepia_cli-0.2.0/src/sepia/usage.py +63 -0
- sepia_cli-0.2.0/src/sepia/viewer.py +246 -0
- sepia_cli-0.1.0/src/sepia/prompts/director.md +0 -17
- sepia_cli-0.1.0/src/sepia/ui.py +0 -220
- sepia_cli-0.1.0/src/sepia/viewer.py +0 -167
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/LICENSE +0 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/__main__.py +0 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/ascii_art.py +0 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/cli.py +0 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/config.py +0 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/ipc.py +0 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/prompts/request.md +0 -0
- {sepia_cli-0.1.0 → sepia_cli-0.2.0}/src/sepia/py.typed +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
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
|
-
| `/
|
|
116
|
-
| `/
|
|
117
|
-
| `/
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
| `/
|
|
79
|
-
| `/
|
|
80
|
-
| `/
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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)
|