code-puppy 0.0.123__py3-none-any.whl → 0.0.125__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.
@@ -20,6 +20,7 @@ import json_repair
20
20
  from pydantic import BaseModel
21
21
  from pydantic_ai import RunContext
22
22
 
23
+ from code_puppy.callbacks import on_delete_file, on_edit_file
23
24
  from code_puppy.messaging import emit_error, emit_info, emit_warning
24
25
  from code_puppy.tools.common import _find_best_window, generate_group_id
25
26
 
@@ -346,16 +347,19 @@ def _edit_file(
346
347
  {"content": "full file contents", "overwrite": true}
347
348
  {"replacements": [ {"old_str": "foo", "new_str": "bar"}, ... ] }
348
349
  {"delete_snippet": "text to remove"}
350
+
349
351
  The function auto-detects the payload type and routes to the appropriate internal helper.
350
352
  """
353
+ # Extract file_path from payload
354
+ file_path = os.path.abspath(payload.file_path)
355
+
351
356
  # Use provided group_id or generate one if not provided
352
357
  if group_id is None:
353
- group_id = generate_group_id("edit_file", payload.file_path)
358
+ group_id = generate_group_id("edit_file", file_path)
354
359
 
355
360
  emit_info(
356
361
  "\n[bold white on blue] EDIT FILE [/bold white on blue]", message_group=group_id
357
362
  )
358
- file_path = os.path.abspath(payload.file_path)
359
363
  try:
360
364
  if isinstance(payload, DeleteSnippetPayload):
361
365
  return delete_snippet_from_file(
@@ -448,9 +452,7 @@ def register_file_modifications_tools(agent):
448
452
  """Attach file-editing tools to *agent* with mandatory diff rendering."""
449
453
 
450
454
  @agent.tool(retries=5)
451
- def edit_file(
452
- context: RunContext, payload: EditFilePayload | str = ""
453
- ) -> Dict[str, Any]:
455
+ def edit_file(context: RunContext, payload: EditFilePayload) -> Dict[str, Any]:
454
456
  """Comprehensive file editing tool supporting multiple modification strategies.
455
457
 
456
458
  This is the primary file modification tool that supports three distinct editing
@@ -476,8 +478,9 @@ def register_file_modifications_tools(agent):
476
478
  DeleteSnippetPayload:
477
479
  - delete_snippet (str): Exact text snippet to remove from file
478
480
 
479
- file_path (str): Path to the target file. Can be relative or absolute.
480
- File will be created if it doesn't exist (for ContentPayload).
481
+ file_path (str): Path to the target file. Can be relative or absolute.
482
+ File will be created if it doesn't exist (for ContentPayload).
483
+
481
484
  Returns:
482
485
  Dict[str, Any]: Operation result containing:
483
486
  - success (bool): True if operation completed successfully
@@ -497,16 +500,16 @@ def register_file_modifications_tools(agent):
497
500
  Examples:
498
501
  >>> # Create new file
499
502
  >>> payload = ContentPayload(file_path="foo.py", content="print('Hello World')")
500
- >>> result = edit_file(payload)
503
+ >>> result = edit_file(context, payload)
501
504
 
502
505
  >>> # Replace specific text
503
506
  >>> replacements = [Replacement(old_str="foo", new_str="bar")]
504
507
  >>> payload = ReplacementsPayload(file_path="foo.py", replacements=replacements)
505
- >>> result = edit_file(payload)
508
+ >>> result = edit_file(context, payload)
506
509
 
507
510
  >>> # Delete code block
508
511
  >>> payload = DeleteSnippetPayload(file_path="foo.py", delete_snippet="# TODO: remove this")
509
- >>> result = edit_file(payload)
512
+ >>> result = edit_file(context, payload)
510
513
 
511
514
  Warning:
512
515
  - Always verify file contents after modification
@@ -542,12 +545,13 @@ def register_file_modifications_tools(agent):
542
545
  }
543
546
  group_id = generate_group_id("edit_file", payload.file_path)
544
547
  result = _edit_file(context, payload, group_id)
548
+ on_edit_file(result)
545
549
  if "diff" in result:
546
550
  del result["diff"]
547
551
  return result
548
552
 
549
553
  @agent.tool(retries=5)
550
- def delete_file(context: RunContext, file_path: str = "") -> Dict[str, Any]:
554
+ def delete_file(context: RunContext, file_path: str) -> Dict[str, Any]:
551
555
  """Safely delete files with comprehensive logging and diff generation.
552
556
 
553
557
  This tool provides safe file deletion with automatic diff generation to show
@@ -600,6 +604,170 @@ def register_file_modifications_tools(agent):
600
604
  # Generate group_id for delete_file tool execution
601
605
  group_id = generate_group_id("delete_file", file_path)
602
606
  result = _delete_file(context, file_path, message_group=group_id)
607
+ on_delete_file(result)
608
+ if "diff" in result:
609
+ del result["diff"]
610
+ return result
611
+
612
+
613
+ def register_edit_file(agent):
614
+ """Register only the edit_file tool."""
615
+
616
+ @agent.tool(strict=False)
617
+ def edit_file(
618
+ context: RunContext,
619
+ payload: EditFilePayload | str = "",
620
+ ) -> Dict[str, Any]:
621
+ """Comprehensive file editing tool supporting multiple modification strategies.
622
+
623
+ This is the primary file modification tool that supports three distinct editing
624
+ approaches: full content replacement, targeted text replacements, and snippet
625
+ deletion. It provides robust diff generation, error handling, and automatic
626
+ retry capabilities for reliable file operations.
627
+
628
+ Args:
629
+ context (RunContext): The PydanticAI runtime context for the agent.
630
+ payload: One of three payload types:
631
+
632
+ ContentPayload:
633
+ - content (str): Full file content to write
634
+ - overwrite (bool, optional): Whether to overwrite existing files.
635
+ Defaults to False (safe mode).
636
+
637
+ ReplacementsPayload:
638
+ - replacements (List[Replacement]): List of text replacements where
639
+ each Replacement contains:
640
+ - old_str (str): Exact text to find and replace
641
+ - new_str (str): Replacement text
642
+
643
+ DeleteSnippetPayload:
644
+ - delete_snippet (str): Exact text snippet to remove from file
645
+
646
+ Returns:
647
+ Dict[str, Any]: Operation result containing:
648
+ - success (bool): True if operation completed successfully
649
+ - path (str): Absolute path to the modified file
650
+ - message (str): Human-readable description of changes
651
+ - changed (bool): True if file content was actually modified
652
+ - diff (str, optional): Unified diff showing changes made
653
+ - error (str, optional): Error message if operation failed
654
+
655
+ Examples:
656
+ >>> # Create new file with content
657
+ >>> payload = {"file_path": "hello.py", "content": "print('Hello!')"}
658
+ >>> result = edit_file(ctx, payload)
659
+
660
+ >>> # Replace text in existing file
661
+ >>> payload = {
662
+ ... "file_path": "config.py",
663
+ ... "replacements": [
664
+ ... {"old_str": "debug = False", "new_str": "debug = True"}
665
+ ... ]
666
+ ... }
667
+ >>> result = edit_file(ctx, payload)
668
+
669
+ >>> # Delete snippet from file
670
+ >>> payload = {
671
+ ... "file_path": "main.py",
672
+ ... "delete_snippet": "# TODO: remove this comment"
673
+ ... }
674
+ >>> result = edit_file(ctx, payload)
675
+
676
+ Best Practices:
677
+ - Use replacements for targeted changes (most efficient)
678
+ - Use content payload only for new files or complete rewrites
679
+ - Always check the 'success' field before assuming changes worked
680
+ - Review the 'diff' field to understand what changed
681
+ - Use delete_snippet for removing specific code blocks
682
+ """
683
+ # Handle string payload parsing (for models that send JSON strings)
684
+ if isinstance(payload, str):
685
+ # Fallback for weird models that just can't help but send json strings...
686
+ payload = json.loads(json_repair.repair_json(payload))
687
+ if "replacements" in payload and "file_path" in payload:
688
+ payload = ReplacementsPayload(**payload)
689
+ elif "delete_snippet" in payload and "file_path" in payload:
690
+ payload = DeleteSnippetPayload(**payload)
691
+ elif "content" in payload and "file_path" in payload:
692
+ payload = ContentPayload(**payload)
693
+ else:
694
+ file_path = "Unknown"
695
+ if "file_path" in payload:
696
+ file_path = payload["file_path"]
697
+ # Diagnose what's missing
698
+ missing = []
699
+ if "file_path" not in payload:
700
+ missing.append("file_path")
701
+
702
+ payload_type = "unknown"
703
+ if "content" in payload:
704
+ payload_type = "content"
705
+ elif "replacements" in payload:
706
+ payload_type = "replacements"
707
+ elif "delete_snippet" in payload:
708
+ payload_type = "delete_snippet"
709
+ else:
710
+ missing.append("content/replacements/delete_snippet")
711
+
712
+ missing_str = ", ".join(missing) if missing else "none"
713
+ return {
714
+ "success": False,
715
+ "path": file_path,
716
+ "message": f"Invalid payload for {payload_type} operation. Missing required fields: {missing_str}. Payload keys: {list(payload.keys())}",
717
+ "changed": False,
718
+ }
719
+
720
+ # Call _edit_file which will extract file_path from payload and handle group_id generation
721
+ result = _edit_file(context, payload)
722
+ if "diff" in result:
723
+ del result["diff"]
724
+ return result
725
+
726
+
727
+ def register_delete_file(agent):
728
+ """Register only the delete_file tool."""
729
+
730
+ @agent.tool(strict=False)
731
+ def delete_file(context: RunContext, file_path: str = "") -> Dict[str, Any]:
732
+ """Safely delete files with comprehensive logging and diff generation.
733
+
734
+ This tool provides safe file deletion with automatic diff generation to show
735
+ exactly what content was removed. It includes proper error handling and
736
+ automatic retry capabilities for reliable operation.
737
+
738
+ Args:
739
+ context (RunContext): The PydanticAI runtime context for the agent.
740
+ file_path (str): Path to the file to delete. Can be relative or absolute.
741
+ Must be an existing regular file (not a directory).
742
+
743
+ Returns:
744
+ Dict[str, Any]: Operation result containing:
745
+ - success (bool): True if file was successfully deleted
746
+ - path (str): Absolute path to the deleted file
747
+ - message (str): Human-readable description of the operation
748
+ - changed (bool): True if file was actually removed
749
+ - error (str, optional): Error message if deletion failed
750
+
751
+ Examples:
752
+ >>> # Delete a specific file
753
+ >>> result = delete_file(ctx, "temp_file.txt")
754
+ >>> if result['success']:
755
+ ... print(f"Deleted: {result['path']}")
756
+
757
+ >>> # Handle deletion errors
758
+ >>> result = delete_file(ctx, "missing.txt")
759
+ >>> if not result['success']:
760
+ ... print(f"Error: {result.get('error', 'Unknown error')}")
761
+
762
+ Best Practices:
763
+ - Always verify file exists before attempting deletion
764
+ - Check 'success' field to confirm operation completed
765
+ - Use list_files first to confirm file paths
766
+ - Cannot delete directories (use shell commands for that)
767
+ """
768
+ # Generate group_id for delete_file tool execution
769
+ group_id = generate_group_id("delete_file", file_path)
770
+ result = _delete_file(context, file_path, message_group=group_id)
603
771
  if "diff" in result:
604
772
  del result["diff"]
605
773
  return result
@@ -173,13 +173,14 @@ def _list_files(
173
173
  if rel_path == ".":
174
174
  rel_path = ""
175
175
  if rel_path:
176
- os.path.join(directory, rel_path)
176
+ dir_path = os.path.join(directory, rel_path)
177
177
  results.append(
178
178
  ListedFile(
179
179
  **{
180
180
  "path": rel_path,
181
181
  "type": "directory",
182
182
  "size": 0,
183
+ "full_path": dir_path,
183
184
  "depth": depth,
184
185
  }
185
186
  )
@@ -187,6 +188,7 @@ def _list_files(
187
188
  folder_structure[rel_path] = {
188
189
  "path": rel_path,
189
190
  "depth": depth,
191
+ "full_path": dir_path,
190
192
  }
191
193
  for file in files:
192
194
  file_path = os.path.join(root, file)
@@ -199,6 +201,7 @@ def _list_files(
199
201
  "path": rel_file_path,
200
202
  "type": "file",
201
203
  "size": size,
204
+ "full_path": file_path,
202
205
  "depth": depth,
203
206
  }
204
207
  results.append(ListedFile(**file_info))
@@ -621,3 +624,170 @@ def register_file_operations_tools(agent):
621
624
  - For case-insensitive search, try multiple variants manually
622
625
  """
