diff --git a/src/TeamsISO.App/Services/TeamsLauncher.cs b/src/TeamsISO.App/Services/TeamsLauncher.cs
index db1a72c..334faca 100644
--- a/src/TeamsISO.App/Services/TeamsLauncher.cs
+++ b/src/TeamsISO.App/Services/TeamsLauncher.cs
@@ -264,8 +264,58 @@ public static class TeamsLauncher
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern int GetWindowTextW(IntPtr hWnd, [Out] System.Text.StringBuilder lpString, int nMaxCount);
+
+ [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ private static extern int GetWindowTextLengthW(IntPtr hWnd);
+
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
+ ///
+ /// Returns the title bar text of Teams' most-recently-used top-level
+ /// window, or empty string if Teams isn't running. Modern Teams puts
+ /// the meeting title in the window title while in a call ("Meeting with
+ /// Alice | Microsoft Teams"), so this is the cheapest way to surface
+ /// meeting context to TeamsISO's UI without burning a UIA traversal.
+ ///
+ /// Includes hidden windows — operators using auto-hide still get the
+ /// title surfaced, which is the whole point.
+ ///
+ public static string GetActiveWindowTitle()
+ {
+ try
+ {
+ var teamsPids = new HashSet(
+ TeamsProcessNames
+ .SelectMany(n => Process.GetProcessesByName(n))
+ .Select(p => { try { return (uint)p.Id; } finally { p.Dispose(); } }));
+ if (teamsPids.Count == 0) return string.Empty;
+
+ string longestTitle = string.Empty;
+ EnumWindows((hWnd, _) =>
+ {
+ if (GetWindow(hWnd, GW_OWNER) != IntPtr.Zero) return true;
+ GetWindowThreadProcessId(hWnd, out var pid);
+ if (!teamsPids.Contains(pid)) return true;
+
+ var len = GetWindowTextLengthW(hWnd);
+ if (len <= 0) return true;
+ var sb = new System.Text.StringBuilder(len + 1);
+ GetWindowTextW(hWnd, sb, sb.Capacity);
+ var title = sb.ToString();
+ // Teams creates a few top-level windows per process; the
+ // call/meeting window has the longest title (other windows
+ // tend to just be "Microsoft Teams"). Pick the longest one
+ // as a heuristic for "most informative".
+ if (title.Length > longestTitle.Length) longestTitle = title;
+ return true;
+ }, IntPtr.Zero);
+ return longestTitle;
+ }
+ catch { return string.Empty; }
+ }
+
///
/// Enumerate every visible top-level window owned by any running Teams
/// process. "Top-level" means GetWindow(GW_OWNER) is null (the window is
diff --git a/src/TeamsISO.App/ViewModels/MainViewModel.cs b/src/TeamsISO.App/ViewModels/MainViewModel.cs
index 7ca8f28..5f93266 100644
--- a/src/TeamsISO.App/ViewModels/MainViewModel.cs
+++ b/src/TeamsISO.App/ViewModels/MainViewModel.cs
@@ -524,6 +524,34 @@ public sealed class MainViewModel : ObservableObject, IDisposable
Toast.Show($"Stopped {enabled.Length} ISO(s)");
}
+ ///
+ /// Pull the meaningful "meeting title" out of Teams' raw window title.
+ /// Teams uses formats like:
+ /// "Weekly Standup | Microsoft Teams"
+ /// "Meeting with Alice | Microsoft Teams"
+ /// "Microsoft Teams" (no meeting, just the app)
+ /// Strip the trailing " | Microsoft Teams" so the IN-CALL pill stays
+ /// short and readable. Truncate beyond 50 chars so a long meeting
+ /// subject doesn't push the rest of the IN-CALL bar off screen.
+ ///
+ internal static string ExtractMeetingTitle(string windowTitle)
+ {
+ if (string.IsNullOrWhiteSpace(windowTitle)) return string.Empty;
+ var t = windowTitle.Trim();
+ // Common separator patterns Teams uses across locales.
+ foreach (var sep in new[] { " | Microsoft Teams", " | Teams", " - Microsoft Teams", " - Teams" })
+ {
+ var idx = t.IndexOf(sep, StringComparison.Ordinal);
+ if (idx > 0) { t = t.Substring(0, idx).Trim(); break; }
+ }
+ // If after stripping we're left with just "Microsoft Teams" the
+ // window has no meeting context — return empty so the pill stays
+ // at "IN CALL" without a stale title.
+ if (t.Equals("Microsoft Teams", StringComparison.OrdinalIgnoreCase)) return string.Empty;
+ if (t.Length > 50) t = t.Substring(0, 47) + "…";
+ return t;
+ }
+
private void OnStatsTick(object? sender, EventArgs e)
{
foreach (var vm in Participants)
@@ -628,10 +656,13 @@ public sealed class MainViewModel : ObservableObject, IDisposable
try
{
var inCall = TeamsControlBridge.IsInCall();
+ var title = inCall ? ExtractMeetingTitle(TeamsLauncher.GetActiveWindowTitle()) : null;
_dispatcher.InvokeAsync(() =>
{
IsTeamsInCall = inCall;
- TeamsMeetingState = inCall ? "IN CALL" : "READY";
+ TeamsMeetingState = inCall
+ ? (string.IsNullOrEmpty(title) ? "IN CALL" : $"IN CALL · {title}")
+ : "READY";
});
}
catch { /* UIA flakiness shouldn't crash the stats tick */ }
diff --git a/src/tests/TeamsISO.App.Tests/MeetingTitleExtractionTests.cs b/src/tests/TeamsISO.App.Tests/MeetingTitleExtractionTests.cs
new file mode 100644
index 0000000..083fc79
--- /dev/null
+++ b/src/tests/TeamsISO.App.Tests/MeetingTitleExtractionTests.cs
@@ -0,0 +1,67 @@
+using TeamsISO.App.ViewModels;
+using Xunit;
+
+namespace TeamsISO.App.Tests;
+
+///
+/// Validates the title-stripping heuristic used by the IN-CALL bar pill.
+/// Teams' raw window title is the format the user actually sees (or doesn't —
+/// when auto-hide is on); we want to surface the meaningful meeting-name
+/// portion without the "| Microsoft Teams" suffix bloating the pill.
+///
+public class MeetingTitleExtractionTests
+{
+ [Theory]
+ [InlineData("Weekly Standup | Microsoft Teams", "Weekly Standup")]
+ [InlineData("Meeting with Alice | Microsoft Teams", "Meeting with Alice")]
+ [InlineData("Q4 Planning - Microsoft Teams", "Q4 Planning")]
+ [InlineData("Meeting | Teams", "Meeting")]
+ public void StripsTeamsSuffix(string raw, string expected)
+ {
+ Assert.Equal(expected, MainViewModel.ExtractMeetingTitle(raw));
+ }
+
+ [Fact]
+ public void EmptyInput_ReturnsEmpty()
+ {
+ Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle(string.Empty));
+ }
+
+ [Fact]
+ public void Whitespace_ReturnsEmpty()
+ {
+ Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle(" "));
+ }
+
+ [Fact]
+ public void BareAppTitle_ReturnsEmpty()
+ {
+ // "Microsoft Teams" alone means no meeting context — pill should
+ // stay at plain "IN CALL" rather than appending a meaningless title.
+ Assert.Equal(string.Empty, MainViewModel.ExtractMeetingTitle("Microsoft Teams"));
+ }
+
+ [Fact]
+ public void LongTitle_GetsTruncated()
+ {
+ var long_ = new string('A', 100) + " | Microsoft Teams";
+ var result = MainViewModel.ExtractMeetingTitle(long_);
+ Assert.True(result.Length <= 50);
+ Assert.EndsWith("…", result);
+ }
+
+ [Fact]
+ public void OuterWhitespaceIsTrimmed()
+ {
+ // Real Teams uses single-space format " | Microsoft Teams"; we only
+ // promise to trim outer whitespace, not normalize internal padding.
+ Assert.Equal("Project sync", MainViewModel.ExtractMeetingTitle(" Project sync | Microsoft Teams "));
+ }
+
+ [Fact]
+ public void TitleWithoutSeparator_PassesThrough()
+ {
+ // If Teams emits an unrecognized format, return it as-is (clamped to 50).
+ Assert.Equal("Quick chat", MainViewModel.ExtractMeetingTitle("Quick chat"));
+ }
+}