feat(ui): add decision-log reader and overlay components
This commit is contained in:
parent
c1259ecba4
commit
2dc711a55c
9 changed files with 469 additions and 0 deletions
4
ui/JevOverlay/.gitignore
vendored
Normal file
4
ui/JevOverlay/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
# Local absolute path to the decision log.
|
||||||
|
JevOverlay.config.json
|
||||||
120
ui/JevOverlay/Core/DecisionHistory.cs
Normal file
120
ui/JevOverlay/Core/DecisionHistory.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace JevOverlay.Core;
|
||||||
|
|
||||||
|
public readonly record struct DecisionKey(string Session, int Step);
|
||||||
|
|
||||||
|
public sealed class DecisionEntry(DecisionKey key, JsonElement record)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions Pretty = new() { WriteIndented = true };
|
||||||
|
public DecisionKey Key { get; } = key;
|
||||||
|
public JsonElement Record { get; } = record;
|
||||||
|
public string Action => Text(Record, "action");
|
||||||
|
public string Source => Text(Record, "source", "unknown");
|
||||||
|
public string Status { get; internal set; } = "Proposal only; execution is not recorded.";
|
||||||
|
public string? SessionEnd { get; internal set; }
|
||||||
|
public string Title => $"#{Key.Step} · {Source.ToUpperInvariant()} · {Text(Record, "state_type")}";
|
||||||
|
|
||||||
|
public string Summary()
|
||||||
|
{
|
||||||
|
string latency = "";
|
||||||
|
var jev = Field(Record, "jev");
|
||||||
|
if (Field(jev, "latency_s") is { ValueKind: JsonValueKind.Number } seconds &&
|
||||||
|
seconds.TryGetDouble(out double number) && double.IsFinite(number))
|
||||||
|
latency = $" · {number * 1000:0} ms";
|
||||||
|
return Limit($"{Title}\nSession: {Key.Session} · {Text(Record, "ts")}\n" +
|
||||||
|
$"Proposed: {Action}\nParameters: {Field(Record, "params")}\n" +
|
||||||
|
$"{Status}\n" + (SessionEnd is null ? "" : $"{SessionEnd}\n") +
|
||||||
|
$"\nReason: {Text(Record, "reason", "Not recorded")}\n" +
|
||||||
|
$"Recorded confidence: {Field(Record, "confidence")?.ToString() ?? "—"}{latency}\n" +
|
||||||
|
(string.IsNullOrEmpty(Text(Record, "error")) ? "" : $"Error: {Text(Record, "error")}\n") +
|
||||||
|
"Confidence does not prove correctness.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Evidence()
|
||||||
|
{
|
||||||
|
var jev = Field(Record, "jev");
|
||||||
|
if (jev is null || jev.Value.ValueKind == JsonValueKind.Null)
|
||||||
|
return "No Jev response recorded for this decision.\nThe source field identifies the policy path.";
|
||||||
|
return Limit("Recorded Jev response (not private reasoning).\n" +
|
||||||
|
"The recorded answer gate may differ from the policy's final gate.\n\n" +
|
||||||
|
JsonSerializer.Serialize(jev.Value, Pretty));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string Limit(string value) => value.Length <= 16000 ? value : value[..16000] + "\n[Display truncated]";
|
||||||
|
internal static JsonElement? Field(JsonElement? obj, string name) =>
|
||||||
|
obj is { ValueKind: JsonValueKind.Object } element && element.TryGetProperty(name, out var value)
|
||||||
|
? value : null;
|
||||||
|
internal static string Text(JsonElement? obj, string name, string fallback = "") =>
|
||||||
|
Field(obj, name) is { ValueKind: JsonValueKind.String } value ? value.GetString()! : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Joins recorded events by session and step, never by wall-clock time.</summary>
|
||||||
|
public sealed class DecisionHistory
|
||||||
|
{
|
||||||
|
public const int Capacity = 100;
|
||||||
|
private readonly List<DecisionEntry> entries = [];
|
||||||
|
public IReadOnlyList<DecisionEntry> Entries => entries;
|
||||||
|
public int SkippedLines { get; private set; }
|
||||||
|
public string? Notice { get; private set; }
|
||||||
|
|
||||||
|
public void Apply(TailBatch batch)
|
||||||
|
{
|
||||||
|
if (batch.Reset)
|
||||||
|
{
|
||||||
|
entries.Clear();
|
||||||
|
Notice = null;
|
||||||
|
SkippedLines = 0;
|
||||||
|
}
|
||||||
|
SkippedLines += batch.SkippedLines;
|
||||||
|
foreach (string line in batch.Lines)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(line);
|
||||||
|
if (!Consume(document.RootElement)) SkippedLines++;
|
||||||
|
}
|
||||||
|
catch (JsonException) { SkippedLines++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool Consume(JsonElement root)
|
||||||
|
{
|
||||||
|
string session = DecisionEntry.Text(root, "session");
|
||||||
|
string kind = DecisionEntry.Text(root, "event");
|
||||||
|
if (session.Length == 0 || kind.Length == 0) return false;
|
||||||
|
if (kind == "session_end")
|
||||||
|
{
|
||||||
|
string message = $"Session ended: {DecisionEntry.Text(root, "reason", "unspecified")}";
|
||||||
|
foreach (var entry in entries.Where(e => e.Key.Session == session)) entry.SessionEnd = message;
|
||||||
|
Notice = $"{session}: {message}";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (kind is not ("decide" or "action_error" or "action_rejected" or "model_error" or
|
||||||
|
"fallback_error" or "policy_error" or "no_decision"))
|
||||||
|
return true; // Other runner events do not describe a decision or its status.
|
||||||
|
if (DecisionEntry.Field(root, "step") is not { ValueKind: JsonValueKind.Number } stepValue ||
|
||||||
|
!stepValue.TryGetInt32(out int step) || step < 0) return false;
|
||||||
|
var key = new DecisionKey(session, step);
|
||||||
|
if (kind == "decide")
|
||||||
|
{
|
||||||
|
if (DecisionEntry.Text(root, "action").Length == 0) return false;
|
||||||
|
entries.RemoveAll(entry => entry.Key == key);
|
||||||
|
entries.Insert(0, new DecisionEntry(key, root.Clone()));
|
||||||
|
if (entries.Count > Capacity) entries.RemoveAt(entries.Count - 1);
|
||||||
|
Notice = null;
|
||||||
|
}
|
||||||
|
else if (kind is "action_error" or "action_rejected")
|
||||||
|
{
|
||||||
|
var entry = entries.Find(entry => entry.Key == key);
|
||||||
|
string action = DecisionEntry.Text(root, "action");
|
||||||
|
if (entry is not null && (action.Length == 0 || action == entry.Action))
|
||||||
|
entry.Status = kind == "action_error"
|
||||||
|
? $"Action request error; completion unknown: {DecisionEntry.Text(root, "error")}"
|
||||||
|
: $"Action request rejected: {DecisionEntry.Text(root, "message")}";
|
||||||
|
}
|
||||||
|
else Notice = DecisionEntry.Limit($"{session} #{step} · {kind}: {DecisionEntry.Text(root, "error")}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
ui/JevOverlay/Core/LogTail.cs
Normal file
93
ui/JevOverlay/Core/LogTail.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace JevOverlay.Core;
|
||||||
|
|
||||||
|
public sealed record TailBatch(IReadOnlyList<string> Lines, bool Reset = false,
|
||||||
|
string? Error = null, bool CatchingUp = false, int SkippedLines = 0);
|
||||||
|
|
||||||
|
/// <summary>Single-worker, bounded JSONL reader. It never writes to the source.</summary>
|
||||||
|
public sealed class LogTail(string path)
|
||||||
|
{
|
||||||
|
public const int MaxReadBytes = 256 * 1024;
|
||||||
|
public const int MaxLineBytes = 128 * 1024;
|
||||||
|
private static readonly UTF8Encoding Utf8 = new(false, true);
|
||||||
|
private readonly MemoryStream pending = new();
|
||||||
|
private long offset;
|
||||||
|
private byte[] prefix = [];
|
||||||
|
private byte[] checkpoint = [];
|
||||||
|
private bool initialized;
|
||||||
|
private bool discardLine;
|
||||||
|
|
||||||
|
public TailBatch Poll()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||||||
|
FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
long length = stream.Length;
|
||||||
|
bool reset = !initialized || length < offset ||
|
||||||
|
!Matches(stream, 0, prefix) ||
|
||||||
|
!Matches(stream, offset - checkpoint.Length, checkpoint);
|
||||||
|
if (reset)
|
||||||
|
{
|
||||||
|
// Start near the end of an existing log, not at the start of a long run.
|
||||||
|
offset = Math.Max(0, length - MaxReadBytes);
|
||||||
|
pending.SetLength(0);
|
||||||
|
discardLine = offset > 0;
|
||||||
|
prefix = ReadAt(stream, 0, (int)Math.Min(64, length));
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] bytes = ReadAt(stream, offset, (int)Math.Min(MaxReadBytes, length - offset));
|
||||||
|
offset += bytes.Length;
|
||||||
|
checkpoint = ReadAt(stream, Math.Max(0, offset - 64), (int)Math.Min(64, offset));
|
||||||
|
List<string> lines = [];
|
||||||
|
int skipped = 0;
|
||||||
|
foreach (byte value in bytes)
|
||||||
|
{
|
||||||
|
if (value == (byte)'\n')
|
||||||
|
{
|
||||||
|
if (!discardLine && pending.Length > 0)
|
||||||
|
{
|
||||||
|
try { lines.Add(Utf8.GetString(pending.GetBuffer(), 0, (int)pending.Length)); }
|
||||||
|
catch (DecoderFallbackException) { skipped++; }
|
||||||
|
}
|
||||||
|
discardLine = false;
|
||||||
|
pending.SetLength(0);
|
||||||
|
}
|
||||||
|
else if (!discardLine)
|
||||||
|
{
|
||||||
|
if (pending.Length == MaxLineBytes)
|
||||||
|
{
|
||||||
|
pending.SetLength(0);
|
||||||
|
discardLine = true;
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
else pending.WriteByte(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new(lines, reset, CatchingUp: offset < length, SkippedLines: skipped);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return new([], Error: "Log unavailable. Check log_path and file permissions.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Matches(FileStream stream, long position, byte[] expected) =>
|
||||||
|
expected.Length == 0 || ReadAt(stream, position, expected.Length).AsSpan().SequenceEqual(expected);
|
||||||
|
|
||||||
|
private static byte[] ReadAt(FileStream stream, long position, int count)
|
||||||
|
{
|
||||||
|
stream.Position = position;
|
||||||
|
byte[] buffer = new byte[count];
|
||||||
|
int read = 0;
|
||||||
|
while (read < count)
|
||||||
|
{
|
||||||
|
int next = stream.Read(buffer, read, count - read);
|
||||||
|
if (next == 0) break;
|
||||||
|
read += next;
|
||||||
|
}
|
||||||
|
return read == count ? buffer : buffer[..read];
|
||||||
|
}
|
||||||
|
}
|
||||||
21
ui/JevOverlay/Core/OverlayConfig.cs
Normal file
21
ui/JevOverlay/Core/OverlayConfig.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace JevOverlay.Core;
|
||||||
|
|
||||||
|
public static class OverlayConfig
|
||||||
|
{
|
||||||
|
public const string FileName = "JevOverlay.config.json";
|
||||||
|
|
||||||
|
public static string LoadLogPath(string modDirectory)
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(modDirectory, FileName)));
|
||||||
|
var root = document.RootElement;
|
||||||
|
if (root.ValueKind != JsonValueKind.Object ||
|
||||||
|
!root.TryGetProperty("log_path", out var value) ||
|
||||||
|
value.ValueKind != JsonValueKind.String ||
|
||||||
|
string.IsNullOrWhiteSpace(value.GetString()) ||
|
||||||
|
!Path.IsPathFullyQualified(value.GetString()!))
|
||||||
|
throw new FormatException("log_path must be an absolute file path.");
|
||||||
|
return Path.GetFullPath(value.GetString()!);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
ui/JevOverlay/JevOverlay.config.example.json
Normal file
3
ui/JevOverlay/JevOverlay.config.example.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"log_path": "/absolute/path/to/sts2-bot/capture/decisions.jsonl"
|
||||||
|
}
|
||||||
27
ui/JevOverlay/JevOverlay.csproj
Normal file
27
ui/JevOverlay/JevOverlay.csproj
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<AssemblyName>JevOverlay</AssemblyName>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="Mod.cs" />
|
||||||
|
<Compile Include="Core/**/*.cs" />
|
||||||
|
<Compile Include="UI/**/*.cs" />
|
||||||
|
<Reference Include="sts2">
|
||||||
|
<HintPath>$(STS2GameDataDir)/sts2.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="GodotSharp">
|
||||||
|
<HintPath>$(STS2GameDataDir)/GodotSharp.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
<None Include="JevOverlay.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Target Name="CheckGameReferences" BeforeTargets="ResolveReferences">
|
||||||
|
<Error Condition="!Exists('$(STS2GameDataDir)/sts2.dll') or !Exists('$(STS2GameDataDir)/GodotSharp.dll')"
|
||||||
|
Text="Set STS2GameDataDir to the installed game's data directory containing sts2.dll and GodotSharp.dll." />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
10
ui/JevOverlay/JevOverlay.json
Normal file
10
ui/JevOverlay/JevOverlay.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"id": "JevOverlay",
|
||||||
|
"name": "Jev Decision Log",
|
||||||
|
"author": "sts2-bot contributors",
|
||||||
|
"description": "Read-only in-game viewer for the bot's decision log.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"has_pck": false,
|
||||||
|
"has_dll": true,
|
||||||
|
"affects_gameplay": false
|
||||||
|
}
|
||||||
7
ui/JevOverlay/NuGet.Config
Normal file
7
ui/JevOverlay/NuGet.Config
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<!-- This mod and its tests need no external packages. -->
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>
|
||||||
184
ui/JevOverlay/UI/OverlayView.cs
Normal file
184
ui/JevOverlay/UI/OverlayView.cs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
using Godot;
|
||||||
|
using JevOverlay.Core;
|
||||||
|
|
||||||
|
namespace JevOverlay.UI;
|
||||||
|
|
||||||
|
/// <summary>Godot controls only. This view cannot read files or call the game API.</summary>
|
||||||
|
public sealed class OverlayView
|
||||||
|
{
|
||||||
|
private readonly CanvasLayer layer = new() { Name = "JevDecisionOverlay", Layer = 100 };
|
||||||
|
private readonly PanelContainer panel = new() { MouseFilter = Control.MouseFilterEnum.Stop };
|
||||||
|
private readonly VBoxContainer body = new();
|
||||||
|
private readonly Label status = new() { AutowrapMode = TextServer.AutowrapMode.WordSmart };
|
||||||
|
private readonly RichTextLabel summary = TextArea();
|
||||||
|
private readonly RichTextLabel evidence = TextArea();
|
||||||
|
private readonly ItemList history = new()
|
||||||
|
{
|
||||||
|
CustomMinimumSize = new(0, 110),
|
||||||
|
FocusMode = Control.FocusModeEnum.None,
|
||||||
|
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
|
||||||
|
};
|
||||||
|
private readonly Button collapse = Button("−", "Collapse panel (F8)");
|
||||||
|
private readonly Button details = Button("Show questions and answers");
|
||||||
|
private readonly Button follow = Button("Follow latest");
|
||||||
|
private IReadOnlyList<DecisionEntry> entries = [];
|
||||||
|
private DecisionKey? selected;
|
||||||
|
private bool following = true;
|
||||||
|
private bool collapsed;
|
||||||
|
private bool expanded;
|
||||||
|
|
||||||
|
public OverlayView(SceneTree tree)
|
||||||
|
{
|
||||||
|
var root = new Control { MouseFilter = Control.MouseFilterEnum.Ignore };
|
||||||
|
layer.AddChild(root);
|
||||||
|
tree.Root.AddChild(layer);
|
||||||
|
root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
|
||||||
|
root.AddChild(panel);
|
||||||
|
panel.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.TopRight);
|
||||||
|
var style = new StyleBoxFlat
|
||||||
|
{
|
||||||
|
BgColor = new Color(0.055f, 0.065f, 0.085f, 0.96f),
|
||||||
|
BorderColor = new Color(0.3f, 0.4f, 0.5f),
|
||||||
|
BorderWidthLeft = 1, BorderWidthRight = 1, BorderWidthTop = 1, BorderWidthBottom = 1,
|
||||||
|
CornerRadiusTopLeft = 8, CornerRadiusTopRight = 8,
|
||||||
|
CornerRadiusBottomLeft = 8, CornerRadiusBottomRight = 8,
|
||||||
|
ContentMarginLeft = 12, ContentMarginRight = 12,
|
||||||
|
ContentMarginTop = 10, ContentMarginBottom = 10,
|
||||||
|
};
|
||||||
|
panel.AddThemeStyleboxOverride("panel", style);
|
||||||
|
panel.Theme = new Theme { DefaultFontSize = 16 };
|
||||||
|
panel.AddThemeColorOverride("font_color", new Color(0.92f, 0.94f, 0.96f));
|
||||||
|
var layout = new VBoxContainer();
|
||||||
|
layout.AddThemeConstantOverride("separation", 8);
|
||||||
|
panel.AddChild(layout);
|
||||||
|
var header = new HBoxContainer();
|
||||||
|
layout.AddChild(header);
|
||||||
|
header.AddChild(new Label
|
||||||
|
{
|
||||||
|
Text = "Jev decisions · read only",
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||||
|
MouseFilter = Control.MouseFilterEnum.Ignore,
|
||||||
|
});
|
||||||
|
header.AddChild(collapse);
|
||||||
|
collapse.Pressed += ToggleCollapsed;
|
||||||
|
layout.AddChild(body);
|
||||||
|
body.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
|
||||||
|
body.AddThemeConstantOverride("separation", 8);
|
||||||
|
body.AddChild(status);
|
||||||
|
body.AddChild(summary);
|
||||||
|
body.AddChild(details);
|
||||||
|
body.AddChild(evidence);
|
||||||
|
evidence.Visible = false;
|
||||||
|
details.Pressed += () =>
|
||||||
|
{
|
||||||
|
expanded = !expanded;
|
||||||
|
evidence.Visible = expanded;
|
||||||
|
details.Text = expanded ? "Hide questions and answers" : "Show questions and answers";
|
||||||
|
};
|
||||||
|
body.AddChild(new Label { Text = "Recent decisions · select to inspect" });
|
||||||
|
body.AddChild(history);
|
||||||
|
body.AddChild(follow);
|
||||||
|
follow.ToggleMode = true;
|
||||||
|
follow.ButtonPressed = true;
|
||||||
|
follow.Toggled += enabled =>
|
||||||
|
{
|
||||||
|
following = enabled;
|
||||||
|
if (enabled) SelectLatest();
|
||||||
|
};
|
||||||
|
history.ItemSelected += index =>
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= entries.Count) return;
|
||||||
|
following = false;
|
||||||
|
follow.SetPressedNoSignal(false);
|
||||||
|
selected = entries[(int)index].Key;
|
||||||
|
RenderSelection();
|
||||||
|
};
|
||||||
|
summary.Text = "Waiting for decision records.\nF8 collapses or expands this panel.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ToggleCollapsed()
|
||||||
|
{
|
||||||
|
collapsed = !collapsed;
|
||||||
|
body.Visible = !collapsed;
|
||||||
|
collapse.Text = collapsed ? "+" : "−";
|
||||||
|
collapse.TooltipText = collapsed ? "Expand panel (F8)" : "Collapse panel (F8)";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Resize(Vector2 viewport)
|
||||||
|
{
|
||||||
|
panel.OffsetLeft = -Math.Min(440, Math.Max(280, viewport.X - 32));
|
||||||
|
panel.OffsetRight = -16;
|
||||||
|
panel.OffsetTop = 64;
|
||||||
|
panel.OffsetBottom = collapsed ? 112 : Math.Min(viewport.Y - 16, expanded ? 764 : 584);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetStatus(string text, string? tooltip = null)
|
||||||
|
{
|
||||||
|
status.Text = text;
|
||||||
|
status.TooltipText = tooltip ?? text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Render(DecisionHistory model)
|
||||||
|
{
|
||||||
|
// Copy the list; selection must refer to the same ordering as the visible rows.
|
||||||
|
entries = model.Entries.ToArray();
|
||||||
|
history.Clear();
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
int index = history.AddItem($"#{entry.Key.Step} {entry.Source.ToUpperInvariant()} {entry.Action}");
|
||||||
|
history.SetItemTooltip(index, $"Session: {entry.Key.Session}\n{entry.Status}");
|
||||||
|
history.SetItemCustomFgColor(index, entry.Source switch
|
||||||
|
{
|
||||||
|
"jev" => new Color(0.55f, 0.8f, 1f),
|
||||||
|
"fallback" => new Color(1f, 0.75f, 0.4f),
|
||||||
|
_ => new Color(0.8f, 0.85f, 0.85f),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (following) SelectLatest();
|
||||||
|
else RenderSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectLatest()
|
||||||
|
{
|
||||||
|
selected = entries.Count == 0 ? null : entries[0].Key;
|
||||||
|
RenderSelection();
|
||||||
|
if (entries.Count > 0) history.EnsureCurrentIsVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderSelection()
|
||||||
|
{
|
||||||
|
int index = -1;
|
||||||
|
for (int i = 0; i < entries.Count; i++)
|
||||||
|
if (entries[i].Key == selected) { index = i; break; }
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
summary.Text = selected is null ? "Waiting for decision records."
|
||||||
|
: "This decision is no longer in the recent history. Select Follow latest.";
|
||||||
|
evidence.Text = "No decision selected.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
history.Select(index);
|
||||||
|
summary.Text = entries[index].Summary();
|
||||||
|
evidence.Text = entries[index].Evidence();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => layer.QueueFree();
|
||||||
|
|
||||||
|
private static Button Button(string text, string tooltip = "") => new()
|
||||||
|
{
|
||||||
|
Text = text, TooltipText = tooltip, FocusMode = Control.FocusModeEnum.None,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RichTextLabel TextArea() => new()
|
||||||
|
{
|
||||||
|
BbcodeEnabled = false,
|
||||||
|
SelectionEnabled = true,
|
||||||
|
ScrollActive = true,
|
||||||
|
CustomMinimumSize = new(0, 90),
|
||||||
|
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
|
||||||
|
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
|
||||||
|
FocusMode = Control.FocusModeEnum.None,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue