aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-30 18:59:43 +0200
committerIgor Soarez <igor@soarez.org>2026-10-30 18:59:43 +0200
commit959f0d46cac1a09d0821da599d8dff54d1147892 (patch)
tree499b38261ccfdc88f60ea8cbac7d279f202709c1 /menubar
parentb0cfd10b80443d3f67d263ff4db177bb53cc90ed (diff)
mark forgotten drafts in pi agents' input boxes, and keep them across a crash or restore
Diffstat (limited to 'menubar')
-rw-r--r--menubar/model.swift45
-rw-r--r--menubar/panel.swift63
-rw-r--r--menubar/search.swift15
3 files changed, 97 insertions, 26 deletions
diff --git a/menubar/model.swift b/menubar/model.swift
index e4d179c..3ed1a7e 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -93,8 +93,14 @@ struct Row: Equatable {
/// The pane's working directory, home as "~".
var path: String = ""
var importance: Importance = .normal
+ /// The draft in the agent's input box, as one line (`@dingger_draft`).
+ var draft: String = ""
+ /// The user looks at this pane: its window is active in an attached session.
+ var attended: Bool = false
var location: String { "\(session) · \(window)" }
+ /// A draft left behind: where the user looks, they may be typing it.
+ var forgottenDraft: Bool { !draft.isEmpty && !attended }
var waiting: Bool { status == .blocked || status == .done }
/// Only an agent you have seen can be marked unread (done again).
var canMarkUnread: Bool { status == .idle }
@@ -105,8 +111,9 @@ struct Row: Equatable {
return name.count > 80 ? String(name.prefix(79)) + "…" : name
}
- /// The title's text as plain text: `! ` marks a high agent.
- var markedLabel: String { (importance == .high ? "! " : "") + label }
+ /// The title's text as plain text: `! ` marks a high agent, `✎ ` a
+ /// forgotten draft.
+ var markedLabel: String { (importance == .high ? "! " : "") + (forgottenDraft ? "✎ " : "") + label }
/// Dim second line: where it runs, which harness, whether it is low,
/// and how long it waits.
@@ -274,30 +281,35 @@ func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) {
private let home = NSHomeDirectory()
-/// Every agent pane: herdr's status priority; within a status, the more
-/// important first, then done and blocked oldest first (the queue),
+/// Every agent pane: herdr's status priority; idle agents with a forgotten
+/// draft before the other idle ones; within a status, the more important
+/// first, then done and blocked oldest first (the queue),
/// working and idle most recent first (`@dingger_since`: when the pane
/// entered its status); then by place.
func fetchRows() -> [Row] {
let format = [
"#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}",
"#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}",
- "#{@dingger_importance}",
+ "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}",
].joined(separator: "\t")
guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] }
+ let lines = out.split(separator: "\n").map {
+ $0.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
+ }
+ // Looked at in any of its sessions, as the daemon counts it.
+ let attended = Set(lines.filter { $0.count >= 14 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] })
// A pane in several sessions (a session group, a linked window) is
// listed once, under its first session, as the daemon counts it.
var listed = Set<String>()
- let rows: [Row] = out.split(separator: "\n").compactMap { line in
- let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
- guard f.count >= 11, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
+ let rows: [Row] = lines.compactMap { f in
+ guard f.count >= 14, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
let status = Status(rawValue: f[2]) ?? .unknown
guard status != .unknown else { return nil }
let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6]
return Row(paneId: f[0], agent: f[1], name: f[4], status: status,
session: f[5], window: window, since: Int(f[3]) ?? Int.max,
path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9],
- importance: Importance(option: f[10]))
+ importance: Importance(option: f[10]), draft: f[13], attended: attended.contains(f[0]))
}
return rows.sorted(by: listedBefore)
}
@@ -305,6 +317,7 @@ func fetchRows() -> [Row] {
/// The list order (see `fetchRows`).
func listedBefore(_ a: Row, _ b: Row) -> Bool {
if a.status.priority != b.status.priority { return a.status.priority > b.status.priority }
+ if a.status == .idle, a.forgottenDraft != b.forgottenDraft { return a.forgottenDraft }
if a.importance != b.importance { return a.importance.rawValue > b.importance.rawValue }
if a.since != b.since {
// Int.max: never stamped; last either way.
@@ -357,12 +370,15 @@ func plainTitle(for rows: [Row]) -> String {
title(for: rows).string
}
-/// The title's characters before the name: the status symbol and the
-/// importance column.
+/// The title's characters before the name: the status symbol, the
+/// importance column, and the draft mark.
func rowTitlePrefix(_ row: Row) -> String {
- "\(row.status.symbol) " + importanceMark(row) + " "
+ "\(row.status.symbol) " + importanceMark(row) + " " + draftMark(row)
}
+/// `✎ ` before the name of an agent with a forgotten draft.
+private func draftMark(_ row: Row) -> String { row.forgottenDraft ? "✎ " : "" }
+
/// The importance column: `!` for a high agent, a blank otherwise, so every
/// name starts in the same place.
private func importanceMark(_ row: Row) -> String { row.importance == .high ? "!" : " " }
@@ -371,7 +387,7 @@ private func importanceMark(_ row: Row) -> String { row.importance == .high ? "!
let lowImportanceAlpha: CGFloat = 0.5
/// Row title: the colored status symbol, the importance column (a peach `!`
-/// for a high agent), then the agent's name. Unnamed agents show their
+/// for a high agent), `✎` for a forgotten draft, then the agent's name. Unnamed agents show their
/// harness, dimmed, so it never competes with real names; a low agent's name
/// is dimmed.
func rowTitle(_ row: Row, primary: NSColor = .labelColor,
@@ -385,6 +401,9 @@ func rowTitle(_ row: Row, primary: NSColor = .labelColor,
string: importanceMark(row) + " ",
attributes: [.font: NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .heavy),
.foregroundColor: herdrPeach]))
+ if row.forgottenDraft {
+ text.append(NSAttributedString(string: draftMark(row), attributes: [.font: font, .foregroundColor: primary]))
+ }
let labelColor = row.name.isEmpty ? secondary
: row.importance == .low ? primary.withAlphaComponent(lowImportanceAlpha) : primary
text.append(NSAttributedString(
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 85cc81e..5362d32 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -5,7 +5,8 @@
// - On or off (the global shortcut and the menu toggle it; off stays off),
// and while on: shown always, or only when agents are waiting.
// - Layout: compact (waiting agents in full, working and idle as one summary
-// line); when shown always, a toggle switches to the full list.
+// line, with a count of forgotten drafts that jumps to the first); when
+// shown always, a toggle switches to the full list.
// - Always dark: herdr's mocha palette with fixed colors on a dark tint, so
// text reads the same over any desktop.
@@ -85,6 +86,8 @@ struct PanelContent {
let full: Bool
/// Whether the panel offers the compact/full toggle (shown always).
let canToggle: Bool
+ /// Agents with a forgotten draft not shown as rows, in list order.
+ let drafts: [Row]
/// Sessions to restore, shown first.
let offer: RestoreOffer?
@@ -107,6 +110,8 @@ struct PanelContent {
}
working = all.filter { $0.status == .working }.count
idle = all.filter { $0.status == .idle }.count
+ let shown = Set(rows.map(\.paneId))
+ drafts = all.filter { $0.forgottenDraft && !shown.contains($0.paneId) }
self.full = full
self.canToggle = canToggle
}
@@ -137,6 +142,11 @@ struct PanelContent {
var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" }
+ /// "✎ 2 drafts", when some agents not shown have one.
+ var draftsLabel: String? {
+ drafts.isEmpty ? nil : "✎ \(drafts.count) \(drafts.count == 1 ? "draft" : "drafts")"
+ }
+
/// The offer row's title: what restore will do, or that it runs.
var offerTitle: String? {
guard let offer else { return nil }
@@ -157,7 +167,8 @@ struct PanelContent {
}
if overflow > 0 { lines.append("+\(overflow) more waiting") }
if showsSummary {
- lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : ""))
+ lines.append(summary.string + (draftsLabel.map { "\t[\($0)]" } ?? "")
+ + (canToggle ? "\t[\(toggleLabel)]" : ""))
}
return lines.joined(separator: "\n")
}
@@ -369,7 +380,7 @@ private final class OfferView: ClickView {
}
}
-/// "Show all ▾" / "Show less ▴".
+/// Clickable caption text: "Show all ▾" / "Show less ▴", "Not now", "✎ 2 drafts".
private final class ToggleView: ClickView {
private let label: NSTextField
@@ -550,17 +561,29 @@ final class PanelController {
return box
}
- /// Summary on the left, the compact/full toggle on the right.
+ /// Summary on the left; on the right, the forgotten drafts (a click
+ /// jumps to the first) and the compact/full toggle.
private func summaryLine(_ content: PanelContent) -> NSView {
let box = caption(content.summary)
- guard content.canToggle else { return box }
- let toggle = ToggleView(text: content.toggleLabel, onClick: onToggleFull)
- toggle.translatesAutoresizingMaskIntoConstraints = false
- box.addSubview(toggle)
- NSLayoutConstraint.activate([
- toggle.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8),
- toggle.centerYAnchor.constraint(equalTo: box.centerYAnchor, constant: 1),
- ])
+ var trailing = box.trailingAnchor
+ var gap: CGFloat = -8
+ var constraints: [NSLayoutConstraint] = []
+ let onClick = self.onClick
+ let draftsView = zip(content.draftsLabel.map { [$0] } ?? [], content.drafts.prefix(1)).map { label, first in
+ ToggleView(text: label, onClick: { onClick(first.paneId) })
+ }
+ let toggleView = content.canToggle ? [ToggleView(text: content.toggleLabel, onClick: onToggleFull)] : []
+ for view in (toggleView + draftsView) {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ box.addSubview(view)
+ constraints += [
+ view.trailingAnchor.constraint(equalTo: trailing, constant: gap),
+ view.centerYAnchor.constraint(equalTo: box.centerYAnchor, constant: 1),
+ ]
+ trailing = view.leadingAnchor
+ gap = -14
+ }
+ NSLayoutConstraint.activate(constraints)
return box
}
@@ -695,6 +718,22 @@ func panelSelftest() -> [String] {
check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"),
"toggle offered only when allowed, labelled by layout")
+ // Forgotten drafts: a draft the user is not looking at.
+ var drafty = [row("d", .done), row("w", .working), row("i1", .idle), row("i2", .idle), row("i3", .idle)]
+ drafty[0].draft = "x"; drafty[1].draft = "x"; drafty[3].draft = "x"; drafty[4].draft = "x"
+ drafty[4].attended = true
+ check(drafty.map(\.forgottenDraft) == [true, true, false, true, false], "draft: forgotten unless looked at")
+ check(drafty.sorted(by: listedBefore).map(\.paneId) == ["d", "w", "i2", "i1", "i3"],
+ "draft: an idle agent with one first among idle agents only")
+ check(rowTitlePrefix(drafty[3]) == "○ ✎ " && rowTitle(drafty[3]).string == "○ ✎ i2"
+ && rowTitle(drafty[2]).string == "○ i1", "draft: ✎ before the name")
+ let draftPanel = PanelContent(rows: drafty.sorted(by: listedBefore), canToggle: true)
+ check(draftPanel.drafts.map(\.paneId) == ["w", "i2"] && draftPanel.draftsLabel == "✎ 2 drafts",
+ "draft: compact counts those not shown, in list order")
+ check(draftPanel.plainText.hasSuffix("○ 3 idle\t[✎ 2 drafts]\t[Show all ▾]"), "draft: the count before the toggle")
+ check(PanelContent(rows: drafty, full: true).draftsLabel == nil, "draft: none counted when every row shows")
+ check(PanelContent(rows: [drafty[3]]).draftsLabel == "✎ 1 draft", "draft: one")
+
let offer = RestoreOffer(saved_at: 0, sessions: [
.init(name: "a", windows: 2, panes: 3, agents: 2), .init(name: "b", windows: 1, panes: 1, agents: 0),
], agents: 2, interrupted: 1)
diff --git a/menubar/search.swift b/menubar/search.swift
index c62c612..ed9adaa 100644
--- a/menubar/search.swift
+++ b/menubar/search.swift
@@ -72,12 +72,15 @@ func searchFields(_ row: Row) -> [String] {
}
/// Rows matching every word of `query`, best first; ties keep the given
-/// order. An empty query returns every row in order.
+/// order. An empty query returns every row in order. A word also matches
+/// the draft in an agent's input box, as written (not fuzzily: a long text
+/// would match almost anything), ranking below the other fields.
func search(_ rows: [Row], _ query: String) -> [SearchMatch] {
let terms = query.split(whereSeparator: \.isWhitespace).map { lowered(String($0)) }
var matches: [(match: SearchMatch, order: Int)] = []
for (order, row) in rows.enumerated() {
let fields = searchFields(row)
+ let draft = String(lowered(row.draft))
var total = 0
var labelPositions = Set<Int>()
var matchedAll = true
@@ -88,6 +91,9 @@ func search(_ rows: [Row], _ query: String) -> [SearchMatch] {
best = (found.score, field, found.positions)
}
}
+ if best == nil, draft.contains(String(term)) {
+ best = (term.count * 8, fields.count, [])
+ }
guard let best else { matchedAll = false; break }
total += best.score
if best.field == 0 { labelPositions.formUnion(best.positions) }
@@ -476,6 +482,13 @@ func searchSelftest() -> [String] {
check(ids(search(rows, "lgsc")) == ["%1"], "query: fuzzy name")
check(ids(search(rows, "langgraph")) == ["%1", "%3"], "query: directories match too")
check(ids(search(rows, "temporal demo")) == ["%2"], "query: every word must match")
+ var drafted = rows
+ drafted[3].draft = "also check the Sidecar restart"
+ check(ids(search(drafted, "sidecar")) == ["%4"], "query: a draft's words match")
+ check(ids(search(drafted, "scr")).first == "%1" && !ids(search(drafted, "sdcr")).contains("%4"),
+ "query: a draft matches as written, not fuzzily")
+ drafted[0].draft = "deploy"
+ check(ids(search(drafted, "deploy")) == ["%3", "%1"], "query: a draft ranks below a name")
check(ids(search(rows, "done")) == ["%1"], "query: status word")
check(ids(search(rows, "notes")) == ["%4"], "query: window")
check(ids(search(rows, "zzz")).isEmpty, "query: no match")