kollabor 0.4.9__py3-none-any.whl → 0.4.15__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 (192) hide show
  1. agents/__init__.py +2 -0
  2. agents/coder/__init__.py +0 -0
  3. agents/coder/agent.json +4 -0
  4. agents/coder/api-integration.md +2150 -0
  5. agents/coder/cli-pretty.md +765 -0
  6. agents/coder/code-review.md +1092 -0
  7. agents/coder/database-design.md +1525 -0
  8. agents/coder/debugging.md +1102 -0
  9. agents/coder/dependency-management.md +1397 -0
  10. agents/coder/git-workflow.md +1099 -0
  11. agents/coder/refactoring.md +1454 -0
  12. agents/coder/security-hardening.md +1732 -0
  13. agents/coder/system_prompt.md +1448 -0
  14. agents/coder/tdd.md +1367 -0
  15. agents/creative-writer/__init__.py +0 -0
  16. agents/creative-writer/agent.json +4 -0
  17. agents/creative-writer/character-development.md +1852 -0
  18. agents/creative-writer/dialogue-craft.md +1122 -0
  19. agents/creative-writer/plot-structure.md +1073 -0
  20. agents/creative-writer/revision-editing.md +1484 -0
  21. agents/creative-writer/system_prompt.md +690 -0
  22. agents/creative-writer/worldbuilding.md +2049 -0
  23. agents/data-analyst/__init__.py +30 -0
  24. agents/data-analyst/agent.json +4 -0
  25. agents/data-analyst/data-visualization.md +992 -0
  26. agents/data-analyst/exploratory-data-analysis.md +1110 -0
  27. agents/data-analyst/pandas-data-manipulation.md +1081 -0
  28. agents/data-analyst/sql-query-optimization.md +881 -0
  29. agents/data-analyst/statistical-analysis.md +1118 -0
  30. agents/data-analyst/system_prompt.md +928 -0
  31. agents/default/__init__.py +0 -0
  32. agents/default/agent.json +4 -0
  33. agents/default/dead-code.md +794 -0
  34. agents/default/explore-agent-system.md +585 -0
  35. agents/default/system_prompt.md +1448 -0
  36. agents/kollabor/__init__.py +0 -0
  37. agents/kollabor/analyze-plugin-lifecycle.md +175 -0
  38. agents/kollabor/analyze-terminal-rendering.md +388 -0
  39. agents/kollabor/code-review.md +1092 -0
  40. agents/kollabor/debug-mcp-integration.md +521 -0
  41. agents/kollabor/debug-plugin-hooks.md +547 -0
  42. agents/kollabor/debugging.md +1102 -0
  43. agents/kollabor/dependency-management.md +1397 -0
  44. agents/kollabor/git-workflow.md +1099 -0
  45. agents/kollabor/inspect-llm-conversation.md +148 -0
  46. agents/kollabor/monitor-event-bus.md +558 -0
  47. agents/kollabor/profile-performance.md +576 -0
  48. agents/kollabor/refactoring.md +1454 -0
  49. agents/kollabor/system_prompt copy.md +1448 -0
  50. agents/kollabor/system_prompt.md +757 -0
  51. agents/kollabor/trace-command-execution.md +178 -0
  52. agents/kollabor/validate-config.md +879 -0
  53. agents/research/__init__.py +0 -0
  54. agents/research/agent.json +4 -0
  55. agents/research/architecture-mapping.md +1099 -0
  56. agents/research/codebase-analysis.md +1077 -0
  57. agents/research/dependency-audit.md +1027 -0
  58. agents/research/performance-profiling.md +1047 -0
  59. agents/research/security-review.md +1359 -0
  60. agents/research/system_prompt.md +492 -0
  61. agents/technical-writer/__init__.py +0 -0
  62. agents/technical-writer/agent.json +4 -0
  63. agents/technical-writer/api-documentation.md +2328 -0
  64. agents/technical-writer/changelog-management.md +1181 -0
  65. agents/technical-writer/readme-writing.md +1360 -0
  66. agents/technical-writer/style-guide.md +1410 -0
  67. agents/technical-writer/system_prompt.md +653 -0
  68. agents/technical-writer/tutorial-creation.md +1448 -0
  69. core/__init__.py +0 -2
  70. core/application.py +343 -88
  71. core/cli.py +229 -10
  72. core/commands/menu_renderer.py +463 -59
  73. core/commands/registry.py +14 -9
  74. core/commands/system_commands.py +2461 -14
  75. core/config/loader.py +151 -37
  76. core/config/service.py +18 -6
  77. core/events/bus.py +29 -9
  78. core/events/executor.py +205 -75
  79. core/events/models.py +27 -8
  80. core/fullscreen/command_integration.py +20 -24
  81. core/fullscreen/components/__init__.py +10 -1
  82. core/fullscreen/components/matrix_components.py +1 -2
  83. core/fullscreen/components/space_shooter_components.py +654 -0
  84. core/fullscreen/plugin.py +5 -0
  85. core/fullscreen/renderer.py +52 -13
  86. core/fullscreen/session.py +52 -15
  87. core/io/__init__.py +29 -5
  88. core/io/buffer_manager.py +6 -1
  89. core/io/config_status_view.py +7 -29
  90. core/io/core_status_views.py +267 -347
  91. core/io/input/__init__.py +25 -0
  92. core/io/input/command_mode_handler.py +711 -0
  93. core/io/input/display_controller.py +128 -0
  94. core/io/input/hook_registrar.py +286 -0
  95. core/io/input/input_loop_manager.py +421 -0
  96. core/io/input/key_press_handler.py +502 -0
  97. core/io/input/modal_controller.py +1011 -0
  98. core/io/input/paste_processor.py +339 -0
  99. core/io/input/status_modal_renderer.py +184 -0
  100. core/io/input_errors.py +5 -1
  101. core/io/input_handler.py +211 -2452
  102. core/io/key_parser.py +7 -0
  103. core/io/layout.py +15 -3
  104. core/io/message_coordinator.py +111 -2
  105. core/io/message_renderer.py +129 -4
  106. core/io/status_renderer.py +147 -607
  107. core/io/terminal_renderer.py +97 -51
  108. core/io/terminal_state.py +21 -4
  109. core/io/visual_effects.py +816 -165
  110. core/llm/agent_manager.py +1063 -0
  111. core/llm/api_adapters/__init__.py +44 -0
  112. core/llm/api_adapters/anthropic_adapter.py +432 -0
  113. core/llm/api_adapters/base.py +241 -0
  114. core/llm/api_adapters/openai_adapter.py +326 -0
  115. core/llm/api_communication_service.py +167 -113
  116. core/llm/conversation_logger.py +322 -16
  117. core/llm/conversation_manager.py +556 -30
  118. core/llm/file_operations_executor.py +84 -32
  119. core/llm/llm_service.py +934 -103
  120. core/llm/mcp_integration.py +541 -57
  121. core/llm/message_display_service.py +135 -18
  122. core/llm/plugin_sdk.py +1 -2
  123. core/llm/profile_manager.py +1183 -0
  124. core/llm/response_parser.py +274 -56
  125. core/llm/response_processor.py +16 -3
  126. core/llm/tool_executor.py +6 -1
  127. core/logging/__init__.py +2 -0
  128. core/logging/setup.py +34 -6
  129. core/models/resume.py +54 -0
  130. core/plugins/__init__.py +4 -2
  131. core/plugins/base.py +127 -0
  132. core/plugins/collector.py +23 -161
  133. core/plugins/discovery.py +37 -3
  134. core/plugins/factory.py +6 -12
  135. core/plugins/registry.py +5 -17
  136. core/ui/config_widgets.py +128 -28
  137. core/ui/live_modal_renderer.py +2 -1
  138. core/ui/modal_actions.py +5 -0
  139. core/ui/modal_overlay_renderer.py +0 -60
  140. core/ui/modal_renderer.py +268 -7
  141. core/ui/modal_state_manager.py +29 -4
  142. core/ui/widgets/base_widget.py +7 -0
  143. core/updates/__init__.py +10 -0
  144. core/updates/version_check_service.py +348 -0
  145. core/updates/version_comparator.py +103 -0
  146. core/utils/config_utils.py +685 -526
  147. core/utils/plugin_utils.py +1 -1
  148. core/utils/session_naming.py +111 -0
  149. fonts/LICENSE +21 -0
  150. fonts/README.md +46 -0
  151. fonts/SymbolsNerdFont-Regular.ttf +0 -0
  152. fonts/SymbolsNerdFontMono-Regular.ttf +0 -0
  153. fonts/__init__.py +44 -0
  154. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/METADATA +54 -4
  155. kollabor-0.4.15.dist-info/RECORD +228 -0
  156. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/top_level.txt +2 -0
  157. plugins/agent_orchestrator/__init__.py +39 -0
  158. plugins/agent_orchestrator/activity_monitor.py +181 -0
  159. plugins/agent_orchestrator/file_attacher.py +77 -0
  160. plugins/agent_orchestrator/message_injector.py +135 -0
  161. plugins/agent_orchestrator/models.py +48 -0
  162. plugins/agent_orchestrator/orchestrator.py +403 -0
  163. plugins/agent_orchestrator/plugin.py +976 -0
  164. plugins/agent_orchestrator/xml_parser.py +191 -0
  165. plugins/agent_orchestrator_plugin.py +9 -0
  166. plugins/enhanced_input/box_styles.py +1 -0
  167. plugins/enhanced_input/color_engine.py +19 -4
  168. plugins/enhanced_input/config.py +2 -2
  169. plugins/enhanced_input_plugin.py +61 -11
  170. plugins/fullscreen/__init__.py +6 -2
  171. plugins/fullscreen/example_plugin.py +1035 -222
  172. plugins/fullscreen/setup_wizard_plugin.py +592 -0
  173. plugins/fullscreen/space_shooter_plugin.py +131 -0
  174. plugins/hook_monitoring_plugin.py +436 -78
  175. plugins/query_enhancer_plugin.py +66 -30
  176. plugins/resume_conversation_plugin.py +1494 -0
  177. plugins/save_conversation_plugin.py +98 -32
  178. plugins/system_commands_plugin.py +70 -56
  179. plugins/tmux_plugin.py +154 -78
  180. plugins/workflow_enforcement_plugin.py +94 -92
  181. system_prompt/default.md +952 -886
  182. core/io/input_mode_manager.py +0 -402
  183. core/io/modal_interaction_handler.py +0 -315
  184. core/io/raw_input_processor.py +0 -946
  185. core/storage/__init__.py +0 -5
  186. core/storage/state_manager.py +0 -84
  187. core/ui/widget_integration.py +0 -222
  188. core/utils/key_reader.py +0 -171
  189. kollabor-0.4.9.dist-info/RECORD +0 -128
  190. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/WHEEL +0 -0
  191. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/entry_points.txt +0 -0
  192. {kollabor-0.4.9.dist-info → kollabor-0.4.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1494 @@
1
+ """Resume conversation plugin for session management."""
2
+
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import Dict, Any, List, Optional
6
+ from pathlib import Path
7
+
8
+ from core.events.models import CommandDefinition, CommandMode, CommandCategory, CommandResult, SlashCommand, UIConfig, Event
9
+ from core.models.resume import SessionMetadata, SessionSummary, ConversationMetadata
10
+ from core.io.visual_effects import AgnosterSegment
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class ResumeConversationPlugin:
16
+ """Plugin for resuming previous conversations."""
17
+
18
+ def __init__(self, **kwargs) -> None:
19
+ """Initialize the resume conversation plugin."""
20
+ self.name = "resume_conversation"
21
+ self.version = "1.0.0"
22
+ self.description = "Resume previous conversation sessions"
23
+ self.enabled = True
24
+ self.logger = logger
25
+
26
+ # Dependencies (will be injected during initialization)
27
+ self.conversation_manager = None
28
+ self.conversation_logger = None
29
+ self.event_bus = None
30
+ self.config = None
31
+ self.llm_service = None
32
+ self.renderer = None
33
+
34
+ async def initialize(self, event_bus, config, **kwargs) -> None:
35
+ """Initialize the plugin and register commands.
36
+
37
+ Args:
38
+ event_bus: Application event bus.
39
+ config: Configuration manager.
40
+ **kwargs: Additional initialization parameters.
41
+ """
42
+ try:
43
+ self.event_bus = event_bus
44
+ self.config = config
45
+
46
+ # Get dependencies from kwargs
47
+ self.conversation_manager = kwargs.get('conversation_manager')
48
+ self.conversation_logger = kwargs.get('conversation_logger')
49
+ self.llm_service = kwargs.get('llm_service')
50
+ self.renderer = kwargs.get('renderer')
51
+
52
+ # Get command registry
53
+ command_registry = kwargs.get('command_registry')
54
+ if not command_registry:
55
+ self.logger.warning("No command registry provided, resume commands not registered")
56
+ return
57
+
58
+ # Register resume command
59
+ self._register_resume_commands(command_registry)
60
+
61
+ # Register status view
62
+ await self._register_status_view()
63
+
64
+ self.logger.info("Resume conversation plugin initialized successfully")
65
+
66
+ except Exception as e:
67
+ self.logger.error(f"Error initializing resume conversation plugin: {e}")
68
+ raise
69
+
70
+ async def _register_status_view(self) -> None:
71
+ """Register resume conversation status view."""
72
+ try:
73
+ if (self.renderer and
74
+ hasattr(self.renderer, 'status_renderer') and
75
+ self.renderer.status_renderer and
76
+ hasattr(self.renderer.status_renderer, 'status_registry') and
77
+ self.renderer.status_renderer.status_registry):
78
+
79
+ from core.io.status_renderer import StatusViewConfig, BlockConfig
80
+
81
+ view = StatusViewConfig(
82
+ name="Resume",
83
+ plugin_source="resume_conversation",
84
+ priority=290,
85
+ blocks=[BlockConfig(
86
+ width_fraction=1.0,
87
+ content_provider=self._get_status_content,
88
+ title="Resume",
89
+ priority=100
90
+ )],
91
+ )
92
+
93
+ registry = self.renderer.status_renderer.status_registry
94
+ registry.register_status_view("resume_conversation", view)
95
+ self.logger.info("Registered 'Resume' status view")
96
+
97
+ except Exception as e:
98
+ self.logger.error(f"Failed to register status view: {e}")
99
+
100
+ def _get_status_content(self) -> List[str]:
101
+ """Get resume conversation status (agnoster style)."""
102
+ try:
103
+ seg = AgnosterSegment()
104
+ seg.add_lime("Resume", "dark")
105
+ seg.add_cyan("/resume", "dark")
106
+ seg.add_neutral("restore previous sessions", "mid")
107
+ return [seg.render()]
108
+
109
+ except Exception as e:
110
+ self.logger.error(f"Error getting status content: {e}")
111
+ seg = AgnosterSegment()
112
+ seg.add_neutral("Resume: Error", "dark")
113
+ return [seg.render()]
114
+
115
+ def _register_resume_commands(self, command_registry) -> None:
116
+ """Register resume-related commands.
117
+
118
+ Args:
119
+ command_registry: Command registry for registration.
120
+ """
121
+ try:
122
+ # Main resume command
123
+ resume_command = CommandDefinition(
124
+ name="resume",
125
+ description="Resume a previous conversation session",
126
+ handler=self.handle_resume,
127
+ plugin_name=self.name,
128
+ category=CommandCategory.CONVERSATION,
129
+ mode=CommandMode.STATUS_TAKEOVER,
130
+ aliases=["restore", "continue"],
131
+ icon="[⏯]",
132
+ ui_config=UIConfig(
133
+ type="modal",
134
+ title="Resume Conversation",
135
+ height=20,
136
+ width=80
137
+ )
138
+ )
139
+ command_registry.register_command(resume_command)
140
+
141
+ # Session search command
142
+ search_command = CommandDefinition(
143
+ name="sessions",
144
+ description="Search and browse conversation sessions",
145
+ handler=self.handle_sessions,
146
+ plugin_name=self.name,
147
+ category=CommandCategory.CONVERSATION,
148
+ mode=CommandMode.STATUS_TAKEOVER,
149
+ aliases=["history", "conversations"],
150
+ icon="[s]",
151
+ ui_config=UIConfig(
152
+ type="modal",
153
+ title="Conversation History",
154
+ height=20,
155
+ width=80
156
+ )
157
+ )
158
+ command_registry.register_command(search_command)
159
+
160
+ # Branch command
161
+ branch_command = CommandDefinition(
162
+ name="branch",
163
+ description="Branch conversation from a specific message",
164
+ handler=self.handle_branch,
165
+ plugin_name=self.name,
166
+ category=CommandCategory.CONVERSATION,
167
+ mode=CommandMode.STATUS_TAKEOVER,
168
+ aliases=["fork"],
169
+ icon="[⑂]",
170
+ ui_config=UIConfig(
171
+ type="modal",
172
+ title="Branch Conversation",
173
+ height=20,
174
+ width=80
175
+ )
176
+ )
177
+ command_registry.register_command(branch_command)
178
+
179
+ except Exception as e:
180
+ self.logger.error(f"Error registering resume commands: {e}")
181
+
182
+ async def handle_resume(self, command: SlashCommand) -> CommandResult:
183
+ """Handle /resume command.
184
+
185
+ Args:
186
+ command: Parsed slash command.
187
+
188
+ Returns:
189
+ Command execution result.
190
+ """
191
+ try:
192
+ args = command.args or []
193
+ force = False
194
+ if args:
195
+ force = "--force" in args
196
+ args = [arg for arg in args if arg != "--force"]
197
+
198
+ if len(args) == 0:
199
+ # Show session selection modal
200
+ return await self._show_conversation_menu()
201
+ elif len(args) == 1:
202
+ # Resume specific session by ID
203
+ session_id = args[0]
204
+ return await self._load_conversation(session_id, force=force)
205
+ elif len(args) >= 2 and args[0].lower() == "search":
206
+ # Search sessions
207
+ query = " ".join(args[1:])
208
+ return await self._search_conversations(query)
209
+ else:
210
+ # Handle filters and other options
211
+ return await self._handle_resume_options(args)
212
+
213
+ except Exception as e:
214
+ self.logger.error(f"Error in resume command: {e}")
215
+ return CommandResult(
216
+ success=False,
217
+ message=f"Error resuming conversation: {str(e)}",
218
+ display_type="error"
219
+ )
220
+
221
+ async def handle_sessions(self, command: SlashCommand) -> CommandResult:
222
+ """Handle /sessions command.
223
+
224
+ Args:
225
+ command: Parsed slash command.
226
+
227
+ Returns:
228
+ Command execution result.
229
+ """
230
+ try:
231
+ args = command.args or []
232
+
233
+ if len(args) == 0:
234
+ # Show all sessions
235
+ return await self._show_conversation_menu()
236
+ elif args[0].lower() == "search":
237
+ if len(args) > 1:
238
+ query = " ".join(args[1:])
239
+ return await self._search_conversations(query)
240
+ else:
241
+ return CommandResult(
242
+ success=False,
243
+ message="Usage: /sessions search <query>",
244
+ display_type="error"
245
+ )
246
+ else:
247
+ return CommandResult(
248
+ success=False,
249
+ message="Usage: /sessions [search <query>]",
250
+ display_type="error"
251
+ )
252
+
253
+ except Exception as e:
254
+ self.logger.error(f"Error in sessions command: {e}")
255
+ return CommandResult(
256
+ success=False,
257
+ message=f"Error browsing sessions: {str(e)}",
258
+ display_type="error"
259
+ )
260
+
261
+ async def handle_branch(self, command: SlashCommand) -> CommandResult:
262
+ """Handle /branch command for branching conversations.
263
+
264
+ Creates a new branch from any conversation at any message point.
265
+ Original conversation remains intact, new branch is created.
266
+
267
+ Usage:
268
+ /branch - Show sessions to branch from
269
+ /branch <session_id> - Show messages to select branch point
270
+ /branch <session_id> <idx> - Create branch from message at index
271
+
272
+ Args:
273
+ command: Parsed slash command.
274
+
275
+ Returns:
276
+ Command execution result.
277
+ """
278
+ try:
279
+ args = command.args or []
280
+
281
+ if not self._ensure_conversation_manager():
282
+ return CommandResult(success=False, message="Conversation manager not available", display_type="error")
283
+
284
+ if len(args) == 0:
285
+ # Step 1: Show sessions to branch from (including current)
286
+ return await self._show_branch_session_selector()
287
+
288
+ elif len(args) == 1:
289
+ # Step 2: Show messages from session to select branch point
290
+ session_id = args[0]
291
+ return await self._show_branch_point_selector(session_id)
292
+
293
+ elif len(args) >= 2:
294
+ # Step 3: Execute the branch - create new session from branch point
295
+ session_id = args[0]
296
+ self.logger.info(f"[BRANCH] Step 3: Execute branch from session={session_id}")
297
+ try:
298
+ branch_index = int(args[1])
299
+ except ValueError:
300
+ self.logger.error(f"[BRANCH] Invalid branch index: {args[1]}")
301
+ return CommandResult(
302
+ success=False,
303
+ message=f"Invalid branch index: {args[1]}. Must be a number.",
304
+ display_type="error"
305
+ )
306
+
307
+ self.logger.info(f"[BRANCH] Executing branch at index {branch_index}")
308
+ # Create branch and load it
309
+ result = await self._execute_branch_from_session(session_id, branch_index)
310
+ self.logger.info(f"[BRANCH] Result: success={result.success}, message={result.message[:50]}...")
311
+ return result
312
+
313
+ except Exception as e:
314
+ self.logger.error(f"Error in branch command: {e}")
315
+ return CommandResult(
316
+ success=False,
317
+ message=f"Error branching conversation: {str(e)}",
318
+ display_type="error"
319
+ )
320
+
321
+ async def _execute_branch_from_session(self, session_id: str, branch_index: int) -> CommandResult:
322
+ """Execute branch operation - create new session from branch point and load it.
323
+
324
+ Args:
325
+ session_id: Source session to branch from (or "current" for active conversation).
326
+ branch_index: Index to branch from (inclusive).
327
+
328
+ Returns:
329
+ Command result.
330
+ """
331
+ try:
332
+ # Handle current conversation
333
+ if session_id == "current":
334
+ if not self.llm_service or not hasattr(self.llm_service, 'conversation_history'):
335
+ return CommandResult(
336
+ success=False,
337
+ message="No current conversation available",
338
+ display_type="error"
339
+ )
340
+
341
+ current_messages = self.llm_service.conversation_history
342
+ if branch_index < 0 or branch_index >= len(current_messages):
343
+ return CommandResult(
344
+ success=False,
345
+ message=f"Invalid index. Must be 0-{len(current_messages)-1}.",
346
+ display_type="error"
347
+ )
348
+
349
+ # Create branch from current - keep messages up to branch_index
350
+ branched_messages = current_messages[:branch_index + 1]
351
+ self.llm_service.conversation_history = branched_messages
352
+
353
+ # Update session stats to reflect truncated messages
354
+ if hasattr(self.llm_service, 'session_stats'):
355
+ self.llm_service.session_stats["messages"] = len(branched_messages)
356
+
357
+ msg_count = len(branched_messages)
358
+ removed_count = len(current_messages) - msg_count
359
+
360
+ return CommandResult(
361
+ success=True,
362
+ message=f"[ok] Branched at message {branch_index}\n"
363
+ f" Kept {msg_count} messages, removed {removed_count}\n"
364
+ f" Continue the conversation from this point.",
365
+ display_type="success"
366
+ )
367
+
368
+ # Branch from saved session
369
+ result = self.conversation_manager.branch_session(session_id, branch_index)
370
+
371
+ if not result.get("success"):
372
+ return CommandResult(success=False, message=f"Branch failed: {result.get('error', 'Unknown error')}", display_type="error")
373
+
374
+ new_session_id = result.get("session_id")
375
+ return await self._load_and_display_session(
376
+ header=f"--- Branched from {session_id} at message {branch_index} ---",
377
+ success_msg=(
378
+ f"[ok] Created branch: {new_session_id}\n"
379
+ f" From: {session_id} at message {result['branch_point']}\n"
380
+ f" Loaded {result['message_count']} messages. Continue below."
381
+ )
382
+ )
383
+
384
+ except Exception as e:
385
+ self.logger.error(f"Error executing branch: {e}")
386
+ return CommandResult(
387
+ success=False,
388
+ message=f"Error executing branch: {str(e)}",
389
+ display_type="error"
390
+ )
391
+
392
+ async def _show_branch_session_selector(self) -> CommandResult:
393
+ """Show modal to select a session to branch from."""
394
+ session_items = []
395
+
396
+ # Add current conversation as first option if it has messages
397
+ current_msg_count = 0
398
+ if self.llm_service and hasattr(self.llm_service, 'conversation_history'):
399
+ current_messages = self.llm_service.conversation_history
400
+ current_msg_count = len(current_messages) if current_messages else 0
401
+
402
+ if current_msg_count >= 2:
403
+ # Get first user message for preview
404
+ first_user_msg = ""
405
+ for msg in current_messages:
406
+ role = msg.role if hasattr(msg, 'role') else msg.get('role', '')
407
+ content = msg.content if hasattr(msg, 'content') else msg.get('content', '')
408
+ if role == "user" and content:
409
+ first_user_msg = content[:45].split('\n')[0]
410
+ if len(content) > 45:
411
+ first_user_msg += "..."
412
+ break
413
+
414
+ session_items.append({
415
+ "id": "current",
416
+ "title": f"[*CURRENT*] {first_user_msg or 'Active conversation'}",
417
+ "subtitle": f"{current_msg_count} msgs | this session",
418
+ "metadata": {"session_id": "current"},
419
+ "action": "branch_select_session"
420
+ })
421
+
422
+ # Add saved conversations
423
+ conversations = await self.discover_conversations(limit=20)
424
+
425
+ for conv in conversations:
426
+ time_str = conv.created_time.strftime("%m/%d %H:%M") if conv.created_time else "Unknown"
427
+ project_name = conv.working_directory.split("/")[-1] if conv.working_directory else "unknown"
428
+
429
+ # Get user's actual request - skip system prompts
430
+ user_request = ""
431
+ if conv.preview_messages:
432
+ for msg in conv.preview_messages:
433
+ role = msg.get("role", "")
434
+ content = msg.get("content", "")
435
+ # Skip system messages and empty content
436
+ if role == "system" or not content:
437
+ continue
438
+ # Skip if content looks like a system prompt (starts with common prompt markers)
439
+ content_lower = content.lower()[:50]
440
+ if any(marker in content_lower for marker in ["you are", "system prompt", "assistant", "kollabor"]):
441
+ continue
442
+ if role == "user" and content:
443
+ user_request = content
444
+ break
445
+
446
+ # Truncate and clean up first line
447
+ if user_request:
448
+ first_line = user_request.split('\n')[0].strip()[:50]
449
+ if len(user_request.split('\n')[0]) > 50:
450
+ first_line += "..."
451
+ else:
452
+ first_line = f"{conv.message_count} messages"
453
+
454
+ session_items.append({
455
+ "id": conv.session_id,
456
+ "title": f"[{time_str}] {first_line}",
457
+ "subtitle": f"{conv.message_count} msgs | {project_name} | {conv.git_branch or '-'}",
458
+ "metadata": {"session_id": conv.session_id},
459
+ "action": "branch_select_session"
460
+ })
461
+
462
+ if not session_items:
463
+ return CommandResult(
464
+ success=False,
465
+ message="No conversations found to branch from. Start a conversation first.",
466
+ display_type="info"
467
+ )
468
+
469
+ modal_definition = {
470
+ "title": "Branch From Session",
471
+ "footer": "Up/Down navigate | Enter select | Esc cancel",
472
+ "width": 80,
473
+ "height": 20,
474
+ "sections": [
475
+ {
476
+ "title": f"Select session to branch ({len(session_items)} available)",
477
+ "type": "session_list",
478
+ "sessions": session_items
479
+ }
480
+ ]
481
+ }
482
+
483
+ return CommandResult(
484
+ success=True,
485
+ message="Select a session to branch from",
486
+ display_type="modal",
487
+ ui_config=UIConfig(
488
+ type="modal",
489
+ title=modal_definition["title"],
490
+ modal_config=modal_definition
491
+ )
492
+ )
493
+
494
+ async def _show_branch_point_selector(self, session_id: str) -> CommandResult:
495
+ """Show modal to select branch point message."""
496
+ messages = []
497
+
498
+ # Handle current conversation
499
+ if session_id == "current":
500
+ if self.llm_service and hasattr(self.llm_service, 'conversation_history'):
501
+ current_messages = self.llm_service.conversation_history
502
+ for i, msg in enumerate(current_messages or []):
503
+ role = msg.role if hasattr(msg, 'role') else msg.get('role', 'unknown')
504
+ content = msg.content if hasattr(msg, 'content') else msg.get('content', '')
505
+ preview = content[:50].replace('\n', ' ')
506
+ if len(content) > 50:
507
+ preview += "..."
508
+ messages.append({
509
+ "index": i,
510
+ "role": role,
511
+ "preview": preview,
512
+ "timestamp": None
513
+ })
514
+ title_text = "Current Session"
515
+ else:
516
+ # Get messages from saved session
517
+ messages = self.conversation_manager.get_session_messages(session_id)
518
+ # Format title - extract readable part from session_id (e.g., "2512301235-shadow-rise" -> "shadow-rise")
519
+ parts = session_id.split("-", 1)
520
+ title_text = parts[1] if len(parts) > 1 else session_id[:12]
521
+
522
+ if not messages:
523
+ return CommandResult(
524
+ success=False,
525
+ message=f"No messages found in session: {session_id}",
526
+ display_type="error"
527
+ )
528
+
529
+ # Build message selector - skip system messages from display
530
+ message_items = []
531
+ for msg in messages:
532
+ role = msg["role"]
533
+ preview = msg["preview"]
534
+
535
+ # Skip system prompt messages from selection (can't meaningfully branch from them)
536
+ if role == "system":
537
+ continue
538
+
539
+ # Role indicators and labels
540
+ if role == "user":
541
+ role_indicator = "YOU:"
542
+ role_label = "user"
543
+ else:
544
+ role_indicator = "AI:"
545
+ role_label = "assistant"
546
+
547
+ # Clean up preview
548
+ clean_preview = preview.strip()[:55]
549
+ if len(preview) > 55:
550
+ clean_preview += "..."
551
+
552
+ # Format timestamp if available
553
+ ts = msg.get('timestamp')
554
+ time_str = ts[11:16] if ts and len(ts) > 16 else "" # Just HH:MM
555
+
556
+ message_items.append({
557
+ "id": str(msg["index"]),
558
+ "title": f"[{msg['index']}] {role_indicator} {clean_preview}",
559
+ "subtitle": f"{role_label}{' | ' + time_str if time_str else ''}",
560
+ "metadata": {
561
+ "session_id": session_id,
562
+ "message_index": msg["index"]
563
+ },
564
+ "action": "branch_execute",
565
+ "exit_mode": "minimal" # Plugin will display content after modal exit
566
+ })
567
+
568
+ modal_definition = {
569
+ "title": f"Branch Point: {title_text}",
570
+ "footer": "Up/Down navigate | Enter branch here | Esc cancel",
571
+ "width": 80,
572
+ "height": 20,
573
+ "sections": [
574
+ {
575
+ "title": f"Select message to branch from ({len(messages)} messages)",
576
+ "type": "session_list",
577
+ "sessions": message_items
578
+ }
579
+ ]
580
+ }
581
+
582
+ return CommandResult(
583
+ success=True,
584
+ message=f"Select branch point",
585
+ display_type="modal",
586
+ ui_config=UIConfig(
587
+ type="modal",
588
+ title=modal_definition["title"],
589
+ modal_config=modal_definition
590
+ )
591
+ )
592
+
593
+ async def discover_conversations(self, limit: int = 50) -> List[ConversationMetadata]:
594
+ """Discover available conversations.
595
+
596
+ Args:
597
+ limit: Maximum number of conversations to return
598
+
599
+ Returns:
600
+ List of conversation metadata
601
+ """
602
+ conversations = []
603
+
604
+ try:
605
+ if not self.conversation_logger:
606
+ self.logger.warning("Conversation logger not available")
607
+ return conversations
608
+
609
+ # Get sessions from conversation logger
610
+ sessions = self.conversation_logger.list_sessions()
611
+
612
+ for session_data in sessions:
613
+ # Stop if we have enough conversations
614
+ if len(conversations) >= limit:
615
+ break
616
+
617
+ try:
618
+ # Filter out sessions with 2 or fewer messages
619
+ message_count = session_data.get("message_count", 0)
620
+ if message_count <= 2:
621
+ continue
622
+
623
+ # Strip 'session_' prefix if present for compatibility with conversation_manager
624
+ raw_session_id = session_data.get("session_id", "")
625
+ session_id = raw_session_id.replace("session_", "") if raw_session_id.startswith("session_") else raw_session_id
626
+
627
+ metadata = ConversationMetadata(
628
+ file_path=session_data.get("file_path", ""),
629
+ session_id=session_id,
630
+ title=self._generate_session_title(session_data),
631
+ message_count=message_count,
632
+ created_time=self._parse_datetime(session_data.get("start_time")),
633
+ modified_time=self._parse_datetime(session_data.get("end_time")),
634
+ last_message_preview=session_data.get("preview_messages", [{}])[0].get("content", ""),
635
+ topics=session_data.get("topics", []),
636
+ file_id=self._generate_file_id(session_data.get("session_id", "")),
637
+ working_directory=session_data.get("working_directory", "unknown"),
638
+ git_branch=session_data.get("git_branch", "unknown"),
639
+ duration=session_data.get("duration"),
640
+ size_bytes=session_data.get("size_bytes", 0),
641
+ preview_messages=session_data.get("preview_messages", [])
642
+ )
643
+ conversations.append(metadata)
644
+
645
+ except Exception as e:
646
+ self.logger.warning(f"Failed to process session: {e}")
647
+ continue
648
+
649
+ except Exception as e:
650
+ self.logger.error(f"Failed to discover conversations: {e}")
651
+
652
+ return conversations
653
+
654
+ async def _show_conversation_menu(self) -> CommandResult:
655
+ """Show interactive conversation selection menu.
656
+
657
+ Returns:
658
+ Command result with modal UI.
659
+ """
660
+ try:
661
+ conversations = await self.discover_conversations()
662
+
663
+ if not conversations:
664
+ return CommandResult(
665
+ success=False,
666
+ message="No saved conversations found.\n\nTip: Use /save to save current conversations for future resumption.",
667
+ display_type="info"
668
+ )
669
+
670
+ # Build modal definition
671
+ modal_definition = self._build_conversation_modal(conversations)
672
+
673
+ return CommandResult(
674
+ success=True,
675
+ message="Select a conversation to resume",
676
+ ui_config=UIConfig(
677
+ type="modal",
678
+ title=modal_definition["title"],
679
+ width=modal_definition["width"],
680
+ height=modal_definition["height"],
681
+ modal_config=modal_definition
682
+ ),
683
+ display_type="modal"
684
+ )
685
+
686
+ except Exception as e:
687
+ self.logger.error(f"Error showing conversation menu: {e}")
688
+ return CommandResult(
689
+ success=False,
690
+ message=f"Error loading conversations: {str(e)}",
691
+ display_type="error"
692
+ )
693
+
694
+ async def _search_conversations(self, query: str) -> CommandResult:
695
+ """Search conversations by content.
696
+
697
+ Args:
698
+ query: Search query
699
+
700
+ Returns:
701
+ Command result with search modal.
702
+ """
703
+ try:
704
+ if not self.conversation_logger:
705
+ return CommandResult(
706
+ success=False,
707
+ message="Conversation search not available",
708
+ display_type="error"
709
+ )
710
+
711
+ sessions = self.conversation_logger.search_sessions(query)
712
+
713
+ if not sessions:
714
+ return CommandResult(
715
+ success=False,
716
+ message=f"No conversations found matching: {query}",
717
+ display_type="info"
718
+ )
719
+
720
+ # Convert to conversation metadata
721
+ conversations = []
722
+ for session_data in sessions[:20]: # Limit search results
723
+ # Strip 'session_' prefix if present for compatibility with conversation_manager
724
+ raw_session_id = session_data.get("session_id", "")
725
+ session_id = raw_session_id.replace("session_", "") if raw_session_id.startswith("session_") else raw_session_id
726
+
727
+ metadata = ConversationMetadata(
728
+ file_path=session_data.get("file_path", ""),
729
+ session_id=session_id,
730
+ title=self._generate_session_title(session_data),
731
+ message_count=session_data.get("message_count", 0),
732
+ created_time=self._parse_datetime(session_data.get("start_time")),
733
+ modified_time=self._parse_datetime(session_data.get("end_time")),
734
+ last_message_preview=session_data.get("preview_messages", [{}])[0].get("content", ""),
735
+ topics=session_data.get("topics", []),
736
+ file_id=self._generate_file_id(session_data.get("session_id", "")),
737
+ working_directory=session_data.get("working_directory", "unknown"),
738
+ git_branch=session_data.get("git_branch", "unknown"),
739
+ duration=session_data.get("duration"),
740
+ size_bytes=session_data.get("size_bytes", 0),
741
+ preview_messages=session_data.get("preview_messages", []),
742
+ search_relevance=session_data.get("search_relevance")
743
+ )
744
+ conversations.append(metadata)
745
+
746
+ # Build search modal
747
+ modal_definition = self._build_search_modal(conversations, query)
748
+
749
+ return CommandResult(
750
+ success=True,
751
+ message=f"Found {len(conversations)} conversations matching: {query}",
752
+ ui_config=UIConfig(
753
+ type="modal",
754
+ title=modal_definition["title"],
755
+ width=modal_definition["width"],
756
+ height=modal_definition["height"],
757
+ modal_config=modal_definition
758
+ ),
759
+ display_type="modal"
760
+ )
761
+
762
+ except Exception as e:
763
+ self.logger.error(f"Error searching conversations: {e}")
764
+ return CommandResult(
765
+ success=False,
766
+ message=f"Error searching conversations: {str(e)}",
767
+ display_type="error"
768
+ )
769
+
770
+ def _get_conversation_manager(self):
771
+ """Get or create conversation manager instance.
772
+
773
+ Returns:
774
+ Conversation manager instance or None.
775
+ """
776
+ # Return existing if available
777
+ if self.conversation_manager:
778
+ return self.conversation_manager
779
+
780
+ # Try to create one
781
+ try:
782
+ from core.llm.conversation_manager import ConversationManager
783
+ from core.utils.config_utils import get_conversations_dir
784
+
785
+ # Use a basic config if no manager available
786
+ class BasicConfig:
787
+ def get(self, key, default=None):
788
+ return default
789
+
790
+ config = BasicConfig()
791
+ conversations_dir = get_conversations_dir()
792
+ conversations_dir.mkdir(parents=True, exist_ok=True)
793
+
794
+ self.conversation_manager = ConversationManager(config)
795
+ return self.conversation_manager
796
+ except Exception as e:
797
+ self.logger.warning(f"Could not create conversation manager: {e}")
798
+ return None
799
+
800
+ def _ensure_conversation_manager(self) -> bool:
801
+ """Ensure conversation manager is available. Returns True if available."""
802
+ if not self.conversation_manager:
803
+ self.conversation_manager = self._get_conversation_manager()
804
+ return self.conversation_manager is not None
805
+
806
+ def _prepare_session_display(self, header: str, success_msg: str) -> list:
807
+ """Prepare session messages for display. Loads into llm_service but returns messages instead of displaying.
808
+
809
+ Args:
810
+ header: Header message
811
+ success_msg: Success message
812
+
813
+ Returns:
814
+ List of display message tuples, or empty list on failure
815
+ """
816
+ if not self.llm_service:
817
+ self.logger.warning("llm_service not available")
818
+ return []
819
+
820
+ raw_messages = self.conversation_manager.messages
821
+ self.logger.info(f"[SESSION] raw_messages count: {len(raw_messages)}")
822
+
823
+ from core.models import ConversationMessage
824
+
825
+ loaded_messages = []
826
+ display_messages = [("system", header, {})]
827
+
828
+ for msg in raw_messages:
829
+ role = msg.get("role", "user")
830
+ content = msg.get("content", "")
831
+ loaded_messages.append(ConversationMessage(role=role, content=content))
832
+
833
+ if role in ("user", "assistant"):
834
+ display_messages.append((role, content, {}))
835
+
836
+ self.llm_service.conversation_history = loaded_messages
837
+
838
+ if hasattr(self.llm_service, 'session_stats'):
839
+ self.llm_service.session_stats["messages"] = len(loaded_messages)
840
+
841
+ display_messages.append(("system", success_msg, {}))
842
+ return display_messages
843
+
844
+ async def _load_and_display_session(self, header: str, success_msg: str) -> CommandResult:
845
+ """Load session into llm_service and display in UI.
846
+
847
+ Reads messages from self.conversation_manager.messages (must be populated first).
848
+ Used by both resume and branch after they load/create the session.
849
+
850
+ Args:
851
+ header: Header message for the display
852
+ success_msg: Success message to show at end
853
+
854
+ Returns:
855
+ CommandResult (always success with empty message, or error)
856
+ """
857
+ if not self.llm_service:
858
+ return CommandResult(success=False, message="LLM service not available", display_type="error")
859
+
860
+ raw_messages = self.conversation_manager.messages
861
+ self.logger.info(f"[SESSION] raw_messages count: {len(raw_messages)}")
862
+
863
+ from core.models import ConversationMessage
864
+
865
+ loaded_messages = []
866
+ messages_for_display = [{"role": "system", "content": header}]
867
+
868
+ for msg in raw_messages:
869
+ role = msg.get("role", "user")
870
+ content = msg.get("content", "")
871
+ loaded_messages.append(ConversationMessage(role=role, content=content))
872
+
873
+ if role in ("user", "assistant"):
874
+ messages_for_display.append({"role": role, "content": content})
875
+
876
+ self.llm_service.conversation_history = loaded_messages
877
+
878
+ if hasattr(self.llm_service, 'session_stats'):
879
+ self.llm_service.session_stats["messages"] = len(loaded_messages)
880
+
881
+ # Add success message
882
+ messages_for_display.append({"role": "system", "content": success_msg})
883
+
884
+ # Use ADD_MESSAGE event for unified display with loading
885
+ from core.events.models import EventType
886
+ await self.event_bus.emit_with_hooks(
887
+ EventType.ADD_MESSAGE,
888
+ {
889
+ "messages": messages_for_display,
890
+ "options": {
891
+ "show_loading": True,
892
+ "loading_message": "Loading conversation...",
893
+ "log_messages": False, # Already logged
894
+ "add_to_history": False, # Already loaded above
895
+ "display_messages": True
896
+ }
897
+ },
898
+ "resume_plugin"
899
+ )
900
+
901
+ return CommandResult(success=True, message="", display_type="success")
902
+
903
+ async def _load_conversation(self, session_id: str, force: bool = False) -> CommandResult:
904
+ """Load specific conversation by session ID into a new session."""
905
+ try:
906
+ if not self._ensure_conversation_manager():
907
+ return CommandResult(success=False, message="Conversation manager not available", display_type="error")
908
+
909
+ # Auto-save current conversation if it has messages
910
+ if self.conversation_manager.messages:
911
+ old_session = self.conversation_manager.current_session_id
912
+ self.conversation_manager.save_conversation()
913
+ self.logger.info(f"Auto-saved current session: {old_session}")
914
+
915
+ # Load the selected conversation's data
916
+ if not self.conversation_manager.load_session(session_id):
917
+ return CommandResult(success=False, message=f"Failed to load session: {session_id}", display_type="error")
918
+
919
+ # Create fresh session name for the resumed conversation
920
+ from core.utils.session_naming import generate_session_name
921
+ new_session_id = generate_session_name()
922
+ self.conversation_manager.current_session_id = new_session_id
923
+
924
+ return await self._load_and_display_session(
925
+ header=f"--- Resumed: {session_id[:20]}... as {new_session_id} ---",
926
+ success_msg=f"[ok] Resumed: {new_session_id}. Continue below."
927
+ )
928
+
929
+ except Exception as e:
930
+ self.logger.error(f"Error loading conversation: {e}")
931
+ return CommandResult(success=False, message=f"Error: {str(e)}", display_type="error")
932
+
933
+ async def _handle_resume_options(self, args: List[str]) -> CommandResult:
934
+ """Handle additional resume options and filters.
935
+
936
+ Args:
937
+ args: Command arguments
938
+
939
+ Returns:
940
+ Command result.
941
+ """
942
+ # Parse filters like --today, --week, --limit N
943
+ filters = {}
944
+ limit = 20
945
+
946
+ i = 0
947
+ while i < len(args):
948
+ arg = args[i]
949
+
950
+ if arg == "--today":
951
+ from datetime import date
952
+ filters["date"] = date.today().isoformat()
953
+ elif arg == "--week":
954
+ from datetime import date, timedelta
955
+ filters["date_range"] = (
956
+ (date.today() - timedelta(days=7)).isoformat(),
957
+ date.today().isoformat()
958
+ )
959
+ elif arg == "--limit" and i + 1 < len(args):
960
+ try:
961
+ limit = int(args[i + 1])
962
+ i += 1
963
+ except ValueError:
964
+ pass
965
+ elif arg.startswith("--"):
966
+ return CommandResult(
967
+ success=False,
968
+ message=f"Unknown option: {arg}",
969
+ display_type="error"
970
+ )
971
+
972
+ i += 1
973
+
974
+ # Apply filters
975
+ conversations = await self.discover_conversations(limit)
976
+
977
+ # Filter conversations based on criteria
978
+ filtered_conversations = []
979
+ for conv in conversations:
980
+ include = True
981
+
982
+ if "date" in filters:
983
+ if conv.created_time and conv.created_time.date().isoformat() != filters["date"]:
984
+ include = False
985
+
986
+ if "date_range" in filters:
987
+ start_date, end_date = filters["date_range"]
988
+ if conv.created_time:
989
+ conv_date = conv.created_time.date().isoformat()
990
+ if not (start_date <= conv_date <= end_date):
991
+ include = False
992
+
993
+ if include:
994
+ filtered_conversations.append(conv)
995
+
996
+ if not filtered_conversations:
997
+ return CommandResult(
998
+ success=False,
999
+ message="No conversations found matching the specified criteria",
1000
+ display_type="info"
1001
+ )
1002
+
1003
+ # Build filtered modal
1004
+ modal_definition = self._build_filtered_modal(filtered_conversations, filters)
1005
+
1006
+ return CommandResult(
1007
+ success=True,
1008
+ message=f"Showing {len(filtered_conversations)} filtered conversations",
1009
+ ui_config=UIConfig(
1010
+ type="modal",
1011
+ title=modal_definition["title"],
1012
+ width=modal_definition["width"],
1013
+ height=modal_definition["height"],
1014
+ modal_config=modal_definition
1015
+ ),
1016
+ display_type="modal"
1017
+ )
1018
+
1019
+ def _build_conversation_modal(self, conversations: List[ConversationMetadata]) -> Dict[str, Any]:
1020
+ """Build conversation selection modal definition.
1021
+
1022
+ Args:
1023
+ conversations: List of conversations
1024
+
1025
+ Returns:
1026
+ Modal definition dictionary.
1027
+ """
1028
+ session_items = []
1029
+ for conv in conversations:
1030
+ # Format time for display
1031
+ time_str = "Unknown"
1032
+ if conv.created_time:
1033
+ time_str = conv.created_time.strftime("%m/%d %H:%M")
1034
+
1035
+ # Extract short project name from working directory
1036
+ project_name = conv.working_directory.split("/")[-1] if conv.working_directory else "unknown"
1037
+
1038
+ # Create preview
1039
+ preview = conv.last_message_preview[:80]
1040
+ if len(conv.last_message_preview) > 80:
1041
+ preview += "..."
1042
+
1043
+ # Get user's actual request (skip system prompt if it's first)
1044
+ user_request = ""
1045
+ if conv.preview_messages:
1046
+ for msg in conv.preview_messages:
1047
+ role = msg.get("role", "")
1048
+ content = msg.get("content", "")
1049
+ # Skip system messages - look for first user message
1050
+ if role == "system":
1051
+ continue
1052
+ if role == "user" and content:
1053
+ user_request = content
1054
+ break
1055
+
1056
+ first_line = user_request.split('\n')[0][:50] if user_request else "Empty"
1057
+ if user_request and len(user_request.split('\n')[0]) > 50:
1058
+ first_line += "..."
1059
+
1060
+ # Format: [12/11 14:30] "first line of request"
1061
+ session_items.append({
1062
+ "id": conv.session_id,
1063
+ "title": f"[{time_str}] {first_line}",
1064
+ "subtitle": f"{conv.message_count} msgs | {conv.duration or '?'} | {project_name} | {conv.git_branch or '-'}",
1065
+ "preview": preview,
1066
+ "action": "resume_session",
1067
+ "exit_mode": "minimal", # Plugin will display content after modal exit
1068
+ "metadata": {
1069
+ "session_id": conv.session_id,
1070
+ "file_id": conv.file_id,
1071
+ "working_directory": conv.working_directory,
1072
+ "git_branch": conv.git_branch,
1073
+ "topics": conv.topics
1074
+ }
1075
+ })
1076
+
1077
+ return {
1078
+ "title": "Resume Conversation",
1079
+ "footer": "↑↓ navigate • Enter select • Tab search • F filter • Esc exit",
1080
+ "width": 80,
1081
+ "height": 20,
1082
+ "sections": [
1083
+ {
1084
+ "title": f"Recent Conversations ({len(conversations)} available)",
1085
+ "type": "session_list",
1086
+ "sessions": session_items
1087
+ }
1088
+ ],
1089
+ "actions": [
1090
+ {"key": "Enter", "label": "Resume", "action": "select"},
1091
+ {"key": "Tab", "label": "Search", "action": "search"},
1092
+ {"key": "F", "label": "Filter", "action": "filter"},
1093
+ {"key": "Escape", "label": "Cancel", "action": "cancel"}
1094
+ ]
1095
+ }
1096
+
1097
+ def _build_search_modal(self, conversations: List[ConversationMetadata], query: str) -> Dict[str, Any]:
1098
+ """Build search results modal definition.
1099
+
1100
+ Args:
1101
+ conversations: Search results
1102
+ query: Search query
1103
+
1104
+ Returns:
1105
+ Modal definition dictionary.
1106
+ """
1107
+ session_items = []
1108
+ for conv in conversations:
1109
+ # Format time for display
1110
+ time_str = "Unknown"
1111
+ if conv.created_time:
1112
+ time_str = conv.created_time.strftime("%m/%d %H:%M")
1113
+
1114
+ # Extract short project name
1115
+ project_name = conv.working_directory.split("/")[-1] if conv.working_directory else "unknown"
1116
+
1117
+ # Get user's actual request (skip system prompt if it's first)
1118
+ user_request = ""
1119
+ if conv.preview_messages:
1120
+ for msg in conv.preview_messages:
1121
+ role = msg.get("role", "")
1122
+ content = msg.get("content", "")
1123
+ # Skip system messages - look for first user message
1124
+ if role == "system":
1125
+ continue
1126
+ if role == "user" and content:
1127
+ user_request = content
1128
+ break
1129
+
1130
+ first_line = user_request.split('\n')[0][:40] if user_request else "Empty"
1131
+ if user_request and len(user_request.split('\n')[0]) > 40:
1132
+ first_line += "..."
1133
+
1134
+ # Relevance score if available
1135
+ relevance_text = f" [{conv.search_relevance:.0%}]" if conv.search_relevance else ""
1136
+
1137
+ session_items.append({
1138
+ "id": conv.session_id,
1139
+ "title": f"[{time_str}] {first_line}{relevance_text}",
1140
+ "subtitle": f"{conv.message_count} msgs | {conv.duration or '?'} | {project_name} | {conv.git_branch or '-'}",
1141
+ "preview": conv.last_message_preview[:80],
1142
+ "metadata": {
1143
+ "session_id": conv.session_id,
1144
+ "file_id": conv.file_id,
1145
+ "search_relevance": conv.search_relevance
1146
+ }
1147
+ })
1148
+
1149
+ return {
1150
+ "title": f"Search Results: {query}",
1151
+ "footer": "↑↓ navigate • Enter select • Esc back",
1152
+ "width": 80,
1153
+ "height": 20,
1154
+ "sections": [
1155
+ {
1156
+ "title": f"Found {len(conversations)} conversations",
1157
+ "type": "session_list",
1158
+ "sessions": session_items
1159
+ }
1160
+ ],
1161
+ "actions": [
1162
+ {"key": "Enter", "label": "Resume", "action": "select"},
1163
+ {"key": "Escape", "label": "Back", "action": "cancel"}
1164
+ ]
1165
+ }
1166
+
1167
+ def _build_filtered_modal(self, conversations: List[ConversationMetadata], filters: Dict) -> Dict[str, Any]:
1168
+ """Build filtered results modal definition.
1169
+
1170
+ Args:
1171
+ conversations: Filtered conversations
1172
+ filters: Applied filters
1173
+
1174
+ Returns:
1175
+ Modal definition dictionary.
1176
+ """
1177
+ filter_desc = []
1178
+ if "date" in filters:
1179
+ filter_desc.append(f"Date: {filters['date']}")
1180
+ if "date_range" in filters:
1181
+ start, end = filters["date_range"]
1182
+ filter_desc.append(f"Date: {start} to {end}")
1183
+
1184
+ filter_text = " • ".join(filter_desc) if filter_desc else "Filtered"
1185
+
1186
+ session_items = []
1187
+ for conv in conversations:
1188
+ # Format time for display
1189
+ time_str = "Unknown"
1190
+ if conv.created_time:
1191
+ time_str = conv.created_time.strftime("%m/%d %H:%M")
1192
+
1193
+ # Extract short project name
1194
+ project_name = conv.working_directory.split("/")[-1] if conv.working_directory else "unknown"
1195
+
1196
+ # Get user's actual request (skip system prompt if it's first)
1197
+ user_request = ""
1198
+ if conv.preview_messages:
1199
+ for msg in conv.preview_messages:
1200
+ role = msg.get("role", "")
1201
+ content = msg.get("content", "")
1202
+ # Skip system messages - look for first user message
1203
+ if role == "system":
1204
+ continue
1205
+ if role == "user" and content:
1206
+ user_request = content
1207
+ break
1208
+
1209
+ first_line = user_request.split('\n')[0][:50] if user_request else "Empty"
1210
+ if user_request and len(user_request.split('\n')[0]) > 50:
1211
+ first_line += "..."
1212
+
1213
+ session_items.append({
1214
+ "id": conv.session_id,
1215
+ "title": f"[{time_str}] {first_line}",
1216
+ "subtitle": f"{conv.message_count} msgs | {conv.duration or '?'} | {project_name} | {conv.git_branch or '-'}",
1217
+ "preview": conv.last_message_preview[:80],
1218
+ "metadata": {
1219
+ "session_id": conv.session_id,
1220
+ "file_id": conv.file_id
1221
+ }
1222
+ })
1223
+
1224
+ return {
1225
+ "title": f"Filtered Conversations ({filter_text})",
1226
+ "footer": "↑↓ navigate • Enter select • Esc back",
1227
+ "width": 80,
1228
+ "height": 20,
1229
+ "sections": [
1230
+ {
1231
+ "title": f"Showing {len(conversations)} conversations",
1232
+ "type": "session_list",
1233
+ "sessions": session_items
1234
+ }
1235
+ ],
1236
+ "actions": [
1237
+ {"key": "Enter", "label": "Resume", "action": "select"},
1238
+ {"key": "Escape", "label": "Back", "action": "cancel"}
1239
+ ]
1240
+ }
1241
+
1242
+ def _generate_session_title(self, session_data: Dict) -> str:
1243
+ """Generate a descriptive title for a session.
1244
+
1245
+ Args:
1246
+ session_data: Session data
1247
+
1248
+ Returns:
1249
+ Generated title
1250
+ """
1251
+ topics = session_data.get("topics", [])
1252
+ working_dir = session_data.get("working_directory", "unknown")
1253
+
1254
+ # Extract project name from working directory
1255
+ project_name = working_dir.split("/")[-1] if working_dir != "unknown" else "Unknown Project"
1256
+
1257
+ # Use topic if available, otherwise use generic title
1258
+ if topics:
1259
+ main_topic = topics[0].replace("_", " ").title()
1260
+ return f"{main_topic} - {project_name}"
1261
+ else:
1262
+ return f"Conversation - {project_name}"
1263
+
1264
+ def _generate_file_id(self, session_id: str) -> str:
1265
+ """Generate short file ID for display.
1266
+
1267
+ Args:
1268
+ session_id: Full session ID
1269
+
1270
+ Returns:
1271
+ Short file ID
1272
+ """
1273
+ # Simple hash to generate a consistent short ID
1274
+ hash_val = hash(session_id) % 100000
1275
+ return f"#{hash_val:05d}"
1276
+
1277
+ def _parse_datetime(self, dt_str: Optional[str]) -> Optional[datetime]:
1278
+ """Parse datetime string.
1279
+
1280
+ Args:
1281
+ dt_str: Datetime string
1282
+
1283
+ Returns:
1284
+ Parsed datetime or None
1285
+ """
1286
+ if not dt_str:
1287
+ return None
1288
+
1289
+ try:
1290
+ from datetime import datetime
1291
+ # Handle ISO format with Z
1292
+ if dt_str.endswith('Z'):
1293
+ dt_str = dt_str.replace('Z', '+00:00')
1294
+ return datetime.fromisoformat(dt_str)
1295
+ except:
1296
+ return None
1297
+
1298
+ async def shutdown(self) -> None:
1299
+ """Shutdown the plugin and cleanup resources."""
1300
+ try:
1301
+ self.logger.info("Resume conversation plugin shutdown completed")
1302
+ except Exception as e:
1303
+ self.logger.error(f"Error shutting down resume conversation plugin: {e}")
1304
+
1305
+ @staticmethod
1306
+ def get_default_config() -> Dict[str, Any]:
1307
+ """Get default configuration for resume conversation plugin.
1308
+
1309
+ Returns:
1310
+ Default configuration dictionary.
1311
+ """
1312
+ return {
1313
+ "plugins": {
1314
+ "resume_conversation": {
1315
+ "enabled": True,
1316
+ "max_conversations": 50,
1317
+ "preview_length": 80,
1318
+ "auto_save_current": True,
1319
+ "confirm_load": True,
1320
+ "session_retention_days": 30
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ async def register_hooks(self) -> None:
1326
+ """Register event hooks for the plugin."""
1327
+ from core.events.models import EventType, Hook
1328
+ hook = Hook(
1329
+ name="resume_modal_command",
1330
+ plugin_name="resume_conversation",
1331
+ event_type=EventType.MODAL_COMMAND_SELECTED,
1332
+ priority=10,
1333
+ callback=self._handle_modal_command
1334
+ )
1335
+ await self.event_bus.register_hook(hook)
1336
+
1337
+ async def _handle_modal_command(self, data: Dict[str, Any], event: Event) -> Dict[str, Any]:
1338
+ """Handle modal command selection events.
1339
+
1340
+ Args:
1341
+ data: Event data containing command info.
1342
+ event: Event object.
1343
+
1344
+ Returns:
1345
+ Modified data dict with display_messages or show_modal keys.
1346
+ """
1347
+ command = data.get("command", {})
1348
+ action = command.get("action")
1349
+
1350
+ if action == "resume_session":
1351
+ session_id = command.get("session_id") or command.get("metadata", {}).get("session_id")
1352
+ if session_id:
1353
+ self.logger.info(f"[RESUME] Modal selected session: {session_id}")
1354
+
1355
+ if self._ensure_conversation_manager() and self.conversation_manager.load_session(session_id):
1356
+ # Load messages into llm_service history
1357
+ display_messages = self._prepare_session_display(
1358
+ header=f"--- Resumed session: {session_id} ---",
1359
+ success_msg=f"[ok] Resumed: {session_id}. Continue below."
1360
+ )
1361
+
1362
+ if display_messages:
1363
+ # Convert display tuples to ADD_MESSAGE format
1364
+ messages = [
1365
+ {"role": role, "content": content}
1366
+ for role, content, _ in display_messages
1367
+ ]
1368
+
1369
+ # Use ADD_MESSAGE event for unified display with loading
1370
+ from core.events.models import EventType
1371
+ await self.event_bus.emit_with_hooks(
1372
+ EventType.ADD_MESSAGE,
1373
+ {
1374
+ "messages": messages,
1375
+ "options": {
1376
+ "show_loading": True,
1377
+ "loading_message": "Loading conversation...",
1378
+ "log_messages": False, # Already logged
1379
+ "add_to_history": False, # Already loaded by _prepare_session_display
1380
+ "display_messages": True
1381
+ }
1382
+ },
1383
+ "resume_plugin"
1384
+ )
1385
+
1386
+ elif action == "branch_select_session":
1387
+ session_id = command.get("session_id") or command.get("metadata", {}).get("session_id")
1388
+ if session_id:
1389
+ self.logger.info(f"[BRANCH] Modal selected session for branch: {session_id}")
1390
+ result = await self._show_branch_point_selector(session_id)
1391
+ if result and result.ui_config and result.display_type == "modal":
1392
+ data["show_modal"] = result.ui_config.modal_config
1393
+
1394
+ elif action == "search":
1395
+ # Show search options modal
1396
+ self.logger.info("[RESUME] Search action triggered")
1397
+ data["show_modal"] = {
1398
+ "title": "Search Conversations",
1399
+ "footer": "Enter select • Esc back",
1400
+ "width": 80,
1401
+ "height": 12,
1402
+ "sections": [
1403
+ {
1404
+ "title": "Search by command",
1405
+ "commands": [
1406
+ {"name": "/resume search <query>", "description": "Search conversation content"},
1407
+ {"name": "/resume search git", "description": "Example: find git-related chats"},
1408
+ {"name": "/resume search modal", "description": "Example: find modal discussions"},
1409
+ ]
1410
+ }
1411
+ ]
1412
+ }
1413
+
1414
+ elif action == "filter":
1415
+ # Show filter options modal
1416
+ self.logger.info("[RESUME] Filter action triggered")
1417
+ data["show_modal"] = {
1418
+ "title": "Filter Conversations",
1419
+ "footer": "Enter select • Esc back",
1420
+ "width": 80,
1421
+ "height": 14,
1422
+ "sections": [
1423
+ {
1424
+ "title": "Filter options",
1425
+ "commands": [
1426
+ {"name": "Today's conversations", "description": "Show only today", "action": "filter_today"},
1427
+ {"name": "This week", "description": "Show this week's conversations", "action": "filter_week"},
1428
+ {"name": "Show more", "description": "Show up to 50 conversations", "action": "filter_limit"},
1429
+ ]
1430
+ }
1431
+ ]
1432
+ }
1433
+
1434
+ elif action == "filter_today":
1435
+ self.logger.info("[RESUME] Filter today triggered")
1436
+ result = await self._handle_resume_options(["--today"])
1437
+ if result and result.ui_config and result.display_type == "modal":
1438
+ data["show_modal"] = result.ui_config.modal_config
1439
+
1440
+ elif action == "filter_week":
1441
+ self.logger.info("[RESUME] Filter week triggered")
1442
+ result = await self._handle_resume_options(["--week"])
1443
+ if result and result.ui_config and result.display_type == "modal":
1444
+ data["show_modal"] = result.ui_config.modal_config
1445
+
1446
+ elif action == "filter_limit":
1447
+ self.logger.info("[RESUME] Filter limit triggered")
1448
+ result = await self._handle_resume_options(["--limit", "50"])
1449
+ if result and result.ui_config and result.display_type == "modal":
1450
+ data["show_modal"] = result.ui_config.modal_config
1451
+
1452
+ elif action == "branch_execute":
1453
+ metadata = command.get("metadata", {})
1454
+ session_id = metadata.get("session_id")
1455
+ message_index = metadata.get("message_index")
1456
+ if session_id is not None and message_index is not None:
1457
+ self.logger.info(f"[BRANCH] Executing branch: {session_id} at {message_index}")
1458
+ if self._ensure_conversation_manager():
1459
+ result = self.conversation_manager.branch_session(session_id, message_index)
1460
+ if result.get("success"):
1461
+ new_session_id = result.get("session_id")
1462
+ display_messages = self._prepare_session_display(
1463
+ header=f"--- Branched from {session_id} at message {message_index} ---",
1464
+ success_msg=(
1465
+ f"[ok] Created branch: {new_session_id}\n"
1466
+ f" From: {session_id} at message {result['branch_point']}\n"
1467
+ f" Loaded {result['message_count']} messages. Continue below."
1468
+ )
1469
+ )
1470
+ if display_messages:
1471
+ # Convert display tuples to ADD_MESSAGE format
1472
+ messages = [
1473
+ {"role": role, "content": content}
1474
+ for role, content, _ in display_messages
1475
+ ]
1476
+
1477
+ # Use ADD_MESSAGE event for unified display with loading
1478
+ from core.events.models import EventType
1479
+ await self.event_bus.emit_with_hooks(
1480
+ EventType.ADD_MESSAGE,
1481
+ {
1482
+ "messages": messages,
1483
+ "options": {
1484
+ "show_loading": True,
1485
+ "loading_message": "Creating branch...",
1486
+ "log_messages": False, # Already logged
1487
+ "add_to_history": False, # Already loaded by _prepare_session_display
1488
+ "display_messages": True
1489
+ }
1490
+ },
1491
+ "resume_plugin"
1492
+ )
1493
+
1494
+ return data