623
626
  return _grep(context, search_string, directory)
627
+
628
+
629
+ def register_list_files(agent):
630
+ """Register only the list_files tool."""
631
+
632
+ @agent.tool(strict=False)
633
+ def list_files(
634
+ context: RunContext, directory: str = ".", recursive: bool = True
635
+ ) -> ListFileOutput:
636
+ """List files and directories with intelligent filtering and safety features.
637
+
638
+ This tool provides comprehensive directory listing with smart home directory
639
+ detection, project-aware recursion, and token-safe output. It automatically
640
+ ignores common build artifacts, cache directories, and other noise while
641
+ providing rich file metadata and visual formatting.
642
+
643
+ Args:
644
+ context (RunContext): The PydanticAI runtime context for the agent.
645
+ directory (str, optional): Path to the directory to list. Can be relative
646
+ or absolute. Defaults to "." (current directory).
647
+ recursive (bool, optional): Whether to recursively list subdirectories.
648
+ Automatically disabled for home directories unless they contain
649
+ project indicators. Defaults to True.
650
+
651
+ Returns:
652
+ ListFileOutput: A structured response containing:
653
+ - files (List[ListedFile]): List of files and directories found, where
654
+ each ListedFile contains:
655
+ - path (str | None): Relative path from the listing directory
656
+ - type (str | None): "file" or "directory"
657
+ - size (int): File size in bytes (0 for directories)
658
+ - full_path (str | None): Absolute path to the item
659
+ - depth (int | None): Nesting depth from the root directory
660
+ - error (str | None): Error message if listing failed
661
+
662
+ Examples:
663
+ >>> # List current directory
664
+ >>> result = list_files(ctx)
665
+ >>> for file in result.files:
666
+ ... print(f"{file.type}: {file.path} ({file.size} bytes)")
667
+
668
+ >>> # List specific directory non-recursively
669
+ >>> result = list_files(ctx, "/path/to/project", recursive=False)
670
+ >>> print(f"Found {len(result.files)} items")
671
+
672
+ >>> # Handle potential errors
673
+ >>> result = list_files(ctx, "/nonexistent/path")
674
+ >>> if result.error:
675
+ ... print(f"Error: {result.error}")
676
+
677
+ Best Practices:
678
+ - Always use this before reading/modifying files
679
+ - Use non-recursive for quick directory overviews
680
+ - Check for errors in the response
681
+ - Combine with grep to find specific file patterns
682
+ """
683
+ return _list_files(context, directory, recursive)
684
+
685
+
686
+ def register_read_file(agent):
687
+ """Register only the read_file tool."""
688
+
689
+ @agent.tool(strict=False)
690
+ def read_file(
691
+ context: RunContext,
692
+ file_path: str = "",
693
+ start_line: int | None = None,
694
+ num_lines: int | None = None,
695
+ ) -> ReadFileOutput:
696
+ """Read file contents with optional line-range selection and token safety.
697
+
698
+ This tool provides safe file reading with automatic token counting and
699
+ optional line-range selection for handling large files efficiently.
700
+ It protects against reading excessively large files that could overwhelm
701
+ the agent's context window.
702
+
703
+ Args:
704
+ context (RunContext): The PydanticAI runtime context for the agent.
705
+ file_path (str): Path to the file to read. Can be relative or absolute.
706
+ Cannot be empty.
707
+ start_line (int | None, optional): Starting line number for partial reads
708
+ (1-based indexing). If specified, num_lines must also be provided.
709
+ Defaults to None (read entire file).
710
+ num_lines (int | None, optional): Number of lines to read starting from
711
+ start_line. Must be specified if start_line is provided.
712
+ Defaults to None (read to end of file).
713
+
714
+ Returns:
715
+ ReadFileOutput: A structured response containing:
716
+ - content (str | None): The file contents or error message
717
+ - num_tokens (int): Estimated token count (constrained to < 10,000)
718
+ - error (str | None): Error message if reading failed
719
+
720
+ Examples:
721
+ >>> # Read entire file
722
+ >>> result = read_file(ctx, "example.py")
723
+ >>> print(f"Read {result.num_tokens} tokens")
724
+ >>> print(result.content)
725
+
726
+ >>> # Read specific line range
727
+ >>> result = read_file(ctx, "large_file.py", start_line=10, num_lines=20)
728
+ >>> print("Lines 10-29:", result.content)
729
+
730
+ >>> # Handle errors
731
+ >>> result = read_file(ctx, "missing.txt")
732
+ >>> if result.error:
733
+ ... print(f"Error: {result.error}")
734
+
735
+ Best Practices:
736
+ - Always check for errors before using content
737
+ - Use line ranges for large files to avoid token limits
738
+ - Monitor num_tokens to stay within context limits
739
+ - Combine with list_files to find files first
740
+ """
741
+ return _read_file(context, file_path, start_line, num_lines)
742
+
743
+
744
+ def register_grep(agent):
745
+ """Register only the grep tool."""
746
+
747
+ @agent.tool(strict=False)
748
+ def grep(
749
+ context: RunContext, search_string: str = "", directory: str = "."
750
+ ) -> GrepOutput:
751
+ """Recursively search for text patterns across files with intelligent filtering.
752
+
753
+ This tool provides powerful text searching across directory trees with
754
+ automatic filtering of irrelevant files, binary detection, and match limiting
755
+ for performance. It's essential for code exploration and finding specific
756
+ patterns or references.
757
+
758
+ Args:
759
+ context (RunContext): The PydanticAI runtime context for the agent.
760
+ search_string (str): The text pattern to search for. Performs exact
761
+ string matching (not regex). Cannot be empty.
762
+ directory (str, optional): Root directory to start the recursive search.
763
+ Can be relative or absolute. Defaults to "." (current directory).
764
+
765
+ Returns:
766
+ GrepOutput: A structured response containing:
767
+ - matches (List[MatchInfo]): List of matches found, where each
768
+ MatchInfo contains:
769
+ - file_path (str | None): Absolute path to the file containing the match
770
+ - line_number (int | None): Line number where match was found (1-based)
771
+ - line_content (str | None): Full line content containing the match
772
+
773
+ Examples:
774
+ >>> # Search for function definitions
775
+ >>> result = grep(ctx, "def my_function")
776
+ >>> for match in result.matches:
777
+ ... print(f"{match.file_path}:{match.line_number}: {match.line_content}")
778
+
779
+ >>> # Search in specific directory
780
+ >>> result = grep(ctx, "TODO", "/path/to/project/src")
781
+ >>> print(f"Found {len(result.matches)} TODO items")
782
+
783
+ >>> # Search for imports
784
+ >>> result = grep(ctx, "import pandas")
785
+ >>> files_using_pandas = {match.file_path for match in result.matches}
786
+
787
+ Best Practices:
788
+ - Use specific search terms to avoid too many results
789
+ - Search is case-sensitive; try variations if needed
790
+ - Combine with read_file to examine matches in detail
791
+ - For case-insensitive search, try multiple variants manually
792
+ """
793
+ return _grep(context, search_string, directory)
code_puppy/tui/app.py CHANGED
@@ -10,7 +10,7 @@ from textual.binding import Binding
10
10
  from textual.containers import Container
