diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-30 18:59:43 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-30 18:59:43 +0200 |
| commit | 959f0d46cac1a09d0821da599d8dff54d1147892 (patch) | |
| tree | 499b38261ccfdc88f60ea8cbac7d279f202709c1 /menubar | |
| parent | b0cfd10b80443d3f67d263ff4db177bb53cc90ed (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.swift | 45 | ||||
| -rw-r--r-- | menubar/panel.swift | 63 | ||||
| -rw-r--r-- | menubar/search.swift | 15 |
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") |
