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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ recorded by the `MandoCode` submodule.
## [Unreleased]

### Added
- **Agent callsigns.** A Settings → Behavior toggle (app-wide) names new agents from a
curated 500+ pool of handles — construct-crew, phreak, and cypher energy ("Morphy",
"Crunch", "Blazor", "Kaos") — drawn from a shuffled deck that doesn't repeat until it runs
dry. Off (the default) keeps "Agent 1, Agent 2, …"; renaming a tab works either way.
- **Agents know their own name.** The tab's name is the agent's spoken identity: reply cards
are labeled with it, and the system prompt introduces the model as "{name}, a local AI
coding assistant running on MandoCode" — so saying "hi" to Blazor gets Blazor, not a
confused MandoCode. Renaming a tab tells the live conversation. (Engine support is
null-safe: the CLI keeps its classic MandoCode identity untouched.)
- **Version in the title bar.** The window title reads "MandoCode Desktop v{version}",
sourced from the same assembly version the update checker compares against releases.
- **Music player.** A music icon on the left rail opens a compact player: play/pause, next,
stop, volume, and a playlist picker. While music plays the rail icon becomes an animated
gold equalizer, and hovering it names the current track. **Add playlist** points at any
Expand Down
65 changes: 65 additions & 0 deletions src/MandoCode.Desktop.Tests/AgentCallsignsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using MandoCode.Desktop.Services;
using Xunit;

namespace MandoCode.Desktop.Tests;

public class AgentCallsignsTests
{
[Fact]
public void Pool_HasAtLeast500Names()
=> Assert.True(AgentCallsigns.Pool.Count >= 500,
$"Pool has {AgentCallsigns.Pool.Count} names; the feature promises 500.");

[Fact]
public void Pool_NamesAreUniqueCaseInsensitive()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dupes = AgentCallsigns.Pool.Where(n => !seen.Add(n)).ToList();
Assert.True(dupes.Count == 0, "Duplicate callsigns: " + string.Join(", ", dupes));
}

[Fact]
public void Pool_NamesAreWellFormed()
{
foreach (var name in AgentCallsigns.Pool)
{
Assert.False(string.IsNullOrWhiteSpace(name));
Assert.Equal(name, name.Trim());
// "Agent N" is the numbered scheme's namespace; a callsign colliding with it
// would make AgentNaming reuse-the-lowest-free-number logic misfire.
Assert.DoesNotContain("Agent ", name, StringComparison.OrdinalIgnoreCase);
}
}

[Fact]
public void Next_DoesNotRepeatWithinOneFullCycle()
{
AgentCallsigns.ResetDeck();
var dealt = new List<string>();
for (var i = 0; i < AgentCallsigns.Pool.Count; i++)
dealt.Add(AgentCallsigns.Next(Array.Empty<string>()));

Assert.Equal(AgentCallsigns.Pool.Count, dealt.Distinct(StringComparer.OrdinalIgnoreCase).Count());
}

[Fact]
public void Next_SkipsNamesWornByOpenTabs()
{
AgentCallsigns.ResetDeck();
var taken = AgentCallsigns.Pool.Take(50).ToArray();
for (var i = 0; i < 100; i++)
{
var name = AgentCallsigns.Next(taken);
Assert.DoesNotContain(name, taken, StringComparer.OrdinalIgnoreCase);
}
}

[Fact]
public void Next_FallsBackToNumbersWhenEveryCallsignIsTaken()
{
AgentCallsigns.ResetDeck();
var everything = AgentCallsigns.Pool.ToList();
var name = AgentCallsigns.Next(everything);
Assert.Equal("Agent 1", name);
}
}
1 change: 1 addition & 0 deletions src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
<Compile Include="..\MandoCode.Desktop\Services\PaneLayout.cs" Link="src\PaneLayout.cs" />
<Compile Include="..\MandoCode.Desktop\Services\ConfigCloning.cs" Link="src\ConfigCloning.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentNaming.cs" Link="src\AgentNaming.cs" />
<Compile Include="..\MandoCode.Desktop\Services\AgentCallsigns.cs" Link="src\AgentCallsigns.cs" />
<Compile Include="..\MandoCode.Desktop\Services\HistorySummarizer.cs" Link="src\HistorySummarizer.cs" />
<Compile Include="..\MandoCode.Desktop\ViewModels\RequestPreambleComposer.cs" Link="src\RequestPreambleComposer.cs" />

