Bolt Console: desktop app with chat list / new chat, dictation, file tools
Add a chat list and server-minted new chats on top of the desk API's
GET/POST /desk/chats:
- server_client.list_chats() / create_chat(), same shape and error
handling as list_outbox_files().
- Switching is config.set_session(id): a plain reassignment of the
SESSION_ID every call reads, persisted to session.json. The window
refuses to switch mid-turn rather than locking.
- gui/chat_list_window.py: a Chats dialog (Ctrl+K) with a QListWidget,
populated on open, with Refresh and New Chat. Network calls run as
QRunnables on the global QThreadPool and come back through Signals,
never from a slot. The header New chat (Ctrl+N) uses the same path.
- Switching clears the local transcript; there is no endpoint for a
past chat's messages. Bolt's memory is per user, so it carries over.
- Terminal: /chats, /chat <n|id>, and /new (server-minted id, local
fallback).
This commit also carries the uncommitted work since 54030a7 that turned
this Bolt-Pet fork into Bolt Console: the pet, wake word, TTS and screen
features removed; a Claude Code-style terminal client; the PySide6
window with tool cards, approvals and diffs; mic dictation through the
server's /desk/stt relay; filectl/JSON repairs; and detection of
invented tool results.
Build output (build/, dist/, Release/, *.spec) is now ignored.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,699 @@
|
||||
"""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'<tr><td style="background:{bg}; color:{fg}; padding:1px 8px; '
|
||||
f'white-space:pre;">{sign} {html.escape(line) or " "}</td></tr>')
|
||||
if len(lines) > limit:
|
||||
rows.append(f'<tr><td style="color:{theme.TEXT_FAINT}; padding:1px 8px;">'
|
||||
f' … {len(lines) - limit} more lines</td></tr>')
|
||||
self.diff.setText('<table width="100%" cellspacing="0" cellpadding="0">' + "".join(rows) + "</table>")
|
||||
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 "name<TAB>size") 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}")
|
||||
Reference in New Issue
Block a user