fix: card list crash, peer_id propagation, _render name collision, animations, gray theme
This commit is contained in:
parent
0554e856a9
commit
fd7461ddb9
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "honcho-ai-cli"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "A terminal for Honcho — memory that reasons."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Honcho TUI — a Textual interface for Honcho."""
|
||||
|
||||
__version__ = "0.1.1"
|
||||
__version__ = "0.1.2"
|
||||
|
|
|
|||
|
|
@ -201,6 +201,10 @@ class HonchoTUI(App[None]):
|
|||
) -> None:
|
||||
self.active_session = session_id
|
||||
|
||||
# Propagate discovered peer back to app state so queries can use it
|
||||
if peer_id and not self.peer_id:
|
||||
self.peer_id = peer_id
|
||||
|
||||
# Update status bar
|
||||
bar = self.query_one("#status-bar", StatusBar)
|
||||
bar.session_id = session_id
|
||||
|
|
|
|||
|
|
@ -2,24 +2,27 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
# ── Color tokens — cyan/navy to match the honcho CLI aesthetic ────────────────
|
||||
BG = "#0d1117" # near-black with blue cast
|
||||
BG_ELEVATED = "#161b22" # panel header / status bar
|
||||
BG_PANEL = "#0d1117" # side panels
|
||||
BG_INPUT = "#0a0e14" # query input background
|
||||
FG = "#B6DAFD" # periwinkle — matches BRAND in branding.py
|
||||
FG_DIM = "#7aadce" # mid-brightness cyan-blue
|
||||
FG_MUTED = "#3d6480" # dim inactive text
|
||||
BORDER = "#2a4a5a" # panel borders
|
||||
ACCENT = "#56d4dd" # bright cyan — primary accent
|
||||
ACCENT_DIM = "#1e4a55" # dim cyan for inactive states
|
||||
BLUE = "#4169e1" # royal blue
|
||||
# ── Color tokens — kitty gray (warm charcoal, no navy) ────────────────────────
|
||||
BG = "#1a1a1a" # warm dark charcoal
|
||||
BG_ELEVATED = "#242424" # slightly raised surface
|
||||
BG_PANEL = "#1e1e1e" # side panels
|
||||
BG_INPUT = "#141414" # query input
|
||||
FG = "#e2e2e2" # near-white foreground
|
||||
FG_DIM = "#9a9a9a" # mid-gray
|
||||
FG_MUTED = "#555555" # inactive / decorative
|
||||
BORDER = "#333333" # subtle border
|
||||
ACCENT = "#56d4dd" # bright cyan — matches CLI branding
|
||||
ACCENT_DIM = "#1e6e78" # dim cyan
|
||||
BLUE = "#8EA8FF" # indigo accent (workstation shell)
|
||||
CYAN = "#56d4dd"
|
||||
SAGE = "#63D0A6" # green / good
|
||||
RUST = "#F7A072" # warn / orange
|
||||
CLAY = "#D4A373" # secondary warm accent
|
||||
PURPLE = "#bc8cff"
|
||||
GREEN = "#7ee6a8" # good / success
|
||||
WARN = "#e6c855" # warn
|
||||
GREEN = "#63D0A6" # good / success
|
||||
WARN = "#F7A072" # warn
|
||||
CRITICAL = "#FF6B6B" # critical
|
||||
ORANGE = "#e6a855"
|
||||
ORANGE = "#D4A373"
|
||||
|
||||
# ── Spinner frame sets ─────────────────────────────────────────────────────────
|
||||
FRAMES_HELIX: list[str] = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]
|
||||
|
|
@ -35,14 +38,12 @@ CURSOR = "▍"
|
|||
TREE_LAST = "└ "
|
||||
TREE_MID = "├ "
|
||||
SEP = "─"
|
||||
CHEVRON_OPEN = "▾"
|
||||
CHEVRON_CLOSED = "▸"
|
||||
|
||||
# ── TCSS ──────────────────────────────────────────────────────────────────────
|
||||
TCSS = """
|
||||
Screen {
|
||||
background: #0d1117;
|
||||
color: #B6DAFD;
|
||||
background: #1a1a1a;
|
||||
color: #e2e2e2;
|
||||
layers: base overlay;
|
||||
}
|
||||
|
||||
|
|
@ -54,15 +55,15 @@ Screen {
|
|||
|
||||
/* ── LEFT: SESSIONS ── */
|
||||
#sessions-panel {
|
||||
width: 28;
|
||||
background: #0d1117;
|
||||
border-right: solid #2a4a5a;
|
||||
width: 26;
|
||||
background: #1e1e1e;
|
||||
border-right: solid #333333;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
height: 1;
|
||||
background: #161b22;
|
||||
background: #242424;
|
||||
color: #56d4dd;
|
||||
text-style: bold;
|
||||
content-align: left middle;
|
||||
|
|
@ -70,8 +71,9 @@ Screen {
|
|||
}
|
||||
|
||||
ListView {
|
||||
background: #0d1117;
|
||||
background: #1e1e1e;
|
||||
height: 1fr;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ListView:focus {
|
||||
|
|
@ -79,46 +81,59 @@ ListView:focus {
|
|||
}
|
||||
|
||||
ListItem {
|
||||
background: #0d1117;
|
||||
height: 2;
|
||||
background: #1e1e1e;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
color: #3d6480;
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
ListItem:hover {
|
||||
background: #161b22;
|
||||
color: #B6DAFD;
|
||||
background: #242424;
|
||||
color: #e2e2e2;
|
||||
}
|
||||
|
||||
ListItem.--highlight {
|
||||
background: #0f1e2e;
|
||||
color: #B6DAFD;
|
||||
background: #2a2a2a;
|
||||
color: #56d4dd;
|
||||
}
|
||||
|
||||
/* ── CENTER ── */
|
||||
#center {
|
||||
width: 1fr;
|
||||
layout: vertical;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
/* ── WAVEFORM ── */
|
||||
WaveformWidget {
|
||||
height: 1;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
SineWaveWidget {
|
||||
height: 1;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
/* ── TRANSCRIPT LOG ── */
|
||||
RichLog {
|
||||
height: 1fr;
|
||||
background: #0d1117;
|
||||
padding: 0 1;
|
||||
scrollbar-color: #2a4a5a;
|
||||
scrollbar-background: #0d1117;
|
||||
scrollbar-corner-color: #0d1117;
|
||||
background: #1a1a1a;
|
||||
padding: 0 2;
|
||||
scrollbar-color: #333333;
|
||||
scrollbar-background: #1a1a1a;
|
||||
scrollbar-corner-color: #1a1a1a;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ── QUERY BAR ── */
|
||||
#query-bar {
|
||||
height: 3;
|
||||
background: #0a0e14;
|
||||
border-top: solid #2a4a5a;
|
||||
background: #141414;
|
||||
border-top: solid #333333;
|
||||
layout: horizontal;
|
||||
align: left middle;
|
||||
padding: 0 1;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
#query-prefix {
|
||||
|
|
@ -130,8 +145,8 @@ RichLog {
|
|||
|
||||
#query-input {
|
||||
width: 1fr;
|
||||
background: #0a0e14;
|
||||
color: #B6DAFD;
|
||||
background: #141414;
|
||||
color: #e2e2e2;
|
||||
border: none;
|
||||
padding: 0 0;
|
||||
}
|
||||
|
|
@ -142,18 +157,25 @@ RichLog {
|
|||
|
||||
/* ── RIGHT: PEER PANEL ── */
|
||||
#peer-panel {
|
||||
width: 34;
|
||||
background: #0d1117;
|
||||
border-left: solid #2a4a5a;
|
||||
width: 32;
|
||||
background: #1e1e1e;
|
||||
border-left: solid #333333;
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
FreqBarsWidget {
|
||||
height: 4;
|
||||
background: #1e1e1e;
|
||||
padding: 0 1;
|
||||
border-bottom: solid #333333;
|
||||
}
|
||||
|
||||
#peer-scroll {
|
||||
height: 1fr;
|
||||
background: #0d1117;
|
||||
background: #1e1e1e;
|
||||
padding: 0 1;
|
||||
scrollbar-color: #2a4a5a;
|
||||
scrollbar-background: #0d1117;
|
||||
scrollbar-color: #333333;
|
||||
scrollbar-background: #1e1e1e;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
|
|
@ -164,11 +186,11 @@ RichLog {
|
|||
}
|
||||
|
||||
.card-body {
|
||||
color: #7aadce;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.conclusion-row {
|
||||
color: #7aadce;
|
||||
color: #9a9a9a;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
|
@ -180,34 +202,27 @@ RichLog {
|
|||
StatusBar {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
background: #161b22;
|
||||
color: #3d6480;
|
||||
layout: horizontal;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
/* ── SPINNER ── */
|
||||
.spinner {
|
||||
color: #56d4dd;
|
||||
width: 1;
|
||||
background: #242424;
|
||||
color: #555555;
|
||||
padding: 0 2;
|
||||
}
|
||||
|
||||
/* ── COLLAPSIBLE ── */
|
||||
Collapsible {
|
||||
background: #0d1117;
|
||||
background: #1e1e1e;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
CollapsibleTitle {
|
||||
color: #3d6480;
|
||||
background: #0d1117;
|
||||
color: #555555;
|
||||
background: #1e1e1e;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
CollapsibleTitle:hover {
|
||||
color: #B6DAFD;
|
||||
background: #161b22;
|
||||
color: #e2e2e2;
|
||||
background: #242424;
|
||||
}
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,195 @@
|
|||
"""Animated display widgets: waveform, frequency bars, matrix cascade.
|
||||
|
||||
IMPORTANT: All Static subclasses here must:
|
||||
1. Call super().__init__("", ...) to set initial content before first render
|
||||
2. Never define a method named _render() — that's a Textual internal
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widgets import Static
|
||||
|
||||
from honcho_tui.theme import ACCENT, CYAN, FG_DIM, FG_MUTED
|
||||
|
||||
BARS = " ▁▂▃▄▅▆▇█"
|
||||
BLOCK = "█"
|
||||
HALF_BOT = "▄"
|
||||
_MC = "アイウエオカキクケコサシスセソタチツテト0123456789ABCDEF◆◇○●"
|
||||
|
||||
|
||||
class WaveformWidget(Static):
|
||||
"""Scrolling dual-sine waveform using Unicode block characters."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
WaveformWidget {
|
||||
height: 1;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
"""
|
||||
|
||||
_phase: float = 0.0
|
||||
_noise: list[float] = []
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__("", **kwargs)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._noise = [random.random() for _ in range(256)]
|
||||
self.set_interval(0.07, self._advance)
|
||||
|
||||
def _advance(self) -> None:
|
||||
self._phase += 0.20
|
||||
width = max(self.size.width, 40) if self.size.width > 0 else 60
|
||||
t = Text()
|
||||
for i in range(width):
|
||||
s1 = math.sin(self._phase + i * 0.28)
|
||||
s2 = math.sin(self._phase * 0.6 + i * 0.45 + 1.7)
|
||||
n = self._noise[(i + int(self._phase * 5)) % len(self._noise)]
|
||||
val = (s1 * 0.5 + s2 * 0.3 + n * 0.2 + 1.0) / 2.0
|
||||
idx = int(val * (len(BARS) - 1))
|
||||
bar = BARS[max(0, min(idx, len(BARS) - 1))]
|
||||
if val > 0.85:
|
||||
style = ACCENT
|
||||
elif val > 0.65:
|
||||
style = CYAN
|
||||
elif val > 0.35:
|
||||
style = FG_DIM
|
||||
else:
|
||||
style = FG_MUTED
|
||||
t.append(bar, style=style)
|
||||
self.update(t)
|
||||
|
||||
|
||||
class FreqBarsWidget(Static):
|
||||
"""Animated vertical frequency bars — sci-fi instrument readout."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
FreqBarsWidget {
|
||||
height: 4;
|
||||
background: #1e1e1e;
|
||||
padding: 0 1;
|
||||
border-bottom: solid #333333;
|
||||
}
|
||||
"""
|
||||
|
||||
NUM_BARS = 14
|
||||
MAX_H = 4.0
|
||||
|
||||
_heights: list[float] = []
|
||||
_targets: list[float] = []
|
||||
_phase: float = 0.0
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__("", **kwargs)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._heights = [random.random() * self.MAX_H for _ in range(self.NUM_BARS)]
|
||||
self._targets = list(self._heights)
|
||||
self.set_interval(0.09, self._advance)
|
||||
|
||||
def _advance(self) -> None:
|
||||
self._phase += 0.10
|
||||
for i in range(self.NUM_BARS):
|
||||
self._heights[i] += (self._targets[i] - self._heights[i]) * 0.30
|
||||
if random.random() < 0.13:
|
||||
sine = (math.sin(self._phase + i * 0.55) + 1) / 2
|
||||
self._targets[i] = sine * self.MAX_H * 0.65 + random.random() * self.MAX_H * 0.35
|
||||
self._draw()
|
||||
|
||||
def _draw(self) -> None:
|
||||
rows: list[Text] = []
|
||||
max_r = int(self.MAX_H)
|
||||
for row in range(max_r, 0, -1):
|
||||
t = Text()
|
||||
for h in self._heights:
|
||||
frac = h - (row - 1)
|
||||
if frac >= 1.0:
|
||||
style = ACCENT if row == max_r else (CYAN if row >= max_r - 1 else FG_DIM)
|
||||
t.append(BLOCK, style=style)
|
||||
elif frac >= 0.5:
|
||||
t.append(HALF_BOT, style=FG_DIM)
|
||||
else:
|
||||
t.append(" ")
|
||||
t.append(" ")
|
||||
rows.append(t)
|
||||
|
||||
combined = Text()
|
||||
for line in rows:
|
||||
combined.append_text(line)
|
||||
combined.append("\n")
|
||||
self.update(combined)
|
||||
|
||||
|
||||
class MatrixCascadeWidget(Static):
|
||||
"""Falling character cascade for loading states."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
MatrixCascadeWidget {
|
||||
height: 5;
|
||||
background: #1a1a1a;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
COLS = 10
|
||||
ROWS = 5
|
||||
_grid: list[list[str]] = []
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__("", **kwargs)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._grid = [
|
||||
[random.choice(_MC) for _ in range(self.COLS)]
|
||||
for _ in range(self.ROWS)
|
||||
]
|
||||
self.set_interval(0.13, self._advance)
|
||||
|
||||
def _advance(self) -> None:
|
||||
self._grid.pop()
|
||||
self._grid.insert(0, [random.choice(_MC) for _ in range(self.COLS)])
|
||||
t = Text()
|
||||
for r_idx, row in enumerate(self._grid):
|
||||
fade = r_idx / max(1, self.ROWS - 1)
|
||||
style = ACCENT if fade < 0.2 else (CYAN if fade < 0.45 else (FG_DIM if fade < 0.75 else FG_MUTED))
|
||||
for ch in row:
|
||||
t.append(ch, style=style)
|
||||
t.append(" ")
|
||||
t.append("\n")
|
||||
self.update(t)
|
||||
|
||||
|
||||
class SineWaveWidget(Static):
|
||||
"""Horizontal sine wave using dot characters."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SineWaveWidget {
|
||||
height: 1;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
"""
|
||||
|
||||
_CHARS = ["·", "∘", "○", "⊙", "●", "⊙", "○", "∘"]
|
||||
_phase: float = 0.0
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__("", **kwargs)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.set_interval(0.09, self._advance)
|
||||
|
||||
def _advance(self) -> None:
|
||||
self._phase += 0.22
|
||||
width = max(self.size.width, 40) if self.size.width > 0 else 60
|
||||
t = Text()
|
||||
for i in range(width):
|
||||
val = (math.sin(self._phase + i * 0.35) + 1) / 2
|
||||
idx = int(val * (len(self._CHARS) - 1))
|
||||
ch = self._CHARS[idx]
|
||||
style = ACCENT if val > 0.8 else (CYAN if val > 0.55 else FG_MUTED)
|
||||
t.append(ch, style=style)
|
||||
self.update(t)
|
||||
|
|
@ -11,7 +11,6 @@ from textual.containers import VerticalScroll
|
|||
from honcho_tui.theme import (
|
||||
ACCENT,
|
||||
ACCENT_DIM,
|
||||
CRITICAL,
|
||||
DOT,
|
||||
DOT_EMPTY,
|
||||
FG,
|
||||
|
|
@ -23,10 +22,22 @@ from honcho_tui.theme import (
|
|||
TREE_MID,
|
||||
WARN,
|
||||
)
|
||||
from honcho_tui.widgets.animations import FreqBarsWidget
|
||||
|
||||
|
||||
def _card_to_str(card) -> str:
|
||||
"""Normalize whatever get_card() returns to a plain string."""
|
||||
if card is None:
|
||||
return ""
|
||||
if isinstance(card, str):
|
||||
return card
|
||||
if isinstance(card, list):
|
||||
return "\n".join(str(item) for item in card)
|
||||
return str(card)
|
||||
|
||||
|
||||
class PeerPanel(Widget):
|
||||
"""Right-side panel: peer card + conclusions + queue status."""
|
||||
"""Right-side panel: freq bars + peer card + conclusions + queue."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
PeerPanel {
|
||||
|
|
@ -40,7 +51,8 @@ class PeerPanel(Widget):
|
|||
_loading: bool = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(f" [{ACCENT}]PEER[/{ACCENT}]", classes="panel-title")
|
||||
yield Static(f" [{ACCENT}]PEER[/{ACCENT}]", classes="panel-title", markup=True)
|
||||
yield FreqBarsWidget(id="freq-bars")
|
||||
yield VerticalScroll(id="peer-scroll")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
|
|
@ -63,20 +75,19 @@ class PeerPanel(Widget):
|
|||
def _show_empty(self) -> None:
|
||||
scroll = self.query_one("#peer-scroll", VerticalScroll)
|
||||
scroll.remove_children()
|
||||
scroll.mount(Static(f"\n [{FG_DIM}]select a session[/{FG_DIM}]"))
|
||||
scroll.mount(Static(f"\n [{FG_DIM}]select a session[/{FG_DIM}]", markup=True))
|
||||
|
||||
def load_peer(
|
||||
self,
|
||||
peer_id: str,
|
||||
card: str | None,
|
||||
card,
|
||||
conclusions: list[dict],
|
||||
queue: dict | None,
|
||||
) -> None:
|
||||
"""Populate panel with peer data (called from main thread)."""
|
||||
self.set_loading(False)
|
||||
|
||||
# Update header with peer ID
|
||||
short_pid = peer_id[:18] + "…" if len(peer_id) > 18 else peer_id
|
||||
short_pid = peer_id[:16] + "…" if len(peer_id) > 16 else peer_id
|
||||
header = self.query_one(".panel-title", Static)
|
||||
header.update(f" [{ACCENT}]PEER[/{ACCENT}] [{FG_DIM}]{short_pid}[/{FG_DIM}]")
|
||||
|
||||
|
|
@ -84,64 +95,63 @@ class PeerPanel(Widget):
|
|||
scroll.remove_children()
|
||||
|
||||
# ── Card ─────────────────────────────────────────────────────────────
|
||||
scroll.mount(Static(f"[{ACCENT}]CARD[/{ACCENT}]", classes="section-label"))
|
||||
scroll.mount(Static(f"[{ACCENT}]CARD[/{ACCENT}]", classes="section-label", markup=True))
|
||||
|
||||
if card and card.strip():
|
||||
raw = card.strip()
|
||||
preview = markup_escape(raw[:400])
|
||||
if len(raw) > 400:
|
||||
preview += f"\n[{FG_MUTED}]… {len(raw) - 400} more chars[/{FG_MUTED}]"
|
||||
card_str = _card_to_str(card).strip()
|
||||
if card_str:
|
||||
preview = markup_escape(card_str[:380])
|
||||
if len(card_str) > 380:
|
||||
preview += f"\n[{FG_MUTED}]… {len(card_str) - 380} more[/{FG_MUTED}]"
|
||||
scroll.mount(Static(preview, classes="card-body", markup=True))
|
||||
else:
|
||||
scroll.mount(Static(f"[{FG_DIM}]no card yet[/{FG_DIM}]", classes="card-body"))
|
||||
scroll.mount(Static(f"[{FG_DIM}]no card yet[/{FG_DIM}]", classes="card-body", markup=True))
|
||||
|
||||
# ── Conclusions ───────────────────────────────────────────────────────
|
||||
count = len(conclusions)
|
||||
scroll.mount(Static(f"\n[{ACCENT}]CONCLUSIONS[/{ACCENT}] [{FG_MUTED}]({count})[/{FG_MUTED}]", classes="section-label"))
|
||||
scroll.mount(Static(
|
||||
f"\n[{ACCENT}]CONCLUSIONS[/{ACCENT}] [{FG_MUTED}]({count})[/{FG_MUTED}]",
|
||||
classes="section-label", markup=True,
|
||||
))
|
||||
|
||||
if not conclusions:
|
||||
scroll.mount(Static(f"[{FG_DIM}]no conclusions yet[/{FG_DIM}]", classes="card-body"))
|
||||
scroll.mount(Static(f"[{FG_DIM}]no conclusions yet[/{FG_DIM}]", classes="card-body", markup=True))
|
||||
else:
|
||||
for i, c in enumerate(conclusions[:12]):
|
||||
content = c.get("content", "")
|
||||
safe = markup_escape(content[:55] + "…" if len(content) > 55 else content)
|
||||
safe = markup_escape(content[:54] + "…" if len(content) > 54 else content)
|
||||
prefix = TREE_LAST if i == min(len(conclusions), 12) - 1 else TREE_MID
|
||||
scroll.mount(
|
||||
Static(
|
||||
f"[{FG_DIM}]{prefix}[/{FG_DIM}][{FG}]{safe}[/{FG}]",
|
||||
classes="conclusion-row",
|
||||
markup=True,
|
||||
)
|
||||
)
|
||||
scroll.mount(Static(
|
||||
f"[{FG_DIM}]{prefix}[/{FG_DIM}][{FG}]{safe}[/{FG}]",
|
||||
classes="conclusion-row", markup=True,
|
||||
))
|
||||
if count > 12:
|
||||
scroll.mount(Static(f"[{FG_MUTED}] … {count - 12} more[/{FG_MUTED}]"))
|
||||
scroll.mount(Static(f"[{FG_MUTED}] … {count - 12} more[/{FG_MUTED}]", markup=True))
|
||||
|
||||
# ── Queue ─────────────────────────────────────────────────────────────
|
||||
scroll.mount(Static(f"\n[{ACCENT}]QUEUE[/{ACCENT}]", classes="section-label"))
|
||||
scroll.mount(Static(f"\n[{ACCENT}]QUEUE[/{ACCENT}]", classes="section-label", markup=True))
|
||||
|
||||
if queue is None:
|
||||
scroll.mount(Static(f"[{FG_DIM}]unavailable[/{FG_DIM}]"))
|
||||
scroll.mount(Static(f"[{FG_DIM}]unavailable[/{FG_DIM}]", markup=True))
|
||||
else:
|
||||
pending = queue.get("pending", 0)
|
||||
running = queue.get("running", 0)
|
||||
completed = queue.get("completed", 0)
|
||||
pending = queue.get("pending", 0) or 0
|
||||
running = queue.get("running", 0) or 0
|
||||
completed = queue.get("completed", 0) or 0
|
||||
|
||||
p_dot = f"[{WARN}]{DOT}[/{WARN}]" if pending > 0 else f"[{FG_MUTED}]{DOT_EMPTY}[/{FG_MUTED}]"
|
||||
r_dot = f"[{GREEN}]{DOT}[/{GREEN}]" if running > 0 else f"[{FG_MUTED}]{DOT_EMPTY}[/{FG_MUTED}]"
|
||||
d_dot = f"[{FG_DIM}]{DOT}[/{FG_DIM}]"
|
||||
|
||||
scroll.mount(Static(
|
||||
f"{p_dot} [{FG_DIM}]{pending} pending[/{FG_DIM}] "
|
||||
f"{r_dot} [{FG_DIM}]{running} running[/{FG_DIM}]",
|
||||
classes="queue-row",
|
||||
f"{p_dot} [{FG_DIM}]{pending} pending[/{FG_DIM}] {r_dot} [{FG_DIM}]{running} running[/{FG_DIM}]",
|
||||
classes="queue-row", markup=True,
|
||||
))
|
||||
scroll.mount(Static(
|
||||
f"{d_dot} [{FG_MUTED}]{completed} done[/{FG_MUTED}]",
|
||||
classes="queue-row",
|
||||
f"[{FG_MUTED}]{DOT} {completed} done[/{FG_MUTED}]",
|
||||
classes="queue-row", markup=True,
|
||||
))
|
||||
|
||||
def show_error(self, msg: str) -> None:
|
||||
self.set_loading(False)
|
||||
scroll = self.query_one("#peer-scroll", VerticalScroll)
|
||||
scroll.remove_children()
|
||||
scroll.mount(Static(f"\n[red]{msg}[/red]"))
|
||||
safe = markup_escape(msg[:80])
|
||||
scroll.mount(Static(f"\n[red]{safe}[/red]", markup=True))
|
||||
|
|
|
|||
|
|
@ -1,26 +1,24 @@
|
|||
"""Bottom status bar: workspace, peer, queue summary, uptime."""
|
||||
"""Bottom status bar: workspace, peer, queue summary, uptime, pulsing dot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.widget import Widget
|
||||
from textual.app import ComposeResult
|
||||
from textual.widgets import Static
|
||||
from rich.text import Text
|
||||
from textual.reactive import reactive
|
||||
from textual.widgets import Static
|
||||
|
||||
from honcho_tui.theme import ACCENT, FG_DIM, FG_MUTED, GREEN, WARN, DOT
|
||||
from honcho_tui.theme import ACCENT, DOT, DOT_EMPTY, FG_DIM, FG_MUTED, GREEN, WARN
|
||||
|
||||
|
||||
class StatusBar(Widget):
|
||||
"""Fixed bottom bar showing workspace, peer, queue, and uptime."""
|
||||
class StatusBar(Static):
|
||||
"""Fixed bottom bar — rendered as a single Rich Text line."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
StatusBar {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
layout: horizontal;
|
||||
background: #141820;
|
||||
color: #3d6480;
|
||||
padding: 0 1;
|
||||
background: #242424;
|
||||
color: #555555;
|
||||
padding: 0 2;
|
||||
}
|
||||
"""
|
||||
|
||||
|
|
@ -31,64 +29,69 @@ class StatusBar(Widget):
|
|||
running: reactive[int] = reactive(0)
|
||||
uptime: reactive[int] = reactive(0)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("", id="status-content")
|
||||
_pulse_on: bool = True
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._render_status()
|
||||
self.set_interval(0.7, self._pulse)
|
||||
self._update_content()
|
||||
|
||||
def watch_workspace_id(self, value: str) -> None:
|
||||
self._render_status()
|
||||
def _pulse(self) -> None:
|
||||
self._pulse_on = not self._pulse_on
|
||||
self._update_content()
|
||||
|
||||
def watch_peer_id(self, value: str) -> None:
|
||||
self._render_status()
|
||||
def watch_workspace_id(self, _: str) -> None:
|
||||
self._update_content()
|
||||
|
||||
def watch_session_id(self, value: str) -> None:
|
||||
self._render_status()
|
||||
def watch_peer_id(self, _: str) -> None:
|
||||
self._update_content()
|
||||
|
||||
def watch_pending(self, value: int) -> None:
|
||||
self._render_status()
|
||||
def watch_session_id(self, _: str) -> None:
|
||||
self._update_content()
|
||||
|
||||
def watch_running(self, value: int) -> None:
|
||||
self._render_status()
|
||||
def watch_pending(self, _: int) -> None:
|
||||
self._update_content()
|
||||
|
||||
def watch_uptime(self, value: int) -> None:
|
||||
self._render_status()
|
||||
def watch_running(self, _: int) -> None:
|
||||
self._update_content()
|
||||
|
||||
def _render_status(self) -> None:
|
||||
try:
|
||||
content = self.query_one("#status-content", Static)
|
||||
except Exception:
|
||||
return
|
||||
def watch_uptime(self, _: int) -> None:
|
||||
self._update_content()
|
||||
|
||||
parts: list[str] = []
|
||||
def _update_content(self) -> None:
|
||||
t = Text(no_wrap=True, overflow="ellipsis")
|
||||
|
||||
# Pulsing connection dot
|
||||
conn_style = ACCENT if self._pulse_on else FG_MUTED
|
||||
t.append(f"{DOT} ", style=conn_style)
|
||||
|
||||
if self.workspace_id:
|
||||
ws = self.workspace_id[:16] + "…" if len(self.workspace_id) > 16 else self.workspace_id
|
||||
parts.append(f"[{ACCENT}]ws[/{ACCENT}] {ws}")
|
||||
ws = self.workspace_id[:14] + "…" if len(self.workspace_id) > 14 else self.workspace_id
|
||||
t.append("ws ", style=ACCENT)
|
||||
t.append(f"{ws} ", style=FG_DIM)
|
||||
else:
|
||||
parts.append(f"[{FG_MUTED}]no workspace[/{FG_MUTED}]")
|
||||
t.append("no workspace ", style=FG_MUTED)
|
||||
|
||||
if self.peer_id:
|
||||
p = self.peer_id[:14] + "…" if len(self.peer_id) > 14 else self.peer_id
|
||||
parts.append(f"[{FG_DIM}]peer[/{FG_DIM}] {p}")
|
||||
p = self.peer_id[:12] + "…" if len(self.peer_id) > 12 else self.peer_id
|
||||
t.append("peer ", style=FG_DIM)
|
||||
t.append(f"{p} ", style=FG_DIM)
|
||||
|
||||
if self.session_id:
|
||||
s = self.session_id[:12] + "…" if len(self.session_id) > 12 else self.session_id
|
||||
parts.append(f"[{FG_DIM}]sess[/{FG_DIM}] {s}")
|
||||
t.append("sess ", style=FG_DIM)
|
||||
t.append(f"{s} ", style=FG_DIM)
|
||||
|
||||
# Queue
|
||||
if self.running > 0:
|
||||
parts.append(f"[{GREEN}]{DOT}[/{GREEN}] [{FG_DIM}]{self.running} running[/{FG_DIM}]")
|
||||
t.append(f"{DOT} ", style=GREEN)
|
||||
t.append(f"{self.running} running ", style=FG_DIM)
|
||||
if self.pending > 0:
|
||||
parts.append(f"[{WARN}]{DOT}[/{WARN}] [{FG_DIM}]{self.pending} pending[/{FG_DIM}]")
|
||||
t.append(f"{DOT} ", style=WARN)
|
||||
t.append(f"{self.pending} pending ", style=FG_DIM)
|
||||
|
||||
# Uptime (right-aligned via spacer)
|
||||
h = self.uptime // 3600
|
||||
m = (self.uptime % 3600) // 60
|
||||
s = self.uptime % 60
|
||||
uptime_str = f"{h:02d}:{m:02d}:{s:02d}"
|
||||
total = int(self.uptime)
|
||||
h = total // 3600
|
||||
m = (total % 3600) // 60
|
||||
s_val = total % 60
|
||||
t.append(f"{h:02d}:{m:02d}:{s_val:02d}", style=FG_MUTED)
|
||||
|
||||
sep = f" [{FG_MUTED}]·[/{FG_MUTED}] "
|
||||
left = sep.join(parts)
|
||||
content.update(f"{left} [{FG_MUTED}]{uptime_str}[/{FG_MUTED}]")
|
||||
self.update(t)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||
|
||||
import datetime
|
||||
|
||||
from rich.markup import escape as markup_escape
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
|
|
@ -13,16 +12,15 @@ from textual.widgets import Input, RichLog, Static
|
|||
|
||||
from honcho_tui.theme import (
|
||||
ACCENT,
|
||||
ACCENT_DIM,
|
||||
BLUE,
|
||||
CURSOR,
|
||||
FG,
|
||||
FG_DIM,
|
||||
FG_MUTED,
|
||||
FRAMES_HELIX,
|
||||
GREEN,
|
||||
ORANGE,
|
||||
RUST,
|
||||
)
|
||||
from honcho_tui.widgets.animations import WaveformWidget
|
||||
|
||||
|
||||
class QuerySubmitted(Message):
|
||||
|
|
@ -34,7 +32,7 @@ class QuerySubmitted(Message):
|
|||
|
||||
|
||||
class TranscriptPanel(Widget):
|
||||
"""Center pane: message log above, query input below."""
|
||||
"""Center pane: waveform header + message log + query input."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
TranscriptPanel {
|
||||
|
|
@ -46,31 +44,22 @@ class TranscriptPanel(Widget):
|
|||
|
||||
_frame_idx: int = 0
|
||||
_streaming: bool = False
|
||||
_cursor_on: bool = True
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield RichLog(highlight=False, markup=True, wrap=True, id="log")
|
||||
yield WaveformWidget(id="transcript-wave")
|
||||
yield RichLog(highlight=False, markup=False, wrap=True, id="log")
|
||||
with Widget(id="query-bar"):
|
||||
yield Static(f"[{ACCENT}]>[/{ACCENT}]", id="query-prefix")
|
||||
yield Input(
|
||||
placeholder="query dialectic…",
|
||||
id="query-input",
|
||||
)
|
||||
yield Static(f"[{ACCENT}]>[/{ACCENT}]", id="query-prefix", markup=True)
|
||||
yield Input(placeholder="query dialectic…", id="query-input")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._spinner_timer = self.set_interval(0.08, self._tick)
|
||||
self._cursor_timer = self.set_interval(0.42, self._blink_cursor)
|
||||
self.show_welcome()
|
||||
|
||||
# ── Timers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _tick(self) -> None:
|
||||
if self._streaming:
|
||||
self._frame_idx = (self._frame_idx + 1) % len(FRAMES_HELIX)
|
||||
|
||||
def _blink_cursor(self) -> None:
|
||||
self._cursor_on = not self._cursor_on
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def show_welcome(self) -> None:
|
||||
|
|
@ -82,13 +71,12 @@ class TranscriptPanel(Widget):
|
|||
t.append(" select a session from the left panel\n", style=FG_DIM)
|
||||
t.append(" or query the dialectic below\n\n", style=FG_DIM)
|
||||
t.append(" bindings\n", style=f"bold {FG_DIM}")
|
||||
binds = [
|
||||
for key, desc in [
|
||||
("ctrl+d", "focus dialectic input"),
|
||||
("tab", "focus session list"),
|
||||
("r", "refresh"),
|
||||
("q", "quit"),
|
||||
]
|
||||
for key, desc in binds:
|
||||
]:
|
||||
t.append(f" {key}", style=f"bold {ACCENT}")
|
||||
t.append(f" {desc}\n", style=FG_MUTED)
|
||||
log.write(t)
|
||||
|
|
@ -97,7 +85,7 @@ class TranscriptPanel(Widget):
|
|||
log = self.query_one("#log", RichLog)
|
||||
log.clear()
|
||||
t = Text()
|
||||
t.append("\n no workspace configured\n\n", style=f"bold {ORANGE}")
|
||||
t.append("\n no workspace configured\n\n", style=f"bold {RUST}")
|
||||
t.append(" set HONCHO_WORKSPACE_ID or pass --workspace\n", style=FG_DIM)
|
||||
t.append(" run 'honcho init' to configure\n", style=FG_DIM)
|
||||
log.write(t)
|
||||
|
|
@ -121,16 +109,14 @@ class TranscriptPanel(Widget):
|
|||
log = self.query_one("#log", RichLog)
|
||||
log.clear()
|
||||
|
||||
# Header
|
||||
short_sid = session_id[:28] + "…" if len(session_id) > 28 else session_id
|
||||
short_sid = session_id[:32] + "…" if len(session_id) > 32 else session_id
|
||||
header = Text()
|
||||
header.append(f" session ", style=FG_MUTED)
|
||||
header.append(short_sid, style=f"bold {FG}")
|
||||
header.append(f"\n {'─' * 50}\n", style=FG_MUTED)
|
||||
header.append(f"\n {short_sid}\n", style=f"bold {FG}")
|
||||
header.append(f" {'─' * 40}\n\n", style=FG_MUTED)
|
||||
log.write(header)
|
||||
|
||||
if not messages:
|
||||
log.write(Text(f" [{FG_DIM}]no messages[/{FG_DIM}]\n"))
|
||||
log.write(Text(" no messages\n", style=FG_DIM))
|
||||
return
|
||||
|
||||
for msg in messages:
|
||||
|
|
@ -141,7 +127,6 @@ class TranscriptPanel(Widget):
|
|||
content = msg.get("content", "")
|
||||
created_at = msg.get("created_at")
|
||||
|
||||
# Timestamp
|
||||
ts = ""
|
||||
if created_at:
|
||||
try:
|
||||
|
|
@ -150,14 +135,12 @@ class TranscriptPanel(Widget):
|
|||
except Exception:
|
||||
ts = str(created_at)[:5]
|
||||
|
||||
# Header line
|
||||
header = Text()
|
||||
header.append(f" {ts} ", style=FG_MUTED)
|
||||
header.append(f" {ts} ", style=FG_MUTED)
|
||||
header.append(peer_id, style=f"bold {BLUE}")
|
||||
header.append("\n")
|
||||
log.write(header)
|
||||
|
||||
# Content — escape raw text so Rich markup chars don't break rendering
|
||||
body = Text()
|
||||
for line in content.splitlines():
|
||||
body.append(" ", style=FG)
|
||||
|
|
@ -169,20 +152,19 @@ class TranscriptPanel(Widget):
|
|||
log.write(Text("\n"))
|
||||
|
||||
def append_response(self, label: str, content: str, is_query: bool = False) -> None:
|
||||
"""Append a query + response pair to the log."""
|
||||
log = self.query_one("#log", RichLog)
|
||||
self._streaming = False
|
||||
|
||||
if is_query:
|
||||
q_text = Text()
|
||||
q_text.append(f" > ", style=f"bold {ACCENT}")
|
||||
q_text.append(f"{label}\n\n", style=f"bold {FG}")
|
||||
log.write(q_text)
|
||||
q = Text()
|
||||
q.append(f" > ", style=f"bold {ACCENT}")
|
||||
q.append(f"{label}\n\n", style=f"bold {FG}")
|
||||
log.write(q)
|
||||
|
||||
resp = Text()
|
||||
resp.append(" dialectic\n", style=f"bold {GREEN}")
|
||||
for line in content.splitlines():
|
||||
resp.append(" ", style=FG)
|
||||
resp.append(" ")
|
||||
resp.append(line, style=FG)
|
||||
resp.append("\n")
|
||||
resp.append("\n")
|
||||
|
|
@ -192,15 +174,13 @@ class TranscriptPanel(Widget):
|
|||
self._streaming = False
|
||||
log = self.query_one("#log", RichLog)
|
||||
t = Text()
|
||||
t.append(f"\n error ", style="bold red")
|
||||
t.append(f"{msg}\n", style=FG_DIM)
|
||||
t.append(f"\n error\n", style="bold red")
|
||||
t.append(f" {msg}\n", style=FG_DIM)
|
||||
log.write(t)
|
||||
|
||||
def focus_input(self) -> None:
|
||||
self.query_one("#query-input", Input).focus()
|
||||
|
||||
# ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
query = event.value.strip()
|
||||
if not query:
|
||||
|
|
|
|||
Loading…
Reference in New Issue