diff --git a/bench/demo_setup.py b/bench/demo_setup.py
index 56b40c4..375a040 100644
--- a/bench/demo_setup.py
+++ b/bench/demo_setup.py
@@ -76,7 +76,7 @@ def arm():
cfg["agent"]["num_draft_tokens"] = 2
json.dump(cfg, open(cfg_path, "w"), indent=2)
print(f" model -> {TRAINABLE} (trainable on 16GB; adapter + draft both match)")
- print(f" previous config saved to config.json.bak.pre-demo")
+ print(" previous config saved to config.json.bak.pre-demo")
golden = ROOT / "golden_cases.json"
if not golden.exists():
@@ -121,7 +121,7 @@ def reset():
reset()
else:
arm()
- print(f"""
+ print("""
ARMED. Now record:
./symb chat
diff --git a/scripts/seed_training.py b/scripts/seed_training.py
index 061c585..9b72d4e 100644
--- a/scripts/seed_training.py
+++ b/scripts/seed_training.py
@@ -106,7 +106,7 @@ def main():
f"You are {user} and I am {assistant}.",
),
(
- f"Who am I and who are you?",
+ "Who am I and who are you?",
f"You are {user}, and I am {assistant}.",
),
(
@@ -460,7 +460,7 @@ def main():
# --- Domain Q&A / boundaries ---
(
"Can you read my emails?",
- f"I can check your email if you configure IMAP/SMTP via environment variables. Once configured, I can list threads, read messages, and send replies.",
+ "I can check your email if you configure IMAP/SMTP via environment variables. Once configured, I can list threads, read messages, and send replies.",
),
]
@@ -514,15 +514,15 @@ def main():
],
[
{"role": "user", "content": "Call yourself Friday."},
- {"role": "assistant", "content": f"Got it — my name is Friday."},
+ {"role": "assistant", "content": "Got it — my name is Friday."},
{"role": "user", "content": "What is your name?"},
- {"role": "assistant", "content": f"My name is Friday."},
+ {"role": "assistant", "content": "My name is Friday."},
],
[
{"role": "user", "content": "Call me Alice."},
- {"role": "assistant", "content": f"Got it — I'll call you Alice."},
+ {"role": "assistant", "content": "Got it — I'll call you Alice."},
{"role": "user", "content": "What is my name?"},
- {"role": "assistant", "content": f"Your name is Alice."},
+ {"role": "assistant", "content": "Your name is Alice."},
],
]
diff --git a/symbio/adapter_crypto.py b/symbio/adapter_crypto.py
index ba33784..79c1d0a 100644
--- a/symbio/adapter_crypto.py
+++ b/symbio/adapter_crypto.py
@@ -351,16 +351,16 @@ def setup_secure_enclave(identity_out: Path, recipients_out: Path,
# from `age` at lock time as "no recipients found".
if result.returncode != 0 or not _has_recipients(recipients_out):
detail = (result.stderr or result.stdout).strip()
- say(f" -- could not derive recipients"
+ say(" -- could not derive recipients"
+ (f": {detail}" if detail else " (the file came out empty)"))
return 1
say(f"{_found(True)}recipients: {recipients_out}")
if access != "none":
- say(f"\n Each adapter load will ask for your Mac login password.")
- say(f" That includes worker swaps and deep-sleep wakes, which happen")
- say(f" several times in a browser session. --access-control none")
- say(f" removes the prompt and still binds the adapter to this Mac.")
+ say("\n Each adapter load will ask for your Mac login password.")
+ say(" That includes worker swaps and deep-sleep wakes, which happen")
+ say(" several times in a browser session. --access-control none")
+ say(" removes the prompt and still binds the adapter to this Mac.")
return 0
@@ -411,7 +411,7 @@ def finish(folder: Path, identity_out: Path, recipients_out: Path,
say(f" -- could not write {config_file.name} ({e}); set it yourself:")
say(f" ./symb config set agent.adapter_identity {identity_out}")
- say(f"\nDone. `./symb chat` will now unlock the adapter at load.")
+ say("\nDone. `./symb chat` will now unlock the adapter at load.")
say(f"If anything goes wrong: rm -rf {folder} && mv {backup} {folder}")
return 0
@@ -496,7 +496,7 @@ def setup(identity_out: Path, recipients_out: Path, touch: str = "cached",
say("\nNext, and read this line before you run it: locking DELETES the")
print("plaintext weights. Keep a copy until a real load has worked.")
- say(f" cp -R adapters adapters.backup")
+ say(" cp -R adapters adapters.backup")
print(f" python3 symbio/adapter_crypto.py lock adapters "
f"--recipients {recipients_out}")
say(f" ./symb config set agent.adapter_identity {identity_out}")
diff --git a/symbio/app/chat.py b/symbio/app/chat.py
index 7607c24..29a020d 100644
--- a/symbio/app/chat.py
+++ b/symbio/app/chat.py
@@ -2293,7 +2293,7 @@ def _generate_tag_metadata(self, prompt: str) -> str:
):
text = re.sub(pattern, "", text, flags=re.DOTALL | re.IGNORECASE)
return text.strip()
- except Exception as e:
+ except Exception:
return ""
def _claims_incapacity(self, reply: str, user_input: str) -> bool:
@@ -3458,6 +3458,16 @@ def _install_command_completion(session) -> bool:
return chat_style.install_command_completion(session.command_names)
+def _default_stream_chunk(text: str) -> None:
+ """Write one streamed fragment to the terminal and flush it.
+
+ A module-level function rather than a lambda assigned in the call site:
+ the loop takes a `stream_chunk_fn` seam, and a named default is what a
+ traceback can point at when a stream write misbehaves.
+ """
+ print(text, end="", flush=True)
+
+
def chat_loop(config: dict[str, Any], model=None, tokenizer=None,
adapter_loaded: bool | None = None,
generate_fn=None, stream_fn=None,
@@ -3470,7 +3480,7 @@ def chat_loop(config: dict[str, Any], model=None, tokenizer=None,
loading weights.
"""
if stream_chunk_fn is None:
- stream_chunk_fn = lambda s: print(s, end="", flush=True)
+ stream_chunk_fn = _default_stream_chunk
# Never run with a blank identity: a skipped wizard or a reset config.json
# can leave names empty, which blanks the chat banner and the input prompt.
# Fill sane defaults in-memory now (cheap); persist only on a real CLI run
diff --git a/symbio/app/chat_commands.py b/symbio/app/chat_commands.py
index e254335..c8c69d6 100644
--- a/symbio/app/chat_commands.py
+++ b/symbio/app/chat_commands.py
@@ -1358,7 +1358,7 @@ def _cmd_run(self, shell_cmd: str):
app = _gui_app_for(shell_cmd, output)
if app:
shell_cmd = f"open -a {shlex.quote(app)}"
- self.output_fn(f" [Shell] that names a GUI app; retrying as:")
+ self.output_fn(" [Shell] that names a GUI app; retrying as:")
self.output_fn(f"\n $ {shell_cmd}")
ok, output = sandbox.run_sandboxed(
shell_cmd, self.config, confirm_fn=self.confirm_fn)
diff --git a/symbio/app/chat_style.py b/symbio/app/chat_style.py
index 430c306..2cdfdcb 100644
--- a/symbio/app/chat_style.py
+++ b/symbio/app/chat_style.py
@@ -32,10 +32,19 @@
_GREEN = "\033[38;5;71m"
_YELLOW = "\033[38;5;179m"
_RED = "\033[38;5;167m"
-_ACCENT = "\033[38;5;173m"
+# Hermes Agent's gold, #ffd700 — which is exactly xterm colour 220, so it
+# renders honestly on a 256-colour terminal as well as a truecolour one.
+# It marks what is live: the prompt you type on, the status spinner, the
+# welcome frame. The hex form of the same value lives in
+# symbio_tui/theme.py (the Textual side) and the desktop's :root tokens in
+# symbio_desktop/static/style.css — ANSI-256, hex and CSS are three
+# representations for three media, so the value is repeated, not the meaning.
+_ACCENT = "\033[38;5;220m"
BULLET = "⏺"
ELBOW = "⎿"
+# Hermes Agent's caduceus. The terminal's identity mark, beside the name.
+MARK = "☤"
def colors_enabled(stream=None) -> bool:
@@ -269,7 +278,7 @@ def welcome_panel(config: dict, *, width: int | None = None,
else "/ for commands")
rows = [
("", ()),
- (f"✦ {name} · personal agent", (_BOLD,)),
+ (f"{MARK} {name} · personal agent", (_BOLD,)),
(f"{greeting} What are we working on?", (_DIM,)),
("", ()),
(f"Model {model}", (_GREY,)),
@@ -280,8 +289,8 @@ def welcome_panel(config: dict, *, width: int | None = None,
rows += [("", ()), (menu, (_DIM,))]
# The fixed header is ten cells before the closing corner. Reserve that
# corner explicitly so the frame never autowraps at the right edge.
- lines = ["", _paint("╭─ Symbio " + "─" * max(0, width - 11) + "╮",
- _ACCENT, enabled=on)]
+ lines = ["", _paint(f"╭─ {MARK} Symbio " + "─" * max(0, width - 13) + "╮",
+ _ACCENT, enabled=on)]
edge = _paint("│", _ACCENT, enabled=on)
for text, codes in rows:
body = _fit(text, room)
diff --git a/symbio/app/chat_tools.py b/symbio/app/chat_tools.py
index 46bd459..162686c 100644
--- a/symbio/app/chat_tools.py
+++ b/symbio/app/chat_tools.py
@@ -828,8 +828,8 @@ def _see_screen(self, params: dict[str, Any]) -> str:
self._status(" [Page] Answered from the page's own controls "
"(no screenshot needed).")
lines = [
- f"The page's own controls answer that — no screenshot "
- f"needed, these coordinates and selectors are exact:",
+ "The page's own controls answer that — no screenshot "
+ "needed, these coordinates and selectors are exact:",
*(self._control_line(c) for c in hits[:6]),
]
rest = [c for c in controls if c not in hits]
@@ -1294,12 +1294,10 @@ def _click_element(self, params: dict[str, Any]) -> str:
before = self._ax_state()
clicks = int(params.get("clicks") or 1)
button = str(params.get("button") or "left").lower()
- how = "pressed"
if clicks == 1 and button == "left" and ax.press(element):
out = (f"Pressed {element['label']!r} ({element['role'][2:]}, "
f"element {element['index']}).")
else:
- how = "clicked"
x, y = _ax_centre(element)
out = computer.desktop_click(x, y, clicks=clicks, button=button)
out = (f"{out} That is {element['label']!r} "
@@ -1488,7 +1486,7 @@ def host(url: str) -> str:
if name == "submit_form":
where = host(str(facts.get("url") or ""))
text = str(facts.get("text") or "")
- headline = (f"Submit the form" + (f" on {where}" if where else "")
+ headline = ("Submit the form" + (f" on {where}" if where else "")
+ f" by pressing “{v(arg('target', 'selector'))}”.")
lines = [v(text)] if text else []
if arg("expected_url"):
diff --git a/symbio/app/chat_turn.py b/symbio/app/chat_turn.py
index 4055c92..657bb25 100644
--- a/symbio/app/chat_turn.py
+++ b/symbio/app/chat_turn.py
@@ -210,7 +210,7 @@ def _agent_turn(self, user_input: str):
"new_tokens": timings.get("new_tokens"),
})
else:
- self.output_fn(f" [Canary] OK — the model still follows the system prompt.")
+ self.output_fn(" [Canary] OK — the model still follows the system prompt.")
return
self.history.append({"role": "user", "content": user_input})
diff --git a/symbio/app/ops_eval.py b/symbio/app/ops_eval.py
index 7c5ef18..d987870 100644
--- a/symbio/app/ops_eval.py
+++ b/symbio/app/ops_eval.py
@@ -31,7 +31,11 @@
keep on. A real sandbox-exec / container boundary would be the honest end
state; until then the refusal list errs toward the blunt side.
"""
-import json, re, shutil, subprocess, tempfile
+import json
+import re
+import shutil
+import subprocess
+import tempfile
from pathlib import Path
# Anything that reaches outside the scratch directory or needs privileges.
diff --git a/symbio/app/setup.py b/symbio/app/setup.py
index d3de5ef..f459420 100644
--- a/symbio/app/setup.py
+++ b/symbio/app/setup.py
@@ -199,7 +199,7 @@ def _adapter_lock_step(config: dict[str, Any],
# locked means — so asking "is anything trained" before "is it already
# locked" reads an encrypted adapter as an absent one.
if adapter_crypto.is_locked(adapters):
- output_fn(f"\n Adapter is already locked to a key.")
+ output_fn("\n Adapter is already locked to a key.")
return
if not any(adapters.glob("*.safetensors")):
return # nothing trained yet; nothing to lock
diff --git a/symbio/app/training.py b/symbio/app/training.py
index e1d0495..19a027d 100644
--- a/symbio/app/training.py
+++ b/symbio/app/training.py
@@ -2186,11 +2186,11 @@ def _run_training(config: dict[str, Any], iters: int | None = None,
# because the run otherwise proceeds and teaches turn markers
# this model will never emit.
if not counts["upgraded"]:
- _say(f" [Train] Not one sample matched this model's chat "
- f"template. If you changed models, the corpus still "
- f"belongs to the old one — delete train/valid.jsonl "
- f"and let seeding rebuild it before trusting this "
- f"adapter.")
+ _say(" [Train] Not one sample matched this model's chat "
+ "template. If you changed models, the corpus still "
+ "belongs to the old one — delete train/valid.jsonl "
+ "and let seeding rebuild it before trusting this "
+ "adapter.")
except Exception as e:
_say(f" [Train] Prompt-masking preflight failed ({e}); "
f"training unmasked.")
diff --git a/symbio/computer.py b/symbio/computer.py
index 26d9dbc..7cc48a5 100644
--- a/symbio/computer.py
+++ b/symbio/computer.py
@@ -1105,9 +1105,9 @@ def press(self, key: str) -> str:
page.keyboard.press(normalized)
if normalized == "Enter" and not self._enter_submitted(page):
return (
- f"Press failed: Enter did not submit the form — the "
- f"focused field still contains text, so it likely "
- f"inserted a newline. Click the submit button to submit."
+ "Press failed: Enter did not submit the form — the "
+ "focused field still contains text, so it likely "
+ "inserted a newline. Click the submit button to submit."
)
# cmd+enter, the combination that actually posts on X, reached
# neither check: _enter_submitted is spelled for bare Enter, so
diff --git a/symbio/llm.py b/symbio/llm.py
index 54cb11a..887f870 100644
--- a/symbio/llm.py
+++ b/symbio/llm.py
@@ -134,7 +134,7 @@ def append(samples: list[tuple[str, str]], path: Path) -> int:
("Hello", f"Hello {user}! What can I do for you?"),
("Hey", f"Hey {user}! What's up?"),
("How are you?", f"I'm doing well, thanks for asking, {user}."),
- ("What can you do?", f"I can chat, save memories, read and edit files, search files, run sandboxed commands, execute Python code, search the web, check email, and fine-tune myself on what I learn."),
+ ("What can you do?", "I can chat, save memories, read and edit files, search files, run sandboxed commands, execute Python code, search the web, check email, and fine-tune myself on what I learn."),
# --- Identity: assistant ---
("What is your name?", f"My name is {assistant}."),
("Who are you?", f"I am {assistant}, your personal AI assistant."),
@@ -267,12 +267,12 @@ def append(samples: list[tuple[str, str]], path: Path) -> int:
# --- Email Q&A ---
(
"Can you read my emails?",
- f"I can check your email if you configure IMAP/SMTP via environment variables (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, EMAIL_SMTP_HOST). Once configured, I can list threads, read messages, and send replies.",
+ "I can check your email if you configure IMAP/SMTP via environment variables (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, EMAIL_SMTP_HOST). Once configured, I can list threads, read messages, and send replies.",
),
# --- Domain task ---
(
"How do you read an email inbox?",
- f"I can read an inbox if you give me access. To read email, I need the user's mail credentials or an API key, then I fetch messages, summarize them, and ask before acting.",
+ "I can read an inbox if you give me access. To read email, I need the user's mail credentials or an API key, then I fetch messages, summarize them, and ask before acting.",
),
]
diff --git a/symbio/rag.py b/symbio/rag.py
index a94b1be..84aa5b9 100644
--- a/symbio/rag.py
+++ b/symbio/rag.py
@@ -586,7 +586,7 @@ def build_context(self, query: str) -> str:
role = r.get("role", "?")
header = f"[Past session {ts} / {role}]"
else:
- header = f"[Training sample]"
+ header = "[Training sample]"
body = r["text"].strip().replace("\n", " ")
snippet = f"{header}\n{body}"
tokens = _token_count_approx(snippet)
diff --git a/symbio_tui/app.py b/symbio_tui/app.py
index 8f0a46e..5f2e04b 100644
--- a/symbio_tui/app.py
+++ b/symbio_tui/app.py
@@ -6,6 +6,9 @@
closing this window does not end the session and two of them can watch the
same one.
+The shape is Hermes Agent's and the palette is Symbio's own; see
+symbio_tui/theme.py for why that split is deliberate.
+
Resizing is Textual's to handle and is deliberately not second-guessed here:
every width in the layout is a fraction or a dock, so a narrow window reflows
instead of truncating. The one thing that must not move is the input box,
@@ -23,35 +26,36 @@
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from symbio_tui.protocol import DaemonLink # noqa: E402
+from symbio_tui.theme import DIM, GOLD # noqa: E402
from symbio_tui.widgets import ( # noqa: E402
HOOK, CommandStrip, Composer, Face, History, StatusLine, Suggestions)
# The tag chat_turn prints once a turn: " [Mood: curious]".
_MOOD_RE = re.compile(r"\[Mood:\s*([^\]]+)\]")
-CSS = """
-/* Claude Code's proportions: a quiet header, an elastic transcript, and a
- composer docked to the bottom that never moves at any size. The accent is
- one colour (#d97757) and it is used for exactly three things — the turn
- bullet, the spinner and the box you type in — so what is accented is what
- is live. */
-Screen { layout: vertical; background: $surface; }
-#face { height: auto; color: #d97757; text-align: left; padding: 1 0 0 2; }
-#history { height: 1fr; min-height: 3; border: none; padding: 0 2;
- scrollbar-size-vertical: 1; }
-#commands { height: auto; color: $text-muted; padding: 0 2; }
-#composer { dock: bottom; height: auto; padding: 0 1; }
-#suggestions { max-height: 8; border: round #d97757; display: none; }
-#status { height: auto; padding: 0 1; }
+# A quiet header, an elastic transcript, and a composer docked to the bottom
+# that never moves at any size — Hermes Agent's proportions. The gold marks
+# exactly what is live (the turn bullet, the spinner, the box you type in) and
+# nothing else; the pond and ink are Symbio's own, so the window keeps its
+# identity while the shape is borrowed. See symbio_tui/theme.py.
+CSS = f"""
+Screen {{ layout: vertical; background: $surface; }}
+#face {{ height: auto; color: {GOLD}; text-align: left; padding: 1 0 0 2; }}
+#history {{ height: 1fr; min-height: 3; border: none; padding: 0 2;
+ scrollbar-size-vertical: 1; }}
+#commands {{ height: auto; color: {DIM}; padding: 0 2; }}
+#composer {{ dock: bottom; height: auto; padding: 0 1; }}
+#suggestions {{ max-height: 8; border: round {GOLD}; display: none; }}
+#status {{ height: auto; padding: 0 1; }}
/* The box: the border belongs to the ROW so the chevron sits inside it, the
way a prompt does. An Input that draws its own border can hold nothing but
text. */
-#promptrow { height: auto; border: round #d97757; padding: 0 1; }
-#chevron { width: 2; height: 1; color: #d97757; }
-#prompt { border: none; background: transparent; padding: 0; height: 1;
- width: 1fr; }
-#prompt:focus { border: none; background: transparent; }
-#hints { height: auto; color: $text-muted; padding: 0 1; }
+#promptrow {{ height: auto; border: round {GOLD}; padding: 0 1; }}
+#chevron {{ width: 2; height: 1; color: {GOLD}; }}
+#prompt {{ border: none; background: transparent; padding: 0; height: 1;
+ width: 1fr; }}
+#prompt:focus {{ border: none; background: transparent; }}
+#hints {{ height: auto; color: {DIM}; padding: 0 1; }}
"""
@@ -223,15 +227,14 @@ def action_quit(self) -> None:
def main() -> int:
- """Inline by default, which is the Claude Code shape rather than a
+ """Inline by default, which is the Hermes inline shape rather than a
full-screen app.
An alternate-buffer TUI takes the whole terminal and with it your
scrollback and your native selection: copying a stack trace out of it goes
- through the app's own selection instead of the terminal's. Claude Code does
- not do that, and neither does the chat_style path in this repo, which says
- so in as many words. Inline mode keeps the composer live at the bottom and
- leaves everything above it in ordinary scrollback.
+ through the app's own selection instead of the terminal's. The inline shape
+ keeps the composer live at the bottom and leaves everything above it in
+ ordinary scrollback.
--fullscreen is still there for a dedicated window, where owning the screen
is the point.
diff --git a/symbio_tui/faces.py b/symbio_tui/faces.py
index 2a785a5..8589263 100644
--- a/symbio_tui/faces.py
+++ b/symbio_tui/faces.py
@@ -12,6 +12,8 @@
"""
from __future__ import annotations
+from symbio_tui.theme import MARK
+
# The eleven tags chat_text._VALID_MOODS can produce, plus the two states the
# UI knows about that the model does not emit: working, and not connected.
FACES: dict[str, str] = {
@@ -45,9 +47,17 @@ def face(mood: str | None) -> str:
def banner(mood: str | None, assistant: str = "Symbio", width: int = 80) -> str:
- """The face and its label, or just the face when there is no room."""
+ """The mark, the face and its label — or less, as the room runs out.
+
+ The caduceus leads, because it is the Hermes-shaped identity this terminal
+ borrows; the mood face follows it so the header still moves when the
+ session does. At the narrowest widths only the face survives: a name cut
+ to "Sy…" says less than a face says whole.
+ """
drawn = face(mood)
label = (mood or "neutral").strip().lower()
- if width < 28:
+ if width < 12:
return drawn
- return f"{drawn} {assistant} — {label}"
+ if width < 28:
+ return f"{MARK} {drawn}"
+ return f"{MARK} {drawn} {assistant} — {label}"
diff --git a/symbio_tui/theme.py b/symbio_tui/theme.py
new file mode 100644
index 0000000..aa33263
--- /dev/null
+++ b/symbio_tui/theme.py
@@ -0,0 +1,40 @@
+"""The terminal's palette, in one place.
+
+Two families, one rule. The **gold** marks what is live — the turn bullet, the
+spinner, the box you type in, the caduceus beside the name. Everything else is
+**pond and ink**, read off the desktop window's own `:root` tokens in
+`symbio_desktop/static/style.css`.
+
+Until 2026-10 this terminal was drawn in Claude Code's orange (`#d97757`) in
+seven places, while `symbio_tui/app.py`'s own docstring called it
+"Hermes-shaped". That is the same copy-another-product mistake
+`symbio_desktop/DESIGN.md` records for the desktop window — it wore Claude's
+cream and orange until 2026-09-27, and the note says plainly that it "made this
+window a copy of another product".
+
+So the two halves are deliberate, and they answer two different requests:
+
+* The **shape** is Hermes Agent's, and this terminal says so. `MARK` is the
+ caduceus, `GOLD` is `#ffd700` — Hermes Agent's own gold, the value in its
+ docs badge.
+* The **ground** stays Symbio's, so the window is not a copy of the thing it
+ is modelled on. Pond teal, tabby ink.
+
+`#ffd700` is also xterm colour 220 exactly, so it renders honestly on a
+256-colour terminal as well as a truecolour one.
+"""
+from __future__ import annotations
+
+# Hermes Agent's gold. What is live and what is about to act.
+GOLD = "#ffd700"
+# The same value as an ANSI 256-colour index, for the readline/CLI path.
+GOLD_ANSI = 220
+
+# Symbio's own ground, from the desktop's pond tokens.
+POND = "#58b8aa"
+INK = "#efe7da"
+DIM = "#a39684"
+
+# The identity mark. Hermes Agent's caduceus, which is what "Hermes-shaped"
+# looks like when it is written down.
+MARK = "☤"
diff --git a/symbio_tui/widgets.py b/symbio_tui/widgets.py
index 5f5fd3f..1bd63fc 100644
--- a/symbio_tui/widgets.py
+++ b/symbio_tui/widgets.py
@@ -1,14 +1,17 @@
"""The pieces of the screen, each owning one job.
-The shape is Claude Code's, because that shape has been through more hours of
-real use than anything invented here: a quiet header, a transcript that reads
-as a list of events rather than as a wall of text, a rounded composer docked to
-the bottom with a chevron in it, and one line of hints under it. What the
-transcript gains from that is legibility at a glance — an accent dot for a turn
+The shape is Hermes Agent's: a quiet header, a transcript that reads as a list
+of events rather than as a wall of text, a rounded composer docked to the
+bottom with a chevron in it, and one line of hints under it. What the
+transcript gains from that is legibility at a glance — a gold dot for a turn
the assistant took, a hooked line for what came back out of it, a dim chevron
for what you typed — so a screenful of scrollback can be skimmed for the one
event you are looking for instead of read.
+The palette is not borrowed: gold marks what is live, and the ground stays
+Symbio's own pond and ink. See symbio_tui/theme.py for why that split is the
+whole point.
+
Face on top, conversation in the middle, the composer docked to the bottom.
History is a RichLog because the daemon's frames already carry ANSI — the skin
chat_style puts on a status line is escape codes, and re-parsing them into
@@ -28,8 +31,9 @@
from textual.widgets.option_list import Option
from symbio_tui.faces import banner
+from symbio_tui.theme import DIM, GOLD, MARK
-# The marks Claude Code uses, and what each one means here.
+# The marks Hermes Agent uses, and what each one means here.
# ● a turn the assistant took ⎿ what came back out of it
# > what you typed ✻ the session working
BULLET = "●"
@@ -107,7 +111,7 @@ def say_agent(self, text: str) -> None:
of the paragraph is indented under it so a long answer stays one
visible block rather than merging with what follows."""
lines = (text or "").splitlines() or [""]
- first = Text(f"{BULLET} ", style="bold #d97757")
+ first = Text(f"{BULLET} ", style=f"bold {GOLD}")
first.append(lines[0], style="")
self.write(first)
for line in lines[1:]:
@@ -159,7 +163,7 @@ def _draw(self) -> None:
if not self._busy:
self.update(Text(f" {self._text}", style="dim"))
return
- line = Text(f" {SPINNER[self._frame]} ", style="#d97757")
+ line = Text(f" {SPINNER[self._frame]} ", style=GOLD)
line.append(self._text, style="dim")
line.append(" (esc to interrupt)", style="dim")
self.update(line)
diff --git a/tests/test_adapter_attrib.py b/tests/test_adapter_attrib.py
index 77ff1a9..49acb6f 100644
--- a/tests/test_adapter_attrib.py
+++ b/tests/test_adapter_attrib.py
@@ -331,7 +331,6 @@ def test_realign_is_diagnostic_unless_explicitly_told_otherwise():
"""The model examining itself is useful. The model rewriting its own
weights on its own initiative is not something an injected instruction
should be able to reach, so the default is report-only."""
- import inspect
from symbio.app import chat
diff --git a/tests/test_learn.py b/tests/test_learn.py
index 4cfa0ef..d895447 100644
--- a/tests/test_learn.py
+++ b/tests/test_learn.py
@@ -309,7 +309,6 @@ def _run(session, monkeypatch, compacted=True):
def fake_compact(store, config, summarize_fn=None):
calls.append(store)
- from pathlib import Path
return (f"compacted {store}", Path("/tmp/x") if compacted else None)
monkeypatch.setattr(memory_mod, "compact_store", fake_compact)