overcode 0.1.3__py3-none-any.whl → 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +7 -2
  3. overcode/implementations.py +74 -8
  4. overcode/monitor_daemon.py +60 -65
  5. overcode/monitor_daemon_core.py +261 -0
  6. overcode/monitor_daemon_state.py +7 -0
  7. overcode/session_manager.py +1 -0
  8. overcode/settings.py +22 -0
  9. overcode/supervisor_daemon.py +48 -47
  10. overcode/supervisor_daemon_core.py +210 -0
  11. overcode/testing/__init__.py +6 -0
  12. overcode/testing/renderer.py +268 -0
  13. overcode/testing/tmux_driver.py +223 -0
  14. overcode/testing/tui_eye.py +185 -0
  15. overcode/testing/tui_eye_skill.md +187 -0
  16. overcode/tmux_manager.py +17 -3
  17. overcode/tui.py +196 -2462
  18. overcode/tui_actions/__init__.py +20 -0
  19. overcode/tui_actions/daemon.py +201 -0
  20. overcode/tui_actions/input.py +128 -0
  21. overcode/tui_actions/navigation.py +117 -0
  22. overcode/tui_actions/session.py +428 -0
  23. overcode/tui_actions/view.py +357 -0
  24. overcode/tui_helpers.py +41 -9
  25. overcode/tui_logic.py +347 -0
  26. overcode/tui_render.py +414 -0
  27. overcode/tui_widgets/__init__.py +24 -0
  28. overcode/tui_widgets/command_bar.py +399 -0
  29. overcode/tui_widgets/daemon_panel.py +153 -0
  30. overcode/tui_widgets/daemon_status_bar.py +245 -0
  31. overcode/tui_widgets/help_overlay.py +71 -0
  32. overcode/tui_widgets/preview_pane.py +69 -0
  33. overcode/tui_widgets/session_summary.py +514 -0
  34. overcode/tui_widgets/status_timeline.py +253 -0
  35. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
  36. overcode-0.1.4.dist-info/RECORD +68 -0
  37. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  38. overcode-0.1.3.dist-info/RECORD +0 -45
  39. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
  40. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  41. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,514 @@
