Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/jrdev/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from jrdev.commands.addcontext import handle_addcontext
from jrdev.commands.asyncsend import handle_asyncsend
from jrdev.commands.cancel import handle_cancel
from jrdev.commands.category import handle_category
from jrdev.commands.clearcontext import handle_clearcontext
from jrdev.commands.code import handle_code
from jrdev.commands.compact import handle_compact
Expand All @@ -17,6 +18,7 @@
from jrdev.commands.help import handle_help
from jrdev.commands.init import handle_init
from jrdev.commands.keys import handle_keys
from jrdev.commands.message import handle_message
from jrdev.commands.migrate import handle_migrate
from jrdev.commands.model import handle_model
from jrdev.commands.modelprofile import handle_modelprofile
Expand All @@ -34,6 +36,7 @@
"handle_addcontext",
"handle_asyncsend",
"handle_cancel",
"handle_category",
"handle_code",
"handle_compact",
"handle_cost",
Expand All @@ -44,6 +47,7 @@
"handle_help",
"handle_init",
"handle_keys",
"handle_message",
"handle_migrate",
"handle_model",
"handle_models",
Expand Down
31 changes: 31 additions & 0 deletions src/jrdev/commands/category.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import argparse
from typing import Any, List
import os

from jrdev.ui.ui import PrintType
from jrdev.messages.thread import THREADS_DIR

async def handle_category(app: Any, args: List[str], _worker_id: str) -> None:
"""
Manage categories for message threads.

Usage:
/category create <name>
"""
if len(args) < 3 or args[1] != "create":
app.ui.print_text("Usage: /category create <name>", PrintType.ERROR)
return

category_name = args[2]

# Validate name (no slashes, dots, etc)
if not category_name.replace('_', '').replace('-', '').isalnum():
app.ui.print_text("Error: Category name must be alphanumeric (with underscores or hyphens).", PrintType.ERROR)
return

cat_dir = os.path.join(THREADS_DIR, category_name)
try:
os.makedirs(cat_dir, exist_ok=True)
app.ui.print_text(f"Category '{category_name}' created.", PrintType.SUCCESS)
except OSError as e:
app.ui.print_text(f"Error creating category: {e}", PrintType.ERROR)
81 changes: 81 additions & 0 deletions src/jrdev/commands/message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import argparse
from typing import Any, List
from jrdev.ui.ui import PrintType

async def handle_message(app: Any, args: List[str], _worker_id: str) -> None:
"""
Manage individual messages.

Usage:
/message edit <thread_id> <index> <content...>
/message delete <thread_id> <index>
"""
if len(args) < 2:
return

subcommand = args[1]

if subcommand == "edit":
if len(args) < 5:
app.ui.print_text("Usage: /message edit <thread_id> <index> <content...>", PrintType.ERROR)
return

thread_id = args[2]
try:
index = int(args[3])
except ValueError:
app.ui.print_text("Error: index must be an integer", PrintType.ERROR)
return

content = " ".join(args[4:])

thread = app.state.threads.get(thread_id)
if not thread:
# try prefix
thread_id_pre = f"thread_{thread_id}"
thread = app.state.threads.get(thread_id_pre)
if thread:
thread_id = thread_id_pre

if not thread:
app.ui.print_text(f"Error: Thread '{thread_id}' not found.", PrintType.ERROR)
return

if thread.edit_message(index, content):
app.ui.print_text("Message updated.", PrintType.SUCCESS)
app.ui.chat_thread_update(thread_id)
else:
app.ui.print_text("Error: Failed to update message (invalid index?)", PrintType.ERROR)

elif subcommand == "delete":
if len(args) < 4:
app.ui.print_text("Usage: /message delete <thread_id> <index>", PrintType.ERROR)
return

thread_id = args[2]
try:
index = int(args[3])
except ValueError:
app.ui.print_text("Error: index must be an integer", PrintType.ERROR)
return

thread = app.state.threads.get(thread_id)
if not thread:
# try prefix
thread_id_pre = f"thread_{thread_id}"
thread = app.state.threads.get(thread_id_pre)
if thread:
thread_id = thread_id_pre

if not thread:
app.ui.print_text(f"Error: Thread '{thread_id}' not found.", PrintType.ERROR)
return

if thread.delete_message(index):
app.ui.print_text("Message deleted.", PrintType.SUCCESS)
app.ui.chat_thread_update(thread_id)
else:
app.ui.print_text("Error: Failed to delete message (invalid index?)", PrintType.ERROR)

