From 6c0eea2b945433a4067ee338e0799c974c5d4a3d Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 23 Jan 2025 13:55:36 -0800 Subject: [PATCH 01/34] Terminal User Interface --- agentstack/cli/__init__.py | 3 +- agentstack/cli/cli.py | 9 + agentstack/main.py | 7 + agentstack/tui/__init__.py | 211 +++++++++++ agentstack/tui/animation.py | 75 ++++ agentstack/tui/color.py | 242 +++++++++++++ agentstack/tui/module.py | 701 ++++++++++++++++++++++++++++++++++++ 7 files changed, 1246 insertions(+), 2 deletions(-) create mode 100644 agentstack/tui/__init__.py create mode 100644 agentstack/tui/animation.py create mode 100644 agentstack/tui/color.py create mode 100644 agentstack/tui/module.py diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index 243aa474..7887aa74 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -1,7 +1,6 @@ -from .cli import configure_default_model, welcome_message, get_validated_input +from .cli import LOGO, configure_default_model, welcome_message, get_validated_input from .init import init_project from .wizard import run_wizard from .run import run_project from .tools import list_tools, add_tool from .templates import insert_template, export_template - diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 6fc36742..81a2006a 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -14,6 +14,15 @@ 'openai/gpt-4-turbo', 'anthropic/claude-3-opus', ] +LOGO = """\ + ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ + /\ \ /\ \ /\ \ /\__\ /\ \ /\ \ /\ \ /\ \ /\ \ /\__\ + /::\ \ /::\ \ /::\ \ /:| _|_ \:\ \ /::\ \ \:\ \ /::\ \ /::\ \ /:/ _/_ + /::\:\__\ /:/\:\__\ /::\:\__\ /::|/\__\ /::\__\ /\:\:\__\ /::\__\ /::\:\__\ /:/\:\__\ /::-"\__\\ + \/\::/ / \:\:\/__/ \:\:\/ / \/|::/ / /:/\/__/ \:\:\/__/ /:/\/__/ \/\::/ / \:\ \/__/ \;:;-",-" + /:/ / \::/ / \:\/ / |:/ / \/__/ \::/ / \/__/ /:/ / \:\__\ |:| | + \/__/ \/__/ \/__/ \/__/ \/__/ \/__/ \/__/ \|__| +""" def welcome_message(): diff --git a/agentstack/main.py b/agentstack/main.py index 9715822b..1da3a6f5 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -63,6 +63,10 @@ def _main(): init_parser.add_argument("--template", "-t", help="Agent template to use") init_parser.add_argument("--framework", "-f", help="Framework to use") + wizard_parser = subparsers.add_parser( + "wizard", help="Run the setup wizard", parents=[global_parser] + ) + # 'run' command run_parser = subparsers.add_parser( "run", @@ -174,6 +178,9 @@ def _main(): webbrowser.open("https://docs.agentstack.sh/quickstart") elif args.command in ["init", "i"]: init_project(args.slug_name, args.template, args.framework, args.wizard) + elif args.command in ["wizard"]: + from agentstack import tui + tui.main() elif args.command in ["tools", "t"]: if args.tools_command in ["list", "l"]: list_tools() diff --git a/agentstack/tui/__init__.py b/agentstack/tui/__init__.py new file mode 100644 index 00000000..6d397b0a --- /dev/null +++ b/agentstack/tui/__init__.py @@ -0,0 +1,211 @@ +import curses +import signal +import time +import math +from random import randint +from dataclasses import dataclass +from typing import Optional, Any, List, Tuple, Union +from enum import Enum + +from agentstack import conf, log +from agentstack.cli import LOGO +from agentstack.tui.module import * +from agentstack.tui.color import Color, AnimatedColor, ColorWheel + + +LOGO_ANTHROPIC = """ _ + /_/_ _/_/_ __ _ ._ +/ // // / ///_//_///_ + / """ + +LOGO_OPENAI = """ _ _ + / /_ _ _ /_// +/_//_//_'/ // // + / """ + + +class LogoModule(Text): + #h_align = ALIGN_CENTER # TODO center stars + + def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): + super().__init__(coords, dims) + self.color = Color(220, 100, 100) + self.value = LOGO + self.stars = [(3, 1), (25, 5), (34, 1), (52, 2), (79, 3), (97, 1)] + self._star_colors = {} + + def _get_star_color(self, index: int) -> Color: + if index not in self._star_colors: + self._star_colors[index] = AnimatedColor( + Color(randint(0, 150), 100, 100), + Color(randint(200, 360), 100, 100), + duration=2.0, + loop=True, + ) + return self._star_colors[index] + + def render(self) -> None: + super().render() + for i, (x, y) in enumerate(self.stars): + if x >= self.width or y >= self.height: + continue + self.grid.addch(y, x, '*', self._get_star_color(i).to_curses()) + + +class HelpText(Text): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int]) -> None: + super().__init__(coords, dims) + self.color = Color(0, 0, 50) + self.value = " | ".join([ + "[tab] to select", + "[up / down] to navigate", + "[space / enter] to confirm", + "[q] to quit", + ]) + + +AGENT_NAME = Node() +AGENT_ROLE = Node() +AGENT_GOAL = Node() +AGENT_BACKSTORY = Node() + +class AgentView(View): + name = "agent" + def layout(self) -> list[Module]: + return [ + Box((0, 0), (self.height, self.width), color=Color(90, 100, 100), modules=[ + LogoModule((1, 1), (7, self.width-2)), + Title((9, 1), (3, self.width-3), color=Color(220, 100, 40, reversed=True), value="Define An Agent"), + + # Text((12, 2), (1, 11), color=Color(0, 100, 100), value="Name"), + # TextInput((12, 13), (2, self.width - 16), AGENT_NAME, color=Color(240, 20, 100, reversed=True)), + + # Text((15, 2), (1, 11), color=Color(0, 100, 100), value="Role"), + # TextInput((15, 13), (6, self.width - 16), AGENT_ROLE, color=Color(240, 20, 100, reversed=True)), + + # Text((22, 2), (1, 11), color=Color(0, 100, 100), value="Goal"), + # TextInput((22, 13), (6, self.width - 16), AGENT_GOAL, color=Color(240, 20, 100, reversed=True)), + + # Text((29, 2), (1, 11), color=Color(0, 100, 100), value="Backstory"), + # TextInput((29, 13), (6, self.width - 16), AGENT_BACKSTORY, color=Color(240, 20, 100, reversed=True)), + + #Button((35, self.width-14), (3, 10), "Next", color=Color(0, 100, 100, reversed=True)), + + ]), + ] + + +class ModelView(View): + name = "model" + + MODEL_OPTIONS = [ + {'value': "gpt-3.5-turbo", 'name': "GPT-3.5 Turbo", 'description': "A fast and cost-effective model.", 'logo': LOGO_ANTHROPIC}, + {'value': "gpt-4", 'name': "GPT-4", 'description': "A more advanced model with better understanding.", 'logo': LOGO_OPENAI}, + {'value': "gpt-4o", 'name': "GPT-4o", 'description': "The latest and most powerful model.", 'logo': LOGO_OPENAI}, + {'value': "gpt-4o-mini", 'name': "GPT-4o Mini", 'description': "A smaller, faster version of GPT-4o.", 'logo': LOGO_ANTHROPIC}, + ] + + model_choice = Node() + model_logo = Node() + model_name = Node() + model_description = Node() + + def set_model_choice(self, index: int, value: str): + model = self.MODEL_OPTIONS[index] + self.model_choice.value = model['value'] + self.model_logo.value = model['logo'] + self.model_name.value = model['name'] + self.model_description.value = model['description'] + + def get_model_options(self): + return [model['value'] for model in self.MODEL_OPTIONS] + + def layout(self) -> list[Module]: + return [ + Box((0, 0), (self.height-1, self.width), color=Color(90), modules=[ + LogoModule((1, 1), (7, self.width-2)), + Title((9, 1), (3, self.width-3), color=Color(220, 100, 40, reversed=True), value="Select A Default Model"), + + RadioSelect((13, 1), (self.height-20, round(self.width/2)-3), options=self.get_model_options(), color=Color(300, 50), highlight=AnimatedColor( + Color(300, 0, 100, reversed=True), Color(300, 70, 50, reversed=True), duration=0.2 + ), on_change=self.set_model_choice), + Box((13, round(self.width/2)), (self.height-20, round(self.width/2)-3), color=Color(300, 50), modules=[ + Text((1, 3), (4, round(self.width/2)-10), color=Color(300, 40), value=self.model_logo), + BoldText((6, 3), (1, round(self.width/2)-10), color=Color(300), value=self.model_name), + WrappedText((8, 3), (5, round(self.width/2)-10), color=Color(300, 50), value=self.model_description), + ]), + + Button((self.height-6, self.width-17), (3, 15), "Next", color=Color(300, 100, 100, reversed=True)), + ]), + HelpText((self.height-1, 0), (1, self.width)), + ] + + +class ColorView(View): + name = "color" + def layout(self) -> list[Module]: + return [ + Box((0, 0), (self.height, self.width), color=Color(90, 100, 100), modules=[ + LogoModule((1, 1), (7, self.width-2)), + ColorWheel((6, 1)), + ]), + ] + + +def run(stdscr): + import io + log.set_stdout(io.StringIO()) # disable on-screen logging for now. + + CMD_STR = f" [q]uit [m]odel" + frame_time = 1.0 / 60 # 30 FPS + app = App(stdscr) + view = None + + def load_view(view_cls): + nonlocal view + if view: + app.destroy(view) + view = None + + view = view_cls() + view.init(app.dims) + app.append(view) + # underline rendered CMD_STR for the active command + # view_name = view.__class__.__name__ + # cmd_i = CMD_STR.find(f"[{view_name[0]}]{view_name[1:]}") + # cmd_len = len(view_name) + # grid.append(ui.string((29, 0), (1, 80), " " * 80)) + # grid.append(ui.string((29, cmd_i), (1, 80), "*" * (cmd_len + 2))) + + load_view(ModelView) + + last_frame = time.time() + while True: + current_time = time.time() + delta = current_time - last_frame + ch = stdscr.getch() + + if ch == curses.KEY_MOUSE: + _, x, y, _, _ = curses.getmouse() + app.click(y, x) + elif ch != -1: + app.input(ch) + + if not App.editing: + if ch == ord('q'): + break + elif ch == ord('a'): + load_view(AgentView) + elif ch == ord('c'): + load_view(ColorView) + elif ch == ord('m'): + load_view(ModelView) + + if delta >= frame_time or ch != -1: + app.render() + delta = 0 + if delta < frame_time: + time.sleep(frame_time - delta) + +def main(): + curses.wrapper(run) \ No newline at end of file diff --git a/agentstack/tui/animation.py b/agentstack/tui/animation.py new file mode 100644 index 00000000..0ad27950 --- /dev/null +++ b/agentstack/tui/animation.py @@ -0,0 +1,75 @@ + + +""" +Animations are classes that interact with elements inside of a tui.Module to +create visual effects over time. tui.Module keeps track of the current frame and +handles render, but we prepare the elements that need to be rendered beforehand. + +We want to accomplish a few things with animation: + +- Fading an element in or out. Module.color -> black and back. +- A color sweep that passes from left to right across an element. The animation + can either play on repeat with a delay, or just once (perhaps by setting a delay of -1) + + + + +# space_colors = [ +# (75, 0, 130), # Indigo +# (138, 43, 226), # Purple +# (0, 0, 255), # Blue +# (138, 43, 226), # Purple +# ] +# main_box.set_color_animation(ColorAnimation(space_colors, speed=0.2)) + + +class ColorAnimation: + def __init__(self, colors: List[Tuple[int, int, int]], speed: float = 1.0): + self.colors = colors + self.speed = speed + self.start_time = time.time() + + def _interpolate_color(self, color1: Tuple[int, int, int], color2: Tuple[int, int, int], factor: float) -> Tuple[int, int, int]: + return tuple(int(c1 + (c2 - c1) * factor) for c1, c2 in zip(color1, color2)) + + def _rgb_to_curses_color(self, r: int, g: int, b: int) -> int: + colors = [ + (0, 0, 0, curses.COLOR_BLACK), + (1000, 0, 0, curses.COLOR_RED), + (0, 1000, 0, curses.COLOR_GREEN), + (1000, 1000, 0, curses.COLOR_YELLOW), + (0, 0, 1000, curses.COLOR_BLUE), + (1000, 0, 1000, curses.COLOR_MAGENTA), + (0, 1000, 1000, curses.COLOR_CYAN), + (1000, 1000, 1000, curses.COLOR_WHITE), + ] + + r = r * 1000 // 255 + g = g * 1000 // 255 + b = b * 1000 // 255 + + min_dist = float('inf') + best_color = curses.COLOR_WHITE + for cr, cg, cb, color in colors: + dist = (cr - r) ** 2 + (cg - g) ** 2 + (cb - b) ** 2 + if dist < min_dist: + min_dist = dist + best_color = color + return best_color + + def get_color_for_line(self, line: int, total_lines: int, current_time: Optional[float] = None) -> int: + if current_time is None: + current_time = time.time() + + wave_pos = (current_time - self.start_time) * self.speed + line_pos = (line / total_lines + wave_pos) % 1.0 + + color_pos = line_pos * len(self.colors) + color_index = int(color_pos) + color_factor = color_pos - color_index + + color1 = self.colors[color_index % len(self.colors)] + color2 = self.colors[(color_index + 1) % len(self.colors)] + + blended = self._interpolate_color(color1, color2, color_factor) + return self._rgb_to_curses_color(*blended) \ No newline at end of file diff --git a/agentstack/tui/color.py b/agentstack/tui/color.py new file mode 100644 index 00000000..0437fe81 --- /dev/null +++ b/agentstack/tui/color.py @@ -0,0 +1,242 @@ +import curses +from typing import Optional +import time +import math +from agentstack import log +from agentstack.tui.module import Module + + +# TODO: fallback for 16 color mode +# TODO: fallback for no color mode + +class Color: + """ + Color class based on HSV color space, mapping directly to terminal color capabilities. + + Hue: 0-360 degrees, mapped to 6 primary directions (0, 60, 120, 180, 240, 300) + Saturation: 0-100%, mapped to 6 levels (0, 20, 40, 60, 80, 100) + Value: 0-100%, mapped to 6 levels for colors, 24 levels for grayscale + """ + SATURATION_LEVELS = 12 + HUE_SEGMENTS = 6 + VALUE_LEVELS = 6 + GRAYSCALE_LEVELS = 24 + COLOR_CUBE_SIZE = 6 # 6x6x6 color cube + + reversed: bool = False + bold: bool = False + + _color_map = {} # Cache for color mappings + + def __init__(self, h: float, s: float = 100, v: float = 100, reversed: bool = False, bold: bool = False) -> None: + """ + Initialize color with HSV values. + + Args: + h: Hue (0-360 degrees) + s: Saturation (0-100 percent) + v: Value (0-100 percent) + """ + self.h = h % 360 + self.s = max(0, min(100, s)) + self.v = max(0, min(100, v)) + self.reversed = reversed + self.bold = bold + self._pair_number: Optional[int] = None + + def _get_closest_color(self) -> int: + """Map HSV to closest available terminal color number.""" + # Handle grayscale case + if self.s < 10: + gray_val = int(self.v * (self.GRAYSCALE_LEVELS - 1) / 100) + return 232 + gray_val if gray_val < self.GRAYSCALE_LEVELS else 231 + + # Convert HSV to the COLOR_CUBE_SIZE x COLOR_CUBE_SIZE x COLOR_CUBE_SIZE color cube + h = self.h + s = self.s / 100 + v = self.v / 100 + + # Map hue to primary and secondary colors (0 to HUE_SEGMENTS-1) + h = (h + 330) % 360 # -30 degrees = +330 degrees + h_segment = int((h / 60) % self.HUE_SEGMENTS) + h_remainder = (h % 60) / 60 + + # Get RGB values based on hue segment + max_level = self.COLOR_CUBE_SIZE - 1 + if h_segment == 0: # Red to Yellow + r, g, b = max_level, int(max_level * h_remainder), 0 + elif h_segment == 1: # Yellow to Green + r, g, b = int(max_level * (1 - h_remainder)), max_level, 0 + elif h_segment == 2: # Green to Cyan + r, g, b = 0, max_level, int(max_level * h_remainder) + elif h_segment == 3: # Cyan to Blue + r, g, b = 0, int(max_level * (1 - h_remainder)), max_level + elif h_segment == 4: # Blue to Magenta + r, g, b = int(max_level * h_remainder), 0, max_level + else: # Magenta to Red + r, g, b = max_level, 0, int(max_level * (1 - h_remainder)) + + # Apply saturation + max_rgb = max(r, g, b) + if max_rgb > 0: + # Map the saturation to the number of levels + s_level = int(s * (self.SATURATION_LEVELS - 1)) + s_factor = s_level / (self.SATURATION_LEVELS - 1) + + r = int(r + (max_level - r) * (1 - s_factor)) + g = int(g + (max_level - g) * (1 - s_factor)) + b = int(b + (max_level - b) * (1 - s_factor)) + + # Apply value (brightness) + v = max(0, min(max_level, int(v * self.VALUE_LEVELS))) + r = min(max_level, int(r * v / max_level)) + g = min(max_level, int(g * v / max_level)) + b = min(max_level, int(b * v / max_level)) + + # Convert to color cube index (16-231) + return int(16 + (r * self.COLOR_CUBE_SIZE * self.COLOR_CUBE_SIZE) + (g * self.COLOR_CUBE_SIZE) + b) + + def _get_color_pair(self, pair_number: int) -> int: + """Apply reversing to the color pair.""" + pair = curses.color_pair(pair_number) + if self.reversed: + pair = pair | curses.A_REVERSE + if self.bold: + pair = pair | curses.A_BOLD + return pair + + def to_curses(self) -> int: + """Get curses color pair for this color.""" + if self._pair_number is not None: + return self._get_color_pair(self._pair_number) + + color_number = self._get_closest_color() + + # Create new pair if needed + if color_number not in self._color_map: + pair_number = len(self._color_map) + 1 + #try: + # TODO make sure we don't overflow the available color pairs + curses.init_pair(pair_number, color_number, -1) + self._color_map[color_number] = pair_number + #except: + # return curses.color_pair(0) + else: + pair_number = self._color_map[color_number] + + self._pair_number = pair_number + return self._get_color_pair(pair_number) + + @classmethod + def initialize(cls) -> None: + """Initialize terminal color support.""" + if not curses.has_colors(): + raise RuntimeError("Terminal does not support colors") + + curses.start_color() + curses.use_default_colors() + + try: + curses.init_pair(1, 1, -1) + except: + raise RuntimeError("Terminal does not support required color features") + + cls._color_map = {} + + +class AnimatedColor(Color): + start: Color + end: Color + duration: float + loop: bool + _start_time: float + + def __init__(self, start: Color, end: Color, duration: float, loop: bool = False): + super().__init__(start.h, start.s, start.v) + self.start = start + self.end = end + self.duration = duration + self.loop = loop + self._start_time = time.time() + + def reset_animation(self): + self._start_time = time.time() + + def to_curses(self) -> int: + elapsed = time.time() - self._start_time + if elapsed > self.duration: + if self.loop: + self.start, self.end = self.end, self.start + self.reset_animation() + return self.start.to_curses() # prevents flickering :shrug: + else: + return self.end.to_curses() + + t = elapsed / self.duration + h1, h2 = self.start.h, self.end.h + # take the shortest path + diff = h2 - h1 + if abs(diff) > 180: + if diff > 0: + h1 += 360 + else: + h2 += 360 + h = (h1 + t * (h2 - h1)) % 360 + + # saturation and value + s = self.start.s + t * (self.end.s - self.start.s) + v = self.start.v + t * (self.end.v - self.start.v) + + return Color(h, s, v, reversed=self.start.reversed).to_curses() + + +class ColorWheel(Module): + """ + A module used for testing color display. + """ + width: int = 80 + height: int = 24 + + def __init__(self, coords: tuple[int, int], duration: float = 10.0): + super().__init__(coords, (self.height, self.width)) + self.duration = duration + self.start_time = time.time() + + def render(self) -> None: + self.grid.erase() + center_y, center_x = 12, 22 + radius = 10 + elapsed = time.time() - self.start_time + hue_offset = (elapsed / self.duration) * 360 # animate + + for y in range(center_y - radius, center_y + radius + 1): + for x in range(center_x - radius * 2, center_x + radius * 2 + 1): + # Convert position to polar coordinates + dx = (x - center_x) / 2 # Compensate for terminal character aspect ratio + dy = y - center_y + distance = math.sqrt(dx*dx + dy*dy) + + if distance <= radius: + # Convert to HSV + angle = math.degrees(math.atan2(dy, dx)) + #h = (angle + 360) % 360 + h = (angle + hue_offset) % 360 + s = (distance / radius) * 100 + v = 100 # (distance / radius) * 100 + + color = Color(h, s, v) + self.grid.addstr(y, x, "█", color.to_curses()) + + x = 50 + y = 4 + for i in range(0, curses.COLORS): + self.grid.addstr(y, x, f"███", curses.color_pair(i + 1)) + y += 1 + if y >= self.height - 4: + y = 4 + x += 3 + if x >= self.width - 3: + break + + self.grid.refresh() + #super().render() \ No newline at end of file diff --git a/agentstack/tui/module.py b/agentstack/tui/module.py new file mode 100644 index 00000000..a99ff3d6 --- /dev/null +++ b/agentstack/tui/module.py @@ -0,0 +1,701 @@ +import curses +import signal +import time +import math +from random import randint +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional, Any, List, Tuple, Union +from enum import Enum + +from agentstack import conf, log +if TYPE_CHECKING: + from agentstack.tui.color import Color, AnimatedColor + + +# horizontal alignment +ALIGN_LEFT = "left" +ALIGN_CENTER = "center" +ALIGN_RIGHT = "right" + +# vertical alignment +ALIGN_TOP = "top" +ALIGN_MIDDLE = "middle" +ALIGN_BOTTOM = "bottom" + +# module positioning +POS_RELATIVE = "relative" +POS_ABSOLUTE = "absolute" + + +class Node: # TODO this needs a better name + """ + A simple data node that can be updated and have callbacks. This is used to + populate and retrieve data from an input field inside the user interface. + """ + value: Any + callbacks: list[callable] + + def __init__(self, value: Any = "") -> None: + self.value = value + self.callbacks = [] + + def __str__(self): + return str(self.value) + + def update(self, value: Any) -> None: + self.value = value + for callback in self.callbacks: + callback(self) + + def add_callback(self, callback): + self.callbacks.append(callback) + + def remove_callback(self, callback): + self.callbacks.remove(callback) + + +class Key: + const = { + 'UP': 259, + 'DOWN': 258, + 'BACKSPACE': 127, + 'TAB': 9, + 'ESC': 27, + 'ENTER': 10, + 'SPACE': 32, + 'PERIOD': 46, + 'PERCENT': 37, + 'MINUS': 45, + } + + def __init__(self, ch: int): + self.ch = ch + log.debug(f"Key: {ch}") + + def __getattr__(self, name): + try: + return self.ch == self.const[name] + except KeyError: + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") + + @property + def is_numeric(self): + return self.ch >= 48 and self.ch <= 57 + + @property + def is_alpha(self): + return (self.ch >= 65 and self.ch <= 90) or (self.ch >= 97 and self.ch <= 122) + + +class Renderable: + _grid: Optional[curses.window] = None + y: int + x: int + height: int + width: int + parent: Optional['Contains'] = None + h_align: str = ALIGN_LEFT + v_align: str = ALIGN_TOP + color: 'Color' + last_render: float = 0 + padding: tuple[int, int] = (1, 1) + positioning: str = POS_ABSOLUTE + + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], color: Optional['Color'] = None): + self.y, self.x = coords + self.height, self.width = dims + from agentstack.tui.color import Color + self.color = color or Color(0, 100, 0) + + def __repr__( self ): + return f"{type(self)} at ({self.y}, {self.x})" + + @property + def grid(self): + # TODO cleanup + # TODO validate that coords and size are within the parent window and give + # an explanatory error message. + if not self._grid: + if self.parent: + if self.positioning == POS_RELATIVE: + grid_func = self.parent.grid.derwin + elif self.positioning == POS_ABSOLUTE: + grid_func = self.parent.grid.subwin + else: + raise ValueError("Invalid positioning value") + else: + grid_func = curses.newwin + + self._grid = grid_func( + self.height + self.padding[0], + self.width + self.padding[1], + self.y, + self.x) # TODO this cant be bigger than the window + return self._grid + + @property + def abs_x(self): + """Absolute X coordinate of this module""" + if self.parent and not self.positioning == POS_ABSOLUTE: + return self.x + self.parent.abs_x + return self.x + + @property + def abs_y(self): + """Absolute Y coordinate of this module""" + if self.parent and not self.positioning == POS_ABSOLUTE: + return self.y + self.parent.abs_y + return self.y + + def render(self): + pass + + def hit(self, y, x): + """Is the mouse click inside this module?""" + return y >= self.abs_y and y < self.abs_y + self.height and x >= self.abs_x and x < self.abs_x + self.width + + def click(self, y, x): + """Handle mouse click event.""" + pass + + def input(self, key: Key): + """Handle key input event.""" + pass + + def destroy(self) -> None: + if self._grid: + self._grid.erase() + self._grid.refresh() + self._grid = None + + +class Module(Renderable): + positioning: str = POS_RELATIVE + word_wrap: bool = False + + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None): + super().__init__(coords, dims, color=color) + self.value = value + + def __repr__( self ): + return f"{type(self)} at ({self.y}, {self.x}) with value '{self.value[:20]}'" + + def _get_lines(self, value: str) -> List[str]: + if self.word_wrap: + splits = [''] * self.height + words = value.split() + for i in range(self.height): + while words and (len(splits[i]) + len(words[0]) + 1) <= self.width: + splits[i] += f"{words.pop(0)} " if words else '' + elif '\n' in value: + splits = value.split('\n') + else: + splits = [value, ] + + if self.v_align == ALIGN_TOP: + # add empty elements below + splits = splits + [''] * (self.height - len(splits)) + elif self.v_align == ALIGN_MIDDLE: + # add empty elements before and after the splits to center it + pad = (self.height // 2) - (len(splits) // 2) + splits = [''] * pad + splits + [''] * pad + elif self.v_align == ALIGN_BOTTOM: + splits = [''] * (self.height - len(splits)) + splits + + lines = [] + for line in splits: + if self.h_align == ALIGN_LEFT: + line = line.ljust(self.width) + elif self.h_align == ALIGN_RIGHT: + line = line.rjust(self.width) + elif self.h_align == ALIGN_CENTER: + line = line.center(self.width) + + lines.append(line[:self.width]) + return lines + + def render(self): + for i, line in enumerate(self._get_lines(str(self.value))): + self.grid.addstr(i, 0, line, self.color.to_curses()) + + +class NodeModule(Module): + format: Optional[callable] = None + + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional['Color'] = None, format: callable=None): + super().__init__(coords, dims, color=color) + self.node = node # TODO can also be str? + self.value = str(node) + self.format = format + if isinstance(node, Node): + self.node.add_callback(self.update) + + def update(self, node: Node): + self.value = str(node) + if self.format: + self.value = self.format(self.value) + + def save(self): + self.node.update(self.value) + self.update(self.node) + + def destroy(self): + if isinstance(self.node, Node): + self.node.remove_callback(self.update) + super().destroy() + + +class Editable(NodeModule): + filter: Optional[callable] = None + active: bool + _original_value: Any + + def __init__(self, coords, dims, node, color=None, format: callable=None, filter: callable=None): + super().__init__(coords, dims, node=node, color=color, format=format) + self.filter = filter + self.active = False + self._original_value = self.value + + def click(self, y, x): + if not self.active and self.hit(y, x): + self.activate() + elif self.active: # click off; revert changes + self.deactivate() + self.value = self._original_value + + def activate(self): + """Make this module the active one; ie. editing or selected.""" + App.editing = True + self.active = True + self._original_value = self.value + + def deactivate(self): + """Deactivate this module, making it no longer active.""" + App.editing = False + self.active = False + + def save(self): + if self.filter: + self.value = self.filter(self.value) + super().save() + + def input(self, key: Key): + if not self.active: + return + + # TODO word wrap + # TODO we probably don't need to filter as prohibitively + if key.is_alpha or key.is_numeric or key.PERIOD or key.MINUS or key.SPACE: + self.value = str(self.value) + chr(ch) + elif key.BACKSPACE: + self.value = str(self.value)[:-1] + elif key.ESC: + self.deactivate() + self.value = self._original_value # revert changes + elif key.ENTER: + self.deactivate() + self.save() + + def destroy(self): + self.deactivate() + super().destroy() + + +class Text(Module): + pass + + +class WrappedText(Text): + word_wrap: bool = True + + +class BoldText(Text): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None): + super().__init__(coords, dims, value=value, color=color) + self.color.bold = True + + +class Title(BoldText): + h_align: str = ALIGN_CENTER + v_align: str = ALIGN_MIDDLE + + +class TextInput(Editable): + """ + A module that allows the user to input text. + """ + H, V, BR = "━", "┃", "┛" + padding: tuple[int, int] = (2, 1) + + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional['Color'] = None, format: callable=None): + super().__init__(coords, dims, node=node, color=color, format=format) + self.width, self.height = (dims[1]-1, dims[0]-1) + from agentstack.tui.color import Color + self.border_color = Color(0, 100, 100) + + def activate(self): + # change the border color to a highlight + self._original_border_color = self.border_color + from agentstack.tui.color import Color + self.border_color = Color(90, 70, 100) + super().activate() + + def deactivate(self): + if self.active: + self.border_color = self._original_border_color + super().deactivate() + + def render(self) -> None: + for i, line in enumerate(self._get_lines(str(self.value))): + self.grid.addstr(i, 0, line, self.color.to_curses()) + + # # add border to bottom right like a drop shadow + for x in range(self.width): + self.grid.addch(self.height, x, self.H, self.border_color.to_curses()) + for y in range(self.height): + self.grid.addch(y, self.width, self.V, self.border_color.to_curses()) + self.grid.addch(self.height, self.width, self.BR, self.border_color.to_curses()) + + +class Button(Module): + h_align: str = ALIGN_CENTER + v_align: str = ALIGN_MIDDLE + active: bool = False + selected: bool = False + highlight: Optional['Color'] = None + # callback: Optional[callable] = None + + def __init__( self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None, highlight: Optional['Color'] = None): + super().__init__(coords, dims, value=value, color=color) + from agentstack.tui.color import Color + self.highlight = highlight or Color(self.color.h, 80, self.color.v, reversed=self.color.reversed) + + def confirm(self): + """Handle button confirmation.""" + log.debug(f"CONFIRM {self}") + + def activate(self): + """Make this module the active one; ie. editing or selected.""" + self.active = True + self._original_color = self.color + self.color = self.highlight or self.color + if hasattr(self.color, 'reset_animation'): + self.color.reset_animation() + + def deactivate(self): + """Deactivate this module, making it no longer active.""" + self.active = False + if hasattr(self, '_original_color'): + self.color = self._original_color + + def click(self, y, x): + if self.hit(y, x): + self.confirm() + + def input(self, key: Key): + """Handle key input event.""" + if not self.active: + return + + if key.ENTER or key.SPACE: + self.confirm() + + +class RadioButton(Button): + """A Button with an indicator that it is selected""" + ON, OFF = "●", "○" + + def render(self): + super().render() + icon = self.ON if self.selected else self.OFF + self.grid.addstr(1, 2, icon, self.color.to_curses()) + + +class CheckButton(RadioButton): + """A Button with an indicator that it is selected""" + ON, OFF = "■", "□" + + +class Contains(Renderable): + _grid: Optional[curses.window] = None + y: int + x: int + positioning: str = POS_RELATIVE + padding: tuple[int, int] = (1, 0) + color: 'Color' + last_render: float = 0 + parent: Optional['Contains'] = None + modules: List[Module] + + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], modules: list[Module], color: Optional['Color'] = None): + super().__init__(coords, dims, color=color) + self.modules = [] + for module in modules: + self.append(module) + + def append(self, module: Union['Contains', Module]): + module.parent = self + self.modules.append(module) + + def render(self): + for module in self.modules: + module.render() + module.last_render = time.time() + module.grid.noutrefresh() + self.last_render = time.time() + + def click(self, y, x): + for module in self.modules: + module.click(y, x) + + def input(self, key: Key): + for module in self.modules: + module.input(key) + + def destroy(self): + for module in self.modules: + module.destroy() + self.grid.erase() + self.grid.refresh() + + +class Box(Contains): + """A container with a border""" + H, V, TL, TR, BL, BR = "─", "│", "┌", "┐", "└", "┘" + + def render(self) -> None: + w: int = self.width - 1 + h: int = self.height - 1 + + for x in range(1, w): + self.grid.addch(0, x, self.H, self.color.to_curses()) + self.grid.addch(h, x, self.H, self.color.to_curses()) + for y in range(1, h): + self.grid.addch(y, 0, self.V, self.color.to_curses()) + self.grid.addch(y, w, self.V, self.color.to_curses()) + self.grid.addch(0, 0, self.TL, self.color.to_curses()) + self.grid.addch(h, 0, self.BL, self.color.to_curses()) + self.grid.addch(0, w, self.TR, self.color.to_curses()) + self.grid.addch(h, w, self.BR, self.color.to_curses()) + + for module in self.modules: + module.render() + module.last_render = time.time() + module.grid.noutrefresh() + self.last_render = time.time() + self.grid.noutrefresh() + + +class LightBox(Box): + """A Box with light borders""" + pass + + +class HeavyBox(Box): + """A Box with heavy borders""" + H, V, TL, TR, BL, BR = "━", "┃", "┏", "┓", "┗", "┛" + + +class DoubleBox(Box): + """A Box with double borders""" + H, V, TL, TR, BL, BR = "═", "║", "╔", "╗", "╚", "╝" + + +class Select(Box): + """ + Build a select menu out of buttons. + """ + on_change: Optional[callable] = None + button_cls: type[Button] = Button + + def __init__(self, coords: Tuple[int, int], dims: Tuple[int, int], options: List[str], color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_change: Optional[callable] = None) -> None: + super().__init__(coords, dims, [], color=color) + from agentstack.tui.color import Color + self.highlight = highlight or Color(0, 100, 100) + self.options = options + self.on_change = on_change + for i, option in enumerate(self.options): + self.append(self.button_cls(((i*3)+1, 1), (3, self.width-2), value=option, color=color, highlight=self.highlight)) + self._mark_active(0) + + def _mark_active(self, index: int): + for module in self.modules: + module.deactivate() + self.modules[index].activate() + # TODO other modules in the app will not get marked. + + if self.on_change: + self.on_change(index, self.options[index]) + + def select(self, module: Module): + module.selected = not module.selected + self._mark_active(self.modules.index(module)) + + def input(self, key: Key): + index = None + for module in self.modules: + if module.active: + index = self.modules.index(module) + + if index is None: + return + + if key.UP or key.DOWN: + direction = -1 if key.UP else 1 + index = (direction + index) % len(self.modules) + self._mark_active(index) + elif key.SPACE or key.ENTER: + self.select(self.modules[index]) + + super().input(key) + + def click(self, y, x): + for module in self.modules: + if not module.hit(y, x): + continue + self._mark_active(self.modules.index(module)) + self.select(module) + + +class RadioSelect(Select): + """Allow one button to be `selected` at a time""" + button_cls = RadioButton + + def select(self, module: Module): + for _module in self.modules: + _module.selected = False + super().select(module) + + +class MultiSelect(Select): + """Allow multiple buttons to be `selected` at a time""" + button_cls = CheckButton + + +class DebugModule(Module): + """Show fps and color usage.""" + def __init__(self, coords: Tuple[int, int]): + super().__init__(coords, (1, 24)) + + def render(self) -> None: + from agentstack.tui.color import Color + self.grid.addstr(0, 1, f"FPS: {1 / (time.time() - self.last_render):.0f}") + self.grid.addstr(0, 10, f"Colors: {len(Color._color_map)}/{curses.COLORS}") + + +class View(Contains): + positioning: str = POS_ABSOLUTE + padding: tuple[int, int] = (0, 0) + y: int = 0 + x: int = 0 + + def __init__(self): + self.modules = [] + + def init(self, dims: Tuple[int, int]) -> None: + self.height, self.width = dims + self.modules = self.layout() + + if conf.DEBUG: + self.append(DebugModule((1, 1))) + + @property + def grid(self): + if not self._grid: + self._grid = curses.newwin(self.height, self.width, self.y, self.x) + return self._grid + + def layout(self) -> list[Module]: + log.warn(f"`layout` not implemented in View: {self.__class__}.") + return [] + + +class App: + editing = False + dims = property(lambda self: self.stdscr.getmaxyx()) # TODO remove this + + def __init__(self, stdscr: curses.window) -> None: + self.stdscr = stdscr + self.height, self.width = stdscr.getmaxyx() + self.modules = [] + + curses.curs_set(0) + stdscr.nodelay(True) + stdscr.timeout(10) # balance framerate with cpu usage + curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + + from agentstack.tui.color import Color + Color.initialize() + + def append(self, module): + self.modules.append(module) + + def render(self): + for module in self.modules: + if not isinstance(module, Contains): + module.grid.erase() + module.render() + module.last_render = time.time() + module.grid.noutrefresh() + curses.doupdate() + + def click(self, y, x): + """Handle mouse click event.""" + for module in self.modules: + module.click(y, x) + + def input(self, ch: int): + """Handle key input event.""" + key = Key(ch) + + if key.TAB: + self._select_next_tabbable() + + for module in self.modules: + module.input(key) + + def destroy(self, module: View): + # TODO this should probably kill all modules + self.modules.remove(module) + module.destroy() + #curses.endwin() + + def _get_tabbable_modules(self): + """ + Search through the tree of modules to find selectable elements. + """ + def _get_activateable(module: Module): + """Find modules with an `activate` method""" + if hasattr(module, 'activate'): + yield module + for submodule in getattr(module, 'modules', []): + yield from _get_activateable(submodule) + return list(_get_activateable(self)) + + def _select_next_tabbable(self): + """ + Activate the next tabbable module in the list. + """ + def _get_active_module(module: Module): + if hasattr(module, 'active') and module.active: + return module + for submodule in getattr(module, 'modules', []): + active = _get_active_module(submodule) + if active: + return active + return None + + modules = self._get_tabbable_modules() + active_module = _get_active_module(self) + if active_module: + for module in modules: + module.deactivate() + next_index = modules.index(active_module) + 1 + if next_index >= len(modules): + next_index = 0 + log.debug(f"Active index: {modules.index(active_module)}") + log.debug(f"Next index: {next_index}") + modules[next_index].activate() # TODO this isn't working + elif modules: + modules[0].activate() + From 5a91d7bf1c3c95ca16e2f18ad564412d2a1cb733 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 24 Jan 2025 14:52:32 -0800 Subject: [PATCH 02/34] TUI progress. --- agentstack/tui/__init__.py | 223 +++++++++++++++++++++++-------------- agentstack/tui/color.py | 18 ++- agentstack/tui/module.py | 119 ++++++++++++++------ 3 files changed, 239 insertions(+), 121 deletions(-) diff --git a/agentstack/tui/__init__.py b/agentstack/tui/__init__.py index 6d397b0a..71d5cbe1 100644 --- a/agentstack/tui/__init__.py +++ b/agentstack/tui/__init__.py @@ -10,35 +10,51 @@ from agentstack import conf, log from agentstack.cli import LOGO from agentstack.tui.module import * -from agentstack.tui.color import Color, AnimatedColor, ColorWheel +from agentstack.tui.color import Color, ColorAnimation, ColorWheel -LOGO_ANTHROPIC = """ _ +# TODO this could be a dynamic module type +LOGO_ANTHROPIC = """\ + _ /_/_ _/_/_ __ _ ._ / // // / ///_//_///_ / """ -LOGO_OPENAI = """ _ _ +LOGO_OPENAI = """\ + _ _ / /_ _ _ /_// /_//_//_'/ // // / """ +COLOR_BORDER = Color(90) +COLOR_MAIN = Color(220) +COLOR_TITLE = Color(220, 100, 40, reversed=True) +COLOR_FORM = Color(300) +COLOR_BUTTON = Color(300, reversed=True) +COLOR_FIELD_BG = Color(240, 20, 100, reversed=True) +COLOR_FIELD_BORDER = Color(300, 100, 50) +COLOR_FIELD_ACTIVE = Color(300, 80) +FIELD_COLORS = { + 'color': COLOR_FIELD_BG, + 'border': COLOR_FIELD_BORDER, + 'active': COLOR_FIELD_ACTIVE, +} class LogoModule(Text): #h_align = ALIGN_CENTER # TODO center stars def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): super().__init__(coords, dims) - self.color = Color(220, 100, 100) + self.color = COLOR_MAIN self.value = LOGO self.stars = [(3, 1), (25, 5), (34, 1), (52, 2), (79, 3), (97, 1)] self._star_colors = {} def _get_star_color(self, index: int) -> Color: if index not in self._star_colors: - self._star_colors[index] = AnimatedColor( - Color(randint(0, 150), 100, 100), - Color(randint(200, 360), 100, 100), + self._star_colors[index] = ColorAnimation( + Color(randint(0, 150)), + Color(randint(200, 360)), duration=2.0, loop=True, ) @@ -62,39 +78,78 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int]) -> None: "[space / enter] to confirm", "[q] to quit", ]) + if conf.DEBUG: + self.value += " | [d]ebug" -AGENT_NAME = Node() -AGENT_ROLE = Node() -AGENT_GOAL = Node() -AGENT_BACKSTORY = Node() +class BannerView(View): + name = "banner" + title = "Welcome to AgentStack" + sparkle = "The fastest way to build AI agents." + subtitle = "Let's get started!" + color = ColorAnimation( + start=Color(90, 0, 0), # TODO make this darker + end=Color(90), + duration=0.5 + ) + + def layout(self) -> list[Module]: + return [ + Box((0, 0), (self.height, self.width), color=COLOR_BORDER, modules=[ + Title((round(self.height / 2)-4, 1), (1, self.width-3), color=self.color, value=self.title), + Title((round(self.height / 2)-2, 1), (1, self.width-3), color=self.color, value=self.sparkle), + Title((round(self.height / 2), 1), (1, self.width-3), color=self.color, value=self.subtitle), + ]), + ] + + +class AfterTaskView(BannerView): + title = "Let there be tasks!" + sparkle = "(ノ ˘_˘)ノ ζ|||ζ ζ|||ζ ζ|||ζ" + subtitle = "Tasks are the heart of your agent's work. " + class AgentView(View): name = "agent" + + agent_name = Node() + agent_role = Node() + agent_goal = Node() + agent_backstory = Node() + + def submit(self): + log.info("Agent defined: %s", self.agent_name.value) + def layout(self) -> list[Module]: return [ - Box((0, 0), (self.height, self.width), color=Color(90, 100, 100), modules=[ + Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ LogoModule((1, 1), (7, self.width-2)), - Title((9, 1), (3, self.width-3), color=Color(220, 100, 40, reversed=True), value="Define An Agent"), - - # Text((12, 2), (1, 11), color=Color(0, 100, 100), value="Name"), - # TextInput((12, 13), (2, self.width - 16), AGENT_NAME, color=Color(240, 20, 100, reversed=True)), + Title((9, 1), (1, self.width-3), color=COLOR_TITLE, value="Define An Agent"), - # Text((15, 2), (1, 11), color=Color(0, 100, 100), value="Role"), - # TextInput((15, 13), (6, self.width - 16), AGENT_ROLE, color=Color(240, 20, 100, reversed=True)), + Text((11, 2), (1, 11), color=COLOR_FORM, value="Name"), + TextInput((11, 13), (2, self.width - 15), self.agent_name, **FIELD_COLORS), - # Text((22, 2), (1, 11), color=Color(0, 100, 100), value="Goal"), - # TextInput((22, 13), (6, self.width - 16), AGENT_GOAL, color=Color(240, 20, 100, reversed=True)), + Text((13, 2), (1, 11), color=COLOR_FORM, value="Role"), + TextInput((13, 13), (5, self.width - 15), self.agent_role, **FIELD_COLORS), - # Text((29, 2), (1, 11), color=Color(0, 100, 100), value="Backstory"), - # TextInput((29, 13), (6, self.width - 16), AGENT_BACKSTORY, color=Color(240, 20, 100, reversed=True)), + Text((18, 2), (1, 11), color=COLOR_FORM, value="Goal"), + TextInput((18, 13), (5, self.width - 15), self.agent_goal, **FIELD_COLORS), - #Button((35, self.width-14), (3, 10), "Next", color=Color(0, 100, 100, reversed=True)), + Text((23, 2), (1, 11), color=COLOR_FORM, value="Backstory"), + TextInput((23, 13), (5, self.width - 15), self.agent_backstory, **FIELD_COLORS), + Button((self.height-6, self.width-17), (3, 15), "Next", color=COLOR_BUTTON, on_confirm=self.submit), ]), + HelpText((self.height-1, 0), (1, self.width)), ] +class AfterAgentView(BannerView): + title = "Boom! We made some agents." + sparkle = "(ノ>ω<)ノ :。・:*:・゚’★,。・:*:・゚’☆" + subtitle = "Now lets make some tasks for the agents to accomplish!" + + class ModelView(View): name = "model" @@ -120,13 +175,16 @@ def set_model_choice(self, index: int, value: str): def get_model_options(self): return [model['value'] for model in self.MODEL_OPTIONS] + def submit(self): + log.info("Model selected: %s", self.model_choice.value) + def layout(self) -> list[Module]: return [ - Box((0, 0), (self.height-1, self.width), color=Color(90), modules=[ + Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ LogoModule((1, 1), (7, self.width-2)), Title((9, 1), (3, self.width-3), color=Color(220, 100, 40, reversed=True), value="Select A Default Model"), - RadioSelect((13, 1), (self.height-20, round(self.width/2)-3), options=self.get_model_options(), color=Color(300, 50), highlight=AnimatedColor( + RadioSelect((13, 1), (self.height-20, round(self.width/2)-3), options=self.get_model_options(), color=Color(300, 50), highlight=ColorAnimation( Color(300, 0, 100, reversed=True), Color(300, 70, 50, reversed=True), duration=0.2 ), on_change=self.set_model_choice), Box((13, round(self.width/2)), (self.height-20, round(self.width/2)-3), color=Color(300, 50), modules=[ @@ -135,77 +193,76 @@ def layout(self) -> list[Module]: WrappedText((8, 3), (5, round(self.width/2)-10), color=Color(300, 50), value=self.model_description), ]), - Button((self.height-6, self.width-17), (3, 15), "Next", color=Color(300, 100, 100, reversed=True)), + Button((self.height-6, self.width-17), (3, 15), "Next", color=COLOR_BUTTON, on_confirm=self.submit), ]), HelpText((self.height-1, 0), (1, self.width)), ] -class ColorView(View): - name = "color" +class DebugView(View): + name = "debug" def layout(self) -> list[Module]: + from agentstack.utils import get_version + return [ - Box((0, 0), (self.height, self.width), color=Color(90, 100, 100), modules=[ - LogoModule((1, 1), (7, self.width-2)), - ColorWheel((6, 1)), + Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ + ColorWheel((1, 1)), + Title((self.height-6, 3), (1, self.width-5), color=COLOR_MAIN, + value=f"AgentStack version {get_version()}"), ]), + HelpText((self.height-1, 0), (1, self.width)), ] -def run(stdscr): - import io - log.set_stdout(io.StringIO()) # disable on-screen logging for now. - - CMD_STR = f" [q]uit [m]odel" - frame_time = 1.0 / 60 # 30 FPS - app = App(stdscr) - view = None - - def load_view(view_cls): - nonlocal view - if view: - app.destroy(view) - view = None +class WizardApp(App): + views = { + 'welcome': BannerView, + 'agent': AgentView, + 'model_selection': ModelView, + 'debug': DebugView, + } + shortcuts = { + 'q': 'quit', + 'd': 'debug', - view = view_cls() - view.init(app.dims) - app.append(view) - # underline rendered CMD_STR for the active command - # view_name = view.__class__.__name__ - # cmd_i = CMD_STR.find(f"[{view_name[0]}]{view_name[1:]}") - # cmd_len = len(view_name) - # grid.append(ui.string((29, 0), (1, 80), " " * 80)) - # grid.append(ui.string((29, cmd_i), (1, 80), "*" * (cmd_len + 2))) + # testing shortcuts + 'a': 'agent', + 'm': 'model_selection', + } + workflow = { + 'root': [ + 'welcome', + 'framework', + 'project', + 'after_project', + 'router', + ], + 'agent': [ + 'agent_details', + 'model_selection', + 'tool_selection', + 'after_agent', + 'router', + ], + 'task': [ + 'task_details', + 'agent_selection', + 'after_task', + 'router', + ], + } - load_view(ModelView) - - last_frame = time.time() - while True: - current_time = time.time() - delta = current_time - last_frame - ch = stdscr.getch() - - if ch == curses.KEY_MOUSE: - _, x, y, _, _ = curses.getmouse() - app.click(y, x) - elif ch != -1: - app.input(ch) + @classmethod + def wrapper(cls, stdscr): + app = cls(stdscr) - if not App.editing: - if ch == ord('q'): - break - elif ch == ord('a'): - load_view(AgentView) - elif ch == ord('c'): - load_view(ColorView) - elif ch == ord('m'): - load_view(ModelView) - - if delta >= frame_time or ch != -1: - app.render() - delta = 0 - if delta < frame_time: - time.sleep(frame_time - delta) + app.load('welcome') + app.run() + def main(): - curses.wrapper(run) \ No newline at end of file + import io + log.set_stdout(io.StringIO()) # disable on-screen logging + + curses.wrapper(WizardApp.wrapper) + diff --git a/agentstack/tui/color.py b/agentstack/tui/color.py index 0437fe81..7fc89d95 100644 --- a/agentstack/tui/color.py +++ b/agentstack/tui/color.py @@ -96,6 +96,22 @@ def _get_closest_color(self) -> int: # Convert to color cube index (16-231) return int(16 + (r * self.COLOR_CUBE_SIZE * self.COLOR_CUBE_SIZE) + (g * self.COLOR_CUBE_SIZE) + b) + def hue(self, h: float) -> 'Color': + """Set the hue of the color.""" + return Color(h, self.s, self.v, self.reversed, self.bold) + + def sat(self, s: float) -> 'Color': + """Set the saturation of the color.""" + return Color(self.h, s, self.v, self.reversed, self.bold) + + def val(self, v: float) -> 'Color': + """Set the value of the color.""" + return Color(self.h, self.s, v, self.reversed, self.bold) + + def reverse(self) -> 'Color': + """Set the reversed attribute of the color.""" + return Color(self.h, self.s, self.v, True, self.bold) + def _get_color_pair(self, pair_number: int) -> int: """Apply reversing to the color pair.""" pair = curses.color_pair(pair_number) @@ -144,7 +160,7 @@ def initialize(cls) -> None: cls._color_map = {} -class AnimatedColor(Color): +class ColorAnimation(Color): start: Color end: Color duration: float diff --git a/agentstack/tui/module.py b/agentstack/tui/module.py index a99ff3d6..06b1088f 100644 --- a/agentstack/tui/module.py +++ b/agentstack/tui/module.py @@ -78,6 +78,10 @@ def __getattr__(self, name): except KeyError: raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") + @property + def chr(self): + return chr(self.ch) + @property def is_numeric(self): return self.ch >= 48 and self.ch <= 57 @@ -259,9 +263,9 @@ def __init__(self, coords, dims, node, color=None, format: callable=None, filter def click(self, y, x): if not self.active and self.hit(y, x): self.activate() - elif self.active: # click off; revert changes + elif self.active: # click off self.deactivate() - self.value = self._original_value + self.save() def activate(self): """Make this module the active one; ie. editing or selected.""" @@ -286,7 +290,7 @@ def input(self, key: Key): # TODO word wrap # TODO we probably don't need to filter as prohibitively if key.is_alpha or key.is_numeric or key.PERIOD or key.MINUS or key.SPACE: - self.value = str(self.value) + chr(ch) + self.value = str(self.value) + key.chr elif key.BACKSPACE: self.value = str(self.value)[:-1] elif key.ESC: @@ -326,22 +330,24 @@ class TextInput(Editable): """ H, V, BR = "━", "┃", "┛" padding: tuple[int, int] = (2, 1) + border_color: 'Color' + active_color: 'Color' + word_wrap: bool = True - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional['Color'] = None, format: callable=None): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional['Color'] = None, border: Optional['Color'] = None, active: Optional['Color'] = None, format: callable=None): super().__init__(coords, dims, node=node, color=color, format=format) self.width, self.height = (dims[1]-1, dims[0]-1) - from agentstack.tui.color import Color - self.border_color = Color(0, 100, 100) + self.border_color = border or self.color + self.active_color = active or self.color def activate(self): # change the border color to a highlight self._original_border_color = self.border_color - from agentstack.tui.color import Color - self.border_color = Color(90, 70, 100) + self.border_color = self.active_color super().activate() def deactivate(self): - if self.active: + if self.active and hasattr(self, '_original_border_color'): self.border_color = self._original_border_color super().deactivate() @@ -363,16 +369,17 @@ class Button(Module): active: bool = False selected: bool = False highlight: Optional['Color'] = None - # callback: Optional[callable] = None + on_confirm: Optional[callable] = None - def __init__( self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None, highlight: Optional['Color'] = None): + def __init__( self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_confirm: Optional[callable] = None): super().__init__(coords, dims, value=value, color=color) - from agentstack.tui.color import Color - self.highlight = highlight or Color(self.color.h, 80, self.color.v, reversed=self.color.reversed) + self.highlight = highlight or self.color.sat(80) + self.on_confirm = on_confirm def confirm(self): """Handle button confirmation.""" - log.debug(f"CONFIRM {self}") + if self.on_confirm: + self.on_confirm() def activate(self): """Make this module the active one; ie. editing or selected.""" @@ -561,6 +568,10 @@ class RadioSelect(Select): """Allow one button to be `selected` at a time""" button_cls = RadioButton + def __init__(self, coords: Tuple[int, int], dims: Tuple[int, int], options: List[str], color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_change: Optional[callable] = None) -> None: + super().__init__(coords, dims, options, color=color, highlight=highlight, on_change=on_change) + self.select(self.modules[0]) + def select(self, module: Module): for _module in self.modules: _module.selected = False @@ -611,13 +622,17 @@ def layout(self) -> list[Module]: class App: + stdscr: curses.window + frame_time: float = 1.0 / 60 # 30 FPS editing = False dims = property(lambda self: self.stdscr.getmaxyx()) # TODO remove this + view: Optional[View] = None # the active view + views: dict[str, type[View]] = {} + shortcuts: dict[str, str] = {} def __init__(self, stdscr: curses.window) -> None: self.stdscr = stdscr - self.height, self.width = stdscr.getmaxyx() - self.modules = [] + self.height, self.width = self.stdscr.getmaxyx() # TODO dynamic resizing curses.curs_set(0) stdscr.nodelay(True) @@ -626,23 +641,59 @@ def __init__(self, stdscr: curses.window) -> None: from agentstack.tui.color import Color Color.initialize() - - def append(self, module): - self.modules.append(module) + + def add_view(self, name: str, view_cls: type[View], shortcut: Optional[str] = None) -> None: + self.views[name] = view_cls + if shortcut: + self.shortcuts[shortcut] = name + + def load(self, name: str): + if self.view: + self.view.destroy() + self.view = None + + view_cls = self.views[name] + self.view = view_cls() + self.view.init(self.dims) + + def run(self): + frame_time = 1.0 / 60 # 30 FPS + last_frame = time.time() + while True: + current_time = time.time() + delta = current_time - last_frame + ch = self.stdscr.getch() + + if ch == curses.KEY_MOUSE: + _, x, y, _, _ = curses.getmouse() + self.click(y, x) + elif ch != -1: + self.input(ch) + + if not App.editing: + if ch == ord('q'): + break + elif ch in [ord(x) for x in self.shortcuts.keys()]: + self.load(self.shortcuts[chr(ch)]) + + if delta >= self.frame_time or ch != -1: + self.render() + delta = 0 + if delta < self.frame_time: + time.sleep(frame_time - delta) def render(self): - for module in self.modules: - if not isinstance(module, Contains): - module.grid.erase() - module.render() - module.last_render = time.time() - module.grid.noutrefresh() + if self.view: + self.view.render() + self.view.last_render = time.time() + self.view.grid.noutrefresh() + curses.doupdate() def click(self, y, x): """Handle mouse click event.""" - for module in self.modules: - module.click(y, x) + if self.view: + self.view.click(y, x) def input(self, ch: int): """Handle key input event.""" @@ -651,14 +702,8 @@ def input(self, ch: int): if key.TAB: self._select_next_tabbable() - for module in self.modules: - module.input(key) - - def destroy(self, module: View): - # TODO this should probably kill all modules - self.modules.remove(module) - module.destroy() - #curses.endwin() + if self.view: + self.view.input(key) def _get_tabbable_modules(self): """ @@ -670,7 +715,7 @@ def _get_activateable(module: Module): yield module for submodule in getattr(module, 'modules', []): yield from _get_activateable(submodule) - return list(_get_activateable(self)) + return list(_get_activateable(self.view)) def _select_next_tabbable(self): """ @@ -686,7 +731,7 @@ def _get_active_module(module: Module): return None modules = self._get_tabbable_modules() - active_module = _get_active_module(self) + active_module = _get_active_module(self.view) if active_module: for module in modules: module.deactivate() From a0a6308ded19ca7abc8af59750e6343f576a5f18 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 24 Jan 2025 15:54:50 -0800 Subject: [PATCH 03/34] Remove unused file. --- agentstack/tui/animation.py | 75 ------------------------------------- 1 file changed, 75 deletions(-) delete mode 100644 agentstack/tui/animation.py diff --git a/agentstack/tui/animation.py b/agentstack/tui/animation.py deleted file mode 100644 index 0ad27950..00000000 --- a/agentstack/tui/animation.py +++ /dev/null @@ -1,75 +0,0 @@ - - -""" -Animations are classes that interact with elements inside of a tui.Module to -create visual effects over time. tui.Module keeps track of the current frame and -handles render, but we prepare the elements that need to be rendered beforehand. - -We want to accomplish a few things with animation: - -- Fading an element in or out. Module.color -> black and back. -- A color sweep that passes from left to right across an element. The animation - can either play on repeat with a delay, or just once (perhaps by setting a delay of -1) - - - - -# space_colors = [ -# (75, 0, 130), # Indigo -# (138, 43, 226), # Purple -# (0, 0, 255), # Blue -# (138, 43, 226), # Purple -# ] -# main_box.set_color_animation(ColorAnimation(space_colors, speed=0.2)) - - -class ColorAnimation: - def __init__(self, colors: List[Tuple[int, int, int]], speed: float = 1.0): - self.colors = colors - self.speed = speed - self.start_time = time.time() - - def _interpolate_color(self, color1: Tuple[int, int, int], color2: Tuple[int, int, int], factor: float) -> Tuple[int, int, int]: - return tuple(int(c1 + (c2 - c1) * factor) for c1, c2 in zip(color1, color2)) - - def _rgb_to_curses_color(self, r: int, g: int, b: int) -> int: - colors = [ - (0, 0, 0, curses.COLOR_BLACK), - (1000, 0, 0, curses.COLOR_RED), - (0, 1000, 0, curses.COLOR_GREEN), - (1000, 1000, 0, curses.COLOR_YELLOW), - (0, 0, 1000, curses.COLOR_BLUE), - (1000, 0, 1000, curses.COLOR_MAGENTA), - (0, 1000, 1000, curses.COLOR_CYAN), - (1000, 1000, 1000, curses.COLOR_WHITE), - ] - - r = r * 1000 // 255 - g = g * 1000 // 255 - b = b * 1000 // 255 - - min_dist = float('inf') - best_color = curses.COLOR_WHITE - for cr, cg, cb, color in colors: - dist = (cr - r) ** 2 + (cg - g) ** 2 + (cb - b) ** 2 - if dist < min_dist: - min_dist = dist - best_color = color - return best_color - - def get_color_for_line(self, line: int, total_lines: int, current_time: Optional[float] = None) -> int: - if current_time is None: - current_time = time.time() - - wave_pos = (current_time - self.start_time) * self.speed - line_pos = (line / total_lines + wave_pos) % 1.0 - - color_pos = line_pos * len(self.colors) - color_index = int(color_pos) - color_factor = color_pos - color_index - - color1 = self.colors[color_index % len(self.colors)] - color2 = self.colors[(color_index + 1) % len(self.colors)] - - blended = self._interpolate_color(color1, color2, color_factor) - return self._rgb_to_curses_color(*blended) \ No newline at end of file From 81433cd9086881efc0be806a7567dec043f692df Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 27 Jan 2025 17:22:14 -0800 Subject: [PATCH 04/34] Full wizard workflow complete. --- agentstack/_tools/__init__.py | 5 + agentstack/cli/cli.py | 4 +- agentstack/cli/init.py | 26 +- agentstack/proj_templates.py | 12 +- agentstack/tui/__init__.py | 810 +++++++++++++++++++++++++++++----- agentstack/tui/module.py | 217 +++++++-- pyproject.toml | 3 +- 7 files changed, 910 insertions(+), 167 deletions(-) diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index a9382780..7ab5e51e 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -121,3 +121,8 @@ def get_all_tool_names() -> list[str]: def get_all_tools() -> list[ToolConfig]: return [ToolConfig.from_tool_name(path) for path in get_all_tool_names()] + + +def get_tool(name: str) -> ToolConfig: + return ToolConfig.from_tool_name(name) + diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index 81a2006a..f505bc99 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -1,5 +1,4 @@ import os, sys -from art import text2art import inquirer from agentstack import conf, log from agentstack.conf import ConfigFile @@ -26,12 +25,11 @@ def welcome_message(): - title = text2art("AgentStack", font="smisome1") tagline = "The easiest way to build a robust agent application!" border = "-" * len(tagline) # Print the welcome message with ASCII art - log.info(title) + log.info(LOGO) log.info(border) log.info(tagline) log.info(border) diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index d0dcbb9e..21e20909 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -37,6 +37,7 @@ def init_project( template: Optional[str] = None, framework: Optional[str] = None, use_wizard: bool = False, + template_data: Optional[TemplateConfig] = None, ): """ Initialize a new project in the current directory. @@ -59,18 +60,19 @@ def init_project( if os.path.exists(conf.PATH): # cookiecutter requires the directory to not exist raise Exception(f"Directory already exists: {conf.PATH}") - if template and use_wizard: - raise Exception("Template and wizard flags cannot be used together") - - if use_wizard: - log.debug("Initializing new project with wizard.") - template_data = run_wizard(slug_name) - elif template: - log.debug(f"Initializing new project with template: {template}") - template_data = TemplateConfig.from_user_input(template) - else: - log.debug(f"Initializing new project with default template: {DEFAULT_TEMPLATE_NAME}") - template_data = TemplateConfig.from_template_name(DEFAULT_TEMPLATE_NAME) + if not template_data: + if template and use_wizard: + raise Exception("Template and wizard flags cannot be used together") + + if use_wizard: + log.debug("Initializing new project with wizard.") + template_data = run_wizard(slug_name) + elif template: + log.debug(f"Initializing new project with template: {template}") + template_data = TemplateConfig.from_user_input(template) + else: + log.debug(f"Initializing new project with default template: {DEFAULT_TEMPLATE_NAME}") + template_data = TemplateConfig.from_template_name(DEFAULT_TEMPLATE_NAME) welcome_message() log.notify("🦾 Creating a new AgentStack project...") diff --git a/agentstack/proj_templates.py b/agentstack/proj_templates.py index 62922787..3beaa825 100644 --- a/agentstack/proj_templates.py +++ b/agentstack/proj_templates.py @@ -174,16 +174,16 @@ class TemplateConfig(pydantic.BaseModel): class Agent(pydantic.BaseModel): name: str - role: str - goal: str - backstory: str + role: Optional[str] + goal: Optional[str] + backstory: Optional[str] allow_delegation: bool = False llm: str class Task(pydantic.BaseModel): name: str - description: str - expected_output: str + description: Optional[str] + expected_output: Optional[str] agent: str # TODO this is redundant with the graph class Tool(pydantic.BaseModel): @@ -203,7 +203,7 @@ class Node(pydantic.BaseModel): agents: list[Agent] tasks: list[Task] tools: list[Tool] - graph: list[list[Node]] + graph: list[list[Node]] = [] inputs: dict[str, str] = {} @pydantic.field_validator('graph') diff --git a/agentstack/tui/__init__.py b/agentstack/tui/__init__.py index 71d5cbe1..d0277515 100644 --- a/agentstack/tui/__init__.py +++ b/agentstack/tui/__init__.py @@ -1,35 +1,31 @@ +import sys import curses -import signal import time import math from random import randint from dataclasses import dataclass from typing import Optional, Any, List, Tuple, Union from enum import Enum +from pathlib import Path from agentstack import conf, log from agentstack.cli import LOGO +from agentstack.cli.init import init_project from agentstack.tui.module import * from agentstack.tui.color import Color, ColorAnimation, ColorWheel +from agentstack.utils import is_snake_case +from agentstack.frameworks import SUPPORTED_FRAMEWORKS, CREWAI, LANGGRAPH +from agentstack._tools import get_all_tools, get_tool +from agentstack.proj_templates import TemplateConfig -# TODO this could be a dynamic module type -LOGO_ANTHROPIC = """\ - _ - /_/_ _/_/_ __ _ ._ -/ // // / ///_//_///_ - / """ - -LOGO_OPENAI = """\ - _ _ - / /_ _ _ /_// -/_//_//_'/ // // - / """ COLOR_BORDER = Color(90) COLOR_MAIN = Color(220) COLOR_TITLE = Color(220, 100, 40, reversed=True) +COLOR_ERROR = Color(0) COLOR_FORM = Color(300) +COLOR_FORM_BORDER = Color(300, 50) COLOR_BUTTON = Color(300, reversed=True) COLOR_FIELD_BG = Color(240, 20, 100, reversed=True) COLOR_FIELD_BORDER = Color(300, 100, 50) @@ -41,7 +37,7 @@ } class LogoModule(Text): - #h_align = ALIGN_CENTER # TODO center stars + h_align = ALIGN_CENTER def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): super().__init__(coords, dims) @@ -49,6 +45,8 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): self.value = LOGO self.stars = [(3, 1), (25, 5), (34, 1), (52, 2), (79, 3), (97, 1)] self._star_colors = {} + content_width = len(LOGO.split('\n')[0]) + self.left_offset = round((self.width - content_width) / 2) def _get_star_color(self, index: int) -> Color: if index not in self._star_colors: @@ -63,9 +61,38 @@ def _get_star_color(self, index: int) -> Color: def render(self) -> None: super().render() for i, (x, y) in enumerate(self.stars): - if x >= self.width or y >= self.height: + if self.width <= x or self.height <= y: continue - self.grid.addch(y, x, '*', self._get_star_color(i).to_curses()) + self.grid.addch(y, self.left_offset + x, '*', self._get_star_color(i).to_curses()) + + +class StarBox(Box): + """Renders random stars that animate down the page in the background of the box.""" + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], **kwargs): + super().__init__(coords, dims, **kwargs) + self.stars = [(randint(0, self.width-1), randint(0, self.height-1)) for _ in range(11)] + self.star_colors = [ColorAnimation( + Color(randint(0, 150)), + Color(randint(200, 360)), + duration=2.0, + loop=False, + ) for _ in range(11)] + self.star_y = [randint(0, self.height-1) for _ in range(11)] + self.star_x = [randint(0, self.width-1) for _ in range(11)] + self.star_speed = 0.001 + self.star_timer = 0.0 + self.star_index = 0 + + def render(self) -> None: + self.grid.clear() + for i in range(len(self.stars)): + if self.star_y[i] < self.height: + self.grid.addch(self.star_y[i], self.star_x[i], '*', self.star_colors[i].to_curses()) + self.star_y[i] += 1 + else: + self.star_y[i] = 0 + self.star_x[i] = randint(0, self.width-1) + super().render() class HelpText(Text): @@ -85,7 +112,7 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int]) -> None: class BannerView(View): name = "banner" title = "Welcome to AgentStack" - sparkle = "The fastest way to build AI agents." + sparkle = "The easiest way to build a robust agent application." subtitle = "Let's get started!" color = ColorAnimation( start=Color(90, 0, 0), # TODO make this darker @@ -94,111 +121,530 @@ class BannerView(View): ) def layout(self) -> list[Module]: + buttons = [] + + if not self.app.state.project: + # no project yet, so we need to create one + buttons.append(Button( + # center button full width below the subtitle + (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), + (3, round(self.width/2)), + "Create Project", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('project', workflow='project'), + )) + else: + # project has been created, so we can add agents + buttons.append(Button( + (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), + (3, round(self.width/2)), + "Add Agent", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('agent', workflow='agent'), + )) + + if len(self.app.state.agents): + # we have one or more agents, so we can add tasks + buttons.append(Button( + (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), + (3, round(self.width/2)), + "Add Task", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('task', workflow='task'), + )) + + # # we can also add more tools to existing agents + # buttons.append(Button( + # (self.height-6, self.width-34), + # (3, 15), + # "Add Tool", + # color=COLOR_BUTTON, + # on_confirm=lambda: self.app.load('tool_category', workflow='agent'), + # )) + + if self.app.state.project: + # we can complete the project + buttons.append(Button( + (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), + (3, round(self.width/2)), + "Finish", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.finish(), + )) + return [ - Box((0, 0), (self.height, self.width), color=COLOR_BORDER, modules=[ - Title((round(self.height / 2)-4, 1), (1, self.width-3), color=self.color, value=self.title), - Title((round(self.height / 2)-2, 1), (1, self.width-3), color=self.color, value=self.sparkle), - Title((round(self.height / 2), 1), (1, self.width-3), color=self.color, value=self.subtitle), + StarBox((0, 0), (self.height, self.width), color=COLOR_BORDER, modules=[ + LogoModule((1, 1), (7, self.width-2)), + # double box half width placed in the center + Box( + (round(self.height / 4), round(self.width / 4)), + (9, round(self.width / 2)), + color=COLOR_BORDER, + modules=[ + Title((1, 1), (2, round(self.width / 2)-2), color=self.color, value=self.title), + Title((3, 1), (2, round(self.width / 2)-2), color=self.color, value=self.sparkle), + Title((5, 1), (2, round(self.width / 2)-2), color=self.color, value=self.subtitle), + ]), + *buttons, ]), ] -class AfterTaskView(BannerView): - title = "Let there be tasks!" - sparkle = "(ノ ˘_˘)ノ ζ|||ζ ζ|||ζ ζ|||ζ" - subtitle = "Tasks are the heart of your agent's work. " - +class FormView(View): + def __init__(self, app: 'App'): + super().__init__(app) + self.error_message = Node() -class AgentView(View): - name = "agent" + def submit(self): + pass - agent_name = Node() - agent_role = Node() - agent_goal = Node() - agent_backstory = Node() + def error(self, message: str): + self.error_message.value = message - def submit(self): - log.info("Agent defined: %s", self.agent_name.value) + def form(self) -> list[Module]: + return [] def layout(self) -> list[Module]: return [ Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ LogoModule((1, 1), (7, self.width-2)), - Title((9, 1), (1, self.width-3), color=COLOR_TITLE, value="Define An Agent"), - - Text((11, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((11, 13), (2, self.width - 15), self.agent_name, **FIELD_COLORS), - - Text((13, 2), (1, 11), color=COLOR_FORM, value="Role"), - TextInput((13, 13), (5, self.width - 15), self.agent_role, **FIELD_COLORS), - - Text((18, 2), (1, 11), color=COLOR_FORM, value="Goal"), - TextInput((18, 13), (5, self.width - 15), self.agent_goal, **FIELD_COLORS), - - Text((23, 2), (1, 11), color=COLOR_FORM, value="Backstory"), - TextInput((23, 13), (5, self.width - 15), self.agent_backstory, **FIELD_COLORS), - + Title((9, 1), (1, self.width-3), color=COLOR_TITLE, value=self.title), + Title((10, 1), (1, self.width-3), color=COLOR_ERROR, value=self.error_message), + *self.form(), Button((self.height-6, self.width-17), (3, 15), "Next", color=COLOR_BUTTON, on_confirm=self.submit), ]), HelpText((self.height-1, 0), (1, self.width)), ] -class AfterAgentView(BannerView): - title = "Boom! We made some agents." - sparkle = "(ノ>ω<)ノ :。・:*:・゚’★,。・:*:・゚’☆" - subtitle = "Now lets make some tasks for the agents to accomplish!" +class ProjectView(FormView): + title = "Define your Project" + + def __init__(self, app: 'App'): + super().__init__(app) + self.project_name = Node() + self.project_description = Node() + + def submit(self): + if not self.project_name.value: + self.error("Name is required.") + return + + if not is_snake_case(self.project_name.value): + self.error("Name must be in snake_case.") + return + + self.app.state.create_project( + name=self.project_name.value, + description=self.project_description.value, + ) + self.app.advance() + + def form(self) -> list[Module]: + return [ + Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), + TextInput((12, 13), (2, self.width - 15), self.project_name, **FIELD_COLORS), + + Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), + TextInput((14, 13), (5, self.width - 15), self.project_description, **FIELD_COLORS), + ] -class ModelView(View): - name = "model" +class FrameworkView(FormView): + title = "Select a Framework" + + FRAMEWORK_OPTIONS = { + CREWAI: {'name': "CrewAI", 'description': "A simple and easy-to-use framework."}, + LANGGRAPH: {'name': "LangGraph", 'description': "A powerful and flexible framework."}, + } + + def __init__(self, app: 'App'): + super().__init__(app) + self.framework_key = Node() + self.framework_logo = Node() + self.framework_name = Node() + self.framework_description = Node() + + def set_framework_selection(self, index: int, value: str): + """Update the content of the framework info box.""" + key, data = None, None + for _key, _value in self.FRAMEWORK_OPTIONS.items(): + if _value['name'] == value: # search by name + key = _key + data = _value + break + + if not key or not data: + key = value + data = { + 'name': "Unknown", + 'description': "Unknown", + } + + self.framework_logo.value = data['name'] + self.framework_name.value = data['name'] + self.framework_description.value = data['description'] + + def set_framework_choice(self, index: int, value: str): + """Save the selection.""" + key = None + for _key, _value in self.FRAMEWORK_OPTIONS.items(): + if _value['name'] == value: # search by name + key = _key + break + + self.framework_key.value = key + + def get_framework_options(self) -> list[str]: + return [self.FRAMEWORK_OPTIONS[key]['name'] for key in SUPPORTED_FRAMEWORKS] + + def submit(self): + if not self.framework_key.value: + self.error("Framework is required.") + return + + self.app.state.update_active_project(framework=self.framework_key.value) + self.app.advance() + + def form(self) -> list[Module]: + return [ + RadioSelect( + (12, 1), (self.height-18, round(self.width/2)-3), + options=self.get_framework_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_change=self.set_framework_selection, + on_select=self.set_framework_choice + ), + Box((12, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ + ASCIIText((1, 3), (4, round(self.width/2)-10), color=COLOR_FORM.sat(40), value=self.framework_logo), + BoldText((5, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.framework_name), + WrappedText((7, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.framework_description), + ]), + ] + + +class AfterProjectView(BannerView): + title = "We've got a project!" + sparkle = "*゚・:*:・゚’★,。・:*:・゚’☆" + subtitle = "Now, add an Agent to handle your tasks!" + + +class AgentView(FormView): + title = "Define your Agent" + + def __init__(self, app: 'App'): + super().__init__(app) + self.agent_name = Node() + self.agent_role = Node() + self.agent_goal = Node() + self.agent_backstory = Node() + + def submit(self): + if not self.agent_name.value: + self.error("Name is required.") + return + + if not is_snake_case(self.agent_name.value): + self.error("Name must be in snake_case.") + return + + self.app.state.create_agent( + name=self.agent_name.value, + role=self.agent_role.value, + goal=self.agent_goal.value, + backstory=self.agent_backstory.value, + ) + self.app.advance() + + def form(self) -> list[Module]: + return [ + Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), + TextInput((12, 13), (2, self.width - 15), self.agent_name, **FIELD_COLORS), + + Text((14, 2), (1, 11), color=COLOR_FORM, value="Role"), + TextInput((14, 13), (5, self.width - 15), self.agent_role, **FIELD_COLORS), + + Text((19, 2), (1, 11), color=COLOR_FORM, value="Goal"), + TextInput((19, 13), (5, self.width - 15), self.agent_goal, **FIELD_COLORS), + + Text((24, 2), (1, 11), color=COLOR_FORM, value="Backstory"), + TextInput((24, 13), (5, self.width - 15), self.agent_backstory, **FIELD_COLORS), + ] + + +class ModelView(FormView): + title = "Select a Model" MODEL_OPTIONS = [ - {'value': "gpt-3.5-turbo", 'name': "GPT-3.5 Turbo", 'description': "A fast and cost-effective model.", 'logo': LOGO_ANTHROPIC}, - {'value': "gpt-4", 'name': "GPT-4", 'description': "A more advanced model with better understanding.", 'logo': LOGO_OPENAI}, - {'value': "gpt-4o", 'name': "GPT-4o", 'description': "The latest and most powerful model.", 'logo': LOGO_OPENAI}, - {'value': "gpt-4o-mini", 'name': "GPT-4o Mini", 'description': "A smaller, faster version of GPT-4o.", 'logo': LOGO_ANTHROPIC}, + {'value': "anthropic/claude-3.5-sonnet", 'name': "Claude 3.5 Sonnet", 'provider': "Anthropic", 'description': "A fast and cost-effective model."}, + {'value': "gpt-3.5-turbo", 'name': "GPT-3.5 Turbo", 'provider': "OpenAI", 'description': "A fast and cost-effective model."}, + {'value': "gpt-4", 'name': "GPT-4", 'provider': "OpenAI", 'description': "A more advanced model with better understanding."}, + {'value': "gpt-4o", 'name': "GPT-4o", 'provider': "OpenAI", 'description': "The latest and most powerful model."}, + {'value': "gpt-4o-mini", 'name': "GPT-4o Mini", 'provider': "OpenAI", 'description': "A smaller, faster version of GPT-4o."}, ] - model_choice = Node() - model_logo = Node() - model_name = Node() - model_description = Node() - def set_model_choice(self, index: int, value: str): + def __init__(self, app: 'App'): + super().__init__(app) + self.model_choice = Node() + self.model_logo = Node() + self.model_name = Node() + self.model_description = Node() + + def set_model_selection(self, index: int, value: str): + """Update the content of the model info box.""" model = self.MODEL_OPTIONS[index] - self.model_choice.value = model['value'] - self.model_logo.value = model['logo'] + self.model_logo.value = model['provider'] self.model_name.value = model['name'] self.model_description.value = model['description'] + def set_model_choice(self, index: int, value: str): + """Save the selection.""" + # list in UI shows the actual key + self.model_choice.value = value + def get_model_options(self): return [model['value'] for model in self.MODEL_OPTIONS] def submit(self): - log.info("Model selected: %s", self.model_choice.value) + if not self.model_choice.value: + self.error("Model is required.") + return + + self.app.state.update_active_agent(llm=self.model_choice.value) + self.app.advance() - def layout(self) -> list[Module]: + def form(self) -> list[Module]: return [ - Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ - LogoModule((1, 1), (7, self.width-2)), - Title((9, 1), (3, self.width-3), color=Color(220, 100, 40, reversed=True), value="Select A Default Model"), - - RadioSelect((13, 1), (self.height-20, round(self.width/2)-3), options=self.get_model_options(), color=Color(300, 50), highlight=ColorAnimation( - Color(300, 0, 100, reversed=True), Color(300, 70, 50, reversed=True), duration=0.2 - ), on_change=self.set_model_choice), - Box((13, round(self.width/2)), (self.height-20, round(self.width/2)-3), color=Color(300, 50), modules=[ - Text((1, 3), (4, round(self.width/2)-10), color=Color(300, 40), value=self.model_logo), - BoldText((6, 3), (1, round(self.width/2)-10), color=Color(300), value=self.model_name), - WrappedText((8, 3), (5, round(self.width/2)-10), color=Color(300, 50), value=self.model_description), - ]), - - Button((self.height-6, self.width-17), (3, 15), "Next", color=COLOR_BUTTON, on_confirm=self.submit), + RadioSelect( + (11, 1), (self.height-18, round(self.width/2)-3), + options=self.get_model_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_change=self.set_model_selection, + on_select=self.set_model_choice + ), + Box((11, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ + ASCIIText((1, 3), (4, round(self.width/2)-10), color=COLOR_FORM.sat(40), value=self.model_logo), + BoldText((5, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.model_name), + WrappedText((7, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.model_description), ]), - HelpText((self.height-1, 0), (1, self.width)), ] +class ToolCategoryView(FormView): + title = "Select a Tool Category" + + # TODO category descriptions for all valid categories + TOOL_CATEGORY_OPTIONS = { + "web": {'name': "Web Tools", 'description': "Tools that interact with the web."}, + "file": {'name': "File Tools", 'description': "Tools that interact with the file system."}, + "code": {'name': "Code Tools", 'description': "Tools that interact with code."}, + } + + def __init__(self, app: 'App'): + super().__init__(app) + self.tool_category_key = Node() + self.tool_category_name = Node() + self.tool_category_description = Node() + + def set_tool_category_selection(self, index: int, value: str): + key, data = None, None + for _key, _value in self.TOOL_CATEGORY_OPTIONS.items(): + if _value['name'] == value: # search by name + key = _key + data = _value + break + + if not key or not data: + key = value + data = { + 'name': "Unknown", + 'description': "Unknown", + } + + self.tool_category_name.value = data['name'] + self.tool_category_description.value = data['description'] + + def set_tool_category_choice(self, index: int, value: str): + self.tool_category_key.value = value + + def get_tool_category_options(self) -> list[str]: + return sorted(list({tool.category for tool in get_all_tools()})) + + def submit(self): + if not self.tool_category_key.value: + self.error("Tool category is required.") + return + + self.app.state.tool_category = self.tool_category_key.value + self.app.advance() + + def form(self) -> list[Module]: + return [ + RadioSelect( + (11, 1), (self.height-18, round(self.width/2)-3), + options=self.get_tool_category_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_change=self.set_tool_category_selection, + on_select=self.set_tool_category_choice + ), + Box((11, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ + BoldText((1, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.tool_category_name), + WrappedText((2, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.tool_category_description), + ]), + ] + + +class ToolView(FormView): + title = "Select a Tool" + + def __init__(self, app: 'App'): + super().__init__(app) + self.tool_key = Node() + self.tool_name = Node() + self.tool_description = Node() + + @property + def category(self) -> str: + return self.app.state.tool_category + + def set_tool_selection(self, index: int, value: str): + tool_config = get_tool(value) + self.tool_name.value = tool_config.name + self.tool_description.value = tool_config.cta + + def set_tool_choice(self, index: int, value: str): + self.tool_key.value = value + + def get_tool_options(self) -> list[str]: + return sorted([tool.name for tool in get_all_tools() if tool.category == self.category]) + + def submit(self): + if not self.tool_key.value: + self.error("Tool is required.") + return + + self.app.state.update_active_agent_tools(self.tool_key.value) + self.app.advance() + + def back(self): + self.app.back() + + def form(self) -> list[Module]: + return [ + RadioSelect( + (12, 1), (self.height-18, round(self.width/2)-3), + options=self.get_tool_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_change=self.set_tool_selection, + on_select=self.set_tool_choice + ), + Box((12, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ + BoldText((1, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.tool_name), + WrappedText((2, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.tool_description), + ]), + Button((self.height-6, self.width-17), (3, 15), "Back", color=COLOR_BUTTON, on_confirm=self.back), + ] + + +class AfterAgentView(BannerView): + title = "Boom! We made some agents." + sparkle = "(ノ>ω<)ノ :。・:*:・゚’★,。・:*:・゚’☆" + subtitle = "Now lets make some tasks for the agents to accomplish!" + + +class TaskView(FormView): + title = "Define your Task" + + def __init__(self, app: 'App'): + super().__init__(app) + self.task_name = Node() + self.task_description = Node() + self.expected_output = Node() + + def submit(self): + if not self.task_name.value: + self.error("Task name is required.") + return + + if not is_snake_case(self.task_name.value): + self.error("Task name must be in snake_case.") + return + + self.app.state.create_task( + name=self.task_name.value, + description=self.task_description.value, + expected_output=self.expected_output.value, + ) + self.app.advance() + + def form(self) -> list[Module]: + return [ + Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), + TextInput((12, 13), (2, self.width - 15), self.task_name, **FIELD_COLORS), + + Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), + TextInput((14, 13), (5, self.width - 15), self.task_description, **FIELD_COLORS), + + Text((19, 2), (1, 11), color=COLOR_FORM, value="Expected Output"), + TextInput((19, 13), (5, self.width - 15), self.expected_output, **FIELD_COLORS), + ] + + +class AgentSelectionView(FormView): + title = "Select an Agent for your Task" + + def __init__(self, app: 'App'): + super().__init__(app) + self.agent_name = Node() + + def set_agent_choice(self, index: int, value: str): + self.agent_name.value = value + + def get_agent_options(self) -> list[str]: + return list(self.app.state.agents.keys()) + + def submit(self): + if not self.agent_name.value: + self.error("Agent is required.") + return + + self.app.state.update_active_task(agent=self.agent_name.value) + self.app.advance() + + def form(self) -> list[Module]: + return [ + RadioSelect( + (12, 1), (self.height-18, round(self.width/2)-3), + options=self.get_agent_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_select=self.set_agent_choice + ), + # TODO agent info pane + ] + + +class AfterTaskView(BannerView): + title = "Let there be tasks!" + sparkle = "(ノ ˘_˘)ノ ζ|||ζ ζ|||ζ ζ|||ζ" + subtitle = "Tasks are the heart of your agent's work. " + + class DebugView(View): name = "debug" def layout(self) -> list[Module]: @@ -209,54 +655,206 @@ def layout(self) -> list[Module]: ColorWheel((1, 1)), Title((self.height-6, 3), (1, self.width-5), color=COLOR_MAIN, value=f"AgentStack version {get_version()}"), + Title((self.height-4, 3), (1, self.width-5), color=COLOR_MAIN, + value=f"Window size: {self.width}x{self.height}"), ]), HelpText((self.height-1, 0), (1, self.width)), ] +class State: + project: dict[str, Any] + # `active_agent` is the agent we are currently working on + active_agent: str + # `active_task` is the task we are currently working on + active_task: str + # `tool_category` is a temporary value while an agent is being created + tool_category: str + # `agents` is a dictionary of agents we have created + agents: dict[str, dict] + # `tasks` is a dictionary of tasks we have created + tasks: dict[str, dict] + + def __init__(self): + self.project = {} + self.agents = {} + self.tasks = {} + + def __repr__(self): + return f"State(project={self.project}, agents={self.agents}, tasks={self.tasks})" + + def create_project(self, name: str, description: str): + self.project = { + 'name': name, + 'description': description, + } + self.active_project = name + + def update_active_project(self, **kwargs): + for key, value in kwargs.items(): + self.project[key] = value + + def create_agent(self, name: str, role: str, goal: str, backstory: str): + self.agents[name] = { + 'role': role, + 'goal': goal, + 'backstory': backstory, + 'llm': None, + 'tools': [], + } + self.active_agent = name + + def update_active_agent(self, **kwargs): + agent = self.agents[self.active_agent] + for key, value in kwargs.items(): + agent[key] = value + + def update_active_agent_tools(self, tool_name: str): + self.agents[self.active_agent]['tools'].append(tool_name) + + def create_task(self, name: str, description: str, expected_output: str): + self.tasks[name] = { + 'description': description, + 'expected_output': expected_output, + } + self.active_task = name + + def update_active_task(self, **kwargs): + task = self.tasks[self.active_task] + for key, value in kwargs.items(): + task[key] = value + + def to_template_config(self) -> TemplateConfig: + tools = [] + for agent_name, agent_data in self.agents.items(): + for tool_name in agent_data['tools']: + tools.append({ + 'name': tool_name, + 'agents': [agent_name], + }) + + return TemplateConfig( + template_version=4, + name=self.project['name'], + description=self.project['description'], + framework=self.project['framework'], + method="sequential", + agents=[TemplateConfig.Agent( + name=agent_name, + role=agent_data['role'], + goal=agent_data['goal'], + backstory=agent_data['backstory'], + llm=agent_data['llm'], + ) for agent_name, agent_data in self.agents.items()], + tasks=[TemplateConfig.Task( + name=task_name, + description=task_data['description'], + expected_output=task_data['expected_output'], + agent=self.active_agent, + ) for task_name, task_data in self.tasks.items()], + tools=tools, + ) + + class WizardApp(App): views = { - 'welcome': BannerView, - 'agent': AgentView, - 'model_selection': ModelView, - 'debug': DebugView, + 'welcome': BannerView, + 'framework': FrameworkView, + 'project': ProjectView, + 'after_project': AfterProjectView, + 'agent': AgentView, + 'model': ModelView, + 'tool_category': ToolCategoryView, + 'tool': ToolView, + 'after_agent': AfterAgentView, + 'task': TaskView, + 'agent_selection': AgentSelectionView, + 'after_task': AfterTaskView, + 'debug': DebugView, } shortcuts = { - 'q': 'quit', 'd': 'debug', - - # testing shortcuts - 'a': 'agent', - 'm': 'model_selection', } workflow = { - 'root': [ - 'welcome', - 'framework', + 'project': [ # initialize a project + 'welcome', 'project', - 'after_project', - 'router', + 'framework', + 'after_project', ], - 'agent': [ - 'agent_details', - 'model_selection', - 'tool_selection', - 'after_agent', - 'router', + 'agent': [ # add agents + 'agent', + 'model', + 'tool_category', + 'tool', + 'after_agent', ], - 'task': [ - 'task_details', + 'task': [ # add tasks + 'task', 'agent_selection', 'after_task', - 'router', ], + # 'tool': [ # add tools to an agent + # 'agent_select', + # 'tool_category', + # 'tool', + # 'after_agent', + # ] } + active_workflow: str + active_view: str + + min_width: int = 80 + min_height: int = 30 + + def start(self): + """Load the first view in the default workflow.""" + view = self.workflow['project'][0] + self.load(view, workflow='project') + + def finish(self): + """Create the project, write the config file, and exit.""" + template = self.state.to_template_config() + + self.stop() + # TODO there's a flash of the app before the project is created + log.set_stdout(sys.stdout) # re-enable on-screen logging + + init_project( + slug_name=template.name, + template_data=template, + ) + + template.write_to_file(conf.PATH / "wizard") + log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") + + def advance(self): + """Load the next view in the active workflow.""" + workflow = self.workflow[self.active_workflow] + current_index = workflow.index(self.active_view) + view = workflow[current_index + 1] + self.load(view, workflow=self.active_workflow) + + def back(self): + """Load the previous view in the active workflow.""" + workflow = self.workflow[self.active_workflow] + current_index = workflow.index(self.active_view) + view = workflow[current_index - 1] + self.load(view, workflow=self.active_workflow) + + def load(self, view: str, workflow: Optional[str] = None): + """Load a view from a workflow.""" + self.active_workflow = workflow + self.active_view = view + super().load(view) + @classmethod def wrapper(cls, stdscr): app = cls(stdscr) + app.state = State() - app.load('welcome') + app.start() app.run() diff --git a/agentstack/tui/module.py b/agentstack/tui/module.py index 06b1088f..80a72ec9 100644 --- a/agentstack/tui/module.py +++ b/agentstack/tui/module.py @@ -6,12 +6,17 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Optional, Any, List, Tuple, Union from enum import Enum +from pyfiglet import Figlet from agentstack import conf, log if TYPE_CHECKING: from agentstack.tui.color import Color, AnimatedColor +class RenderException(Exception): + pass + + # horizontal alignment ALIGN_LEFT = "left" ALIGN_CENTER = "center" @@ -88,7 +93,7 @@ def is_numeric(self): @property def is_alpha(self): - return (self.ch >= 65 and self.ch <= 90) or (self.ch >= 97 and self.ch <= 122) + return (self.ch >= 65 and self.ch <= 122) class Renderable: @@ -137,6 +142,16 @@ def grid(self): self.x) # TODO this cant be bigger than the window return self._grid + def move(self, y: int, x: int): + self.y, self.x = y, x + if self._grid: + if self.positioning == POS_RELATIVE: + self._grid.mvderwin(self.y, self.x) + elif self.positioning == POS_ABSOLUTE: + self._grid.mvwin(self.y, self.x) + else: + raise ValueError("Cannot move a root window") + @property def abs_x(self): """Absolute X coordinate of this module""" @@ -273,10 +288,12 @@ def activate(self): self.active = True self._original_value = self.value - def deactivate(self): + def deactivate(self, save: bool = True): """Deactivate this module, making it no longer active.""" App.editing = False self.active = False + if save: + self.save() def save(self): if self.filter: @@ -287,18 +304,16 @@ def input(self, key: Key): if not self.active: return - # TODO word wrap - # TODO we probably don't need to filter as prohibitively if key.is_alpha or key.is_numeric or key.PERIOD or key.MINUS or key.SPACE: self.value = str(self.value) + key.chr elif key.BACKSPACE: self.value = str(self.value)[:-1] elif key.ESC: - self.deactivate() + self.deactivate(save=False) self.value = self._original_value # revert changes elif key.ENTER: self.deactivate() - self.save() + log.debug(f"Saving {self.value} to {self.node}") def destroy(self): self.deactivate() @@ -313,6 +328,25 @@ class WrappedText(Text): word_wrap: bool = True +class ASCIIText(Text): + default_font: str = "pepper" + formatter: Optional[Figlet] + _ascii_render: Optional[str] = None # rendered content + _ascii_value: Optional[str] = None # value used to render content + + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None, formatter: Optional[Figlet] = None): + super().__init__(coords, dims, value=value, color=color) + self.formatter = formatter or Figlet(font=self.default_font) + + def _get_lines(self, value: str) -> List[str]: + if not self._ascii_render or self._ascii_value != value: + # prevent rendering on every frame + self._ascii_value = value + self._ascii_render = self.formatter.renderText(value) + + return super()._get_lines(self._ascii_render) + + class BoldText(Text): def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None): super().__init__(coords, dims, value=value, color=color) @@ -346,10 +380,10 @@ def activate(self): self.border_color = self.active_color super().activate() - def deactivate(self): + def deactivate(self, save: bool = True): if self.active and hasattr(self, '_original_border_color'): self.border_color = self._original_border_color - super().deactivate() + super().deactivate(save) def render(self) -> None: for i, line in enumerate(self._get_lines(str(self.value))): @@ -389,7 +423,7 @@ def activate(self): if hasattr(self.color, 'reset_animation'): self.color.reset_animation() - def deactivate(self): + def deactivate(self, save: bool = True): """Deactivate this module, making it no longer active.""" self.active = False if hasattr(self, '_original_color'): @@ -444,8 +478,12 @@ def append(self, module: Union['Contains', Module]): module.parent = self self.modules.append(module) + def get_modules(self): + """Override this to filter displayed modules""" + return self.modules + def render(self): - for module in self.modules: + for module in self.get_modules(): module.render() module.last_render = time.time() module.grid.noutrefresh() @@ -485,7 +523,7 @@ def render(self) -> None: self.grid.addch(0, w, self.TR, self.color.to_curses()) self.grid.addch(h, w, self.BR, self.color.to_curses()) - for module in self.modules: + for module in self.get_modules(): module.render() module.last_render = time.time() module.grid.noutrefresh() @@ -512,20 +550,37 @@ class Select(Box): """ Build a select menu out of buttons. """ + UP, DOWN = "▲", "▼" on_change: Optional[callable] = None button_cls: type[Button] = Button + button_height: int = 3 + show_up: bool = False + show_down: bool = False - def __init__(self, coords: Tuple[int, int], dims: Tuple[int, int], options: List[str], color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_change: Optional[callable] = None) -> None: + def __init__(self, coords: Tuple[int, int], dims: Tuple[int, int], options: List[str], color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_change: Optional[callable] = None, on_select: Optional[callable] = None) -> None: super().__init__(coords, dims, [], color=color) from agentstack.tui.color import Color self.highlight = highlight or Color(0, 100, 100) self.options = options self.on_change = on_change + self.on_select = on_select + for i, option in enumerate(self.options): - self.append(self.button_cls(((i*3)+1, 1), (3, self.width-2), value=option, color=color, highlight=self.highlight)) + self.append(self._get_button(i, option)) self._mark_active(0) + def _get_button(self, index: int, option: str) -> Button: + """Helper to create a button for an option""" + return self.button_cls( + ((index * self.button_height) + 1, 1), + (self.button_height, self.width - 2), + value=option, + color=self.color, + highlight=self.highlight, + ) + def _mark_active(self, index: int): + """Mark a submodule as active.""" for module in self.modules: module.deactivate() self.modules[index].activate() @@ -534,22 +589,73 @@ def _mark_active(self, index: int): if self.on_change: self.on_change(index, self.options[index]) - def select(self, module: Module): - module.selected = not module.selected - self._mark_active(self.modules.index(module)) - - def input(self, key: Key): - index = None + def _get_active_index(self): + """Get the index of the active option.""" for module in self.modules: if module.active: - index = self.modules.index(module) + return self.modules.index(module) + return 0 + + def get_modules(self): + """Return a subset of modules to be rendered""" + # since we can't always render all of the buttons, return a subset + # that can be displayed in the available height. + num_displayed = (self.height - 4) // self.button_height + index = self._get_active_index() + count = len(self.modules) + + if count <= num_displayed: + start = 0 + self.show_up = False + else: + ideal_start = index - (num_displayed // 2) + start = min(ideal_start, count - num_displayed) + start = max(0, start) + self.show_up = bool(start > 0) + + end = min(start + num_displayed, count) + self.show_down = bool(end < count) + visible = self.modules[start:end] + + for i, module in enumerate(visible): + pad = 2 if self.show_up else 1 + module.move((i * self.button_height) + pad, module.x) + return visible + + def render(self): + """Render all options and conditionally show up/down arrows.""" + for module in self.modules: + if module.last_render: + module.grid.erase() + + self.grid.erase() + if self.show_up: + self.grid.addstr(1, 1, self.UP.center(self.width-2), self.color.to_curses()) + if self.show_down: + self.grid.addstr(self.height - 2, 1, self.DOWN.center(self.width-2), self.color.to_curses()) + + super().render() + + def select(self, option: Module): + """Select an option; ie. mark it as the value of this element.""" + index = self.modules.index(option) + option.selected = not option.selected + self._mark_active(index) + if self.on_select: + self.on_select(index, self.options[index]) + + def input(self, key: Key): + """Handle key input event.""" + index = self._get_active_index() if index is None: return if key.UP or key.DOWN: direction = -1 if key.UP else 1 - index = (direction + index) % len(self.modules) + index = direction + index + if index < 0 or index >= len(self.modules): + return # don't loop self._mark_active(index) elif key.SPACE or key.ENTER: self.select(self.modules[index]) @@ -557,10 +663,12 @@ def input(self, key: Key): super().input(key) def click(self, y, x): + # TODO there is a bug when you click on the last element in a scrollable list for module in self.modules: + if not module in self.get_modules(): + continue # module is not visible if not module.hit(y, x): continue - self._mark_active(self.modules.index(module)) self.select(module) @@ -568,11 +676,12 @@ class RadioSelect(Select): """Allow one button to be `selected` at a time""" button_cls = RadioButton - def __init__(self, coords: Tuple[int, int], dims: Tuple[int, int], options: List[str], color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_change: Optional[callable] = None) -> None: - super().__init__(coords, dims, options, color=color, highlight=highlight, on_change=on_change) + def __init__(self, coords: Tuple[int, int], dims: Tuple[int, int], options: List[str], color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_change: Optional[callable] = None, on_select: Optional[callable] = None) -> None: + super().__init__(coords, dims, options, color=color, highlight=highlight, on_change=on_change, on_select=on_select) self.select(self.modules[0]) def select(self, module: Module): + """Radio buttons only allow a single selection. """ for _module in self.modules: _module.selected = False super().select(module) @@ -595,12 +704,14 @@ def render(self) -> None: class View(Contains): + app: 'App' positioning: str = POS_ABSOLUTE padding: tuple[int, int] = (0, 0) y: int = 0 x: int = 0 - def __init__(self): + def __init__(self, app: 'App'): + self.app = app self.modules = [] def init(self, dims: Tuple[int, int]) -> None: @@ -617,15 +728,19 @@ def grid(self): return self._grid def layout(self) -> list[Module]: - log.warn(f"`layout` not implemented in View: {self.__class__}.") + """Override this in subclasses to define the layout of the view.""" + log.warning(f"`layout` not implemented in View: {self.__class__}.") return [] class App: stdscr: curses.window + height: int + width: int + min_height: int = 30 + min_width: int = 80 frame_time: float = 1.0 / 60 # 30 FPS editing = False - dims = property(lambda self: self.stdscr.getmaxyx()) # TODO remove this view: Optional[View] = None # the active view views: dict[str, type[View]] = {} shortcuts: dict[str, str] = {} @@ -634,10 +749,13 @@ def __init__(self, stdscr: curses.window) -> None: self.stdscr = stdscr self.height, self.width = self.stdscr.getmaxyx() # TODO dynamic resizing + if not self.width >= self.min_width or not self.height >= self.min_height: + raise RenderException(f"Terminal window is too small. Resize to at least {self.min_width}x{self.min_height}.") + curses.curs_set(0) stdscr.nodelay(True) stdscr.timeout(10) # balance framerate with cpu usage - curses.mousemask(curses.ALL_MOUSE_EVENTS | curses.REPORT_MOUSE_POSITION) + curses.mousemask(curses.BUTTON1_CLICKED | curses.REPORT_MOUSE_POSITION) from agentstack.tui.color import Color Color.initialize() @@ -647,26 +765,32 @@ def add_view(self, name: str, view_cls: type[View], shortcut: Optional[str] = No if shortcut: self.shortcuts[shortcut] = name - def load(self, name: str): + def load(self, view_name: str): if self.view: self.view.destroy() self.view = None - view_cls = self.views[name] - self.view = view_cls() - self.view.init(self.dims) + view_cls = self.views[view_name] + self.view = view_cls(self) + self.view.init((self.height, self.width)) def run(self): frame_time = 1.0 / 60 # 30 FPS last_frame = time.time() - while True: + self._running = True + while self._running: current_time = time.time() delta = current_time - last_frame ch = self.stdscr.getch() if ch == curses.KEY_MOUSE: - _, x, y, _, _ = curses.getmouse() - self.click(y, x) + try: + _, x, y, _, bstate = curses.getmouse() + if not bstate & curses.BUTTON1_CLICKED: + continue # only allow left click + self.click(y, x) + except curses.error: + pass elif ch != -1: self.input(ch) @@ -682,14 +806,29 @@ def run(self): if delta < self.frame_time: time.sleep(frame_time - delta) - def render(self): + def stop(self): if self.view: + self.view.destroy() + self._running = False + curses.endwin() + + def render(self): + if not self.view: + return + + try: self.view.render() self.view.last_render = time.time() self.view.grid.noutrefresh() - - curses.doupdate() - + curses.doupdate() + except curses.error as e: + log.debug(f"Error rendering view: {e}") + if "add_wch() returned ERR" in str(e): + raise RenderException("Grid not large enough to render all modules.") + if "curses function returned NULL" in str(e): + raise RenderException("Window not large enough to render.") + raise e + def click(self, y, x): """Handle mouse click event.""" if self.view: diff --git a/pyproject.toml b/pyproject.toml index 8734a0f3..af789de1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,8 @@ dependencies = [ "agentops>=0.3.18", "typer>=0.12.5", "inquirer>=3.4.0", - "art>=6.3", + #"art>=6.3", + "pyfiglet==1.0.2", "toml>=0.10.2", "ruamel.yaml.base>=0.3.2", "cookiecutter==2.6.0", From dd8ebd50cd78bce193a9e70e5fe92464041a111b Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 28 Jan 2025 09:18:56 -0800 Subject: [PATCH 05/34] Cleanup directory structure. Typing fixes. --- agentstack/cli/__init__.py | 1 - agentstack/cli/init.py | 6 +- agentstack/cli/wizard.py | 1023 +++++++++++++++++++++----- agentstack/main.py | 4 +- agentstack/{tui/module.py => tui.py} | 350 +++++++-- agentstack/tui/__init__.py | 866 ---------------------- agentstack/tui/color.py | 258 ------- 7 files changed, 1152 insertions(+), 1356 deletions(-) rename agentstack/{tui/module.py => tui.py} (69%) delete mode 100644 agentstack/tui/__init__.py delete mode 100644 agentstack/tui/color.py diff --git a/agentstack/cli/__init__.py b/agentstack/cli/__init__.py index 7887aa74..f7313bbb 100644 --- a/agentstack/cli/__init__.py +++ b/agentstack/cli/__init__.py @@ -1,6 +1,5 @@ from .cli import LOGO, configure_default_model, welcome_message, get_validated_input from .init import init_project -from .wizard import run_wizard from .run import run_project from .tools import list_tools, add_tool from .templates import insert_template, export_template diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index 21e20909..dea7e755 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -11,7 +11,6 @@ from agentstack.proj_templates import TemplateConfig from agentstack.cli import welcome_message -from agentstack.cli.wizard import run_wizard from agentstack.cli.templates import insert_template DEFAULT_TEMPLATE_NAME: str = "hello_alex" @@ -65,8 +64,7 @@ def init_project( raise Exception("Template and wizard flags cannot be used together") if use_wizard: - log.debug("Initializing new project with wizard.") - template_data = run_wizard(slug_name) + raise NotImplementedError("Run `agentstack wizard` to use the wizard") elif template: log.debug(f"Initializing new project with template: {template}") template_data = TemplateConfig.from_user_input(template) @@ -74,6 +72,8 @@ def init_project( log.debug(f"Initializing new project with default template: {DEFAULT_TEMPLATE_NAME}") template_data = TemplateConfig.from_template_name(DEFAULT_TEMPLATE_NAME) + assert template_data # appease type checker + welcome_message() log.notify("🦾 Creating a new AgentStack project...") log.info(f"Using project directory: {conf.PATH.absolute()}") diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 0b2b59ac..872730d9 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -1,204 +1,877 @@ -from typing import Optional -import os +import sys +import curses import time -import inquirer -import webbrowser -from art import text2art -from agentstack import log -from agentstack.utils import open_json_file, is_snake_case -from agentstack.cli import welcome_message, get_validated_input +import math +from random import randint +from dataclasses import dataclass +from typing import Optional, Any, Union, TypedDict +from enum import Enum +from pathlib import Path + +from agentstack import conf, log +from agentstack.utils import is_snake_case +from agentstack.tui import * +from agentstack.frameworks import SUPPORTED_FRAMEWORKS, CREWAI, LANGGRAPH +from agentstack._tools import get_all_tools, get_tool from agentstack.proj_templates import TemplateConfig - - -def run_wizard(slug_name: str) -> TemplateConfig: - raise NotImplementedError("TODO wizard functionality needs to be migrated") - - project_details = ask_project_details(slug_name) - welcome_message() - framework = ask_framework() - design = ask_design() - tools = ask_tools() - # TODO return TemplateConfig object - - -def ask_framework() -> str: - framework = "CrewAI" - # framework = inquirer.list_input( - # message="What agent framework do you want to use?", - # choices=["CrewAI", "Autogen", "LiteLLM", "Learn what these are (link)"], - # ) - # - # if framework == "Learn what these are (link)": - # webbrowser.open("https://youtu.be/xvFZjo5PgG0") - # framework = inquirer.list_input( - # message="What agent framework do you want to use?", - # choices=["CrewAI", "Autogen", "LiteLLM"], - # ) - # - # while framework in ['Autogen', 'LiteLLM']: - # print(f"{framework} support coming soon!!") - # framework = inquirer.list_input( - # message="What agent framework do you want to use?", - # choices=["CrewAI", "Autogen", "LiteLLM"], - # ) - - log.success("Congrats! Your project is ready to go! Quickly add features now or skip to do it later.\n\n") - - return framework - - -def ask_agent_details(): - agent = {} - - agent['name'] = get_validated_input( - "What's the name of this agent? (snake_case)", min_length=3, snake_case=True - ) - - agent['role'] = get_validated_input("What role does this agent have?", min_length=3) - - agent['goal'] = get_validated_input("What is the goal of the agent?", min_length=10) - - agent['backstory'] = get_validated_input("Give your agent a backstory", min_length=10) - - agent['model'] = inquirer.list_input( - message="What LLM should this agent use?", choices=PREFERRED_MODELS, default=PREFERRED_MODELS[0] +from agentstack.cli import LOGO, init_project + + +COLOR_BORDER = Color(90) +COLOR_MAIN = Color(220) +COLOR_TITLE = Color(220, 100, 40, reversed=True) +COLOR_ERROR = Color(0) +COLOR_FORM = Color(300) +COLOR_FORM_BORDER = Color(300, 50) +COLOR_BUTTON = Color(300, reversed=True) +COLOR_FIELD_BG = Color(240, 20, 100, reversed=True) +COLOR_FIELD_BORDER = Color(300, 100, 50) +COLOR_FIELD_ACTIVE = Color(300, 80) + +class FieldColors(TypedDict): + color: Color + border: Color + active: Color + +FIELD_COLORS: FieldColors = { + 'color': COLOR_FIELD_BG, + 'border': COLOR_FIELD_BORDER, + 'active': COLOR_FIELD_ACTIVE, +} + +class LogoElement(Text): + h_align = ALIGN_CENTER + + def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): + super().__init__(coords, dims) + self.color = COLOR_MAIN + self.value = LOGO + self.stars = [(3, 1), (25, 5), (34, 1), (52, 2), (79, 3), (97, 1)] + self._star_colors = {} + content_width = len(LOGO.split('\n')[0]) + self.left_offset = round((self.width - content_width) / 2) + + def _get_star_color(self, index: int) -> Color: + if index not in self._star_colors: + self._star_colors[index] = ColorAnimation( + Color(randint(0, 150)), + Color(randint(200, 360)), + duration=2.0, + loop=True, + ) + return self._star_colors[index] + + def render(self) -> None: + super().render() + for i, (x, y) in enumerate(self.stars): + if self.width <= x or self.height <= y: + continue + self.grid.addch(y, self.left_offset + x, '*', self._get_star_color(i).to_curses()) + + +class StarBox(Box): + """Renders random stars that animate down the page in the background of the box.""" + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], **kwargs): + super().__init__(coords, dims, **kwargs) + self.stars = [(randint(0, self.width-1), randint(0, self.height-1)) for _ in range(11)] + self.star_colors = [ColorAnimation( + Color(randint(0, 150)), + Color(randint(200, 360)), + duration=2.0, + loop=False, + ) for _ in range(11)] + self.star_y = [randint(0, self.height-1) for _ in range(11)] + self.star_x = [randint(0, self.width-1) for _ in range(11)] + self.star_speed = 0.001 + self.star_timer = 0.0 + self.star_index = 0 + + def render(self) -> None: + self.grid.clear() + for i in range(len(self.stars)): + if self.star_y[i] < self.height: + self.grid.addch(self.star_y[i], self.star_x[i], '*', self.star_colors[i].to_curses()) + self.star_y[i] += 1 + else: + self.star_y[i] = 0 + self.star_x[i] = randint(0, self.width-1) + super().render() + + +class HelpText(Text): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int]) -> None: + super().__init__(coords, dims) + self.color = Color(0, 0, 50) + self.value = " | ".join([ + "[tab] to select", + "[up / down] to navigate", + "[space / enter] to confirm", + "[q] to quit", + ]) + if conf.DEBUG: + self.value += " | [d]ebug" + + +class WizardView(View): + app: 'WizardApp' + + +class BannerView(WizardView): + name = "banner" + title = "Welcome to AgentStack" + sparkle = "The easiest way to build a robust agent application." + subtitle = "Let's get started!" + color = ColorAnimation( + start=Color(90, 0, 0), # TODO make this darker + end=Color(90), + duration=0.5 ) + + def layout(self) -> list[Renderable]: + buttons = [] + + if not self.app.state.project: + # no project yet, so we need to create one + buttons.append(Button( + # center button full width below the subtitle + (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), + (3, round(self.width/2)), + "Create Project", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('project', workflow='project'), + )) + else: + # project has been created, so we can add agents + buttons.append(Button( + (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), + (3, round(self.width/2)), + "Add Agent", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('agent', workflow='agent'), + )) + + if len(self.app.state.agents): + # we have one or more agents, so we can add tasks + buttons.append(Button( + (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), + (3, round(self.width/2)), + "Add Task", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('task', workflow='task'), + )) + + # # we can also add more tools to existing agents + # buttons.append(Button( + # (self.height-6, self.width-34), + # (3, 15), + # "Add Tool", + # color=COLOR_BUTTON, + # on_confirm=lambda: self.app.load('tool_category', workflow='agent'), + # )) + + if self.app.state.project: + # we can complete the project + buttons.append(Button( + (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), + (3, round(self.width/2)), + "Finish", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.finish(), + )) + + return [ + StarBox((0, 0), (self.height, self.width), color=COLOR_BORDER, modules=[ + LogoElement((1, 1), (7, self.width-2)), + Box( + (round(self.height / 4), round(self.width / 4)), + (9, round(self.width / 2)), + color=COLOR_BORDER, + modules=[ + Title((1, 1), (2, round(self.width / 2)-2), color=self.color, value=self.title), + Title((3, 1), (2, round(self.width / 2)-2), color=self.color, value=self.sparkle), + Title((5, 1), (2, round(self.width / 2)-2), color=self.color, value=self.subtitle), + ]), + *buttons, + ]), + ] - return agent +class FormView(WizardView): + title: str + error_message: Node + + def __init__(self, app: 'App'): + super().__init__(app) + self.error_message = Node() + + def submit(self): + pass + + def error(self, message: str): + self.error_message.value = message + + def form(self) -> list[Renderable]: + return [] + + def layout(self) -> list[Renderable]: + return [ + Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ + LogoElement((1, 1), (7, self.width-2)), + Title((9, 1), (1, self.width-3), color=COLOR_TITLE, value=self.title), + Title((10, 1), (1, self.width-3), color=COLOR_ERROR, value=self.error_message), + *self.form(), + Button((self.height-6, self.width-17), (3, 15), "Next", color=COLOR_BUTTON, on_confirm=self.submit), + ]), + HelpText((self.height-1, 0), (1, self.width)), + ] -def ask_task_details(agents: list[dict]) -> dict: - task = {} - task['name'] = get_validated_input( - "What's the name of this task? (snake_case)", min_length=3, snake_case=True - ) +class ProjectView(FormView): + title = "Define your Project" + + def __init__(self, app: 'App'): + super().__init__(app) + self.project_name = Node() + self.project_description = Node() + + def submit(self): + if not self.project_name.value: + self.error("Name is required.") + return + + if not is_snake_case(self.project_name.value): + self.error("Name must be in snake_case.") + return + + self.app.state.create_project( + name=self.project_name.value, + description=self.project_description.value, + ) + self.app.advance() + + def form(self) -> list[Renderable]: + return [ + Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), + TextInput((12, 13), (2, self.width - 15), self.project_name, **FIELD_COLORS), + + Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), + TextInput((14, 13), (5, self.width - 15), self.project_description, **FIELD_COLORS), + ] - task['description'] = get_validated_input("Describe the task in more detail", min_length=10) - task['expected_output'] = get_validated_input( - "What do you expect the result to look like? (ex: A 5 bullet point summary of the email)", - min_length=10, - ) +class FrameworkView(FormView): + title = "Select a Framework" + + FRAMEWORK_OPTIONS = { + CREWAI: {'name': "CrewAI", 'description': "A simple and easy-to-use framework."}, + LANGGRAPH: {'name': "LangGraph", 'description': "A powerful and flexible framework."}, + } + + def __init__(self, app: 'App'): + super().__init__(app) + self.framework_key = Node() + self.framework_logo = Node() + self.framework_name = Node() + self.framework_description = Node() + + def set_framework_selection(self, index: int, value: str): + """Update the content of the framework info box.""" + key, data = None, None + for _key, _value in self.FRAMEWORK_OPTIONS.items(): + if _value['name'] == value: # search by name + key = _key + data = _value + break + + if not key or not data: + key = value + data = { + 'name': "Unknown", + 'description': "Unknown", + } + + self.framework_logo.value = data['name'] + self.framework_name.value = data['name'] + self.framework_description.value = data['description'] + + def set_framework_choice(self, index: int, value: str): + """Save the selection.""" + key = None + for _key, _value in self.FRAMEWORK_OPTIONS.items(): + if _value['name'] == value: # search by name + key = _key + break + + self.framework_key.value = key + + def get_framework_options(self) -> list[str]: + return [self.FRAMEWORK_OPTIONS[key]['name'] for key in SUPPORTED_FRAMEWORKS] + + def submit(self): + if not self.framework_key.value: + self.error("Framework is required.") + return + + self.app.state.update_active_project(framework=self.framework_key.value) + self.app.advance() + + def form(self) -> list[Renderable]: + return [ + RadioSelect( + (12, 1), (self.height-18, round(self.width/2)-3), + options=self.get_framework_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_change=self.set_framework_selection, + on_select=self.set_framework_choice + ), + Box((12, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ + ASCIIText((1, 3), (4, round(self.width/2)-10), color=COLOR_FORM.sat(40), value=self.framework_logo), + BoldText((5, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.framework_name), + WrappedText((7, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.framework_description), + ]), + ] - task['agent'] = inquirer.list_input( - message="Which agent should be assigned this task?", - choices=[a['name'] for a in agents], - ) - return task +class AfterProjectView(BannerView): + title = "We've got a project!" + sparkle = "*゚・:*:・゚’★,。・:*:・゚’☆" + subtitle = "Now, add an Agent to handle your tasks!" + + +class AgentView(FormView): + title = "Define your Agent" + + def __init__(self, app: 'App'): + super().__init__(app) + self.agent_name = Node() + self.agent_role = Node() + self.agent_goal = Node() + self.agent_backstory = Node() + + def submit(self): + if not self.agent_name.value: + self.error("Name is required.") + return + + if not is_snake_case(self.agent_name.value): + self.error("Name must be in snake_case.") + return + + self.app.state.create_agent( + name=self.agent_name.value, + role=self.agent_role.value, + goal=self.agent_goal.value, + backstory=self.agent_backstory.value, + ) + self.app.advance() + + def form(self) -> list[Renderable]: + return [ + Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), + TextInput((12, 13), (2, self.width - 15), self.agent_name, **FIELD_COLORS), + + Text((14, 2), (1, 11), color=COLOR_FORM, value="Role"), + TextInput((14, 13), (5, self.width - 15), self.agent_role, **FIELD_COLORS), + + Text((19, 2), (1, 11), color=COLOR_FORM, value="Goal"), + TextInput((19, 13), (5, self.width - 15), self.agent_goal, **FIELD_COLORS), + + Text((24, 2), (1, 11), color=COLOR_FORM, value="Backstory"), + TextInput((24, 13), (5, self.width - 15), self.agent_backstory, **FIELD_COLORS), + ] -def ask_design() -> dict: - use_wizard = inquirer.confirm( - message="Would you like to use the CLI wizard to set up agents and tasks?", - ) +class ModelView(FormView): + title = "Select a Model" + + MODEL_OPTIONS = [ + {'value': "anthropic/claude-3.5-sonnet", 'name': "Claude 3.5 Sonnet", 'provider': "Anthropic", 'description': "A fast and cost-effective model."}, + {'value': "gpt-3.5-turbo", 'name': "GPT-3.5 Turbo", 'provider': "OpenAI", 'description': "A fast and cost-effective model."}, + {'value': "gpt-4", 'name': "GPT-4", 'provider': "OpenAI", 'description': "A more advanced model with better understanding."}, + {'value': "gpt-4o", 'name': "GPT-4o", 'provider': "OpenAI", 'description': "The latest and most powerful model."}, + {'value': "gpt-4o-mini", 'name': "GPT-4o Mini", 'provider': "OpenAI", 'description': "A smaller, faster version of GPT-4o."}, + ] + + + def __init__(self, app: 'App'): + super().__init__(app) + self.model_choice = Node() + self.model_logo = Node() + self.model_name = Node() + self.model_description = Node() + + def set_model_selection(self, index: int, value: str): + """Update the content of the model info box.""" + model = self.MODEL_OPTIONS[index] + self.model_logo.value = model['provider'] + self.model_name.value = model['name'] + self.model_description.value = model['description'] + + def set_model_choice(self, index: int, value: str): + """Save the selection.""" + # list in UI shows the actual key + self.model_choice.value = value + + def get_model_options(self): + return [model['value'] for model in self.MODEL_OPTIONS] + + def submit(self): + if not self.model_choice.value: + self.error("Model is required.") + return + + self.app.state.update_active_agent(llm=self.model_choice.value) + self.app.advance() + + def form(self) -> list[Renderable]: + return [ + RadioSelect( + (11, 1), (self.height-18, round(self.width/2)-3), + options=self.get_model_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_change=self.set_model_selection, + on_select=self.set_model_choice + ), + Box((11, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ + ASCIIText((1, 3), (4, round(self.width/2)-10), color=COLOR_FORM.sat(40), value=self.model_logo), + BoldText((5, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.model_name), + WrappedText((7, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.model_description), + ]), + ] - if not use_wizard: - return {'agents': [], 'tasks': []} - - os.system("cls" if os.name == "nt" else "clear") - - title = text2art("AgentWizard", font="shimrod") - - print(title) - - print(""" -🪄 welcome to the agent builder wizard!! 🪄 - -First we need to create the agents that will work together to accomplish tasks: - """) - make_agent = True - agents = [] - while make_agent: - print('---') - print(f"Agent #{len(agents)+1}") - agent = None - agent = ask_agent_details() - agents.append(agent) - make_agent = inquirer.confirm(message="Create another agent?") - - print('') - for x in range(3): - time.sleep(0.3) - print('.') - print('Boom! We made some agents (ノ>ω<)ノ :。・:*:・゚’★,。・:*:・゚’☆') - time.sleep(0.5) - print('') - print('Now lets make some tasks for the agents to accomplish!') - print('') - - make_task = True - tasks = [] - while make_task: - print('---') - print(f"Task #{len(tasks) + 1}") - task = ask_task_details(agents) - tasks.append(task) - make_task = inquirer.confirm(message="Create another task?") - - print('') - for x in range(3): - time.sleep(0.3) - print('.') - print('Let there be tasks (ノ ˘_˘)ノ ζ|||ζ ζ|||ζ ζ|||ζ') - - return {'tasks': tasks, 'agents': agents} - - -def ask_tools() -> list: - use_tools = inquirer.confirm( - message="Do you want to add agent tools now? (you can do this later with `agentstack tools add `)", - ) - if not use_tools: - return [] +class ToolCategoryView(FormView): + title = "Select a Tool Category" + + # TODO category descriptions for all valid categories + TOOL_CATEGORY_OPTIONS = { + "web": {'name': "Web Tools", 'description': "Tools that interact with the web."}, + "file": {'name': "File Tools", 'description': "Tools that interact with the file system."}, + "code": {'name': "Code Tools", 'description': "Tools that interact with code."}, + } + + def __init__(self, app: 'App'): + super().__init__(app) + self.tool_category_key = Node() + self.tool_category_name = Node() + self.tool_category_description = Node() + + def set_tool_category_selection(self, index: int, value: str): + key, data = None, None + for _key, _value in self.TOOL_CATEGORY_OPTIONS.items(): + if _value['name'] == value: # search by name + key = _key + data = _value + break + + if not key or not data: + key = value + data = { + 'name': "Unknown", + 'description': "Unknown", + } + + self.tool_category_name.value = data['name'] + self.tool_category_description.value = data['description'] + + def set_tool_category_choice(self, index: int, value: str): + self.tool_category_key.value = value + + def get_tool_category_options(self) -> list[str]: + return sorted(list({tool.category for tool in get_all_tools()})) + + def submit(self): + if not self.tool_category_key.value: + self.error("Tool category is required.") + return + + self.app.state.tool_category = self.tool_category_key.value + self.app.advance() + + def form(self) -> list[Renderable]: + return [ + RadioSelect( + (11, 1), (self.height-18, round(self.width/2)-3), + options=self.get_tool_category_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_change=self.set_tool_category_selection, + on_select=self.set_tool_category_choice + ), + Box((11, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ + BoldText((1, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.tool_category_name), + WrappedText((2, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.tool_category_description), + ]), + ] - tools_to_add = [] - adding_tools = True - script_dir = os.path.dirname(os.path.abspath(__file__)) - tools_json_path = os.path.join(script_dir, '..', 'tools', 'tools.json') +class ToolView(FormView): + title = "Select a Tool" + + def __init__(self, app: 'App'): + super().__init__(app) + self.tool_key = Node() + self.tool_name = Node() + self.tool_description = Node() + + @property + def category(self) -> str: + return self.app.state.tool_category + + def set_tool_selection(self, index: int, value: str): + tool_config = get_tool(value) + self.tool_name.value = tool_config.name + self.tool_description.value = tool_config.cta + + def set_tool_choice(self, index: int, value: str): + self.tool_key.value = value + + def get_tool_options(self) -> list[str]: + return sorted([tool.name for tool in get_all_tools() if tool.category == self.category]) + + def submit(self): + if not self.tool_key.value: + self.error("Tool is required.") + return + + self.app.state.update_active_agent_tools(self.tool_key.value) + self.app.advance() + + def back(self): + self.app.back() + + def form(self) -> list[Renderable]: + return [ + RadioSelect( + (12, 1), (self.height-18, round(self.width/2)-3), + options=self.get_tool_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_change=self.set_tool_selection, + on_select=self.set_tool_choice + ), + Box((12, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ + BoldText((1, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.tool_name), + WrappedText((2, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.tool_description), + ]), + Button((self.height-6, self.width-17), (3, 15), "Back", color=COLOR_BUTTON, on_confirm=self.back), + ] - # Load the JSON data - tools_data = open_json_file(tools_json_path) - while adding_tools: - tool_type = inquirer.list_input( - message="What category tool do you want to add?", - choices=list(tools_data.keys()) + ["~~ Stop adding tools ~~"], +class AfterAgentView(BannerView): + title = "Boom! We made some agents." + sparkle = "(ノ>ω<)ノ :。・:*:・゚’★,。・:*:・゚’☆" + subtitle = "Now lets make some tasks for the agents to accomplish!" + + +class TaskView(FormView): + title = "Define your Task" + + def __init__(self, app: 'App'): + super().__init__(app) + self.task_name = Node() + self.task_description = Node() + self.expected_output = Node() + + def submit(self): + if not self.task_name.value: + self.error("Task name is required.") + return + + if not is_snake_case(self.task_name.value): + self.error("Task name must be in snake_case.") + return + + self.app.state.create_task( + name=self.task_name.value, + description=self.task_description.value, + expected_output=self.expected_output.value, ) + self.app.advance() + + def form(self) -> list[Renderable]: + return [ + Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), + TextInput((12, 13), (2, self.width - 15), self.task_name, **FIELD_COLORS), + + Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), + TextInput((14, 13), (5, self.width - 15), self.task_description, **FIELD_COLORS), + + Text((19, 2), (1, 11), color=COLOR_FORM, value="Expected Output"), + TextInput((19, 13), (5, self.width - 15), self.expected_output, **FIELD_COLORS), + ] - tools_in_cat = [f"{t['name']} - {t['url']}" for t in tools_data[tool_type] if t not in tools_to_add] - tool_selection = inquirer.list_input(message="Select your tool", choices=tools_in_cat) - - tools_to_add.append(tool_selection.split(' - ')[0]) - - log.info("Adding tools:") - for t in tools_to_add: - log.info(f' - {t}') - log.info('') - adding_tools = inquirer.confirm("Add another tool?") - return tools_to_add +class AgentSelectionView(FormView): + title = "Select an Agent for your Task" + + def __init__(self, app: 'App'): + super().__init__(app) + self.agent_name = Node() + + def set_agent_choice(self, index: int, value: str): + self.agent_name.value = value + + def get_agent_options(self) -> list[str]: + return list(self.app.state.agents.keys()) + + def submit(self): + if not self.agent_name.value: + self.error("Agent is required.") + return + + self.app.state.update_active_task(agent=self.agent_name.value) + self.app.advance() + + def form(self) -> list[Renderable]: + return [ + RadioSelect( + (12, 1), (self.height-18, round(self.width/2)-3), + options=self.get_agent_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation( + COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 + ), + on_select=self.set_agent_choice + ), + # TODO agent info pane + ] -def ask_project_details(slug_name: Optional[str] = None) -> dict: - name = inquirer.text(message="What's the name of your project (snake_case)", default=slug_name or '') +class AfterTaskView(BannerView): + title = "Let there be tasks!" + sparkle = "(ノ ˘_˘)ノ ζ|||ζ ζ|||ζ ζ|||ζ" + subtitle = "Tasks are the heart of your agent's work. " + + +class DebugView(WizardView): + name = "debug" + def layout(self) -> list[Renderable]: + from agentstack.utils import get_version + + return [ + Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ + ColorWheel((1, 1)), + Title((self.height-6, 3), (1, self.width-5), color=COLOR_MAIN, + value=f"AgentStack version {get_version()}"), + Title((self.height-4, 3), (1, self.width-5), color=COLOR_MAIN, + value=f"Window size: {self.width}x{self.height}"), + ]), + HelpText((self.height-1, 0), (1, self.width)), + ] - if not is_snake_case(name): - log.error("Project name must be snake case") - return ask_project_details(slug_name) - questions = inquirer.prompt( - [ - inquirer.Text("version", message="What's the initial version", default="0.1.0"), - inquirer.Text("description", message="Enter a description for your project"), - inquirer.Text("author", message="Who's the author (your name)?"), - ] - ) +class State: + project: dict[str, Any] + # `active_agent` is the agent we are currently working on + active_agent: str + # `active_task` is the task we are currently working on + active_task: str + # `tool_category` is a temporary value while an agent is being created + tool_category: str + # `agents` is a dictionary of agents we have created + agents: dict[str, dict] + # `tasks` is a dictionary of tasks we have created + tasks: dict[str, dict] + + def __init__(self): + self.project = {} + self.agents = {} + self.tasks = {} + + def __repr__(self): + return f"State(project={self.project}, agents={self.agents}, tasks={self.tasks})" + + def create_project(self, name: str, description: str): + self.project = { + 'name': name, + 'description': description, + } + self.active_project = name + + def update_active_project(self, **kwargs): + for key, value in kwargs.items(): + self.project[key] = value + + def create_agent(self, name: str, role: str, goal: str, backstory: str): + self.agents[name] = { + 'role': role, + 'goal': goal, + 'backstory': backstory, + 'llm': None, + 'tools': [], + } + self.active_agent = name + + def update_active_agent(self, **kwargs): + agent = self.agents[self.active_agent] + for key, value in kwargs.items(): + agent[key] = value + + def update_active_agent_tools(self, tool_name: str): + self.agents[self.active_agent]['tools'].append(tool_name) + + def create_task(self, name: str, description: str, expected_output: str): + self.tasks[name] = { + 'description': description, + 'expected_output': expected_output, + } + self.active_task = name + + def update_active_task(self, **kwargs): + task = self.tasks[self.active_task] + for key, value in kwargs.items(): + task[key] = value + + def to_template_config(self) -> TemplateConfig: + tools = [] + for agent_name, agent_data in self.agents.items(): + for tool_name in agent_data['tools']: + tools.append(TemplateConfig.Tool( + name=tool_name, + agents=[agent_name], + )) + + return TemplateConfig( + template_version=4, + name=self.project['name'], + description=self.project['description'], + framework=self.project['framework'], + method="sequential", + agents=[TemplateConfig.Agent( + name=agent_name, + role=agent_data['role'], + goal=agent_data['goal'], + backstory=agent_data['backstory'], + llm=agent_data['llm'], + ) for agent_name, agent_data in self.agents.items()], + tasks=[TemplateConfig.Task( + name=task_name, + description=task_data['description'], + expected_output=task_data['expected_output'], + agent=self.active_agent, + ) for task_name, task_data in self.tasks.items()], + tools=tools, + ) - questions['name'] = name - return questions +class WizardApp(App): + views = { + 'welcome': BannerView, + 'framework': FrameworkView, + 'project': ProjectView, + 'after_project': AfterProjectView, + 'agent': AgentView, + 'model': ModelView, + 'tool_category': ToolCategoryView, + 'tool': ToolView, + 'after_agent': AfterAgentView, + 'task': TaskView, + 'agent_selection': AgentSelectionView, + 'after_task': AfterTaskView, + 'debug': DebugView, + } + shortcuts = { + 'd': 'debug', + } + workflow = { + 'project': [ # initialize a project + 'welcome', + 'project', + 'framework', + 'after_project', + ], + 'agent': [ # add agents + 'agent', + 'model', + 'tool_category', + 'tool', + 'after_agent', + ], + 'task': [ # add tasks + 'task', + 'agent_selection', + 'after_task', + ], + # 'tool': [ # add tools to an agent + # 'agent_select', + # 'tool_category', + # 'tool', + # 'after_agent', + # ] + } + + state: State + active_workflow: Optional[str] + active_view: Optional[str] + + min_width: int = 80 + min_height: int = 30 + + def start(self): + """Load the first view in the default workflow.""" + view = self.workflow['project'][0] + self.load(view, workflow='project') + + def finish(self): + """Create the project, write the config file, and exit.""" + template = self.state.to_template_config() + + self.stop() + # TODO the main loop can still execute once more after this; we need a + # better marker for executing once. + log.set_stdout(sys.stdout) # re-enable on-screen logging + + init_project( + slug_name=template.name, + template_data=template, + ) + + template.write_to_file(conf.PATH / "wizard") + log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") + + def advance(self): + """Load the next view in the active workflow.""" + workflow = self.workflow[self.active_workflow] + current_index = workflow.index(self.active_view) + view = workflow[current_index + 1] + self.load(view, workflow=self.active_workflow) + + def back(self): + """Load the previous view in the active workflow.""" + workflow = self.workflow[self.active_workflow] + current_index = workflow.index(self.active_view) + view = workflow[current_index - 1] + self.load(view, workflow=self.active_workflow) + + def load(self, view: str, workflow: Optional[str] = None): + """Load a view from a workflow.""" + self.active_workflow = workflow + self.active_view = view + super().load(view) + + @classmethod + def wrapper(cls, stdscr): + app = cls(stdscr) + app.state = State() + + app.start() + app.run() + + +def main(): + import io + log.set_stdout(io.StringIO()) # disable on-screen logging + + curses.wrapper(WizardApp.wrapper) diff --git a/agentstack/main.py b/agentstack/main.py index 1da3a6f5..9dcbe692 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -12,6 +12,7 @@ run_project, export_template, ) +from agentstack.cli import wizard from agentstack.telemetry import track_cli_command, update_telemetry from agentstack.utils import get_version, term_color from agentstack import generation @@ -179,8 +180,7 @@ def _main(): elif args.command in ["init", "i"]: init_project(args.slug_name, args.template, args.framework, args.wizard) elif args.command in ["wizard"]: - from agentstack import tui - tui.main() + wizard.main() elif args.command in ["tools", "t"]: if args.tools_command in ["list", "l"]: list_tools() diff --git a/agentstack/tui/module.py b/agentstack/tui.py similarity index 69% rename from agentstack/tui/module.py rename to agentstack/tui.py index 80a72ec9..9bd7b5fd 100644 --- a/agentstack/tui/module.py +++ b/agentstack/tui.py @@ -4,13 +4,11 @@ import math from random import randint from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional, Any, List, Tuple, Union +from typing import TYPE_CHECKING, Optional, Union, Callable, Any from enum import Enum from pyfiglet import Figlet from agentstack import conf, log -if TYPE_CHECKING: - from agentstack.tui.color import Color, AnimatedColor class RenderException(Exception): @@ -38,7 +36,7 @@ class Node: # TODO this needs a better name populate and retrieve data from an input field inside the user interface. """ value: Any - callbacks: list[callable] + callbacks: list[Callable] def __init__(self, value: Any = "") -> None: self.value = value @@ -96,6 +94,205 @@ def is_alpha(self): return (self.ch >= 65 and self.ch <= 122) +class Color: + """ + Color class based on HSV color space, mapping directly to terminal color capabilities. + + Hue: 0-360 degrees, mapped to 6 primary directions (0, 60, 120, 180, 240, 300) + Saturation: 0-100%, mapped to 6 levels (0, 20, 40, 60, 80, 100) + Value: 0-100%, mapped to 6 levels for colors, 24 levels for grayscale + """ + # TODO: fallback for 16 color mode + # TODO: fallback for no color mode + SATURATION_LEVELS = 12 + HUE_SEGMENTS = 6 + VALUE_LEVELS = 6 + GRAYSCALE_LEVELS = 24 + COLOR_CUBE_SIZE = 6 # 6x6x6 color cube + + reversed: bool = False + bold: bool = False + + _color_map = {} # Cache for color mappings + + def __init__(self, h: float, s: float = 100, v: float = 100, reversed: bool = False, bold: bool = False) -> None: + """ + Initialize color with HSV values. + + Args: + h: Hue (0-360 degrees) + s: Saturation (0-100 percent) + v: Value (0-100 percent) + """ + self.h = h % 360 + self.s = max(0, min(100, s)) + self.v = max(0, min(100, v)) + self.reversed = reversed + self.bold = bold + self._pair_number: Optional[int] = None + + def _get_closest_color(self) -> int: + """Map HSV to closest available terminal color number.""" + # Handle grayscale case + if self.s < 10: + gray_val = int(self.v * (self.GRAYSCALE_LEVELS - 1) / 100) + return 232 + gray_val if gray_val < self.GRAYSCALE_LEVELS else 231 + + # Convert HSV to the COLOR_CUBE_SIZE x COLOR_CUBE_SIZE x COLOR_CUBE_SIZE color cube + h = self.h + s = self.s / 100 + v = self.v / 100 + + # Map hue to primary and secondary colors (0 to HUE_SEGMENTS-1) + h = (h + 330) % 360 # -30 degrees = +330 degrees + h_segment = int((h / 60) % self.HUE_SEGMENTS) + h_remainder = (h % 60) / 60 + + # Get RGB values based on hue segment + max_level = self.COLOR_CUBE_SIZE - 1 + if h_segment == 0: # Red to Yellow + r, g, b = max_level, int(max_level * h_remainder), 0 + elif h_segment == 1: # Yellow to Green + r, g, b = int(max_level * (1 - h_remainder)), max_level, 0 + elif h_segment == 2: # Green to Cyan + r, g, b = 0, max_level, int(max_level * h_remainder) + elif h_segment == 3: # Cyan to Blue + r, g, b = 0, int(max_level * (1 - h_remainder)), max_level + elif h_segment == 4: # Blue to Magenta + r, g, b = int(max_level * h_remainder), 0, max_level + else: # Magenta to Red + r, g, b = max_level, 0, int(max_level * (1 - h_remainder)) + + # Apply saturation + max_rgb = max(r, g, b) + if max_rgb > 0: + # Map the saturation to the number of levels + s_level = int(s * (self.SATURATION_LEVELS - 1)) + s_factor = s_level / (self.SATURATION_LEVELS - 1) + + r = int(r + (max_level - r) * (1 - s_factor)) + g = int(g + (max_level - g) * (1 - s_factor)) + b = int(b + (max_level - b) * (1 - s_factor)) + + # Apply value (brightness) + v = max(0, min(max_level, int(v * self.VALUE_LEVELS))) + r = min(max_level, int(r * v / max_level)) + g = min(max_level, int(g * v / max_level)) + b = min(max_level, int(b * v / max_level)) + + # Convert to color cube index (16-231) + return int(16 + (r * self.COLOR_CUBE_SIZE * self.COLOR_CUBE_SIZE) + (g * self.COLOR_CUBE_SIZE) + b) + + def hue(self, h: float) -> 'Color': + """Set the hue of the color.""" + return Color(h, self.s, self.v, self.reversed, self.bold) + + def sat(self, s: float) -> 'Color': + """Set the saturation of the color.""" + return Color(self.h, s, self.v, self.reversed, self.bold) + + def val(self, v: float) -> 'Color': + """Set the value of the color.""" + return Color(self.h, self.s, v, self.reversed, self.bold) + + def reverse(self) -> 'Color': + """Set the reversed attribute of the color.""" + return Color(self.h, self.s, self.v, True, self.bold) + + def _get_color_pair(self, pair_number: int) -> int: + """Apply reversing to the color pair.""" + pair = curses.color_pair(pair_number) + if self.reversed: + pair = pair | curses.A_REVERSE + if self.bold: + pair = pair | curses.A_BOLD + return pair + + def to_curses(self) -> int: + """Get curses color pair for this color.""" + if self._pair_number is not None: + return self._get_color_pair(self._pair_number) + + color_number = self._get_closest_color() + + # Create new pair if needed + if color_number not in self._color_map: + pair_number = len(self._color_map) + 1 + #try: + # TODO make sure we don't overflow the available color pairs + curses.init_pair(pair_number, color_number, -1) + self._color_map[color_number] = pair_number + #except: + # return curses.color_pair(0) + else: + pair_number = self._color_map[color_number] + + self._pair_number = pair_number + return self._get_color_pair(pair_number) + + @classmethod + def initialize(cls) -> None: + """Initialize terminal color support.""" + if not curses.has_colors(): + raise RuntimeError("Terminal does not support colors") + + curses.start_color() + curses.use_default_colors() + + try: + curses.init_pair(1, 1, -1) + except: + raise RuntimeError("Terminal does not support required color features") + + cls._color_map = {} + + +class ColorAnimation(Color): + start: Color + end: Color + duration: float + loop: bool + _start_time: float + + def __init__(self, start: Color, end: Color, duration: float, loop: bool = False): + super().__init__(start.h, start.s, start.v) + self.start = start + self.end = end + self.duration = duration + self.loop = loop + self._start_time = time.time() + + def reset_animation(self): + self._start_time = time.time() + + def to_curses(self) -> int: + elapsed = time.time() - self._start_time + if elapsed > self.duration: + if self.loop: + self.start, self.end = self.end, self.start + self.reset_animation() + return self.start.to_curses() # prevents flickering :shrug: + else: + return self.end.to_curses() + + t = elapsed / self.duration + h1, h2 = self.start.h, self.end.h + # take the shortest path + diff = h2 - h1 + if abs(diff) > 180: + if diff > 0: + h1 += 360 + else: + h2 += 360 + h = (h1 + t * (h2 - h1)) % 360 + + # saturation and value + s = self.start.s + t * (self.end.s - self.start.s) + v = self.start.v + t * (self.end.v - self.start.v) + + return Color(h, s, v, reversed=self.start.reversed).to_curses() + + class Renderable: _grid: Optional[curses.window] = None y: int @@ -105,15 +302,14 @@ class Renderable: parent: Optional['Contains'] = None h_align: str = ALIGN_LEFT v_align: str = ALIGN_TOP - color: 'Color' + color: Color last_render: float = 0 padding: tuple[int, int] = (1, 1) positioning: str = POS_ABSOLUTE - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], color: Optional['Color'] = None): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], color: Optional[Color] = None): self.y, self.x = coords self.height, self.width = dims - from agentstack.tui.color import Color self.color = color or Color(0, 100, 0) def __repr__( self ): @@ -188,18 +384,18 @@ def destroy(self) -> None: self._grid = None -class Module(Renderable): +class Element(Renderable): positioning: str = POS_RELATIVE word_wrap: bool = False - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional[Color] = None): super().__init__(coords, dims, color=color) self.value = value def __repr__( self ): return f"{type(self)} at ({self.y}, {self.x}) with value '{self.value[:20]}'" - def _get_lines(self, value: str) -> List[str]: + def _get_lines(self, value: str) -> list[str]: if self.word_wrap: splits = [''] * self.height words = value.split() @@ -238,10 +434,10 @@ def render(self): self.grid.addstr(i, 0, line, self.color.to_curses()) -class NodeModule(Module): - format: Optional[callable] = None +class NodeElement(Element): + format: Optional[Callable] = None - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional['Color'] = None, format: callable=None): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional[Color] = None, format: Optional[Callable]=None): super().__init__(coords, dims, color=color) self.node = node # TODO can also be str? self.value = str(node) @@ -264,12 +460,12 @@ def destroy(self): super().destroy() -class Editable(NodeModule): - filter: Optional[callable] = None +class Editable(NodeElement): + filter: Optional[Callable] = None active: bool _original_value: Any - def __init__(self, coords, dims, node, color=None, format: callable=None, filter: callable=None): + def __init__(self, coords, dims, node, color=None, format: Optional[Callable]=None, filter: Optional[Callable]=None): super().__init__(coords, dims, node=node, color=color, format=format) self.filter = filter self.active = False @@ -320,7 +516,7 @@ def destroy(self): super().destroy() -class Text(Module): +class Text(Element): pass @@ -330,25 +526,25 @@ class WrappedText(Text): class ASCIIText(Text): default_font: str = "pepper" - formatter: Optional[Figlet] + formatter: Figlet _ascii_render: Optional[str] = None # rendered content _ascii_value: Optional[str] = None # value used to render content - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None, formatter: Optional[Figlet] = None): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional[Color] = None, formatter: Optional[Figlet] = None): super().__init__(coords, dims, value=value, color=color) self.formatter = formatter or Figlet(font=self.default_font) - def _get_lines(self, value: str) -> List[str]: + def _get_lines(self, value: str) -> list[str]: if not self._ascii_render or self._ascii_value != value: # prevent rendering on every frame self._ascii_value = value - self._ascii_render = self.formatter.renderText(value) + self._ascii_render = self.formatter.renderText(value) or "" return super()._get_lines(self._ascii_render) class BoldText(Text): - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional[Color] = None): super().__init__(coords, dims, value=value, color=color) self.color.bold = True @@ -364,11 +560,11 @@ class TextInput(Editable): """ H, V, BR = "━", "┃", "┛" padding: tuple[int, int] = (2, 1) - border_color: 'Color' - active_color: 'Color' + border_color: Color + active_color: Color word_wrap: bool = True - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional['Color'] = None, border: Optional['Color'] = None, active: Optional['Color'] = None, format: callable=None): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional[Color] = None, border: Optional[Color] = None, active: Optional[Color] = None, format: Optional[Callable]=None): super().__init__(coords, dims, node=node, color=color, format=format) self.width, self.height = (dims[1]-1, dims[0]-1) self.border_color = border or self.color @@ -397,15 +593,15 @@ def render(self) -> None: self.grid.addch(self.height, self.width, self.BR, self.border_color.to_curses()) -class Button(Module): +class Button(Element): h_align: str = ALIGN_CENTER v_align: str = ALIGN_MIDDLE active: bool = False selected: bool = False - highlight: Optional['Color'] = None - on_confirm: Optional[callable] = None + highlight: Optional[Color] = None + on_confirm: Optional[Callable] = None - def __init__( self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_confirm: Optional[callable] = None): + def __init__( self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional[Color] = None, highlight: Optional[Color] = None, on_confirm: Optional[Callable] = None): super().__init__(coords, dims, value=value, color=color) self.highlight = highlight or self.color.sat(80) self.on_confirm = on_confirm @@ -463,18 +659,18 @@ class Contains(Renderable): x: int positioning: str = POS_RELATIVE padding: tuple[int, int] = (1, 0) - color: 'Color' + color: Color last_render: float = 0 parent: Optional['Contains'] = None - modules: List[Module] + modules: list[Renderable] - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], modules: list[Module], color: Optional['Color'] = None): + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], modules: list[Renderable], color: Optional[Color] = None): super().__init__(coords, dims, color=color) self.modules = [] for module in modules: self.append(module) - def append(self, module: Union['Contains', Module]): + def append(self, module: Renderable): module.parent = self self.modules.append(module) @@ -551,15 +747,14 @@ class Select(Box): Build a select menu out of buttons. """ UP, DOWN = "▲", "▼" - on_change: Optional[callable] = None + on_change: Optional[Callable] = None button_cls: type[Button] = Button button_height: int = 3 show_up: bool = False show_down: bool = False - def __init__(self, coords: Tuple[int, int], dims: Tuple[int, int], options: List[str], color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_change: Optional[callable] = None, on_select: Optional[callable] = None) -> None: + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], options: list[str], color: Optional[Color] = None, highlight: Optional[Color] = None, on_change: Optional[Callable] = None, on_select: Optional[Callable] = None) -> None: super().__init__(coords, dims, [], color=color) - from agentstack.tui.color import Color self.highlight = highlight or Color(0, 100, 100) self.options = options self.on_change = on_change @@ -582,9 +777,12 @@ def _get_button(self, index: int, option: str) -> Button: def _mark_active(self, index: int): """Mark a submodule as active.""" for module in self.modules: + assert hasattr(module, 'deactivate') module.deactivate() - self.modules[index].activate() - # TODO other modules in the app will not get marked. + + active = self.modules[index] + assert hasattr(active, 'activate') + active.activate() if self.on_change: self.on_change(index, self.options[index]) @@ -636,7 +834,7 @@ def render(self): super().render() - def select(self, option: Module): + def select(self, option: Button): """Select an option; ie. mark it as the value of this element.""" index = self.modules.index(option) option.selected = not option.selected @@ -676,13 +874,14 @@ class RadioSelect(Select): """Allow one button to be `selected` at a time""" button_cls = RadioButton - def __init__(self, coords: Tuple[int, int], dims: Tuple[int, int], options: List[str], color: Optional['Color'] = None, highlight: Optional['Color'] = None, on_change: Optional[callable] = None, on_select: Optional[callable] = None) -> None: + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], options: list[str], color: Optional[Color] = None, highlight: Optional[Color] = None, on_change: Optional[Callable] = None, on_select: Optional[Callable] = None) -> None: super().__init__(coords, dims, options, color=color, highlight=highlight, on_change=on_change, on_select=on_select) - self.select(self.modules[0]) + self.select(self.modules[0]) # type: ignore[arg-type] - def select(self, module: Module): + def select(self, module: Button): """Radio buttons only allow a single selection. """ for _module in self.modules: + assert hasattr(_module, 'selected') _module.selected = False super().select(module) @@ -692,13 +891,63 @@ class MultiSelect(Select): button_cls = CheckButton -class DebugModule(Module): +class ColorWheel(Element): + """ + A module used for testing color display. + """ + width: int = 80 + height: int = 24 + + def __init__(self, coords: tuple[int, int], duration: float = 10.0): + super().__init__(coords, (self.height, self.width)) + self.duration = duration + self.start_time = time.time() + + def render(self) -> None: + self.grid.erase() + center_y, center_x = 12, 22 + radius = 10 + elapsed = time.time() - self.start_time + hue_offset = (elapsed / self.duration) * 360 # animate + + for y in range(center_y - radius, center_y + radius + 1): + for x in range(center_x - radius * 2, center_x + radius * 2 + 1): + # Convert position to polar coordinates + dx = (x - center_x) / 2 # Compensate for terminal character aspect ratio + dy = y - center_y + distance = math.sqrt(dx*dx + dy*dy) + + if distance <= radius: + # Convert to HSV + angle = math.degrees(math.atan2(dy, dx)) + #h = (angle + 360) % 360 + h = (angle + hue_offset) % 360 + s = (distance / radius) * 100 + v = 100 # (distance / radius) * 100 + + color = Color(h, s, v) + self.grid.addstr(y, x, "█", color.to_curses()) + + x = 50 + y = 4 + for i in range(0, curses.COLORS): + self.grid.addstr(y, x, f"███", curses.color_pair(i + 1)) + y += 1 + if y >= self.height - 4: + y = 4 + x += 3 + if x >= self.width - 3: + break + + self.grid.refresh() + + +class DebugElement(Element): """Show fps and color usage.""" - def __init__(self, coords: Tuple[int, int]): + def __init__(self, coords: tuple[int, int]): super().__init__(coords, (1, 24)) def render(self) -> None: - from agentstack.tui.color import Color self.grid.addstr(0, 1, f"FPS: {1 / (time.time() - self.last_render):.0f}") self.grid.addstr(0, 10, f"Colors: {len(Color._color_map)}/{curses.COLORS}") @@ -714,12 +963,12 @@ def __init__(self, app: 'App'): self.app = app self.modules = [] - def init(self, dims: Tuple[int, int]) -> None: + def init(self, dims: tuple[int, int]) -> None: self.height, self.width = dims self.modules = self.layout() if conf.DEBUG: - self.append(DebugModule((1, 1))) + self.append(DebugElement((1, 1))) @property def grid(self): @@ -727,7 +976,7 @@ def grid(self): self._grid = curses.newwin(self.height, self.width, self.y, self.x) return self._grid - def layout(self) -> list[Module]: + def layout(self) -> list[Renderable]: """Override this in subclasses to define the layout of the view.""" log.warning(f"`layout` not implemented in View: {self.__class__}.") return [] @@ -757,7 +1006,6 @@ def __init__(self, stdscr: curses.window) -> None: stdscr.timeout(10) # balance framerate with cpu usage curses.mousemask(curses.BUTTON1_CLICKED | curses.REPORT_MOUSE_POSITION) - from agentstack.tui.color import Color Color.initialize() def add_view(self, name: str, view_cls: type[View], shortcut: Optional[str] = None) -> None: @@ -848,7 +1096,7 @@ def _get_tabbable_modules(self): """ Search through the tree of modules to find selectable elements. """ - def _get_activateable(module: Module): + def _get_activateable(module: Element): """Find modules with an `activate` method""" if hasattr(module, 'activate'): yield module @@ -860,7 +1108,7 @@ def _select_next_tabbable(self): """ Activate the next tabbable module in the list. """ - def _get_active_module(module: Module): + def _get_active_module(module: Element): if hasattr(module, 'active') and module.active: return module for submodule in getattr(module, 'modules', []): diff --git a/agentstack/tui/__init__.py b/agentstack/tui/__init__.py deleted file mode 100644 index d0277515..00000000 --- a/agentstack/tui/__init__.py +++ /dev/null @@ -1,866 +0,0 @@ -import sys -import curses -import time -import math -from random import randint -from dataclasses import dataclass -from typing import Optional, Any, List, Tuple, Union -from enum import Enum -from pathlib import Path - -from agentstack import conf, log -from agentstack.cli import LOGO -from agentstack.cli.init import init_project -from agentstack.tui.module import * -from agentstack.tui.color import Color, ColorAnimation, ColorWheel - -from agentstack.utils import is_snake_case -from agentstack.frameworks import SUPPORTED_FRAMEWORKS, CREWAI, LANGGRAPH -from agentstack._tools import get_all_tools, get_tool -from agentstack.proj_templates import TemplateConfig - - -COLOR_BORDER = Color(90) -COLOR_MAIN = Color(220) -COLOR_TITLE = Color(220, 100, 40, reversed=True) -COLOR_ERROR = Color(0) -COLOR_FORM = Color(300) -COLOR_FORM_BORDER = Color(300, 50) -COLOR_BUTTON = Color(300, reversed=True) -COLOR_FIELD_BG = Color(240, 20, 100, reversed=True) -COLOR_FIELD_BORDER = Color(300, 100, 50) -COLOR_FIELD_ACTIVE = Color(300, 80) -FIELD_COLORS = { - 'color': COLOR_FIELD_BG, - 'border': COLOR_FIELD_BORDER, - 'active': COLOR_FIELD_ACTIVE, -} - -class LogoModule(Text): - h_align = ALIGN_CENTER - - def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): - super().__init__(coords, dims) - self.color = COLOR_MAIN - self.value = LOGO - self.stars = [(3, 1), (25, 5), (34, 1), (52, 2), (79, 3), (97, 1)] - self._star_colors = {} - content_width = len(LOGO.split('\n')[0]) - self.left_offset = round((self.width - content_width) / 2) - - def _get_star_color(self, index: int) -> Color: - if index not in self._star_colors: - self._star_colors[index] = ColorAnimation( - Color(randint(0, 150)), - Color(randint(200, 360)), - duration=2.0, - loop=True, - ) - return self._star_colors[index] - - def render(self) -> None: - super().render() - for i, (x, y) in enumerate(self.stars): - if self.width <= x or self.height <= y: - continue - self.grid.addch(y, self.left_offset + x, '*', self._get_star_color(i).to_curses()) - - -class StarBox(Box): - """Renders random stars that animate down the page in the background of the box.""" - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], **kwargs): - super().__init__(coords, dims, **kwargs) - self.stars = [(randint(0, self.width-1), randint(0, self.height-1)) for _ in range(11)] - self.star_colors = [ColorAnimation( - Color(randint(0, 150)), - Color(randint(200, 360)), - duration=2.0, - loop=False, - ) for _ in range(11)] - self.star_y = [randint(0, self.height-1) for _ in range(11)] - self.star_x = [randint(0, self.width-1) for _ in range(11)] - self.star_speed = 0.001 - self.star_timer = 0.0 - self.star_index = 0 - - def render(self) -> None: - self.grid.clear() - for i in range(len(self.stars)): - if self.star_y[i] < self.height: - self.grid.addch(self.star_y[i], self.star_x[i], '*', self.star_colors[i].to_curses()) - self.star_y[i] += 1 - else: - self.star_y[i] = 0 - self.star_x[i] = randint(0, self.width-1) - super().render() - - -class HelpText(Text): - def __init__(self, coords: tuple[int, int], dims: tuple[int, int]) -> None: - super().__init__(coords, dims) - self.color = Color(0, 0, 50) - self.value = " | ".join([ - "[tab] to select", - "[up / down] to navigate", - "[space / enter] to confirm", - "[q] to quit", - ]) - if conf.DEBUG: - self.value += " | [d]ebug" - - -class BannerView(View): - name = "banner" - title = "Welcome to AgentStack" - sparkle = "The easiest way to build a robust agent application." - subtitle = "Let's get started!" - color = ColorAnimation( - start=Color(90, 0, 0), # TODO make this darker - end=Color(90), - duration=0.5 - ) - - def layout(self) -> list[Module]: - buttons = [] - - if not self.app.state.project: - # no project yet, so we need to create one - buttons.append(Button( - # center button full width below the subtitle - (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), - (3, round(self.width/2)), - "Create Project", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('project', workflow='project'), - )) - else: - # project has been created, so we can add agents - buttons.append(Button( - (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), - (3, round(self.width/2)), - "Add Agent", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('agent', workflow='agent'), - )) - - if len(self.app.state.agents): - # we have one or more agents, so we can add tasks - buttons.append(Button( - (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), - (3, round(self.width/2)), - "Add Task", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('task', workflow='task'), - )) - - # # we can also add more tools to existing agents - # buttons.append(Button( - # (self.height-6, self.width-34), - # (3, 15), - # "Add Tool", - # color=COLOR_BUTTON, - # on_confirm=lambda: self.app.load('tool_category', workflow='agent'), - # )) - - if self.app.state.project: - # we can complete the project - buttons.append(Button( - (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), - (3, round(self.width/2)), - "Finish", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.finish(), - )) - - return [ - StarBox((0, 0), (self.height, self.width), color=COLOR_BORDER, modules=[ - LogoModule((1, 1), (7, self.width-2)), - # double box half width placed in the center - Box( - (round(self.height / 4), round(self.width / 4)), - (9, round(self.width / 2)), - color=COLOR_BORDER, - modules=[ - Title((1, 1), (2, round(self.width / 2)-2), color=self.color, value=self.title), - Title((3, 1), (2, round(self.width / 2)-2), color=self.color, value=self.sparkle), - Title((5, 1), (2, round(self.width / 2)-2), color=self.color, value=self.subtitle), - ]), - *buttons, - ]), - ] - - -class FormView(View): - def __init__(self, app: 'App'): - super().__init__(app) - self.error_message = Node() - - def submit(self): - pass - - def error(self, message: str): - self.error_message.value = message - - def form(self) -> list[Module]: - return [] - - def layout(self) -> list[Module]: - return [ - Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ - LogoModule((1, 1), (7, self.width-2)), - Title((9, 1), (1, self.width-3), color=COLOR_TITLE, value=self.title), - Title((10, 1), (1, self.width-3), color=COLOR_ERROR, value=self.error_message), - *self.form(), - Button((self.height-6, self.width-17), (3, 15), "Next", color=COLOR_BUTTON, on_confirm=self.submit), - ]), - HelpText((self.height-1, 0), (1, self.width)), - ] - - -class ProjectView(FormView): - title = "Define your Project" - - def __init__(self, app: 'App'): - super().__init__(app) - self.project_name = Node() - self.project_description = Node() - - def submit(self): - if not self.project_name.value: - self.error("Name is required.") - return - - if not is_snake_case(self.project_name.value): - self.error("Name must be in snake_case.") - return - - self.app.state.create_project( - name=self.project_name.value, - description=self.project_description.value, - ) - self.app.advance() - - def form(self) -> list[Module]: - return [ - Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((12, 13), (2, self.width - 15), self.project_name, **FIELD_COLORS), - - Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), - TextInput((14, 13), (5, self.width - 15), self.project_description, **FIELD_COLORS), - ] - - -class FrameworkView(FormView): - title = "Select a Framework" - - FRAMEWORK_OPTIONS = { - CREWAI: {'name': "CrewAI", 'description': "A simple and easy-to-use framework."}, - LANGGRAPH: {'name': "LangGraph", 'description': "A powerful and flexible framework."}, - } - - def __init__(self, app: 'App'): - super().__init__(app) - self.framework_key = Node() - self.framework_logo = Node() - self.framework_name = Node() - self.framework_description = Node() - - def set_framework_selection(self, index: int, value: str): - """Update the content of the framework info box.""" - key, data = None, None - for _key, _value in self.FRAMEWORK_OPTIONS.items(): - if _value['name'] == value: # search by name - key = _key - data = _value - break - - if not key or not data: - key = value - data = { - 'name': "Unknown", - 'description': "Unknown", - } - - self.framework_logo.value = data['name'] - self.framework_name.value = data['name'] - self.framework_description.value = data['description'] - - def set_framework_choice(self, index: int, value: str): - """Save the selection.""" - key = None - for _key, _value in self.FRAMEWORK_OPTIONS.items(): - if _value['name'] == value: # search by name - key = _key - break - - self.framework_key.value = key - - def get_framework_options(self) -> list[str]: - return [self.FRAMEWORK_OPTIONS[key]['name'] for key in SUPPORTED_FRAMEWORKS] - - def submit(self): - if not self.framework_key.value: - self.error("Framework is required.") - return - - self.app.state.update_active_project(framework=self.framework_key.value) - self.app.advance() - - def form(self) -> list[Module]: - return [ - RadioSelect( - (12, 1), (self.height-18, round(self.width/2)-3), - options=self.get_framework_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), - on_change=self.set_framework_selection, - on_select=self.set_framework_choice - ), - Box((12, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ - ASCIIText((1, 3), (4, round(self.width/2)-10), color=COLOR_FORM.sat(40), value=self.framework_logo), - BoldText((5, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.framework_name), - WrappedText((7, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.framework_description), - ]), - ] - - -class AfterProjectView(BannerView): - title = "We've got a project!" - sparkle = "*゚・:*:・゚’★,。・:*:・゚’☆" - subtitle = "Now, add an Agent to handle your tasks!" - - -class AgentView(FormView): - title = "Define your Agent" - - def __init__(self, app: 'App'): - super().__init__(app) - self.agent_name = Node() - self.agent_role = Node() - self.agent_goal = Node() - self.agent_backstory = Node() - - def submit(self): - if not self.agent_name.value: - self.error("Name is required.") - return - - if not is_snake_case(self.agent_name.value): - self.error("Name must be in snake_case.") - return - - self.app.state.create_agent( - name=self.agent_name.value, - role=self.agent_role.value, - goal=self.agent_goal.value, - backstory=self.agent_backstory.value, - ) - self.app.advance() - - def form(self) -> list[Module]: - return [ - Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((12, 13), (2, self.width - 15), self.agent_name, **FIELD_COLORS), - - Text((14, 2), (1, 11), color=COLOR_FORM, value="Role"), - TextInput((14, 13), (5, self.width - 15), self.agent_role, **FIELD_COLORS), - - Text((19, 2), (1, 11), color=COLOR_FORM, value="Goal"), - TextInput((19, 13), (5, self.width - 15), self.agent_goal, **FIELD_COLORS), - - Text((24, 2), (1, 11), color=COLOR_FORM, value="Backstory"), - TextInput((24, 13), (5, self.width - 15), self.agent_backstory, **FIELD_COLORS), - ] - - -class ModelView(FormView): - title = "Select a Model" - - MODEL_OPTIONS = [ - {'value': "anthropic/claude-3.5-sonnet", 'name': "Claude 3.5 Sonnet", 'provider': "Anthropic", 'description': "A fast and cost-effective model."}, - {'value': "gpt-3.5-turbo", 'name': "GPT-3.5 Turbo", 'provider': "OpenAI", 'description': "A fast and cost-effective model."}, - {'value': "gpt-4", 'name': "GPT-4", 'provider': "OpenAI", 'description': "A more advanced model with better understanding."}, - {'value': "gpt-4o", 'name': "GPT-4o", 'provider': "OpenAI", 'description': "The latest and most powerful model."}, - {'value': "gpt-4o-mini", 'name': "GPT-4o Mini", 'provider': "OpenAI", 'description': "A smaller, faster version of GPT-4o."}, - ] - - - def __init__(self, app: 'App'): - super().__init__(app) - self.model_choice = Node() - self.model_logo = Node() - self.model_name = Node() - self.model_description = Node() - - def set_model_selection(self, index: int, value: str): - """Update the content of the model info box.""" - model = self.MODEL_OPTIONS[index] - self.model_logo.value = model['provider'] - self.model_name.value = model['name'] - self.model_description.value = model['description'] - - def set_model_choice(self, index: int, value: str): - """Save the selection.""" - # list in UI shows the actual key - self.model_choice.value = value - - def get_model_options(self): - return [model['value'] for model in self.MODEL_OPTIONS] - - def submit(self): - if not self.model_choice.value: - self.error("Model is required.") - return - - self.app.state.update_active_agent(llm=self.model_choice.value) - self.app.advance() - - def form(self) -> list[Module]: - return [ - RadioSelect( - (11, 1), (self.height-18, round(self.width/2)-3), - options=self.get_model_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), - on_change=self.set_model_selection, - on_select=self.set_model_choice - ), - Box((11, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ - ASCIIText((1, 3), (4, round(self.width/2)-10), color=COLOR_FORM.sat(40), value=self.model_logo), - BoldText((5, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.model_name), - WrappedText((7, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.model_description), - ]), - ] - - -class ToolCategoryView(FormView): - title = "Select a Tool Category" - - # TODO category descriptions for all valid categories - TOOL_CATEGORY_OPTIONS = { - "web": {'name': "Web Tools", 'description': "Tools that interact with the web."}, - "file": {'name': "File Tools", 'description': "Tools that interact with the file system."}, - "code": {'name': "Code Tools", 'description': "Tools that interact with code."}, - } - - def __init__(self, app: 'App'): - super().__init__(app) - self.tool_category_key = Node() - self.tool_category_name = Node() - self.tool_category_description = Node() - - def set_tool_category_selection(self, index: int, value: str): - key, data = None, None - for _key, _value in self.TOOL_CATEGORY_OPTIONS.items(): - if _value['name'] == value: # search by name - key = _key - data = _value - break - - if not key or not data: - key = value - data = { - 'name': "Unknown", - 'description': "Unknown", - } - - self.tool_category_name.value = data['name'] - self.tool_category_description.value = data['description'] - - def set_tool_category_choice(self, index: int, value: str): - self.tool_category_key.value = value - - def get_tool_category_options(self) -> list[str]: - return sorted(list({tool.category for tool in get_all_tools()})) - - def submit(self): - if not self.tool_category_key.value: - self.error("Tool category is required.") - return - - self.app.state.tool_category = self.tool_category_key.value - self.app.advance() - - def form(self) -> list[Module]: - return [ - RadioSelect( - (11, 1), (self.height-18, round(self.width/2)-3), - options=self.get_tool_category_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), - on_change=self.set_tool_category_selection, - on_select=self.set_tool_category_choice - ), - Box((11, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ - BoldText((1, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.tool_category_name), - WrappedText((2, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.tool_category_description), - ]), - ] - - -class ToolView(FormView): - title = "Select a Tool" - - def __init__(self, app: 'App'): - super().__init__(app) - self.tool_key = Node() - self.tool_name = Node() - self.tool_description = Node() - - @property - def category(self) -> str: - return self.app.state.tool_category - - def set_tool_selection(self, index: int, value: str): - tool_config = get_tool(value) - self.tool_name.value = tool_config.name - self.tool_description.value = tool_config.cta - - def set_tool_choice(self, index: int, value: str): - self.tool_key.value = value - - def get_tool_options(self) -> list[str]: - return sorted([tool.name for tool in get_all_tools() if tool.category == self.category]) - - def submit(self): - if not self.tool_key.value: - self.error("Tool is required.") - return - - self.app.state.update_active_agent_tools(self.tool_key.value) - self.app.advance() - - def back(self): - self.app.back() - - def form(self) -> list[Module]: - return [ - RadioSelect( - (12, 1), (self.height-18, round(self.width/2)-3), - options=self.get_tool_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), - on_change=self.set_tool_selection, - on_select=self.set_tool_choice - ), - Box((12, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ - BoldText((1, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.tool_name), - WrappedText((2, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.tool_description), - ]), - Button((self.height-6, self.width-17), (3, 15), "Back", color=COLOR_BUTTON, on_confirm=self.back), - ] - - -class AfterAgentView(BannerView): - title = "Boom! We made some agents." - sparkle = "(ノ>ω<)ノ :。・:*:・゚’★,。・:*:・゚’☆" - subtitle = "Now lets make some tasks for the agents to accomplish!" - - -class TaskView(FormView): - title = "Define your Task" - - def __init__(self, app: 'App'): - super().__init__(app) - self.task_name = Node() - self.task_description = Node() - self.expected_output = Node() - - def submit(self): - if not self.task_name.value: - self.error("Task name is required.") - return - - if not is_snake_case(self.task_name.value): - self.error("Task name must be in snake_case.") - return - - self.app.state.create_task( - name=self.task_name.value, - description=self.task_description.value, - expected_output=self.expected_output.value, - ) - self.app.advance() - - def form(self) -> list[Module]: - return [ - Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((12, 13), (2, self.width - 15), self.task_name, **FIELD_COLORS), - - Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), - TextInput((14, 13), (5, self.width - 15), self.task_description, **FIELD_COLORS), - - Text((19, 2), (1, 11), color=COLOR_FORM, value="Expected Output"), - TextInput((19, 13), (5, self.width - 15), self.expected_output, **FIELD_COLORS), - ] - - -class AgentSelectionView(FormView): - title = "Select an Agent for your Task" - - def __init__(self, app: 'App'): - super().__init__(app) - self.agent_name = Node() - - def set_agent_choice(self, index: int, value: str): - self.agent_name.value = value - - def get_agent_options(self) -> list[str]: - return list(self.app.state.agents.keys()) - - def submit(self): - if not self.agent_name.value: - self.error("Agent is required.") - return - - self.app.state.update_active_task(agent=self.agent_name.value) - self.app.advance() - - def form(self) -> list[Module]: - return [ - RadioSelect( - (12, 1), (self.height-18, round(self.width/2)-3), - options=self.get_agent_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), - on_select=self.set_agent_choice - ), - # TODO agent info pane - ] - - -class AfterTaskView(BannerView): - title = "Let there be tasks!" - sparkle = "(ノ ˘_˘)ノ ζ|||ζ ζ|||ζ ζ|||ζ" - subtitle = "Tasks are the heart of your agent's work. " - - -class DebugView(View): - name = "debug" - def layout(self) -> list[Module]: - from agentstack.utils import get_version - - return [ - Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ - ColorWheel((1, 1)), - Title((self.height-6, 3), (1, self.width-5), color=COLOR_MAIN, - value=f"AgentStack version {get_version()}"), - Title((self.height-4, 3), (1, self.width-5), color=COLOR_MAIN, - value=f"Window size: {self.width}x{self.height}"), - ]), - HelpText((self.height-1, 0), (1, self.width)), - ] - - -class State: - project: dict[str, Any] - # `active_agent` is the agent we are currently working on - active_agent: str - # `active_task` is the task we are currently working on - active_task: str - # `tool_category` is a temporary value while an agent is being created - tool_category: str - # `agents` is a dictionary of agents we have created - agents: dict[str, dict] - # `tasks` is a dictionary of tasks we have created - tasks: dict[str, dict] - - def __init__(self): - self.project = {} - self.agents = {} - self.tasks = {} - - def __repr__(self): - return f"State(project={self.project}, agents={self.agents}, tasks={self.tasks})" - - def create_project(self, name: str, description: str): - self.project = { - 'name': name, - 'description': description, - } - self.active_project = name - - def update_active_project(self, **kwargs): - for key, value in kwargs.items(): - self.project[key] = value - - def create_agent(self, name: str, role: str, goal: str, backstory: str): - self.agents[name] = { - 'role': role, - 'goal': goal, - 'backstory': backstory, - 'llm': None, - 'tools': [], - } - self.active_agent = name - - def update_active_agent(self, **kwargs): - agent = self.agents[self.active_agent] - for key, value in kwargs.items(): - agent[key] = value - - def update_active_agent_tools(self, tool_name: str): - self.agents[self.active_agent]['tools'].append(tool_name) - - def create_task(self, name: str, description: str, expected_output: str): - self.tasks[name] = { - 'description': description, - 'expected_output': expected_output, - } - self.active_task = name - - def update_active_task(self, **kwargs): - task = self.tasks[self.active_task] - for key, value in kwargs.items(): - task[key] = value - - def to_template_config(self) -> TemplateConfig: - tools = [] - for agent_name, agent_data in self.agents.items(): - for tool_name in agent_data['tools']: - tools.append({ - 'name': tool_name, - 'agents': [agent_name], - }) - - return TemplateConfig( - template_version=4, - name=self.project['name'], - description=self.project['description'], - framework=self.project['framework'], - method="sequential", - agents=[TemplateConfig.Agent( - name=agent_name, - role=agent_data['role'], - goal=agent_data['goal'], - backstory=agent_data['backstory'], - llm=agent_data['llm'], - ) for agent_name, agent_data in self.agents.items()], - tasks=[TemplateConfig.Task( - name=task_name, - description=task_data['description'], - expected_output=task_data['expected_output'], - agent=self.active_agent, - ) for task_name, task_data in self.tasks.items()], - tools=tools, - ) - - -class WizardApp(App): - views = { - 'welcome': BannerView, - 'framework': FrameworkView, - 'project': ProjectView, - 'after_project': AfterProjectView, - 'agent': AgentView, - 'model': ModelView, - 'tool_category': ToolCategoryView, - 'tool': ToolView, - 'after_agent': AfterAgentView, - 'task': TaskView, - 'agent_selection': AgentSelectionView, - 'after_task': AfterTaskView, - 'debug': DebugView, - } - shortcuts = { - 'd': 'debug', - } - workflow = { - 'project': [ # initialize a project - 'welcome', - 'project', - 'framework', - 'after_project', - ], - 'agent': [ # add agents - 'agent', - 'model', - 'tool_category', - 'tool', - 'after_agent', - ], - 'task': [ # add tasks - 'task', - 'agent_selection', - 'after_task', - ], - # 'tool': [ # add tools to an agent - # 'agent_select', - # 'tool_category', - # 'tool', - # 'after_agent', - # ] - } - - active_workflow: str - active_view: str - - min_width: int = 80 - min_height: int = 30 - - def start(self): - """Load the first view in the default workflow.""" - view = self.workflow['project'][0] - self.load(view, workflow='project') - - def finish(self): - """Create the project, write the config file, and exit.""" - template = self.state.to_template_config() - - self.stop() - # TODO there's a flash of the app before the project is created - log.set_stdout(sys.stdout) # re-enable on-screen logging - - init_project( - slug_name=template.name, - template_data=template, - ) - - template.write_to_file(conf.PATH / "wizard") - log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") - - def advance(self): - """Load the next view in the active workflow.""" - workflow = self.workflow[self.active_workflow] - current_index = workflow.index(self.active_view) - view = workflow[current_index + 1] - self.load(view, workflow=self.active_workflow) - - def back(self): - """Load the previous view in the active workflow.""" - workflow = self.workflow[self.active_workflow] - current_index = workflow.index(self.active_view) - view = workflow[current_index - 1] - self.load(view, workflow=self.active_workflow) - - def load(self, view: str, workflow: Optional[str] = None): - """Load a view from a workflow.""" - self.active_workflow = workflow - self.active_view = view - super().load(view) - - @classmethod - def wrapper(cls, stdscr): - app = cls(stdscr) - app.state = State() - - app.start() - app.run() - - -def main(): - import io - log.set_stdout(io.StringIO()) # disable on-screen logging - - curses.wrapper(WizardApp.wrapper) - diff --git a/agentstack/tui/color.py b/agentstack/tui/color.py deleted file mode 100644 index 7fc89d95..00000000 --- a/agentstack/tui/color.py +++ /dev/null @@ -1,258 +0,0 @@ -import curses -from typing import Optional -import time -import math -from agentstack import log -from agentstack.tui.module import Module - - -# TODO: fallback for 16 color mode -# TODO: fallback for no color mode - -class Color: - """ - Color class based on HSV color space, mapping directly to terminal color capabilities. - - Hue: 0-360 degrees, mapped to 6 primary directions (0, 60, 120, 180, 240, 300) - Saturation: 0-100%, mapped to 6 levels (0, 20, 40, 60, 80, 100) - Value: 0-100%, mapped to 6 levels for colors, 24 levels for grayscale - """ - SATURATION_LEVELS = 12 - HUE_SEGMENTS = 6 - VALUE_LEVELS = 6 - GRAYSCALE_LEVELS = 24 - COLOR_CUBE_SIZE = 6 # 6x6x6 color cube - - reversed: bool = False - bold: bool = False - - _color_map = {} # Cache for color mappings - - def __init__(self, h: float, s: float = 100, v: float = 100, reversed: bool = False, bold: bool = False) -> None: - """ - Initialize color with HSV values. - - Args: - h: Hue (0-360 degrees) - s: Saturation (0-100 percent) - v: Value (0-100 percent) - """ - self.h = h % 360 - self.s = max(0, min(100, s)) - self.v = max(0, min(100, v)) - self.reversed = reversed - self.bold = bold - self._pair_number: Optional[int] = None - - def _get_closest_color(self) -> int: - """Map HSV to closest available terminal color number.""" - # Handle grayscale case - if self.s < 10: - gray_val = int(self.v * (self.GRAYSCALE_LEVELS - 1) / 100) - return 232 + gray_val if gray_val < self.GRAYSCALE_LEVELS else 231 - - # Convert HSV to the COLOR_CUBE_SIZE x COLOR_CUBE_SIZE x COLOR_CUBE_SIZE color cube - h = self.h - s = self.s / 100 - v = self.v / 100 - - # Map hue to primary and secondary colors (0 to HUE_SEGMENTS-1) - h = (h + 330) % 360 # -30 degrees = +330 degrees - h_segment = int((h / 60) % self.HUE_SEGMENTS) - h_remainder = (h % 60) / 60 - - # Get RGB values based on hue segment - max_level = self.COLOR_CUBE_SIZE - 1 - if h_segment == 0: # Red to Yellow - r, g, b = max_level, int(max_level * h_remainder), 0 - elif h_segment == 1: # Yellow to Green - r, g, b = int(max_level * (1 - h_remainder)), max_level, 0 - elif h_segment == 2: # Green to Cyan - r, g, b = 0, max_level, int(max_level * h_remainder) - elif h_segment == 3: # Cyan to Blue - r, g, b = 0, int(max_level * (1 - h_remainder)), max_level - elif h_segment == 4: # Blue to Magenta - r, g, b = int(max_level * h_remainder), 0, max_level - else: # Magenta to Red - r, g, b = max_level, 0, int(max_level * (1 - h_remainder)) - - # Apply saturation - max_rgb = max(r, g, b) - if max_rgb > 0: - # Map the saturation to the number of levels - s_level = int(s * (self.SATURATION_LEVELS - 1)) - s_factor = s_level / (self.SATURATION_LEVELS - 1) - - r = int(r + (max_level - r) * (1 - s_factor)) - g = int(g + (max_level - g) * (1 - s_factor)) - b = int(b + (max_level - b) * (1 - s_factor)) - - # Apply value (brightness) - v = max(0, min(max_level, int(v * self.VALUE_LEVELS))) - r = min(max_level, int(r * v / max_level)) - g = min(max_level, int(g * v / max_level)) - b = min(max_level, int(b * v / max_level)) - - # Convert to color cube index (16-231) - return int(16 + (r * self.COLOR_CUBE_SIZE * self.COLOR_CUBE_SIZE) + (g * self.COLOR_CUBE_SIZE) + b) - - def hue(self, h: float) -> 'Color': - """Set the hue of the color.""" - return Color(h, self.s, self.v, self.reversed, self.bold) - - def sat(self, s: float) -> 'Color': - """Set the saturation of the color.""" - return Color(self.h, s, self.v, self.reversed, self.bold) - - def val(self, v: float) -> 'Color': - """Set the value of the color.""" - return Color(self.h, self.s, v, self.reversed, self.bold) - - def reverse(self) -> 'Color': - """Set the reversed attribute of the color.""" - return Color(self.h, self.s, self.v, True, self.bold) - - def _get_color_pair(self, pair_number: int) -> int: - """Apply reversing to the color pair.""" - pair = curses.color_pair(pair_number) - if self.reversed: - pair = pair | curses.A_REVERSE - if self.bold: - pair = pair | curses.A_BOLD - return pair - - def to_curses(self) -> int: - """Get curses color pair for this color.""" - if self._pair_number is not None: - return self._get_color_pair(self._pair_number) - - color_number = self._get_closest_color() - - # Create new pair if needed - if color_number not in self._color_map: - pair_number = len(self._color_map) + 1 - #try: - # TODO make sure we don't overflow the available color pairs - curses.init_pair(pair_number, color_number, -1) - self._color_map[color_number] = pair_number - #except: - # return curses.color_pair(0) - else: - pair_number = self._color_map[color_number] - - self._pair_number = pair_number - return self._get_color_pair(pair_number) - - @classmethod - def initialize(cls) -> None: - """Initialize terminal color support.""" - if not curses.has_colors(): - raise RuntimeError("Terminal does not support colors") - - curses.start_color() - curses.use_default_colors() - - try: - curses.init_pair(1, 1, -1) - except: - raise RuntimeError("Terminal does not support required color features") - - cls._color_map = {} - - -class ColorAnimation(Color): - start: Color - end: Color - duration: float - loop: bool - _start_time: float - - def __init__(self, start: Color, end: Color, duration: float, loop: bool = False): - super().__init__(start.h, start.s, start.v) - self.start = start - self.end = end - self.duration = duration - self.loop = loop - self._start_time = time.time() - - def reset_animation(self): - self._start_time = time.time() - - def to_curses(self) -> int: - elapsed = time.time() - self._start_time - if elapsed > self.duration: - if self.loop: - self.start, self.end = self.end, self.start - self.reset_animation() - return self.start.to_curses() # prevents flickering :shrug: - else: - return self.end.to_curses() - - t = elapsed / self.duration - h1, h2 = self.start.h, self.end.h - # take the shortest path - diff = h2 - h1 - if abs(diff) > 180: - if diff > 0: - h1 += 360 - else: - h2 += 360 - h = (h1 + t * (h2 - h1)) % 360 - - # saturation and value - s = self.start.s + t * (self.end.s - self.start.s) - v = self.start.v + t * (self.end.v - self.start.v) - - return Color(h, s, v, reversed=self.start.reversed).to_curses() - - -class ColorWheel(Module): - """ - A module used for testing color display. - """ - width: int = 80 - height: int = 24 - - def __init__(self, coords: tuple[int, int], duration: float = 10.0): - super().__init__(coords, (self.height, self.width)) - self.duration = duration - self.start_time = time.time() - - def render(self) -> None: - self.grid.erase() - center_y, center_x = 12, 22 - radius = 10 - elapsed = time.time() - self.start_time - hue_offset = (elapsed / self.duration) * 360 # animate - - for y in range(center_y - radius, center_y + radius + 1): - for x in range(center_x - radius * 2, center_x + radius * 2 + 1): - # Convert position to polar coordinates - dx = (x - center_x) / 2 # Compensate for terminal character aspect ratio - dy = y - center_y - distance = math.sqrt(dx*dx + dy*dy) - - if distance <= radius: - # Convert to HSV - angle = math.degrees(math.atan2(dy, dx)) - #h = (angle + 360) % 360 - h = (angle + hue_offset) % 360 - s = (distance / radius) * 100 - v = 100 # (distance / radius) * 100 - - color = Color(h, s, v) - self.grid.addstr(y, x, "█", color.to_curses()) - - x = 50 - y = 4 - for i in range(0, curses.COLORS): - self.grid.addstr(y, x, f"███", curses.color_pair(i + 1)) - y += 1 - if y >= self.height - 4: - y = 4 - x += 3 - if x >= self.width - 3: - break - - self.grid.refresh() - #super().render() \ No newline at end of file From f70990231e9f6e834ffe36b78bc93fd05ed57356 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 28 Jan 2025 10:29:33 -0800 Subject: [PATCH 06/34] Ensure template writing is only called once. --- agentstack/cli/wizard.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 872730d9..b4766e07 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -818,6 +818,10 @@ class WizardApp(App): min_width: int = 80 min_height: int = 30 + # the main loop can still execute once more after this; so we create an + # explicit marker to ensure the template is only written once + _finish_run_once: bool = True + def start(self): """Load the first view in the default workflow.""" view = self.workflow['project'][0] @@ -828,17 +832,18 @@ def finish(self): template = self.state.to_template_config() self.stop() - # TODO the main loop can still execute once more after this; we need a - # better marker for executing once. - log.set_stdout(sys.stdout) # re-enable on-screen logging - - init_project( - slug_name=template.name, - template_data=template, - ) - template.write_to_file(conf.PATH / "wizard") - log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") + if self._finish_run_once: + log.set_stdout(sys.stdout) # re-enable on-screen logging + + init_project( + slug_name=template.name, + template_data=template, + ) + + template.write_to_file(conf.PATH / "wizard") + log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") + self._finish_run_once = False def advance(self): """Load the next view in the active workflow.""" From f3196d366b21b9e273b10c1b3800e8f0004074d6 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 28 Jan 2025 12:02:23 -0800 Subject: [PATCH 07/34] Attempt at resolving RadioSelect state sync. --- agentstack/tui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agentstack/tui.py b/agentstack/tui.py index 9bd7b5fd..192900aa 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -748,6 +748,7 @@ class Select(Box): """ UP, DOWN = "▲", "▼" on_change: Optional[Callable] = None + on_select: Optional[Callable] = None button_cls: type[Button] = Button button_height: int = 3 show_up: bool = False @@ -838,6 +839,7 @@ def select(self, option: Button): """Select an option; ie. mark it as the value of this element.""" index = self.modules.index(option) option.selected = not option.selected + self.value = self.options[index] self._mark_active(index) if self.on_select: self.on_select(index, self.options[index]) From 32fdc64e21cfea6609979e455109ba9832550ec9 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 28 Jan 2025 21:15:21 -0800 Subject: [PATCH 08/34] Background color. Ruff formatting. --- agentstack/cli/wizard.py | 726 +++++++++++++++++++++++---------------- agentstack/tui.py | 389 +++++++++++++-------- 2 files changed, 676 insertions(+), 439 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index b4766e07..a5e054b4 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -28,20 +28,23 @@ COLOR_FIELD_BORDER = Color(300, 100, 50) COLOR_FIELD_ACTIVE = Color(300, 80) + class FieldColors(TypedDict): color: Color border: Color active: Color + FIELD_COLORS: FieldColors = { - 'color': COLOR_FIELD_BG, - 'border': COLOR_FIELD_BORDER, - 'active': COLOR_FIELD_ACTIVE, + 'color': COLOR_FIELD_BG, + 'border': COLOR_FIELD_BORDER, + 'active': COLOR_FIELD_ACTIVE, } + class LogoElement(Text): - h_align = ALIGN_CENTER - + #h_align = ALIGN_CENTER + def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): super().__init__(coords, dims) self.color = COLOR_MAIN @@ -50,38 +53,44 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): self._star_colors = {} content_width = len(LOGO.split('\n')[0]) self.left_offset = round((self.width - content_width) / 2) - + def _get_star_color(self, index: int) -> Color: if index not in self._star_colors: self._star_colors[index] = ColorAnimation( - Color(randint(0, 150)), - Color(randint(200, 360)), - duration=2.0, - loop=True, + Color(randint(0, 150)), + Color(randint(200, 360)), + duration=2.0, + loop=True, ) return self._star_colors[index] - + def render(self) -> None: super().render() for i, (x, y) in enumerate(self.stars): - if self.width <= x or self.height <= y: - continue - self.grid.addch(y, self.left_offset + x, '*', self._get_star_color(i).to_curses()) + try: + self.grid.addch(y, x, '*', self._get_star_color(i).to_curses()) + #self.grid.addch(y, self.left_offset + x, '*', self._get_star_color(i).to_curses()) + except curses.error: + pass # overflow class StarBox(Box): """Renders random stars that animate down the page in the background of the box.""" + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], **kwargs): super().__init__(coords, dims, **kwargs) - self.stars = [(randint(0, self.width-1), randint(0, self.height-1)) for _ in range(11)] - self.star_colors = [ColorAnimation( - Color(randint(0, 150)), - Color(randint(200, 360)), - duration=2.0, - loop=False, - ) for _ in range(11)] - self.star_y = [randint(0, self.height-1) for _ in range(11)] - self.star_x = [randint(0, self.width-1) for _ in range(11)] + self.stars = [(randint(0, self.width - 1), randint(0, self.height - 1)) for _ in range(11)] + self.star_colors = [ + ColorAnimation( + Color(randint(0, 150)), + Color(randint(200, 360)), + duration=2.0, + loop=False, + ) + for _ in range(11) + ] + self.star_y = [randint(0, self.height - 1) for _ in range(11)] + self.star_x = [randint(0, self.width - 1) for _ in range(11)] self.star_speed = 0.001 self.star_timer = 0.0 self.star_index = 0 @@ -94,7 +103,7 @@ def render(self) -> None: self.star_y[i] += 1 else: self.star_y[i] = 0 - self.star_x[i] = randint(0, self.width-1) + self.star_x[i] = randint(0, self.width - 1) super().render() @@ -102,12 +111,14 @@ class HelpText(Text): def __init__(self, coords: tuple[int, int], dims: tuple[int, int]) -> None: super().__init__(coords, dims) self.color = Color(0, 0, 50) - self.value = " | ".join([ - "[tab] to select", - "[up / down] to navigate", - "[space / enter] to confirm", - "[q] to quit", - ]) + self.value = " | ".join( + [ + "[tab] to select", + "[up / down] to navigate", + "[space / enter] to confirm", + "[q] to quit", + ] + ) if conf.DEBUG: self.value += " | [d]ebug" @@ -124,116 +135,145 @@ class BannerView(WizardView): color = ColorAnimation( start=Color(90, 0, 0), # TODO make this darker end=Color(90), - duration=0.5 + duration=0.5, ) - + def layout(self) -> list[Renderable]: buttons = [] if not self.app.state.project: # no project yet, so we need to create one - buttons.append(Button( - # center button full width below the subtitle - (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), - (3, round(self.width/2)), - "Create Project", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('project', workflow='project'), - )) + buttons.append( + Button( + # center button full width below the subtitle + (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), + (3, round(self.width / 2)), + "Create Project", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('project', workflow='project'), + ) + ) else: # project has been created, so we can add agents - buttons.append(Button( - (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), - (3, round(self.width/2)), - "Add Agent", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('agent', workflow='agent'), - )) + buttons.append( + Button( + (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), + (3, round(self.width / 2)), + "Add Agent", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('agent', workflow='agent'), + ) + ) if len(self.app.state.agents): # we have one or more agents, so we can add tasks - buttons.append(Button( - (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), - (3, round(self.width/2)), - "Add Task", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('task', workflow='task'), - )) - + buttons.append( + Button( + (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), + (3, round(self.width / 2)), + "Add Task", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('task', workflow='task'), + ) + ) + # # we can also add more tools to existing agents # buttons.append(Button( - # (self.height-6, self.width-34), - # (3, 15), - # "Add Tool", + # (self.height-6, self.width-34), + # (3, 15), + # "Add Tool", # color=COLOR_BUTTON, # on_confirm=lambda: self.app.load('tool_category', workflow='agent'), # )) if self.app.state.project: # we can complete the project - buttons.append(Button( - (round(self.height / 2)+(len(buttons)*4), round(self.width/2/2)), - (3, round(self.width/2)), - "Finish", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.finish(), - )) + buttons.append( + Button( + (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), + (3, round(self.width / 2)), + "Finish", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.finish(), + ) + ) return [ - StarBox((0, 0), (self.height, self.width), color=COLOR_BORDER, modules=[ - LogoElement((1, 1), (7, self.width-2)), - Box( - (round(self.height / 4), round(self.width / 4)), - (9, round(self.width / 2)), - color=COLOR_BORDER, - modules=[ - Title((1, 1), (2, round(self.width / 2)-2), color=self.color, value=self.title), - Title((3, 1), (2, round(self.width / 2)-2), color=self.color, value=self.sparkle), - Title((5, 1), (2, round(self.width / 2)-2), color=self.color, value=self.subtitle), - ]), - *buttons, - ]), + StarBox( + (0, 0), + (self.height, self.width), + color=COLOR_BORDER, + modules=[ + LogoElement((1, 1), (7, self.width - 2)), + Box( + (round(self.height / 4), round(self.width / 4)), + (9, round(self.width / 2)), + color=COLOR_BORDER, + modules=[ + Title((1, 1), (2, round(self.width / 2) - 2), color=self.color, value=self.title), + Title( + (3, 1), (2, round(self.width / 2) - 2), color=self.color, value=self.sparkle + ), + Title( + (5, 1), (2, round(self.width / 2) - 2), color=self.color, value=self.subtitle + ), + ], + ), + *buttons, + ], + ), ] class FormView(WizardView): title: str error_message: Node - + def __init__(self, app: 'App'): super().__init__(app) self.error_message = Node() def submit(self): pass - + def error(self, message: str): self.error_message.value = message - + def form(self) -> list[Renderable]: return [] - + def layout(self) -> list[Renderable]: return [ - Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ - LogoElement((1, 1), (7, self.width-2)), - Title((9, 1), (1, self.width-3), color=COLOR_TITLE, value=self.title), - Title((10, 1), (1, self.width-3), color=COLOR_ERROR, value=self.error_message), - *self.form(), - Button((self.height-6, self.width-17), (3, 15), "Next", color=COLOR_BUTTON, on_confirm=self.submit), - ]), - HelpText((self.height-1, 0), (1, self.width)), + Box( + (0, 0), + (self.height - 1, self.width), + color=COLOR_BORDER, + modules=[ + LogoElement((1, 1), (7, self.width - 2)), + Title((9, 1), (1, self.width - 3), color=COLOR_TITLE, value=self.title), + Title((10, 1), (1, self.width - 3), color=COLOR_ERROR, value=self.error_message), + *self.form(), + Button( + (self.height - 6, self.width - 17), + (3, 15), + "Next", + color=COLOR_BUTTON, + on_confirm=self.submit, + ), + ], + ), + HelpText((self.height - 1, 0), (1, self.width)), ] class ProjectView(FormView): title = "Define your Project" - + def __init__(self, app: 'App'): super().__init__(app) self.project_name = Node() self.project_description = Node() - + def submit(self): if not self.project_name.value: self.error("Name is required.") @@ -242,18 +282,17 @@ def submit(self): if not is_snake_case(self.project_name.value): self.error("Name must be in snake_case.") return - + self.app.state.create_project( name=self.project_name.value, description=self.project_description.value, ) self.app.advance() - + def form(self) -> list[Renderable]: return [ Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((12, 13), (2, self.width - 15), self.project_name, **FIELD_COLORS), - + TextInput((12, 13), (2, self.width - 15), self.project_name, **FIELD_COLORS), Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), TextInput((14, 13), (5, self.width - 15), self.project_description, **FIELD_COLORS), ] @@ -261,19 +300,19 @@ def form(self) -> list[Renderable]: class FrameworkView(FormView): title = "Select a Framework" - + FRAMEWORK_OPTIONS = { CREWAI: {'name': "CrewAI", 'description': "A simple and easy-to-use framework."}, LANGGRAPH: {'name': "LangGraph", 'description': "A powerful and flexible framework."}, } - + def __init__(self, app: 'App'): super().__init__(app) self.framework_key = Node() self.framework_logo = Node() self.framework_name = Node() self.framework_description = Node() - + def set_framework_selection(self, index: int, value: str): """Update the content of the framework info box.""" key, data = None, None @@ -282,18 +321,18 @@ def set_framework_selection(self, index: int, value: str): key = _key data = _value break - + if not key or not data: key = value data = { 'name': "Unknown", 'description': "Unknown", } - + self.framework_logo.value = data['name'] self.framework_name.value = data['name'] self.framework_description.value = data['description'] - + def set_framework_choice(self, index: int, value: str): """Save the selection.""" key = None @@ -301,37 +340,53 @@ def set_framework_choice(self, index: int, value: str): if _value['name'] == value: # search by name key = _key break - + self.framework_key.value = key - + def get_framework_options(self) -> list[str]: return [self.FRAMEWORK_OPTIONS[key]['name'] for key in SUPPORTED_FRAMEWORKS] - + def submit(self): if not self.framework_key.value: self.error("Framework is required.") return - + self.app.state.update_active_project(framework=self.framework_key.value) self.app.advance() - + def form(self) -> list[Renderable]: return [ RadioSelect( - (12, 1), (self.height-18, round(self.width/2)-3), - options=self.get_framework_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), - on_change=self.set_framework_selection, - on_select=self.set_framework_choice + (12, 1), + (self.height - 18, round(self.width / 2) - 3), + options=self.get_framework_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), + on_change=self.set_framework_selection, + on_select=self.set_framework_choice, + ), + Box( + (12, round(self.width / 2)), + (self.height - 18, round(self.width / 2) - 3), + color=COLOR_FORM_BORDER, + modules=[ + ASCIIText( + (1, 3), + (4, round(self.width / 2) - 10), + color=COLOR_FORM.sat(40), + value=self.framework_logo, + ), + BoldText( + (5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.framework_name + ), + WrappedText( + (7, 3), + (5, round(self.width / 2) - 10), + color=COLOR_FORM.sat(50), + value=self.framework_description, + ), + ], ), - Box((12, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ - ASCIIText((1, 3), (4, round(self.width/2)-10), color=COLOR_FORM.sat(40), value=self.framework_logo), - BoldText((5, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.framework_name), - WrappedText((7, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.framework_description), - ]), ] @@ -343,14 +398,14 @@ class AfterProjectView(BannerView): class AgentView(FormView): title = "Define your Agent" - + def __init__(self, app: 'App'): super().__init__(app) self.agent_name = Node() self.agent_role = Node() self.agent_goal = Node() self.agent_backstory = Node() - + def submit(self): if not self.agent_name.value: self.error("Name is required.") @@ -367,18 +422,15 @@ def submit(self): backstory=self.agent_backstory.value, ) self.app.advance() - + def form(self) -> list[Renderable]: return [ Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((12, 13), (2, self.width - 15), self.agent_name, **FIELD_COLORS), - + TextInput((12, 13), (2, self.width - 15), self.agent_name, **FIELD_COLORS), Text((14, 2), (1, 11), color=COLOR_FORM, value="Role"), TextInput((14, 13), (5, self.width - 15), self.agent_role, **FIELD_COLORS), - Text((19, 2), (1, 11), color=COLOR_FORM, value="Goal"), TextInput((19, 13), (5, self.width - 15), self.agent_goal, **FIELD_COLORS), - Text((24, 2), (1, 11), color=COLOR_FORM, value="Backstory"), TextInput((24, 13), (5, self.width - 15), self.agent_backstory, **FIELD_COLORS), ] @@ -386,82 +438,122 @@ def form(self) -> list[Renderable]: class ModelView(FormView): title = "Select a Model" - + MODEL_OPTIONS = [ - {'value': "anthropic/claude-3.5-sonnet", 'name': "Claude 3.5 Sonnet", 'provider': "Anthropic", 'description': "A fast and cost-effective model."}, - {'value': "gpt-3.5-turbo", 'name': "GPT-3.5 Turbo", 'provider': "OpenAI", 'description': "A fast and cost-effective model."}, - {'value': "gpt-4", 'name': "GPT-4", 'provider': "OpenAI", 'description': "A more advanced model with better understanding."}, - {'value': "gpt-4o", 'name': "GPT-4o", 'provider': "OpenAI", 'description': "The latest and most powerful model."}, - {'value': "gpt-4o-mini", 'name': "GPT-4o Mini", 'provider': "OpenAI", 'description': "A smaller, faster version of GPT-4o."}, + { + 'value': "anthropic/claude-3.5-sonnet", + 'name': "Claude 3.5 Sonnet", + 'provider': "Anthropic", + 'description': "A fast and cost-effective model.", + }, + { + 'value': "gpt-3.5-turbo", + 'name': "GPT-3.5 Turbo", + 'provider': "OpenAI", + 'description': "A fast and cost-effective model.", + }, + { + 'value': "gpt-4", + 'name': "GPT-4", + 'provider': "OpenAI", + 'description': "A more advanced model with better understanding.", + }, + { + 'value': "gpt-4o", + 'name': "GPT-4o", + 'provider': "OpenAI", + 'description': "The latest and most powerful model.", + }, + { + 'value': "gpt-4o-mini", + 'name': "GPT-4o Mini", + 'provider': "OpenAI", + 'description': "A smaller, faster version of GPT-4o.", + }, ] - - + def __init__(self, app: 'App'): super().__init__(app) self.model_choice = Node() self.model_logo = Node() self.model_name = Node() self.model_description = Node() - + def set_model_selection(self, index: int, value: str): """Update the content of the model info box.""" model = self.MODEL_OPTIONS[index] self.model_logo.value = model['provider'] self.model_name.value = model['name'] self.model_description.value = model['description'] - + def set_model_choice(self, index: int, value: str): """Save the selection.""" # list in UI shows the actual key self.model_choice.value = value - + def get_model_options(self): return [model['value'] for model in self.MODEL_OPTIONS] - + def submit(self): if not self.model_choice.value: self.error("Model is required.") return - + self.app.state.update_active_agent(llm=self.model_choice.value) self.app.advance() - + def form(self) -> list[Renderable]: return [ RadioSelect( - (11, 1), (self.height-18, round(self.width/2)-3), + (11, 1), + (self.height - 18, round(self.width / 2) - 3), options=self.get_model_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), on_change=self.set_model_selection, - on_select=self.set_model_choice + on_select=self.set_model_choice, + ), + Box( + (11, round(self.width / 2)), + (self.height - 18, round(self.width / 2) - 3), + color=COLOR_FORM_BORDER, + modules=[ + ASCIIText( + (1, 3), + (4, round(self.width / 2) - 10), + color=COLOR_FORM.sat(40), + value=self.model_logo, + ), + BoldText( + (5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.model_name + ), + WrappedText( + (7, 3), + (5, round(self.width / 2) - 10), + color=COLOR_FORM.sat(50), + value=self.model_description, + ), + ], ), - Box((11, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ - ASCIIText((1, 3), (4, round(self.width/2)-10), color=COLOR_FORM.sat(40), value=self.model_logo), - BoldText((5, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.model_name), - WrappedText((7, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.model_description), - ]), ] class ToolCategoryView(FormView): title = "Select a Tool Category" - + # TODO category descriptions for all valid categories TOOL_CATEGORY_OPTIONS = { "web": {'name': "Web Tools", 'description': "Tools that interact with the web."}, "file": {'name': "File Tools", 'description': "Tools that interact with the file system."}, "code": {'name': "Code Tools", 'description': "Tools that interact with code."}, } - + def __init__(self, app: 'App'): super().__init__(app) self.tool_category_key = Node() self.tool_category_name = Node() self.tool_category_description = Node() - + def set_tool_category_selection(self, index: int, value: str): key, data = None, None for _key, _value in self.TOOL_CATEGORY_OPTIONS.items(): @@ -469,102 +561,127 @@ def set_tool_category_selection(self, index: int, value: str): key = _key data = _value break - + if not key or not data: key = value data = { 'name': "Unknown", 'description': "Unknown", } - + self.tool_category_name.value = data['name'] self.tool_category_description.value = data['description'] - + def set_tool_category_choice(self, index: int, value: str): self.tool_category_key.value = value - + def get_tool_category_options(self) -> list[str]: return sorted(list({tool.category for tool in get_all_tools()})) - + def submit(self): if not self.tool_category_key.value: self.error("Tool category is required.") return - + self.app.state.tool_category = self.tool_category_key.value self.app.advance() - + def form(self) -> list[Renderable]: return [ RadioSelect( - (11, 1), (self.height-18, round(self.width/2)-3), - options=self.get_tool_category_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), + (11, 1), + (self.height - 18, round(self.width / 2) - 3), + options=self.get_tool_category_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), on_change=self.set_tool_category_selection, - on_select=self.set_tool_category_choice + on_select=self.set_tool_category_choice, + ), + Box( + (11, round(self.width / 2)), + (self.height - 18, round(self.width / 2) - 3), + color=COLOR_FORM_BORDER, + modules=[ + BoldText( + (1, 3), + (1, round(self.width / 2) - 10), + color=COLOR_FORM, + value=self.tool_category_name, + ), + WrappedText( + (2, 3), + (5, round(self.width / 2) - 10), + color=COLOR_FORM.sat(50), + value=self.tool_category_description, + ), + ], ), - Box((11, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ - BoldText((1, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.tool_category_name), - WrappedText((2, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.tool_category_description), - ]), ] class ToolView(FormView): title = "Select a Tool" - + def __init__(self, app: 'App'): super().__init__(app) self.tool_key = Node() self.tool_name = Node() self.tool_description = Node() - + @property def category(self) -> str: return self.app.state.tool_category - + def set_tool_selection(self, index: int, value: str): tool_config = get_tool(value) self.tool_name.value = tool_config.name self.tool_description.value = tool_config.cta - + def set_tool_choice(self, index: int, value: str): self.tool_key.value = value - + def get_tool_options(self) -> list[str]: return sorted([tool.name for tool in get_all_tools() if tool.category == self.category]) - + def submit(self): if not self.tool_key.value: self.error("Tool is required.") return - + self.app.state.update_active_agent_tools(self.tool_key.value) self.app.advance() - + def back(self): self.app.back() - + def form(self) -> list[Renderable]: return [ RadioSelect( - (12, 1), (self.height-18, round(self.width/2)-3), + (12, 1), + (self.height - 18, round(self.width / 2) - 3), options=self.get_tool_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), on_change=self.set_tool_selection, - on_select=self.set_tool_choice + on_select=self.set_tool_choice, + ), + Box( + (12, round(self.width / 2)), + (self.height - 18, round(self.width / 2) - 3), + color=COLOR_FORM_BORDER, + modules=[ + BoldText((1, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.tool_name), + WrappedText( + (2, 3), + (5, round(self.width / 2) - 10), + color=COLOR_FORM.sat(50), + value=self.tool_description, + ), + ], + ), + Button( + (self.height - 6, self.width - 17), (3, 15), "Back", color=COLOR_BUTTON, on_confirm=self.back ), - Box((12, round(self.width/2)), (self.height-18, round(self.width/2)-3), color=COLOR_FORM_BORDER, modules=[ - BoldText((1, 3), (1, round(self.width/2)-10), color=COLOR_FORM, value=self.tool_name), - WrappedText((2, 3), (5, round(self.width/2)-10), color=COLOR_FORM.sat(50), value=self.tool_description), - ]), - Button((self.height-6, self.width-17), (3, 15), "Back", color=COLOR_BUTTON, on_confirm=self.back), ] @@ -576,13 +693,13 @@ class AfterAgentView(BannerView): class TaskView(FormView): title = "Define your Task" - + def __init__(self, app: 'App'): super().__init__(app) self.task_name = Node() self.task_description = Node() self.expected_output = Node() - + def submit(self): if not self.task_name.value: self.error("Task name is required.") @@ -598,15 +715,13 @@ def submit(self): expected_output=self.expected_output.value, ) self.app.advance() - + def form(self) -> list[Renderable]: return [ Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((12, 13), (2, self.width - 15), self.task_name, **FIELD_COLORS), - + TextInput((12, 13), (2, self.width - 15), self.task_name, **FIELD_COLORS), Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), TextInput((14, 13), (5, self.width - 15), self.task_description, **FIELD_COLORS), - Text((19, 2), (1, 11), color=COLOR_FORM, value="Expected Output"), TextInput((19, 13), (5, self.width - 15), self.expected_output, **FIELD_COLORS), ] @@ -614,35 +729,34 @@ def form(self) -> list[Renderable]: class AgentSelectionView(FormView): title = "Select an Agent for your Task" - + def __init__(self, app: 'App'): super().__init__(app) self.agent_name = Node() - + def set_agent_choice(self, index: int, value: str): self.agent_name.value = value - + def get_agent_options(self) -> list[str]: return list(self.app.state.agents.keys()) - + def submit(self): if not self.agent_name.value: self.error("Agent is required.") return - + self.app.state.update_active_task(agent=self.agent_name.value) self.app.advance() def form(self) -> list[Renderable]: return [ RadioSelect( - (12, 1), (self.height-18, round(self.width/2)-3), + (12, 1), + (self.height - 18, round(self.width / 2) - 3), options=self.get_agent_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation( - COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2 - ), - on_select=self.set_agent_choice + color=COLOR_FORM_BORDER, + highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), + on_select=self.set_agent_choice, ), # TODO agent info pane ] @@ -656,18 +770,32 @@ class AfterTaskView(BannerView): class DebugView(WizardView): name = "debug" + def layout(self) -> list[Renderable]: from agentstack.utils import get_version - + return [ - Box((0, 0), (self.height-1, self.width), color=COLOR_BORDER, modules=[ - ColorWheel((1, 1)), - Title((self.height-6, 3), (1, self.width-5), color=COLOR_MAIN, - value=f"AgentStack version {get_version()}"), - Title((self.height-4, 3), (1, self.width-5), color=COLOR_MAIN, - value=f"Window size: {self.width}x{self.height}"), - ]), - HelpText((self.height-1, 0), (1, self.width)), + Box( + (0, 0), + (self.height - 1, self.width), + color=COLOR_BORDER, + modules=[ + ColorWheel((1, 1)), + Title( + (self.height - 6, 3), + (1, self.width - 5), + color=COLOR_MAIN, + value=f"AgentStack version {get_version()}", + ), + Title( + (self.height - 4, 3), + (1, self.width - 5), + color=COLOR_MAIN, + value=f"Window size: {self.width}x{self.height}", + ), + ], + ), + HelpText((self.height - 1, 0), (1, self.width)), ] @@ -683,51 +811,51 @@ class State: agents: dict[str, dict] # `tasks` is a dictionary of tasks we have created tasks: dict[str, dict] - + def __init__(self): self.project = {} self.agents = {} self.tasks = {} - + def __repr__(self): return f"State(project={self.project}, agents={self.agents}, tasks={self.tasks})" - + def create_project(self, name: str, description: str): self.project = { 'name': name, 'description': description, } self.active_project = name - + def update_active_project(self, **kwargs): for key, value in kwargs.items(): self.project[key] = value - + def create_agent(self, name: str, role: str, goal: str, backstory: str): self.agents[name] = { - 'role': role, - 'goal': goal, - 'backstory': backstory, - 'llm': None, - 'tools': [], + 'role': role, + 'goal': goal, + 'backstory': backstory, + 'llm': None, + 'tools': [], } self.active_agent = name - + def update_active_agent(self, **kwargs): agent = self.agents[self.active_agent] for key, value in kwargs.items(): agent[key] = value - + def update_active_agent_tools(self, tool_name: str): self.agents[self.active_agent]['tools'].append(tool_name) def create_task(self, name: str, description: str, expected_output: str): self.tasks[name] = { - 'description': description, - 'expected_output': expected_output, + 'description': description, + 'expected_output': expected_output, } self.active_task = name - + def update_active_task(self, **kwargs): task = self.tasks[self.active_task] for key, value in kwargs.items(): @@ -737,146 +865,154 @@ def to_template_config(self) -> TemplateConfig: tools = [] for agent_name, agent_data in self.agents.items(): for tool_name in agent_data['tools']: - tools.append(TemplateConfig.Tool( - name=tool_name, - agents=[agent_name], - )) - + tools.append( + TemplateConfig.Tool( + name=tool_name, + agents=[agent_name], + ) + ) + return TemplateConfig( template_version=4, name=self.project['name'], description=self.project['description'], framework=self.project['framework'], method="sequential", - agents=[TemplateConfig.Agent( - name=agent_name, - role=agent_data['role'], - goal=agent_data['goal'], - backstory=agent_data['backstory'], - llm=agent_data['llm'], - ) for agent_name, agent_data in self.agents.items()], - tasks=[TemplateConfig.Task( - name=task_name, - description=task_data['description'], - expected_output=task_data['expected_output'], - agent=self.active_agent, - ) for task_name, task_data in self.tasks.items()], + agents=[ + TemplateConfig.Agent( + name=agent_name, + role=agent_data['role'], + goal=agent_data['goal'], + backstory=agent_data['backstory'], + llm=agent_data['llm'], + ) + for agent_name, agent_data in self.agents.items() + ], + tasks=[ + TemplateConfig.Task( + name=task_name, + description=task_data['description'], + expected_output=task_data['expected_output'], + agent=self.active_agent, + ) + for task_name, task_data in self.tasks.items() + ], tools=tools, ) class WizardApp(App): views = { - 'welcome': BannerView, - 'framework': FrameworkView, - 'project': ProjectView, - 'after_project': AfterProjectView, - 'agent': AgentView, - 'model': ModelView, - 'tool_category': ToolCategoryView, - 'tool': ToolView, - 'after_agent': AfterAgentView, - 'task': TaskView, - 'agent_selection': AgentSelectionView, - 'after_task': AfterTaskView, - 'debug': DebugView, + 'welcome': BannerView, + 'framework': FrameworkView, + 'project': ProjectView, + 'after_project': AfterProjectView, + 'agent': AgentView, + 'model': ModelView, + 'tool_category': ToolCategoryView, + 'tool': ToolView, + 'after_agent': AfterAgentView, + 'task': TaskView, + 'agent_selection': AgentSelectionView, + 'after_task': AfterTaskView, + 'debug': DebugView, } shortcuts = { 'd': 'debug', } workflow = { 'project': [ # initialize a project - 'welcome', - 'project', - 'framework', - 'after_project', + 'welcome', + 'project', + 'framework', + 'after_project', ], 'agent': [ # add agents - 'agent', - 'model', - 'tool_category', - 'tool', - 'after_agent', - ], + 'agent', + 'model', + 'tool_category', + 'tool', + 'after_agent', + ], 'task': [ # add tasks - 'task', - 'agent_selection', + 'task', + 'agent_selection', 'after_task', ], # 'tool': [ # add tools to an agent - # 'agent_select', + # 'agent_select', # 'tool_category', # 'tool', # 'after_agent', # ] } - + state: State active_workflow: Optional[str] active_view: Optional[str] - + min_width: int = 80 min_height: int = 30 - - # the main loop can still execute once more after this; so we create an + + # the main loop can still execute once more after this; so we create an # explicit marker to ensure the template is only written once _finish_run_once: bool = True - + def start(self): """Load the first view in the default workflow.""" view = self.workflow['project'][0] self.load(view, workflow='project') - + def finish(self): """Create the project, write the config file, and exit.""" template = self.state.to_template_config() - + self.stop() - + if self._finish_run_once: log.set_stdout(sys.stdout) # re-enable on-screen logging - + init_project( slug_name=template.name, template_data=template, ) - + template.write_to_file(conf.PATH / "wizard") log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") self._finish_run_once = False - + def advance(self): """Load the next view in the active workflow.""" workflow = self.workflow[self.active_workflow] current_index = workflow.index(self.active_view) view = workflow[current_index + 1] self.load(view, workflow=self.active_workflow) - + def back(self): """Load the previous view in the active workflow.""" workflow = self.workflow[self.active_workflow] current_index = workflow.index(self.active_view) view = workflow[current_index - 1] self.load(view, workflow=self.active_workflow) - + def load(self, view: str, workflow: Optional[str] = None): """Load a view from a workflow.""" self.active_workflow = workflow self.active_view = view super().load(view) - + @classmethod def wrapper(cls, stdscr): app = cls(stdscr) app.state = State() - + app.start() app.run() def main(): import io + log.set_stdout(io.StringIO()) # disable on-screen logging - - curses.wrapper(WizardApp.wrapper) + curses.wrapper(WizardApp.wrapper) diff --git a/agentstack/tui.py b/agentstack/tui.py index 192900aa..590dbe0f 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -32,19 +32,20 @@ class RenderException(Exception): class Node: # TODO this needs a better name """ - A simple data node that can be updated and have callbacks. This is used to - populate and retrieve data from an input field inside the user interface. + A simple data node that can be updated and have callbacks. This is used to + populate and retrieve data from an input field inside the user interface. """ + value: Any callbacks: list[Callable] - + def __init__(self, value: Any = "") -> None: self.value = value self.callbacks = [] - + def __str__(self): return str(self.value) - + def update(self, value: Any) -> None: self.value = value for callback in self.callbacks: @@ -70,40 +71,41 @@ class Key: 'PERCENT': 37, 'MINUS': 45, } - + def __init__(self, ch: int): self.ch = ch - log.debug(f"Key: {ch}") - + def __getattr__(self, name): try: return self.ch == self.const[name] except KeyError: raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") - + @property def chr(self): return chr(self.ch) - + @property def is_numeric(self): return self.ch >= 48 and self.ch <= 57 - + @property def is_alpha(self): - return (self.ch >= 65 and self.ch <= 122) + return self.ch >= 65 and self.ch <= 122 class Color: """ Color class based on HSV color space, mapping directly to terminal color capabilities. - + Hue: 0-360 degrees, mapped to 6 primary directions (0, 60, 120, 180, 240, 300) Saturation: 0-100%, mapped to 6 levels (0, 20, 40, 60, 80, 100) Value: 0-100%, mapped to 6 levels for colors, 24 levels for grayscale """ + # TODO: fallback for 16 color mode # TODO: fallback for no color mode + BACKGROUND = curses.COLOR_BLACK SATURATION_LEVELS = 12 HUE_SEGMENTS = 6 VALUE_LEVELS = 6 @@ -113,12 +115,14 @@ class Color: reversed: bool = False bold: bool = False - _color_map = {} # Cache for color mappings - - def __init__(self, h: float, s: float = 100, v: float = 100, reversed: bool = False, bold: bool = False) -> None: + _color_map = {} # Cache for color mappings + + def __init__( + self, h: float, s: float = 100, v: float = 100, reversed: bool = False, bold: bool = False + ) -> None: """ Initialize color with HSV values. - + Args: h: Hue (0-360 degrees) s: Saturation (0-100 percent) @@ -150,7 +154,7 @@ def _get_closest_color(self) -> int: # Get RGB values based on hue segment max_level = self.COLOR_CUBE_SIZE - 1 - if h_segment == 0: # Red to Yellow + if h_segment == 0: # Red to Yellow r, g, b = max_level, int(max_level * h_remainder), 0 elif h_segment == 1: # Yellow to Green r, g, b = int(max_level * (1 - h_remainder)), max_level, 0 @@ -160,7 +164,7 @@ def _get_closest_color(self) -> int: r, g, b = 0, int(max_level * (1 - h_remainder)), max_level elif h_segment == 4: # Blue to Magenta r, g, b = int(max_level * h_remainder), 0, max_level - else: # Magenta to Red + else: # Magenta to Red r, g, b = max_level, 0, int(max_level * (1 - h_remainder)) # Apply saturation @@ -169,7 +173,7 @@ def _get_closest_color(self) -> int: # Map the saturation to the number of levels s_level = int(s * (self.SATURATION_LEVELS - 1)) s_factor = s_level / (self.SATURATION_LEVELS - 1) - + r = int(r + (max_level - r) * (1 - s_factor)) g = int(g + (max_level - g) * (1 - s_factor)) b = int(b + (max_level - b) * (1 - s_factor)) @@ -186,7 +190,7 @@ def _get_closest_color(self) -> int: def hue(self, h: float) -> 'Color': """Set the hue of the color.""" return Color(h, self.s, self.v, self.reversed, self.bold) - + def sat(self, s: float) -> 'Color': """Set the saturation of the color.""" return Color(self.h, s, self.v, self.reversed, self.bold) @@ -214,19 +218,15 @@ def to_curses(self) -> int: return self._get_color_pair(self._pair_number) color_number = self._get_closest_color() - + # Create new pair if needed if color_number not in self._color_map: pair_number = len(self._color_map) + 1 - #try: - # TODO make sure we don't overflow the available color pairs - curses.init_pair(pair_number, color_number, -1) + curses.init_pair(pair_number, color_number, self.BACKGROUND) self._color_map[color_number] = pair_number - #except: - # return curses.color_pair(0) else: pair_number = self._color_map[color_number] - + self._pair_number = pair_number return self._get_color_pair(pair_number) @@ -238,12 +238,12 @@ def initialize(cls) -> None: curses.start_color() curses.use_default_colors() - + try: curses.init_pair(1, 1, -1) except: raise RuntimeError("Terminal does not support required color features") - + cls._color_map = {} @@ -253,7 +253,7 @@ class ColorAnimation(Color): duration: float loop: bool _start_time: float - + def __init__(self, start: Color, end: Color, duration: float, loop: bool = False): super().__init__(start.h, start.s, start.v) self.start = start @@ -261,10 +261,10 @@ def __init__(self, start: Color, end: Color, duration: float, loop: bool = False self.duration = duration self.loop = loop self._start_time = time.time() - + def reset_animation(self): self._start_time = time.time() - + def to_curses(self) -> int: elapsed = time.time() - self._start_time if elapsed > self.duration: @@ -274,7 +274,7 @@ def to_curses(self) -> int: return self.start.to_curses() # prevents flickering :shrug: else: return self.end.to_curses() - + t = elapsed / self.duration h1, h2 = self.start.h, self.end.h # take the shortest path @@ -285,11 +285,11 @@ def to_curses(self) -> int: else: h2 += 360 h = (h1 + t * (h2 - h1)) % 360 - + # saturation and value s = self.start.s + t * (self.end.s - self.start.s) v = self.start.v + t * (self.end.v - self.start.v) - + return Color(h, s, v, reversed=self.start.reversed).to_curses() @@ -306,15 +306,15 @@ class Renderable: last_render: float = 0 padding: tuple[int, int] = (1, 1) positioning: str = POS_ABSOLUTE - + def __init__(self, coords: tuple[int, int], dims: tuple[int, int], color: Optional[Color] = None): self.y, self.x = coords self.height, self.width = dims self.color = color or Color(0, 100, 0) - - def __repr__( self ): + + def __repr__(self): return f"{type(self)} at ({self.y}, {self.x})" - + @property def grid(self): # TODO cleanup @@ -330,12 +330,11 @@ def grid(self): raise ValueError("Invalid positioning value") else: grid_func = curses.newwin - + self._grid = grid_func( - self.height + self.padding[0], - self.width + self.padding[1], - self.y, - self.x) # TODO this cant be bigger than the window + self.height + self.padding[0], self.width + self.padding[1], self.y, self.x + ) # TODO this cant be bigger than the window + self._grid.bkgd(' ', curses.color_pair(1)) return self._grid def move(self, y: int, x: int): @@ -346,7 +345,7 @@ def move(self, y: int, x: int): elif self.positioning == POS_ABSOLUTE: self._grid.mvwin(self.y, self.x) else: - raise ValueError("Cannot move a root window") + raise ValueError("Cannot move a root window") @property def abs_x(self): @@ -367,7 +366,12 @@ def render(self): def hit(self, y, x): """Is the mouse click inside this module?""" - return y >= self.abs_y and y < self.abs_y + self.height and x >= self.abs_x and x < self.abs_x + self.width + return ( + y >= self.abs_y + and y < self.abs_y + self.height + and x >= self.abs_x + and x < self.abs_x + self.width + ) def click(self, y, x): """Handle mouse click event.""" @@ -387,12 +391,18 @@ def destroy(self) -> None: class Element(Renderable): positioning: str = POS_RELATIVE word_wrap: bool = False - - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional[Color] = None): + + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + value: Optional[Any] = "", + color: Optional[Color] = None, + ): super().__init__(coords, dims, color=color) self.value = value - - def __repr__( self ): + + def __repr__(self): return f"{type(self)} at ({self.y}, {self.x}) with value '{self.value[:20]}'" def _get_lines(self, value: str) -> list[str]: @@ -405,8 +415,10 @@ def _get_lines(self, value: str) -> list[str]: elif '\n' in value: splits = value.split('\n') else: - splits = [value, ] - + splits = [ + value, + ] + if self.v_align == ALIGN_TOP: # add empty elements below splits = splits + [''] * (self.height - len(splits)) @@ -416,7 +428,7 @@ def _get_lines(self, value: str) -> list[str]: splits = [''] * pad + splits + [''] * pad elif self.v_align == ALIGN_BOTTOM: splits = [''] * (self.height - len(splits)) + splits - + lines = [] for line in splits: if self.h_align == ALIGN_LEFT: @@ -425,10 +437,10 @@ def _get_lines(self, value: str) -> list[str]: line = line.rjust(self.width) elif self.h_align == ALIGN_CENTER: line = line.center(self.width) - - lines.append(line[:self.width]) + + lines.append(line[: self.width]) return lines - + def render(self): for i, line in enumerate(self._get_lines(str(self.value))): self.grid.addstr(i, 0, line, self.color.to_curses()) @@ -436,10 +448,17 @@ def render(self): class NodeElement(Element): format: Optional[Callable] = None - - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional[Color] = None, format: Optional[Callable]=None): + + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + node: Node, + color: Optional[Color] = None, + format: Optional[Callable] = None, + ): super().__init__(coords, dims, color=color) - self.node = node # TODO can also be str? + self.node = node # TODO can also be str? self.value = str(node) self.format = format if isinstance(node, Node): @@ -464,38 +483,46 @@ class Editable(NodeElement): filter: Optional[Callable] = None active: bool _original_value: Any - - def __init__(self, coords, dims, node, color=None, format: Optional[Callable]=None, filter: Optional[Callable]=None): + + def __init__( + self, + coords, + dims, + node, + color=None, + format: Optional[Callable] = None, + filter: Optional[Callable] = None, + ): super().__init__(coords, dims, node=node, color=color, format=format) self.filter = filter self.active = False self._original_value = self.value - + def click(self, y, x): if not self.active and self.hit(y, x): self.activate() elif self.active: # click off self.deactivate() self.save() - + def activate(self): """Make this module the active one; ie. editing or selected.""" App.editing = True self.active = True self._original_value = self.value - + def deactivate(self, save: bool = True): """Deactivate this module, making it no longer active.""" App.editing = False self.active = False if save: self.save() - + def save(self): if self.filter: self.value = self.filter(self.value) super().save() - + def input(self, key: Key): if not self.active: return @@ -510,7 +537,7 @@ def input(self, key: Key): elif key.ENTER: self.deactivate() log.debug(f"Saving {self.value} to {self.node}") - + def destroy(self): self.deactivate() super().destroy() @@ -529,22 +556,35 @@ class ASCIIText(Text): formatter: Figlet _ascii_render: Optional[str] = None # rendered content _ascii_value: Optional[str] = None # value used to render content - - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional[Color] = None, formatter: Optional[Figlet] = None): + + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + value: Optional[Any] = "", + color: Optional[Color] = None, + formatter: Optional[Figlet] = None, + ): super().__init__(coords, dims, value=value, color=color) self.formatter = formatter or Figlet(font=self.default_font) - + def _get_lines(self, value: str) -> list[str]: if not self._ascii_render or self._ascii_value != value: # prevent rendering on every frame self._ascii_value = value self._ascii_render = self.formatter.renderText(value) or "" - + return super()._get_lines(self._ascii_render) class BoldText(Text): - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional[Color] = None): + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + value: Optional[Any] = "", + color: Optional[Color] = None, + ): super().__init__(coords, dims, value=value, color=color) self.color.bold = True @@ -556,35 +596,45 @@ class Title(BoldText): class TextInput(Editable): """ - A module that allows the user to input text. + A module that allows the user to input text. """ + H, V, BR = "━", "┃", "┛" padding: tuple[int, int] = (2, 1) border_color: Color active_color: Color word_wrap: bool = True - - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], node: Node, color: Optional[Color] = None, border: Optional[Color] = None, active: Optional[Color] = None, format: Optional[Callable]=None): + + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + node: Node, + color: Optional[Color] = None, + border: Optional[Color] = None, + active: Optional[Color] = None, + format: Optional[Callable] = None, + ): super().__init__(coords, dims, node=node, color=color, format=format) - self.width, self.height = (dims[1]-1, dims[0]-1) + self.width, self.height = (dims[1] - 1, dims[0] - 1) self.border_color = border or self.color self.active_color = active or self.color - + def activate(self): # change the border color to a highlight self._original_border_color = self.border_color self.border_color = self.active_color super().activate() - + def deactivate(self, save: bool = True): if self.active and hasattr(self, '_original_border_color'): self.border_color = self._original_border_color super().deactivate(save) - + def render(self) -> None: for i, line in enumerate(self._get_lines(str(self.value))): self.grid.addstr(i, 0, line, self.color.to_curses()) - + # # add border to bottom right like a drop shadow for x in range(self.width): self.grid.addch(self.height, x, self.H, self.border_color.to_curses()) @@ -600,17 +650,25 @@ class Button(Element): selected: bool = False highlight: Optional[Color] = None on_confirm: Optional[Callable] = None - - def __init__( self, coords: tuple[int, int], dims: tuple[int, int], value: Optional[Any] = "", color: Optional[Color] = None, highlight: Optional[Color] = None, on_confirm: Optional[Callable] = None): + + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + value: Optional[Any] = "", + color: Optional[Color] = None, + highlight: Optional[Color] = None, + on_confirm: Optional[Callable] = None, + ): super().__init__(coords, dims, value=value, color=color) self.highlight = highlight or self.color.sat(80) self.on_confirm = on_confirm - + def confirm(self): """Handle button confirmation.""" if self.on_confirm: self.on_confirm() - + def activate(self): """Make this module the active one; ie. editing or selected.""" self.active = True @@ -640,8 +698,9 @@ def input(self, key: Key): class RadioButton(Button): """A Button with an indicator that it is selected""" + ON, OFF = "●", "○" - + def render(self): super().render() icon = self.ON if self.selected else self.OFF @@ -650,6 +709,7 @@ def render(self): class CheckButton(RadioButton): """A Button with an indicator that it is selected""" + ON, OFF = "■", "□" @@ -663,13 +723,19 @@ class Contains(Renderable): last_render: float = 0 parent: Optional['Contains'] = None modules: list[Renderable] - - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], modules: list[Renderable], color: Optional[Color] = None): + + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + modules: list[Renderable], + color: Optional[Color] = None, + ): super().__init__(coords, dims, color=color) self.modules = [] for module in modules: self.append(module) - + def append(self, module: Renderable): module.parent = self self.modules.append(module) @@ -702,7 +768,8 @@ def destroy(self): class Box(Contains): """A container with a border""" - H, V, TL, TR, BL, BR = "─", "│", "┌", "┐", "└", "┘" + + H, V, TL, TR, BL, BR = "─", "│", "┌", "┐", "└", "┘" def render(self) -> None: w: int = self.width - 1 @@ -718,7 +785,7 @@ def render(self) -> None: self.grid.addch(h, 0, self.BL, self.color.to_curses()) self.grid.addch(0, w, self.TR, self.color.to_curses()) self.grid.addch(h, w, self.BR, self.color.to_curses()) - + for module in self.get_modules(): module.render() module.last_render = time.time() @@ -729,23 +796,27 @@ def render(self) -> None: class LightBox(Box): """A Box with light borders""" + pass class HeavyBox(Box): """A Box with heavy borders""" - H, V, TL, TR, BL, BR = "━", "┃", "┏", "┓", "┗", "┛" + + H, V, TL, TR, BL, BR = "━", "┃", "┏", "┓", "┗", "┛" class DoubleBox(Box): """A Box with double borders""" - H, V, TL, TR, BL, BR = "═", "║", "╔", "╗", "╚", "╝" + + H, V, TL, TR, BL, BR = "═", "║", "╔", "╗", "╚", "╝" class Select(Box): """ Build a select menu out of buttons. """ + UP, DOWN = "▲", "▼" on_change: Optional[Callable] = None on_select: Optional[Callable] = None @@ -753,14 +824,23 @@ class Select(Box): button_height: int = 3 show_up: bool = False show_down: bool = False - - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], options: list[str], color: Optional[Color] = None, highlight: Optional[Color] = None, on_change: Optional[Callable] = None, on_select: Optional[Callable] = None) -> None: + + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + options: list[str], + color: Optional[Color] = None, + highlight: Optional[Color] = None, + on_change: Optional[Callable] = None, + on_select: Optional[Callable] = None, + ) -> None: super().__init__(coords, dims, [], color=color) self.highlight = highlight or Color(0, 100, 100) self.options = options self.on_change = on_change self.on_select = on_select - + for i, option in enumerate(self.options): self.append(self._get_button(i, option)) self._mark_active(0) @@ -768,11 +848,11 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int], options: list def _get_button(self, index: int, option: str) -> Button: """Helper to create a button for an option""" return self.button_cls( - ((index * self.button_height) + 1, 1), - (self.button_height, self.width - 2), - value=option, - color=self.color, - highlight=self.highlight, + ((index * self.button_height) + 1, 1), + (self.button_height, self.width - 2), + value=option, + color=self.color, + highlight=self.highlight, ) def _mark_active(self, index: int): @@ -780,11 +860,11 @@ def _mark_active(self, index: int): for module in self.modules: assert hasattr(module, 'deactivate') module.deactivate() - + active = self.modules[index] assert hasattr(active, 'activate') active.activate() - + if self.on_change: self.on_change(index, self.options[index]) @@ -798,7 +878,7 @@ def _get_active_index(self): def get_modules(self): """Return a subset of modules to be rendered""" # since we can't always render all of the buttons, return a subset - # that can be displayed in the available height. + # that can be displayed in the available height. num_displayed = (self.height - 4) // self.button_height index = self._get_active_index() count = len(self.modules) @@ -826,13 +906,13 @@ def render(self): for module in self.modules: if module.last_render: module.grid.erase() - + self.grid.erase() if self.show_up: - self.grid.addstr(1, 1, self.UP.center(self.width-2), self.color.to_curses()) + self.grid.addstr(1, 1, self.UP.center(self.width - 2), self.color.to_curses()) if self.show_down: - self.grid.addstr(self.height - 2, 1, self.DOWN.center(self.width-2), self.color.to_curses()) - + self.grid.addstr(self.height - 2, 1, self.DOWN.center(self.width - 2), self.color.to_curses()) + super().render() def select(self, option: Button): @@ -847,10 +927,10 @@ def select(self, option: Button): def input(self, key: Key): """Handle key input event.""" index = self._get_active_index() - + if index is None: return - + if key.UP or key.DOWN: direction = -1 if key.UP else 1 index = direction + index @@ -859,9 +939,9 @@ def input(self, key: Key): self._mark_active(index) elif key.SPACE or key.ENTER: self.select(self.modules[index]) - + super().input(key) - + def click(self, y, x): # TODO there is a bug when you click on the last element in a scrollable list for module in self.modules: @@ -874,14 +954,26 @@ def click(self, y, x): class RadioSelect(Select): """Allow one button to be `selected` at a time""" + button_cls = RadioButton - def __init__(self, coords: tuple[int, int], dims: tuple[int, int], options: list[str], color: Optional[Color] = None, highlight: Optional[Color] = None, on_change: Optional[Callable] = None, on_select: Optional[Callable] = None) -> None: - super().__init__(coords, dims, options, color=color, highlight=highlight, on_change=on_change, on_select=on_select) + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + options: list[str], + color: Optional[Color] = None, + highlight: Optional[Color] = None, + on_change: Optional[Callable] = None, + on_select: Optional[Callable] = None, + ) -> None: + super().__init__( + coords, dims, options, color=color, highlight=highlight, on_change=on_change, on_select=on_select + ) self.select(self.modules[0]) # type: ignore[arg-type] def select(self, module: Button): - """Radio buttons only allow a single selection. """ + """Radio buttons only allow a single selection.""" for _module in self.modules: assert hasattr(_module, 'selected') _module.selected = False @@ -890,46 +982,48 @@ def select(self, module: Button): class MultiSelect(Select): """Allow multiple buttons to be `selected` at a time""" + button_cls = CheckButton class ColorWheel(Element): """ - A module used for testing color display. + A module used for testing color display. """ + width: int = 80 height: int = 24 - + def __init__(self, coords: tuple[int, int], duration: float = 10.0): super().__init__(coords, (self.height, self.width)) self.duration = duration self.start_time = time.time() - + def render(self) -> None: self.grid.erase() - center_y, center_x = 12, 22 + center_y, center_x = 12, 22 radius = 10 elapsed = time.time() - self.start_time hue_offset = (elapsed / self.duration) * 360 # animate - + for y in range(center_y - radius, center_y + radius + 1): for x in range(center_x - radius * 2, center_x + radius * 2 + 1): # Convert position to polar coordinates dx = (x - center_x) / 2 # Compensate for terminal character aspect ratio dy = y - center_y - distance = math.sqrt(dx*dx + dy*dy) - + distance = math.sqrt(dx * dx + dy * dy) + if distance <= radius: # Convert to HSV angle = math.degrees(math.atan2(dy, dx)) - #h = (angle + 360) % 360 + # h = (angle + 360) % 360 h = (angle + hue_offset) % 360 s = (distance / radius) * 100 - v = 100 # (distance / radius) * 100 - + v = 100 # (distance / radius) * 100 + color = Color(h, s, v) self.grid.addstr(y, x, "█", color.to_curses()) - + x = 50 y = 4 for i in range(0, curses.COLORS): @@ -940,15 +1034,16 @@ def render(self) -> None: x += 3 if x >= self.width - 3: break - + self.grid.refresh() class DebugElement(Element): """Show fps and color usage.""" + def __init__(self, coords: tuple[int, int]): super().__init__(coords, (1, 24)) - + def render(self) -> None: self.grid.addstr(0, 1, f"FPS: {1 / (time.time() - self.last_render):.0f}") self.grid.addstr(0, 10, f"Colors: {len(Color._color_map)}/{curses.COLORS}") @@ -968,7 +1063,7 @@ def __init__(self, app: 'App'): def init(self, dims: tuple[int, int]) -> None: self.height, self.width = dims self.modules = self.layout() - + if conf.DEBUG: self.append(DebugElement((1, 1))) @@ -976,6 +1071,7 @@ def init(self, dims: tuple[int, int]) -> None: def grid(self): if not self._grid: self._grid = curses.newwin(self.height, self.width, self.y, self.x) + self._grid.bkgd(' ', curses.color_pair(1)) return self._grid def layout(self) -> list[Renderable]: @@ -995,20 +1091,23 @@ class App: view: Optional[View] = None # the active view views: dict[str, type[View]] = {} shortcuts: dict[str, str] = {} - + def __init__(self, stdscr: curses.window) -> None: self.stdscr = stdscr self.height, self.width = self.stdscr.getmaxyx() # TODO dynamic resizing - + if not self.width >= self.min_width or not self.height >= self.min_height: - raise RenderException(f"Terminal window is too small. Resize to at least {self.min_width}x{self.min_height}.") - + raise RenderException( + f"Terminal window is too small. Resize to at least {self.min_width}x{self.min_height}." + ) + curses.curs_set(0) stdscr.nodelay(True) stdscr.timeout(10) # balance framerate with cpu usage curses.mousemask(curses.BUTTON1_CLICKED | curses.REPORT_MOUSE_POSITION) - + Color.initialize() + curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK) def add_view(self, name: str, view_cls: type[View], shortcut: Optional[str] = None) -> None: self.views[name] = view_cls @@ -1019,7 +1118,7 @@ def load(self, view_name: str): if self.view: self.view.destroy() self.view = None - + view_cls = self.views[view_name] self.view = view_cls(self) self.view.init((self.height, self.width)) @@ -1032,7 +1131,7 @@ def run(self): current_time = time.time() delta = current_time - last_frame ch = self.stdscr.getch() - + if ch == curses.KEY_MOUSE: try: _, x, y, _, bstate = curses.getmouse() @@ -1043,13 +1142,13 @@ def run(self): pass elif ch != -1: self.input(ch) - + if not App.editing: if ch == ord('q'): break elif ch in [ord(x) for x in self.shortcuts.keys()]: self.load(self.shortcuts[chr(ch)]) - + if delta >= self.frame_time or ch != -1: self.render() delta = 0 @@ -1087,29 +1186,32 @@ def click(self, y, x): def input(self, ch: int): """Handle key input event.""" key = Key(ch) - + if key.TAB: self._select_next_tabbable() - + if self.view: self.view.input(key) - + def _get_tabbable_modules(self): """ - Search through the tree of modules to find selectable elements. + Search through the tree of modules to find selectable elements. """ + def _get_activateable(module: Element): """Find modules with an `activate` method""" if hasattr(module, 'activate'): yield module for submodule in getattr(module, 'modules', []): yield from _get_activateable(submodule) + return list(_get_activateable(self.view)) def _select_next_tabbable(self): """ - Activate the next tabbable module in the list. + Activate the next tabbable module in the list. """ + def _get_active_module(module: Element): if hasattr(module, 'active') and module.active: return module @@ -1132,4 +1234,3 @@ def _get_active_module(module: Element): modules[next_index].activate() # TODO this isn't working elif modules: modules[0].activate() - From d5819a8ab00868ccc9aee648dd19b2a7ae9ae42d Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Tue, 28 Jan 2025 21:20:33 -0800 Subject: [PATCH 09/34] Deprecate `init --wizard` command. --- agentstack/cli/init.py | 8 +------- agentstack/main.py | 8 ++++++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index dea7e755..7e5b4f83 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -35,7 +35,6 @@ def init_project( slug_name: Optional[str] = None, template: Optional[str] = None, framework: Optional[str] = None, - use_wizard: bool = False, template_data: Optional[TemplateConfig] = None, ): """ @@ -60,12 +59,7 @@ def init_project( raise Exception(f"Directory already exists: {conf.PATH}") if not template_data: - if template and use_wizard: - raise Exception("Template and wizard flags cannot be used together") - - if use_wizard: - raise NotImplementedError("Run `agentstack wizard` to use the wizard") - elif template: + if template: log.debug(f"Initializing new project with template: {template}") template_data = TemplateConfig.from_user_input(template) else: diff --git a/agentstack/main.py b/agentstack/main.py index 9dcbe692..94bc840a 100644 --- a/agentstack/main.py +++ b/agentstack/main.py @@ -60,7 +60,7 @@ def _main(): "init", aliases=["i"], help="Initialize a directory for the project", parents=[global_parser] ) init_parser.add_argument("slug_name", nargs="?", help="The directory name to place the project in") - init_parser.add_argument("--wizard", "-w", action="store_true", help="Use the setup wizard") + init_parser.add_argument("--wizard", "-w", action="store_true", help="Use the setup wizard [deprecated]") init_parser.add_argument("--template", "-t", help="Agent template to use") init_parser.add_argument("--framework", "-f", help="Framework to use") @@ -178,7 +178,11 @@ def _main(): elif args.command in ["templates"]: webbrowser.open("https://docs.agentstack.sh/quickstart") elif args.command in ["init", "i"]: - init_project(args.slug_name, args.template, args.framework, args.wizard) + if args.wizard: + log.warning("init --wizard is deprecated. Use `agentstack wizard`") + wizard.main() + else: + init_project(args.slug_name, args.template, args.framework) elif args.command in ["wizard"]: wizard.main() elif args.command in ["tools", "t"]: From 56a9a2c887895a3ba8bc486bbc3ab198c0c0946f Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 29 Jan 2025 12:25:59 -0800 Subject: [PATCH 10/34] Bugfixes. --- agentstack/cli/wizard.py | 24 +++++++++++++----------- agentstack/tui.py | 24 ++++++++++++++++++------ 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index a5e054b4..558a5f88 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -43,7 +43,7 @@ class FieldColors(TypedDict): class LogoElement(Text): - #h_align = ALIGN_CENTER + # h_align = ALIGN_CENTER def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): super().__init__(coords, dims) @@ -68,8 +68,10 @@ def render(self) -> None: super().render() for i, (x, y) in enumerate(self.stars): try: + # TODO condition to prevent rendering out of bounds + # TODO fix centering self.grid.addch(y, x, '*', self._get_star_color(i).to_curses()) - #self.grid.addch(y, self.left_offset + x, '*', self._get_star_color(i).to_curses()) + # self.grid.addch(y, self.left_offset + x, '*', self._get_star_color(i).to_curses()) except curses.error: pass # overflow @@ -309,7 +311,6 @@ class FrameworkView(FormView): def __init__(self, app: 'App'): super().__init__(app) self.framework_key = Node() - self.framework_logo = Node() self.framework_name = Node() self.framework_description = Node() @@ -329,7 +330,6 @@ def set_framework_selection(self, index: int, value: str): 'description': "Unknown", } - self.framework_logo.value = data['name'] self.framework_name.value = data['name'] self.framework_description.value = data['description'] @@ -351,7 +351,7 @@ def submit(self): self.error("Framework is required.") return - self.app.state.update_active_project(framework=self.framework_key.value) + self.app.state.update_framework(self.framework_key.value) self.app.advance() def form(self) -> list[Renderable]: @@ -374,7 +374,7 @@ def form(self) -> list[Renderable]: (1, 3), (4, round(self.width / 2) - 10), color=COLOR_FORM.sat(40), - value=self.framework_logo, + value=self.framework_name, ), BoldText( (5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.framework_name @@ -680,7 +680,11 @@ def form(self) -> list[Renderable]: ], ), Button( - (self.height - 6, self.width - 17), (3, 15), "Back", color=COLOR_BUTTON, on_confirm=self.back + (self.height - 6, 2), + (3, 15), + "Back", + color=COLOR_BUTTON, + on_confirm=self.back, ), ] @@ -825,11 +829,9 @@ def create_project(self, name: str, description: str): 'name': name, 'description': description, } - self.active_project = name - def update_active_project(self, **kwargs): - for key, value in kwargs.items(): - self.project[key] = value + def update_framework(self, framework: str): + self.project['framework'] = framework def create_agent(self, name: str, role: str, goal: str, backstory: str): self.agents[name] = { diff --git a/agentstack/tui.py b/agentstack/tui.py index 590dbe0f..3baf596b 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -647,9 +647,9 @@ class Button(Element): h_align: str = ALIGN_CENTER v_align: str = ALIGN_MIDDLE active: bool = False - selected: bool = False highlight: Optional[Color] = None on_confirm: Optional[Callable] = None + on_activate: Optional[Callable] = None def __init__( self, @@ -659,10 +659,12 @@ def __init__( color: Optional[Color] = None, highlight: Optional[Color] = None, on_confirm: Optional[Callable] = None, + on_activate: Optional[Callable] = None, ): super().__init__(coords, dims, value=value, color=color) self.highlight = highlight or self.color.sat(80) self.on_confirm = on_confirm + self.on_activate = on_activate def confirm(self): """Handle button confirmation.""" @@ -676,6 +678,8 @@ def activate(self): self.color = self.highlight or self.color if hasattr(self.color, 'reset_animation'): self.color.reset_animation() + if self.on_activate: + self.on_activate(self.value) def deactivate(self, save: bool = True): """Deactivate this module, making it no longer active.""" @@ -700,6 +704,7 @@ class RadioButton(Button): """A Button with an indicator that it is selected""" ON, OFF = "●", "○" + selected: bool = False def render(self): super().render() @@ -853,8 +858,14 @@ def _get_button(self, index: int, option: str) -> Button: value=option, color=self.color, highlight=self.highlight, + on_activate=lambda value: self._button_on_activate(index, option), ) + def _button_on_activate(self, index: int, option: str): + """Callback for when a button is activated.""" + if self.on_change: + self.on_change(index, option) + def _mark_active(self, index: int): """Mark a submodule as active.""" for module in self.modules: @@ -862,8 +873,9 @@ def _mark_active(self, index: int): module.deactivate() active = self.modules[index] - assert hasattr(active, 'activate') - active.activate() + if not active.active: + assert hasattr(active, 'activate') + active.activate() if self.on_change: self.on_change(index, self.options[index]) @@ -873,14 +885,14 @@ def _get_active_index(self): for module in self.modules: if module.active: return self.modules.index(module) - return 0 + return None def get_modules(self): """Return a subset of modules to be rendered""" # since we can't always render all of the buttons, return a subset # that can be displayed in the available height. num_displayed = (self.height - 4) // self.button_height - index = self._get_active_index() + index = self._get_active_index() or 0 count = len(self.modules) if count <= num_displayed: @@ -929,7 +941,7 @@ def input(self, key: Key): index = self._get_active_index() if index is None: - return + return # can't select a non-active element if key.UP or key.DOWN: direction = -1 if key.UP else 1 From 7bed6f7ef061732efcae89021c8d69ec7b44673d Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 29 Jan 2025 14:00:14 -0800 Subject: [PATCH 11/34] Fix form alignment. Allow skipping adding a tool. Additional validation on agent and task creation. --- agentstack/cli/wizard.py | 118 +++++++++++++++++++++++++++++---------- agentstack/tui.py | 18 +++++- 2 files changed, 104 insertions(+), 32 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 558a5f88..7addb20c 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -134,11 +134,13 @@ class BannerView(WizardView): title = "Welcome to AgentStack" sparkle = "The easiest way to build a robust agent application." subtitle = "Let's get started!" - color = ColorAnimation( - start=Color(90, 0, 0), # TODO make this darker - end=Color(90), - duration=0.5, - ) + + def _get_color(self) -> Color: + return ColorAnimation( + start=Color(90, 0, 0), # TODO make this darker + end=Color(90, 90), + duration=0.6, + ) def layout(self) -> list[Renderable]: buttons = [] @@ -212,12 +214,12 @@ def layout(self) -> list[Renderable]: (9, round(self.width / 2)), color=COLOR_BORDER, modules=[ - Title((1, 1), (2, round(self.width / 2) - 2), color=self.color, value=self.title), - Title( - (3, 1), (2, round(self.width / 2) - 2), color=self.color, value=self.sparkle + BoldText((1, 2), (2, round(self.width / 2) - 3), color=self._get_color(), value=self.title), + WrappedText( + (3, 2), (3, round(self.width / 2) - 3), color=self._get_color(), value=self.sparkle ), - Title( - (5, 1), (2, round(self.width / 2) - 2), color=self.color, value=self.subtitle + WrappedText( + (6, 2), (2, round(self.width / 2) - 3), color=self._get_color(), value=self.subtitle ), ], ), @@ -293,10 +295,10 @@ def submit(self): def form(self) -> list[Renderable]: return [ - Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((12, 13), (2, self.width - 15), self.project_name, **FIELD_COLORS), - Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), - TextInput((14, 13), (5, self.width - 15), self.project_description, **FIELD_COLORS), + Text((12, 2), (1, 12), color=COLOR_FORM, value="Name"), + TextInput((12, 14), (2, self.width - 15), self.project_name, **FIELD_COLORS), + Text((14, 2), (1, 12), color=COLOR_FORM, value="Description"), + TextInput((14, 14), (5, self.width - 15), self.project_description, **FIELD_COLORS), ] @@ -407,16 +409,25 @@ def __init__(self, app: 'App'): self.agent_backstory = Node() def submit(self): - if not self.agent_name.value: + agent_name = self.agent_name.value + if not agent_name: self.error("Name is required.") return - if not is_snake_case(self.agent_name.value): + if not is_snake_case(agent_name): self.error("Name must be in snake_case.") return + if agent_name in self.app.state.agents.keys(): + self.error("Agent name must be unique.") + return + + if agent_name in self.app.state.tasks.keys(): + self.error("Agent name cannot match a task name.") + return + self.app.state.create_agent( - name=self.agent_name.value, + name=agent_name, role=self.agent_role.value, goal=self.agent_goal.value, backstory=self.agent_backstory.value, @@ -586,6 +597,9 @@ def submit(self): self.app.state.tool_category = self.tool_category_key.value self.app.advance() + def skip(self): + self.app.advance(steps=2) + def form(self) -> list[Renderable]: return [ RadioSelect( @@ -616,6 +630,13 @@ def form(self) -> list[Renderable]: ), ], ), + Button( + (self.height - 6, 2), + (3, 15), + "Skip", + color=COLOR_BUTTON, + on_confirm=self.skip, + ), ] @@ -705,16 +726,25 @@ def __init__(self, app: 'App'): self.expected_output = Node() def submit(self): - if not self.task_name.value: + task_name = self.task_name.value + if not self.task_name: self.error("Task name is required.") return - if not is_snake_case(self.task_name.value): + if not is_snake_case(task_name): self.error("Task name must be in snake_case.") return + if task_name in self.app.state.tasks.keys(): + self.error("Task name must be unique.") + return + + if task_name in self.app.state.agents.keys(): + self.error("Task name cannot match an agent name.") + return + self.app.state.create_task( - name=self.task_name.value, + name=task_name, description=self.task_description.value, expected_output=self.expected_output.value, ) @@ -736,20 +766,29 @@ class AgentSelectionView(FormView): def __init__(self, app: 'App'): super().__init__(app) + self.agent_key = Node() self.agent_name = Node() + self.agent_llm = Node() + self.agent_description = Node() - def set_agent_choice(self, index: int, value: str): + def set_agent_selection(self, index: int, value: str): + agent_data = self.app.state.agents[value] self.agent_name.value = value + self.agent_llm.value = agent_data['llm'] + self.agent_description.value = agent_data['role'] + + def set_agent_choice(self, index: int, value: str): + self.agent_key.value = value def get_agent_options(self) -> list[str]: return list(self.app.state.agents.keys()) def submit(self): - if not self.agent_name.value: + if not self.agent_key.value: self.error("Agent is required.") return - self.app.state.update_active_task(agent=self.agent_name.value) + self.app.state.update_active_task(agent=self.agent_key.value) self.app.advance() def form(self) -> list[Renderable]: @@ -760,9 +799,31 @@ def form(self) -> list[Renderable]: options=self.get_agent_options(), color=COLOR_FORM_BORDER, highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), + on_change=self.set_agent_selection, on_select=self.set_agent_choice, ), - # TODO agent info pane + Box( + (12, round(self.width / 2)), + (self.height - 18, round(self.width / 2) - 3), + color=COLOR_FORM_BORDER, + modules=[ + ASCIIText( + (1, 3), + (4, round(self.width / 2) - 10), + color=COLOR_FORM.sat(40), + value=self.agent_name, + ), + BoldText( + (5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.agent_llm + ), + WrappedText( + (7, 3), + (5, round(self.width / 2) - 10), + color=COLOR_FORM.sat(50), + value=self.agent_description, + ), + ], + ), ] @@ -983,19 +1044,16 @@ def finish(self): log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") self._finish_run_once = False - def advance(self): + def advance(self, steps: int = 1): """Load the next view in the active workflow.""" workflow = self.workflow[self.active_workflow] current_index = workflow.index(self.active_view) - view = workflow[current_index + 1] + view = workflow[current_index + steps] self.load(view, workflow=self.active_workflow) def back(self): """Load the previous view in the active workflow.""" - workflow = self.workflow[self.active_workflow] - current_index = workflow.index(self.active_view) - view = workflow[current_index - 1] - self.load(view, workflow=self.active_workflow) + return self.advance(-1) def load(self, view: str, workflow: Optional[str] = None): """Load a view from a workflow.""" diff --git a/agentstack/tui.py b/agentstack/tui.py index 3baf596b..985412fe 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -250,6 +250,8 @@ def initialize(cls) -> None: class ColorAnimation(Color): start: Color end: Color + reversed: bool = False + bold: bool = False duration: float loop: bool _start_time: float @@ -266,6 +268,18 @@ def reset_animation(self): self._start_time = time.time() def to_curses(self) -> int: + if self.reversed: + self.start.reversed = True + self.end.reversed = True + elif self.start.reversed: + self.reversed = True + + if self.bold: + self.start.bold = True + self.end.bold = True + elif self.start.bold: + self.bold = True + elapsed = time.time() - self._start_time if elapsed > self.duration: if self.loop: @@ -290,7 +304,7 @@ def to_curses(self) -> int: s = self.start.s + t * (self.end.s - self.start.s) v = self.start.v + t * (self.end.v - self.start.v) - return Color(h, s, v, reversed=self.start.reversed).to_curses() + return Color(h, s, v, reversed=self.reversed, bold=self.bold).to_curses() class Renderable: @@ -858,7 +872,7 @@ def _get_button(self, index: int, option: str) -> Button: value=option, color=self.color, highlight=self.highlight, - on_activate=lambda value: self._button_on_activate(index, option), + on_activate=lambda _: self._button_on_activate(index, option), ) def _button_on_activate(self, index: int, option: str): From 71523534bc0119881a0d4be4f8b3a83c4b5e061f Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 29 Jan 2025 14:03:22 -0800 Subject: [PATCH 12/34] Fix type checking. --- agentstack/cli/wizard.py | 3 +++ agentstack/tui.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 7addb20c..d6cf1676 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -1046,6 +1046,9 @@ def finish(self): def advance(self, steps: int = 1): """Load the next view in the active workflow.""" + assert self.active_workflow, "No active workflow set." + assert self.active_view, "No active view set." + workflow = self.workflow[self.active_workflow] current_index = workflow.index(self.active_view) view = workflow[current_index + steps] diff --git a/agentstack/tui.py b/agentstack/tui.py index 985412fe..8ab42c02 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -661,6 +661,7 @@ class Button(Element): h_align: str = ALIGN_CENTER v_align: str = ALIGN_MIDDLE active: bool = False + selected: bool = False highlight: Optional[Color] = None on_confirm: Optional[Callable] = None on_activate: Optional[Callable] = None @@ -887,6 +888,7 @@ def _mark_active(self, index: int): module.deactivate() active = self.modules[index] + assert hasattr(active, 'active') if not active.active: assert hasattr(active, 'activate') active.activate() From 4bb0b3eed3003e889fa5f6dac89de846c944fd36 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 29 Jan 2025 14:52:57 -0800 Subject: [PATCH 13/34] Define human readable tool categories. --- agentstack/_tools/__init__.py | 19 ++++++++++ agentstack/_tools/categories.json | 46 ++++++++++++++++++++++ agentstack/cli/wizard.py | 63 +++++++++++++++---------------- agentstack/tui.py | 2 + tests/test_tool_config.py | 26 ++++++++++++- 5 files changed, 123 insertions(+), 33 deletions(-) create mode 100644 agentstack/_tools/categories.json diff --git a/agentstack/_tools/__init__.py b/agentstack/_tools/__init__.py index 7ab5e51e..daf43bf2 100644 --- a/agentstack/_tools/__init__.py +++ b/agentstack/_tools/__init__.py @@ -13,6 +13,12 @@ TOOLS_CONFIG_FILENAME: str = 'config.json' +class ToolCategory(pydantic.BaseModel): + name: str + title: str # human readable title + description: str + + class ToolConfig(pydantic.BaseModel): """ This represents the configuration data for a tool. @@ -100,6 +106,19 @@ def module(self) -> ModuleType: ) +def get_all_tool_categories() -> list[ToolCategory]: + categories = [] + filename = TOOLS_DIR / 'categories.json' + data = open_json_file(filename) + for name, category in data.items(): + categories.append(ToolCategory(name=name, **category)) + return categories + + +def get_all_tool_category_names() -> list[str]: + return [category.name for category in get_all_tool_categories()] + + def get_all_tool_paths() -> list[Path]: """ Get all the paths to the tool configuration files. diff --git a/agentstack/_tools/categories.json b/agentstack/_tools/categories.json new file mode 100644 index 00000000..17aee3b4 --- /dev/null +++ b/agentstack/_tools/categories.json @@ -0,0 +1,46 @@ +{ + "application-specific": { + "title": "Application Specific", + "description": "Tools that are specific to a particular application or domain." + }, + "browsing": { + "title": "Browsing", + "description": "Tools that are used to browse the web." + }, + "code-execution": { + "title": "Code Execution", + "description": "Tools that are used to execute code." + }, + "computer-control": { + "title": "Computer Control", + "description": "Tools that are used to control a computer." + }, + "database": { + "title": "Database", + "description": "Tools that are used to interact with databases." + }, + "image-analysis": { + "title": "Image Analysis", + "description": "Tools that are used to analyze images." + }, + "network-protocols": { + "title": "Network Protocols", + "description": "Tools that are used to interact with network protocols." + }, + "search": { + "title": "Search", + "description": "Tools that are used to search for information." + }, + "storage": { + "title": "Storage", + "description": "Tools that are used to interact with storage." + }, + "unified-apis": { + "title": "Unified APIs", + "description": "Tools that provide a unified API for interacting with multiple services." + }, + "web-retrieval": { + "title": "Web Retrieval", + "description": "Tools that are used to retrieve information from the web." + } +} diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index d6cf1676..d8b0dec4 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -12,7 +12,12 @@ from agentstack.utils import is_snake_case from agentstack.tui import * from agentstack.frameworks import SUPPORTED_FRAMEWORKS, CREWAI, LANGGRAPH -from agentstack._tools import get_all_tools, get_tool +from agentstack._tools import ( + get_all_tools, + get_tool, + get_all_tool_categories, + get_all_tool_category_names, +) from agentstack.proj_templates import TemplateConfig from agentstack.cli import LOGO, init_project @@ -552,13 +557,6 @@ def form(self) -> list[Renderable]: class ToolCategoryView(FormView): title = "Select a Tool Category" - # TODO category descriptions for all valid categories - TOOL_CATEGORY_OPTIONS = { - "web": {'name': "Web Tools", 'description': "Tools that interact with the web."}, - "file": {'name': "File Tools", 'description': "Tools that interact with the file system."}, - "code": {'name': "Code Tools", 'description': "Tools that interact with code."}, - } - def __init__(self, app: 'App'): super().__init__(app) self.tool_category_key = Node() @@ -566,29 +564,19 @@ def __init__(self, app: 'App'): self.tool_category_description = Node() def set_tool_category_selection(self, index: int, value: str): - key, data = None, None - for _key, _value in self.TOOL_CATEGORY_OPTIONS.items(): - if _value['name'] == value: # search by name - key = _key - data = _value + tool_category = None + for _tool_category in get_all_tool_categories(): + if _tool_category.name == value: # search by name + tool_category = _tool_category break - if not key or not data: - key = value - data = { - 'name': "Unknown", - 'description': "Unknown", - } - - self.tool_category_name.value = data['name'] - self.tool_category_description.value = data['description'] + if tool_category: + self.tool_category_name.value = tool_category.title + self.tool_category_description.value = tool_category.description def set_tool_category_choice(self, index: int, value: str): self.tool_category_key.value = value - def get_tool_category_options(self) -> list[str]: - return sorted(list({tool.category for tool in get_all_tools()})) - def submit(self): if not self.tool_category_key.value: self.error("Tool category is required.") @@ -605,7 +593,7 @@ def form(self) -> list[Renderable]: RadioSelect( (11, 1), (self.height - 18, round(self.width / 2) - 3), - options=self.get_tool_category_options(), + options=get_all_tool_category_names(), color=COLOR_FORM_BORDER, highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), on_change=self.set_tool_category_selection, @@ -616,14 +604,17 @@ def form(self) -> list[Renderable]: (self.height - 18, round(self.width / 2) - 3), color=COLOR_FORM_BORDER, modules=[ - BoldText( + ASCIIText( (1, 3), - (1, round(self.width / 2) - 10), - color=COLOR_FORM, + (4, round(self.width / 2) - 10), + color=COLOR_FORM.sat(40), value=self.tool_category_name, ), + BoldText( + (5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.tool_category_name + ), WrappedText( - (2, 3), + (7, 3), (5, round(self.width / 2) - 10), color=COLOR_FORM.sat(50), value=self.tool_category_description, @@ -691,9 +682,17 @@ def form(self) -> list[Renderable]: (self.height - 18, round(self.width / 2) - 3), color=COLOR_FORM_BORDER, modules=[ - BoldText((1, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.tool_name), + ASCIIText( + (1, 3), + (4, round(self.width / 2) - 10), + color=COLOR_FORM.sat(40), + value=self.tool_name, + ), + BoldText( + (5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.tool_name + ), WrappedText( - (2, 3), + (7, 3), (5, round(self.width / 2) - 10), color=COLOR_FORM.sat(50), value=self.tool_description, diff --git a/agentstack/tui.py b/agentstack/tui.py index 8ab42c02..4e2627f5 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -1156,6 +1156,8 @@ def run(self): last_frame = time.time() self._running = True while self._running: + self.height, self.width = self.stdscr.getmaxyx() + current_time = time.time() delta = current_time - last_frame ch = self.stdscr.getch() diff --git a/tests/test_tool_config.py b/tests/test_tool_config.py index bf187e44..bc2c3f67 100644 --- a/tests/test_tool_config.py +++ b/tests/test_tool_config.py @@ -2,7 +2,16 @@ import unittest import re from pathlib import Path -from agentstack._tools import ToolConfig, get_all_tool_paths, get_all_tool_names +from agentstack.exceptions import ValidationError +from agentstack._tools import ( + ToolConfig, + get_all_tools, + get_all_tool_paths, + get_all_tool_names, + ToolCategory, + get_all_tool_categories, + get_all_tool_category_names, +) BASE_PATH = Path(__file__).parent @@ -43,6 +52,17 @@ def test_dependency_versions(self): "All dependencies must include version specifications." ) + def test_tool_category(self): + categories = get_all_tool_categories() + assert categories + for category in categories: + assert category.name in get_all_tool_category_names() + assert isinstance(category, ToolCategory) + + def test_all_tools_have_valid_categories(self): + for tool_config in get_all_tools(): + assert tool_config.category in get_all_tool_category_names() + def test_all_json_configs_from_tool_name(self): for tool_name in get_all_tool_names(): config = ToolConfig.from_tool_name(tool_name) @@ -60,3 +80,7 @@ def test_all_json_configs_from_tool_path(self): ) assert config.name == path.stem + + def test_get_missing_tool(self): + with self.assertRaises(ValidationError): + ToolConfig.from_tool_name("missing_tool") \ No newline at end of file From 257b393744411f68ef1712aa5f52d20c8f26d1e9 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 29 Jan 2025 15:34:33 -0800 Subject: [PATCH 14/34] Move preferred models to new `providers` module with validation and human readable descriptions. Move LLM parsing from `frameworks` to `providers`. Add tests. Update CLI and wizard to use providers. --- agentstack/agents.py | 6 +-- agentstack/cli/cli.py | 10 +--- agentstack/cli/wizard.py | 45 +++--------------- agentstack/frameworks/__init__.py | 13 ------ agentstack/frameworks/crewai.py | 9 ---- agentstack/frameworks/langgraph.py | 9 ---- agentstack/providers.py | 73 ++++++++++++++++++++++++++++++ tests/test_providers.py | 42 +++++++++++++++++ 8 files changed, 127 insertions(+), 80 deletions(-) create mode 100644 agentstack/providers.py create mode 100644 tests/test_providers.py diff --git a/agentstack/agents.py b/agentstack/agents.py index 181ee2d7..fcec5163 100644 --- a/agentstack/agents.py +++ b/agentstack/agents.py @@ -5,7 +5,7 @@ from ruamel.yaml import YAML, YAMLError from ruamel.yaml.scalarstring import FoldedScalarString from agentstack import conf, log -from agentstack import frameworks +from agentstack.providers import parse_provider_model from agentstack.exceptions import ValidationError @@ -71,11 +71,11 @@ def __init__(self, name: str): @property def provider(self) -> str: - return frameworks.parse_llm(self.llm)[0] + return parse_provider_model(self.llm)[0] @property def model(self) -> str: - return frameworks.parse_llm(self.llm)[1] + return parse_provider_model(self.llm)[1] @property def prompt(self) -> str: diff --git a/agentstack/cli/cli.py b/agentstack/cli/cli.py index f505bc99..ebe705ac 100644 --- a/agentstack/cli/cli.py +++ b/agentstack/cli/cli.py @@ -4,15 +4,9 @@ from agentstack.conf import ConfigFile from agentstack.exceptions import ValidationError from agentstack.utils import validator_not_empty, is_snake_case +from agentstack import providers -PREFERRED_MODELS = [ - 'openai/gpt-4o', - 'anthropic/claude-3-5-sonnet', - 'openai/o1-preview', - 'openai/gpt-4-turbo', - 'anthropic/claude-3-opus', -] LOGO = """\ ___ ___ ___ ___ ___ ___ ___ ___ ___ ___ /\ \ /\ \ /\ \ /\__\ /\ \ /\ \ /\ \ /\ \ /\ \ /\__\ @@ -46,7 +40,7 @@ def configure_default_model(): other_msg = "Other (enter a model name)" model = inquirer.list_input( message="Which model would you like to use?", - choices=PREFERRED_MODELS + [other_msg], + choices=providers.get_preferred_model_ids() + [other_msg], ) if model == other_msg: # If the user selects "Other", prompt for a model name diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index d8b0dec4..533ebd9f 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -12,6 +12,7 @@ from agentstack.utils import is_snake_case from agentstack.tui import * from agentstack.frameworks import SUPPORTED_FRAMEWORKS, CREWAI, LANGGRAPH +from agentstack import providers from agentstack._tools import ( get_all_tools, get_tool, @@ -455,41 +456,9 @@ def form(self) -> list[Renderable]: class ModelView(FormView): title = "Select a Model" - MODEL_OPTIONS = [ - { - 'value': "anthropic/claude-3.5-sonnet", - 'name': "Claude 3.5 Sonnet", - 'provider': "Anthropic", - 'description': "A fast and cost-effective model.", - }, - { - 'value': "gpt-3.5-turbo", - 'name': "GPT-3.5 Turbo", - 'provider': "OpenAI", - 'description': "A fast and cost-effective model.", - }, - { - 'value': "gpt-4", - 'name': "GPT-4", - 'provider': "OpenAI", - 'description': "A more advanced model with better understanding.", - }, - { - 'value': "gpt-4o", - 'name': "GPT-4o", - 'provider': "OpenAI", - 'description': "The latest and most powerful model.", - }, - { - 'value': "gpt-4o-mini", - 'name': "GPT-4o Mini", - 'provider': "OpenAI", - 'description': "A smaller, faster version of GPT-4o.", - }, - ] - def __init__(self, app: 'App'): super().__init__(app) + self.MODEL_CHOICES = providers.get_preferred_models() self.model_choice = Node() self.model_logo = Node() self.model_name = Node() @@ -497,10 +466,10 @@ def __init__(self, app: 'App'): def set_model_selection(self, index: int, value: str): """Update the content of the model info box.""" - model = self.MODEL_OPTIONS[index] - self.model_logo.value = model['provider'] - self.model_name.value = model['name'] - self.model_description.value = model['description'] + model = self.MODEL_CHOICES[index] + self.model_logo.value = model.provider + self.model_name.value = model.name + self.model_description.value = model.description def set_model_choice(self, index: int, value: str): """Save the selection.""" @@ -508,7 +477,7 @@ def set_model_choice(self, index: int, value: str): self.model_choice.value = value def get_model_options(self): - return [model['value'] for model in self.MODEL_OPTIONS] + return providers.get_preferred_model_ids() def submit(self): if not self.model_choice.value: diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index 6f738b7c..6173edfe 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -40,12 +40,6 @@ def validate_project(self) -> None: """ ... - def parse_llm(self, llm: str) -> tuple[str, str]: - """ - Parse a language model string into a provider and model. - """ - ... - def add_tool(self, tool: ToolConfig, agent_name: str) -> None: """ Add a tool to an agent in the user's project. @@ -125,13 +119,6 @@ def validate_project(): return get_framework_module(get_framework()).validate_project() -def parse_llm(llm: str) -> tuple[str, str]: - """ - Parse a language model string into a provider and model. - """ - return get_framework_module(get_framework()).parse_llm(llm) - - def add_tool(tool: ToolConfig, agent_name: str): """ Add a tool to the user's project. diff --git a/agentstack/frameworks/crewai.py b/agentstack/frameworks/crewai.py index 1b437d9b..9ed64c1d 100644 --- a/agentstack/frameworks/crewai.py +++ b/agentstack/frameworks/crewai.py @@ -230,15 +230,6 @@ def validate_project() -> None: ) -def parse_llm(llm: str) -> tuple[str, str]: - """ - Parse the llm string into a `LLM` dataclass. - Crew separates providers and models with a forward slash. - """ - provider, model = llm.split('/') - return provider, model - - def get_task_method_names() -> list[str]: """ Get a list of task names (methods with an @task decorator). diff --git a/agentstack/frameworks/langgraph.py b/agentstack/frameworks/langgraph.py index d3e6a38e..cffba0a6 100644 --- a/agentstack/frameworks/langgraph.py +++ b/agentstack/frameworks/langgraph.py @@ -528,15 +528,6 @@ def validate_project() -> None: ) -def parse_llm(llm: str) -> tuple[str, str]: - """ - Parse a language model string into a provider and model. - LangGraph separates providers and models with a forward slash. - """ - provider, model = llm.split('/') - return provider, model - - def get_task_method_names() -> list[str]: """ Get a list of task names (methods with an @task decorator). diff --git a/agentstack/providers.py b/agentstack/providers.py new file mode 100644 index 00000000..e54c1eab --- /dev/null +++ b/agentstack/providers.py @@ -0,0 +1,73 @@ +from typing import Optional +import re +import pydantic +from agentstack.exceptions import ValidationError + +# model ids follow LiteLLM format +PREFERRED_MODELS = { + 'groq/deepseek-r1-distill-llama-70b': { + 'name': "DeepSeek R1 Distill Llama 70B", + 'provider': "Groq", + 'description': "The Groq DeepSeek R1 Distill Llama 70B model", + }, + 'openai/o1-preview': { + 'name': "o1 Preview", + 'provider': "OpenAI", + 'description': "The OpenAI o1 Preview model", + }, + 'anthropic/claude-3-5-sonnet': { + 'name': "Claude 3.5 Sonnet", + 'provider': "Anthropic", + 'description': "The Anthropic Claude 3.5 Sonnet model", + }, + 'deepseek/deepseek-reasoner': { + 'name': "DeepSeek Reasoner", + 'provider': "DeepSeek", + 'description': "The DeepSeek Reasoner model hosted by DeepSeek", + }, + 'openrouter/deepseek/deepseek-r1': { + 'name': "DeepSeek R1", + 'provider': "OpenRouter", + 'description': "The DeepSeek R1 model hosted by OpenRouter", + }, + 'openai/gpt-4o': { + 'name': "GPT-4o", + 'provider': "OpenAI", + 'description': "The OpenAI GPT-4o model", + }, + 'anthropic/claude-3-opus': { + 'name': "Claude 3 Opus", + 'provider': "Anthropic", + 'description': "The Anthropic Claude 3 Opus model", + }, +} + + +def parse_provider_model(model_id: str) -> tuple[str, str]: + """Parse the provider and model name from the model ID""" + # most providers are in the format "/" + # openrouter models are in the format "openrouter//" + parts = tuple(model_id.split('/')) + if len(parts) == 2: + return parts + elif len(parts) == 3: + return '/'.join(parts[:2]), parts[2] + else: + raise ValidationError(f"Model id '{model_id}' does not match expected format.") + + +class ProviderConfig(pydantic.BaseModel): + id: str + name: Optional[str] + provider: Optional[str] + description: Optional[str] + provider = property(lambda self: parse_provider_model(self.id)[0]) + model = property(lambda self: parse_provider_model(self.id)[1]) + + +def get_preferred_models() -> list[ProviderConfig]: + return [ProviderConfig(id=model_id, **model) for model_id, model in PREFERRED_MODELS.items()] + + +def get_preferred_model_ids() -> list[str]: + return [model.id for model in get_preferred_models()] diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 00000000..e89b39e4 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,42 @@ +import unittest +from agentstack.exceptions import ValidationError +from agentstack.providers import ( + PREFERRED_MODELS, + ProviderConfig, + parse_provider_model, + get_preferred_model_ids, + get_preferred_models, +) + + +class ProvidersTest(unittest.TestCase): + def test_parse_provider_model(self): + cases = [ + "deepseek/deepseek-reasoner", + "openrouter/deepseek/deepseek-r1", + "openai/gpt-4o", + "anthropic/claude-3-opus", + "provider/model", + ] + expected = [ + ("deepseek", "deepseek-reasoner"), + ("openrouter/deepseek", "deepseek-r1"), + ("openai", "gpt-4o"), + ("anthropic", "claude-3-opus"), + ("provider", "model"), + ] + for case, expect in zip(cases, expected): + self.assertEqual(parse_provider_model(case), expect) + + def test_invalid_provider_model(self): + with self.assertRaises(ValidationError): + parse_provider_model("invalid_provider_model") + + def test_all_preferred_provider_config(self): + for model in get_preferred_models(): + self.assertIsInstance(model, ProviderConfig) + + def test_all_preferred_model_ids(self): + for model_id in get_preferred_model_ids(): + self.assertIsInstance(model_id, str) + From 935d1d2dd2af54d250164e8036389a3230cdd9c9 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 29 Jan 2025 15:54:57 -0800 Subject: [PATCH 15/34] Wizard model selection bugfix. Added tests to ensure frameworks can add agents with all recommended models. Add Groq and DeepSeek configs for LangGraph projects. --- agentstack/cli/wizard.py | 2 +- agentstack/frameworks/langgraph.py | 12 ++++++++++- agentstack/providers.py | 34 ++++++++++++++++-------------- tests/test_frameworks.py | 9 ++++++++ 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 533ebd9f..501dd7fa 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -467,7 +467,7 @@ def __init__(self, app: 'App'): def set_model_selection(self, index: int, value: str): """Update the content of the model info box.""" model = self.MODEL_CHOICES[index] - self.model_logo.value = model.provider + self.model_logo.value = model.host self.model_name.value = model.name self.model_description.value = model.description diff --git a/agentstack/frameworks/langgraph.py b/agentstack/frameworks/langgraph.py index cffba0a6..d307f531 100644 --- a/agentstack/frameworks/langgraph.py +++ b/agentstack/frameworks/langgraph.py @@ -66,6 +66,16 @@ class LangGraphProvider: module_name='langchain_ollama.chat_models', dependency='langchain-ollama', ), + 'groq': LangGraphProvider( + class_name='ChatGroq', + module_name='langchain_groq', + dependency='langchain-groq', + ), + 'deepseek': LangGraphProvider( + class_name='ChatDeepSeek', + module_name='langchain_deepseek', + dependency='langchain-deepseek-official', + ), } @@ -636,7 +646,7 @@ def add_agent(agent: AgentConfig, position: Optional[InsertionPoint] = None) -> packaging.install(provider.dependency) except KeyError: raise ValidationError( - f"LangGraph provider '{provider}' has not been implemented. " + f"LangGraph provider '{agent.provider}' has not been implemented. " f"AgentStack currently supports: {', '.join(PROVIDERS.keys())} " ) diff --git a/agentstack/providers.py b/agentstack/providers.py index e54c1eab..d5c970aa 100644 --- a/agentstack/providers.py +++ b/agentstack/providers.py @@ -7,37 +7,39 @@ PREFERRED_MODELS = { 'groq/deepseek-r1-distill-llama-70b': { 'name': "DeepSeek R1 Distill Llama 70B", - 'provider': "Groq", + 'host': "Groq", 'description': "The Groq DeepSeek R1 Distill Llama 70B model", }, + 'deepseek/deepseek-reasoner': { + 'name': "DeepSeek Reasoner", + 'host': "DeepSeek", + 'description': "The DeepSeek Reasoner model hosted by DeepSeek", + }, 'openai/o1-preview': { 'name': "o1 Preview", - 'provider': "OpenAI", + 'host': "OpenAI", 'description': "The OpenAI o1 Preview model", }, 'anthropic/claude-3-5-sonnet': { 'name': "Claude 3.5 Sonnet", - 'provider': "Anthropic", + 'host': "Anthropic", 'description': "The Anthropic Claude 3.5 Sonnet model", }, - 'deepseek/deepseek-reasoner': { - 'name': "DeepSeek Reasoner", - 'provider': "DeepSeek", - 'description': "The DeepSeek Reasoner model hosted by DeepSeek", - }, - 'openrouter/deepseek/deepseek-r1': { - 'name': "DeepSeek R1", - 'provider': "OpenRouter", - 'description': "The DeepSeek R1 model hosted by OpenRouter", - }, + # TODO there is no publicly available OpenRouter implementation for + # LangChain, so we can't recommend this yet. + # 'openrouter/deepseek/deepseek-r1': { + # 'name': "DeepSeek R1", + # 'host': "OpenRouter", + # 'description': "The DeepSeek R1 model hosted by OpenRouter", + # }, 'openai/gpt-4o': { 'name': "GPT-4o", - 'provider': "OpenAI", + 'host': "OpenAI", 'description': "The OpenAI GPT-4o model", }, 'anthropic/claude-3-opus': { 'name': "Claude 3 Opus", - 'provider': "Anthropic", + 'host': "Anthropic", 'description': "The Anthropic Claude 3 Opus model", }, } @@ -59,7 +61,7 @@ def parse_provider_model(model_id: str) -> tuple[str, str]: class ProviderConfig(pydantic.BaseModel): id: str name: Optional[str] - provider: Optional[str] + host: Optional[str] description: Optional[str] provider = property(lambda self: parse_provider_model(self.id)[0]) model = property(lambda self: parse_provider_model(self.id)[1]) diff --git a/tests/test_frameworks.py b/tests/test_frameworks.py index 2952d916..44044bb3 100644 --- a/tests/test_frameworks.py +++ b/tests/test_frameworks.py @@ -11,6 +11,7 @@ from agentstack._tools import ToolConfig, get_all_tools from agentstack.agents import AGENTS_FILENAME, AgentConfig from agentstack.tasks import TASKS_FILENAME, TaskConfig +from agentstack.providers import get_preferred_model_ids from agentstack import graph BASE_PATH = Path(__file__).parent @@ -104,6 +105,14 @@ def test_get_agent_tool_names(self): tool_names = frameworks.get_agent_tool_names('agent_name') assert tool_names == ['test_tool'] + @parameterized.expand([(x, ) for x in get_preferred_model_ids()]) + def test_add_agent_preferred_models(self, llm: str): + """Test adding an Agent to the graph with all preferred models we support""" + self._populate_min_entrypoint() + agent = self._get_test_agent() + agent.llm = llm + frameworks.add_agent(agent) + def test_add_tool(self): self._populate_max_entrypoint() frameworks.add_tool(self._get_test_tool(), 'agent_name') From 3d1cefa14192188440125ae7a00f06bb83e08c3e Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 29 Jan 2025 17:27:43 -0800 Subject: [PATCH 16/34] Better colors. --- agentstack/cli/wizard.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 501dd7fa..da425e65 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -26,11 +26,12 @@ COLOR_BORDER = Color(90) COLOR_MAIN = Color(220) COLOR_TITLE = Color(220, 100, 40, reversed=True) -COLOR_ERROR = Color(0) +COLOR_ERROR = Color(0, 50) +COLOR_BANNER = Color(80, 80, 80) COLOR_FORM = Color(300) -COLOR_FORM_BORDER = Color(300, 50) -COLOR_BUTTON = Color(300, reversed=True) -COLOR_FIELD_BG = Color(240, 20, 100, reversed=True) +COLOR_FORM_BORDER = Color(300, 80) +COLOR_BUTTON = Color(300, 100, 80, reversed=True) +COLOR_FIELD_BG = Color(300, 50, 50, reversed=True) COLOR_FIELD_BORDER = Color(300, 100, 50) COLOR_FIELD_ACTIVE = Color(300, 80) @@ -118,7 +119,7 @@ def render(self) -> None: class HelpText(Text): def __init__(self, coords: tuple[int, int], dims: tuple[int, int]) -> None: super().__init__(coords, dims) - self.color = Color(0, 0, 50) + self.color = Color(0, 0, 70) self.value = " | ".join( [ "[tab] to select", @@ -143,9 +144,9 @@ class BannerView(WizardView): def _get_color(self) -> Color: return ColorAnimation( - start=Color(90, 0, 0), # TODO make this darker - end=Color(90, 90), - duration=0.6, + start=COLOR_BANNER.sat(0).val(0), + end=COLOR_BANNER, + duration=0.5, ) def layout(self) -> list[Renderable]: @@ -211,14 +212,14 @@ def layout(self) -> list[Renderable]: return [ StarBox( (0, 0), - (self.height, self.width), + (self.height - 1, self.width), color=COLOR_BORDER, modules=[ LogoElement((1, 1), (7, self.width - 2)), Box( (round(self.height / 4), round(self.width / 4)), (9, round(self.width / 2)), - color=COLOR_BORDER, + color=COLOR_BANNER, modules=[ BoldText((1, 2), (2, round(self.width / 2) - 3), color=self._get_color(), value=self.title), WrappedText( @@ -232,6 +233,7 @@ def layout(self) -> list[Renderable]: *buttons, ], ), + HelpText((self.height - 1, 0), (1, self.width)), ] From 30f072322e4e017ceeeb03c23cad076e54e1a9da Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Wed, 29 Jan 2025 17:32:34 -0800 Subject: [PATCH 17/34] Log cleanup. Dependency cleanup. --- agentstack/tui.py | 3 --- pyproject.toml | 1 - 2 files changed, 4 deletions(-) diff --git a/agentstack/tui.py b/agentstack/tui.py index 4e2627f5..0cbacb4d 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -550,7 +550,6 @@ def input(self, key: Key): self.value = self._original_value # revert changes elif key.ENTER: self.deactivate() - log.debug(f"Saving {self.value} to {self.node}") def destroy(self): self.deactivate() @@ -1259,8 +1258,6 @@ def _get_active_module(module: Element): next_index = modules.index(active_module) + 1 if next_index >= len(modules): next_index = 0 - log.debug(f"Active index: {modules.index(active_module)}") - log.debug(f"Next index: {next_index}") modules[next_index].activate() # TODO this isn't working elif modules: modules[0].activate() diff --git a/pyproject.toml b/pyproject.toml index 456fdebb..7c109818 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ dependencies = [ "agentops>=0.3.18", "typer>=0.12.5", "inquirer>=3.4.0", - #"art>=6.3", "pyfiglet==1.0.2", "toml>=0.10.2", "ruamel.yaml.base>=0.3.2", From 132c87749b86b352568a0d982f771e03cbc66aef Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 6 Feb 2025 11:36:46 -0800 Subject: [PATCH 18/34] Live resize. Pull frameworks info from `frameworks` module. --- agentstack/cli/wizard.py | 81 +++++++++++---------------- agentstack/frameworks/__init__.py | 13 +++++ agentstack/frameworks/crewai.py | 5 ++ agentstack/frameworks/langgraph.py | 5 ++ agentstack/frameworks/openai_swarm.py | 4 ++ agentstack/tui.py | 12 +++- 6 files changed, 71 insertions(+), 49 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index da425e65..c9c8a0ac 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -11,13 +11,13 @@ from agentstack import conf, log from agentstack.utils import is_snake_case from agentstack.tui import * -from agentstack.frameworks import SUPPORTED_FRAMEWORKS, CREWAI, LANGGRAPH from agentstack import providers +from agentstack import frameworks from agentstack._tools import ( - get_all_tools, - get_tool, - get_all_tool_categories, - get_all_tool_category_names, + get_all_tools, + get_tool, + get_all_tool_categories, + get_all_tool_category_names, ) from agentstack.proj_templates import TemplateConfig from agentstack.cli import LOGO, init_project @@ -221,12 +221,23 @@ def layout(self) -> list[Renderable]: (9, round(self.width / 2)), color=COLOR_BANNER, modules=[ - BoldText((1, 2), (2, round(self.width / 2) - 3), color=self._get_color(), value=self.title), + BoldText( + (1, 2), + (2, round(self.width / 2) - 3), + color=self._get_color(), + value=self.title, + ), WrappedText( - (3, 2), (3, round(self.width / 2) - 3), color=self._get_color(), value=self.sparkle + (3, 2), + (3, round(self.width / 2) - 3), + color=self._get_color(), + value=self.sparkle, ), WrappedText( - (6, 2), (2, round(self.width / 2) - 3), color=self._get_color(), value=self.subtitle + (6, 2), + (2, round(self.width / 2) - 3), + color=self._get_color(), + value=self.subtitle, ), ], ), @@ -313,48 +324,24 @@ def form(self) -> list[Renderable]: class FrameworkView(FormView): title = "Select a Framework" - FRAMEWORK_OPTIONS = { - CREWAI: {'name': "CrewAI", 'description': "A simple and easy-to-use framework."}, - LANGGRAPH: {'name': "LangGraph", 'description': "A powerful and flexible framework."}, - } - def __init__(self, app: 'App'): super().__init__(app) self.framework_key = Node() self.framework_name = Node() self.framework_description = Node() + self.framework_options = { + key: frameworks.get_framework_info(key) for key in frameworks.SUPPORTED_FRAMEWORKS + } def set_framework_selection(self, index: int, value: str): """Update the content of the framework info box.""" - key, data = None, None - for _key, _value in self.FRAMEWORK_OPTIONS.items(): - if _value['name'] == value: # search by name - key = _key - data = _value - break - - if not key or not data: - key = value - data = { - 'name': "Unknown", - 'description': "Unknown", - } - + data = self.framework_options[value] self.framework_name.value = data['name'] self.framework_description.value = data['description'] def set_framework_choice(self, index: int, value: str): """Save the selection.""" - key = None - for _key, _value in self.FRAMEWORK_OPTIONS.items(): - if _value['name'] == value: # search by name - key = _key - break - - self.framework_key.value = key - - def get_framework_options(self) -> list[str]: - return [self.FRAMEWORK_OPTIONS[key]['name'] for key in SUPPORTED_FRAMEWORKS] + self.framework_key.value = value def submit(self): if not self.framework_key.value: @@ -369,7 +356,7 @@ def form(self) -> list[Renderable]: RadioSelect( (12, 1), (self.height - 18, round(self.width / 2) - 3), - options=self.get_framework_options(), + options=list(self.framework_options.keys()), color=COLOR_FORM_BORDER, highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), on_change=self.set_framework_selection, @@ -582,7 +569,10 @@ def form(self) -> list[Renderable]: value=self.tool_category_name, ), BoldText( - (5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.tool_category_name + (5, 3), + (1, round(self.width / 2) - 10), + color=COLOR_FORM, + value=self.tool_category_name, ), WrappedText( (7, 3), @@ -659,9 +649,7 @@ def form(self) -> list[Renderable]: color=COLOR_FORM.sat(40), value=self.tool_name, ), - BoldText( - (5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.tool_name - ), + BoldText((5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.tool_name), WrappedText( (7, 3), (5, round(self.width / 2) - 10), @@ -783,9 +771,7 @@ def form(self) -> list[Renderable]: color=COLOR_FORM.sat(40), value=self.agent_name, ), - BoldText( - (5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.agent_llm - ), + BoldText((5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.agent_llm), WrappedText( (7, 3), (5, round(self.width / 2) - 10), @@ -1018,7 +1004,7 @@ def advance(self, steps: int = 1): """Load the next view in the active workflow.""" assert self.active_workflow, "No active workflow set." assert self.active_view, "No active view set." - + workflow = self.workflow[self.active_workflow] current_index = workflow.index(self.active_view) view = workflow[current_index + steps] @@ -1030,7 +1016,7 @@ def back(self): def load(self, view: str, workflow: Optional[str] = None): """Load a view from a workflow.""" - self.active_workflow = workflow + self.active_workflow = workflow if workflow else self.active_workflow self.active_view = view super().load(view) @@ -1047,5 +1033,4 @@ def main(): import io log.set_stdout(io.StringIO()) # disable on-screen logging - curses.wrapper(WizardApp.wrapper) diff --git a/agentstack/frameworks/__init__.py b/agentstack/frameworks/__init__.py index 0f62b16a..007f7297 100644 --- a/agentstack/frameworks/__init__.py +++ b/agentstack/frameworks/__init__.py @@ -30,6 +30,8 @@ class FrameworkModule(Protocol): Protocol spec for a framework implementation module. """ + NAME: str # Human readable name of the framework + DESCRIPTION: str # Human readable description of the framework ENTRYPOINT: Path """ Relative path to the entrypoint file for the framework in the user's project. @@ -108,6 +110,17 @@ def get_framework_module(framework: str) -> FrameworkModule: raise Exception(f"Framework {framework} could not be imported.") +def get_framework_info(framework: str) -> dict[str, str]: + """ + Get the info for a framework. + """ + _module = get_framework_module(framework) + return { + 'name': _module.NAME, + 'description': _module.DESCRIPTION, + } + + def get_entrypoint_path(framework: str) -> Path: """ Get the path to the entrypoint file for a framework. diff --git a/agentstack/frameworks/crewai.py b/agentstack/frameworks/crewai.py index 6992f272..25db7d4b 100644 --- a/agentstack/frameworks/crewai.py +++ b/agentstack/frameworks/crewai.py @@ -12,6 +12,11 @@ if TYPE_CHECKING: from agentstack.generation import InsertionPoint +NAME: str = "CrewAI" +DESCRIPTION: str = ( + "Framework for orchestrating role-playing, autonomous AI agents. By fostering collaborative " + "intelligence, CrewAI empowers agents to work together seamlessly, tackling complex tasks." +) ENTRYPOINT: Path = Path('src/crew.py') diff --git a/agentstack/frameworks/langgraph.py b/agentstack/frameworks/langgraph.py index e535eae9..397a9761 100644 --- a/agentstack/frameworks/langgraph.py +++ b/agentstack/frameworks/langgraph.py @@ -12,6 +12,11 @@ from agentstack.tasks import TaskConfig, get_all_task_names from agentstack import graph +NAME: str = "LangGraph" +DESCRIPTION: str = ( + "A library for building stateful, multi-actor applications with LLMs, used to create " + "agent and multi-agent workflows." +) ENTRYPOINT: Path = Path('src/graph.py') GRAPH_NODE_START = 'START' diff --git a/agentstack/frameworks/openai_swarm.py b/agentstack/frameworks/openai_swarm.py index c4c6e5ad..cbaeab50 100644 --- a/agentstack/frameworks/openai_swarm.py +++ b/agentstack/frameworks/openai_swarm.py @@ -12,6 +12,10 @@ NAME: str = "OpenAI Swarm" +DESCRIPTION: str = ( + "Educational framework exploring ergonomic, lightweight multi-agent orchestration. " + "Managed by OpenAI Solution team." +) ENTRYPOINT: Path = Path('src/stack.py') diff --git a/agentstack/tui.py b/agentstack/tui.py index 0cbacb4d..6b705556 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -676,7 +676,7 @@ def __init__( on_activate: Optional[Callable] = None, ): super().__init__(coords, dims, value=value, color=color) - self.highlight = highlight or self.color.sat(80) + self.highlight = highlight or self.color.sat(50) self.on_confirm = on_confirm self.on_activate = on_activate @@ -1194,6 +1194,16 @@ def render(self): if not self.view: return + # handle resize + height, width = self.stdscr.getmaxyx() + if self.view.width != width or self.view.height != height: + self.width, self.height = width, height + for name, cls in self.views.items(): + if cls == self.view.__class__: + break + self.load(name) + + # render loop try: self.view.render() self.view.last_render = time.time() From 67dc514d80c58037ad193a1495fe27016e9c507b Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 6 Feb 2025 12:01:04 -0800 Subject: [PATCH 19/34] Allow adding more tools to an agent. --- agentstack/cli/init.py | 1 + agentstack/cli/wizard.py | 174 +++++++++++++++++++++++---------------- 2 files changed, 102 insertions(+), 73 deletions(-) diff --git a/agentstack/cli/init.py b/agentstack/cli/init.py index c7c3a989..142e00e0 100644 --- a/agentstack/cli/init.py +++ b/agentstack/cli/init.py @@ -104,6 +104,7 @@ def init_project( log.debug("Initializing new project with template selection.") template_data = select_template(slug_name, framework) + assert template_data # appease type checker log.notify("🦾 Creating a new AgentStack project...") log.info(f"Using project directory: {conf.PATH.absolute()}") diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index c9c8a0ac..db290d15 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -7,6 +7,7 @@ from typing import Optional, Any, Union, TypedDict from enum import Enum from pathlib import Path +from abc import abstractmethod, ABCMeta from agentstack import conf, log from agentstack.utils import is_snake_case @@ -187,15 +188,19 @@ def layout(self) -> list[Renderable]: on_confirm=lambda: self.app.load('task', workflow='task'), ) ) + + # we can also add more tools to existing agents + buttons.append( + Button( + (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), + (3, round(self.width / 2)), + "Add More Tools", + color=COLOR_BUTTON, + on_confirm=lambda: self.app.load('tool_agent_selection', workflow='tool'), + ) + ) + - # # we can also add more tools to existing agents - # buttons.append(Button( - # (self.height-6, self.width-34), - # (3, 15), - # "Add Tool", - # color=COLOR_BUTTON, - # on_confirm=lambda: self.app.load('tool_category', workflow='agent'), - # )) if self.app.state.project: # we can complete the project @@ -248,7 +253,7 @@ def layout(self) -> list[Renderable]: ] -class FormView(WizardView): +class FormView(WizardView, metaclass=ABCMeta): title: str error_message: Node @@ -262,8 +267,9 @@ def submit(self): def error(self, message: str): self.error_message.value = message + @abstractmethod def form(self) -> list[Renderable]: - return [] + ... def layout(self) -> list[Renderable]: return [ @@ -289,6 +295,66 @@ def layout(self) -> list[Renderable]: ] +class AgentSelectionView(FormView, metaclass=ABCMeta): + title = "Select an Agent" + + def __init__(self, app: 'App'): + super().__init__(app) + self.agent_key = Node() + self.agent_name = Node() + self.agent_llm = Node() + self.agent_description = Node() + + def set_agent_selection(self, index: int, value: str): + agent_data = self.app.state.agents[value] + self.agent_name.value = value + self.agent_llm.value = agent_data['llm'] + self.agent_description.value = agent_data['role'] + + def set_agent_choice(self, index: int, value: str): + self.agent_key.value = value + + def get_agent_options(self) -> list[str]: + return list(self.app.state.agents.keys()) + + @abstractmethod + def submit(self): + ... + + def form(self) -> list[Renderable]: + return [ + RadioSelect( + (12, 1), + (self.height - 18, round(self.width / 2) - 3), + options=self.get_agent_options(), + color=COLOR_FORM_BORDER, + highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), + on_change=self.set_agent_selection, + on_select=self.set_agent_choice, + ), + Box( + (12, round(self.width / 2)), + (self.height - 18, round(self.width / 2) - 3), + color=COLOR_FORM_BORDER, + modules=[ + ASCIIText( + (1, 3), + (4, round(self.width / 2) - 10), + color=COLOR_FORM.sat(40), + value=self.agent_name, + ), + BoldText((5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.agent_llm), + WrappedText( + (7, 3), + (5, round(self.width / 2) - 10), + color=COLOR_FORM.sat(50), + value=self.agent_description, + ), + ], + ), + ] + + class ProjectView(FormView): title = "Define your Project" @@ -668,6 +734,18 @@ def form(self) -> list[Renderable]: ] +class ToolAgentSelectionView(AgentSelectionView): + title = "Select an Agent for your Tool" + + def submit(self): + if not self.agent_key.value: + self.error("Agent is required.") + return + + self.app.state.active_agent = self.agent_key.value + self.app.advance() + + class AfterAgentView(BannerView): title = "Boom! We made some agents." sparkle = "(ノ>ω<)ノ :。・:*:・゚’★,。・:*:・゚’☆" @@ -719,28 +797,9 @@ def form(self) -> list[Renderable]: ] -class AgentSelectionView(FormView): +class TaskAgentSelectionView(AgentSelectionView): title = "Select an Agent for your Task" - - def __init__(self, app: 'App'): - super().__init__(app) - self.agent_key = Node() - self.agent_name = Node() - self.agent_llm = Node() - self.agent_description = Node() - - def set_agent_selection(self, index: int, value: str): - agent_data = self.app.state.agents[value] - self.agent_name.value = value - self.agent_llm.value = agent_data['llm'] - self.agent_description.value = agent_data['role'] - - def set_agent_choice(self, index: int, value: str): - self.agent_key.value = value - - def get_agent_options(self) -> list[str]: - return list(self.app.state.agents.keys()) - + def submit(self): if not self.agent_key.value: self.error("Agent is required.") @@ -749,39 +808,6 @@ def submit(self): self.app.state.update_active_task(agent=self.agent_key.value) self.app.advance() - def form(self) -> list[Renderable]: - return [ - RadioSelect( - (12, 1), - (self.height - 18, round(self.width / 2) - 3), - options=self.get_agent_options(), - color=COLOR_FORM_BORDER, - highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), - on_change=self.set_agent_selection, - on_select=self.set_agent_choice, - ), - Box( - (12, round(self.width / 2)), - (self.height - 18, round(self.width / 2) - 3), - color=COLOR_FORM_BORDER, - modules=[ - ASCIIText( - (1, 3), - (4, round(self.width / 2) - 10), - color=COLOR_FORM.sat(40), - value=self.agent_name, - ), - BoldText((5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.agent_llm), - WrappedText( - (7, 3), - (5, round(self.width / 2) - 10), - color=COLOR_FORM.sat(50), - value=self.agent_description, - ), - ], - ), - ] - class AfterTaskView(BannerView): title = "Let there be tasks!" @@ -928,11 +954,12 @@ class WizardApp(App): 'after_project': AfterProjectView, 'agent': AgentView, 'model': ModelView, + 'tool_agent_selection': ToolAgentSelectionView, 'tool_category': ToolCategoryView, 'tool': ToolView, 'after_agent': AfterAgentView, 'task': TaskView, - 'agent_selection': AgentSelectionView, + 'task_agent_selection': TaskAgentSelectionView, 'after_task': AfterTaskView, 'debug': DebugView, } @@ -955,15 +982,15 @@ class WizardApp(App): ], 'task': [ # add tasks 'task', - 'agent_selection', + 'task_agent_selection', 'after_task', ], - # 'tool': [ # add tools to an agent - # 'agent_select', - # 'tool_category', - # 'tool', - # 'after_agent', - # ] + 'tool': [ # add tools to an agent + 'tool_agent_selection', + 'tool_category', + 'tool', + 'after_agent', + ] } state: State @@ -989,6 +1016,7 @@ def finish(self): self.stop() if self._finish_run_once: + log.set_stdout(sys.stdout) # re-enable on-screen logging init_project( @@ -998,7 +1026,7 @@ def finish(self): template.write_to_file(conf.PATH / "wizard") log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") - self._finish_run_once = False + def advance(self, steps: int = 1): """Load the next view in the active workflow.""" From 7dc0e58c8a48ffc0976bca38486cab8ab1d6e844 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 6 Feb 2025 12:07:29 -0800 Subject: [PATCH 20/34] Fix bug when selecting last element in a scrollable list. --- agentstack/tui.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/agentstack/tui.py b/agentstack/tui.py index 6b705556..598ec928 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -970,10 +970,7 @@ def input(self, key: Key): super().input(key) def click(self, y, x): - # TODO there is a bug when you click on the last element in a scrollable list - for module in self.modules: - if not module in self.get_modules(): - continue # module is not visible + for module in self.get_modules(): if not module.hit(y, x): continue self.select(module) From b37e483b4e1f1f31729cceb772156efecb278e4e Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 6 Feb 2025 12:14:26 -0800 Subject: [PATCH 21/34] Fix centering of logo. --- agentstack/cli/wizard.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index db290d15..0ba2ac08 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -51,7 +51,7 @@ class FieldColors(TypedDict): class LogoElement(Text): - # h_align = ALIGN_CENTER + h_align = ALIGN_CENTER def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): super().__init__(coords, dims) @@ -60,7 +60,7 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int]): self.stars = [(3, 1), (25, 5), (34, 1), (52, 2), (79, 3), (97, 1)] self._star_colors = {} content_width = len(LOGO.split('\n')[0]) - self.left_offset = round((self.width - content_width) / 2) + self.left_offset = max(0, round((self.width - content_width) / 2)) def _get_star_color(self, index: int) -> Color: if index not in self._star_colors: @@ -76,10 +76,7 @@ def render(self) -> None: super().render() for i, (x, y) in enumerate(self.stars): try: - # TODO condition to prevent rendering out of bounds - # TODO fix centering - self.grid.addch(y, x, '*', self._get_star_color(i).to_curses()) - # self.grid.addch(y, self.left_offset + x, '*', self._get_star_color(i).to_curses()) + self.grid.addch(y, self.left_offset + x, '*', self._get_star_color(i).to_curses()) except curses.error: pass # overflow From c11cb598de91480f492fd27d630277d8d6900ac3 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 6 Feb 2025 14:02:34 -0800 Subject: [PATCH 22/34] Renders ad 80x24 --- agentstack/cli/wizard.py | 182 +++++++++++++++++---------------------- agentstack/tui.py | 36 +++++--- 2 files changed, 105 insertions(+), 113 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 0ba2ac08..6a239832 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -27,7 +27,7 @@ COLOR_BORDER = Color(90) COLOR_MAIN = Color(220) COLOR_TITLE = Color(220, 100, 40, reversed=True) -COLOR_ERROR = Color(0, 50) +COLOR_ERROR = Color(0, 70) COLOR_BANNER = Color(80, 80, 80) COLOR_FORM = Color(300) COLOR_FORM_BORDER = Color(300, 80) @@ -120,10 +120,10 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int]) -> None: self.color = Color(0, 0, 70) self.value = " | ".join( [ - "[tab] to select", - "[up / down] to navigate", - "[space / enter] to confirm", - "[q] to quit", + "select [tab]", + "navigate [up / down]", + "confirm [space / enter]", + "[q]uit", ] ) if conf.DEBUG: @@ -148,68 +148,41 @@ def _get_color(self) -> Color: ) def layout(self) -> list[Renderable]: - buttons = [] + buttons_conf: dict[str, Callable] = {} if not self.app.state.project: # no project yet, so we need to create one - buttons.append( - Button( - # center button full width below the subtitle - (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), - (3, round(self.width / 2)), - "Create Project", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('project', workflow='project'), - ) - ) + buttons_conf["Create Project"] = lambda: self.app.load('project', workflow='project') else: # project has been created, so we can add agents - buttons.append( - Button( - (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), - (3, round(self.width / 2)), - "Add Agent", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('agent', workflow='agent'), - ) - ) + buttons_conf["New Agent"] = lambda: self.app.load('agent', workflow='agent') if len(self.app.state.agents): # we have one or more agents, so we can add tasks - buttons.append( - Button( - (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), - (3, round(self.width / 2)), - "Add Task", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('task', workflow='task'), - ) - ) - + buttons_conf["New Task"] = lambda: self.app.load('task', workflow='task') # we can also add more tools to existing agents - buttons.append( - Button( - (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), - (3, round(self.width / 2)), - "Add More Tools", - color=COLOR_BUTTON, - on_confirm=lambda: self.app.load('tool_agent_selection', workflow='tool'), - ) - ) - - - + buttons_conf["Add Tools"] = lambda: self.app.load('tool_agent_selection', workflow='tool') + if self.app.state.project: # we can complete the project + buttons_conf["Finish"] = lambda: self.app.finish() + + buttons: list[Button] = [] + num_buttons = len(buttons_conf) + button_width = min(round(self.width / 2), round(self.width / num_buttons) - 2) + left_offset = round((self.width - (num_buttons * button_width)) / 2) if num_buttons == 1 else 2 + + for title, action in buttons_conf.items(): buttons.append( Button( - (round(self.height / 2) + (len(buttons) * 4), round(self.width / 2 / 2)), - (3, round(self.width / 2)), - "Finish", + (self.height - 5, left_offset), + (3, button_width), + title, color=COLOR_BUTTON, - on_confirm=lambda: self.app.finish(), + on_confirm=action, ) ) + left_offset += button_width + 1 return [ StarBox( @@ -219,7 +192,7 @@ def layout(self) -> list[Renderable]: modules=[ LogoElement((1, 1), (7, self.width - 2)), Box( - (round(self.height / 4), round(self.width / 4)), + (round(self.height / 3), round(self.width / 4)), (9, round(self.width / 2)), color=COLOR_BANNER, modules=[ @@ -276,11 +249,11 @@ def layout(self) -> list[Renderable]: color=COLOR_BORDER, modules=[ LogoElement((1, 1), (7, self.width - 2)), - Title((9, 1), (1, self.width - 3), color=COLOR_TITLE, value=self.title), - Title((10, 1), (1, self.width - 3), color=COLOR_ERROR, value=self.error_message), + Title((9, 1), (1, self.width - 2), color=COLOR_TITLE, value=self.title), + Title((self.height - 5, round(self.width / 3)), (3, round(self.width / 3)), color=COLOR_ERROR, value=self.error_message), *self.form(), Button( - (self.height - 6, self.width - 17), + (self.height - 5, self.width - 17), (3, 15), "Next", color=COLOR_BUTTON, @@ -321,8 +294,8 @@ def submit(self): def form(self) -> list[Renderable]: return [ RadioSelect( - (12, 1), - (self.height - 18, round(self.width / 2) - 3), + (10, 1), + (self.height - 15, round(self.width / 2) - 2), options=self.get_agent_options(), color=COLOR_FORM_BORDER, highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), @@ -330,8 +303,8 @@ def form(self) -> list[Renderable]: on_select=self.set_agent_choice, ), Box( - (12, round(self.width / 2)), - (self.height - 18, round(self.width / 2) - 3), + (10, round(self.width / 2) + 1), + (self.height - 15, round(self.width / 2) - 2), color=COLOR_FORM_BORDER, modules=[ ASCIIText( @@ -343,7 +316,7 @@ def form(self) -> list[Renderable]: BoldText((5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.agent_llm), WrappedText( (7, 3), - (5, round(self.width / 2) - 10), + (min(5, self.height - 24), round(self.width / 2) - 10), color=COLOR_FORM.sat(50), value=self.agent_description, ), @@ -377,10 +350,10 @@ def submit(self): def form(self) -> list[Renderable]: return [ - Text((12, 2), (1, 12), color=COLOR_FORM, value="Name"), - TextInput((12, 14), (2, self.width - 15), self.project_name, **FIELD_COLORS), - Text((14, 2), (1, 12), color=COLOR_FORM, value="Description"), - TextInput((14, 14), (5, self.width - 15), self.project_description, **FIELD_COLORS), + Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), + TextInput((11, 14), (2, self.width - 15), self.project_name, **FIELD_COLORS), + Text((13, 2), (1, 12), color=COLOR_FORM, value="Description"), + TextInput((13, 14), (5, self.width - 15), self.project_description, **FIELD_COLORS), ] @@ -417,8 +390,8 @@ def submit(self): def form(self) -> list[Renderable]: return [ RadioSelect( - (12, 1), - (self.height - 18, round(self.width / 2) - 3), + (10, 1), + (self.height - 15, round(self.width / 2) - 2), options=list(self.framework_options.keys()), color=COLOR_FORM_BORDER, highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), @@ -426,8 +399,8 @@ def form(self) -> list[Renderable]: on_select=self.set_framework_choice, ), Box( - (12, round(self.width / 2)), - (self.height - 18, round(self.width / 2) - 3), + (10, round(self.width / 2)), + (self.height - 15, round(self.width / 2) - 2), color=COLOR_FORM_BORDER, modules=[ ASCIIText( @@ -441,7 +414,7 @@ def form(self) -> list[Renderable]: ), WrappedText( (7, 3), - (5, round(self.width / 2) - 10), + (min(5, self.height - 24), round(self.width / 2) - 10), color=COLOR_FORM.sat(50), value=self.framework_description, ), @@ -493,15 +466,19 @@ def submit(self): self.app.advance() def form(self) -> list[Renderable]: + large_field_height = min(5, round((self.height - 17) / 3)) return [ - Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((12, 13), (2, self.width - 15), self.agent_name, **FIELD_COLORS), - Text((14, 2), (1, 11), color=COLOR_FORM, value="Role"), - TextInput((14, 13), (5, self.width - 15), self.agent_role, **FIELD_COLORS), - Text((19, 2), (1, 11), color=COLOR_FORM, value="Goal"), - TextInput((19, 13), (5, self.width - 15), self.agent_goal, **FIELD_COLORS), - Text((24, 2), (1, 11), color=COLOR_FORM, value="Backstory"), - TextInput((24, 13), (5, self.width - 15), self.agent_backstory, **FIELD_COLORS), + Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), + TextInput((11, 14), (2, self.width - 16), self.agent_name, **FIELD_COLORS), + + Text((13, 2), (1, 12), color=COLOR_FORM, value="Role"), + TextInput((13, 14), (large_field_height, self.width - 16), self.agent_role, **FIELD_COLORS), + + Text((13 + large_field_height, 2), (1, 12), color=COLOR_FORM, value="Goal"), + TextInput((13 + large_field_height, 14), (large_field_height, self.width - 16), self.agent_goal, **FIELD_COLORS), + + Text((13 + (large_field_height * 2), 2), (1, 12), color=COLOR_FORM, value="Backstory"), + TextInput((13 + (large_field_height * 2), 14), (large_field_height, self.width - 16), self.agent_backstory, **FIELD_COLORS), ] @@ -542,8 +519,8 @@ def submit(self): def form(self) -> list[Renderable]: return [ RadioSelect( - (11, 1), - (self.height - 18, round(self.width / 2) - 3), + (10, 1), + (self.height - 15, round(self.width / 2) - 2), options=self.get_model_options(), color=COLOR_FORM_BORDER, highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), @@ -551,8 +528,8 @@ def form(self) -> list[Renderable]: on_select=self.set_model_choice, ), Box( - (11, round(self.width / 2)), - (self.height - 18, round(self.width / 2) - 3), + (10, round(self.width / 2)), + (self.height - 15, round(self.width / 2) - 2), color=COLOR_FORM_BORDER, modules=[ ASCIIText( @@ -566,7 +543,7 @@ def form(self) -> list[Renderable]: ), WrappedText( (7, 3), - (5, round(self.width / 2) - 10), + (min(5, self.height - 24), round(self.width / 2) - 10), color=COLOR_FORM.sat(50), value=self.model_description, ), @@ -612,8 +589,8 @@ def skip(self): def form(self) -> list[Renderable]: return [ RadioSelect( - (11, 1), - (self.height - 18, round(self.width / 2) - 3), + (10, 1), + (self.height - 15, round(self.width / 2) - 2), options=get_all_tool_category_names(), color=COLOR_FORM_BORDER, highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), @@ -621,8 +598,8 @@ def form(self) -> list[Renderable]: on_select=self.set_tool_category_choice, ), Box( - (11, round(self.width / 2)), - (self.height - 18, round(self.width / 2) - 3), + (10, round(self.width / 2) + 1), + (self.height - 15, round(self.width / 2) - 2), color=COLOR_FORM_BORDER, modules=[ ASCIIText( @@ -639,14 +616,14 @@ def form(self) -> list[Renderable]: ), WrappedText( (7, 3), - (5, round(self.width / 2) - 10), + (min(5, self.height - 24), round(self.width / 2) - 10), color=COLOR_FORM.sat(50), value=self.tool_category_description, ), ], ), Button( - (self.height - 6, 2), + (self.height - 5, 2), (3, 15), "Skip", color=COLOR_BUTTON, @@ -693,8 +670,8 @@ def back(self): def form(self) -> list[Renderable]: return [ RadioSelect( - (12, 1), - (self.height - 18, round(self.width / 2) - 3), + (10, 1), + (self.height - 15, round(self.width / 2) - 2), options=self.get_tool_options(), color=COLOR_FORM_BORDER, highlight=ColorAnimation(COLOR_BUTTON.sat(0), COLOR_BUTTON, duration=0.2), @@ -702,8 +679,8 @@ def form(self) -> list[Renderable]: on_select=self.set_tool_choice, ), Box( - (12, round(self.width / 2)), - (self.height - 18, round(self.width / 2) - 3), + (10, round(self.width / 2) + 1), + (self.height - 15, round(self.width / 2) - 2), color=COLOR_FORM_BORDER, modules=[ ASCIIText( @@ -715,14 +692,14 @@ def form(self) -> list[Renderable]: BoldText((5, 3), (1, round(self.width / 2) - 10), color=COLOR_FORM, value=self.tool_name), WrappedText( (7, 3), - (5, round(self.width / 2) - 10), + (min(5, self.height - 24), round(self.width / 2) - 10), color=COLOR_FORM.sat(50), value=self.tool_description, ), ], ), Button( - (self.height - 6, 2), + (self.height - 5, 2), (3, 15), "Back", color=COLOR_BUTTON, @@ -784,13 +761,16 @@ def submit(self): self.app.advance() def form(self) -> list[Renderable]: + large_field_height = min(5, round((self.height - 17) / 3)) return [ - Text((12, 2), (1, 11), color=COLOR_FORM, value="Name"), - TextInput((12, 13), (2, self.width - 15), self.task_name, **FIELD_COLORS), - Text((14, 2), (1, 11), color=COLOR_FORM, value="Description"), - TextInput((14, 13), (5, self.width - 15), self.task_description, **FIELD_COLORS), - Text((19, 2), (1, 11), color=COLOR_FORM, value="Expected Output"), - TextInput((19, 13), (5, self.width - 15), self.expected_output, **FIELD_COLORS), + Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), + TextInput((11, 14), (2, self.width - 16), self.task_name, **FIELD_COLORS), + + Text((13, 2), (1, 12), color=COLOR_FORM, value="Description"), + TextInput((13, 14), (large_field_height, self.width - 16), self.task_description, **FIELD_COLORS), + + Text((13 + large_field_height, 2), (2, 12), color=COLOR_FORM, value="Expected\nOutput"), + TextInput((13 + large_field_height, 14), (large_field_height, self.width - 16), self.expected_output, **FIELD_COLORS), ] @@ -995,7 +975,7 @@ class WizardApp(App): active_view: Optional[str] min_width: int = 80 - min_height: int = 30 + min_height: int = 24 # the main loop can still execute once more after this; so we create an # explicit marker to ensure the template is only written once diff --git a/agentstack/tui.py b/agentstack/tui.py index 598ec928..b5a1a4b4 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -457,7 +457,10 @@ def _get_lines(self, value: str) -> list[str]: def render(self): for i, line in enumerate(self._get_lines(str(self.value))): - self.grid.addstr(i, 0, line, self.color.to_curses()) + try: + self.grid.addstr(i, 0, line, self.color.to_curses()) + except curses.error: + pass # ignore overflow class NodeElement(Element): @@ -765,9 +768,12 @@ def get_modules(self): def render(self): for module in self.get_modules(): - module.render() - module.last_render = time.time() - module.grid.noutrefresh() + try: + module.render() + module.last_render = time.time() + module.grid.noutrefresh() + except RenderException: + pass # ignore overflow self.last_render = time.time() def click(self, y, x): @@ -806,9 +812,12 @@ def render(self) -> None: self.grid.addch(h, w, self.BR, self.color.to_curses()) for module in self.get_modules(): - module.render() - module.last_render = time.time() - module.grid.noutrefresh() + try: + module.render() + module.last_render = time.time() + module.grid.noutrefresh() + except RenderException: + pass # ignore overflow self.last_render = time.time() self.grid.noutrefresh() @@ -906,7 +915,7 @@ def get_modules(self): """Return a subset of modules to be rendered""" # since we can't always render all of the buttons, return a subset # that can be displayed in the available height. - num_displayed = (self.height - 4) // self.button_height + num_displayed = (self.height - 3) // self.button_height index = self._get_active_index() or 0 count = len(self.modules) @@ -1066,11 +1075,12 @@ class DebugElement(Element): """Show fps and color usage.""" def __init__(self, coords: tuple[int, int]): - super().__init__(coords, (1, 24)) + super().__init__(coords, (1, 40)) def render(self) -> None: self.grid.addstr(0, 1, f"FPS: {1 / (time.time() - self.last_render):.0f}") self.grid.addstr(0, 10, f"Colors: {len(Color._color_map)}/{curses.COLORS}") + self.grid.addstr(0, 27, f"Dims: {self.parent.width}x{self.parent.height}") class View(Contains): @@ -1108,7 +1118,7 @@ class App: stdscr: curses.window height: int width: int - min_height: int = 30 + min_height: int = 24 min_width: int = 80 frame_time: float = 1.0 / 60 # 30 FPS editing = False @@ -1122,7 +1132,8 @@ def __init__(self, stdscr: curses.window) -> None: if not self.width >= self.min_width or not self.height >= self.min_height: raise RenderException( - f"Terminal window is too small. Resize to at least {self.min_width}x{self.min_height}." + f"Terminal window is too small. Resize to at least {self.min_width}x{self.min_height}. \n" + f"Current size: {self.width}x{self.height}" ) curses.curs_set(0) @@ -1211,7 +1222,8 @@ def render(self): if "add_wch() returned ERR" in str(e): raise RenderException("Grid not large enough to render all modules.") if "curses function returned NULL" in str(e): - raise RenderException("Window not large enough to render.") + pass + #raise RenderException("Window not large enough to render.") raise e def click(self, y, x): From 41200701f5554cf43dea90eb1d89b03a3d236459 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Thu, 6 Feb 2025 15:48:26 -0800 Subject: [PATCH 23/34] Ignore type. --- agentstack/tui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentstack/tui.py b/agentstack/tui.py index b5a1a4b4..4a470555 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -1080,7 +1080,7 @@ def __init__(self, coords: tuple[int, int]): def render(self) -> None: self.grid.addstr(0, 1, f"FPS: {1 / (time.time() - self.last_render):.0f}") self.grid.addstr(0, 10, f"Colors: {len(Color._color_map)}/{curses.COLORS}") - self.grid.addstr(0, 27, f"Dims: {self.parent.width}x{self.parent.height}") + self.grid.addstr(0, 27, f"Dims: {self.parent.width}x{self.parent.height}") # type: ignore class View(Contains): From f3ba4feae32f9079348a863f2848701e29070009 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 7 Feb 2025 20:43:57 +0000 Subject: [PATCH 24/34] Update llms.txt --- docs/llms.txt | 59 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/docs/llms.txt b/docs/llms.txt index 2e0af5b7..fa23ea5c 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -514,10 +514,6 @@ which adheres to a common pattern or exporting your project to share. Templates are versioned, and each previous version provides a method to convert it's content to the current version. -> TODO: Templates are currently identified as `proj_templates` since they conflict -with the templates used by `generation`. Move existing templates to be part of -the generation package. - ### `TemplateConfig.from_user_input(identifier: str)` `` Returns a `TemplateConfig` object for either a URL, file path, or builtin template name. @@ -716,7 +712,7 @@ title: 'System Analyzer' description: 'Inspect a project directory and improve it' --- -[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/proj_templates/system_analyzer.json) +[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/system_analyzer.json) ```bash agentstack init --template=system_analyzer @@ -737,7 +733,7 @@ title: 'Researcher' description: 'Research and report result from a query' --- -[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/proj_templates/research.json) +[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/research.json) ```bash agentstack init --template=research @@ -828,7 +824,54 @@ title: 'Content Creator' description: 'Research a topic and create content on it' --- -[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/proj_templates/content_creator.json) +[View Template](https://github.com/AgentOps-AI/AgentStack/blob/main/agentstack/templates/content_creator.json) + +## frameworks/list.mdx + +--- +title: Frameworks +description: 'Supported frameworks in AgentStack' +icon: 'ship' +--- + +These are documentation links to the frameworks supported directly by AgentStack. + +To start a project with one of these frameworks, use +```bash +agentstack init --framework +``` + +## Framework Docs + + + An intuitive agentic framework (recommended) + + + A complex but capable framework with a _steep_ learning curve + + + A simple framework with a cult following + + + An expansive framework with many ancillary features + + ## tools/package-structure.mdx @@ -1043,7 +1086,7 @@ You can pass the `--wizard` flag to `agentstack init` to use an interactive proj You can also pass a `--template=` argument to `agentstack init` which will pre-populate your project with functionality from a built-in template, or one found on the internet. A `template_name` can be one of three identifiers: -- A built-in AgentStack template (see the `templates/proj_templates` directory in the AgentStack repo for bundled templates). +- A built-in AgentStack template (see the `templates` directory in the AgentStack repo for bundled templates). - A template file from the internet; pass the full https URL of the template. - A local template file; pass an absolute or relative path. From d3db89e8942bdc527e66b2c99e1b28849dcd0156 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 10 Feb 2025 09:18:27 -0800 Subject: [PATCH 25/34] Undraw previous star instead of clearing grid to prevent flicker. --- agentstack/cli/wizard.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 6a239832..7d6b7811 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -103,8 +103,12 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int], **kwargs): self.star_index = 0 def render(self) -> None: - self.grid.clear() for i in range(len(self.stars)): + if self.star_y[i] > 0: # undraw previous star position + self.grid.addch(self.star_y[i] - 1, self.star_x[i], ' ') + else: # previous star was at bottom of screen + self.grid.addch(self.height - 1, self.star_x[i], ' ') + if self.star_y[i] < self.height: self.grid.addch(self.star_y[i], self.star_x[i], '*', self.star_colors[i].to_curses()) self.star_y[i] += 1 From 81e1149ec8e46b443f5895faaa87217736491b00 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 10 Feb 2025 09:58:11 -0800 Subject: [PATCH 26/34] Standardize categories for new tools. --- agentstack/_tools/categories.json | 8 ++++---- agentstack/_tools/payman/config.json | 2 +- agentstack/_tools/stripe/config.json | 2 +- agentstack/_tools/weaviate/config.json | 2 +- agentstack/cli/wizard.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/agentstack/_tools/categories.json b/agentstack/_tools/categories.json index 17aee3b4..d0ebebaa 100644 --- a/agentstack/_tools/categories.json +++ b/agentstack/_tools/categories.json @@ -1,8 +1,4 @@ { - "application-specific": { - "title": "Application Specific", - "description": "Tools that are specific to a particular application or domain." - }, "browsing": { "title": "Browsing", "description": "Tools that are used to browse the web." @@ -19,6 +15,10 @@ "title": "Database", "description": "Tools that are used to interact with databases." }, + "finance": { + "title": "Finance", + "description": "Tools that are used to interact with financial services." + }, "image-analysis": { "title": "Image Analysis", "description": "Tools that are used to analyze images." diff --git a/agentstack/_tools/payman/config.json b/agentstack/_tools/payman/config.json index 10eee8d9..9e31ca54 100644 --- a/agentstack/_tools/payman/config.json +++ b/agentstack/_tools/payman/config.json @@ -1,6 +1,6 @@ { "name": "payman", - "category": "financial-infra", + "category": "finance", "tools": [ "send_payment", "search_available_payees", diff --git a/agentstack/_tools/stripe/config.json b/agentstack/_tools/stripe/config.json index 89b18366..8cdfe3da 100644 --- a/agentstack/_tools/stripe/config.json +++ b/agentstack/_tools/stripe/config.json @@ -1,7 +1,7 @@ { "name": "stripe", "url": "https://github.com/stripe/agent-toolkit", - "category": "application-specific", + "category": "finance", "env": { "STRIPE_SECRET_KEY": null }, diff --git a/agentstack/_tools/weaviate/config.json b/agentstack/_tools/weaviate/config.json index 1323a10f..7e7bb5ce 100644 --- a/agentstack/_tools/weaviate/config.json +++ b/agentstack/_tools/weaviate/config.json @@ -1,7 +1,7 @@ { "name": "weaviate", "url": "https://github.com/weaviate/weaviate-python-client", - "category": "vector-store", + "category": "database", "env": { "WEAVIATE_URL": null, "WEAVIATE_API_KEY": null, diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 7d6b7811..74a1e36c 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -20,7 +20,7 @@ get_all_tool_categories, get_all_tool_category_names, ) -from agentstack.proj_templates import TemplateConfig +from agentstack.templates import TemplateConfig from agentstack.cli import LOGO, init_project From 8531d4698cc8ea5160615f3d68deeb4166f7b0a1 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 10 Feb 2025 11:36:05 -0800 Subject: [PATCH 27/34] Placeholder help text in wizard fields. --- agentstack/cli/wizard.py | 114 ++++++++++++++++------ agentstack/frameworks/llamaindex.py | 3 + agentstack/tui.py | 144 ++++++++++++++-------------- 3 files changed, 159 insertions(+), 102 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 74a1e36c..0550ab54 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -1,4 +1,4 @@ -import sys +import os, sys import curses import time import math @@ -104,11 +104,11 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int], **kwargs): def render(self) -> None: for i in range(len(self.stars)): - if self.star_y[i] > 0: # undraw previous star position + if self.star_y[i] > 0: # undraw previous star position self.grid.addch(self.star_y[i] - 1, self.star_x[i], ' ') - else: # previous star was at bottom of screen + else: # previous star was at bottom of screen self.grid.addch(self.height - 1, self.star_x[i], ' ') - + if self.star_y[i] < self.height: self.grid.addch(self.star_y[i], self.star_x[i], '*', self.star_colors[i].to_curses()) self.star_y[i] += 1 @@ -166,7 +166,7 @@ def layout(self) -> list[Renderable]: buttons_conf["New Task"] = lambda: self.app.load('task', workflow='task') # we can also add more tools to existing agents buttons_conf["Add Tools"] = lambda: self.app.load('tool_agent_selection', workflow='tool') - + if self.app.state.project: # we can complete the project buttons_conf["Finish"] = lambda: self.app.finish() @@ -181,7 +181,7 @@ def layout(self) -> list[Renderable]: Button( (self.height - 5, left_offset), (3, button_width), - title, + title, color=COLOR_BUTTON, on_confirm=action, ) @@ -242,8 +242,7 @@ def error(self, message: str): self.error_message.value = message @abstractmethod - def form(self) -> list[Renderable]: - ... + def form(self) -> list[Renderable]: ... def layout(self) -> list[Renderable]: return [ @@ -254,7 +253,12 @@ def layout(self) -> list[Renderable]: modules=[ LogoElement((1, 1), (7, self.width - 2)), Title((9, 1), (1, self.width - 2), color=COLOR_TITLE, value=self.title), - Title((self.height - 5, round(self.width / 3)), (3, round(self.width / 3)), color=COLOR_ERROR, value=self.error_message), + Title( + (self.height - 5, round(self.width / 3)), + (3, round(self.width / 3)), + color=COLOR_ERROR, + value=self.error_message, + ), *self.form(), Button( (self.height - 5, self.width - 17), @@ -292,8 +296,7 @@ def get_agent_options(self) -> list[str]: return list(self.app.state.agents.keys()) @abstractmethod - def submit(self): - ... + def submit(self): ... def form(self) -> list[Renderable]: return [ @@ -346,6 +349,10 @@ def submit(self): self.error("Name must be in snake_case.") return + if os.path.exists(conf.PATH / self.project_name.value): + self.error(f"Directory '{self.project_name.value}' already exists.") + return + self.app.state.create_project( name=self.project_name.value, description=self.project_description.value, @@ -355,9 +362,21 @@ def submit(self): def form(self) -> list[Renderable]: return [ Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), - TextInput((11, 14), (2, self.width - 15), self.project_name, **FIELD_COLORS), + TextInput( + (11, 14), + (2, self.width - 15), + self.project_name, + placeholder="This will be used to create a new directory. Must be snake_case.", + **FIELD_COLORS, + ), Text((13, 2), (1, 12), color=COLOR_FORM, value="Description"), - TextInput((13, 14), (5, self.width - 15), self.project_description, **FIELD_COLORS), + TextInput( + (13, 14), + (5, self.width - 15), + self.project_description, + placeholder="Describe what you project will do.", + **FIELD_COLORS, + ), ] @@ -473,16 +492,37 @@ def form(self) -> list[Renderable]: large_field_height = min(5, round((self.height - 17) / 3)) return [ Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), - TextInput((11, 14), (2, self.width - 16), self.agent_name, **FIELD_COLORS), - + TextInput( + (11, 14), + (2, self.width - 16), + self.agent_name, + placeholder="A unique name for this agent. Must be snake_case.", + **FIELD_COLORS, + ), Text((13, 2), (1, 12), color=COLOR_FORM, value="Role"), - TextInput((13, 14), (large_field_height, self.width - 16), self.agent_role, **FIELD_COLORS), - + TextInput( + (13, 14), + (large_field_height, self.width - 16), + self.agent_role, + placeholder="A prompt to the agent that describes the role it takes in your project.", + ** FIELD_COLORS, + ), Text((13 + large_field_height, 2), (1, 12), color=COLOR_FORM, value="Goal"), - TextInput((13 + large_field_height, 14), (large_field_height, self.width - 16), self.agent_goal, **FIELD_COLORS), - + TextInput( + (13 + large_field_height, 14), + (large_field_height, self.width - 16), + self.agent_goal, + placeholder="A prompt to the agent that describes the goal it is trying to achieve.", + **FIELD_COLORS, + ), Text((13 + (large_field_height * 2), 2), (1, 12), color=COLOR_FORM, value="Backstory"), - TextInput((13 + (large_field_height * 2), 14), (large_field_height, self.width - 16), self.agent_backstory, **FIELD_COLORS), + TextInput( + (13 + (large_field_height * 2), 14), + (large_field_height, self.width - 16), + self.agent_backstory, + placeholder="A prompt to the agent that describes the backstory of it's purpose.", + **FIELD_COLORS, + ), ] @@ -768,19 +808,35 @@ def form(self) -> list[Renderable]: large_field_height = min(5, round((self.height - 17) / 3)) return [ Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), - TextInput((11, 14), (2, self.width - 16), self.task_name, **FIELD_COLORS), - + TextInput( + (11, 14), + (2, self.width - 16), + self.task_name, + placeholder="A unique name for this task. Must be snake_case.", + **FIELD_COLORS, + ), Text((13, 2), (1, 12), color=COLOR_FORM, value="Description"), - TextInput((13, 14), (large_field_height, self.width - 16), self.task_description, **FIELD_COLORS), - + TextInput( + (13, 14), + (large_field_height, self.width - 16), + self.task_description, + placeholder="A prompt for this task that describes what should be done.", + **FIELD_COLORS, + ), Text((13 + large_field_height, 2), (2, 12), color=COLOR_FORM, value="Expected\nOutput"), - TextInput((13 + large_field_height, 14), (large_field_height, self.width - 16), self.expected_output, **FIELD_COLORS), + TextInput( + (13 + large_field_height, 14), + (large_field_height, self.width - 16), + self.expected_output, + placeholder="A prompt for this task that describes what the output should look like.", + **FIELD_COLORS, + ), ] class TaskAgentSelectionView(AgentSelectionView): title = "Select an Agent for your Task" - + def submit(self): if not self.agent_key.value: self.error("Agent is required.") @@ -966,12 +1022,12 @@ class WizardApp(App): 'task_agent_selection', 'after_task', ], - 'tool': [ # add tools to an agent + 'tool': [ # add tools to an agent 'tool_agent_selection', 'tool_category', 'tool', 'after_agent', - ] + ], } state: State @@ -997,7 +1053,6 @@ def finish(self): self.stop() if self._finish_run_once: - log.set_stdout(sys.stdout) # re-enable on-screen logging init_project( @@ -1007,7 +1062,6 @@ def finish(self): template.write_to_file(conf.PATH / "wizard") log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") - def advance(self, steps: int = 1): """Load the next view in the active workflow.""" diff --git a/agentstack/frameworks/llamaindex.py b/agentstack/frameworks/llamaindex.py index 269dd4b4..84dc77f8 100644 --- a/agentstack/frameworks/llamaindex.py +++ b/agentstack/frameworks/llamaindex.py @@ -12,6 +12,9 @@ from agentstack import graph NAME: str = "LLamaIndex" +DESCRIPTION: str = ( + "LlamaIndex is the leading framework for building LLM-powered agents over your data." +) ENTRYPOINT: Path = Path('src/stack.py') PROVIDERS = { diff --git a/agentstack/tui.py b/agentstack/tui.py index 4a470555..5fd65419 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -472,32 +472,24 @@ def __init__( dims: tuple[int, int], node: Node, color: Optional[Color] = None, - format: Optional[Callable] = None, ): super().__init__(coords, dims, color=color) - self.node = node # TODO can also be str? + self.node = node self.value = str(node) - self.format = format - if isinstance(node, Node): - self.node.add_callback(self.update) + self.node.add_callback(self.update) # allow the node to listen for changes def update(self, node: Node): self.value = str(node) - if self.format: - self.value = self.format(self.value) def save(self): self.node.update(self.value) - self.update(self.node) def destroy(self): - if isinstance(self.node, Node): - self.node.remove_callback(self.update) + self.node.remove_callback(self.update) super().destroy() class Editable(NodeElement): - filter: Optional[Callable] = None active: bool _original_value: Any @@ -505,13 +497,10 @@ def __init__( self, coords, dims, - node, + node: Node, color=None, - format: Optional[Callable] = None, - filter: Optional[Callable] = None, ): - super().__init__(coords, dims, node=node, color=color, format=format) - self.filter = filter + super().__init__(coords, dims, node=node, color=color) self.active = False self._original_value = self.value @@ -519,8 +508,7 @@ def click(self, y, x): if not self.active and self.hit(y, x): self.activate() elif self.active: # click off - self.deactivate() - self.save() + self.deactivate(save=False) def activate(self): """Make this module the active one; ie. editing or selected.""" @@ -535,11 +523,6 @@ def deactivate(self, save: bool = True): if save: self.save() - def save(self): - if self.filter: - self.value = self.filter(self.value) - super().save() - def input(self, key: Key): if not self.active: return @@ -559,6 +542,72 @@ def destroy(self): super().destroy() +class TextInput(Editable): + """ + A module that allows the user to input text. + """ + + H, V, BR = "━", "┃", "┛" + padding: tuple[int, int] = (2, 1) + border_color: Color + active_color: Color + placeholder: str = "" + word_wrap: bool = True + + def __init__( + self, + coords: tuple[int, int], + dims: tuple[int, int], + node: Node, + placeholder: str = "", + color: Optional[Color] = None, + border: Optional[Color] = None, + active: Optional[Color] = None, + ): + super().__init__(coords, dims, node=node, color=color) + self.width, self.height = (dims[1] - 1, dims[0] - 1) + self.border_color = border or self.color + self.active_color = active or self.color + self.placeholder = placeholder + if self.value == "": + self.value = self.placeholder + + def activate(self): + # change the border color to a highlight + self._original_border_color = self.border_color + self.border_color = self.active_color + if self.value == self.placeholder: + self.value = "" + super().activate() + + def deactivate(self, save: bool = True): + if self.active and hasattr(self, '_original_border_color'): + self.border_color = self._original_border_color + if self.value == "": + self.value = self.placeholder + super().deactivate(save) + + def save(self): + if self.value != self.placeholder: + super().save() + + def render(self) -> None: + if self.value == self.placeholder: + color = self.color.to_curses() | curses.A_ITALIC + else: + color = self.color.to_curses() + + for i, line in enumerate(self._get_lines(str(self.value))): + self.grid.addstr(i, 0, line, color) + + # # add border to bottom right like a drop shadow + for x in range(self.width): + self.grid.addch(self.height, x, self.H, self.border_color.to_curses()) + for y in range(self.height): + self.grid.addch(y, self.width, self.V, self.border_color.to_curses()) + self.grid.addch(self.height, self.width, self.BR, self.border_color.to_curses()) + + class Text(Element): pass @@ -610,55 +659,6 @@ class Title(BoldText): v_align: str = ALIGN_MIDDLE -class TextInput(Editable): - """ - A module that allows the user to input text. - """ - - H, V, BR = "━", "┃", "┛" - padding: tuple[int, int] = (2, 1) - border_color: Color - active_color: Color - word_wrap: bool = True - - def __init__( - self, - coords: tuple[int, int], - dims: tuple[int, int], - node: Node, - color: Optional[Color] = None, - border: Optional[Color] = None, - active: Optional[Color] = None, - format: Optional[Callable] = None, - ): - super().__init__(coords, dims, node=node, color=color, format=format) - self.width, self.height = (dims[1] - 1, dims[0] - 1) - self.border_color = border or self.color - self.active_color = active or self.color - - def activate(self): - # change the border color to a highlight - self._original_border_color = self.border_color - self.border_color = self.active_color - super().activate() - - def deactivate(self, save: bool = True): - if self.active and hasattr(self, '_original_border_color'): - self.border_color = self._original_border_color - super().deactivate(save) - - def render(self) -> None: - for i, line in enumerate(self._get_lines(str(self.value))): - self.grid.addstr(i, 0, line, self.color.to_curses()) - - # # add border to bottom right like a drop shadow - for x in range(self.width): - self.grid.addch(self.height, x, self.H, self.border_color.to_curses()) - for y in range(self.height): - self.grid.addch(y, self.width, self.V, self.border_color.to_curses()) - self.grid.addch(self.height, self.width, self.BR, self.border_color.to_curses()) - - class Button(Element): h_align: str = ALIGN_CENTER v_align: str = ALIGN_MIDDLE From fa4f7ca4cd0f47e504d50fbcf40059baa74e2988 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 10 Feb 2025 12:25:42 -0800 Subject: [PATCH 28/34] Docstrings for TUI. --- agentstack/tui.py | 80 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/agentstack/tui.py b/agentstack/tui.py index 5fd65419..797d67bd 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -30,7 +30,7 @@ class RenderException(Exception): POS_ABSOLUTE = "absolute" -class Node: # TODO this needs a better name +class Node: """ A simple data node that can be updated and have callbacks. This is used to populate and retrieve data from an input field inside the user interface. @@ -59,6 +59,17 @@ def remove_callback(self, callback): class Key: + """ + Conversions and convenience methods for key codes. + + Provides booleans about the key pressed: + + `key.BACKSPACE` + `key.is_numeric` + `key.is_alpha` + ... + """ + const = { 'UP': 259, 'DOWN': 258, @@ -75,7 +86,7 @@ class Key: def __init__(self, ch: int): self.ch = ch - def __getattr__(self, name): + def __getattr__(self, name) -> bool: try: return self.ch == self.const[name] except KeyError: @@ -86,11 +97,11 @@ def chr(self): return chr(self.ch) @property - def is_numeric(self): + def is_numeric(self) -> bool: return self.ch >= 48 and self.ch <= 57 @property - def is_alpha(self): + def is_alpha(self) -> bool: return self.ch >= 65 and self.ch <= 122 @@ -248,6 +259,12 @@ def initialize(cls) -> None: class ColorAnimation(Color): + """ + Animate between two colors over a duration. + + Compatible interface with `Color` to add animation to element's color. + """ + start: Color end: Color reversed: bool = False @@ -273,13 +290,13 @@ def to_curses(self) -> int: self.end.reversed = True elif self.start.reversed: self.reversed = True - + if self.bold: self.start.bold = True self.end.bold = True elif self.start.bold: self.bold = True - + elapsed = time.time() - self._start_time if elapsed > self.duration: if self.loop: @@ -308,6 +325,12 @@ def to_curses(self) -> int: class Renderable: + """ + A base class for all renderable modules. + + Handles sizing, positioning, and inserting the module into the grid. + """ + _grid: Optional[curses.window] = None y: int x: int @@ -331,7 +354,6 @@ def __repr__(self): @property def grid(self): - # TODO cleanup # TODO validate that coords and size are within the parent window and give # an explanatory error message. if not self._grid: @@ -352,6 +374,7 @@ def grid(self): return self._grid def move(self, y: int, x: int): + """Move the module's grid to a new position.""" self.y, self.x = y, x if self._grid: if self.positioning == POS_RELATIVE: @@ -376,6 +399,7 @@ def abs_y(self): return self.y def render(self): + """Render the module to the screen.""" pass def hit(self, y, x): @@ -420,6 +444,12 @@ def __repr__(self): return f"{type(self)} at ({self.y}, {self.x}) with value '{self.value[:20]}'" def _get_lines(self, value: str) -> list[str]: + """ + Get the lines to render. + + Called by `render()` using the value of the element. This allows us to have + word wrapping and alignment in all module types. + """ if self.word_wrap: splits = [''] * self.height words = value.split() @@ -464,6 +494,10 @@ def render(self): class NodeElement(Element): + """ + A module that is bound to a node and updates when the node changes. + """ + format: Optional[Callable] = None def __init__( @@ -490,6 +524,12 @@ def destroy(self): class Editable(NodeElement): + """ + A module that can be edited by the user. + + Handles mouse clicks, key input, and managing global editing state. + """ + active: bool _original_value: Any @@ -497,7 +537,7 @@ def __init__( self, coords, dims, - node: Node, + node: Node, color=None, ): super().__init__(coords, dims, node=node, color=color) @@ -596,7 +636,7 @@ def render(self) -> None: color = self.color.to_curses() | curses.A_ITALIC else: color = self.color.to_curses() - + for i, line in enumerate(self._get_lines(str(self.value))): self.grid.addstr(i, 0, line, color) @@ -609,14 +649,20 @@ def render(self) -> None: class Text(Element): + """Basic text module""" + pass class WrappedText(Text): + """Text module with word wrapping""" + word_wrap: bool = True class ASCIIText(Text): + """Text module that renders as ASCII art""" + default_font: str = "pepper" formatter: Figlet _ascii_render: Optional[str] = None # rendered content @@ -643,6 +689,8 @@ def _get_lines(self, value: str) -> list[str]: class BoldText(Text): + """Text module with bold text""" + def __init__( self, coords: tuple[int, int], @@ -655,11 +703,15 @@ def __init__( class Title(BoldText): + """A title module; shortcut for bold, centered text""" + h_align: str = ALIGN_CENTER v_align: str = ALIGN_MIDDLE class Button(Element): + """A clickable button module""" + h_align: str = ALIGN_CENTER v_align: str = ALIGN_MIDDLE active: bool = False @@ -736,6 +788,8 @@ class CheckButton(RadioButton): class Contains(Renderable): + """A container for other modules""" + _grid: Optional[curses.window] = None y: int x: int @@ -1115,6 +1169,8 @@ def layout(self) -> list[Renderable]: class App: + """The main application class.""" + stdscr: curses.window height: int width: int @@ -1128,7 +1184,7 @@ class App: def __init__(self, stdscr: curses.window) -> None: self.stdscr = stdscr - self.height, self.width = self.stdscr.getmaxyx() # TODO dynamic resizing + self.height, self.width = self.stdscr.getmaxyx() if not self.width >= self.min_width or not self.height >= self.min_height: raise RenderException( @@ -1210,7 +1266,7 @@ def render(self): if cls == self.view.__class__: break self.load(name) - + # render loop try: self.view.render() @@ -1223,7 +1279,7 @@ def render(self): raise RenderException("Grid not large enough to render all modules.") if "curses function returned NULL" in str(e): pass - #raise RenderException("Window not large enough to render.") + # raise RenderException("Window not large enough to render.") raise e def click(self, y, x): From 4cc52c8d47f69609e5b53090bf05ed4e7a45533c Mon Sep 17 00:00:00 2001 From: Braelyn Boynton Date: Mon, 10 Feb 2025 13:56:12 -0800 Subject: [PATCH 29/34] wizard smile --- agentstack/cli/wizard.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 6a239832..30286a8e 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -151,20 +151,15 @@ def layout(self) -> list[Renderable]: buttons_conf: dict[str, Callable] = {} if not self.app.state.project: - # no project yet, so we need to create one buttons_conf["Create Project"] = lambda: self.app.load('project', workflow='project') else: - # project has been created, so we can add agents buttons_conf["New Agent"] = lambda: self.app.load('agent', workflow='agent') if len(self.app.state.agents): - # we have one or more agents, so we can add tasks buttons_conf["New Task"] = lambda: self.app.load('task', workflow='task') - # we can also add more tools to existing agents buttons_conf["Add Tools"] = lambda: self.app.load('tool_agent_selection', workflow='tool') - + if self.app.state.project: - # we can complete the project buttons_conf["Finish"] = lambda: self.app.finish() buttons: list[Button] = [] @@ -425,7 +420,7 @@ def form(self) -> list[Renderable]: class AfterProjectView(BannerView): title = "We've got a project!" - sparkle = "*゚・:*:・゚’★,。・:*:・゚’☆" + sparkle = "(づ ◕‿◕ )づ *゚・:*:・゚’★,。・:*:・゚’☆" subtitle = "Now, add an Agent to handle your tasks!" From a3e6642be24e53a3b2b196ae10debf0689e866df Mon Sep 17 00:00:00 2001 From: Braelyn Boynton Date: Thu, 13 Feb 2025 14:38:18 -0800 Subject: [PATCH 30/34] fix proj_template import --- agentstack/cli/wizard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 86c0544f..04114270 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -20,7 +20,7 @@ get_all_tool_categories, get_all_tool_category_names, ) -from agentstack.proj_templates import TemplateConfig +from agentstack.templates import TemplateConfig from agentstack.cli import LOGO, init_project From d1e8f6a4247a78bf1ba6b6666fc93eef065796f3 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 08:53:06 -0800 Subject: [PATCH 31/34] 16 color support. --- agentstack/cli/wizard.py | 18 ++++++------- agentstack/tui.py | 57 +++++++++++++++++++++++++++++++++------- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index 04114270..a1d8131c 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -172,7 +172,7 @@ def layout(self) -> list[Renderable]: Button( (self.height - 5, left_offset), (3, button_width), - title, + title, color=COLOR_BUTTON, on_confirm=action, ) @@ -465,13 +465,13 @@ def form(self) -> list[Renderable]: return [ Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), TextInput((11, 14), (2, self.width - 16), self.agent_name, **FIELD_COLORS), - + Text((13, 2), (1, 12), color=COLOR_FORM, value="Role"), TextInput((13, 14), (large_field_height, self.width - 16), self.agent_role, **FIELD_COLORS), - + Text((13 + large_field_height, 2), (1, 12), color=COLOR_FORM, value="Goal"), TextInput((13 + large_field_height, 14), (large_field_height, self.width - 16), self.agent_goal, **FIELD_COLORS), - + Text((13 + (large_field_height * 2), 2), (1, 12), color=COLOR_FORM, value="Backstory"), TextInput((13 + (large_field_height * 2), 14), (large_field_height, self.width - 16), self.agent_backstory, **FIELD_COLORS), ] @@ -760,10 +760,10 @@ def form(self) -> list[Renderable]: return [ Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), TextInput((11, 14), (2, self.width - 16), self.task_name, **FIELD_COLORS), - + Text((13, 2), (1, 12), color=COLOR_FORM, value="Description"), TextInput((13, 14), (large_field_height, self.width - 16), self.task_description, **FIELD_COLORS), - + Text((13 + large_field_height, 2), (2, 12), color=COLOR_FORM, value="Expected\nOutput"), TextInput((13 + large_field_height, 14), (large_field_height, self.width - 16), self.expected_output, **FIELD_COLORS), ] @@ -771,7 +771,7 @@ def form(self) -> list[Renderable]: class TaskAgentSelectionView(AgentSelectionView): title = "Select an Agent for your Task" - + def submit(self): if not self.agent_key.value: self.error("Agent is required.") @@ -988,7 +988,7 @@ def finish(self): self.stop() if self._finish_run_once: - + log.set_stdout(sys.stdout) # re-enable on-screen logging init_project( @@ -998,7 +998,7 @@ def finish(self): template.write_to_file(conf.PATH / "wizard") log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") - + def advance(self, steps: int = 1): """Load the next view in the active workflow.""" diff --git a/agentstack/tui.py b/agentstack/tui.py index 797d67bd..cc83b599 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -126,7 +126,18 @@ class Color: reversed: bool = False bold: bool = False - _color_map = {} # Cache for color mappings + FALLBACK_COLORS = [ + curses.COLOR_WHITE, + curses.COLOR_RED, + curses.COLOR_GREEN, + curses.COLOR_YELLOW, + curses.COLOR_BLUE, + curses.COLOR_MAGENTA, + curses.COLOR_CYAN, + ] + + _color_map = {} # cache for color mappings + COLOR_SUPPORT = "none" def __init__( self, h: float, s: float = 100, v: float = 100, reversed: bool = False, bold: bool = False @@ -223,12 +234,38 @@ def _get_color_pair(self, pair_number: int) -> int: pair = pair | curses.A_BOLD return pair + def _get_fallback_color(self): + hue = self.h + if self.s <= 50 or self.v <= 50: + return curses.COLOR_WHITE + + if hue < 30 or hue >= 330: + return curses.COLOR_RED + elif 30 < hue <= 90: + return curses.COLOR_YELLOW + elif 90 < hue <= 150: + return curses.COLOR_GREEN + elif 150 < hue <= 230: + return curses.COLOR_CYAN + elif 230 < hue <= 270: + return curses.COLOR_BLUE + elif 270 < hue <= 330: + return curses.COLOR_MAGENTA + else: + return curses.COLOR_WHITE + def to_curses(self) -> int: """Get curses color pair for this color.""" if self._pair_number is not None: return self._get_color_pair(self._pair_number) - color_number = self._get_closest_color() + if Color.COLOR_SUPPORT == "none": + return 0 + + if Color.COLOR_SUPPORT == "basic": + color_number = self._get_fallback_color() + else: + color_number = self._get_closest_color() # Create new pair if needed if color_number not in self._color_map: @@ -244,18 +281,20 @@ def to_curses(self) -> int: @classmethod def initialize(cls) -> None: """Initialize terminal color support.""" - if not curses.has_colors(): - raise RuntimeError("Terminal does not support colors") + cls._color_map = {} + cls.COLOR_SUPPORT = "none" - curses.start_color() - curses.use_default_colors() + if not curses.has_colors(): + return try: + curses.start_color() + curses.use_default_colors() curses.init_pair(1, 1, -1) + curses.color_pair(1) + cls.COLOR_SUPPORT = "full" if curses.COLORS >= 256 else "basic" except: - raise RuntimeError("Terminal does not support required color features") - - cls._color_map = {} + pass class ColorAnimation(Color): From d411d1eb392560918b2613cd4f4b55ebf5d1385d Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 10 Feb 2025 11:36:05 -0800 Subject: [PATCH 32/34] Placeholder help text in wizard fields. --- agentstack/cli/wizard.py | 105 ++++++++++++++++++++++++++++++--------- agentstack/tui.py | 3 +- 2 files changed, 83 insertions(+), 25 deletions(-) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index a1d8131c..b194ba07 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -105,6 +105,11 @@ def __init__(self, coords: tuple[int, int], dims: tuple[int, int], **kwargs): def render(self) -> None: self.grid.clear() for i in range(len(self.stars)): + if self.star_y[i] > 0: # undraw previous star position + self.grid.addch(self.star_y[i] - 1, self.star_x[i], ' ') + else: # previous star was at bottom of screen + self.grid.addch(self.height - 1, self.star_x[i], ' ') + if self.star_y[i] < self.height: self.grid.addch(self.star_y[i], self.star_x[i], '*', self.star_colors[i].to_curses()) self.star_y[i] += 1 @@ -233,8 +238,7 @@ def error(self, message: str): self.error_message.value = message @abstractmethod - def form(self) -> list[Renderable]: - ... + def form(self) -> list[Renderable]: ... def layout(self) -> list[Renderable]: return [ @@ -245,7 +249,12 @@ def layout(self) -> list[Renderable]: modules=[ LogoElement((1, 1), (7, self.width - 2)), Title((9, 1), (1, self.width - 2), color=COLOR_TITLE, value=self.title), - Title((self.height - 5, round(self.width / 3)), (3, round(self.width / 3)), color=COLOR_ERROR, value=self.error_message), + Title( + (self.height - 5, round(self.width / 3)), + (3, round(self.width / 3)), + color=COLOR_ERROR, + value=self.error_message, + ), *self.form(), Button( (self.height - 5, self.width - 17), @@ -283,8 +292,7 @@ def get_agent_options(self) -> list[str]: return list(self.app.state.agents.keys()) @abstractmethod - def submit(self): - ... + def submit(self): ... def form(self) -> list[Renderable]: return [ @@ -337,6 +345,10 @@ def submit(self): self.error("Name must be in snake_case.") return + if os.path.exists(conf.PATH / self.project_name.value): + self.error(f"Directory '{self.project_name.value}' already exists.") + return + self.app.state.create_project( name=self.project_name.value, description=self.project_description.value, @@ -346,9 +358,21 @@ def submit(self): def form(self) -> list[Renderable]: return [ Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), - TextInput((11, 14), (2, self.width - 15), self.project_name, **FIELD_COLORS), + TextInput( + (11, 14), + (2, self.width - 15), + self.project_name, + placeholder="This will be used to create a new directory. Must be snake_case.", + **FIELD_COLORS, + ), Text((13, 2), (1, 12), color=COLOR_FORM, value="Description"), - TextInput((13, 14), (5, self.width - 15), self.project_description, **FIELD_COLORS), + TextInput( + (13, 14), + (5, self.width - 15), + self.project_description, + placeholder="Describe what you project will do.", + **FIELD_COLORS, + ), ] @@ -464,16 +488,37 @@ def form(self) -> list[Renderable]: large_field_height = min(5, round((self.height - 17) / 3)) return [ Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), - TextInput((11, 14), (2, self.width - 16), self.agent_name, **FIELD_COLORS), - + TextInput( + (11, 14), + (2, self.width - 16), + self.agent_name, + placeholder="A unique name for this agent. Must be snake_case.", + **FIELD_COLORS, + ), Text((13, 2), (1, 12), color=COLOR_FORM, value="Role"), - TextInput((13, 14), (large_field_height, self.width - 16), self.agent_role, **FIELD_COLORS), - + TextInput( + (13, 14), + (large_field_height, self.width - 16), + self.agent_role, + placeholder="A prompt to the agent that describes the role it takes in your project.", + ** FIELD_COLORS, + ), Text((13 + large_field_height, 2), (1, 12), color=COLOR_FORM, value="Goal"), - TextInput((13 + large_field_height, 14), (large_field_height, self.width - 16), self.agent_goal, **FIELD_COLORS), - + TextInput( + (13 + large_field_height, 14), + (large_field_height, self.width - 16), + self.agent_goal, + placeholder="A prompt to the agent that describes the goal it is trying to achieve.", + **FIELD_COLORS, + ), Text((13 + (large_field_height * 2), 2), (1, 12), color=COLOR_FORM, value="Backstory"), - TextInput((13 + (large_field_height * 2), 14), (large_field_height, self.width - 16), self.agent_backstory, **FIELD_COLORS), + TextInput( + (13 + (large_field_height * 2), 14), + (large_field_height, self.width - 16), + self.agent_backstory, + placeholder="A prompt to the agent that describes the backstory of it's purpose.", + **FIELD_COLORS, + ), ] @@ -759,13 +804,29 @@ def form(self) -> list[Renderable]: large_field_height = min(5, round((self.height - 17) / 3)) return [ Text((11, 2), (1, 12), color=COLOR_FORM, value="Name"), - TextInput((11, 14), (2, self.width - 16), self.task_name, **FIELD_COLORS), - + TextInput( + (11, 14), + (2, self.width - 16), + self.task_name, + placeholder="A unique name for this task. Must be snake_case.", + **FIELD_COLORS, + ), Text((13, 2), (1, 12), color=COLOR_FORM, value="Description"), - TextInput((13, 14), (large_field_height, self.width - 16), self.task_description, **FIELD_COLORS), - + TextInput( + (13, 14), + (large_field_height, self.width - 16), + self.task_description, + placeholder="A prompt for this task that describes what should be done.", + **FIELD_COLORS, + ), Text((13 + large_field_height, 2), (2, 12), color=COLOR_FORM, value="Expected\nOutput"), - TextInput((13 + large_field_height, 14), (large_field_height, self.width - 16), self.expected_output, **FIELD_COLORS), + TextInput( + (13 + large_field_height, 14), + (large_field_height, self.width - 16), + self.expected_output, + placeholder="A prompt for this task that describes what the output should look like.", + **FIELD_COLORS, + ), ] @@ -957,12 +1018,12 @@ class WizardApp(App): 'task_agent_selection', 'after_task', ], - 'tool': [ # add tools to an agent + 'tool': [ # add tools to an agent 'tool_agent_selection', 'tool_category', 'tool', 'after_agent', - ] + ], } state: State @@ -988,7 +1049,6 @@ def finish(self): self.stop() if self._finish_run_once: - log.set_stdout(sys.stdout) # re-enable on-screen logging init_project( @@ -999,7 +1059,6 @@ def finish(self): template.write_to_file(conf.PATH / "wizard") log.info(f"Saved template to: {conf.PATH / 'wizard.json'}") - def advance(self, steps: int = 1): """Load the next view in the active workflow.""" assert self.active_workflow, "No active workflow set." diff --git a/agentstack/tui.py b/agentstack/tui.py index cc83b599..6df093ef 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -576,7 +576,7 @@ def __init__( self, coords, dims, - node: Node, + node: Node, color=None, ): super().__init__(coords, dims, node=node, color=color) @@ -675,7 +675,6 @@ def render(self) -> None: color = self.color.to_curses() | curses.A_ITALIC else: color = self.color.to_curses() - for i, line in enumerate(self._get_lines(str(self.value))): self.grid.addstr(i, 0, line, color) From 2fcbf23bfd995d774ed2c8a429d236c240e6b9d9 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Mon, 10 Feb 2025 12:25:42 -0800 Subject: [PATCH 33/34] Docstrings for TUI. --- agentstack/tui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentstack/tui.py b/agentstack/tui.py index 6df093ef..568e6600 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -576,7 +576,7 @@ def __init__( self, coords, dims, - node: Node, + node: Node, color=None, ): super().__init__(coords, dims, node=node, color=color) From 87df3dd00ffa1e23df690b198df93bdd45180c36 Mon Sep 17 00:00:00 2001 From: Travis Dent Date: Fri, 14 Feb 2025 09:32:00 -0800 Subject: [PATCH 34/34] Restore variable. --- agentstack/cli/wizard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/agentstack/cli/wizard.py b/agentstack/cli/wizard.py index b194ba07..d114f4a3 100644 --- a/agentstack/cli/wizard.py +++ b/agentstack/cli/wizard.py @@ -1049,6 +1049,7 @@ def finish(self): self.stop() if self._finish_run_once: + self._finish_run_once = False log.set_stdout(sys.stdout) # re-enable on-screen logging init_project(