Expand Down
2 changes: 1 addition & 1 deletion src/MandoCode.Desktop.Tests/ResponseStreamerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public sealed class ResponseStreamerTests
// Tags each fragment type so a test can assert which builder method produced a transcript block.
private sealed class TagHtml : ITranscriptHtml
{
public string AssistantCard(string markdown) => $"CARD:{markdown}";
public string AssistantCard(string markdown, string? speaker = null) => $"CARD:{markdown}";
public string Warn(string text) => $"WARN:{text}";
public string Error(string text) => $"ERR:{text}";
public string Dim(string text) => $"DIM:{text}";
Expand Down
6 changes: 6 additions & 0 deletions src/MandoCode.Desktop/MainWindow.History.cs
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,12 @@ private async Task RenameTabAsync(ChatTabEntry entry)
entry.View.Session.Title = name;
entry.Label.Text = name;
RefreshTabStrip();

// The system prompt was baked at session start, so a live conversation learns the new
// name the way it learns other outside-the-conversation facts; the next fresh session
// bakes it properly via the Title setter's Config.AgentName stamp.
entry.View.Session.Controller.NoteWorkspaceEvent(
$"The user renamed you — your name is now “{name}”.");
}

/// <summary>Selecting an agent also returns you to the chat page — the Settings you were
Expand Down
10 changes: 10 additions & 0 deletions src/MandoCode.Desktop/MainWindow.Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ private void LoadSettings()
S_TemperatureLabel.Text = cfg.Temperature.ToString("0.##");
S_MaxTokens.Value = cfg.MaxTokens;
S_Streaming.SelectedItem = cfg.ResponseStreaming;
S_AgentCallsigns.IsOn = AgentCallsigns.Enabled; // app-wide, not from the agent's config
S_TaskPlanning.IsOn = cfg.EnableTaskPlanning;
S_DiffApprovals.IsOn = cfg.EnableDiffApprovals;
S_AutoContinue.IsOn = cfg.EnableAutoContinuation;
Expand All @@ -69,6 +70,15 @@ private void LoadSettings()
}
}

/// <summary>App-wide naming style for new agents — applies immediately (like Appearance),
/// not through the agent config / Make Default flow the rest of the page uses.</summary>
private void AgentCallsigns_Toggled(object sender, RoutedEventArgs e)
{
if (_loadingSettings) return;
AgentCallsigns.Enabled = S_AgentCallsigns.IsOn;
SavePanelState();
}

/// <summary>Runs the guided /setup wizard in the active agent's chat — the same flow that
/// fires on first launch. Routed through SubmitAsync so it gets the standard command echo
/// and the is-processing guard.</summary>
Expand Down
3 changes: 2 additions & 1 deletion src/MandoCode.Desktop/MainWindow.Snapshots.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ private void MarkSnapshotsSeen()
private void SavePanelState() => PanelState.Save(new PanelStateShape(
_collapsedSnapshotGroups.ToList(), _collapsedHistoryGroups.ToList(),
_snapshotsSeenAt, _historySeenAt,
_collapsedNoteGroups.ToList(), _lastNotePath, _noteModel));
_collapsedNoteGroups.ToList(), _lastNotePath, _noteModel,
AgentCallsigns.Enabled));

// The group object is kept in sync (not just the set) so that when the ListView recycles a
// container on scroll, the OneTime IsExpanded x:Bind re-reads the correct, current state.
Expand Down
6 changes: 6 additions & 0 deletions src/MandoCode.Desktop/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,12 @@
HorizontalAlignment="Left" Padding="0,4,0,8">
<TextBlock Text="Behavior" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource MandoAccentBrush}"/>
<!-- App-wide (naming happens in SessionManager before any agent exists), unlike
the rest of this tab — the header says so. Applies to agents opened after
the change; existing tabs keep their names. -->
<ToggleSwitch x:Name="S_AgentCallsigns" Header="Callsign names for new agents — app-wide"
Toggled="AgentCallsigns_Toggled"
ToolTipService.ToolTip="Off: new agents are Agent 1, Agent 2, … On: each new agent draws a random callsign — Morphy, Kernel, Cloud — from a 500-name deck that doesn't repeat until it runs dry. Renaming a tab always works either way."/>
<ToggleSwitch x:Name="S_TaskPlanning" Header="Task planning (propose_plan for multi-step requests)"
Tag="taskPlanning" Toggled="Setting_Toggled"
ToolTipService.ToolTip="For multi-step requests the model first proposes a numbered plan you approve before any work starts. More predictable on big tasks; an extra step on small ones."/>
Expand Down
3 changes: 2 additions & 1 deletion src/MandoCode.Desktop/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ private enum LeftPanel { None, Snapshots, History, Notes }
public MainWindow()
{
InitializeComponent();
Title = "MandoCode Desktop";
Title = $"MandoCode Desktop v{UiUpdateCheckService.CurrentVersion}";

ThemeManager.Initialize(Root);
// ONE window-level subscription to the static ThemeChanged event. Chat tabs must not
Expand Down Expand Up @@ -102,6 +102,7 @@ public MainWindow()
_historySeenAt = panelState.HistorySeenAt;
_lastNotePath = panelState.LastNotePath;
_noteModel = panelState.NoteModel;
AgentCallsigns.Enabled = panelState.AgentCallsigns ?? false;
// The editor writes note content; the panel only lists. One store, handed over once.
NoteEditor.Store = _notes;
WireNotesPanel();
Expand Down
137 changes: 137 additions & 0 deletions src/MandoCode.Desktop/Services/AgentCallsigns.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
namespace MandoCode.Desktop.Services;

/// <summary>
/// The callsign pool for agent naming — street-energy handles in the spirit of breaking
/// cyphers, phone phreaks, and construct crews, without naming any real person or property:
/// distinctive real-world handles are riffed rather than copied (the "Morphy" rule), and
/// everything else is ordinary words that just sound like they earned a spot in a cypher.
///
/// Selection is a shuffled deck: no name repeats until the whole pool has been dealt, then it
/// reshuffles. The deck is app-session state — a restart reshuffles. Names already worn by an
/// open tab are skipped; in the (theoretical) case that every callsign is simultaneously in
/// use, naming falls back to <see cref="AgentNaming.NextFreeName"/> numbers.
/// </summary>
public static class AgentCallsigns
{
/// <summary>App-wide naming style for NEW agents — persisted in panel-state.json, toggled
/// from Settings → Behavior. Existing tabs keep whatever name they have.</summary>
public static bool Enabled { get; set; }

private static readonly Random Rng = new();
private static readonly List<string> Deck = new();

public static string Next(IEnumerable<string?> takenTitles)
{
var titles = takenTitles.ToList();
var taken = new HashSet<string>(titles.Where(t => !string.IsNullOrEmpty(t))!,
StringComparer.OrdinalIgnoreCase);

// Deal until a name lands that no open tab is wearing — reshuffling at most once per
// call, so a pool with nothing free degrades to numbers instead of spinning.
var reshuffled = false;
while (true)
{
if (Deck.Count == 0)
{
if (reshuffled) return AgentNaming.NextFreeName(titles);
Deck.AddRange(Pool);
for (var i = Deck.Count - 1; i > 0; i--) // Fisher–Yates
{
var j = Rng.Next(i + 1);
(Deck[i], Deck[j]) = (Deck[j], Deck[i]);
}
reshuffled = true;
}

var name = Deck[^1];
Deck.RemoveAt(Deck.Count - 1);
if (!taken.Contains(name)) return name;
}
}

/// <summary>Forgets the current deal so the next draw starts a fresh shuffled cycle.
/// Exists for tests, which need cycle behavior to be observable from a known state.</summary>
public static void ResetDeck() => Deck.Clear();

public static readonly IReadOnlyList<string> Pool = new[]
{
"Morphy", "Neo", "Trin", "Cypher", "Oracle", "Tank", "Dozer", "Mouse",
"Switch", "Link", "Seraph", "Keysmith", "Construct", "Sentinel", "Nebu", "Merov",
"Niobe", "Sati", "Smitty", "Rabbit", "Deja", "Anomaly", "Redshift",
// Hand-picked additions:
"Phteven", "Mandox", "Sequoia", "Tule", "Merrill", "Parker", "Rey", "Warlock",
"Kaweah", "Wichita", "KC", "Dodge", "Doppler", "Nimbus", "Cirus",
"Dorth", "Chiki", "Lulu", "Cripto", "Mandeezy", "Finity", "Runner", "ExJay",
"Wrangler", "Zonik", "M-117", "Loggic", "Blazor", "Michelle", "Han", "Twister",
"Cuamatzi",
"Condor", "Crunch", "Phreak", "Acid", "Burn", "Crash", "Override", "Zed",
"Razor", "Blade", "Falkon", "Root", "Sudo", "Kernel",
"Shell", "Grep", "Proxy", "Hex", "Null", "Void",
"Segfault", "Packet", "Socket", "Ping", "Tracer", "Payload", "Cache",
"Regex", "Lambda", "Quine", "Enigma", "Morse", "Opcode", "Byte",
"Chip", "Circuit", "Diode", "Neon", "Laser", "Photon", "Prism",
"Firewall", "Mainframe", "Codec", "Modem", "Terminal",
"Cloud", "Storm", "Wing", "Freeze", "Flare", "Halo", "Cyclone",
"Vortex", "Torque", "Kinetik", "Jetik", "Physix", "Rukus", "Havok", "Kaos",
"Vertigo", "Spinz", "Mills", "Boogie", "Flava", "Breaker", "Ghost", "Banshee",
"Funk", "Groove", "Riddim", "Tempo", "Beatz", "Blaze", "Ember",
"Inferno", "Frost", "Glacier", "Tundra", "Quake", "Tremor", "Rumble", "Thunder",
"Bolt", "Volt", "Surge", "Static", "Spark", "Flux", "Pulse", "Eclipse",
"Comet", "Meteor", "Nova", "Quasar", "Pulsar", "Nebula", "Orbit", "Zenith",
"Apex", "Vertex", "Kryptik", "Mystik", "Majik", "Logik", "Tekniq", "Uniq",
"Freq", "Sonik", "Kosmik", "Atomik", "Elektrik", "Dynamik", "Klassik", "Fanatik",
"Mekanik", "Organik", "Volkanik", "Galaktik", "Robotik", "Poetik", "Akrobat", "Hypnotik",
"Toxik", "Seismik", "Optik", "Grafik", "Frantik", "Drastik", "Tactik",
"Drift", "Skid", "Dash", "Vault", "Flip",
"Ollie",
"Sway",
"Strobe", "Flash",
"Ray", "Beam", "Lumen", "Lux", "Aurora",
"Titan", "Atlas", "Orion", "Vega", "Sirius", "Rigel", "Lyra", "Draco",
"Nyx", "Erebus", "Chronos", "Hyperion", "Icarus", "Nemesis", "Janus", "Juno",
"Ceres", "Vesta", "Io", "Callisto", "Europa", "Andromeda",
"Phoenix", "Gryphon", "Hydra", "Kraken", "Wyvern", "Basilisk", "Chimera", "Sphinx",
"Cobra", "Viper", "Mamba", "Python", "Adder", "Raptor", "Osprey", "Talon",
"Panther", "Lynx", "Ocelot", "Cheetah", "Wolf", "Lobo", "Coyote", "Vixen",
"Mantis", "Hornet", "Wasp", "Firefly", "Dragonfly",
"Rhino", "Bison", "Grizzly", "Kodiak", "Husky", "Akita", "Dingo",
"Mongoose", "Badger", "Serval", "Caracal", "Jackal", "Fennec",
"Ronin", "Shogun", "Sensei", "Ninja", "Shinobi", "Kunai", "Shuriken", "Katana",
"Sabre", "Rapier", "Dagger", "Kris", "Scimitar", "Falchion", "Cutlass", "Claymore",
"Bishop", "Rook", "Gambit", "Checkm8", "Blitz", "Bullet",
"Dice", "Domino", "Ace", "Deuce", "Wildcard", "Maverick",
"Rebel", "Rogue", "Bandit", "Outlaw", "Renegade", "Drifter", "Nomad", "Vagabond",
"Recon", "Stealth", "Decoy", "Smoke", "Mirage", "Cloak", "Veil", "Whisper",
"Scout", "Ranger", "Warden", "Sentry", "Vanguard", "Bastion", "Citadel", "Aegis",
"Paladin", "Ricochet", "Ballistix", "Zigzag", "Riddle", "Karma", "Mantra",
"Zen", "Halcyon", "Ozone", "Headrush", "Adrenalin",
"Specter", "Phantom", "Wraith", "Shade", "Shadow", "Eidolon", "Revenant", "Umbra",
"Onyx", "Obsidian", "Jade", "Cobalt", "Crimson", "Scarlet", "Indigo", "Violet",
"Slate", "Graphite", "Steel", "Iron", "Mercury", "Platinum",
"Titanium", "Tungsten", "Granite", "Basalt", "Flint", "Quartz", "Topaz", "Garnet",
"Argon", "Xenon", "Krypton", "Plasma", "Ion", "Isotope", "Quark",
"Proton", "Neutron", "Electron", "Fusion", "Fission", "Reactor", "Dynamo", "Turbine",
"Piston", "Throttle", "Clutch", "Nitro", "Turbo", "Redline", "Burnout", "Slipstream",
"Rocket", "Thruster", "Igniter", "Apogee",
"Radar", "Sonar", "Lidar", "Beacon", "Signal", "Uplink",
"Relay", "Conduit", "Transistor", "Amp", "Waveform",
"Echo", "Reverb", "Tremolo", "Crescendo", "Staccato",
"Allegro", "Forte", "Presto", "Octave", "Cadence",
"Scratch", "Fader", "Vinyl", "Needle", "Breakbeat", "Beatbox",
"Remix", "Dub", "Bassline", "Subz", "Snare",
"Monsoon", "Typhoon", "Sirocco", "Mistral", "Zephyr", "Gale", "Squall", "Tempest",
"Avalanche", "Icicle", "Polaris", "Boreal", "Arctic", "Chill", "Coldsnap",
"Solstice", "Equinox", "Midnight", "Dusk", "Dawn", "Nightfall", "Daybreak",
"Horizon", "Meridian",
"Canyon", "Mesa", "Dune", "Oasis", "Sahara", "Savanna", "Delta", "Ridge",
"Summit", "Crag", "Bluff", "Cliff", "Fjord", "Reef",
"Rapids", "Cascade", "Torrent", "Riptide", "Undertow", "Geyser", "Wake",
"Stencil", "Tag", "Aerosol", "FatKap", "Wildstyle", "Throwie", "Burner",
"Flexx", "Twista", "Spida", "Casper", "Primo", "Nollie", "Fakie",
"Hurricane", "Tornado", "Loki", "Odin", "Freya", "Fenrir", "Valkyrie",
"Zeus", "Hermes", "Apollo", "Artemis", "Athena", "Ares", "Helios", "Selene",
"Wisp", "Cinder", "Ash",
"Pixel", "Sprite", "Voxel", "Shader", "Raster", "Vector",
"Render", "Framez", "Raycast", "Skybox", "Bloom",
};
}
19 changes: 15 additions & 4 deletions src/MandoCode.Desktop/Services/AgentSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,16 @@ public sealed class AgentSession

public int Id { get; }

/// <summary>Tab-strip label. Defaults to the project folder's leaf name.</summary>
public string Title { get; set; }
/// <summary>Tab-strip label AND the agent's spoken identity: setting it also stamps
/// <see cref="MandoCodeConfig.AgentName"/> on this session's config clone, so the system
/// prompt introduces the agent by this name on the next prompt rebuild (construction,
/// settings refresh, or model switch). Defaults to the project folder's leaf name.</summary>
public string Title
{
get => _title;
set { _title = value; Config.AgentName = value; }
}
private string _title = "";

/// <summary>Durable identity across app launches (unlike <see cref="Id"/>, a process-local
/// counter). Names this session's transcript journal on disk; a restored tab passes its
Expand Down Expand Up @@ -67,7 +75,8 @@ public AgentSession(
ConfigCoordinator configs,
McpCoordinator mcp,
string projectRoot,
string? persistKey = null)
string? persistKey = null,
string? title = null)
{
Id = Interlocked.Increment(ref _nextId);
PersistKey = string.IsNullOrWhiteSpace(persistKey) ? Guid.NewGuid().ToString("N") : persistKey;
Expand All @@ -83,7 +92,9 @@ public AgentSession(

Config = configs.CreateClone();
ProjectRoot = new ProjectRootAccessor(projectRoot);
Title = FolderLabel(projectRoot);
// Before AIService below: its constructor bakes the system prompt, and the agent's
// spoken identity (Config.AgentName, stamped by the Title setter) must be in it.
Title = title ?? FolderLabel(projectRoot);

Tokens = new TokenTrackingService();
PlanHandoff = new PlanHandoff();
Expand Down
2 changes: 1 addition & 1 deletion src/MandoCode.Desktop/Services/ITranscriptHtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace MandoCode.Desktop.Services;
/// </summary>
public interface ITranscriptHtml
{
string AssistantCard(string markdown);
string AssistantCard(string markdown, string? speaker = null);
string Warn(string text);
string Error(string text);
string Dim(string text);
Expand Down
Loading
Loading