else:
app.ui.print_text(f"Unknown message subcommand: {subcommand}", PrintType.ERROR)
57 changes: 57 additions & 0 deletions src/jrdev/commands/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None:
help="Turn web search on or off for the current thread",
)

# Move thread command
move_parser = subparsers.add_parser(
"move",
help="Move a thread to a category",
description="Move an existing conversation thread to a category",
epilog=f"Example: {format_command_with_args_plain('/thread move', 'thread_abc feature_requests')}",
)
move_parser.add_argument("thread_id", type=str, help="Unique ID of the thread to move")
move_parser.add_argument("category", type=str, help="Target category name")

try:
if any(arg in ["-h", "--help"] for arg in args[1:]):
if len(args) == 2 and args[1] in ["-h", "--help"]:
Expand Down Expand Up @@ -186,6 +196,8 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None:
await _handle_delete_thread(app, parsed_args)
elif parsed_args.subcommand == "websearch":
await _handle_websearch_toggle(app, parsed_args)
elif parsed_args.subcommand == "move":
await _handle_move_thread(app, parsed_args)
else:
app.ui.print_text("Error: Missing subcommand", PrintType.ERROR)
app.ui.print_text("Available Thread Subcommands:", PrintType.HEADER)
Expand All @@ -198,6 +210,7 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None:
("info", "", "Show current thread details", "thread info"),
("view", "[count]", "Display message history", "thread view 5"),
("delete", "<thread_id>", "Delete an existing thread", "thread delete thread_abc"),
("move", "<thread_id> <category>", "Move thread to category", "thread move thread_abc work"),
]

for cmd, cmd_args, desc, example in subcommands:
Expand Down Expand Up @@ -252,6 +265,11 @@ async def handle_thread(app: Any, args: List[str], _worker_id: str) -> None:
format_command_with_args_plain("/thread websearch", "on|off"),
"Enable or disable per-thread web search mode used by chat input\nExample: /thread websearch on",
),
(
"Move Thread",
format_command_with_args_plain("/thread move", "<thread_id> <category>"),
"Move thread to a category\nExample: /thread move thread_abc work",
),
]

for header, cmd, desc in sections:
Expand Down Expand Up @@ -467,3 +485,42 @@ async def _handle_websearch_toggle(app: Any, args: argparse.Namespace) -> None:
state_str = "enabled" if enable else "disabled"
app.ui.print_text(f"Web search {state_str} for thread {thread.thread_id}", PrintType.SUCCESS)
app.ui.chat_thread_update(thread.thread_id)


async def _handle_move_thread(app: Any, args: argparse.Namespace) -> None:
"""Move an existing message thread to a category."""
thread_id = args.thread_id
category = args.category

thread = app.state.threads.get(thread_id)
if not thread:
# try with prefix
thread_id_pre = f"thread_{thread_id}"
thread = app.state.threads.get(thread_id_pre)
if thread:
thread_id = thread_id_pre

if not thread:
app.ui.print_text(f"Error: Thread '{args.thread_id}' not found.", PrintType.ERROR)
return

old_category = getattr(thread, "category", "default")

# If category is same, do nothing
if old_category == category:
app.ui.print_text(f"Thread is already in category '{category}'.", PrintType.INFO)
return

# Delete old file
thread.delete_persisted_file()

# Update category
thread.category = category

# Save (creates new file in new dir)
thread.save()

app.ui.print_text(f"Thread '{thread_id}' moved to category '{category}'.", PrintType.SUCCESS)

# Update UI
app.ui.chat_thread_update(thread_id)
64 changes: 38 additions & 26 deletions src/jrdev/core/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,39 +84,51 @@ def write_terminal_text_styles(self) -> None:
self.logger.error("Error writing terminal text styles")

def _load_persisted_threads(self) -> Dict[str, MessageThread]:
"""Load all persisted message threads from disk."""
"""Load all persisted message threads from disk, including subdirectories."""
loaded_threads: Dict[str, MessageThread] = {}
if not os.path.isdir(THREADS_DIR):
self.logger.info(f"Threads directory '{THREADS_DIR}' not found. No threads to load.")
return loaded_threads

self.logger.info(f"Loading persisted threads from '{THREADS_DIR}'...")
for filename in os.listdir(THREADS_DIR):
if filename.endswith(".json"):
file_path = os.path.join(THREADS_DIR, filename)
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)

