diff --git a/MainContext.md b/MainContext.md index 6245b90..27a9b4d 100644 --- a/MainContext.md +++ b/MainContext.md @@ -5,6 +5,8 @@ Make sure to update this file every time. DO NOT EVER make a shell script unless told to. DO NOT EVER make a readme or a file describing your changes unless your are told to. If you have commands I should be entering into the command line or if you have something to explain to me, please just use code blocks or normal text output. DO NOT DO ANYTHING OTHER THAN WHAT YOU WERE TOLD TODO. DO NOT EVER, EVER DO ANYTHING OTHER THAN WHAT YOU WERE TOLD TO DO. IF YOU WANT TO DO OTHER THINGS, SIMPLY SUGGEST THEM, AND THEN I WILL REVIEW YOUR CHANGES, AND MAKE THE DECISION ON HOW TO PROCEED. WHEN WRITING SCRIPTS USE A 120-160 character limit per line. I don't want to see scrunched code. +Make destructive modifications to the project, ITS OK, I HAVE GIT HISTORY TO MANAGE THE PROJECTS. + ## Summary Is a local GUI tool for manually curating and sending context to AI APIs. It aggregates files, screenshots, and discussion history into a structured markdown file and sends it to a chosen AI provider with a user-written message. The AI can also execute PowerShell scripts within the project directory, with user confirmation required before each execution. diff --git a/config.toml b/config.toml index 4b12e08..4a0356f 100644 --- a/config.toml +++ b/config.toml @@ -1,44 +1,3 @@ -[output] -namespace = "manual_slop" -output_dir = "./md_gen" - -[files] -base_dir = "C:/projects/manual_slop" -paths = [ - "config.toml", - "ai_client.py", - "aggregate.py", - "gemini.py", - "gui.py", - "pyproject.toml", - "MainContext.md", - "C:/projects/manual_slop/shell_runner.py", - "C:/projects/manual_slop/session_logger.py", - "C:/projects/manual_slop/file_cache.py", - "C:/projects/manual_slop/theme.py", - "C:/projects/manual_slop/project_manager.py", - "C:/projects/manual_slop/manual_slop.toml", -] - -[screenshots] -base_dir = "C:/Users/Ed/scoop/apps/sharex/current/ShareX/Screenshots/2026-02" -paths = [] - -[discussion] -history = [ - "User:\nLets add multiple project support. currently this project would be the \"manual_slop\" project. \nI need to make a separate \"manual_slop.toml\" for this and offload things that would be within a per project to it.\nI also want to have several discussion histories that I can label. \nThis would allow me to stash and continue separate effort's histories..\nIt would be important if these discussions saved the last git commit they were on. (I'll need a buton to manually update it).\nAnd timestamps on each discussion entry to give an idea of the duration involved and \nhow the project has evolved since the discussion stream's effort was last done.\nBasically preserve context so ai can use that as valuable information in its working memory.\n\nTo load a project I'll need to hoist it into the config by providing the path.\nMaybe allow multiple project tomls to be specified path wise in the array.", - "User:\nOk your started to work on multiple-project support, you added project_manager.py and manaual_slop.toml, its not being used yet. the rest of the codebase hasn't intergrated with the changes.\nThere might be something wrong with powershell tool use. If you suspect so try to fix that but otherwise lets finish the support for multiple projects.", - "AI:\nGood. Now the big one — rewriting `gui.py`. The integration points are:\n\n- Load/save via `project_manager` instead of raw `config.toml` for project data\n- A **Projects** panel: list of loaded project paths, add/remove, switch active project, \"New Project\" button\n- A **Discussions** sub-section inside Discussion History panel: list named discussions, create/rename/delete/switch active, show git commit + timestamp, \"Update Commit\" button\n- `disc_entries` timestamps wired up via `entry_to_str`/`str_to_entry`\n- `_flush_to_project()` saves to the active `.toml`; `_flush_to_config()` saves global config only", - "User:\nSure do it, I can't restart the program till you do those changes cause otherwise the config will not work.", - "AI:\nI have a full picture of the current state. The plan is clear. I'll write the new `gui.py` as a Python script to avoid all PowerShell string-escaping issues — this is a full rewrite of that file since the integration touches nearly every part of it.\n\nKey changes:\n- `App.__init__` loads from `config.toml` for global settings (`[ai]`, `[theme]`, `[projects]`), then loads the active project `.toml` via `project_manager`\n- `_flush_to_project()` / `_flush_to_config()` save to the right places\n- **Projects panel** (`win_projects`): list of project paths from `config.toml [projects]`, add/remove, switch active — reloads all project-derived state\n- **Discussion selector** inside the Discussion History panel: list named discussions, create new, rename, delete, switch — each has git commit + last_updated shown, \"Update Commit\" button\n- `disc_entries` now round-trips through `project_manager.entry_to_str` / `str_to_entry` (preserving `@timestamp` lines)\n- New entries get a `ts` stamp when appended via `-> History`", -] -roles = [ - "User", - "AI", - "Vendor API", - "System", -] - [ai] provider = "anthropic" model = "claude-sonnet-4-6" @@ -48,3 +7,9 @@ palette = "10x Dark" font_path = "C:/Users/Ed/AppData/Local/uv/cache/archive-v0/WSthkYsQ82b_ywV6DkiaJ/pygame_gui/data/FiraCode-Regular.ttf" font_size = 18.0 scale = 1.1 + +[projects] +paths = [ + "manual_slop.toml", +] +active = "manual_slop.toml" diff --git a/dpg_layout.ini b/dpg_layout.ini index 2ad3bb1..13d5ce2 100644 --- a/dpg_layout.ini +++ b/dpg_layout.ini @@ -10,13 +10,13 @@ Collapsed=0 [Window][###22] Pos=0,0 -Size=498,409 +Size=473,417 Collapsed=0 -DockId=0x0000003D,0 +DockId=0x00000013,1 [Window][###30] Pos=0,781 -Size=498,726 +Size=473,726 Collapsed=0 DockId=0x0000003B,0 @@ -45,10 +45,10 @@ Collapsed=0 DockId=0x0000002A,0 [Window][###103] -Pos=1613,1330 -Size=2227,807 +Pos=475,0 +Size=1102,2137 Collapsed=0 -DockId=0x0000001C,0 +DockId=0x00000011,1 [Window][###86] Pos=378,0 @@ -62,10 +62,10 @@ Size=700,440 Collapsed=0 [Window][###126] -Pos=500,0 -Size=768,2137 +Pos=475,0 +Size=809,2137 Collapsed=0 -DockId=0x00000013,2 +DockId=0x00000012,2 [Window][###147] Pos=1578,868 @@ -89,28 +89,28 @@ Collapsed=0 DockId=0x00000017,0 [Window][###83] -Pos=366,0 -Size=847,425 +Pos=475,0 +Size=1102,2137 Collapsed=0 DockId=0x00000011,0 [Window][###91] -Pos=1270,0 -Size=1049,2137 +Pos=2817,1071 +Size=1023,1066 Collapsed=0 -DockId=0x00000014,0 +DockId=0x00000040,0 [Window][###98] Pos=2531,0 Size=1309,1690 Collapsed=0 -DockId=0x00000031,0 +DockId=0x00000037,0 [Window][###106] -Pos=500,0 -Size=768,2137 +Pos=475,0 +Size=809,2137 Collapsed=0 -DockId=0x00000013,0 +DockId=0x00000012,0 [Window][###100] Pos=396,0 @@ -147,7 +147,7 @@ Collapsed=0 Pos=376,0 Size=942,2137 Collapsed=0 -DockId=0x00000013,2 +DockId=0x00000012,2 [Window][###78] Pos=0,1422 @@ -165,7 +165,7 @@ DockId=0x00000015,0 Pos=551,0 Size=1060,2137 Collapsed=0 -DockId=0x00000031,1 +DockId=0x00000037,1 [Window][###110] Pos=2438,0 @@ -177,7 +177,7 @@ DockId=0x00000016,0 Pos=500,0 Size=745,2137 Collapsed=0 -DockId=0x00000013,0 +DockId=0x00000012,0 [Window][###145] Pos=1578,868 @@ -213,7 +213,7 @@ Collapsed=0 Pos=351,0 Size=645,1548 Collapsed=0 -DockId=0x00000013,0 +DockId=0x00000012,0 [Window][###75] Pos=0,1352 @@ -225,13 +225,13 @@ DockId=0x00000022,0 Pos=1181,0 Size=1224,2137 Collapsed=0 -DockId=0x00000031,0 +DockId=0x00000037,0 [Window][###92] Pos=376,0 Size=942,2137 Collapsed=0 -DockId=0x00000013,0 +DockId=0x00000012,0 [Window][###107] Pos=1525,1414 @@ -240,10 +240,10 @@ Collapsed=0 DockId=0x0000001A,0 [Window][###109] -Pos=500,0 -Size=858,2137 +Pos=475,0 +Size=1102,2137 Collapsed=0 -DockId=0x00000013,0 +DockId=0x00000011,2 [Window][###142] Pos=0,328 @@ -302,13 +302,13 @@ DockId=0x00000028,0 Pos=998,0 Size=853,602 Collapsed=0 -DockId=0x00000031,0 +DockId=0x00000037,0 [Window][###89] Pos=351,0 Size=645,1548 Collapsed=0 -DockId=0x00000013,1 +DockId=0x00000012,1 [Window][###97] Pos=1247,0 @@ -665,7 +665,7 @@ DockId=0x00000034,0 Pos=1360,0 Size=959,2137 Collapsed=0 -DockId=0x00000038,0 +DockId=0x00000012,0 [Window][###117] Pos=2321,0 @@ -674,22 +674,22 @@ Collapsed=0 DockId=0x00000035,0 [Window][###125] -Pos=2321,1160 -Size=1519,977 +Pos=0,0 +Size=473,417 Collapsed=0 -DockId=0x00000031,0 +DockId=0x00000013,0 [Window][###129] Pos=500,0 Size=858,2137 Collapsed=0 -DockId=0x00000013,2 +DockId=0x00000012,2 [Window][###135] Pos=500,0 Size=858,2137 Collapsed=0 -DockId=0x00000013,1 +DockId=0x00000012,1 [Window][###341] Pos=1578,868 @@ -719,7 +719,7 @@ DockId=0x0000003A,0 [Window][###81] Pos=0,1509 -Size=498,628 +Size=473,628 Collapsed=0 DockId=0x0000003C,0 @@ -727,19 +727,19 @@ DockId=0x0000003C,0 Pos=2304,0 Size=1536,1069 Collapsed=0 -DockId=0x00000031,0 +DockId=0x00000037,0 [Window][###132] -Pos=500,0 -Size=768,2137 +Pos=475,0 +Size=809,2137 Collapsed=0 -DockId=0x00000013,1 +DockId=0x00000012,1 [Window][###138] Pos=500,0 Size=745,2137 Collapsed=0 -DockId=0x00000013,1 +DockId=0x00000012,1 [Window][###358] Pos=1578,868 @@ -758,21 +758,21 @@ Collapsed=0 [Window][###148] Pos=0,411 -Size=498,368 +Size=473,368 Collapsed=0 DockId=0x0000003E,0 [Window][###114] -Pos=2321,1071 -Size=1519,1066 +Pos=2348,1071 +Size=1492,1066 Collapsed=0 DockId=0x00000032,0 [Window][###122] -Pos=2321,0 -Size=1519,1069 +Pos=2348,0 +Size=1492,1069 Collapsed=0 -DockId=0x00000031,0 +DockId=0x00000037,0 [Window][###240] Pos=1578,868 @@ -819,68 +819,150 @@ Pos=1578,868 Size=700,440 Collapsed=0 -[Docking][Data] -DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,0 Size=3840,2137 Split=X Selected=0x40484D8F - DockNode ID=0x00000003 Parent=0x7C6B3D9B SizeRef=498,1161 Split=Y Selected=0xEE087978 - DockNode ID=0x00000005 Parent=0x00000003 SizeRef=235,779 Split=Y Selected=0xEE087978 - DockNode ID=0x0000002D Parent=0x00000005 SizeRef=374,239 Split=Y Selected=0xEE087978 - DockNode ID=0x0000002F Parent=0x0000002D SizeRef=374,326 Split=Y Selected=0xEE087978 - DockNode ID=0x00000033 Parent=0x0000002F SizeRef=394,253 Split=Y Selected=0xEE087978 - DockNode ID=0x00000039 Parent=0x00000033 SizeRef=498,410 Split=Y Selected=0xEE087978 - DockNode ID=0x0000003D Parent=0x00000039 SizeRef=498,409 Selected=0xEE087978 - DockNode ID=0x0000003E Parent=0x00000039 SizeRef=498,368 Selected=0x2349CB28 - DockNode ID=0x0000003A Parent=0x00000033 SizeRef=498,240 Selected=0x2DE5F58C - DockNode ID=0x00000034 Parent=0x0000002F SizeRef=394,397 Selected=0xE5057AFC - DockNode ID=0x00000030 Parent=0x0000002D SizeRef=374,324 Selected=0x69F9D389 - DockNode ID=0x0000002E Parent=0x00000005 SizeRef=374,411 Selected=0xFBBC1691 - DockNode ID=0x00000006 Parent=0x00000003 SizeRef=235,1356 Split=Y Selected=0x5F94F9BD - DockNode ID=0x00000009 Parent=0x00000006 SizeRef=235,453 Split=Y Selected=0x5F94F9BD - DockNode ID=0x0000001D Parent=0x00000009 SizeRef=364,766 Split=Y Selected=0x5F94F9BD - DockNode ID=0x00000021 Parent=0x0000001D SizeRef=549,696 Split=Y Selected=0x5F94F9BD - DockNode ID=0x00000027 Parent=0x00000021 SizeRef=549,793 Split=Y Selected=0x5F94F9BD - DockNode ID=0x0000003B Parent=0x00000027 SizeRef=498,726 Selected=0x5F94F9BD - DockNode ID=0x0000003C Parent=0x00000027 SizeRef=498,628 Selected=0x083320CE - DockNode ID=0x00000028 Parent=0x00000021 SizeRef=549,688 Selected=0xBEC5E8CB - DockNode ID=0x00000022 Parent=0x0000001D SizeRef=549,785 Selected=0x0CE534DB - DockNode ID=0x0000001E Parent=0x00000009 SizeRef=364,715 Selected=0xF475F06A - DockNode ID=0x0000000A Parent=0x00000006 SizeRef=235,350 Selected=0x80199DAE - DockNode ID=0x00000004 Parent=0x7C6B3D9B SizeRef=3340,1161 Split=X - DockNode ID=0x00000001 Parent=0x00000004 SizeRef=1060,1161 Split=Y Selected=0x40484D8F - DockNode ID=0x00000007 Parent=0x00000001 SizeRef=595,492 Selected=0xBA13FCDE - DockNode ID=0x00000008 Parent=0x00000001 SizeRef=595,1643 Split=X Selected=0x40484D8F - DockNode ID=0x0000000F Parent=0x00000008 SizeRef=1819,2137 Split=Y Selected=0x07E8375F - DockNode ID=0x00000011 Parent=0x0000000F SizeRef=835,425 Selected=0x72F373AE - DockNode ID=0x00000012 Parent=0x0000000F SizeRef=835,1710 Split=X Selected=0xC6DC3F21 - DockNode ID=0x00000037 Parent=0x00000012 SizeRef=858,2137 Split=X Selected=0x4A209654 - DockNode ID=0x00000013 Parent=0x00000037 SizeRef=768,2137 Selected=0x74FCE331 - DockNode ID=0x00000014 Parent=0x00000037 SizeRef=1049,2137 Selected=0xC36FF36B - DockNode ID=0x00000038 Parent=0x00000012 SizeRef=959,2137 Selected=0x0B8F7C1B - DockNode ID=0x00000010 Parent=0x00000008 SizeRef=1519,2137 Split=X Selected=0xCE7F911A - DockNode ID=0x00000017 Parent=0x00000010 SizeRef=1314,1749 Selected=0x4B454E0B - DockNode ID=0x00000018 Parent=0x00000010 SizeRef=1309,1749 Split=Y Selected=0x88A8C2FF - DockNode ID=0x00000019 Parent=0x00000018 SizeRef=2440,1412 Split=X Selected=0x88A8C2FF - DockNode ID=0x00000023 Parent=0x00000019 SizeRef=2009,737 Split=Y Selected=0x4F935A1E - DockNode ID=0x00000025 Parent=0x00000023 SizeRef=2315,1244 Split=X Selected=0x4F935A1E - DockNode ID=0x00000029 Parent=0x00000025 SizeRef=853,1867 Split=Y Selected=0xFDB3860E - DockNode ID=0x00000035 Parent=0x00000029 SizeRef=2500,1158 Selected=0xF1D4CD4A - DockNode ID=0x00000036 Parent=0x00000029 SizeRef=2500,977 Split=Y Selected=0xF5102835 - DockNode ID=0x0000001F Parent=0x00000036 SizeRef=1325,1069 Split=Y Selected=0xBFA03094 - DockNode ID=0x00000031 Parent=0x0000001F SizeRef=1519,1069 CentralNode=1 Selected=0xBFA03094 - DockNode ID=0x00000032 Parent=0x0000001F SizeRef=1519,1066 Selected=0xB674B79A - DockNode ID=0x00000020 Parent=0x00000036 SizeRef=1325,1066 Selected=0xC56063F4 - DockNode ID=0x0000002A Parent=0x00000025 SizeRef=890,1867 Selected=0x40484D8F - DockNode ID=0x00000026 Parent=0x00000023 SizeRef=2315,621 Selected=0x7D28643F - DockNode ID=0x00000024 Parent=0x00000019 SizeRef=1433,737 Split=Y Selected=0xB8D8893E - DockNode ID=0x0000002B Parent=0x00000024 SizeRef=1153,968 Selected=0xB8D8893E - DockNode ID=0x0000002C Parent=0x00000024 SizeRef=1153,1167 Selected=0xCCB4E4FA - DockNode ID=0x0000001A Parent=0x00000018 SizeRef=2440,723 Selected=0x3A881EEF - DockNode ID=0x00000002 Parent=0x00000004 SizeRef=2227,1161 Split=X Selected=0x714F2F7B - DockNode ID=0x0000000B Parent=0x00000002 SizeRef=968,1161 Selected=0xC915D9DA - DockNode ID=0x0000000C Parent=0x00000002 SizeRef=1661,1161 Split=Y Selected=0x714F2F7B - DockNode ID=0x0000000D Parent=0x0000000C SizeRef=396,342 Selected=0x714F2F7B - DockNode ID=0x0000000E Parent=0x0000000C SizeRef=396,817 Split=Y Selected=0xCF08B82F - DockNode ID=0x0000001B Parent=0x0000000E SizeRef=2104,1328 Split=X Selected=0x43F4115A - DockNode ID=0x00000015 Parent=0x0000001B SizeRef=823,1328 Selected=0x052342BF - DockNode ID=0x00000016 Parent=0x0000001B SizeRef=1402,1328 Selected=0x43F4115A - DockNode ID=0x0000001C Parent=0x0000000E SizeRef=2104,807 Selected=0xCF08B82F +[Window][###275] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###350] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###440] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###520] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###618] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###734] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###851] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###1199] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###1327] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###1624] +Pos=1578,868 +Size=700,440 +Collapsed=0 + +[Window][###36] +Pos=0,0 +Size=473,417 +Collapsed=0 +DockId=0x00000013,2 + +[Window][###43] +Pos=0,419 +Size=473,781 +Collapsed=0 +DockId=0x00000014,0 + +[Window][###55] +Pos=0,1202 +Size=473,935 +Collapsed=0 +DockId=0x0000001C,0 + +[Window][###65] +Pos=1579,0 +Size=1236,2137 +Collapsed=0 +DockId=0x00000037,0 + +[Window][###99] +Pos=2817,0 +Size=1023,1069 +Collapsed=0 +DockId=0x0000003F,0 + +[Docking][Data] +DockSpace ID=0x7C6B3D9B Window=0xA87D555D Pos=0,0 Size=3840,2137 Split=X Selected=0x40484D8F + DockNode ID=0x00000003 Parent=0x7C6B3D9B SizeRef=473,1161 Split=Y Selected=0xEE087978 + DockNode ID=0x00000005 Parent=0x00000003 SizeRef=235,779 Split=Y Selected=0xEE087978 + DockNode ID=0x0000002D Parent=0x00000005 SizeRef=374,239 Split=Y Selected=0xEE087978 + DockNode ID=0x0000002F Parent=0x0000002D SizeRef=374,326 Split=Y Selected=0xEE087978 + DockNode ID=0x00000033 Parent=0x0000002F SizeRef=394,253 Split=Y Selected=0xEE087978 + DockNode ID=0x00000039 Parent=0x00000033 SizeRef=498,410 Split=Y Selected=0xEE087978 + DockNode ID=0x0000003D Parent=0x00000039 SizeRef=498,409 Split=Y Selected=0xD0D40C1D + DockNode ID=0x0000001B Parent=0x0000003D SizeRef=473,1200 Split=Y Selected=0xD0D40C1D + DockNode ID=0x00000013 Parent=0x0000001B SizeRef=473,417 Selected=0xEE087978 + DockNode ID=0x00000014 Parent=0x0000001B SizeRef=473,781 Selected=0x0531B3D5 + DockNode ID=0x0000001C Parent=0x0000003D SizeRef=473,935 Selected=0x412D95D0 + DockNode ID=0x0000003E Parent=0x00000039 SizeRef=498,368 Selected=0x2349CB28 + DockNode ID=0x0000003A Parent=0x00000033 SizeRef=498,240 Selected=0x2DE5F58C + DockNode ID=0x00000034 Parent=0x0000002F SizeRef=394,397 Selected=0xE5057AFC + DockNode ID=0x00000030 Parent=0x0000002D SizeRef=374,324 Selected=0x69F9D389 + DockNode ID=0x0000002E Parent=0x00000005 SizeRef=374,411 Selected=0xFBBC1691 + DockNode ID=0x00000006 Parent=0x00000003 SizeRef=235,1356 Split=Y Selected=0x5F94F9BD + DockNode ID=0x00000009 Parent=0x00000006 SizeRef=235,453 Split=Y Selected=0x5F94F9BD + DockNode ID=0x0000001D Parent=0x00000009 SizeRef=364,766 Split=Y Selected=0x5F94F9BD + DockNode ID=0x00000021 Parent=0x0000001D SizeRef=549,696 Split=Y Selected=0x5F94F9BD + DockNode ID=0x00000027 Parent=0x00000021 SizeRef=549,793 Split=Y Selected=0x5F94F9BD + DockNode ID=0x0000003B Parent=0x00000027 SizeRef=498,726 Selected=0x5F94F9BD + DockNode ID=0x0000003C Parent=0x00000027 SizeRef=498,628 Selected=0x083320CE + DockNode ID=0x00000028 Parent=0x00000021 SizeRef=549,688 Selected=0xBEC5E8CB + DockNode ID=0x00000022 Parent=0x0000001D SizeRef=549,785 Selected=0x0CE534DB + DockNode ID=0x0000001E Parent=0x00000009 SizeRef=364,715 Selected=0xF475F06A + DockNode ID=0x0000000A Parent=0x00000006 SizeRef=235,350 Selected=0x80199DAE + DockNode ID=0x00000004 Parent=0x7C6B3D9B SizeRef=3365,1161 Split=X + DockNode ID=0x00000001 Parent=0x00000004 SizeRef=1885,1161 Split=Y Selected=0x40484D8F + DockNode ID=0x00000007 Parent=0x00000001 SizeRef=595,492 Selected=0xBA13FCDE + DockNode ID=0x00000008 Parent=0x00000001 SizeRef=595,1643 Split=X Selected=0x40484D8F + DockNode ID=0x0000000F Parent=0x00000008 SizeRef=1102,2137 Split=Y Selected=0x07E8375F + DockNode ID=0x00000011 Parent=0x0000000F SizeRef=835,425 Selected=0x72F373AE + DockNode ID=0x00000012 Parent=0x0000000F SizeRef=835,1710 Selected=0xC6DC3F21 + DockNode ID=0x00000010 Parent=0x00000008 SizeRef=781,2137 Split=X Selected=0xCE7F911A + DockNode ID=0x00000017 Parent=0x00000010 SizeRef=1314,1749 Selected=0x4B454E0B + DockNode ID=0x00000018 Parent=0x00000010 SizeRef=1309,1749 Split=Y Selected=0x88A8C2FF + DockNode ID=0x00000019 Parent=0x00000018 SizeRef=2440,1412 Split=X Selected=0x88A8C2FF + DockNode ID=0x00000023 Parent=0x00000019 SizeRef=2009,737 Split=Y Selected=0x4F935A1E + DockNode ID=0x00000025 Parent=0x00000023 SizeRef=2315,1244 Split=X Selected=0x4F935A1E + DockNode ID=0x00000029 Parent=0x00000025 SizeRef=853,1867 Split=Y Selected=0xFDB3860E + DockNode ID=0x00000035 Parent=0x00000029 SizeRef=2500,1158 Selected=0xF1D4CD4A + DockNode ID=0x00000036 Parent=0x00000029 SizeRef=2500,977 Split=Y Selected=0xF5102835 + DockNode ID=0x0000001F Parent=0x00000036 SizeRef=1325,1069 Split=Y Selected=0xBFA03094 + DockNode ID=0x00000031 Parent=0x0000001F SizeRef=1519,1069 Split=X Selected=0xC7B9E77E + DockNode ID=0x00000037 Parent=0x00000031 SizeRef=1236,2137 CentralNode=1 Selected=0xC7B9E77E + DockNode ID=0x00000038 Parent=0x00000031 SizeRef=1023,2137 Split=Y Selected=0xF31FB8AA + DockNode ID=0x0000003F Parent=0x00000038 SizeRef=1023,1069 Selected=0xF31FB8AA + DockNode ID=0x00000040 Parent=0x00000038 SizeRef=1023,1066 Selected=0xC36FF36B + DockNode ID=0x00000032 Parent=0x0000001F SizeRef=1519,1066 Selected=0xB674B79A + DockNode ID=0x00000020 Parent=0x00000036 SizeRef=1325,1066 Selected=0xC56063F4 + DockNode ID=0x0000002A Parent=0x00000025 SizeRef=890,1867 Selected=0x40484D8F + DockNode ID=0x00000026 Parent=0x00000023 SizeRef=2315,621 Selected=0x7D28643F + DockNode ID=0x00000024 Parent=0x00000019 SizeRef=1433,737 Split=Y Selected=0xB8D8893E + DockNode ID=0x0000002B Parent=0x00000024 SizeRef=1153,968 Selected=0xB8D8893E + DockNode ID=0x0000002C Parent=0x00000024 SizeRef=1153,1167 Selected=0xCCB4E4FA + DockNode ID=0x0000001A Parent=0x00000018 SizeRef=2440,723 Selected=0x3A881EEF + DockNode ID=0x00000002 Parent=0x00000004 SizeRef=1478,1161 Split=X Selected=0x714F2F7B + DockNode ID=0x0000000B Parent=0x00000002 SizeRef=968,1161 Selected=0xC915D9DA + DockNode ID=0x0000000C Parent=0x00000002 SizeRef=1661,1161 Split=Y Selected=0x714F2F7B + DockNode ID=0x0000000D Parent=0x0000000C SizeRef=396,342 Selected=0x714F2F7B + DockNode ID=0x0000000E Parent=0x0000000C SizeRef=396,817 Split=X Selected=0xCF08B82F + DockNode ID=0x00000015 Parent=0x0000000E SizeRef=823,1328 Selected=0x052342BF + DockNode ID=0x00000016 Parent=0x0000000E SizeRef=1402,1328 Selected=0x43F4115A diff --git a/gui.py b/gui.py index 3d1014c..c16d477 100644 --- a/gui.py +++ b/gui.py @@ -10,6 +10,7 @@ import ai_client from ai_client import ProviderError import shell_runner import session_logger +import project_manager import theme CONFIG_PATH = Path("config.toml") @@ -289,59 +290,52 @@ DISC_ROLES = ["User", "AI", "Vendor API", "System"] def _parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict]: """ - Convert the raw TOML string array into a flat list of {role, content, collapsed} dicts. - Each TOML string is one excerpt (may contain multiple role blocks separated - by lines like "Role:" or "[Role]"). We detect the common patterns: - "User:\n..." "AI:\n..." "[User]\n..." "[AI]\n..." - and split accordingly. Unrecognised text becomes a User entry. - roles: list of known role strings (defaults to module DISC_ROLES if None). + Convert the raw TOML string array into a flat list of {role, content, collapsed, ts} dicts. + Supports both legacy format (no timestamps) and new format (@timestamp prefix). """ - import re known = roles if roles is not None else DISC_ROLES entries: list[dict] = [] - role_pattern = re.compile( - r'^(?:\[)?(' + '|'.join(re.escape(r) for r in known) + r')(?:\])?:?\s*$', - re.IGNORECASE | re.MULTILINE, - ) - for excerpt in history: - # Find all role header positions - splits = [(m.start(), m.end(), m.group(1)) for m in role_pattern.finditer(excerpt)] - if not splits: - # No role headers found - treat whole excerpt as User content - text = excerpt.strip() - if text: - entries.append({"role": "User", "content": text, "collapsed": False}) - continue - # Extract content between headers - for idx, (start, end, role) in enumerate(splits): - next_start = splits[idx + 1][0] if idx + 1 < len(splits) else len(excerpt) - content = excerpt[end:next_start].strip() - # Normalise role capitalisation to match known list - matched = next((r for r in known if r.lower() == role.lower()), role) - entries.append({"role": matched, "content": content, "collapsed": False}) + for raw in history: + entry = project_manager.str_to_entry(raw, known) + entries.append(entry) return entries + class App: def __init__(self): self.config = load_config() - self.files: list[str] = list(self.config["files"].get("paths", [])) - self.screenshots: list[str] = list( - self.config.get("screenshots", {}).get("paths", []) - ) - self.history: list[str] = list( - self.config.get("discussion", {}).get("history", []) - ) - - self.disc_roles: list[str] = list( - self.config.get("discussion", {}).get("roles", list(DISC_ROLES)) - ) - self.disc_entries: list[dict] = _parse_history_entries(self.history, self.disc_roles) + # ---- global settings from config.toml ---- ai_cfg = self.config.get("ai", {}) self.current_provider: str = ai_cfg.get("provider", "gemini") self.current_model: str = ai_cfg.get("model", "gemini-2.0-flash") self.available_models: list[str] = [] + # ---- project management ---- + projects_cfg = self.config.get("projects", {}) + self.project_paths: list[str] = list(projects_cfg.get("paths", [])) + self.active_project_path: str = projects_cfg.get("active", "") + + # The loaded project dict (from the active .toml file) + self.project: dict = {} + # The active discussion name within the project + self.active_discussion: str = "main" + + # Load the active project, or migrate from legacy config + self._load_active_project() + + # ---- project-derived state ---- + self.files: list[str] = list(self.project.get("files", {}).get("paths", [])) + self.screenshots: list[str] = list(self.project.get("screenshots", {}).get("paths", [])) + + disc_sec = self.project.get("discussion", {}) + self.disc_roles: list[str] = list(disc_sec.get("roles", list(DISC_ROLES))) + self.active_discussion = disc_sec.get("active", "main") + + disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) + history_strings = disc_data.get("history", []) + self.disc_entries: list[dict] = _parse_history_entries(history_strings, self.disc_roles) + self.ai_status = "idle" self.ai_response = "" self.last_md = "" @@ -366,6 +360,219 @@ class App: ai_client.comms_log_callback = self._on_comms_entry ai_client.tool_log_callback = self._on_tool_log + # ---------------------------------------------------------------- project loading + + def _load_active_project(self): + """ + Load the active project .toml. If no project paths configured or + active path is missing, attempt migration from legacy config.toml. + """ + # Try to load from the active path + if self.active_project_path and Path(self.active_project_path).exists(): + try: + self.project = project_manager.load_project(self.active_project_path) + return + except Exception as e: + print(f"Failed to load project {self.active_project_path}: {e}") + + # Try first available project path + for pp in self.project_paths: + if Path(pp).exists(): + try: + self.project = project_manager.load_project(pp) + self.active_project_path = pp + return + except Exception: + continue + + # No valid project file found — migrate from legacy config.toml + self.project = project_manager.migrate_from_legacy_config(self.config) + name = self.project.get("project", {}).get("name", "project") + fallback_path = f"{name}.toml" + project_manager.save_project(self.project, fallback_path) + self.active_project_path = fallback_path + if fallback_path not in self.project_paths: + self.project_paths.append(fallback_path) + + def _switch_project(self, path: str): + """Switch to a different project .toml file.""" + if not Path(path).exists(): + self._update_status(f"project file not found: {path}") + return + + # Save current project first + self._flush_to_project() + self._save_active_project() + + # Load the new one + try: + self.project = project_manager.load_project(path) + self.active_project_path = path + except Exception as e: + self._update_status(f"failed to load project: {e}") + return + + # Refresh all project-derived state + self._refresh_from_project() + + # Reset AI session since context changed + ai_client.reset_session() + self._update_status(f"switched to: {Path(path).stem}") + + def _refresh_from_project(self): + """Reload all GUI state from self.project after a project switch or discussion switch.""" + self.files = list(self.project.get("files", {}).get("paths", [])) + self.screenshots = list(self.project.get("screenshots", {}).get("paths", [])) + + disc_sec = self.project.get("discussion", {}) + self.disc_roles = list(disc_sec.get("roles", list(DISC_ROLES))) + self.active_discussion = disc_sec.get("active", "main") + + disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) + history_strings = disc_data.get("history", []) + self.disc_entries = _parse_history_entries(history_strings, self.disc_roles) + + # Update all GUI widgets + self._refresh_project_widgets() + self._rebuild_files_list() + self._rebuild_shots_list() + self._rebuild_disc_list() + self._rebuild_disc_roles_list() + self._rebuild_discussion_selector() + + def _refresh_project_widgets(self): + """Push project-level values into the GUI widgets.""" + proj = self.project + if dpg.does_item_exist("namespace"): + dpg.set_value("namespace", proj.get("output", {}).get("namespace", "")) + if dpg.does_item_exist("output_dir"): + dpg.set_value("output_dir", proj.get("output", {}).get("output_dir", "./md_gen")) + if dpg.does_item_exist("files_base_dir"): + dpg.set_value("files_base_dir", proj.get("files", {}).get("base_dir", ".")) + if dpg.does_item_exist("shots_base_dir"): + dpg.set_value("shots_base_dir", proj.get("screenshots", {}).get("base_dir", ".")) + if dpg.does_item_exist("project_name_text"): + name = proj.get("project", {}).get("name", Path(self.active_project_path).stem) + dpg.set_value("project_name_text", f"Active: {name}") + if dpg.does_item_exist("project_git_dir"): + dpg.set_value("project_git_dir", proj.get("project", {}).get("git_dir", "")) + + def _save_active_project(self): + """Write self.project to the active project .toml file.""" + if self.active_project_path: + try: + project_manager.save_project(self.project, self.active_project_path) + except Exception as e: + self._update_status(f"save error: {e}") + + # ---------------------------------------------------------------- discussion management + + def _get_discussion_names(self) -> list[str]: + """Return sorted list of discussion names in the active project.""" + disc_sec = self.project.get("discussion", {}) + discussions = disc_sec.get("discussions", {}) + return sorted(discussions.keys()) + + def _switch_discussion(self, name: str): + """Save current discussion entries, then switch to a different one.""" + # Save current entries into project + self._flush_disc_entries_to_project() + + disc_sec = self.project.get("discussion", {}) + discussions = disc_sec.get("discussions", {}) + if name not in discussions: + self._update_status(f"discussion not found: {name}") + return + + self.active_discussion = name + disc_sec["active"] = name + + disc_data = discussions[name] + history_strings = disc_data.get("history", []) + self.disc_entries = _parse_history_entries(history_strings, self.disc_roles) + + self._rebuild_disc_list() + self._rebuild_discussion_selector() + self._update_status(f"discussion: {name}") + + def _flush_disc_entries_to_project(self): + """Serialize current disc_entries back into the active discussion in self.project.""" + # Pull latest content from widgets + for i, entry in enumerate(self.disc_entries): + tag = f"disc_content_{i}" + if dpg.does_item_exist(tag): + entry["content"] = dpg.get_value(tag) + + history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries] + + disc_sec = self.project.setdefault("discussion", {}) + discussions = disc_sec.setdefault("discussions", {}) + disc_data = discussions.setdefault(self.active_discussion, project_manager.default_discussion()) + disc_data["history"] = history_strings + disc_data["last_updated"] = project_manager.now_ts() + + def _create_discussion(self, name: str): + """Create a new empty discussion in the active project.""" + disc_sec = self.project.setdefault("discussion", {}) + discussions = disc_sec.setdefault("discussions", {}) + if name in discussions: + self._update_status(f"discussion '{name}' already exists") + return + discussions[name] = project_manager.default_discussion() + self._switch_discussion(name) + + def _rename_discussion(self, old_name: str, new_name: str): + """Rename a discussion.""" + disc_sec = self.project.get("discussion", {}) + discussions = disc_sec.get("discussions", {}) + if old_name not in discussions: + return + if new_name in discussions: + self._update_status(f"discussion '{new_name}' already exists") + return + discussions[new_name] = discussions.pop(old_name) + if self.active_discussion == old_name: + self.active_discussion = new_name + disc_sec["active"] = new_name + self._rebuild_discussion_selector() + + def _delete_discussion(self, name: str): + """Delete a discussion. Cannot delete the last one.""" + disc_sec = self.project.get("discussion", {}) + discussions = disc_sec.get("discussions", {}) + if len(discussions) <= 1: + self._update_status("cannot delete the last discussion") + return + if name not in discussions: + return + del discussions[name] + if self.active_discussion == name: + # Switch to the first remaining discussion + remaining = sorted(discussions.keys()) + self._switch_discussion(remaining[0]) + else: + self._rebuild_discussion_selector() + + def _update_discussion_git_commit(self): + """Update the git commit hash on the active discussion.""" + git_dir = self.project.get("project", {}).get("git_dir", "") + if not git_dir: + git_dir = dpg.get_value("project_git_dir") if dpg.does_item_exist("project_git_dir") else "" + if not git_dir: + self._update_status("no git_dir configured") + return + commit = project_manager.get_git_commit(git_dir) + if not commit: + self._update_status("could not read git commit") + return + disc_sec = self.project.get("discussion", {}) + discussions = disc_sec.get("discussions", {}) + disc_data = discussions.get(self.active_discussion, {}) + disc_data["git_commit"] = commit + disc_data["last_updated"] = project_manager.now_ts() + self._rebuild_discussion_selector() + self._update_status(f"commit: {commit[:12]}") + # ---------------------------------------------------------------- comms log def _on_comms_entry(self, entry: dict): @@ -445,32 +652,59 @@ class App: # ---------------------------------------------------------------- helpers + def _flush_to_project(self): + """Pull all widget values into self.project (the active project dict).""" + proj = self.project + + # Output + proj.setdefault("output", {}) + if dpg.does_item_exist("namespace"): + proj["output"]["namespace"] = dpg.get_value("namespace") + if dpg.does_item_exist("output_dir"): + proj["output"]["output_dir"] = dpg.get_value("output_dir") + + # Files + proj.setdefault("files", {}) + if dpg.does_item_exist("files_base_dir"): + proj["files"]["base_dir"] = dpg.get_value("files_base_dir") + proj["files"]["paths"] = self.files + + # Screenshots + proj.setdefault("screenshots", {}) + if dpg.does_item_exist("shots_base_dir"): + proj["screenshots"]["base_dir"] = dpg.get_value("shots_base_dir") + proj["screenshots"]["paths"] = self.screenshots + + # Project metadata + proj.setdefault("project", {}) + if dpg.does_item_exist("project_git_dir"): + proj["project"]["git_dir"] = dpg.get_value("project_git_dir") + + # Discussion + self._flush_disc_entries_to_project() + disc_sec = proj.setdefault("discussion", {}) + disc_sec["roles"] = self.disc_roles + disc_sec["active"] = self.active_discussion + def _flush_to_config(self): - self.config["output"]["namespace"] = dpg.get_value("namespace") - self.config["output"]["output_dir"] = dpg.get_value("output_dir") - self.config["files"]["base_dir"] = dpg.get_value("files_base_dir") - if "screenshots" not in self.config: - self.config["screenshots"] = {} - self.config["screenshots"]["base_dir"] = dpg.get_value("shots_base_dir") - self.config["files"]["paths"] = self.files - self.config["screenshots"]["paths"] = self.screenshots - # Pull latest content edits from disc widgets - for i, entry in enumerate(self.disc_entries): - tag = f"disc_content_{i}" - if dpg.does_item_exist(tag): - entry["content"] = dpg.get_value(tag) - self.history = self._disc_serialize() - self.config["discussion"] = {"history": self.history, "roles": self.disc_roles} + """Pull global settings into self.config (config.toml).""" self.config["ai"] = { "provider": self.current_provider, "model": self.current_model, } + self.config["projects"] = { + "paths": self.project_paths, + "active": self.active_project_path, + } theme.save_to_config(self.config) def _do_generate(self) -> tuple[str, Path, list]: + self._flush_to_project() + self._save_active_project() self._flush_to_config() save_config(self.config) - return aggregate.run(self.config) + flat = project_manager.flat_config(self.project, self.active_discussion) + return aggregate.run(flat) def _update_status(self, status: str): self.ai_status = status @@ -515,6 +749,73 @@ class App: dpg.set_value("model_listbox", self.current_model) ai_client.set_provider(self.current_provider, self.current_model) + def _rebuild_projects_list(self): + if not dpg.does_item_exist("projects_scroll"): + return + dpg.delete_item("projects_scroll", children_only=True) + for i, pp in enumerate(self.project_paths): + is_active = (pp == self.active_project_path) + with dpg.group(horizontal=True, parent="projects_scroll"): + dpg.add_button( + label="x", width=24, callback=self._make_remove_project_cb(i) + ) + name_color = (140, 255, 160) if is_active else (200, 200, 200) + marker = " *" if is_active else "" + dpg.add_button( + label=f"{Path(pp).stem}{marker}", + callback=self._make_switch_project_cb(pp), + ) + dpg.add_text(pp, color=(140, 140, 140)) + + def _rebuild_discussion_selector(self): + """Rebuild the discussion selector UI: listbox + metadata for active discussion.""" + if not dpg.does_item_exist("disc_selector_group"): + return + dpg.delete_item("disc_selector_group", children_only=True) + + names = self._get_discussion_names() + + dpg.add_listbox( + tag="disc_listbox", + items=names, + default_value=self.active_discussion, + width=-1, + num_items=min(len(names), 5), + callback=self.cb_disc_switch, + parent="disc_selector_group", + ) + + # Show metadata for the active discussion + disc_sec = self.project.get("discussion", {}) + disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {}) + git_commit = disc_data.get("git_commit", "") + last_updated = disc_data.get("last_updated", "") + + with dpg.group(horizontal=False, parent="disc_selector_group"): + with dpg.group(horizontal=True): + dpg.add_text("commit:", color=(160, 160, 160)) + dpg.add_text( + git_commit[:12] if git_commit else "(none)", + color=(180, 255, 180) if git_commit else (120, 120, 120), + ) + dpg.add_button(label="Update Commit", callback=self.cb_update_git_commit) + with dpg.group(horizontal=True): + dpg.add_text("updated:", color=(160, 160, 160)) + dpg.add_text( + last_updated if last_updated else "(never)", + color=(200, 200, 160), + ) + + with dpg.group(horizontal=True, parent="disc_selector_group"): + dpg.add_input_text( + tag="disc_new_name_input", + hint="New discussion name", + width=-180, + ) + dpg.add_button(label="Create", callback=self.cb_disc_create) + dpg.add_button(label="Rename", callback=self.cb_disc_rename) + dpg.add_button(label="Delete", callback=self.cb_disc_delete) + def _make_remove_file_cb(self, idx: int): def cb(): if idx < len(self.files): @@ -529,6 +830,22 @@ class App: self._rebuild_shots_list() return cb + def _make_remove_project_cb(self, idx: int): + def cb(): + if idx < len(self.project_paths): + removed = self.project_paths.pop(idx) + if removed == self.active_project_path and self.project_paths: + self._switch_project(self.project_paths[0]) + self._rebuild_projects_list() + return cb + + def _make_switch_project_cb(self, path: str): + def cb(): + if path != self.active_project_path: + self._switch_project(path) + self._rebuild_projects_list() + return cb + def _fetch_models(self, provider: str): self._update_status("fetching models...") @@ -554,6 +871,8 @@ class App: dpg.set_value("output_dir", d) def cb_save_config(self): + self._flush_to_project() + self._save_active_project() self._flush_to_config() save_config(self.config) self._update_status("config saved") @@ -604,18 +923,6 @@ class App: self.screenshots.append(p) self._rebuild_shots_list() - def cb_add_excerpt(self): - current = dpg.get_value("discussion_box") - dpg.set_value("discussion_box", current + "\n---\n") - - def cb_clear_discussion(self): - dpg.set_value("discussion_box", "") - - def cb_save_discussion(self): - self._flush_to_config() - save_config(self.config) - self._update_status("discussion saved") - def cb_md_only(self): try: md, path, _file_items = self._do_generate() @@ -630,7 +937,6 @@ class App: ai_client.clear_comms_log() self._tool_log.clear() self._rebuild_tool_log() - # Clear pending queue and counter, then wipe the comms panel with self._pending_comms_lock: self._pending_comms.clear() self._comms_entry_count = 0 @@ -655,8 +961,6 @@ class App: user_msg = dpg.get_value("ai_input") base_dir = dpg.get_value("files_base_dir") - file_items_snap = self.last_file_items - def do_send(): try: response = ai_client.send(self.last_md, user_msg, base_dir) @@ -676,7 +980,6 @@ class App: self.available_models = [] self._rebuild_models_list() self._fetch_models(self.current_provider) - self._rebuild_disc_list() def cb_model_changed(self, sender, app_data): if app_data: @@ -700,16 +1003,166 @@ class App: if dpg.does_item_exist("comms_scroll"): dpg.delete_item("comms_scroll", children_only=True) - # ---------------------------------------------------------------- build ui + # ---- project callbacks ---- - # ------------------------------------------------------------ disc history + def cb_add_project(self): + root = hide_tk_root() + p = filedialog.askopenfilename( + title="Select Project .toml", + filetypes=[("TOML", "*.toml"), ("All", "*.*")], + ) + root.destroy() + if p and p not in self.project_paths: + self.project_paths.append(p) + self._rebuild_projects_list() - def _disc_serialize(self) -> list[str]: - """Flatten disc_entries back to a single TOML history string per logical block.""" - lines = [] - for e in self.disc_entries: - lines.append(f"{e['role']}:\n{e['content']}") - return lines + def cb_new_project(self): + root = hide_tk_root() + p = filedialog.asksaveasfilename( + title="Create New Project .toml", + defaultextension=".toml", + filetypes=[("TOML", "*.toml"), ("All", "*.*")], + ) + root.destroy() + if not p: + return + name = Path(p).stem + proj = project_manager.default_project(name) + project_manager.save_project(proj, p) + if p not in self.project_paths: + self.project_paths.append(p) + self._switch_project(p) + self._rebuild_projects_list() + self._update_status(f"created project: {name}") + + def cb_browse_git_dir(self): + root = hide_tk_root() + d = filedialog.askdirectory(title="Select Git Directory") + root.destroy() + if d and dpg.does_item_exist("project_git_dir"): + dpg.set_value("project_git_dir", d) + + # ---- discussion callbacks ---- + + def cb_disc_switch(self, sender, app_data): + if app_data and app_data != self.active_discussion: + self._switch_discussion(app_data) + + def cb_disc_create(self): + if not dpg.does_item_exist("disc_new_name_input"): + return + name = dpg.get_value("disc_new_name_input").strip() + if not name: + self._update_status("enter a discussion name") + return + self._create_discussion(name) + dpg.set_value("disc_new_name_input", "") + + def cb_disc_rename(self): + if not dpg.does_item_exist("disc_new_name_input"): + return + new_name = dpg.get_value("disc_new_name_input").strip() + if not new_name: + self._update_status("enter a new name") + return + self._rename_discussion(self.active_discussion, new_name) + dpg.set_value("disc_new_name_input", "") + + def cb_disc_delete(self): + self._delete_discussion(self.active_discussion) + + def cb_update_git_commit(self): + self._update_discussion_git_commit() + + def cb_disc_save(self): + self._flush_to_project() + self._save_active_project() + self._flush_to_config() + save_config(self.config) + self._update_status("discussion saved") + + def cb_disc_append_entry(self): + default_role = self.disc_roles[0] if self.disc_roles else "User" + self.disc_entries.append({ + "role": default_role, + "content": "", + "collapsed": False, + "ts": project_manager.now_ts(), + }) + self._rebuild_disc_list() + + def cb_disc_clear(self): + self.disc_entries.clear() + self._rebuild_disc_list() + + def cb_disc_collapse_all(self): + for i, entry in enumerate(self.disc_entries): + tag = f"disc_content_{i}" + if dpg.does_item_exist(tag): + entry["content"] = dpg.get_value(tag) + entry["collapsed"] = True + self._rebuild_disc_list() + + def cb_disc_expand_all(self): + for entry in self.disc_entries: + entry["collapsed"] = False + self._rebuild_disc_list() + + def cb_append_message_to_history(self): + msg = dpg.get_value("ai_input") + if msg: + self.disc_entries.append({ + "role": "User", + "content": msg, + "collapsed": False, + "ts": project_manager.now_ts(), + }) + self._rebuild_disc_list() + + def cb_append_response_to_history(self): + resp = self.ai_response + if resp: + self.disc_entries.append({ + "role": "AI", + "content": resp, + "collapsed": False, + "ts": project_manager.now_ts(), + }) + self._rebuild_disc_list() + + # ---- disc roles ---- + + def _rebuild_disc_roles_list(self): + if not dpg.does_item_exist("disc_roles_scroll"): + return + dpg.delete_item("disc_roles_scroll", children_only=True) + for i, role in enumerate(self.disc_roles): + with dpg.group(horizontal=True, parent="disc_roles_scroll"): + dpg.add_button( + label="x", width=24, + callback=self._make_disc_remove_role_cb(i), + ) + dpg.add_text(role) + + def _make_disc_remove_role_cb(self, idx: int): + def cb(): + if idx < len(self.disc_roles): + self.disc_roles.pop(idx) + self._rebuild_disc_roles_list() + self._rebuild_disc_list() + return cb + + def cb_disc_add_role(self): + if not dpg.does_item_exist("disc_new_role_input"): + return + name = dpg.get_value("disc_new_role_input").strip() + if name and name not in self.disc_roles: + self.disc_roles.append(name) + dpg.set_value("disc_new_role_input", "") + self._rebuild_disc_roles_list() + self._rebuild_disc_list() + + # ---- disc entry list ---- def _rebuild_disc_list(self): if not dpg.does_item_exist("disc_scroll"): @@ -720,6 +1173,8 @@ class App: preview = entry["content"].replace("\n", " ")[:60] if len(entry["content"]) > 60: preview += "..." + ts_str = entry.get("ts", "") + with dpg.group(parent="disc_scroll"): with dpg.group(horizontal=True): dpg.add_button( @@ -732,9 +1187,11 @@ class App: tag=f"disc_role_{i}", items=self.disc_roles, default_value=entry["role"], - width=160, + width=120, callback=self._make_disc_role_cb(i), ) + if ts_str: + dpg.add_text(ts_str, color=(120, 120, 100)) if collapsed: dpg.add_button( label="Ins", @@ -773,7 +1230,12 @@ class App: def _make_disc_insert_cb(self, idx: int): def cb(): - self.disc_entries.insert(idx, {"role": "User", "content": ""}) + self.disc_entries.insert(idx, { + "role": "User", + "content": "", + "collapsed": False, + "ts": project_manager.now_ts(), + }) self._rebuild_disc_list() return cb @@ -794,81 +1256,6 @@ class App: self._rebuild_disc_list() return cb - def cb_disc_collapse_all(self): - for i, entry in enumerate(self.disc_entries): - tag = f"disc_content_{i}" - if dpg.does_item_exist(tag): - entry["content"] = dpg.get_value(tag) - entry["collapsed"] = True - self._rebuild_disc_list() - - def cb_disc_expand_all(self): - for entry in self.disc_entries: - entry["collapsed"] = False - self._rebuild_disc_list() - - def _rebuild_disc_roles_list(self): - if not dpg.does_item_exist("disc_roles_scroll"): - return - dpg.delete_item("disc_roles_scroll", children_only=True) - for i, role in enumerate(self.disc_roles): - with dpg.group(horizontal=True, parent="disc_roles_scroll"): - dpg.add_button( - label="x", width=24, - callback=self._make_disc_remove_role_cb(i), - ) - dpg.add_text(role) - - def _make_disc_remove_role_cb(self, idx: int): - def cb(): - if idx < len(self.disc_roles): - self.disc_roles.pop(idx) - self._rebuild_disc_roles_list() - self._rebuild_disc_list() - return cb - - def cb_disc_add_role(self): - if not dpg.does_item_exist("disc_new_role_input"): - return - name = dpg.get_value("disc_new_role_input").strip() - if name and name not in self.disc_roles: - self.disc_roles.append(name) - dpg.set_value("disc_new_role_input", "") - self._rebuild_disc_roles_list() - self._rebuild_disc_list() - - def cb_disc_append_entry(self): - default_role = self.disc_roles[0] if self.disc_roles else "User" - self.disc_entries.append({"role": default_role, "content": "", "collapsed": False}) - self._rebuild_disc_list() - - def cb_disc_clear(self): - self.disc_entries.clear() - self._rebuild_disc_list() - - def cb_disc_save(self): - # Pull any in-progress edits from widgets into disc_entries - for i, entry in enumerate(self.disc_entries): - tag = f"disc_content_{i}" - if dpg.does_item_exist(tag): - entry["content"] = dpg.get_value(tag) - self._flush_to_config() - save_config(self.config) - self._update_status("discussion saved") - - def cb_append_message_to_history(self): - msg = dpg.get_value("ai_input") - if msg: - self.disc_entries.append({"role": "User", "content": msg, "collapsed": False}) - self._rebuild_disc_list() - - def cb_append_response_to_history(self): - resp = self.ai_response - if resp: - self.disc_entries.append({"role": "AI", "content": resp, "collapsed": False}) - self._rebuild_disc_list() - - # ------------------------------------------------------------ theme def cb_palette_changed(self, sender, app_data): @@ -898,6 +1285,8 @@ class App: def _read_colour_overrides(self) -> dict: return {} + # ------------------------------------------------------------ build ui + def _build_theme_window(self): t_cfg = self.config.get("theme", {}) cur_palette = t_cfg.get("palette", "DPG Default") @@ -957,43 +1346,74 @@ class App: def _build_ui(self): + # ---- Projects panel ---- + with dpg.window( + label="Projects", + tag="win_projects", + pos=(8, 8), + width=400, + height=260, + no_close=True, + ): + proj_meta = self.project.get("project", {}) + proj_name = proj_meta.get("name", Path(self.active_project_path).stem) + dpg.add_text(f"Active: {proj_name}", tag="project_name_text", color=(140, 255, 160)) + dpg.add_separator() + dpg.add_text("Git Directory") + with dpg.group(horizontal=True): + dpg.add_input_text( + tag="project_git_dir", + default_value=proj_meta.get("git_dir", ""), + width=-100, + ) + dpg.add_button(label="Browse##git", callback=self.cb_browse_git_dir) + dpg.add_separator() + dpg.add_text("Project Files") + with dpg.child_window(tag="projects_scroll", height=-40, border=True): + pass + with dpg.group(horizontal=True): + dpg.add_button(label="Add Project", callback=self.cb_add_project) + dpg.add_button(label="New Project", callback=self.cb_new_project) + dpg.add_button(label="Save All", callback=self.cb_save_config) + + # ---- Config panel ---- with dpg.window( label="Config", tag="win_config", - pos=(8, 8), + pos=(8, 276), width=400, - height=200, + height=160, no_close=True, ): dpg.add_text("Namespace") dpg.add_input_text( tag="namespace", - default_value=self.config["output"]["namespace"], + default_value=self.project.get("output", {}).get("namespace", ""), width=-1, ) dpg.add_text("Output Dir") - dpg.add_input_text( - tag="output_dir", - default_value=self.config["output"]["output_dir"], - width=-1, - ) with dpg.group(horizontal=True): + dpg.add_input_text( + tag="output_dir", + default_value=self.project.get("output", {}).get("output_dir", "./md_gen"), + width=-220, + ) dpg.add_button(label="Browse Output Dir", callback=self.cb_browse_output) - dpg.add_button(label="Save Config", callback=self.cb_save_config) + # ---- Files panel ---- with dpg.window( label="Files", tag="win_files", - pos=(8, 216), + pos=(8, 444), width=400, - height=500, + height=400, no_close=True, ): dpg.add_text("Base Dir") with dpg.group(horizontal=True): dpg.add_input_text( tag="files_base_dir", - default_value=self.config["files"]["base_dir"], + default_value=self.project.get("files", {}).get("base_dir", "."), width=-220, ) dpg.add_button( @@ -1003,12 +1423,12 @@ class App: dpg.add_text("Paths") with dpg.child_window(tag="files_scroll", height=-64, border=True): pass - self._rebuild_files_list() dpg.add_separator() with dpg.group(horizontal=True): dpg.add_button(label="Add File(s)", callback=self.cb_add_files) dpg.add_button(label="Add Wildcard", callback=self.cb_add_wildcard) + # ---- Screenshots panel ---- with dpg.window( label="Screenshots", tag="win_screenshots", @@ -1021,7 +1441,7 @@ class App: with dpg.group(horizontal=True): dpg.add_input_text( tag="shots_base_dir", - default_value=self.config.get("screenshots", {}).get("base_dir", "."), + default_value=self.project.get("screenshots", {}).get("base_dir", "."), width=-220, ) dpg.add_button( @@ -1035,6 +1455,7 @@ class App: dpg.add_separator() dpg.add_button(label="Add Screenshot(s)", callback=self.cb_add_shots) + # ---- Discussion History panel ---- with dpg.window( label="Discussion History", tag="win_discussion", @@ -1043,13 +1464,21 @@ class App: height=600, no_close=True, ): + # Discussion selector section + with dpg.collapsing_header(label="Discussions", default_open=True): + with dpg.group(tag="disc_selector_group"): + pass # populated by _rebuild_discussion_selector + + dpg.add_separator() + + # Entry toolbar with dpg.group(horizontal=True): dpg.add_button(label="+ Entry", callback=self.cb_disc_append_entry) dpg.add_button(label="-All", callback=self.cb_disc_collapse_all) dpg.add_button(label="+All", callback=self.cb_disc_expand_all) dpg.add_button(label="Clear All", callback=self.cb_disc_clear) dpg.add_button(label="Save", callback=self.cb_disc_save) - dpg.add_separator() + dpg.add_separator() with dpg.collapsing_header(label="Roles", default_open=False): with dpg.child_window(tag="disc_roles_scroll", height=96, border=True): pass @@ -1064,10 +1493,11 @@ class App: with dpg.child_window(tag="disc_scroll", height=-1, border=False): pass + # ---- Provider panel ---- with dpg.window( label="Provider", tag="win_provider", - pos=(1232, 8), + pos=(1252, 8), width=420, height=260, no_close=True, @@ -1093,10 +1523,11 @@ class App: callback=self.cb_model_changed, ) + # ---- Message panel ---- with dpg.window( label="Message", tag="win_message", - pos=(1232, 276), + pos=(1252, 276), width=420, height=280, no_close=True, @@ -1114,10 +1545,11 @@ class App: dpg.add_button(label="Reset", callback=self.cb_reset_session) dpg.add_button(label="-> History", callback=self.cb_append_message_to_history) + # ---- Response panel ---- with dpg.window( label="Response", tag="win_response", - pos=(1232, 564), + pos=(1252, 564), width=420, height=300, no_close=True, @@ -1132,10 +1564,11 @@ class App: dpg.add_separator() dpg.add_button(label="-> History", callback=self.cb_append_response_to_history) + # ---- Tool Calls panel ---- with dpg.window( label="Tool Calls", tag="win_tool_log", - pos=(1232, 872), + pos=(1252, 872), width=420, height=300, no_close=True, @@ -1147,22 +1580,20 @@ class App: with dpg.child_window(tag="tool_log_scroll", height=-1, border=False): pass - # ---- Comms History panel (new) ---- + # ---- Comms History panel ---- with dpg.window( label="Comms History", tag="win_comms", - pos=(1660, 8), + pos=(1680, 8), width=520, height=1164, no_close=True, ): - # Status line lives here now with dpg.group(horizontal=True): dpg.add_text("Status: idle", tag="ai_status", color=(200, 220, 160)) dpg.add_spacer(width=16) dpg.add_button(label="Clear", callback=self.cb_clear_comms) dpg.add_separator() - # Colour legend with dpg.group(horizontal=True): dpg.add_text("OUT", color=_DIR_COLORS["OUT"]) dpg.add_text("request", color=_KIND_COLORS["request"]) @@ -1186,8 +1617,12 @@ class App: dpg.maximize_viewport() self._build_ui() theme.load_from_config(self.config) + self._rebuild_files_list() + self._rebuild_shots_list() self._rebuild_disc_list() self._rebuild_disc_roles_list() + self._rebuild_projects_list() + self._rebuild_discussion_selector() self._fetch_models(self.current_provider) while dpg.is_dearpygui_running(): @@ -1203,6 +1638,12 @@ class App: dpg.render_dearpygui_frame() + # Save everything on exit + self._flush_to_project() + self._save_active_project() + self._flush_to_config() + save_config(self.config) + dpg.save_init_file("dpg_layout.ini") session_logger.close_session() dpg.destroy_context() @@ -1215,7 +1656,3 @@ def main(): if __name__ == "__main__": main() - - - - diff --git a/manual_slop.toml b/manual_slop.toml index fd8c877..a9d4cc5 100644 --- a/manual_slop.toml +++ b/manual_slop.toml @@ -41,19 +41,11 @@ active = "main" git_commit = "" last_updated = "2026-02-21T19:26:54" history = [ - "AI:\nGood. Now I'll patch `gui.py` — adding the Theme window and wiring it up. I'll make targeted insertions rather than a full rewrite:\n\n1. Add `import theme` at the top\n2. Add `_build_theme_window()` and its callbacks to `App`\n3. Call `theme.load_from_config()` at startup (in `run()`)\n4. Call `theme.save_to_config()` in `_flush_to_config()`\n5. Add `win_theme` to `_build_ui()`", - "User:\nSure lets patch gui.py.", - "AI:\nGood. Now I have a clear picture. I'll do the full patch in one Python script to avoid PowerShell string escaping issues:\n(Finished the job)", - "User:\nOk so the implementation works but when switching fonts it tends to crash (FiraCode, MapleMono, etc).", - "AI:\nThe em-dash in the docstring differs. Let me match by a more robust regex:", - "User:\nOk it seems fixed now... (themeing for fonts, nice job). \nNow lets do those adjustments to discussion history:\nIt'd also be nice if we can collapse the discussion entries indvidual and with a collapse/expand all button.\nAlso there is no way right now for me to define new roles for the discussion other than by editing the source.\nShould at least be configurable frm the config.toml...", - "AI:\nGood, I have a clear picture. Here's the plan:\n\n**Changes needed:**\n1. **`config.toml`** — add `[discussion] roles = [...]` so roles are configurable\n2. **`gui.py`** — 4 targeted edits:\n - `DISC_ROLES` becomes a module-level fallback; the real list comes from config at load time and lives on `App`\n - `_parse_history_entries()` takes `roles` as a parameter\n - `_rebuild_disc_list()` — add collapse/expand per entry (using a `bool` `collapsed` flag in each dict), Collapse All / Expand All buttons in the toolbar\n - Add a \"Roles\" sub-section in the Discussion window for adding/removing role names from the config list, and wire it into `_flush_to_config()`\n\nI'll do this as a Python patch script to avoid PowerShell string escaping issues:", - "User:\nSure, go ahead and make to those change patches and apply them.", - "AI:\nAll 16 checks pass. Now update `MainContext.md` and clean up the patch file:\n(Note from user: the AI did so!)", "User:\nLets add multiple project support. currently this project would be the \"manual_slop\" project. \nI need to make a separate \"manual_slop.toml\" for this and offload things that would be within a per project to it.\nI also want to have several discussion histories that I can label. \nThis would allow me to stash and continue separate effort's histories..\nIt would be important if these discussions saved the last git commit they were on. (I'll need a buton to manually update it).\nAnd timestamps on each discussion entry to give an idea of the duration involved and \nhow the project has evolved since the discussion stream's effort was last done.\nBasically preserve context so ai can use that as valuable information in its working memory.\n\nTo load a project I'll need to hoist it into the config by providing the path.\nMaybe allow multiple project tomls to be specified path wise in the array.", - "AI:\nAnnotated by user:\nNo response from AI, taking sceenshot of scripts run... and directory's state", - "User:\nHey it seems like you were not done want to continue?\n", - "AI:\nDid work, No response from AI (this is an annotated entry by the user), made project_manager.py, adding it to md context.", - "User:\ncontinue. You made a file but no changes have been made yet to gui.py. Still in progress to add all the multiple project support wishlist from user message earlier.", - "AI:\nNo response yet again maybe the response box is broken (User annotated)", + "User:\nOk your started to work on multiple-project support, you added project_manager.py and manaual_slop.toml, its not being used yet. the rest of the codebase hasn't intergrated with the changes.\nThere might be something wrong with powershell tool use. If you suspect so try to fix that but otherwise lets finish the support for multiple projects.", + "AI:\nGood. Now the big one — rewriting `gui.py`. The integration points are:\n\n- Load/save via `project_manager` instead of raw `config.toml` for project data\n- A **Projects** panel: list of loaded project paths, add/remove, switch active project, \"New Project\" button\n- A **Discussions** sub-section inside Discussion History panel: list named discussions, create/rename/delete/switch active, show git commit + timestamp, \"Update Commit\" button\n- `disc_entries` timestamps wired up via `entry_to_str`/`str_to_entry`\n- `_flush_to_project()` saves to the active `.toml`; `_flush_to_config()` saves global config only", + "User:\nSure do it, I can't restart the program till you do those changes cause otherwise the config will not work.", + "AI:\nI have a full picture of the current state. The plan is clear. I'll write the new `gui.py` as a Python script to avoid all PowerShell string-escaping issues — this is a full rewrite of that file since the integration touches nearly every part of it.\n\nKey changes:\n- `App.__init__` loads from `config.toml` for global settings (`[ai]`, `[theme]`, `[projects]`), then loads the active project `.toml` via `project_manager`\n- `_flush_to_project()` / `_flush_to_config()` save to the right places\n- **Projects panel** (`win_projects`): list of project paths from `config.toml [projects]`, add/remove, switch active — reloads all project-derived state\n- **Discussion selector** inside the Discussion History panel: list named discussions, create new, rename, delete, switch — each has git commit + last_updated shown, \"Update Commit\" button\n- `disc_entries` now round-trips through `project_manager.entry_to_str` / `str_to_entry` (preserving `@timestamp` lines)\n- New entries get a `ts` stamp when appended via `-> History`", + "User:\nfor the love of god. please just finish the todos, I tired to fix a bug with the way your able to use tooling, hopefully its fixed\n\nOpus helped me:\nBug 1 — Anthropic SDK objects stored as-is in history (the main tool use bug): response.content contains Anthropic SDK ContentBlock objects (not dicts). These were stored directly into _anthropic_history. On subsequent API calls, the SDK would try to re-serialize these objects alongside plain dicts, causing failures — especially after tool use rounds where the history gets complex. Fixed by adding _content_block_to_dict() and calling [_content_block_to_dict(b) for b in response.content] before storing.\n\nBug 2 — _repair_anthropic_history tried to handle SDK objects but history now always has dicts: The old code had a dual path (isinstance(block, dict) vs getattr(block, \"type\", None)) to handle both dicts and SDK objects. Since we now always store dicts, I simplified it to only check dicts, removing the dead code path that could mask issues.\n\nBug 3 — Gemini function_call access without hasattr guard: The tool call list comprehension did part.function_call is not None without first checking hasattr(part, \"function_call\"). Added the hasattr guard to match the pattern used for part.text.\n\nBug 4 — Anthropic b.type == \"tool_use\" direct attribute access: Changed to getattr(b, \"type\", None) == \"tool_use\" for safer access on SDK content blocks during the response processing loop (before they get converted to dicts).\n\n---\nSo lets get back to supporting multiple projects thanks.\n", + "AI:\nPowerShell is collapsing the string interpolation. I'll write it as a `.py` file instead.", ]