mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
Resolves the hermes_cli/cli_tui_mixin.py conflict from #141501 landing: main moved _Panel to hermes_cli/cli_panel.py and this branch moved the wrap helpers to hermes_cli/cli_tui_wrap.py. Keep both moves: import _Panel and the wrap helpers, define neither here. Refs #134889 (N8)
533 lines
23 KiB
Python
533 lines
23 KiB
Python
"""Classic CLI live-work dock (subagents, background processes, active goal, standing loop, queued
|
||
prompts) and scoped controls; no agent-loop state is changed. Process rows come from
|
||
``cli_process_dock``, goal/loop/queue rows from ``cli_session_dock``."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
|
||
from prompt_toolkit.utils import get_cwidth
|
||
|
||
from agent.i18n import t
|
||
from hermes_cli import cli_process_dock as procs
|
||
from hermes_cli import cli_session_dock as session_rows
|
||
|
||
|
||
def _clip(value, width):
|
||
text = ' '.join(str(value or '').split())
|
||
text = ''.join(c for c in text if c.isprintable())
|
||
if get_cwidth(text) <= width:
|
||
return text
|
||
result = ''
|
||
for char in text:
|
||
if get_cwidth(result + char) > max(0, width - 1):
|
||
break
|
||
result += char
|
||
return result + ('…' if width else '')
|
||
|
||
|
||
class SubagentMonitor:
|
||
def __init__(self, cli):
|
||
self.cli = cli
|
||
self.entries = []
|
||
self.processes = []
|
||
self.goal = ''
|
||
self.loop = ''
|
||
self.loop_badge = ''
|
||
self.loop_prompt = ''
|
||
self.queued = []
|
||
self.selected_id = None
|
||
self._signature = None
|
||
self._last_poll = 0
|
||
self.app = None
|
||
self.opening = False
|
||
self.collapsed = False
|
||
self.detail = False # the monitor's detail view is open (its tick fetches the gateway tail)
|
||
|
||
@property
|
||
def has_rows(self):
|
||
return bool(self.entries or self.processes or self.goal or self.loop or self.queued)
|
||
|
||
@property
|
||
def roster(self):
|
||
"""Agents first, then processes — the order the dock and the full-height monitor paint."""
|
||
return [*self.entries, *self.processes]
|
||
|
||
@staticmethod
|
||
def _key(row):
|
||
return row.get('key', row.get('subagent_id'))
|
||
|
||
@property
|
||
def selected(self):
|
||
return next((r for r in self.roster if self._key(r) == self.selected_id), None)
|
||
|
||
@property
|
||
def selected_process(self):
|
||
row = self.selected
|
||
return row if row and row.get('kind') == 'process' else None
|
||
|
||
def subagents(self):
|
||
"""The subagent backend: the gateway session's view when its children run there (CLI-12),
|
||
else this process's own registry; both answer the same ``subagent_*`` calls."""
|
||
from hermes_cli.gateway_tools import LocalSubagents, process_view
|
||
view = process_view()
|
||
if view is not None:
|
||
return view
|
||
if getattr(self, '_local', None) is None:
|
||
self._local = LocalSubagents(self.cli)
|
||
return self._local
|
||
|
||
def refresh(self, now=None):
|
||
now = time.time() if now is None else now
|
||
entries = self.subagents().subagent_rows(busy=bool(getattr(self.cli, '_agent_running', False)))
|
||
for row in entries:
|
||
row['elapsed'] = max(0, int(now - (row.get('started_at') or now)))
|
||
row['last_tool'] = row.get('last_tool') or ''
|
||
row['key'] = row['subagent_id']
|
||
row.pop('running_seconds', None)
|
||
processes = procs.process_rows(now)
|
||
session_rows.sync_automation(self.cli)
|
||
goal = session_rows.goal_line(self.cli)
|
||
loop_badge, loop, loop_prompt = session_rows.loop_row(self.cli, now)
|
||
queued = session_rows.queued_prompts(self.cli)
|
||
signature = json.dumps([entries, processes, goal, loop, queued], sort_keys=True, default=str)
|
||
changed = signature != self._signature
|
||
self._signature = signature
|
||
self.entries = entries
|
||
self.processes = processes
|
||
self.goal = goal
|
||
self.loop = loop
|
||
self.loop_badge = loop_badge
|
||
self.loop_prompt = loop_prompt
|
||
self.queued = queued
|
||
if self.selected is None:
|
||
roster = self.roster
|
||
self.selected_id = self._key(roster[0]) if roster else None
|
||
return changed
|
||
|
||
def invalidate(self):
|
||
from hermes_cli.cli_terminal_mixin import _run_on_app_loop
|
||
|
||
app = self.app
|
||
if app is not None:
|
||
# Teardown clears app.loop; don't let it interleave with a worker's
|
||
# invalidate call, which reads the loop more than once.
|
||
_run_on_app_loop(app, app.invalidate)
|
||
|
||
def poll_soon(self):
|
||
"""Let the spinner thread's next tick poll the gateway, instead of the caller blocking the
|
||
UI loop on it."""
|
||
self._last_poll = 0
|
||
|
||
def tick(self):
|
||
now = time.monotonic()
|
||
if now - self._last_poll < 1:
|
||
return
|
||
self._last_poll = now
|
||
changed = self.refresh()
|
||
if self._poll_tail() or changed:
|
||
if self.app is not None:
|
||
self.invalidate()
|
||
else:
|
||
self.cli._invalidate()
|
||
|
||
def _poll_tail(self):
|
||
"""Off the render path (the spinner thread's tick): refresh the gateway tail the open detail
|
||
view paints from the cache. True when the text changed."""
|
||
row = self.selected
|
||
if not self.detail or row is None or row.get('kind') == 'process':
|
||
return False
|
||
backend = self.subagents()
|
||
before = backend.subagent_tail(row['subagent_id'])
|
||
return backend.subagent_tail(row['subagent_id'], fetch=True) != before
|
||
|
||
def select(self, delta):
|
||
roster = self.roster
|
||
if roster:
|
||
index = next((i for i, r in enumerate(roster) if self._key(r) == self.selected_id), 0)
|
||
self.selected_id = self._key(roster[(index + delta) % len(roster)])
|
||
|
||
def control(self, action, message=None, *, target=None):
|
||
target = target or self.selected_id
|
||
if any(r['key'] == target for r in self.processes):
|
||
if action != 'stop':
|
||
return {'error': t('cli.subagents.cannot_steer_process')}
|
||
return procs.kill(target)
|
||
return self.subagents().subagent_control(action, target, message)
|
||
|
||
def _counts(self, *, session=True):
|
||
"""Count fragment: ``2 live``, ``1 proc``, ``2 live · 3 procs``; the collapsed heading
|
||
(``session=True``) adds ``goal active|paused|parked``, the loop badge and ``N queued``."""
|
||
parts = []
|
||
if self.entries:
|
||
parts.append(t('cli.subagents.count_live', count=len(self.entries)))
|
||
if self.processes:
|
||
running = sum(r['status'] == 'running' for r in self.processes)
|
||
if running:
|
||
parts.append(t('cli.subagents.count_procs_one' if running == 1 else 'cli.subagents.count_procs_other',
|
||
count=running))
|
||
else:
|
||
parts.append(t('cli.subagents.count_done', count=len(self.processes)))
|
||
if session and self.goal:
|
||
parts.append(t('cli.subagents.goal_parked' if self.goal.startswith('⏳') else
|
||
'cli.subagents.goal_paused' if self.goal.startswith('⏸') else 'cli.subagents.goal_active'))
|
||
if session and self.loop_badge:
|
||
parts.append(self.loop_badge)
|
||
if session and self.queued:
|
||
parts.append(t('cli.subagents.count_queued', count=len(self.queued)))
|
||
return ' · '.join(parts)
|
||
|
||
def _title(self):
|
||
if self.entries and self.processes:
|
||
return t('cli.subagents.title_live_work')
|
||
return t('cli.subagents.title_subagents') if self.entries else t('cli.subagents.title_processes')
|
||
|
||
@staticmethod
|
||
def _agent_activity(row):
|
||
"""``last: <tool>`` while a tool runs, else the raw status id, else ``starting``."""
|
||
if row.get('last_tool'):
|
||
return t('cli.subagents.last_tool', tool=row['last_tool'])
|
||
return row.get('status') or t('cli.subagents.starting')
|
||
|
||
def _collapsed_activity(self):
|
||
if self.entries:
|
||
return self._agent_activity(self.entries[0])
|
||
if self.processes:
|
||
return procs.process_activity(self.processes[0])
|
||
# The loop's state is already the heading's badge; its prompt is the activity.
|
||
return self.goal or self.loop_prompt or t('cli.subagents.next_queued', text=self.queued[0])
|
||
|
||
def dock_text(self, *, columns, rows):
|
||
if not self.has_rows:
|
||
return ''
|
||
if self.collapsed:
|
||
count = self._counts()
|
||
# Keep both controls before spending scarce cells on activity. Ctrl+T opens the
|
||
# subagent/process monitor, so a goal/queue-only dock offers just the
|
||
# collapse/restore shortcut.
|
||
if self.entries or self.processes:
|
||
headings = (
|
||
t('cli.subagents.heading_full', title=self._title(), count=count),
|
||
t('cli.subagents.heading_medium', count=count),
|
||
t('cli.subagents.heading_compact', count=count),
|
||
count,
|
||
)
|
||
else:
|
||
headings = (t('cli.subagents.heading_session_restore', count=count),
|
||
t('cli.subagents.heading_session_compact', count=count), count)
|
||
width = max(0, columns - 1)
|
||
heading = next((text for text in headings if get_cwidth(text) <= width), count)
|
||
activity = self._collapsed_activity()
|
||
# A goal/queue preview is long prose: clip it into the room left instead of dropping it.
|
||
room = width - get_cwidth(heading + ' · ')
|
||
if get_cwidth(activity) <= room or (room >= 12 and not (self.entries or self.processes)):
|
||
heading += ' · ' + _clip(activity, room)
|
||
return _clip(' ' + heading, max(0, columns))
|
||
columns = max(0, columns - 2)
|
||
lines = [_clip(f' {row}', columns) for row in (self.goal, self.loop) if row]
|
||
budget = max(1, min(4, (rows - 10) // 3))
|
||
# Both blocks present: split the row budget so neither hides the other entirely.
|
||
agent_budget = budget if not self.processes else max(1, budget - max(1, budget // 2))
|
||
agent_count = min(len(self.entries), agent_budget)
|
||
if self.entries:
|
||
hidden = len(self.entries) - agent_count
|
||
lines.append(_clip(' ' + t('cli.subagents.subagents_heading', count=len(self.entries)), columns))
|
||
for row in self.entries[:agent_count]:
|
||
activity = f"{row['elapsed']}s · " + self._agent_activity(row)
|
||
# Reserve activity even on narrow terminals; task names use the remainder.
|
||
goal_width = max(3, columns - get_cwidth(activity) - 5)
|
||
lines.append(_clip(f" ● {_clip(row.get('goal'), goal_width)} · {activity}", columns))
|
||
if hidden:
|
||
lines.append(_clip(' ' + t('cli.subagents.more_subagents', count=hidden), columns))
|
||
if self.processes:
|
||
proc_count = min(len(self.processes), max(1, budget - agent_count))
|
||
running = sum(r['status'] == 'running' for r in self.processes)
|
||
done = len(self.processes) - running
|
||
summary = ' · '.join(p for p in (
|
||
t('cli.subagents.count_running', count=running) if running else '',
|
||
t('cli.subagents.count_done', count=done) if done else '') if p)
|
||
controls = t('cli.subagents.controls_expand_collapse') if not self.entries else ''
|
||
lines.append(_clip(' ' + t('cli.subagents.processes_heading', summary=summary, controls=controls), columns))
|
||
for row in self.processes[:proc_count]:
|
||
activity = procs.process_activity(row)
|
||
command_width = max(3, columns - get_cwidth(activity) - 5)
|
||
lines.append(_clip(f" {procs.process_glyph(row)} {_clip(row['command'], command_width)} · {activity}", columns))
|
||
if len(self.processes) > proc_count:
|
||
lines.append(_clip(
|
||
' ' + t('cli.subagents.more_processes', count=len(self.processes) - proc_count), columns))
|
||
if self.queued:
|
||
# Last, so the next prompt to run sits right above the input it came from.
|
||
shown = min(len(self.queued), session_rows.QUEUE_ROWS if rows >= 24 else 1)
|
||
controls = '' if self.entries or self.processes else t('cli.subagents.controls_collapse')
|
||
lines.append(_clip(
|
||
' ' + t('cli.subagents.queue_heading', count=len(self.queued), controls=controls), columns))
|
||
for index, text in enumerate(self.queued[:shown], 1):
|
||
lines.append(_clip(f' {index}. {text}', columns))
|
||
if len(self.queued) > shown:
|
||
lines.append(_clip(' ' + t('cli.subagents.more_queued', count=len(self.queued) - shown), columns))
|
||
return '\n'.join(' ' + line for line in lines)
|
||
|
||
|
||
def read_tail(path):
|
||
if not path:
|
||
return t('cli.subagents.transcript_unavailable')
|
||
try:
|
||
with open(path, 'rb') as stream:
|
||
stream.seek(0, 2)
|
||
stream.seek(max(0, stream.tell() - 32768))
|
||
text = stream.read(32768).decode('utf-8', errors='replace')
|
||
return ''.join(c for c in text if c.isprintable() or c in '\n\t')
|
||
except OSError:
|
||
return t('cli.subagents.transcript_unavailable')
|
||
|
||
|
||
def subagent_tail(monitor, row):
|
||
"""The child's transcript tail from the monitor's backend (the gateway's is fetched and filtered by
|
||
the monitor's tick, never here: the detail view calls this every frame)."""
|
||
text = monitor.subagents().subagent_tail(row['subagent_id'])
|
||
return t('cli.subagents.transcript_unavailable') if text is None else text
|
||
|
||
|
||
def modal_prompt_active(cli):
|
||
"""A prompt owns the composer (its text is an answer or a secret, never a chat message):
|
||
every composer shortcut stays off while one is open."""
|
||
return any(getattr(cli, name, None) for name in (
|
||
'_clarify_state', '_approval_state', '_slash_confirm_state', '_sudo_state',
|
||
'_secret_state', '_connection_state', '_model_picker_state', '_command_palette_state'))
|
||
|
||
|
||
def build_monitor_application(monitor, **kwargs):
|
||
from prompt_toolkit.application import Application
|
||
from prompt_toolkit.data_structures import Point
|
||
from prompt_toolkit.filters import Condition
|
||
from prompt_toolkit.key_binding import KeyBindings
|
||
from prompt_toolkit.layout import ConditionalContainer, HSplit, Layout, Window
|
||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||
from prompt_toolkit.widgets import TextArea
|
||
|
||
state = {'steering': False, 'confirm': False, 'notice': ''}
|
||
steer = TextArea(height=1, prompt=t('cli.subagents.steer_prompt'), multiline=False)
|
||
tail = TextArea(read_only=True, scrollbar=True, wrap_lines=True)
|
||
|
||
def roster_text():
|
||
size = app.output.get_size()
|
||
rows = []
|
||
for row in monitor.roster:
|
||
selected = monitor._key(row) == monitor.selected_id
|
||
if row.get('kind') == 'process':
|
||
prefix = f"{procs.process_glyph(row)} {procs.process_activity(row)} · "
|
||
activity = ''
|
||
subject = row['command']
|
||
else:
|
||
prefix = f"{row['elapsed']}s · {row.get('status') or t('cli.subagents.starting')} · "
|
||
activity = ' · ' + t('cli.subagents.last_tool', tool=row['last_tool']) if row.get('last_tool') else ''
|
||
subject = row.get('goal') or row['subagent_id']
|
||
goal_width = max(0, size.columns - 2 - get_cwidth(prefix + activity))
|
||
text = f"{'❯' if selected else ' '} " + _clip(prefix + _clip(subject, goal_width) + activity, max(0, size.columns - 2))
|
||
# Pad selection in terminal cells, not codepoints (task names may be wide).
|
||
text += ' ' * max(0, size.columns - get_cwidth(text))
|
||
rows.append(('class:subagent-dock.selected' if selected else '', text + '\n'))
|
||
return rows or [('', t('cli.subagents.roster_empty'))]
|
||
|
||
def cursor():
|
||
index = next((i for i, row in enumerate(monitor.roster) if monitor._key(row) == monitor.selected_id), 0)
|
||
return Point(x=0, y=index)
|
||
|
||
roster = Window(FormattedTextControl(roster_text, focusable=True, get_cursor_position=cursor))
|
||
|
||
def update_tail():
|
||
row = monitor.selected
|
||
if row is None:
|
||
text = t('cli.subagents.entry_gone')
|
||
elif row.get('kind') == 'process':
|
||
text = procs.process_tail(row['id'])
|
||
else:
|
||
text = subagent_tail(monitor, row)
|
||
if text != tail.text:
|
||
following = tail.buffer.cursor_position == len(tail.text)
|
||
position = tail.buffer.cursor_position
|
||
tail.text = text
|
||
tail.buffer.cursor_position = len(text) if following else min(position, len(text))
|
||
|
||
def header():
|
||
row = monitor.selected
|
||
title = f"{monitor._title()} · {monitor._counts(session=False)}"
|
||
if monitor.detail and row:
|
||
title += f" · {monitor._key(row)} · {row.get('goal') or row.get('command') or ''}"
|
||
return [('class:subagent-dock.heading', _clip(title, app.output.get_size().columns))]
|
||
|
||
def footer():
|
||
narrow = app.output.get_size().columns < 60
|
||
process = monitor.selected_process is not None
|
||
if state['confirm']:
|
||
noun = t('cli.subagents.noun_process' if process else 'cli.subagents.noun_subagent')
|
||
return (t('cli.subagents.footer_confirm_stop_compact') if narrow
|
||
else t('cli.subagents.footer_confirm_stop', noun=noun))
|
||
if state['steering']:
|
||
return t('cli.subagents.footer_steering_compact' if narrow else 'cli.subagents.footer_steering')
|
||
steer = '' if process else t('cli.subagents.footer_steer_key')
|
||
if narrow:
|
||
return (t('cli.subagents.footer_detail_compact', steer=steer) if monitor.detail
|
||
else t('cli.subagents.footer_roster_compact'))
|
||
steer = '' if process else t('cli.subagents.footer_steer_key_long')
|
||
return t('cli.subagents.footer_detail' if monitor.detail else 'cli.subagents.footer_roster', steer=steer)
|
||
|
||
def run_control(action, message, target):
|
||
"""Steer/stop off the UI loop: a stalled gateway call (up to its timeout) must not freeze the
|
||
monitor's repaint, Esc or Ctrl+C. The answer lands in the notice line when it arrives."""
|
||
import asyncio
|
||
state['notice'] = t('cli.subagents.control_pending')
|
||
|
||
def done(future):
|
||
try:
|
||
result = future.result()
|
||
except Exception as exc: # health: allow BLE001 -- a control failure is shown, never raised into the UI loop
|
||
result = {'error': str(exc)}
|
||
state['notice'] = result.get('error') or result.get('note') or str(result)
|
||
app.invalidate()
|
||
|
||
asyncio.get_running_loop().run_in_executor(
|
||
None, lambda: monitor.control(action, message, target=target)).add_done_callback(done)
|
||
|
||
kb = KeyBindings()
|
||
normal = Condition(lambda: not state['steering'] and not state['confirm'])
|
||
listing = normal & Condition(lambda: not monitor.detail)
|
||
|
||
@kb.add('up', filter=listing)
|
||
def up(event):
|
||
monitor.select(-1)
|
||
|
||
@kb.add('down', filter=listing)
|
||
def down(event):
|
||
monitor.select(1)
|
||
|
||
@kb.add('enter', filter=listing)
|
||
def detail(event):
|
||
if monitor.selected:
|
||
monitor.detail = True
|
||
monitor.poll_soon() # the spinner thread's next tick fetches the tail
|
||
update_tail()
|
||
app.layout.focus(tail)
|
||
|
||
@kb.add('s', filter=normal)
|
||
def start_steer(event):
|
||
if monitor.selected and monitor.selected_process is None:
|
||
state['steering'] = True
|
||
state['target'] = monitor.selected_id
|
||
app.layout.focus(steer)
|
||
|
||
@kb.add('enter', filter=Condition(lambda: state['steering']))
|
||
def send_steer(event):
|
||
if not steer.text.strip():
|
||
return
|
||
run_control('steer', steer.text, state['target'])
|
||
steer.text = ''
|
||
state['steering'] = False
|
||
app.layout.focus(tail if monitor.detail else roster)
|
||
|
||
@kb.add('x', filter=normal)
|
||
def stop(event):
|
||
if monitor.selected:
|
||
state['confirm'] = True
|
||
state['target'] = monitor.selected_id
|
||
|
||
@kb.add('y', filter=Condition(lambda: state['confirm']))
|
||
def confirm(event):
|
||
run_control('stop', None, state['target'])
|
||
state['confirm'] = False
|
||
|
||
@kb.add('escape', eager=True)
|
||
def back(event):
|
||
if state['steering'] or state['confirm']:
|
||
state['steering'] = state['confirm'] = False
|
||
app.layout.focus(tail if monitor.detail else roster)
|
||
elif monitor.detail:
|
||
monitor.detail = False
|
||
app.layout.focus(roster)
|
||
else:
|
||
app.exit()
|
||
|
||
@kb.add('q', filter=normal)
|
||
@kb.add('f6', filter=normal)
|
||
@kb.add('c-t', filter=normal)
|
||
@kb.add('c-c')
|
||
def close(event):
|
||
app.exit()
|
||
|
||
layout = Layout(HSplit([
|
||
Window(FormattedTextControl(header), height=1),
|
||
ConditionalContainer(roster, filter=Condition(lambda: not monitor.detail)),
|
||
ConditionalContainer(tail, filter=Condition(lambda: monitor.detail)),
|
||
ConditionalContainer(steer, filter=Condition(lambda: state['steering'])),
|
||
Window(FormattedTextControl(lambda: _clip(state['notice'], app.output.get_size().columns)), height=1),
|
||
Window(FormattedTextControl(footer), height=1),
|
||
], style='class:subagent-dock'), focused_element=roster)
|
||
def before_render(app):
|
||
# Prompts arrive on worker threads; exit on the UI loop, including the
|
||
# first frame if a prompt won the race with in_terminal() acquisition.
|
||
if modal_prompt_active(monitor.cli) and not app.is_done:
|
||
app.exit()
|
||
elif monitor.detail:
|
||
update_tail()
|
||
|
||
from prompt_toolkit.styles import Style
|
||
from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides
|
||
kwargs.setdefault('style', Style.from_dict(get_prompt_toolkit_style_overrides()))
|
||
app = Application(layout=layout, key_bindings=kb, full_screen=True, mouse_support=False,
|
||
before_render=before_render, **kwargs)
|
||
return app
|
||
|
||
|
||
def open_monitor(cli):
|
||
import asyncio
|
||
from prompt_toolkit.application import in_terminal
|
||
monitor = getattr(cli, '_subagent_monitor', None)
|
||
if monitor is None or monitor.opening:
|
||
return
|
||
monitor.opening = True
|
||
|
||
async def run():
|
||
try:
|
||
async with in_terminal():
|
||
# Paint the roster the dock's tick already holds; refreshing here would block the UI
|
||
# loop on the gateway's subagent.list. The spinner thread's next tick re-reads it.
|
||
monitor.poll_soon()
|
||
monitor.app = build_monitor_application(monitor)
|
||
await monitor.app.run_async()
|
||
finally:
|
||
monitor.app = None
|
||
monitor.detail = monitor.opening = False
|
||
cli._invalidate()
|
||
|
||
asyncio.get_running_loop().create_task(run())
|
||
|
||
|
||
def toggle_dock(cli):
|
||
monitor = getattr(cli, '_subagent_monitor', None)
|
||
if monitor is not None:
|
||
monitor.collapsed = not monitor.collapsed
|
||
cli._invalidate()
|
||
|
||
|
||
def install_dock(cli):
|
||
from prompt_toolkit.application import get_app
|
||
from prompt_toolkit.layout import ConditionalContainer, Window
|
||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||
from prompt_toolkit.filters import Condition
|
||
monitor = SubagentMonitor(cli)
|
||
cli._subagent_monitor = monitor
|
||
monitor.refresh()
|
||
|
||
def text():
|
||
size = get_app().output.get_size()
|
||
lines = monitor.dock_text(columns=size.columns, rows=size.rows).splitlines()
|
||
return [('class:subagent-dock.heading' if i == 0 else '',
|
||
line + ('\n' if i < len(lines) - 1 else ''))
|
||
for i, line in enumerate(lines)]
|
||
|
||
cli._subagent_dock_widget = ConditionalContainer(
|
||
Window(FormattedTextControl(text), wrap_lines=False, dont_extend_height=True,
|
||
style='class:subagent-dock'),
|
||
filter=Condition(lambda: monitor.has_rows and not modal_prompt_active(cli)),
|
||
)
|