for root, dirs, files in os.walk(THREADS_DIR):
for filename in files:
if filename.endswith(".json"):
file_path = os.path.join(root, filename)

# Infer category from parent directory relative to THREADS_DIR
rel_dir = os.path.relpath(root, THREADS_DIR)
category = "default"
if rel_dir != ".":
category = rel_dir

if "thread_id" not in data:
self.logger.warning(f"File {file_path} is missing 'thread_id'. Skipping.")
continue

thread = MessageThread.from_dict(data)

# Don't load old router threads
thread_type = thread.metadata.get("type")
if thread_type and thread_type == "router":
continue

loaded_threads[thread.thread_id] = thread
self.logger.debug(f"Successfully loaded thread: {thread.thread_id} from {file_path}")
except json.JSONDecodeError as e:
self.logger.error(f"Error decoding JSON from {file_path}: {e}. Skipping file.")
except KeyError as e:
self.logger.error(f"Missing key in thread data from {file_path}: {e}. Skipping file.")
except Exception as e:
self.logger.error(f"Unexpected error loading thread from {file_path}: {e}. Skipping file.")
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)

if "thread_id" not in data:
self.logger.warning(f"File {file_path} is missing 'thread_id'. Skipping.")
continue

thread = MessageThread.from_dict(data)

# Ensure loaded category matches filesystem structure
thread.category = category

# Don't load old router threads
thread_type = thread.metadata.get("type")
if thread_type and thread_type == "router":
continue

loaded_threads[thread.thread_id] = thread
self.logger.debug(f"Successfully loaded thread: {thread.thread_id} from {file_path}")
except json.JSONDecodeError as e:
self.logger.error(f"Error decoding JSON from {file_path}: {e}. Skipping file.")
except KeyError as e:
self.logger.error(f"Missing key in thread data from {file_path}: {e}. Skipping file.")
except Exception as e:
self.logger.error(f"Unexpected error loading thread from {file_path}: {e}. Skipping file.")

self.logger.info(f"Finished loading threads. Total loaded: {len(loaded_threads)}.")
return loaded_threads
Expand Down
6 changes: 5 additions & 1 deletion src/jrdev/core/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
handle_addcontext,
handle_asyncsend,
handle_cancel,
handle_category,
handle_clearcontext,
handle_code,
handle_compact,
Expand All @@ -14,6 +15,7 @@
handle_help,
handle_init,
handle_keys,
handle_message,
handle_migrate,
handle_model,
handle_models,
Expand Down Expand Up @@ -58,6 +60,7 @@ def _register_core_commands(self) -> None:
"/viewcontext": handle_viewcontext,
"/asyncsend": handle_asyncsend,
"/tasks": handle_tasks,
"/category": handle_category,
"/cancel": handle_cancel,
"/code": handle_code,
"/projectcontext": handle_projectcontext,
Expand All @@ -67,7 +70,8 @@ def _register_core_commands(self) -> None:
"/research": handle_research,
"/routeragent": handle_routeragent,
"/thread": handle_thread,
"/migrate": handle_migrate
"/migrate": handle_migrate,
"/message": handle_message
}

self.commands.update(core_commands)
Expand Down
7 changes: 5 additions & 2 deletions src/jrdev/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def get_all_threads(self) -> List[MessageThread]:
return list(self.threads.values())

# Thread management
def create_thread(self, thread_id: str="", meta_data: Dict[str, str]=None) -> str:
def create_thread(self, thread_id: str="", meta_data: Dict[str, str]=None, category: str="default", mode: str="chat") -> str:
"""Create a new message thread"""
if thread_id == "":
thread_id = f"thread_{uuid.uuid4().hex[:8]}"
Expand All @@ -109,7 +109,10 @@ def create_thread(self, thread_id: str="", meta_data: Dict[str, str]=None) -> st
# This is handled by @auto_persist on MessageThread methods like set_name or if it's saved on creation
# For now, MessageThread constructor doesn't auto-save, so an explicit save might be needed
# or ensure first mutation triggers save. The current design relies on mutation.
self.threads[thread_id] = MessageThread(thread_id)
thread = MessageThread(thread_id)
thread.category = category
thread.metadata["mode"] = mode
self.threads[thread_id] = thread
if meta_data:
for k, v in meta_data.items():
self.threads[thread_id].metadata[k] = v
Expand Down
Loading