11
11
  from textual.events import Resize
12
12
  from textual.reactive import reactive
13
- from textual.widgets import Footer, Label, ListItem, ListView
13
+ from textual.widgets import Footer, ListView
14
14
 
15
15
  from code_puppy.agent import get_code_generation_agent, get_custom_usage_limits
16
16
  from code_puppy.command_line.command_handler import handle_command
@@ -27,7 +27,11 @@ from code_puppy.message_history_processor import (
27
27
 
28
28
  # Import our message queue system
29
29
  from code_puppy.messaging import TUIRenderer, get_global_queue
30
- from code_puppy.state_management import clear_message_history, get_message_history, set_message_history
30
+ from code_puppy.state_management import (
31
+ clear_message_history,
32
+ get_message_history,
33
+ set_message_history,
34
+ )
31
35
  from code_puppy.tui.components import (
32
36
  ChatView,
33
37
  CustomTextArea,
@@ -404,6 +408,15 @@ class CodePuppyTUI(App):
404
408
  self.action_clear_chat()
405
409
  return
406
410
 
411
+ # Let the command handler process all /agent commands
412
+ # result will be handled by the command handler directly through messaging system
413
+ if message.strip().startswith("/agent"):
414
+ # The command handler will emit messages directly to our messaging system
415
+ handle_command(message.strip())
416
+ # Refresh our agent instance after potential change
417
+ self.agent = get_code_generation_agent()
418
+ return
419
+
407
420
  # Handle exit commands
408
421
  if message.strip().lower() in ("/exit", "/quit"):
409
422
  self.add_system_message("Goodbye!")
@@ -412,36 +425,11 @@ class CodePuppyTUI(App):
412
425
  return
413
426
 
414
427
  # Use the existing command handler
428
+ # The command handler directly uses the messaging system, so we don't need to capture stdout
415
429
  try:
416
- import sys
417
- from io import StringIO
418
-
419
- from code_puppy.tools.common import console as rich_console
420
-
421
- # Capture the output from the command handler
422
- old_stdout = sys.stdout
423
- captured_output = StringIO()
424
- sys.stdout = captured_output
425
-
426
- # Also capture Rich console output
427
- rich_console.file = captured_output
428
-
429
- try:
430
- # Call the existing command handler
431
- result = handle_command(message.strip())
432
- if result: # Command was handled
433
- output = captured_output.getvalue()
434
- if output.strip():
435
- self.add_system_message(output.strip())
436
- else:
437
- self.add_system_message(f"Command '{message}' executed")
438
- else:
439
- self.add_system_message(f"Unknown command: {message}")
440
- finally:
441
- # Restore stdout and console
442
- sys.stdout = old_stdout
443
- rich_console.file = sys.__stdout__
444
-
430
+ result = handle_command(message.strip())
431
+ if not result:
432
+ self.add_system_message(f"Unknown command: {message}")
445
433
  except Exception as e:
446
434
  self.add_error_message(f"Error executing command: {str(e)}")
447
435
  return
@@ -499,7 +487,9 @@ class CodePuppyTUI(App):
499
487
  # Handle regular exceptions
500
488
  self.add_error_message(f"MCP/Agent error: {str(eg)}")
501
489
  finally:
502
- set_message_history(prune_interrupted_tool_calls(get_message_history()))
490
+ set_message_history(
491
+ prune_interrupted_tool_calls(get_message_history())
492
+ )
503
493
  except Exception as agent_error:
504
494
  # Handle any other errors in agent processing
505
495
  self.add_error_message(
@@ -662,134 +652,6 @@ class CodePuppyTUI(App):
662
652
  # Automatically submit the message
663
653
  self.action_send_message()
664
654
 
665
- # History management methods
666
- def load_history_list(self) -> None:
667
- """Load session history into the history tab."""
668
- try:
669
- from datetime import datetime, timezone
670
-
671
- history_list = self.query_one("#history-list", ListView)
672
-
673
- # Get history from session memory
674
- if self.session_memory:
675
- # Get recent history (last 24 hours by default)
676
- recent_history = self.session_memory.get_history(within_minutes=24 * 60)
677
-
678
- if not recent_history:
679
- # No history available
680
- history_list.append(
681
- ListItem(Label("No recent history", classes="history-empty"))
682
- )
683
- return
684
-
685
- # Filter out model loading entries and group history by type, display most recent first
686
- filtered_history = [
687
- entry
688
- for entry in recent_history
689
- if not entry.get("description", "").startswith("Agent loaded")
690
- ]
691
-
692
- # Get sidebar width for responsive text truncation
693
- try:
694
- sidebar_width = (
695
- self.query_one("Sidebar").size.width
696
- if hasattr(self.query_one("Sidebar"), "size")
697
- else 30
698
- )
699
- except Exception:
700
- sidebar_width = 30
701
-
702
- # Adjust text length based on sidebar width
703
- if sidebar_width >= 35:
704
- max_text_length = 45
705
- time_format = "%H:%M:%S"
706
- elif sidebar_width >= 25:
707
- max_text_length = 30
708
- time_format = "%H:%M"
709
- else:
710
- max_text_length = 20
711
- time_format = "%H:%M"
712
-
713
- for entry in reversed(filtered_history[-20:]): # Show last 20 entries
714
- timestamp_str = entry.get("timestamp", "")
715
- description = entry.get("description", "Unknown task")
716
-
717
- # Parse timestamp for display with safe parsing
718
- def parse_timestamp_safely_for_display(timestamp_str: str) -> str:
719
- """Parse timestamp string safely for display purposes."""
720
- try:
721
- # Handle 'Z' suffix (common UTC format)
722
- cleaned_timestamp = timestamp_str.replace("Z", "+00:00")
723
- parsed_dt = datetime.fromisoformat(cleaned_timestamp)
724
-
725
- # If the datetime is naive (no timezone), assume UTC
726
- if parsed_dt.tzinfo is None:
727
- parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
728
-
729
- return parsed_dt.strftime(time_format)
730
- except (ValueError, AttributeError, TypeError):
731
- # Handle invalid timestamp formats gracefully
732
- fallback = (
733
- timestamp_str[:5]
734
- if sidebar_width < 25
735
- else timestamp_str[:8]
736
- )
737
- return "??:??" if len(fallback) < 5 else fallback
738
-
739
- time_display = parse_timestamp_safely_for_display(timestamp_str)
740
-
741
- # Format description for display with responsive truncation
742
- if description.startswith("Interactive task:"):
743
- task_text = description[
744
- 17:
745
- ].strip() # Remove "Interactive task: "
746
- truncated = task_text[:max_text_length] + (
747
- "..." if len(task_text) > max_text_length else ""
748
- )
749
- display_text = f"[{time_display}] 💬 {truncated}"
750
- css_class = "history-interactive"
751
- elif description.startswith("TUI interaction:"):
752
- task_text = description[
753
- 16:
754
- ].strip() # Remove "TUI interaction: "
755
- truncated = task_text[:max_text_length] + (
756
- "..." if len(task_text) > max_text_length else ""
757
- )
758
- display_text = f"[{time_display}] 🖥️ {truncated}"
759
- css_class = "history-tui"
760
- elif description.startswith("Command executed"):
761
- cmd_text = description[
762
- 18:
763
- ].strip() # Remove "Command executed: "
764
- truncated = cmd_text[: max_text_length - 5] + (
765
- "..." if len(cmd_text) > max_text_length - 5 else ""
766
- )
767
- display_text = f"[{time_display}] ⚡ {truncated}"
768
- css_class = "history-command"
769
- else:
770
- # Generic entry
771
- truncated = description[:max_text_length] + (
772
- "..." if len(description) > max_text_length else ""
773
- )
774
- display_text = f"[{time_display}] 📝 {truncated}"
775
- css_class = "history-generic"
776
-
777
- label = Label(display_text, classes=css_class)
778
- history_item = ListItem(label)
779
- history_item.history_entry = (
780
- entry # Store full entry for detail view
781
- )
782
- history_list.append(history_item)
783
- else:
784
- history_list.append(
785
- ListItem(
786
- Label("Session memory not available", classes="history-error")
787
- )
788
- )
789
-
790
- except Exception as e:
791
- self.add_error_message(f"Failed to load history: {e}")
792
-
793
655
  def show_history_details(self, history_entry: dict) -> None:
794
656
  """Show detailed information about a selected history entry."""
795
657
  try: