fix: card list crash, peer_id propagation, _render name collision, animations, gray theme

This commit is contained in:
Erosika 2026-04-15 17:53:32 -04:00
parent 0554e856a9
commit fd7461ddb9
8 changed files with 401 additions and 194 deletions

View File

@ -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"

View File

@ -1,3 +1,3 @@
"""Honcho TUI — a Textual interface for Honcho."""
__version__ = "0.1.1"
__version__ = "0.1.2"

View File

@ -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

View File

@ -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;
}
"""

View File

@ -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)

View File

@ -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))

View File

@ -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)

View File

@ -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: