commit 8431323848efd2fe8e5e4645f6ad2c9f545714ef Author: weishaupt Date: Fri Oct 9 11:32:32 2026 -0400 QuickClaude 1.0 Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37f802e --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Build output +bin/ +obj/ +out/ +publish/ +artifacts/ +*.exe +*.dll +*.pdb + +# IDE and user-specific files +.vs/ +.vscode/ +.idea/ +*.user +*.suo +*.rsuser +*.DotSettings.user + +# NuGet +*.nupkg +packages/ + +# Logs and OS clutter +*.log +Thumbs.db +desktop.ini +.DS_Store + +# Local-only files (scratch scripts, backups) +*.bak +*.ps1 +*.sh +*.jsonl diff --git a/ClaudeRunner.cs b/ClaudeRunner.cs new file mode 100644 index 0000000..1387c66 --- /dev/null +++ b/ClaudeRunner.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace QuickClaude +{ + // Runs one turn of Claude Code in headless mode and reports what happens. + public class ClaudeRunner + { + public event Action TextDelta; // reply text as it streams + public event Action ToolUsed; // one line describing a tool call + public event Action SessionStarted; // session id + public event Action Finished; // null when fine, otherwise an error message + + Process proc; + public bool Running => proc != null && !proc.HasExited; + + const string StylePrompt = + "Your replies appear in a tiny plain-text note on the user's Windows desktop. " + + "Keep answers short and conversational. Write plain text only: no markdown headings, " + + "no bold or italics, no tables, no code fences unless he asks for code. " + + "Never use em dashes or en dashes."; + + const string ToolsPrompt = + "You are running inside QuickClaude, a small note window on the user's Windows PC. " + + "You have web search, web fetch, and access to files in the user's home folder " + + "(Downloads, Documents, Desktop and so on). Use them when they help instead of saying you can't. " + + "Whether you can change files depends on the user's \"Claude can\" setting in QuickClaude's tray menu; " + + "if Write or Edit is unavailable, say they can switch it to \"Edit files in the work folder\". " + + "For anything on the web, use the WebSearch and WebFetch tools (load them with ToolSearch first if they " + + "aren't loaded yet). They work here without asking the user, even if earlier in this conversation they were blocked. " + + "Never use PowerShell, Bash, curl or Invoke-WebRequest to search the web or fetch web pages. " + + "Never put the user's email address, email or website domain, name, account or organization details, " + + "or other personal information from your context into web searches or URLs you fetch, " + + "unless the user specifically asks you to search for them. " + + "Shell commands (Bash or PowerShell) are allowed for other things, but each one first shows the user a confirm box; " + + "if they decline, carry on without it."; + + // Run without prompts. Bash and PowerShell stay out so they go through the confirm box hook. + // Write and Edit only when the user picked "Edit files" or "Auto"; "Only look" removes them outright. + const string ReadTools = "WebSearch,WebFetch,Read,Glob,Grep"; + const string EditTools = "Write,Edit,NotebookEdit"; + + public static string FindClaude(string preferred) + { + if (!string.IsNullOrWhiteSpace(preferred) && File.Exists(preferred)) return preferred; + + var candidates = new List(); + var path = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var dir in path.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + candidates.Add(Path.Combine(dir.Trim(), "claude.exe")); + candidates.Add(Path.Combine(dir.Trim(), "claude.cmd")); + } + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + candidates.Add(Path.Combine(home, ".local", "bin", "claude.exe")); + candidates.Add(Path.Combine(appData, "npm", "claude.cmd")); + candidates.Add(Path.Combine(home, "scoop", "shims", "claude.exe")); + + foreach (var c in candidates) + if (File.Exists(c)) return c; + + // Copies of Claude Code that the Claude desktop app keeps for itself + foreach (var root in new[] { Path.Combine(appData, "Claude", "claude-code"), Path.Combine(local, "Claude", "claude-code") }) + { + try + { + if (!Directory.Exists(root)) continue; + var found = Directory.GetFiles(root, "claude.exe", SearchOption.AllDirectories) + .OrderByDescending(f => File.GetLastWriteTimeUtc(f)).FirstOrDefault(); + if (found != null) return found; + } + catch { } + } + return null; + } + + public async Task RunAsync(string claudePath, string prompt, string workDir, string sessionId, string mode) + { + var psi = new ProcessStartInfo + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Directory.Exists(workDir) ? workDir : Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + StandardInputEncoding = new UTF8Encoding(false), + StandardOutputEncoding = new UTF8Encoding(false), + StandardErrorEncoding = new UTF8Encoding(false), + }; + + var args = new List { + "-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", + "--append-system-prompt", StylePrompt + "\n\n" + ToolsPrompt, + }; + bool canEdit = mode == "acceptEdits" || mode == "auto"; + args.Add("--allowedTools"); args.Add(canEdit ? ReadTools + "," + EditTools : ReadTools); + if (!canEdit) { args.Add("--disallowedTools"); args.Add(EditTools); } + var home = Environment.GetEnvironmentVariable("USERPROFILE"); + if (!string.IsNullOrEmpty(home) && Directory.Exists(home)) { args.Add("--add-dir"); args.Add(home); } + // Claude Code otherwise keeps a resumed conversation's first system prompt, so prompt + // changes here would never reach older conversations. Needs Claude Code v2.1.257+. + args.Add("--system-prompt-snapshot"); args.Add("off"); + if (!string.IsNullOrEmpty(sessionId)) { args.Add("--resume"); args.Add(sessionId); } + if (!string.IsNullOrEmpty(mode) && mode != "default") { args.Add("--permission-mode"); args.Add(mode); } + args.Add("--settings"); args.Add(ConfirmHookSettings()); + + if (claudePath.EndsWith(".cmd", StringComparison.OrdinalIgnoreCase)) + { + psi.FileName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; + psi.ArgumentList.Add("/c"); + psi.ArgumentList.Add(claudePath); + } + else psi.FileName = claudePath; + foreach (var a in args) psi.ArgumentList.Add(a); + + var stderr = new StringBuilder(); + bool gotText = false, gotResult = false; + string error = null; + + try + { + proc = Process.Start(psi); + } + catch (Exception ex) + { + Finished?.Invoke("Couldn't start Claude Code: " + ex.Message); + return; + } + + var errTask = Task.Run(async () => + { + string l; + while ((l = await proc.StandardError.ReadLineAsync()) != null) stderr.AppendLine(l); + }); + + try + { + await proc.StandardInput.WriteAsync(prompt); + proc.StandardInput.Close(); + } + catch { } + + string line; + while ((line = await proc.StandardOutput.ReadLineAsync()) != null) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + var type = Str(root, "type"); + bool main = !root.TryGetProperty("parent_tool_use_id", out var parent) || parent.ValueKind == JsonValueKind.Null; + + if (type == "system" && Str(root, "subtype") == "init") + { + var sid = Str(root, "session_id"); + if (!string.IsNullOrEmpty(sid)) SessionStarted?.Invoke(sid); + } + else if (type == "stream_event" && main && root.TryGetProperty("event", out var ev)) + { + var evType = Str(ev, "type"); + if (evType == "content_block_delta" && ev.TryGetProperty("delta", out var d) && Str(d, "type") == "text_delta") + { + var t = Str(d, "text"); + if (!string.IsNullOrEmpty(t)) { gotText = true; TextDelta?.Invoke(t); } + } + else if (evType == "content_block_start" && ev.TryGetProperty("content_block", out var cb) && Str(cb, "type") == "text") + { + TextDelta?.Invoke("\u0001"); // marker: a new text block starts + } + } + else if (type == "assistant" && main && root.TryGetProperty("message", out var msg) + && msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array) + { + foreach (var block in content.EnumerateArray()) + if (Str(block, "type") == "tool_use") + ToolUsed?.Invoke(DescribeTool(block)); + } + else if (type == "result") + { + gotResult = true; + var sid = Str(root, "session_id"); + if (!string.IsNullOrEmpty(sid)) SessionStarted?.Invoke(sid); + bool isError = root.TryGetProperty("is_error", out var ie) && ie.ValueKind == JsonValueKind.True; + var result = Str(root, "result"); + if (isError) error = string.IsNullOrEmpty(result) ? "Claude Code reported an error." : result; + else if (!gotText && !string.IsNullOrEmpty(result)) TextDelta?.Invoke(result); + } + } + catch { /* ignore lines that aren't JSON */ } + } + + await errTask; + proc.WaitForExit(); + int code = proc.ExitCode; + proc.Dispose(); + proc = null; + + if (error == null && !gotResult) + { + var e = stderr.ToString().Trim(); + if (code == -1 || code == 137 || code == 143) error = "Stopped."; + else error = string.IsNullOrEmpty(e) ? $"Claude Code exited without an answer (code {code})." : e; + } + Finished?.Invoke(error); + } + + // Shell commands go through QuickClaude's own confirm box: a PreToolUse hook that runs + // "QuickClaude.exe --confirm" (exec form, so no shell quoting). The dialog denies after + // 2 minutes; the hook timeout is a bit longer so Claude Code never cuts it off first. + // Written to a file rather than passed inline, since cmd /c (for claude.cmd) mangles JSON quotes. + static string ConfirmHookSettings() + { + var exe = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule.FileName; + var json = JsonSerializer.Serialize(new + { + hooks = new + { + PreToolUse = new[] + { + new + { + matcher = "Bash|PowerShell|Monitor", // Monitor also runs shell commands + hooks = new[] { new { type = "command", command = exe, args = new[] { "--confirm" }, timeout = 150 } } + } + } + } + }); + var path = Path.Combine(Settings.Folder, "hook-settings.json"); + Directory.CreateDirectory(Settings.Folder); + File.WriteAllText(path, json, new UTF8Encoding(false)); + return path; + } + + public void Stop() + { + try { if (Running) proc.Kill(true); } catch { } + } + + static string Str(JsonElement e, string name) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + static string DescribeTool(JsonElement block) + { + var name = Str(block, "name") ?? "tool"; + string detail = null; + if (block.TryGetProperty("input", out var input)) + { + foreach (var key in new[] { "description", "file_path", "path", "pattern", "url", "query", "command", "prompt" }) + { + detail = Str(input, key); + if (!string.IsNullOrEmpty(detail)) break; + } + } + if (detail != null) + { + detail = detail.Replace("\r", " ").Replace("\n", " "); + if (detail.Length > 70) detail = detail.Substring(0, 67) + "..."; + return $"[{name}: {detail}]"; + } + return $"[{name}]"; + } + } +} diff --git a/ConfirmWindow.cs b/ConfirmWindow.cs new file mode 100644 index 0000000..81775fa --- /dev/null +++ b/ConfirmWindow.cs @@ -0,0 +1,222 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; + +namespace QuickClaude +{ + // QuickClaude.exe --confirm: Claude Code's PreToolUse hook for shell commands. + // Reads the hook JSON from stdin, asks the user, prints an allow/deny decision. + public static class ConfirmHook + { + public static int Run() + { + string toolName = "Bash", command = null, cwd = null; + try + { + using var stdin = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false)); + using var doc = JsonDocument.Parse(stdin.ReadToEnd()); + var root = doc.RootElement; + toolName = Str(root, "tool_name") ?? toolName; + cwd = Str(root, "cwd"); + if (root.TryGetProperty("tool_input", out var input)) command = Str(input, "command"); + } + catch { } + + // Nothing to show: say nothing, so Claude Code's normal permission rules decide + if (string.IsNullOrEmpty(command)) return 0; + + new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; + var win = new ConfirmWindow(toolName, command, cwd); + win.ShowDialog(); + + string decision = win.Allowed ? "allow" : "deny"; + string reason = win.Allowed ? "The user allowed this command." + : win.TimedOut ? "The user didn't answer within 2 minutes, so the command was not run. Carry on without it." + : "The user declined to run this command. Carry on without it."; + + var json = JsonSerializer.Serialize(new + { + hookSpecificOutput = new + { + hookEventName = "PreToolUse", + permissionDecision = decision, + permissionDecisionReason = reason + } + }); + using var stdout = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)); + stdout.Write(json); + return 0; + } + + static string Str(JsonElement e, string name) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + } + + public class ConfirmWindow : Window + { + public bool Allowed { get; private set; } + public bool TimedOut { get; private set; } + + static readonly TimeSpan Limit = TimeSpan.FromMinutes(2); + internal static readonly Brush Text = Brushes.White; + internal static readonly Brush Faint = new SolidColorBrush(Color.FromArgb(140, 255, 255, 255)); + internal static readonly Brush Panel = new SolidColorBrush(Color.FromArgb(225, 20, 20, 20)); + internal static readonly Brush Edge = new SolidColorBrush(Color.FromArgb(70, 255, 255, 255)); + internal static readonly Brush EdgeBright = new SolidColorBrush(Color.FromArgb(200, 255, 255, 255)); + internal static readonly Brush Hover = new SolidColorBrush(Color.FromArgb(40, 255, 255, 255)); + internal static readonly Brush Press = new SolidColorBrush(Color.FromArgb(70, 255, 255, 255)); + + readonly DispatcherTimer timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + readonly DateTime deadline = DateTime.UtcNow + Limit; + readonly TextBlock countdown = new TextBlock(); + + public ConfirmWindow(string toolName, string command, string cwd) + { + Title = "QuickClaude - run command?"; + WindowStyle = WindowStyle.None; + AllowsTransparency = true; + Background = Brushes.Transparent; + ResizeMode = ResizeMode.NoResize; + Topmost = true; + ShowInTaskbar = true; + Width = 520; + SizeToContent = SizeToContent.Height; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + FontFamily = MainWindow.Mono; + FontSize = MainWindow.TextSize; + Foreground = Text; + + var header = new TextBlock + { + Text = "clod wants to run " + toolName + (string.IsNullOrEmpty(cwd) ? "" : " in " + cwd), + Foreground = Faint, + TextWrapping = TextWrapping.Wrap + }; + + // The exact command, selectable, scrolls if it's very long + var cmd = new TextBox + { + Text = command, + IsReadOnly = true, + Background = Brushes.Transparent, + Foreground = Text, + CaretBrush = Text, + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + FocusVisualStyle = null, + FontFamily = MainWindow.Mono, + FontSize = MainWindow.TextSize, + TextWrapping = TextWrapping.Wrap, + MaxHeight = 320, + Margin = new Thickness(0, 8, 0, 8), + Template = MainWindow.BareTextBoxTemplate, + IsUndoEnabled = false + }; + + countdown.Foreground = Faint; + countdown.VerticalAlignment = VerticalAlignment.Center; + UpdateCountdown(); + + var deny = MakeButton("Deny"); + var allow = MakeButton("Allow"); + deny.Click += (s, e) => Finish(false); + allow.Click += (s, e) => Finish(true); + allow.Margin = new Thickness(8, 0, 0, 0); + + var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right }; + buttons.Children.Add(deny); + buttons.Children.Add(allow); + + var bottom = new Grid(); + bottom.Children.Add(countdown); + bottom.Children.Add(buttons); + + var stack = new StackPanel(); + stack.Children.Add(header); + stack.Children.Add(cmd); + stack.Children.Add(bottom); + + var frame = new Border + { + Background = Panel, + BorderBrush = Edge, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(0), + Padding = new Thickness(12), + Child = stack + }; + frame.MouseLeftButtonDown += (s, e) => { try { DragMove(); } catch { } }; + Content = frame; + + PreviewKeyDown += (s, e) => { if (e.Key == Key.Escape) { e.Handled = true; Finish(false); } }; + timer.Tick += (s, e) => + { + if (DateTime.UtcNow >= deadline) { TimedOut = true; Finish(false); } + else UpdateCountdown(); + }; + Loaded += (s, e) => { timer.Start(); Activate(); deny.Focus(); }; + Closed += (s, e) => timer.Stop(); + } + + void UpdateCountdown() + { + var left = deadline - DateTime.UtcNow; + if (left < TimeSpan.Zero) left = TimeSpan.Zero; + countdown.Text = $"denies in {(int)left.TotalMinutes}:{left.Seconds:00}"; + } + + void Finish(bool allowed) + { + Allowed = allowed; + timer.Stop(); + Close(); + } + + static Button MakeButton(string text) => new Button + { + Content = text, + Foreground = Text, + Background = Brushes.Transparent, + BorderBrush = Edge, + Padding = new Thickness(14, 4, 14, 4), + FocusVisualStyle = null, + Cursor = Cursors.Hand, + Template = ButtonTemplate + }; + + // Square, flat button: brighter edge when focused, light fill on hover. No theme chrome. + static readonly ControlTemplate ButtonTemplate = MakeButtonTemplate(); + + static ControlTemplate MakeButtonTemplate() + { + var bd = new FrameworkElementFactory(typeof(Border), "bd"); + bd.SetValue(Border.BorderThicknessProperty, new Thickness(1)); + bd.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Control.BorderBrushProperty)); + bd.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Control.BackgroundProperty)); + bd.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Control.PaddingProperty)); + var cp = new FrameworkElementFactory(typeof(ContentPresenter)); + cp.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center); + cp.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); + bd.AppendChild(cp); + + var t = new ControlTemplate(typeof(Button)) { VisualTree = bd }; + var hover = new Trigger { Property = UIElement.IsMouseOverProperty, Value = true }; + hover.Setters.Add(new Setter(Border.BackgroundProperty, Hover, "bd")); + var pressed = new Trigger { Property = System.Windows.Controls.Primitives.ButtonBase.IsPressedProperty, Value = true }; + pressed.Setters.Add(new Setter(Border.BackgroundProperty, Press, "bd")); + var focused = new Trigger { Property = UIElement.IsKeyboardFocusedProperty, Value = true }; + focused.Setters.Add(new Setter(Border.BorderBrushProperty, EdgeBright, "bd")); + t.Triggers.Add(hover); + t.Triggers.Add(pressed); + t.Triggers.Add(focused); + t.Seal(); + return t; + } + } +} diff --git a/DarkMenu.cs b/DarkMenu.cs new file mode 100644 index 0000000..d0f02e5 --- /dev/null +++ b/DarkMenu.cs @@ -0,0 +1,101 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; + +namespace QuickClaude +{ + // Right-click menu in QuickClaude's own look: dark, square, Consolas, no theme chrome or blue highlight. + public static class DarkMenu + { + // target: the text box whose Cut/Copy/Paste the menu should offer (null for none). + public static ContextMenu Make(TextBox target, Action newConversation) + { + var menu = new ContextMenu + { + Template = MenuTemplate, + FontFamily = MainWindow.Mono, + FontSize = MainWindow.TextSize, + Foreground = ConfirmWindow.Text, + FocusVisualStyle = null, + HasDropShadow = false + }; + + if (target != null) + { + if (!target.IsReadOnly) menu.Items.Add(Item("Cut", ApplicationCommands.Cut, target)); + menu.Items.Add(Item("Copy", ApplicationCommands.Copy, target)); + if (!target.IsReadOnly) menu.Items.Add(Item("Paste", ApplicationCommands.Paste, target)); + menu.Items.Add(new Separator { Template = SeparatorTemplate }); + } + + var fresh = Item("New conversation", null, null); + fresh.Click += (s, e) => newConversation(); + menu.Items.Add(fresh); + return menu; + } + + static MenuItem Item(string header, ICommand command, IInputElement target) => new MenuItem + { + Header = header, + Command = command, + CommandTarget = target, + Template = ItemTemplate, + FocusVisualStyle = null, + Foreground = ConfirmWindow.Text + }; + + static readonly ControlTemplate MenuTemplate = MakeMenuTemplate(); + static readonly ControlTemplate ItemTemplate = MakeItemTemplate(); + static readonly ControlTemplate SeparatorTemplate = MakeSeparatorTemplate(); + + static ControlTemplate MakeMenuTemplate() + { + var bd = new FrameworkElementFactory(typeof(Border)); + bd.SetValue(Border.BackgroundProperty, ConfirmWindow.Panel); + bd.SetValue(Border.BorderBrushProperty, ConfirmWindow.Edge); + bd.SetValue(Border.BorderThicknessProperty, new Thickness(1)); + bd.SetValue(Border.PaddingProperty, new Thickness(0, 2, 0, 2)); + var items = new FrameworkElementFactory(typeof(StackPanel)); + items.SetValue(Panel.IsItemsHostProperty, true); + bd.AppendChild(items); + var t = new ControlTemplate(typeof(ContextMenu)) { VisualTree = bd }; + t.Seal(); + return t; + } + + // Header only: no icon column, no gesture text. Faint fill when highlighted, dimmed when disabled. + static ControlTemplate MakeItemTemplate() + { + var bd = new FrameworkElementFactory(typeof(Border), "bd"); + bd.SetValue(Border.BackgroundProperty, System.Windows.Media.Brushes.Transparent); + bd.SetValue(Border.PaddingProperty, new Thickness(10, 3, 16, 3)); + var cp = new FrameworkElementFactory(typeof(ContentPresenter)); + cp.SetValue(ContentPresenter.ContentSourceProperty, "Header"); + cp.SetValue(ContentPresenter.RecognizesAccessKeyProperty, true); + bd.AppendChild(cp); + + var t = new ControlTemplate(typeof(MenuItem)) { VisualTree = bd }; + var hi = new Trigger { Property = MenuItem.IsHighlightedProperty, Value = true }; + hi.Setters.Add(new Setter(Border.BackgroundProperty, ConfirmWindow.Hover, "bd")); + var off = new Trigger { Property = UIElement.IsEnabledProperty, Value = false }; + off.Setters.Add(new Setter(UIElement.OpacityProperty, 0.4, "bd")); + t.Triggers.Add(hi); + t.Triggers.Add(off); + t.Seal(); + return t; + } + + static ControlTemplate MakeSeparatorTemplate() + { + var line = new FrameworkElementFactory(typeof(Border)); + line.SetValue(FrameworkElement.HeightProperty, 1.0); + line.SetValue(FrameworkElement.MarginProperty, new Thickness(0, 3, 0, 3)); + line.SetValue(Border.BackgroundProperty, ConfirmWindow.Edge); + var t = new ControlTemplate(typeof(Separator)) { VisualTree = line }; + t.Seal(); + return t; + } + } +} diff --git a/MainWindow.cs b/MainWindow.cs new file mode 100644 index 0000000..8b81c4f --- /dev/null +++ b/MainWindow.cs @@ -0,0 +1,453 @@ +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 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)); + internal static readonly 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(); + + 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 faint "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); + + // Right-click menus: Copy (and Cut/Paste in the input) plus New conversation; blank areas get just the latter + transcript.ContextMenu = DarkMenu.Make(transcript, NewConversation); + input.ContextMenu = DarkMenu.Make(input, NewConversation); + rootGrid.ContextMenu = DarkMenu.Make(null, NewConversation); + + Content = rootGrid; + } + + 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 = faint; + 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); + + 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; + } + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..3db8703 --- /dev/null +++ b/Program.cs @@ -0,0 +1,57 @@ +using System; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Threading; + +namespace QuickClaude +{ + public static class Program + { + [STAThread] + public static int Main(string[] args) + { + // Run as Claude Code's shell-command confirm hook (separate short-lived process) + if (Array.IndexOf(args, "--confirm") >= 0) return ConfirmHook.Run(); + + using var mutex = new Mutex(true, "QuickClaude.SingleInstance", out bool first); + if (!first) return 0; + + var app = new System.Windows.Application { ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown }; + var win = new MainWindow(); + win.Show(); + app.Run(); + return 0; + } + } + + public static class TrayIcon + { + [System.Runtime.InteropServices.DllImport("user32.dll")] static extern bool DestroyIcon(IntPtr h); + + public static Icon Make() + { + using var bmp = new Bitmap(32, 32); + using (var g = Graphics.FromImage(bmp)) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + g.Clear(Color.Transparent); + using var fill = new SolidBrush(Color.FromArgb(0x1E, 0x3A, 0x8A)); // dark blue, matches app.ico + using var path = new GraphicsPath(); + int r = 8; + path.AddArc(1, 1, r * 2, r * 2, 180, 90); + path.AddArc(31 - r * 2, 1, r * 2, r * 2, 270, 90); + path.AddArc(31 - r * 2, 31 - r * 2, r * 2, r * 2, 0, 90); + path.AddArc(1, 31 - r * 2, r * 2, r * 2, 90, 90); + path.CloseFigure(); + g.FillPath(fill, path); + using var font = new Font("Segoe UI", 17, FontStyle.Bold, GraphicsUnit.Pixel); + var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; + g.DrawString("C", font, Brushes.White, new RectangleF(0, 1, 32, 32), sf); + } + var h = bmp.GetHicon(); + var icon = (Icon)Icon.FromHandle(h).Clone(); + DestroyIcon(h); + return icon; + } + } +} diff --git a/QuickClaude.csproj b/QuickClaude.csproj new file mode 100644 index 0000000..ee34c2b --- /dev/null +++ b/QuickClaude.csproj @@ -0,0 +1,24 @@ + + + + WinExe + net8.0-windows + true + true + true + disable + disable + QuickClaude + QuickClaude + 1.0.0 + QuickClaude + app.ico + win-x64 + true + true + true + true + none + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9b7391 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# QuickClaude + +A small, nearly invisible desktop window for talking to Claude on Windows. + +QuickClaude is a lightweight front end for [Claude Code](https://docs.claude.com/en/docs/claude-code/overview). Instead of working in a terminal, you get a transparent, borderless window that sits on your desktop like a note. You type into it, Claude Code does the work in the background, and the answer shows up right there in the window. + +It was built for people who want Claude close at hand without a browser tab or a terminal window taking up space. + +## What it looks like and how it behaves + +The window has no title bar, no borders and no background to speak of. Text floats on your desktop in a small monospace font (Consolas 10, falling back to Courier New). + +The faint word "clod" in the top left corner is the drag handle. Grab it to move the window anywhere. A small status line sits next to it so you can tell when Claude is working. + +Drag any edge or corner to resize the window, with the normal Windows resize arrows. QuickClaude remembers its size and position, so it opens where you left it. + +The input at the bottom is just a blinking cursor. No lines, outlines, placeholder text or focus highlights. Click anywhere in the window and the cursor jumps there, ready to type. Click and drag over the conversation to select text instead, then copy it with Ctrl+C or the right-click menu. + +## Menus + +**Right-click in the window** for a small menu that matches the rest of QuickClaude: + +On the conversation: Copy and New conversation. +In the input: Cut, Copy, Paste and New conversation. +Anywhere else: New conversation. + +**The tray icon** (the dark blue square with a white "C") has New conversation, the "Claude can" setting described below, and Exit. + +New conversation clears the window and starts Claude fresh, with no memory of what you talked about before. + +## What it can do + +QuickClaude is not just a chat box. Because Claude Code is running behind it, Claude can actually do things on your behalf: + +Search the web and read web pages, so it can answer questions about current events, people and anything newer than its training. + +Read and search files anywhere in your user folder (Downloads, Documents, Desktop and so on), and create or edit them when you allow it. + +Run commands on your computer, but only after you approve each one. See below. + +## The "Claude can" setting + +Choose how much Claude is allowed to do from the tray menu. The change takes effect with your next message, no restart needed. + +**Only look.** Claude can search the web, read web pages, and read and search your files. It cannot create, change or delete any file. + +**Edit files.** Everything in Only look, plus creating and editing files in your user folder. + +**Auto.** Everything in Edit files. On top of that, Claude Code's own safety check reviews anything else Claude tries and blocks risky actions, such as sending data off your PC or deleting things in bulk. + +In every mode, commands still go through the confirm box. + +## The confirm box + +A command can do almost anything to your computer, so QuickClaude never runs one silently. Whenever Claude wants to run a command (PowerShell, Bash, or a background monitor), a small dark box pops up showing the exact command and which folder it would run in, with **Allow** and **Deny** buttons and a countdown. + +Allow runs it. Deny, pressing Esc or closing the box tells Claude you declined, and it carries on another way without running anything. + +Deny has focus when the box opens, so an accidental press of Enter never runs a command. + +If you don't answer within two minutes, the command is denied automatically. + +## Privacy + +QuickClaude does not contain anyone's login, API key or account. It uses whatever account your own copy of Claude Code is signed into. Your messages, usage and billing all go through your own account, exactly as if you were using Claude Code in a terminal. + +Claude is also instructed never to put your email address, website domain, name, account details or other personal information into web searches or the pages it fetches, unless you specifically ask it to search for them. + +## Requirements + +**Windows 10 or 11, 64-bit.** Nothing else to install for QuickClaude itself. It's a single self-contained exe and doesn't need .NET installed. + +**Claude Code version 2.1.257 or newer, installed and signed in.** This is the important one. QuickClaude does not include Claude itself. It is only a window in front of Claude Code, so Claude Code must already be installed on your PC and logged in. You can check by opening PowerShell and typing `claude --version`. If it shows a version number and `claude` starts up signed in, you're set. + +**An Anthropic account.** Claude Code needs either a paid Claude subscription (Pro or Max) or an API key from the [Anthropic Console](https://console.anthropic.com). Without one, QuickClaude will open but every message will fail. + +Setup instructions for Claude Code: https://docs.claude.com/en/docs/claude-code/setup + +## Installing + +1. Make sure Claude Code is installed, up to date and signed in (see Requirements). +2. Download `QuickClaude.exe` from the Releases page of this repository. +3. Put it wherever you like and double-click it. It's a single file, so there's nothing else to install. + +If you'd like it handy, right-click the exe and choose **Pin to Start** (on Windows 11 this can be under "Show more options"). + +## Building it yourself + +You'll need the [.NET 8 SDK](https://aka.ms/dotnet/download). + +``` +git clone https://git.bloggin.space/weishaupt/QuickClaude.git +cd QuickClaude +dotnet publish -c Release +``` + +The finished exe ends up in: + +``` +bin\Release\net8.0-windows\win-x64\publish\QuickClaude.exe +``` + +Close QuickClaude first if it's running from that folder, or the build can't replace the exe. + +## Troubleshooting + +**Nothing happens when I send a message.** Claude Code probably isn't installed, isn't on your PATH, isn't signed in, or is older than 2.1.257. Open PowerShell, type `claude --version`, and update if needed. + +**Claude says it can't search the web, or uses commands to look things up.** Start a new conversation from the right-click or tray menu. An older conversation can carry over outdated instructions. + +**Claude says it can't change a file.** Check the "Claude can" setting in the tray menu. Only look is read-only by design. + +**The icon still shows an old color after updating.** That's Windows caching the icon. Moving the exe to a different folder, or unpinning and re-pinning it, usually fixes it. + +**The window disappeared.** It's transparent, so it can be easy to lose against a busy wallpaper. Look for the faint "clod" label, or exit from the tray icon and reopen it. diff --git a/Settings.cs b/Settings.cs new file mode 100644 index 0000000..15c7f0a --- /dev/null +++ b/Settings.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using System.Text.Json; + +namespace QuickClaude +{ + public class Settings + { + public double Left { get; set; } = double.NaN; + public double Top { get; set; } = double.NaN; + public double Width { get; set; } = 460; + public double Height { get; set; } = 380; + public string TextColor { get; set; } = "Black"; + public bool Topmost { get; set; } = false; + public string WorkDir { get; set; } = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + public string Mode { get; set; } = "default"; + public string SessionId { get; set; } = ""; + public string ClaudePath { get; set; } = ""; + + public static string Folder => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "QuickClaude"); + + static string FilePath => Path.Combine(Folder, "settings.json"); + public static string TranscriptPath => Path.Combine(Folder, "transcript.txt"); + + public static Settings Load() + { + try + { + if (File.Exists(FilePath)) + { + var s = JsonSerializer.Deserialize(File.ReadAllText(FilePath)); + if (s != null) return s; + } + } + catch { } + return new Settings(); + } + + public void Save() + { + try + { + Directory.CreateDirectory(Folder); + File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true })); + } + catch { } + } + } +} diff --git a/app.ico b/app.ico new file mode 100644 index 0000000..719a8bb Binary files /dev/null and b/app.ico differ