488 lines
20 KiB
C#
488 lines
20 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Media;
|
|
using WF = System.Windows.Forms;
|
|
|
|
namespace QuickClaude
|
|
{
|
|
public class MainWindow : Window
|
|
{
|
|
readonly Settings settings;
|
|
readonly ClaudeRunner runner = new ClaudeRunner();
|
|
readonly TextBox transcript = new TextBox();
|
|
readonly TextBox input = new TextBox();
|
|
readonly TextBlock status = new TextBlock();
|
|
readonly TextBlock grip = new TextBlock();
|
|
WF.NotifyIcon tray;
|
|
WF.ToolStripMenuItem fontMenu, topItem, blackItem, whiteItem, lookItem, editItem, autoItem, folderItem;
|
|
bool lastWasTool;
|
|
bool quitting;
|
|
|
|
// Nearly invisible fill so the window still catches the mouse (fully clear pixels click through)
|
|
static readonly Brush HitFill = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
|
|
// The current font (picked from the tray's Font menu). Still called Mono since
|
|
// ConfirmWindow and DarkMenu read it when they open.
|
|
internal static FontFamily Mono = new FontFamily("Consolas, Courier New");
|
|
// Matches wIRC at its smallest font slider setting (10sp = 10 WPF units)
|
|
internal const double TextSize = 10;
|
|
const double ResizeEdge = 6;
|
|
|
|
public MainWindow()
|
|
{
|
|
settings = Settings.Load();
|
|
Mono = Fonts.Find(settings.Font).Family;
|
|
|
|
Title = "QuickClaude";
|
|
WindowStyle = WindowStyle.None;
|
|
AllowsTransparency = true;
|
|
Background = HitFill;
|
|
ShowInTaskbar = false;
|
|
ResizeMode = ResizeMode.CanResize;
|
|
MinWidth = 240;
|
|
MinHeight = 160;
|
|
FontFamily = Mono;
|
|
FontSize = TextSize;
|
|
Width = Math.Max(240, settings.Width);
|
|
Height = Math.Max(160, settings.Height);
|
|
Topmost = settings.Topmost;
|
|
if (double.IsNaN(settings.Left) || double.IsNaN(settings.Top) || !OnScreen(settings.Left, settings.Top))
|
|
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
|
else { Left = settings.Left; Top = settings.Top; }
|
|
|
|
BuildLayout();
|
|
ApplyColor();
|
|
BuildTray();
|
|
|
|
runner.TextDelta += t => Dispatcher.InvokeAsync(() => OnText(t));
|
|
runner.ToolUsed += t => Dispatcher.InvokeAsync(() => OnTool(t));
|
|
runner.SessionStarted += id => Dispatcher.InvokeAsync(() => { settings.SessionId = id; settings.Save(); });
|
|
runner.Finished += err => Dispatcher.InvokeAsync(() => OnFinished(err));
|
|
|
|
try { if (File.Exists(Settings.TranscriptPath)) transcript.Text = File.ReadAllText(Settings.TranscriptPath); } catch { }
|
|
if (transcript.Text.Length == 0)
|
|
transcript.Text = "QuickClaude. Type below and press Enter. Shift+Enter for a new line, Esc stops Claude, Ctrl+Alt+C hides or shows this note.\n";
|
|
|
|
Loaded += (s, e) => { transcript.ScrollToEnd(); input.Focus(); };
|
|
PreviewMouseLeftButtonUp += ClickToInput;
|
|
SourceInitialized += (s, e) => RegisterHotkey();
|
|
LocationChanged += (s, e) => SavePlace();
|
|
Closing += (s, e) =>
|
|
{
|
|
if (!quitting) { e.Cancel = true; Hide(); return; }
|
|
runner.Stop();
|
|
SavePlace();
|
|
SaveTranscript();
|
|
tray.Visible = false;
|
|
tray.Dispose();
|
|
};
|
|
}
|
|
|
|
void BuildLayout()
|
|
{
|
|
var rootGrid = new Grid { Background = HitFill, Margin = new Thickness(4) };
|
|
rootGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
|
rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
|
rootGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
|
|
|
// Top row: a solid "clod" label that moves the window, plus a quiet status line
|
|
grip.Text = "clod";
|
|
grip.FontSize = TextSize;
|
|
grip.FontFamily = Mono;
|
|
grip.Background = HitFill;
|
|
grip.Cursor = Cursors.SizeAll;
|
|
grip.ToolTip = "Drag to move";
|
|
grip.MouseLeftButtonDown += (s, e) => { try { DragMove(); } catch { } SavePlace(); };
|
|
|
|
status.FontSize = TextSize;
|
|
status.FontFamily = Mono;
|
|
status.Margin = new Thickness(8, 0, 0, 0);
|
|
|
|
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) };
|
|
top.Children.Add(grip);
|
|
top.Children.Add(status);
|
|
Grid.SetRow(top, 0);
|
|
rootGrid.Children.Add(top);
|
|
|
|
// Conversation: read-only, selectable text with no border or fill
|
|
StyleBox(transcript);
|
|
transcript.IsReadOnly = true;
|
|
transcript.TextWrapping = TextWrapping.Wrap;
|
|
transcript.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
|
|
transcript.IsUndoEnabled = false;
|
|
Grid.SetRow(transcript, 1);
|
|
rootGrid.Children.Add(transcript);
|
|
|
|
// Input line
|
|
var bottom = new Grid { Margin = new Thickness(0, 6, 0, 0) };
|
|
|
|
StyleBox(input);
|
|
input.AcceptsReturn = true;
|
|
input.TextWrapping = TextWrapping.Wrap;
|
|
input.MaxHeight = 120;
|
|
input.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
|
|
input.Padding = new Thickness(0, 4, 0, 0);
|
|
input.PreviewKeyDown += InputKeyDown;
|
|
|
|
bottom.Children.Add(input);
|
|
Grid.SetRow(bottom, 2);
|
|
rootGrid.Children.Add(bottom);
|
|
|
|
Content = rootGrid;
|
|
BuildContextMenus();
|
|
}
|
|
|
|
// Right-click menus: Copy (and Cut/Paste in the input) plus New conversation; blank areas get just the latter.
|
|
// Rebuilt when the font changes so they use it too.
|
|
void BuildContextMenus()
|
|
{
|
|
transcript.ContextMenu = DarkMenu.Make(transcript, NewConversation);
|
|
input.ContextMenu = DarkMenu.Make(input, NewConversation);
|
|
((Grid)Content).ContextMenu = DarkMenu.Make(null, NewConversation);
|
|
}
|
|
|
|
void SetFont(string name)
|
|
{
|
|
var choice = Fonts.Find(name);
|
|
settings.Font = choice.Name;
|
|
settings.Save();
|
|
Mono = choice.Family;
|
|
FontFamily = Mono;
|
|
transcript.FontFamily = Mono;
|
|
input.FontFamily = Mono;
|
|
status.FontFamily = Mono;
|
|
grip.FontFamily = Mono;
|
|
BuildContextMenus();
|
|
if (fontMenu != null)
|
|
foreach (WF.ToolStripMenuItem item in fontMenu.DropDownItems)
|
|
item.Checked = item.Text == choice.Name;
|
|
}
|
|
|
|
static void StyleBox(TextBox tb)
|
|
{
|
|
tb.Background = Brushes.Transparent;
|
|
tb.BorderThickness = new Thickness(0);
|
|
tb.FontFamily = Mono;
|
|
tb.FontSize = TextSize;
|
|
tb.Padding = new Thickness(0);
|
|
tb.FocusVisualStyle = null;
|
|
tb.Template = BareTextBoxTemplate;
|
|
}
|
|
|
|
// Just the text host: no border, so the theme's blue focus/hover border never shows
|
|
internal static readonly ControlTemplate BareTextBoxTemplate = MakeBareTextBoxTemplate();
|
|
|
|
static ControlTemplate MakeBareTextBoxTemplate()
|
|
{
|
|
var host = new FrameworkElementFactory(typeof(ScrollViewer), "PART_ContentHost");
|
|
host.SetValue(ScrollViewer.FocusableProperty, false);
|
|
host.SetValue(ScrollViewer.HorizontalScrollBarVisibilityProperty, ScrollBarVisibility.Hidden);
|
|
host.SetValue(ScrollViewer.VerticalScrollBarVisibilityProperty, ScrollBarVisibility.Hidden);
|
|
var root = new FrameworkElementFactory(typeof(Border));
|
|
root.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Control.BackgroundProperty));
|
|
root.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Control.PaddingProperty));
|
|
root.AppendChild(host);
|
|
var t = new ControlTemplate(typeof(TextBox)) { VisualTree = root };
|
|
t.Seal();
|
|
return t;
|
|
}
|
|
|
|
void ApplyColor()
|
|
{
|
|
var c = settings.TextColor == "White" ? Colors.White : Colors.Black;
|
|
var solid = new SolidColorBrush(c);
|
|
var faint = new SolidColorBrush(Color.FromArgb(110, c.R, c.G, c.B));
|
|
transcript.Foreground = solid;
|
|
input.Foreground = solid;
|
|
input.CaretBrush = solid;
|
|
transcript.CaretBrush = solid;
|
|
status.Foreground = faint;
|
|
grip.Foreground = solid;
|
|
if (blackItem != null) { blackItem.Checked = c == Colors.Black; whiteItem.Checked = c == Colors.White; }
|
|
}
|
|
|
|
// A click anywhere puts the cursor in the input. Clicks on the input itself and on "clod"
|
|
// keep their own behavior, and a drag that selected conversation text keeps the selection
|
|
// (and focus) so it can be copied. Edge resizing never gets here: it's non-client.
|
|
void ClickToInput(object sender, MouseButtonEventArgs e)
|
|
{
|
|
var src = e.OriginalSource as DependencyObject;
|
|
if (IsWithin(src, input) || IsWithin(src, grip)) return;
|
|
bool inTranscript = IsWithin(src, transcript);
|
|
Dispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
if (inTranscript && transcript.SelectionLength > 0) return;
|
|
input.Focus();
|
|
Keyboard.Focus(input);
|
|
}), System.Windows.Threading.DispatcherPriority.Input);
|
|
}
|
|
|
|
static bool IsWithin(DependencyObject node, DependencyObject target)
|
|
{
|
|
while (node != null)
|
|
{
|
|
if (node == target) return true;
|
|
node = node is Visual || node is System.Windows.Media.Media3D.Visual3D
|
|
? VisualTreeHelper.GetParent(node)
|
|
: LogicalTreeHelper.GetParent(node);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void InputKeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
if (e.Key == Key.Escape)
|
|
{
|
|
if (runner.Running) runner.Stop(); else Hide();
|
|
e.Handled = true;
|
|
}
|
|
else if (e.Key == Key.Enter && (Keyboard.Modifiers & ModifierKeys.Shift) == 0)
|
|
{
|
|
e.Handled = true;
|
|
Send();
|
|
}
|
|
}
|
|
|
|
async void Send()
|
|
{
|
|
var prompt = input.Text.Trim();
|
|
if (prompt.Length == 0 || runner.Running) return;
|
|
|
|
var claude = ClaudeRunner.FindClaude(settings.ClaudePath);
|
|
if (claude == null)
|
|
{
|
|
Append((transcript.Text.Length > 0 ? "\n" : "") +
|
|
"Can't find Claude Code on this computer. Install it, or use the tray menu's \"Find claude.exe...\" to point at it.\n");
|
|
return;
|
|
}
|
|
|
|
input.Clear();
|
|
var lead = transcript.Text.Length == 0 ? "" : (transcript.Text.EndsWith("\n") ? "\n" : "\n\n");
|
|
Append(lead + "> " + prompt.Replace("\n", "\n ") + "\n\n");
|
|
lastWasTool = false;
|
|
status.Text = "working... (Esc to stop)";
|
|
|
|
await runner.RunAsync(claude, prompt, settings.WorkDir, settings.SessionId, settings.Mode);
|
|
}
|
|
|
|
void OnText(string t)
|
|
{
|
|
if (t == "\u0001")
|
|
{
|
|
if (lastWasTool && !transcript.Text.EndsWith("\n")) Append("\n");
|
|
lastWasTool = false;
|
|
return;
|
|
}
|
|
Append(t);
|
|
}
|
|
|
|
void OnTool(string line)
|
|
{
|
|
if (transcript.Text.Length > 0 && !transcript.Text.EndsWith("\n")) Append("\n");
|
|
Append(line + "\n");
|
|
lastWasTool = true;
|
|
}
|
|
|
|
void OnFinished(string err)
|
|
{
|
|
if (err != null)
|
|
{
|
|
if (!transcript.Text.EndsWith("\n")) Append("\n");
|
|
Append("(" + err.Trim() + ")\n");
|
|
}
|
|
else if (!transcript.Text.EndsWith("\n")) Append("\n");
|
|
status.Text = "";
|
|
SaveTranscript();
|
|
}
|
|
|
|
void Append(string text)
|
|
{
|
|
transcript.AppendText(text);
|
|
transcript.ScrollToEnd();
|
|
}
|
|
|
|
void SaveTranscript()
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(Settings.Folder);
|
|
var t = transcript.Text;
|
|
if (t.Length > 200000) t = t.Substring(t.Length - 200000);
|
|
File.WriteAllText(Settings.TranscriptPath, t, new UTF8Encoding(false));
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
void SavePlace()
|
|
{
|
|
if (WindowState != WindowState.Normal) return;
|
|
settings.Left = Left; settings.Top = Top; settings.Width = Width; settings.Height = Height;
|
|
settings.Save();
|
|
}
|
|
|
|
static bool OnScreen(double left, double top)
|
|
{
|
|
foreach (var s in WF.Screen.AllScreens)
|
|
if (s.WorkingArea.Contains((int)left + 20, (int)top + 20)) return true;
|
|
return false;
|
|
}
|
|
|
|
public void Toggle()
|
|
{
|
|
if (IsVisible) Hide();
|
|
else
|
|
{
|
|
Show();
|
|
Activate();
|
|
input.Focus();
|
|
Keyboard.Focus(input);
|
|
}
|
|
}
|
|
|
|
void NewConversation()
|
|
{
|
|
if (runner.Running) runner.Stop();
|
|
settings.SessionId = "";
|
|
settings.Save();
|
|
transcript.Clear();
|
|
Append("New conversation.\n");
|
|
SaveTranscript();
|
|
if (!IsVisible) Toggle();
|
|
}
|
|
|
|
void SetMode(string mode)
|
|
{
|
|
settings.Mode = mode;
|
|
settings.Save();
|
|
lookItem.Checked = mode == "default";
|
|
editItem.Checked = mode == "acceptEdits";
|
|
autoItem.Checked = mode == "auto";
|
|
}
|
|
|
|
void BuildTray()
|
|
{
|
|
var menu = new WF.ContextMenuStrip();
|
|
menu.Items.Add("Show / hide (Ctrl+Alt+C)", null, (s, e) => Toggle());
|
|
menu.Items.Add("New conversation", null, (s, e) => NewConversation());
|
|
menu.Items.Add(new WF.ToolStripSeparator());
|
|
|
|
var color = new WF.ToolStripMenuItem("Text color");
|
|
blackItem = new WF.ToolStripMenuItem("Black", null, (s, e) => { settings.TextColor = "Black"; settings.Save(); ApplyColor(); });
|
|
whiteItem = new WF.ToolStripMenuItem("White", null, (s, e) => { settings.TextColor = "White"; settings.Save(); ApplyColor(); });
|
|
color.DropDownItems.Add(blackItem);
|
|
color.DropDownItems.Add(whiteItem);
|
|
menu.Items.Add(color);
|
|
|
|
fontMenu = new WF.ToolStripMenuItem("Font");
|
|
var current = Fonts.Find(settings.Font).Name;
|
|
foreach (var choice in Fonts.All)
|
|
{
|
|
var name = choice.Name;
|
|
fontMenu.DropDownItems.Add(new WF.ToolStripMenuItem(name, null, (s, e) => SetFont(name)) { Checked = name == current });
|
|
}
|
|
menu.Items.Add(fontMenu);
|
|
|
|
topItem = new WF.ToolStripMenuItem("Always on top", null, (s, e) =>
|
|
{
|
|
settings.Topmost = !settings.Topmost; Topmost = settings.Topmost; topItem.Checked = settings.Topmost; settings.Save();
|
|
}) { Checked = settings.Topmost };
|
|
menu.Items.Add(topItem);
|
|
menu.Items.Add(new WF.ToolStripSeparator());
|
|
|
|
var can = new WF.ToolStripMenuItem("Claude can");
|
|
lookItem = new WF.ToolStripMenuItem("Only look (read files, no changes)", null, (s, e) => SetMode("default"));
|
|
editItem = new WF.ToolStripMenuItem("Edit files in the work folder", null, (s, e) => SetMode("acceptEdits"));
|
|
autoItem = new WF.ToolStripMenuItem("Auto (Claude Code's auto mode)", null, (s, e) => SetMode("auto"));
|
|
can.DropDownItems.Add(lookItem);
|
|
can.DropDownItems.Add(editItem);
|
|
can.DropDownItems.Add(autoItem);
|
|
menu.Items.Add(can);
|
|
|
|
folderItem = new WF.ToolStripMenuItem("Work folder: " + settings.WorkDir, null, (s, e) =>
|
|
{
|
|
using var dlg = new WF.FolderBrowserDialog { SelectedPath = settings.WorkDir, Description = "Folder Claude works in" };
|
|
if (dlg.ShowDialog() == WF.DialogResult.OK)
|
|
{
|
|
settings.WorkDir = dlg.SelectedPath;
|
|
settings.Save();
|
|
folderItem.Text = "Work folder: " + settings.WorkDir;
|
|
}
|
|
});
|
|
menu.Items.Add(folderItem);
|
|
menu.Items.Add("Find claude.exe...", null, (s, e) =>
|
|
{
|
|
using var dlg = new WF.OpenFileDialog { Filter = "Claude Code|claude.exe;claude.cmd|All files|*.*", Title = "Find Claude Code" };
|
|
if (dlg.ShowDialog() == WF.DialogResult.OK) { settings.ClaudePath = dlg.FileName; settings.Save(); }
|
|
});
|
|
menu.Items.Add(new WF.ToolStripSeparator());
|
|
menu.Items.Add("Exit", null, (s, e) => { quitting = true; Close(); System.Windows.Application.Current.Shutdown(); });
|
|
|
|
tray = new WF.NotifyIcon
|
|
{
|
|
Icon = TrayIcon.Make(),
|
|
Text = "QuickClaude",
|
|
ContextMenuStrip = menu,
|
|
Visible = true
|
|
};
|
|
tray.MouseClick += (s, e) => { if (e.Button == WF.MouseButtons.Left) Toggle(); };
|
|
|
|
SetMode(settings.Mode);
|
|
ApplyColor();
|
|
}
|
|
|
|
// Global hotkey: Ctrl+Alt+C
|
|
[DllImport("user32.dll")] static extern bool RegisterHotKey(IntPtr hWnd, int id, uint mods, uint vk);
|
|
const int HotkeyId = 0x5143;
|
|
|
|
void RegisterHotkey()
|
|
{
|
|
var helper = new WindowInteropHelper(this);
|
|
RegisterHotKey(helper.Handle, HotkeyId, 0x0001 | 0x0002 | 0x4000, 0x43);
|
|
HwndSource.FromHwnd(helper.Handle)?.AddHook((IntPtr hwnd, int msg, IntPtr w, IntPtr l, ref bool handled) =>
|
|
{
|
|
if (msg == 0x0312 && w.ToInt32() == HotkeyId) { Toggle(); handled = true; }
|
|
else if (msg == WM_NCHITTEST)
|
|
{
|
|
var hit = EdgeHit(hwnd, l);
|
|
if (hit != 0) { handled = true; return new IntPtr(hit); }
|
|
}
|
|
else if (msg == WM_EXITSIZEMOVE) SavePlace();
|
|
return IntPtr.Zero;
|
|
});
|
|
}
|
|
|
|
// Resize from any edge or corner: report a ~6px border strip as the matching hit code
|
|
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
|
[StructLayout(LayoutKind.Sequential)] struct RECT { public int Left, Top, Right, Bottom; }
|
|
const int WM_NCHITTEST = 0x0084, WM_EXITSIZEMOVE = 0x0232;
|
|
const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
|
|
|
|
int EdgeHit(IntPtr hwnd, IntPtr l)
|
|
{
|
|
if (!GetWindowRect(hwnd, out var r)) return 0;
|
|
long lp = l.ToInt64();
|
|
int x = (short)(lp & 0xFFFF), y = (short)((lp >> 16) & 0xFFFF);
|
|
double scale = PresentationSource.FromVisual(this)?.CompositionTarget?.TransformToDevice.M11 ?? 1;
|
|
int edge = (int)Math.Ceiling(ResizeEdge * scale);
|
|
|
|
bool left = x < r.Left + edge, right = x >= r.Right - edge;
|
|
bool top = y < r.Top + edge, bottom = y >= r.Bottom - edge;
|
|
if (top && left) return HTTOPLEFT;
|
|
if (top && right) return HTTOPRIGHT;
|
|
if (bottom && left) return HTBOTTOMLEFT;
|
|
if (bottom && right) return HTBOTTOMRIGHT;
|
|
if (left) return HTLEFT;
|
|
if (right) return HTRIGHT;
|
|
if (top) return HTTOP;
|
|
if (bottom) return HTBOTTOM;
|
|
return 0;
|
|
}
|
|
}
|
|
}
|