1
+ """
2
+ Session summary widget for TUI.
3
+
4
+ Displays expandable session summary with status, metrics, and pane content.
5
+ """
6
+
7
+ from datetime import datetime
8
+ from typing import List, Optional
9
+
10
+ from textual.widgets import Static
11
+ from textual.reactive import reactive
12
+ from textual import events
13
+ from rich.text import Text
14
+
15
+ from ..session_manager import Session
16
+ from ..status_detector import StatusDetector
17
+ from ..history_reader import get_session_stats, ClaudeSessionStats
18
+ from ..tui_helpers import (
19
+ format_duration,
20
+ format_tokens,
21
+ format_cost,
22
+ format_line_count,
23
+ calculate_uptime,
24
+ get_current_state_times,
25
+ get_status_symbol,
26
+ style_pane_line,
27
+ get_git_diff_stats,
28
+ )
29
+
30
+
31
+ def format_standing_instructions(instructions: str, max_len: int = 95) -> str:
32
+ """Format standing instructions for display.
33
+
34
+ Shows "[DEFAULT]" if instructions match the configured default,
35
+ otherwise shows the truncated instructions.
36
+ """
37
+ from ..config import get_default_standing_instructions
38
+
39
+ if not instructions:
40
+ return ""
41
+
42
+ default = get_default_standing_instructions()
43
+ if default and instructions.strip() == default.strip():
44
+ return "[DEFAULT]"
45
+
46
+ if len(instructions) > max_len:
47
+ return instructions[:max_len - 3] + "..."
48
+ return instructions
49
+
50
+
51
+ class SessionSummary(Static, can_focus=True):
52
+ """Widget displaying expandable session summary"""
53
+
54
+ expanded: reactive[bool] = reactive(True) # Start expanded
55
+ detail_lines: reactive[int] = reactive(5) # Lines of output to show (5, 10, 20, 50)
56
+ summary_detail: reactive[str] = reactive("low") # low, med, full
57
+ summary_content_mode: reactive[str] = reactive("ai_short") # ai_short, ai_long, orders, annotation (#74)
58
+
59
+ def __init__(self, session: Session, status_detector: StatusDetector, *args, **kwargs):
60
+ super().__init__(*args, **kwargs)
61
+ self.session = session
62
+ self.status_detector = status_detector
63
+ # Initialize from session status (for terminated) or persisted state
64
+ if session.status == "terminated":
65
+ self.detected_status = "terminated"
66
+ self.current_activity = "(tmux window no longer exists)"
67
+ else:
68
+ self.detected_status = session.stats.current_state if session.stats.current_state else "running"
69
+ self.current_activity = "Initializing..."
70
+ # AI-generated summaries (from daemon's SummarizerComponent)
71
+ self.ai_summary_short: str = "" # Short: current activity (~50 chars)
72
+ self.ai_summary_context: str = "" # Context: wider context (~80 chars)
73
+ self.monochrome: bool = False # B&W mode for terminals with ANSI issues (#138)
74
+ self.show_cost: bool = False # Show $ cost instead of token counts
75
+ self.summarizer_enabled: bool = False # Track if summarizer is enabled
76
+ self.pane_content: List[str] = [] # Cached pane content
77
+ self.claude_stats: Optional[ClaudeSessionStats] = None # Token/interaction stats
78
+ self.git_diff_stats: Optional[tuple] = None # (files, insertions, deletions)
79
+ # Track if this is a stalled agent that hasn't been visited yet
80
+ self.is_unvisited_stalled: bool = False
81
+ # Track when status last changed (for immediate time-in-state updates)
82
+ self._status_changed_at: Optional[datetime] = None
83
+ self._last_known_status: str = self.detected_status
84
+ # Start with expanded class since expanded=True by default
85
+ self.add_class("expanded")
86
+
87
+ def on_click(self) -> None:
88
+ """Toggle expanded state on click"""
89
+ self.expanded = not self.expanded
90
+ # Notify parent app to save state
91
+ self.post_message(self.ExpandedChanged(self.session.id, self.expanded))
92
+ # Mark as visited if this is an unvisited stalled agent
93
+ if self.is_unvisited_stalled:
94
+ self.post_message(self.StalledAgentVisited(self.session.id))
95
+
96
+ def on_focus(self) -> None:
97
+ """Handle focus event - mark stalled agent as visited and update selection"""
98
+ if self.is_unvisited_stalled:
99
+ self.post_message(self.StalledAgentVisited(self.session.id))
100
+ # Notify app to update selection highlighting
101
+ self.post_message(self.SessionSelected(self.session.id))
102
+
103
+ class SessionSelected(events.Message):
104
+ """Message sent when a session is selected/focused"""
105
+ def __init__(self, session_id: str):
106
+ super().__init__()
107
+ self.session_id = session_id
108
+
109
+ class ExpandedChanged(events.Message):
110
+ """Message sent when expanded state changes"""
111
+ def __init__(self, session_id: str, expanded: bool):
112
+ super().__init__()
113
+ self.session_id = session_id
114
+ self.expanded = expanded
115
+
116
+ class StalledAgentVisited(events.Message):
117
+ """Message sent when user visits a stalled agent (focus or click)"""
118
+ def __init__(self, session_id: str):
119
+ super().__init__()
120
+ self.session_id = session_id
121
+
122
+ def watch_expanded(self, expanded: bool) -> None:
123
+ """Called when expanded state changes"""
124
+ # Toggle CSS class for proper height
125
+ if expanded:
126
+ self.add_class("expanded")
127
+ else:
128
+ self.remove_class("expanded")
129
+ self.refresh(layout=True)
130
+ # Notify parent app to save state
131
+ self.post_message(self.ExpandedChanged(self.session.id, expanded))
132
+
133
+ def watch_detail_lines(self, detail_lines: int) -> None:
134
+ """Called when detail_lines changes - force layout refresh"""
135
+ self.refresh(layout=True)
136
+
137
+ def update_status(self) -> None:
138
+ """Update the detected status for this session.
139
+
140
+ NOTE: This is now VIEW-ONLY. Time tracking is handled by the Monitor Daemon.
141
+ We only detect status for display and capture pane content for the expanded view.
142
+ """
143
+ # detect_status returns (status, activity, pane_content) - reuse content to avoid
144
+ # duplicate tmux subprocess calls (was 2 calls per widget, now just 1)
145
+ new_status, self.current_activity, content = self.status_detector.detect_status(self.session)
146
+ self.apply_status(new_status, self.current_activity, content)
147
+
148
+ def apply_status(self, status: str, activity: str, content: str) -> None:
149
+ """Apply pre-fetched status data to this widget.
150
+
151
+ Used by parallel status updates to apply data fetched in background threads.
152
+ Note: This still fetches claude_stats synchronously - used for single widget updates.
153
+ """
154
+ # Fetch claude stats (only for standalone update_status calls)
155
+ claude_stats = get_session_stats(self.session)
156
+ # Fetch git diff stats
157
+ git_diff = None
158
+ if self.session.start_directory:
159
+ git_diff = get_git_diff_stats(self.session.start_directory)
160
+ self.apply_status_no_refresh(status, activity, content, claude_stats, git_diff)
161
+ self.refresh()
162
+
163
+ def apply_status_no_refresh(self, status: str, activity: str, content: str, claude_stats: Optional[ClaudeSessionStats] = None, git_diff_stats: Optional[tuple] = None) -> None:
164
+ """Apply pre-fetched status data without triggering refresh.
165
+
166
+ Used for batched updates where the caller will refresh once at the end.
167
+ All data including claude_stats should be pre-fetched in background thread.
168
+ """
169
+ self.current_activity = activity
170
+
171
+ # Use pane content from detect_status (already fetched)
172
+ if content:
173
+ # Keep all lines including blanks for proper formatting, just strip trailing blanks
174
+ lines = content.rstrip().split('\n')
175
+ self.pane_content = lines[-50:] if lines else [] # Keep last 50 lines max
176
+ else:
177
+ self.pane_content = []
178
+
179
+ # Update detected status for display
180
+ # NOTE: Time tracking removed - Monitor Daemon is the single source of truth
181
+ # The session.stats values are read from what Monitor Daemon has persisted
182
+ # If session is asleep, keep the asleep status instead of the detected status
183
+ new_status = "asleep" if self.session.is_asleep else status
184
+
185
+ # Track status changes for immediate time-in-state reset (#73)
186
+ if new_status != self._last_known_status:
187
+ self._status_changed_at = datetime.now()
188
+ self._last_known_status = new_status
189
+
190
+ self.detected_status = new_status
191
+
192
+ # Use pre-fetched claude stats (no file I/O on main thread)
193
+ if claude_stats is not None:
194
+ self.claude_stats = claude_stats
195
+
196
+ # Use pre-fetched git diff stats
197
+ if git_diff_stats is not None:
198
+ self.git_diff_stats = git_diff_stats
199
+
200
+ def watch_summary_detail(self, summary_detail: str) -> None:
201
+ """Called when summary_detail changes"""
202
+ self.refresh()
203
+
204
+ def watch_summary_content_mode(self, summary_content_mode: str) -> None:
205
+ """Called when summary_content_mode changes (#74)"""
206
+ self.refresh()
207
+
208
+ def render(self) -> Text:
209
+ """Render session summary (compact or expanded)"""
210
+ import shutil
211
+ s = self.session
212
+ stats = s.stats
213
+ term_width = shutil.get_terminal_size().columns
214
+
215
+ # Helper for monochrome styling - returns simplified style when monochrome enabled
216
+ def mono(colored: str, simple: str = "bold") -> str:
217
+ return simple if self.monochrome else colored
218
+
219
+ # Expansion indicator
220
+ expand_icon = "▼" if self.expanded else "▶"
221
+
222
+ # Calculate all values (only use what we need per level)
223
+ uptime = calculate_uptime(self.session.start_time)
224
+ repo_info = f"{s.repo_name or 'n/a'}:{s.branch or 'n/a'}"
225
+ green_time, non_green_time, sleep_time = get_current_state_times(
226
+ self.session.stats, is_asleep=self.session.is_asleep
227
+ )
228
+
229
+ # Get median work time from claude stats (or 0 if unavailable)
230
+ median_work = self.claude_stats.median_work_time if self.claude_stats else 0.0
231
+
232
+ # Status indicator - larger emoji circles based on detected status
233
+ # Blue background matching Textual header/footer style
234
+ # In monochrome mode, use no colors (just bold/dim for emphasis)
235
+ if self.monochrome:
236
+ bg = ""
237
+ status_symbol, _ = get_status_symbol(self.detected_status)
238
+ status_color = "bold"
239
+ else:
240
+ bg = " on #0d2137"
241
+ status_symbol, base_color = get_status_symbol(self.detected_status)
242
+ status_color = f"bold {base_color}{bg}"
243
+
244
+ # Permissiveness mode with emoji
245
+ if s.permissiveness_mode == "bypass":
246
+ perm_emoji = "🔥" # Fire - burning through all permissions
247
+ elif s.permissiveness_mode == "permissive":
248
+ perm_emoji = "🏃" # Running permissively
249
+ else:
250
+ perm_emoji = "👮" # Normal mode with permissions
251
+
252
+ content = Text()
253
+
254
+ # Determine name width based on detail level (more space in lower detail modes)
255
+ if self.summary_detail == "low":
256
+ name_width = 24
257
+ elif self.summary_detail == "med":
258
+ name_width = 20
259
+ else: # full
260
+ name_width = 16
261
+
262
+ # Truncate name if needed
263
+ display_name = s.name[:name_width].ljust(name_width)
264
+
265
+ # Always show: status symbol, time in state, expand icon, agent name
266
+ content.append(f"{status_symbol} ", style=status_color)
267
+
268
+ # Show 🔔 indicator for unvisited stalled agents (needs attention)
269
+ if self.is_unvisited_stalled:
270
+ content.append("🔔", style=mono(f"bold blink red{bg}", "bold"))
271
+ else:
272
+ content.append(" ", style=mono(f"dim{bg}", "dim")) # Maintain alignment
273
+
274
+ # Time in current state (directly after status light)
275
+ # Use locally tracked change time if more recent than daemon's state_since (#73)
276
+ state_start = None
277
+ if self._status_changed_at:
278
+ state_start = self._status_changed_at
279
+ if stats.state_since:
280
+ try:
281
+ daemon_state_start = datetime.fromisoformat(stats.state_since)
282
+ # Use whichever is more recent (our local detection or daemon's record)
283
+ if state_start is None or daemon_state_start > state_start:
284
+ state_start = daemon_state_start
285
+ except (ValueError, TypeError):
286
+ pass
287
+ if state_start:
288
+ elapsed = (datetime.now() - state_start).total_seconds()
289
+ content.append(f"{format_duration(elapsed):>5} ", style=status_color)
290
+ else:
291
+ content.append(" - ", style=mono(f"dim{bg}", "dim"))
292
+
293
+ # In list-mode, show focus indicator instead of expand icon
294
+ if "list-mode" in self.classes:
295
+ if self.has_focus:
296
+ content.append("→ ", style=status_color)
297
+ else:
298
+ content.append(" ", style=status_color)
299
+ else:
300
+ content.append(f"{expand_icon} ", style=status_color)
301
+ content.append(f"{display_name}", style=mono(f"bold cyan{bg}", "bold"))
302
+
303
+ # Full detail: add repo:branch (padded to longest across all sessions)
304
+ if self.summary_detail == "full":
305
+ repo_width = getattr(self.app, 'max_repo_info_width', 18)
306
+ content.append(f" {repo_info:<{repo_width}} ", style=mono(f"bold dim{bg}", "dim"))
307
+
308
+ # Med/Full detail: add uptime, running time, stalled time, sleep time
309
+ if self.summary_detail in ("med", "full"):
310
+ content.append(f" ↑{uptime:>5}", style=mono(f"bold white{bg}", "bold"))
311
+ content.append(f" ▶{format_duration(green_time):>5}", style=mono(f"bold green{bg}", "bold"))
312
+ content.append(f" ⏸{format_duration(non_green_time):>5}", style=mono(f"bold red{bg}", "dim"))
313
+ # Show sleep time (#141) - always show for alignment, dim when 0
314
+ # Build complete column string with explicit padding to ensure consistent width
315
+ # Use 8 total cells: space(1) + emoji(2) + value(5) = 8
316
+ sleep_str = format_duration(sleep_time) if sleep_time > 0 else "-"
317
+ sleep_col = f" 💤{sleep_str:>5}" # This should be 8 cells
318
+ sleep_style = mono(f"bold cyan{bg}", "bold") if sleep_time > 0 else mono(f"dim cyan{bg}", "dim")
319
+ content.append(sleep_col, style=sleep_style)
320
+ # Full detail: show percentage active (excludes sleep time from total)
321
+ if self.summary_detail == "full":
322
+ active_time = green_time + non_green_time
323
+ pct = (green_time / active_time * 100) if active_time > 0 else 0
324
+ content.append(f" {pct:>3.0f}%", style=mono(f"bold green{bg}" if pct >= 50 else f"bold red{bg}", "bold"))
325
+
326
+ # Always show: token usage or cost (from Claude Code)
327
+ # ALIGNMENT: context indicator is always 7 chars " c@NNN%" (or placeholder)
328
+ if self.claude_stats is not None:
329
+ if self.show_cost:
330
+ # Show estimated cost instead of tokens
331
+ cost = s.stats.estimated_cost_usd
332
+ content.append(f" {format_cost(cost):>7}", style=mono(f"bold orange1{bg}", "bold"))
333
+ else:
334
+ content.append(f" Σ{format_tokens(self.claude_stats.total_tokens):>6}", style=mono(f"bold orange1{bg}", "bold"))
335
+ # Show current context window usage as percentage (assuming 200K max)
336
+ if self.claude_stats.current_context_tokens > 0:
337
+ max_context = 200_000 # Claude models have 200K context window
338
+ ctx_pct = min(100, self.claude_stats.current_context_tokens / max_context * 100)
339
+ content.append(f" c@{ctx_pct:>3.0f}%", style=mono(f"bold orange1{bg}", "bold"))
340
+ else:
341
+ content.append(" c@ -%", style=mono(f"dim orange1{bg}", "dim"))
342
+ else:
343
+ content.append(" - c@ -%", style=mono(f"dim orange1{bg}", "dim"))
344
+
345
+ # Git diff stats (outstanding changes since last commit)
346
+ # ALIGNMENT: Use fixed widths - low/med: 4 chars "Δnn ", full: 16 chars "Δnn +nnnn -nnnn"
347
+ # Large line counts are shortened: 173242 -> "173K", 1234567 -> "1.2M"
348
+ if self.git_diff_stats:
349
+ files, ins, dels = self.git_diff_stats
350
+ if self.summary_detail == "full":
351
+ # Full: show files and lines with fixed widths
352
+ content.append(f" Δ{files:>2}", style=mono(f"bold magenta{bg}", "bold"))
353
+ content.append(f" +{format_line_count(ins):>4}", style=mono(f"bold green{bg}", "bold"))
354
+ content.append(f" -{format_line_count(dels):>4}", style=mono(f"bold red{bg}", "dim"))
355
+ else:
356
+ # Compact: just files changed (fixed 4 char width)
357
+ content.append(f" Δ{files:>2}", style=mono(f"bold magenta{bg}" if files > 0 else f"dim{bg}", "bold" if files > 0 else "dim"))
358
+ else:
359
+ # Placeholder matching width for alignment
360
+ if self.summary_detail == "full":
361
+ content.append(" Δ- + - - ", style=mono(f"dim{bg}", "dim"))
362
+ else:
363
+ content.append(" Δ-", style=mono(f"dim{bg}", "dim"))
364
+
365
+ # Med/Full detail: add median work time (p50 autonomous work duration)
366
+ if self.summary_detail in ("med", "full"):
367
+ work_str = format_duration(median_work) if median_work > 0 else "0s"
368
+ content.append(f" ⏱{work_str:>5}", style=mono(f"bold blue{bg}", "bold"))
369
+
370
+ # Always show: permission mode, human interactions, robot supervisions
371
+ content.append(f" {perm_emoji}", style=mono(f"bold white{bg}", "bold"))
372
+ # Human interaction count = total interactions - robot interventions
373
+ if self.claude_stats is not None:
374
+ human_count = max(0, self.claude_stats.interaction_count - stats.steers_count)
375
+ content.append(f" 👤{human_count:>3}", style=mono(f"bold yellow{bg}", "bold"))
376
+ else:
377
+ content.append(" 👤 -", style=mono(f"dim yellow{bg}", "dim"))
378
+ # Robot supervision count (from daemon steers) - 3 digit padding
379
+ content.append(f" 🤖{stats.steers_count:>3}", style=mono(f"bold cyan{bg}", "bold"))
380
+
381
+ # Standing orders indicator (after supervision count) - always show for alignment
382
+ if s.standing_instructions:
383
+ if s.standing_orders_complete:
384
+ content.append(" ✓", style=mono(f"bold green{bg}", "bold"))
385
+ elif s.standing_instructions_preset:
386
+ # Show preset name (truncated to fit)
387
+ preset_display = f" {s.standing_instructions_preset[:8]}"
388
+ content.append(preset_display, style=mono(f"bold cyan{bg}", "bold"))
389
+ else:
390
+ content.append(" 📋", style=mono(f"bold yellow{bg}", "bold"))
391
+ else:
392
+ content.append(" ➖", style=mono(f"bold dim{bg}", "dim")) # No instructions indicator
393
+
394
+ # Agent value indicator (#61)
395
+ # Full detail: show numeric value with money bag
396
+ # Short/med: show priority chevrons (⏫ high, ⏹ normal, ⏬ low)
397
+ if self.summary_detail == "full":
398
+ content.append(f" 💰{s.agent_value:>4}", style=mono(f"bold magenta{bg}", "bold"))
399
+ else:
400
+ # Priority icon based on value relative to default 1000
401
+ # Note: Rich measures ⏹️ as 2 cells but ⏫️/⏬️ as 3 cells, so we add
402
+ # a trailing space to ⏹️ for alignment
403
+ if s.agent_value > 1000:
404
+ content.append(" ⏫️", style=mono(f"bold red{bg}", "bold")) # High priority
405
+ elif s.agent_value < 1000:
406
+ content.append(" ⏬️", style=mono(f"bold blue{bg}", "bold")) # Low priority
407
+ else:
408
+ content.append(" ⏹️ ", style=mono(f"dim{bg}", "dim")) # Normal (extra space for alignment)
409
+
410
+ if not self.expanded:
411
+ # Compact view: show content based on summary_content_mode (#74)
412
+ content.append(" │ ", style=mono(f"bold dim{bg}", "dim"))
413
+ # Calculate remaining space for content
414
+ current_len = len(content.plain)
415
+ remaining = max(20, term_width - current_len - 2)
416
+
417
+ # Determine what to show based on mode
418
+ mode = self.summary_content_mode
419
+
420
+ if mode == "annotation":
421
+ # Show human annotation (✏️ icon)
422
+ if s.human_annotation:
423
+ content.append(f"✏️ {s.human_annotation[:remaining-3]}", style=mono(f"bold magenta{bg}", "bold"))
424
+ else:
425
+ content.append("✏️ (no annotation)", style=mono(f"dim italic{bg}", "dim"))
426
+ elif mode == "orders":
427
+ # Show standing orders (🎯 icon, ✓ if complete)
428
+ if s.standing_instructions:
429
+ if s.standing_orders_complete:
430
+ order_style = mono(f"bold green{bg}", "bold")
431
+ prefix = "🎯✓ "
432
+ elif s.standing_instructions_preset:
433
+ order_style = mono(f"bold cyan{bg}", "bold")
434
+ prefix = f"🎯 {s.standing_instructions_preset}: "
435
+ else:
436
+ order_style = mono(f"bold italic yellow{bg}", "bold")
437
+ prefix = "🎯 "
438
+ display_text = f"{prefix}{format_standing_instructions(s.standing_instructions, remaining - len(prefix))}"
439
+ content.append(display_text[:remaining], style=order_style)
440
+ else:
441
+ content.append("🎯 (no standing orders)", style=mono(f"dim italic{bg}", "dim"))
442
+ elif mode == "ai_long":
443
+ # ai_long: show context summary (📖 icon - wider context/goal from AI)
444
+ if self.ai_summary_context:
445
+ content.append(f"📖 {self.ai_summary_context[:remaining-3]}", style=mono(f"bold italic{bg}", "bold"))
446
+ elif not self.summarizer_enabled:
447
+ content.append("📖 (summarizer disabled - press 'a')", style=mono(f"dim italic{bg}", "dim"))
448
+ else:
449
+ content.append("📖 (awaiting context...)", style=mono(f"dim italic{bg}", "dim"))
450
+ else:
451
+ # ai_short: show short summary (💬 icon - current activity from AI)
452
+ if self.ai_summary_short:
453
+ content.append(f"💬 {self.ai_summary_short[:remaining-3]}", style=mono(f"bold italic{bg}", "bold"))
454
+ elif not self.summarizer_enabled:
455
+ content.append("💬 (summarizer disabled - press 'a')", style=mono(f"dim italic{bg}", "dim"))
456
+ else:
457
+ content.append("💬 (awaiting summary...)", style=mono(f"dim italic{bg}", "dim"))
458
+
459
+ # Pad to fill terminal width
460
+ current_len = len(content.plain)
461
+ if current_len < term_width:
462
+ content.append(" " * (term_width - current_len), style=mono(f"{bg}", ""))
463
+ return content
464
+
465
+ # Pad header line to full width before adding expanded content
466
+ current_len = len(content.plain)
467
+ if current_len < term_width:
468
+ content.append(" " * (term_width - current_len), style=mono(f"{bg}", ""))
469
+
470
+ # Expanded view: show standing instructions first if set
471
+ if s.standing_instructions:
472
+ content.append("\n")
473
+ content.append(" ")
474
+ display_instr = format_standing_instructions(s.standing_instructions)
475
+ if s.standing_orders_complete:
476
+ content.append("│ ", style=mono("bold green", "bold"))
477
+ content.append("✓ ", style=mono("bold green", "bold"))
478
+ content.append(display_instr, style=mono("green", ""))
479
+ elif s.standing_instructions_preset:
480
+ content.append("│ ", style=mono("cyan", "dim"))
481
+ content.append(f"{s.standing_instructions_preset}: ", style=mono("bold cyan", "bold"))
482
+ content.append(display_instr, style=mono("cyan", ""))
483
+ else:
484
+ content.append("│ ", style=mono("cyan", "dim"))
485
+ content.append("📋 ", style=mono("yellow", "bold"))
486
+ content.append(display_instr, style=mono("italic yellow", "italic"))
487
+
488
+ # Expanded view: show pane content based on detail_lines setting
489
+ lines_to_show = self.detail_lines
490
+ # Account for standing instructions line if present
491
+ if s.standing_instructions:
492
+ lines_to_show = max(1, lines_to_show - 1)
493
+
494
+ # Get the last N lines of pane content
495
+ pane_lines = self.pane_content[-lines_to_show:] if self.pane_content else []
496
+
497
+ # Show pane output lines
498
+ for line in pane_lines:
499
+ content.append("\n")
500
+ content.append(" ") # Indent
501
+ # Truncate long lines and style based on content
502
+ display_line = line[:100] + "..." if len(line) > 100 else line
503
+ prefix_style, content_style = style_pane_line(line)
504
+ content.append("│ ", style=prefix_style)
505
+ content.append(display_line, style=content_style)
506
+
507
+ # If no pane content and no standing instructions shown above, show placeholder
508
+ if not pane_lines and not s.standing_instructions:
509
+ content.append("\n")
510
+ content.append(" ") # Indent
511
+ content.append("│ ", style=mono("cyan", "dim"))
512
+ content.append("(no output)", style=mono("dim italic", "dim"))
513
+
514
+ return content