"""The pieces the window is built from: messages, tool cards, the composer, the header controls and the empty state. Styling lives in theme.stylesheet(); these set object names and properties for it to hook onto.""" from __future__ import annotations import html import math import re from PySide6.QtCore import QEvent, QRectF, QSize, Qt, QTimer, Signal from PySide6.QtGui import QColor, QFontDatabase, QPainter, QTextCursor, QTextDocument, QTextOption from PySide6.QtWidgets import ( QButtonGroup, QFrame, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QSizePolicy, QVBoxLayout, QWidget, ) from . import icons, theme # Tool name (as tools.py reports it) -> icon. TOOL_ICONS = { "Shell": "shell", "Read": "read", "List": "list", "Write": "write", "Edit": "edit", "Pet": "blocked", "File": "file", } COLLAPSED_LINES = 6 def _label(text: str = "", name: str = "", wrap: bool = True, selectable: bool = False) -> QLabel: label = QLabel(text) if name: label.setObjectName(name) label.setWordWrap(wrap) if selectable: label.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse) label.setCursor(Qt.IBeamCursor) return label def repolish(widget: QWidget) -> None: """Re-apply the stylesheet after a dynamic property changed.""" widget.style().unpolish(widget) widget.style().polish(widget) widget.update() def markdown_html(text: str, font) -> str: """Markdown -> the HTML QLabel renders, with code in the app's mono font and a warm tint instead of Qt's generic 'monospace' (usually Courier).""" doc = QTextDocument() doc.setDefaultFont(font) doc.setMarkdown(text) out = doc.toHtml() code_style = f"font-family:'{theme.mono_font_family()}'; color:{theme.CODE};" # Qt writes the platform's fixed font for code: 'Courier New' on Windows, # 'monospace' elsewhere. for fixed in {QFontDatabase.systemFont(QFontDatabase.FixedFont).family(), "monospace", "Courier New"}: out = out.replace(f"font-family:'{fixed}';", code_style) return out.replace("font-family:'Sans Serif';", "") # ── transcript entries ────────────────────────────────────────────────────── class UserMessage(QWidget): def __init__(self, text: str, parent=None): super().__init__(parent) row = QHBoxLayout(self) row.setContentsMargins(80, 6, 0, 6) row.addStretch(1) bubble = QFrame() bubble.setObjectName("UserBubble") inner = QVBoxLayout(bubble) inner.setContentsMargins(16, 10, 16, 10) label = _label(text, selectable=True) label.setTextFormat(Qt.PlainText) # A word-wrapped QLabel's size hint is a guess that wraps short text # early; give it the width its longest line actually needs. widest = max(label.fontMetrics().horizontalAdvance(line) for line in text.splitlines() or [""]) label.setMinimumWidth(min(widest + 4, 588)) inner.addWidget(label) bubble.setMaximumWidth(620) bubble.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred) row.addWidget(bubble) class AssistantMessage(QWidget): """Bolt's reply. Streamed sentences are appended; a sentence that starts a list item or heading goes on its own line so the markdown still renders.""" _BLOCK_START = re.compile(r"^(?:[-*+] |\d+[.)] |#{1,6} |```|>)") def __init__(self, text: str = "", parent=None): super().__init__(parent) self._text = "" row = QHBoxLayout(self) row.setContentsMargins(0, 8, 0, 8) row.setSpacing(14) avatar = icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT) row.addWidget(avatar, 0, Qt.AlignTop) self.label = _label(name="AssistantText", selectable=True) self.label.setTextFormat(Qt.RichText) self.label.setOpenExternalLinks(True) self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) row.addWidget(self.label, 1) if text: self.set_text(text) def set_text(self, text: str) -> None: self._text = text self.label.setText(markdown_html(text, self.label.font())) def append(self, sentence: str) -> None: if not self._text: joiner = "" elif self._BLOCK_START.match(sentence) or self._text.endswith("\n"): joiner = "\n\n" if not self._text.endswith("\n") else "" else: joiner = " " self.set_text(self._text + joiner + sentence) def text(self) -> str: return self._text class HoldingLine(QWidget): def __init__(self, text: str, parent=None): super().__init__(parent) row = QHBoxLayout(self) row.setContentsMargins(40, 0, 0, 2) row.addWidget(_label(text, "Holding")) class Notice(QWidget): def __init__(self, level: str, text: str, parent=None): super().__init__(parent) row = QHBoxLayout(self) row.setContentsMargins(40, 4, 0, 4) label = _label(text, "NoticeError" if level == "error" else "Notice", selectable=True) label.setTextFormat(Qt.PlainText) if level == "warn": label.setStyleSheet(f"color: {theme.ACCENT};") row.addWidget(label, 1) class Pill(QLabel): TEXT = {"running": "Running", "ok": "Done", "error": "Failed", "waiting": "Needs approval", "declined": "Declined"} def __init__(self, kind: str = "running", parent=None): super().__init__(parent) self.setObjectName("Pill") self.set_kind(kind) def set_kind(self, kind: str) -> None: self.setProperty("kind", kind) self.setText(self.TEXT.get(kind, kind)) repolish(self) class _ClickableFrame(QFrame): clicked = Signal() def mouseReleaseEvent(self, event) -> None: if event.button() == Qt.LeftButton and self.rect().contains(event.position().toPoint()): self.clicked.emit() super().mouseReleaseEvent(event) class ToolCard(QFrame): """One relayed command: header (icon, name, argument, status), optional diff, output that expands on click, and an inline approval bar.""" answered = Signal(str) # yes / no / always def __init__(self, name: str, argument: str, parent=None): super().__init__(parent) self.setObjectName("ToolCard") self._output = "" self._expanded = False self._error = False outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) self.header = _ClickableFrame() self.header.setObjectName("ToolHeader") self.header.setCursor(Qt.PointingHandCursor) head = QHBoxLayout(self.header) head.setContentsMargins(12, 9, 12, 9) head.setSpacing(10) self.icon = icons.IconLabel(TOOL_ICONS.get(name, "shell"), theme.TEXT_DIM, 16) head.addWidget(self.icon) title = _label(name, "ToolName", wrap=False) head.addWidget(title) shown = argument if len(argument) <= 110 else argument[:107] + "..." arg = _label(shown, "ToolArg", wrap=False) arg.setTextFormat(Qt.PlainText) arg.setToolTip(argument) arg.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) head.addWidget(arg, 1) self.pill = Pill("running") head.addWidget(self.pill) self.chevron = icons.IconLabel("chevron", theme.TEXT_FAINT, 14) self.chevron.hide() head.addWidget(self.chevron) self.header.clicked.connect(self.toggle) outer.addWidget(self.header) self.diff = _label(name="ToolOutput", wrap=True, selectable=True) self.diff.setTextFormat(Qt.RichText) self.diff.hide() outer.addWidget(self.diff) self.output = _label(name="ToolOutput", wrap=True, selectable=True) self.output.setTextFormat(Qt.PlainText) self.output.hide() outer.addWidget(self.output) self.approval = QFrame() self.approval.setObjectName("ApprovalBar") bar = QHBoxLayout(self.approval) bar.setContentsMargins(14, 10, 10, 10) bar.setSpacing(8) self.question = _label("", "ApprovalQuestion", wrap=False) bar.addWidget(self.question, 1) for text, value, style in (("Deny", "no", "Ghost"), ("Always allow", "always", "Secondary"), ("Allow", "yes", "Primary")): button = QPushButton(text) button.setObjectName(style) button.setCursor(Qt.PointingHandCursor) button.clicked.connect(lambda _=False, v=value: self._answer(v)) bar.addWidget(button) if value == "yes": self.allow_button = button self.approval.hide() outer.addWidget(self.approval) # ── state ──────────────────────────────────────────────────────────── def set_result(self, text: str, error: bool) -> None: if self.pill.property("kind") == "declined": return self._output, self._error = text.rstrip(), error self.pill.set_kind("error" if error else "ok") self.icon.set_icon(self.icon._name, theme.RED if error else theme.TEXT_DIM) self._render_output() def set_diff(self, old: str, new: str, limit: int = 40) -> None: rows = [] for sign, text, fg, bg in (("-", old, theme.RED, theme.DIFF_DEL_BG), ("+", new, theme.GREEN, theme.DIFF_ADD_BG)): lines = text.splitlines() or [""] for line in lines[:limit]: rows.append( f'{sign} {html.escape(line) or " "}') if len(lines) > limit: rows.append(f'' f' … {len(lines) - limit} more lines') self.diff.setText('' + "".join(rows) + "
") self.diff.show() def ask(self, question: str) -> None: self.question.setText(question) self.pill.set_kind("waiting") self.setProperty("state", "waiting") repolish(self) self.approval.show() self.allow_button.setFocus() def _answer(self, value: str) -> None: self.approval.hide() self.setProperty("state", "") repolish(self) if value == "no": self.pill.set_kind("declined") else: self.pill.set_kind("running") self.answered.emit(value) def toggle(self) -> None: if self._output: self._expanded = not self._expanded self._render_output() def _render_output(self) -> None: lines = align_columns(self._output.splitlines()) if not lines: self.output.hide() self.chevron.hide() return long = len(lines) > COLLAPSED_LINES shown = lines if (self._expanded or not long) else lines[:COLLAPSED_LINES] text = "\n".join(shown) if long and not self._expanded: text += f"\n… {len(lines) - COLLAPSED_LINES} more lines — click to expand" self.output.setText(text) self.output.setStyleSheet(f"color: {theme.RED};" if self._error else "") self.output.show() self.chevron.setVisible(long) def align_columns(lines): """Tab-separated output (filectl list's "namesize") into padded columns — a QLabel's tab stops don't line up with anything.""" rows = [line.split(" ") for line in lines] if not any(len(r) > 1 for r in rows): return lines width = max(len(r[0]) for r in rows if len(r) > 1) return [r[0].ljust(width + 2) + " ".join(r[1:]) if len(r) > 1 else r[0] for r in rows] class ThinkingIndicator(QWidget): """Three dots breathing in sequence, next to a quiet 'Thinking'.""" def __init__(self, parent=None): super().__init__(parent) self.setFixedHeight(34) self._phase = 0.0 self._timer = QTimer(self) self._timer.setInterval(40) self._timer.timeout.connect(self._tick) self.label = "Thinking" def start(self) -> None: self._phase = 0.0 self.show() self._timer.start() def stop(self) -> None: self._timer.stop() self.hide() def _tick(self) -> None: self._phase += 0.12 self.update() def paintEvent(self, _event) -> None: p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) p.setPen(Qt.NoPen) x, y = 50.0, self.height() / 2 for i in range(3): level = (math.sin(self._phase - i * 0.9) + 1) / 2 colour = QColor(theme.ACCENT) colour.setAlphaF(0.25 + 0.75 * level) p.setBrush(colour) r = 2.6 + 1.0 * level p.drawEllipse(QRectF(x + i * 12 - r, y - r, 2 * r, 2 * r)) p.setPen(QColor(theme.TEXT_FAINT)) p.drawText(QRectF(x + 34, 0, 300, self.height()), Qt.AlignVCenter | Qt.AlignLeft, self.label) # ── composer ──────────────────────────────────────────────────────────────── class _SendButton(QPushButton): def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(34, 34) self.setCursor(Qt.PointingHandCursor) self.setToolTip("Send (Enter)") self.setFlat(True) def paintEvent(self, _event) -> None: p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) p.setPen(Qt.NoPen) enabled = self.isEnabled() fill = QColor(theme.ACCENT_HI if self.underMouse() and enabled else theme.ACCENT) if not enabled: fill = QColor(theme.BORDER_HI) p.setBrush(fill) p.drawEllipse(QRectF(self.rect()).adjusted(1, 1, -1, -1)) icons.paint(p, "send", QRectF(self.rect()).adjusted(8, 8, -8, -8), theme.ACCENT_INK if enabled else theme.TEXT_FAINT) class MicButton(QPushButton): """Dictation toggle. idle: a quiet mic outline. recording: red, with a ring that swells with your voice, and a stop square. finishing: an amber arc while the last words come back. unavailable: faint, and the tooltip says what's missing.""" TIPS = { "idle": "Dictate (Ctrl+M)\nClick, talk, click again (or pause) to stop", "recording": "Listening. Click or pause to stop, Esc to cancel", "finishing": "Transcribing...", } def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(34, 34) self.setCursor(Qt.PointingHandCursor) self.setFlat(True) self.state = "idle" self._level = 0.0 self._phase = 0.0 self._timer = QTimer(self) self._timer.setInterval(33) self._timer.timeout.connect(self._tick) self.set_state("idle") def set_state(self, state: str, reason: str = "") -> None: self.state = state self.setToolTip(reason if state == "unavailable" else self.TIPS.get(state, "")) if state in ("recording", "finishing"): self._timer.start() else: self._timer.stop() self._level = 0.0 self.update() def set_level(self, level: float) -> None: # Fast attack, slow release, so the ring breathes instead of flickering. self._level = level if level > self._level else self._level * 0.85 + level * 0.15 def _tick(self) -> None: self._phase += 0.2 self._level *= 0.97 self.update() def paintEvent(self, _event) -> None: p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) rect = QRectF(self.rect()).adjusted(1, 1, -1, -1) centre = rect.center() if self.state == "recording": ring = QColor(theme.RED) ring.setAlphaF(0.22 + 0.3 * self._level) p.setPen(Qt.NoPen) p.setBrush(ring) p.drawEllipse(centre, 16.0, 16.0) p.setBrush(QColor(theme.RED)) radius = 10.5 + 3.5 * self._level p.drawEllipse(centre, radius, radius) icons.paint(p, "stop", QRectF(centre.x() - 8, centre.y() - 8, 16, 16), "#ffffff") return if self.state == "finishing": pen = QColor(theme.ACCENT) p.setPen(icons._pen(pen, 2.2)) p.drawArc(rect.adjusted(4, 4, -4, -4), int(-self._phase * 16 * 57), 100 * 16) icons.paint(p, "mic", rect.adjusted(9, 9, -9, -9), theme.TEXT_DIM) return if self.state == "idle" and self.underMouse(): p.setPen(Qt.NoPen) p.setBrush(QColor(theme.SURFACE_HI)) p.drawEllipse(rect) colour = theme.TEXT_FAINT if self.state == "unavailable" else ( theme.TEXT if self.underMouse() else theme.TEXT_DIM) icons.paint(p, "mic", rect.adjusted(8, 8, -8, -8), colour) class _ComposerEdit(QPlainTextEdit): submitted = Signal() def keyPressEvent(self, event) -> None: if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not (event.modifiers() & Qt.ShiftModifier): self.submitted.emit() return super().keyPressEvent(event) class Composer(QFrame): """Rounded input that grows with its text. Enter sends, Shift+Enter starts a new line. While a turn runs you can keep typing; sending waits.""" submitted = Signal(str) MAX_LINES = 9 def __init__(self, parent=None): super().__init__(parent) self.setObjectName("Composer") row = QHBoxLayout(self) row.setContentsMargins(18, 10, 10, 10) row.setSpacing(10) self.edit = _ComposerEdit() self.edit.setObjectName("ComposerEdit") self.edit.setPlaceholderText("Ask Bolt to read, change or run something…") self.edit.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere) self.edit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.edit.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.edit.document().setDocumentMargin(2) self.edit.textChanged.connect(self._resize) self.edit.submitted.connect(self._submit) self.edit.installEventFilter(self) row.addWidget(self.edit, 1, Qt.AlignVCenter) self.mic = MicButton() row.addWidget(self.mic, 0, Qt.AlignBottom) self._dictation_prefix = None # composer text from before a recording self.send = _SendButton() self.send.clicked.connect(self._submit) row.addWidget(self.send, 0, Qt.AlignBottom) self._busy = False self._resize() self._sync_button() self.edit.textChanged.connect(self._sync_button) def eventFilter(self, obj, event) -> bool: if obj is self.edit and event.type() in (QEvent.FocusIn, QEvent.FocusOut): self.setProperty("focused", event.type() == QEvent.FocusIn) repolish(self) return False def set_busy(self, busy: bool) -> None: self._busy = busy self._sync_button() def set_text(self, text: str) -> None: self.edit.setPlainText(text) cursor = self.edit.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) self.edit.setTextCursor(cursor) self.edit.setFocus() # ── dictation: the words appear in the box as you speak ────────────── def begin_dictation(self) -> None: self._dictation_prefix = self.edit.toPlainText().rstrip() self.edit.setReadOnly(True) # typing into text that's being rewritten would be lost self._sync_button() def show_dictation(self, final: str, interim: str = "") -> None: if self._dictation_prefix is None: return spoken = " ".join(part for part in (final, interim) if part) self._show(" ".join(part for part in (self._dictation_prefix, spoken) if part)) def end_dictation(self, text: str) -> None: if self._dictation_prefix is None: return self._show(" ".join(part for part in (self._dictation_prefix, text.strip()) if part)) self._dictation_prefix = None self.edit.setReadOnly(False) self.edit.setFocus() self._sync_button() @property def dictating(self) -> bool: return self._dictation_prefix is not None def _show(self, text: str) -> None: self.edit.setPlainText(text) cursor = self.edit.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) self.edit.setTextCursor(cursor) def _sync_button(self) -> None: ready = not self._busy and not self.dictating and bool(self.edit.toPlainText().strip()) self.send.setEnabled(ready) self.send.update() def _resize(self) -> None: line = self.edit.fontMetrics().lineSpacing() lines = max(1, min(self.MAX_LINES, int(self.edit.document().size().height()))) self.edit.setFixedHeight(int(lines * line + 8)) def _submit(self) -> None: text = self.edit.toPlainText().strip() if not text or self._busy or self.dictating: return self.edit.clear() self.submitted.emit(text) # ── header ────────────────────────────────────────────────────────────────── class StatusDot(QWidget): COLOURS = {"unknown": theme.TEXT_FAINT, "ok": theme.GREEN, "error": theme.RED} def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(QSize(10, 10)) self._state = "unknown" def set_state(self, state: str, tooltip: str) -> None: self._state = state self.setToolTip(tooltip) self.update() def paintEvent(self, _event) -> None: p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) p.setPen(Qt.NoPen) p.setBrush(QColor(self.COLOURS.get(self._state, theme.TEXT_FAINT))) p.drawEllipse(QRectF(1, 1, 8, 8)) class FolderChip(QPushButton): """A pill button with a painted icon on the left (the folder by default).""" def __init__(self, parent=None, icon_name: str = "folder", text: str = ""): super().__init__(text, parent) self._icon_name = icon_name self.setObjectName("Chip") self.setCursor(Qt.PointingHandCursor) self.setFixedHeight(30) def set_path(self, path: str) -> None: name = path.rstrip("\\/").replace("\\", "/").split("/")[-1] or path self.setText(name) self.setToolTip(f"Working directory: {path}\nClick to change") def paintEvent(self, event) -> None: super().paintEvent(event) p = QPainter(self) paint_colour = theme.TEXT if self.underMouse() else theme.TEXT_DIM if not self.isEnabled(): paint_colour = theme.TEXT_FAINT icons.paint(p, self._icon_name, QRectF(10, (self.height() - 15) / 2, 15, 15), paint_colour) class ModeSwitch(QFrame): """Segmented control: ask before changes, or run everything.""" changed = Signal(str) def __init__(self, mode: str, parent=None): super().__init__(parent) self.setObjectName("Segment") self.setFixedHeight(30) row = QHBoxLayout(self) row.setContentsMargins(2, 2, 2, 2) row.setSpacing(2) self._group = QButtonGroup(self) self.buttons = {} for value, text, tip in ( ("ask", "Ask first", "Confirm shell commands, writes and edits"), ("auto", "Auto", "Run everything Bolt asks for without confirming"), ): button = QPushButton(text) button.setObjectName("SegmentButton") button.setCheckable(True) button.setCursor(Qt.PointingHandCursor) button.setToolTip(tip) if value == "auto": button.setProperty("danger", True) self._group.addButton(button) row.addWidget(button) self.buttons[value] = button button.clicked.connect(lambda _=False, v=value: self.changed.emit(v)) self.set_mode(mode) def set_mode(self, mode: str) -> None: self.buttons.get(mode, self.buttons["ask"]).setChecked(True) # ── empty state ───────────────────────────────────────────────────────────── SUGGESTIONS = ( "What's in this folder?", "Summarise the README", "Git status and recent commits", "Find TODO comments in this project", ) class EmptyState(QWidget): picked = Signal(str) def __init__(self, parent=None): super().__init__(parent) col = QVBoxLayout(self) col.setContentsMargins(0, 0, 0, 0) col.addStretch(3) col.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 52, tile=theme.ACCENT), 0, Qt.AlignHCenter) col.addSpacing(18) hero = _label("What are we working on?", "Hero") hero.setAlignment(Qt.AlignHCenter) col.addWidget(hero) self.sub = _label("", "HeroSub") self.sub.setAlignment(Qt.AlignHCenter) col.addWidget(self.sub) col.addSpacing(26) grid = QVBoxLayout() grid.setSpacing(10) for i in range(0, len(SUGGESTIONS), 2): pair = QHBoxLayout() pair.setSpacing(10) for text in SUGGESTIONS[i:i + 2]: button = QPushButton(text) button.setObjectName("Suggestion") button.setCursor(Qt.PointingHandCursor) button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) button.clicked.connect(lambda _=False, t=text: self.picked.emit(t)) pair.addWidget(button) grid.addLayout(pair) wrap = QHBoxLayout() wrap.addStretch(1) holder = QWidget() holder.setLayout(grid) holder.setMaximumWidth(560) holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) wrap.addWidget(holder, 4) wrap.addStretch(1) col.addLayout(wrap) col.addStretch(4) def set_folder(self, path: str) -> None: self.sub.setText(f"Bolt can read, edit and run things in {path}")