diff --git a/README.md b/README.md index 0075e69..7a5fc85 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ up to three times the pet, and a *maximum*: the panel fits itself to its content by default — every row on one shared set of columns, so items line up down the panel — never wider than the maximum and never narrower than the pet. The panel's text size is yours to set (8–20pt at the default pet — every label, -glyph and bar in the row draws at that size, and the panel widens with it, so +glyph and ring in the row draws at that size, and the panel widens with it, so the setting zooms the whole panel rather than enlarging one line inside the same box), or you can let the pet's size govern both with "Scale the panel with the pet", which greys the two sliders out. The alignment picker says where the diff --git a/README.zh-CN.md b/README.zh-CN.md index 4a69c28..e6fcfee 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -177,7 +177,7 @@ Oh My Pi 没有 hook 表,所以上面那张表是 Claude Code 的。运行时 动画之外,宠物还带着 Codex 原版宠物才有的消息元素:精灵上方一小块状态,用同一套词汇——**Running**、等审批时的 **Needs input**(并写出是哪个工具)、回合结束的 **Ready**、出错的 **Blocked**;出现时窗口向上生长,宠物本体的位置纹丝不动。 -这一块现在是一个**会话面板**:每个开着的会话一行,同一 agent 的会话排在一起,宠物正在展示的那个排最前。每行可显示:agent 名、session id 后六位、会话名或项目名、模型(含 effort)、当前工具、上下文占用(荧光绿横条)、会话花费、5 小时 / 7 天额度用量、状态消息。每一项都可以在管理器的 Settings 里开关与排序;面板可设为常驻或只在有事发生时出现;宽度是宠物宽度的**上限**(宠物宽度的 100–300%,滑杆 + 数值框 + 加减按钮):面板默认**贴合内容**——所有行共用同一组列(同一项在同一 x,缺项留空),因此看起来是一张对齐的表;它不会超过上限,也不会比宠物窄。面板的字号也可调(默认宠物下 8–20pt):整块面板——状态措辞、会话名、工具、图标、荧光条、行高、内边距——都按同一比例缩放,面板宽度也随之变宽,所以这个设置是**整块面板的缩放**;打开"随宠物缩放"则由宠物尺寸决定这两项,两个滑杆随之置灰。对齐选的是**面板相对宠物的锚定边**(左缘貼左缘 / 居中 / 右缘貼右缘),行内文字永远左对齐。**宠物本身不动**:面板向上生长、并向锚定的一侧展开,宽度涨得即时、缩得等内容稳定下来。状态消息的第二行仍是事件真正携带的信息:在等哪个工具、什么失败了,以及回合结束时**助手最后一条消息的预览**——Claude Code 通过 `last_assistant_message` 把它交给 hook,Codex 也是这么展示的。它按 Codex 的规则整理(折叠空白、截到 200 字符),只存在于内存,绝不写入任何日志文件;你输入过的内容永远不上屏。 +这一块现在是一个**会话面板**:每个开着的会话一行,同一 agent 的会话排在一起,宠物正在展示的那个排最前。每行可显示:agent 名、session id 后六位、会话名或项目名、模型(含 effort)、当前工具、上下文占用(荧光绿圆环)、会话花费、5 小时 / 7 天额度用量、状态消息。每一项都可以在管理器的 Settings 里开关与排序;面板可设为常驻或只在有事发生时出现;宽度是宠物宽度的**上限**(宠物宽度的 100–300%,滑杆 + 数值框 + 加减按钮):面板默认**贴合内容**——所有行共用同一组列(同一项在同一 x,缺项留空),因此看起来是一张对齐的表;它不会超过上限,也不会比宠物窄。面板的字号也可调(默认宠物下 8–20pt):整块面板——状态措辞、会话名、工具、图标、荧光圆环、行高、内边距——都按同一比例缩放,面板宽度也随之变宽,所以这个设置是**整块面板的缩放**;打开"随宠物缩放"则由宠物尺寸决定这两项,两个滑杆随之置灰。对齐选的是**面板相对宠物的锚定边**(左缘貼左缘 / 居中 / 右缘貼右缘),行内文字永远左对齐。**宠物本身不动**:面板向上生长、并向锚定的一侧展开,宽度涨得即时、缩得等内容稳定下来。状态消息的第二行仍是事件真正携带的信息:在等哪个工具、什么失败了,以及回合结束时**助手最后一条消息的预览**——Claude Code 通过 `last_assistant_message` 把它交给 hook,Codex 也是这么展示的。它按 Codex 的规则整理(折叠空白、截到 200 字符),只存在于内存,绝不写入任何日志文件;你输入过的内容永远不上屏。 **模型、上下文、花费、额度**是 hook 不携带的数字——Claude Code 只把它们交给状态栏命令。Settings 里可以开启"状态栏 tap":运行时接收状态栏 JSON、只保留减字段后的几项,然后把你原有的状态栏命令原样跑过去,输出照旧。默认关闭、立即生效、一键还原、每次渲染约 +2ms,且从不读取 transcript。app 被删除时 wrapper 会发现 shim 不在,把状态栏交还给你自己的命令。 diff --git a/Sources/AgentPetApp/MessagePanelLayout.swift b/Sources/AgentPetApp/MessagePanelLayout.swift index bd14777..cb89e5c 100644 --- a/Sources/AgentPetApp/MessagePanelLayout.swift +++ b/Sources/AgentPetApp/MessagePanelLayout.swift @@ -1,5 +1,6 @@ import AgentPetCore import AppKit +import CoreText /// Turns a `MessagePanel` into rectangles. /// @@ -22,7 +23,63 @@ enum MessagePanelLayout { nonisolated static let baseTailHeight: CGFloat = 4 /// Narrower than this and an item says nothing useful. nonisolated static let baseMinimumItemWidth: CGFloat = 34 - nonisolated static let baseContextBarSize = CGSize(width: 46, height: 7) + /// The usage badge: a ring as tall as the row it sits in, with the reading + /// inside it. + /// + /// A ring rather than the bar this used to be. The bar was 46×7pt of a row + /// that has to hold nine items: with the panel at its default 200% and the + /// pet at 112pt, that was 46pt of a 224pt row — a fifth of everything the + /// row had to say — and on a large pet the wording beside it was what gave + /// way (user request, 2026-09-20). The reading costs 17pt here, the ring + /// itself and nothing else, about a third of what the bar and its gap took. + /// + /// **Why the number is inside rather than beside it.** Beside it, the item + /// asks for the ring, a gap and the number — 43pt at the baseline and 64pt + /// at 1.5×, with the number drawn at the item's own size as that version + /// did — and the squeeze answers by giving every non-flexible column its + /// floor, so the context column came to rest at the width of the ring and + /// the number was never drawn at all: measured with all nine items enabled + /// and the panel width swept from 100% to 300%, the number was on screen in + /// **none** of the 11 places that row drew the ring (the roomy three-item + /// row, for contrast, had it in 9 of its 11). Inside the ring, the item's + /// whole reading costs one ring's width, and the ring's width is what the + /// cell floors at. + /// + /// The three numbers are all drawn through `Metrics`, so they follow the + /// pet: **Pet → Size** doubles the diameter, the stroke and the digits + /// together, which is what makes it read as one badge at any size rather + /// than a hairline beside big text (user requirement, 2026-09-20). + nonisolated static let baseContextRingDiameter: CGFloat = 17 + nonisolated static let baseContextRingLineWidth: CGFloat = 2 + + /// The badge's digits, as a fraction of the panel's own text. + /// + /// Geometry, not taste. The ring may not be taller than the row (17pt + /// inside an 18pt row, a point of air), so with a 2pt stroke its hole is + /// 13pt across at the baseline. The digits have to fit inside that hole, + /// and they are sized for **"100"** — the widest reading there is, and the + /// one to size for, because a font size that shrank when the number + /// happened to reach three digits would read as a bug. + /// + /// Measured against the system font, per point of its size: "100" covers + /// 1.67 of ink across and 0.74 down — a digit's ink is its cap height, + /// there is no descender — so what has to fit the hole is a diagonal of + /// 1.83, and the 13pt hole holds 7.1pt, 0.68 of the panel's 10.5pt text. + /// 0.62 is that with the margin left in: the ink's diagonal then comes to + /// 11.9pt against the 13pt hole, so its corners sit six tenths of a point + /// inside the hole at the baseline. + /// + /// What is measured is the **ink**, not the font's line box: digits have no + /// descenders and no tails, so empty line-height may fall outside the hole + /// without anything being drawn through the stroke. `--selftest` measures + /// the same ink at 80, 112, 168 and 224pt pets, so raising this ratio + /// cannot silently start drawing digits over the ring. + /// + /// Three fifths of the size is the honest cost of putting the number + /// inside, and it is the price of the only version that is ever *drawn*: + /// beside the ring the item asks for about 43pt at the baseline against the + /// ring's 17, and the column never has that to give. + nonisolated static let baseContextRingFontRatio: CGFloat = 0.62 /// The width an icon item is drawn at. nonisolated static let baseIconItemWidth: CGFloat = 13 /// The width a message's own text may ask for before it is treated as @@ -49,7 +106,7 @@ enum MessagePanelLayout { /// Every number the panel draws with, at the scale its text asks for. /// /// **One scale drives all of it.** The message, the other labels, the - /// agent's glyph, the usage bar, the row height, the padding, the tail: + /// agent's glyph, the usage ring, the row height, the padding, the tail: /// at the default setting on the default pet every one of them is the /// number it has always been, and anything that doubles the text — a /// bigger setting, a bigger pet — doubles the panel with it. @@ -81,7 +138,11 @@ enum MessagePanelLayout { let iconItemWidth: CGFloat let messageWidthCap: CGFloat let messageDetailAllowance: CGFloat - let contextBarSize: CGSize + /// The usage badge: the ring's diameter and stroke, and the font its + /// digits are drawn at. + let contextRingDiameter: CGFloat + let contextRingLineWidth: CGFloat + let contextRingFont: NSFont } /// The font size the panel draws at. @@ -115,8 +176,9 @@ enum MessagePanelLayout { iconItemWidth: baseIconItemWidth * scale, messageWidthCap: baseMessageWidthCap * scale, messageDetailAllowance: baseMessageDetailAllowance * scale, - contextBarSize: CGSize(width: baseContextBarSize.width * scale, - height: baseContextBarSize.height * scale) + contextRingDiameter: baseContextRingDiameter * scale, + contextRingLineWidth: baseContextRingLineWidth * scale, + contextRingFont: NSFont.systemFont(ofSize: points * baseContextRingFontRatio) ) } @@ -270,7 +332,7 @@ enum MessagePanelLayout { /// The items one row would draw, in configured order. /// /// Content that does not exist is left out rather than drawn as a blank: - /// a session with no status line has no context figure, and an empty bar + /// a session with no status line has no context figure, and an empty ring /// would read as a full one at a glance. static func items( for row: MessagePanel.Row, @@ -318,11 +380,38 @@ enum MessagePanelLayout { width: capped(tool, 130), isFlexible: false) case .context: guard let context = row.context, let label = usageLabel(context) else { return nil } - let hasBar = usageFraction(context) != nil - let barAndGap: CGFloat = hasBar ? metrics.contextBarSize.width + 5 * metrics.scale : 0 + let hasRing = usageFraction(context) != nil + // The badge, or the plain token count that stands in for it. + // + // With the reading inside the ring, the item asking for the + // ring asks for the whole reading: there is no second figure to + // make room for, and no state in which the column is wide + // enough for the gauge but not for the number beside it. The + // floor is the same number, so a column that cannot pay for a + // ring draws nothing rather than a ring cut open — the same + // exception a glyph gets. + // + // Half a *label's* width was the floor the bar had, and a bar + // has nothing to do with a label: on a narrow panel the column + // was cut below the width of the bar itself, the bar was still + // drawn at its own width, and it ran under the status wording + // in the next column (user report, 2026-09-20). + // + // A floor above the item's own claim is a claim the row cannot + // pay, and a column takes the widest floor of its cells: a + // token count demanding the general 17pt for a 14pt label would + // drop the whole column and take the badges out of every row + // that shares it. So the token path asks for no more than its + // label (measured: 38 of 350 sampled configurations, none of + // them before this change, whose ring cell alone asked for more + // than the general floor). + let claim = hasRing ? metrics.contextRingDiameter + : width(of: label, font: metrics.itemFont) + let floor = hasRing + ? metrics.contextRingDiameter + : min(claim, MessagePanelLayout.baseMinimumItemWidth / 2) return Item(kind: .context, primary: label, secondary: nil, context: context, - width: barAndGap + width(of: label, font: metrics.itemFont), - isFlexible: false) + width: claim, isFlexible: false, minimumWidth: floor) case .cost: guard let label = row.context?.costLabel else { return nil } return Item(kind: .cost, primary: label, secondary: nil, context: row.context, @@ -587,6 +676,106 @@ enum MessagePanelLayout { } + /// What the usage figure draws inside its column: the badge, or the plain + /// text a token count falls back to. + /// + /// One place decides it, because two callers need the same answer: the + /// view draws these rects, and the self-test checks them. What keeps them + /// inside the column is **not** anything in here — it is the item's floor, + /// which is the ring's own width (`items(for:)`), and the assertion in + /// `--selftest` that a drawn ring stays inside its column. That pairing is + /// the answer to the bug this shape was introduced for: the bar was drawn + /// at the metrics' own width whatever the column had been squeezed to, and + /// it ran under the status wording in the next column (user report, + /// 2026-09-20). Nothing here measures text either: the item asking for one + /// ring is the claim the layout pass already made, and this is asked on + /// every frame the pet animates. + struct UsageLayout { + /// The badge: as wide as the metrics ask for, centred in its column + /// the way the agent's glyph is. `nil` when no fraction is known — a + /// token count with no window size behind it has nothing to gauge. + let ring: CGRect? + /// The hole, which is where the digits are drawn. Centred in it, so + /// the ring's stroke and the number cannot collide however wide the + /// panel's own text gets. + let digits: CGRect? + /// The column itself, for the token count that has no ring to sit in. + let text: CGRect? + } + + /// The reading as the badge shows it: the whole percent, without its sign. + /// + /// A ring is drawn only when there *is* a percentage, so "72" inside one + /// cannot be read as anything else — and the sign is not cheap: it makes + /// the reading half again as wide (1.67pt of ink per point of font for + /// "100", 2.64 for "100%"), which over a hole this size is the difference + /// between digits two thirds of the panel's text and digits of half it + /// (see `baseContextRingFontRatio`). + static func percentDigits(_ fraction: Double) -> String { + "\(Int((fraction * 100).rounded()))" + } + + /// The ink a string covers, measured from its baseline origin: the box the + /// glyphs occupy, not the line box the font reserves around them. + /// + /// The badge cares about both halves of that. Whether the digits fit the + /// ring's hole is a question about their ink — a digit has no descender and + /// no tail, so reserved line height that falls outside the hole draws + /// nothing — and where to put them is a question about where the ink is, + /// not where the baseline is. + static func ink(of text: String, font: NSFont) -> CGRect { + let line = CTLineCreateWithAttributedString( + NSAttributedString(string: text, attributes: [.font: font]) + ) + return CTLineGetBoundsWithOptions(line, .useGlyphPathBounds) + } + + /// The baseline origin that puts a line's ink in the middle of a rect. + static func origin(centring ink: CGRect, in rect: CGRect) -> CGPoint { + CGPoint(x: rect.midX - ink.midX, y: rect.midY - ink.midY) + } + + /// Whether the digits' ink still fits inside the ring's hole, measured + /// rather than assumed; `nil` when it fits. + /// + /// The size of the badge's digits is a ratio, and a ratio is a promise + /// about two other things: the hole, which is the ring less its stroke, and + /// the ink of the widest reading there is — **"100"**, three digits, since + /// the fraction is clamped to 0...1 and no reading is wider. + /// + /// One place, so the drawing and the self-test cannot disagree about it. + static func badgeInkFault(of ring: CGRect?, metrics: Metrics) -> String? { + let widest = "100" + let hole = (ring?.width ?? metrics.contextRingDiameter) + - 2 * metrics.contextRingLineWidth + let box = ink(of: widest, font: metrics.contextRingFont) + let diagonal = (box.width * box.width + box.height * box.height).squareRoot() + guard diagonal > hole + 0.01 else { return nil } + return String(format: "\"%@\" needs %.1fpt of hole in %.1fpt", widest, diagonal, hole) + } + + static func usageLayout(_ item: Item, in rect: CGRect, metrics: Metrics) -> UsageLayout { + guard let context = item.context, usageFraction(context) != nil else { + // No gauge: the item is its own label, and a token count takes the + // whole column it has always taken. + return UsageLayout(ring: nil, digits: nil, text: rect) + } + // Drawn at the size the metrics ask for, with no clamp on the way in: + // the column's floor is never below this width (`items(for:)`), so a column too + // narrow for the ring is a column that is not drawn at all, and the + // self-test asserts that the ring it is handed stays inside its column. + // (The bar this replaced had no such floor behind it and was drawn at + // its own width regardless — user report, 2026-09-20.) + let ring = CGRect(x: rect.midX - metrics.contextRingDiameter / 2, + y: rect.midY - metrics.contextRingDiameter / 2, + width: metrics.contextRingDiameter, + height: metrics.contextRingDiameter) + return UsageLayout(ring: ring, + digits: ring.insetBy(dx: metrics.contextRingLineWidth, + dy: metrics.contextRingLineWidth), + text: nil) + } + /// Where each of a row's items is drawn, given the panel's columns. /// /// The columns are shared, so an item sits in the same place in every row: @@ -614,6 +803,13 @@ enum MessagePanelLayout { // MARK: - Measuring + /// How much room a string needs, rounded up. + /// + /// Rounded up on purpose: this number becomes an item's claim, and a claim + /// half a point short of what the text draws is a claim that truncates. + /// Only the width is ever asked for — the badge's digits are measured by + /// their **ink** instead (`ink(of:font:)`), which is a different question + /// with a different answer (see `baseContextRingFontRatio`). static func width(of text: String, font: NSFont) -> CGFloat { (text as NSString).size(withAttributes: [.font: font]).width.rounded(.up) } diff --git a/Sources/AgentPetApp/PetView.swift b/Sources/AgentPetApp/PetView.swift index 281cded..6983657 100644 --- a/Sources/AgentPetApp/PetView.swift +++ b/Sources/AgentPetApp/PetView.swift @@ -1,5 +1,6 @@ import AgentPetCore import AppKit +import CoreText /// Draws the current sprite frame, the session panel above it, and handles /// dragging. @@ -316,46 +317,87 @@ final class PetView: NSView { return true } - /// The usage bar and its number. + /// The usage badge: a ring with the reading inside it. /// - /// A bar rather than a ring: rows are one line tall, and a ring that small - /// reads as a dot. The filled part is fluorescent green on purpose — it is - /// the one number here that changes on its own and is worth noticing from - /// across the room. + /// A ring rather than the bar this used to be. The bar was 46pt of a row + /// that has to hold nine items beside a pet, and on a narrow panel it was + /// the part of the row that pushed the status wording out (user request, + /// 2026-09-20). The number sits inside the ring rather than beside it + /// because inside is the only place it is ever drawn: beside it, the item + /// asks for the ring and the number, the squeeze gives every non-flexible + /// column its floor, and the number was never on screen at all with more + /// than a handful of items enabled. It costs the size — three fifths of + /// the panel's own text, measured in `baseContextRingFontRatio` — and buys + /// a reading that is always there. + /// + /// The filled arc is fluorescent green on purpose: it is the one number + /// here that changes on its own and is worth noticing from across the room. + /// + /// Both marks come from `MessagePanelLayout.usageLayout`, and both stay + /// inside the column because the item floors at the ring's own width — + /// the bar this replaced was drawn at its own width whatever the column had + /// been squeezed to, and ran under the wording beside it. private func drawContext( _ item: MessagePanelLayout.Item, in rect: CGRect, metrics: MessagePanelLayout.Metrics ) { - var textOrigin = rect.minX - if let context = item.context, let fraction = MessagePanelLayout.usageFraction(context) { - let bar = CGRect( - x: rect.minX, - y: rect.midY - metrics.contextBarSize.height / 2, - width: metrics.contextBarSize.width, - height: metrics.contextBarSize.height - ) - NSColor.separatorColor.withAlphaComponent(0.6).setFill() - NSBezierPath(roundedRect: bar, xRadius: 3 * metrics.scale, - yRadius: 3 * metrics.scale).fill() + let layout = MessagePanelLayout.usageLayout(item, in: rect, metrics: metrics) + + if let ring = layout.ring, let context = item.context, + let fraction = MessagePanelLayout.usageFraction(context) { + let centre = CGPoint(x: ring.midX, y: ring.midY) + let lineWidth = metrics.contextRingLineWidth + let radius = (ring.width - lineWidth) / 2 + + let track = NSBezierPath() + track.appendArc(withCenter: centre, radius: radius, startAngle: 0, endAngle: 360) + track.lineWidth = lineWidth + NSColor.separatorColor.withAlphaComponent(0.6).setStroke() + track.stroke() + + if fraction > 0 { + // Twelve o'clock, clockwise. This view is not flipped, so 90° + // is straight up and the arc walks back down from there — the + // direction it fills in is the direction a gauge fills in. + let arc = NSBezierPath() + arc.appendArc(withCenter: centre, radius: radius, + startAngle: 90, endAngle: 90 - 360 * fraction, + clockwise: true) + arc.lineWidth = lineWidth + arc.lineCapStyle = .round + MessagePanelLayout.usageColor.setStroke() + arc.stroke() + } - let filled = CGRect( - x: bar.minX, y: bar.minY, - width: max(2, bar.width * CGFloat(fraction)), height: bar.height - ) - MessagePanelLayout.usageColor.setFill() - NSBezierPath(roundedRect: filled, xRadius: 3 * metrics.scale, - yRadius: 3 * metrics.scale).fill() - textOrigin = bar.maxX + 5 * metrics.scale + // Drawn through Core Text rather than `NSAttributedString.draw`, + // because the position that matters is the *ink's* and not the line + // box's: `draw(in:)` puts a line at the top of the rect it is given, + // which would hang a digit — no descender, so its ink sits at the + // top of its line box — against the stroke above it. + if let digits = layout.digits, let context = NSGraphicsContext.current?.cgContext { + let string = NSAttributedString( + string: MessagePanelLayout.percentDigits(fraction), + attributes: [ + .font: metrics.contextRingFont, + .foregroundColor: NSColor.labelColor.cgColor, + ] + ) + let line = CTLineCreateWithAttributedString(string) + context.saveGState() + context.textPosition = MessagePanelLayout.origin( + centring: CTLineGetBoundsWithOptions(line, .useGlyphPathBounds), in: digits + ) + CTLineDraw(line, context) + context.restoreGState() + } } - let textRect = CGRect( - x: textOrigin, y: rect.minY, - width: max(0, rect.maxX - textOrigin), height: rect.height - ) - Self.text(item.primary, font: metrics.itemFont, - color: .secondaryLabelColor) - .draw(in: textRect.insetBy(dx: 0, dy: 3 * metrics.scale)) + if let text = layout.text { + Self.text(item.primary, font: metrics.itemFont, + color: .secondaryLabelColor) + .draw(in: text.insetBy(dx: 0, dy: 3 * metrics.scale)) + } } /// The wording the pet has always used, now one row among several. diff --git a/Sources/AgentPetApp/RenderSelfTest.swift b/Sources/AgentPetApp/RenderSelfTest.swift index 9c67858..cd5a13e 100644 --- a/Sources/AgentPetApp/RenderSelfTest.swift +++ b/Sources/AgentPetApp/RenderSelfTest.swift @@ -366,7 +366,7 @@ enum RenderSelfTest { } /// Verifies the message panel beside the pet: the rows, the space they - /// reserve, the window that grows for them, and the usage bar. + /// reserve, the window that grows for them, and the usage ring. /// /// Driven by real events through the real engine — the panel is a view of /// the activity list, and a test that hand-built the rows would prove @@ -675,14 +675,122 @@ enum RenderSelfTest { let withoutContextHash = contentHash(view) view.show(panel, config: appPanelConfig) if !appPanelConfig.items.contains(where: { $0.kind == .context && $0.isEnabled }) { - print(" – usage bar skipped: this config has the context item switched off") + print(" – usage ring skipped: this config has the context item switched off") } else if withoutContextHash != withPanel { - print(" ✓ the usage bar is actually painted") + print(" ✓ the usage ring is actually painted") } else { print(" ✗ turning the context item off changed nothing") failures += 1 } + // Neither mark leaves the column it was given — the ring inside the + // column, the digits inside the ring — at any width the settings + // allow. The bar this replaced did neither: it was drawn at the + // metrics' own width whatever the column had been squeezed to, and its + // column's floor was half a *label's* minimum, a number with nothing to + // do with a bar, so on a narrow panel it ran straight under the status + // wording beside it (user report, 2026-09-20). Read here as rectangles, + // because that is the whole of the bug: the ring is painted, and the + // next item starts where the column ends. + var escapes: [String] = [] + var ringsLookedAt = 0 + var digitsLookedAt = 0 + let crowded = MessagePanelConfig() // every item on + var roomy = MessagePanelConfig() // and a row with room to spare + roomy.items = [.session, .context, .message].map { MessagePanelConfig.Item($0) } + for (name, config) in [("the full row", crowded), ("a roomy row", roomy)] { + for percent in stride(from: MessagePanelConfig.minimumWidthPercent, + through: MessagePanelConfig.maximumWidthPercent, by: 20) { + var tight = config + tight.widthPercent = percent + tight.fitsText = false + let tightPlan = MessagePanelLayout.plan(for: panel, config: tight, + petWidth: petWidth) + for row in tightPlan.rows { + for (item, frame) in MessagePanelLayout.frames( + for: row, columns: tightPlan.columns, metrics: tightPlan.metrics + ) where item.kind == .context { + // Only the badge is read here. An item with no badge is + // a token count, which is text in the column it was + // given and truncates in it, as it always has. + let marks = MessagePanelLayout.usageLayout( + item, in: frame, metrics: tightPlan.metrics + ) + guard let ring = marks.ring else { continue } + ringsLookedAt += 1 + if ring.minX < frame.minX - 0.5 || ring.maxX > frame.maxX + 0.5 + || ring.width > tightPlan.metrics.contextRingDiameter + 0.5 { + escapes.append("a \(Int(ring.width))pt ring in a " + + "\(Int(frame.width))pt column at " + + "\(Int(percent))% in \(name)") + } + // The digits' rect is the ring's hole by construction, + // so what is checked is the thing that decision is for: + // that the ink a percentage can produce — three digits, + // "100" being the widest — is inside the hole and not + // through the stroke. The rule itself lives in the + // layout (`badgeInkFault`); it is asked here with the + // ring the layout actually handed out, which is the same + // hole by construction, but asked over every (config, + // width) pair this sweep covers. + // + // Asking the layout's own rule is how this check stopped + // reporting a failure at the default pet for a badge + // that draws correctly: measured by hand, the line box's + // diagonal is 13.3pt against a 13pt hole while the ink's + // is 10.6pt at the ratio this first ran at, 14.4 and + // 11.9 at the 0.62 it ships with. The line box is never + // the question — digits have no descenders, so empty + // line height outside the hole draws nothing. + if marks.digits != nil { + digitsLookedAt += 1 + if let why = MessagePanelLayout.badgeInkFault( + of: ring, metrics: tightPlan.metrics + ) { + escapes.append("\(why) at \(Int(percent))% in \(name)") + } + } + } + } + } + } + + // And at every scale the pet can be set to, because the badge is one + // drawing at one scale and the fit above is a ratio: at twice the pet + // the hole is twice as wide, but the digits are twice as tall too. + var holeAtTheSmallest: CGFloat = 0 + for width in [CGFloat(AppConfig.PetConfig.minimumWidth), 112, 168, 224] { + var scaleConfig = MessagePanelConfig() + scaleConfig.messageFontSize = MessagePanelConfig.defaultFontSize + let metrics = MessagePanelLayout.metrics(for: scaleConfig, petWidth: width) + if holeAtTheSmallest == 0 { + holeAtTheSmallest = metrics.contextRingDiameter + - 2 * metrics.contextRingLineWidth + } + if let why = MessagePanelLayout.badgeInkFault(of: nil, metrics: metrics) { + escapes.append("\(why) at a \(Int(width))pt pet") + } + } + + if escapes.isEmpty, ringsLookedAt > 0, digitsLookedAt > 0 { + let atDefault = MessagePanelLayout.metrics(for: MessagePanelConfig(), petWidth: 112) + print(" ✓ the badge stays in its own column — \(ringsLookedAt) rings, " + + "\(digitsLookedAt) of them with the reading inside, over " + + "\(Int(MessagePanelConfig.minimumWidthPercent))–" + + "\(Int(MessagePanelConfig.maximumWidthPercent))% of the pet — and \"100\", the " + + "widest reading there is, fits its hole at every Pet → Size " + + "(\(String(format: "%.1f", holeAtTheSmallest))pt hole at the smallest pet for " + + "\(String(format: "%.1f", atDefault.contextRingFont.pointSize))pt digits " + + "against \(String(format: "%.1f", atDefault.itemFont.pointSize))pt text)") + } else if !escapes.isEmpty { + print(" ✗ the usage figure is drawn outside its own column: " + + escapes.prefix(4).joined(separator: "; ")) + failures += 1 + } else if ringsLookedAt == 0 || digitsLookedAt == 0 { + print(" ✗ no usage badge was laid out at any width — this check proves nothing") + failures += 1 + } + // Temporary: a PNG of what is actually drawn, for looking at. // // Dumped with the app's own config rather than the one this test last @@ -784,7 +892,7 @@ enum RenderSelfTest { ("the padding", baseMetrics.padding, bigMetrics.padding), ("the item gap", baseMetrics.spacing, bigMetrics.spacing), ("the agent glyph", baseMetrics.iconItemWidth, bigMetrics.iconItemWidth), - ("the usage bar", baseMetrics.contextBarSize.width, bigMetrics.contextBarSize.width), + ("the usage badge", baseMetrics.contextRingDiameter, bigMetrics.contextRingDiameter), ("the panel", MessagePanelLayout.maximumPanelWidth(for: fullConfig, petWidth: 112), MessagePanelLayout.maximumPanelWidth(for: fullConfig, petWidth: 224)), ] @@ -794,7 +902,32 @@ enum RenderSelfTest { } else { print(" ✓ at twice the pet the whole row is twice the size — " + "\(Int(bigMetrics.rowHeight))pt row, \(Int(bigMetrics.itemFont.pointSize))pt labels, " - + "\(Int(bigMetrics.contextBarSize.width))pt bar") + + "\(Int(bigMetrics.contextRingDiameter))pt usage ring, " + + "\(Int(bigMetrics.contextRingLineWidth))pt of stroke") + } + + // And the badge's own numbers, because they are what the request was + // about: **Pet → Size** has to move the ring's diameter, its stroke and + // its digits *together*, or a bigger pet draws a hairline ring around a + // number that did not grow with it — the same two-panels-in-one-row + // fault the row above was fixed for. (The bar this replaced scaled as a + // whole too, 46×7pt by the same factor; what it failed at was staying + // inside the column it was handed, not following the pet.) + let ringWithThePet: [(String, CGFloat, CGFloat)] = [ + ("diameter", baseMetrics.contextRingDiameter, bigMetrics.contextRingDiameter), + ("stroke", baseMetrics.contextRingLineWidth, bigMetrics.contextRingLineWidth), + ("digits", baseMetrics.contextRingFont.pointSize, + bigMetrics.contextRingFont.pointSize), + ] + if let (name, one, two) = ringWithThePet.first(where: { abs($0.2 - 2 * $0.1) > 0.01 }) { + print(" ✗ the badge's \(name) did not double with the pet: \(one) -> \(two)") + failures += 1 + } else { + print(" ✓ the badge follows **Pet → Size**: a " + + "\(String(format: "%.0f", baseMetrics.contextRingDiameter))pt ring in " + + "\(String(format: "%.0f", baseMetrics.contextRingLineWidth))pt of stroke with " + + "\(String(format: "%.1f", baseMetrics.contextRingFont.pointSize))pt digits at a " + + "112pt pet, twice all three at 224pt") } // The status wording survives the largest text size the settings @@ -967,6 +1100,71 @@ enum RenderSelfTest { failures += 1 } + // A session that reports tokens without a window size has no fraction + // to gauge, so its cell is a plain token count — and it shares the + // context *column* with the rows that do draw a badge. A column takes + // the widest floor of the cells in it, so a token cell whose floor sits + // above its own claim drops the whole column and takes those badges + // with it (measured: 38 of 350 sampled configurations, none of them + // before the badge replaced the bar, whose cell asked for more than the + // general floor on its own). At the smallest pet the claim of "999" is + // under the general floor, which is what makes this the case that + // breaks. + controller.resetActivities() + send(.working, agent: "claude-code", session: "cafebabe-8888", at: 20, + context: SessionContext(usedPercent: 72, capturedAt: now)) + send(.working, agent: "codex", session: "deadbeef-9999", at: 21, tool: "Bash", + context: SessionContext(totalTokens: 999, capturedAt: now)) + var mixedConfig = MessagePanelConfig() + mixedConfig.items = [.session, .context, .message].map { MessagePanelConfig.Item($0) } + mixedConfig.widthPercent = MessagePanelConfig.minimumWidthPercent + let mixedPet = CGFloat(AppConfig.PetConfig.minimumWidth) + let mixedPlan = MessagePanelLayout.plan(for: view.currentPanel, config: mixedConfig, + petWidth: mixedPet) + var badgeRows = 0 + var tokenRows = 0 + var drawn = 0 + for row in mixedPlan.rows { + for (item, frame) in MessagePanelLayout.frames( + for: row, columns: mixedPlan.columns, metrics: mixedPlan.metrics + ) where item.kind == .context { + drawn += 1 + if MessagePanelLayout.usageLayout(item, in: frame, metrics: mixedPlan.metrics) + .ring != nil { + badgeRows += 1 + } else { + tokenRows += 1 + } + } + } + // The column itself is where the defect showed: a floor above the + // widest claim in it means the column is not drawn at all + // (`Column.isDrawn`), and the cells above would simply not be there to + // count — which is why the check belongs here and not per cell: a + // per-cell form ("this item floors above its own claim") cannot fire + // once the floor is capped, and with this row's "999" label it could + // not have fired before the cap either, because the claim that was + // under the floor was in the column that got dropped, so there was no + // cell to complain about (round 3, 2026-09-20 — the per-cell form had + // survived round 2's fix by accident). + let mixedColumn = mixedPlan.columns.first { $0.kind == .context } + if mixedColumn?.isDrawn != true { + let shape = mixedColumn.map { column in + "width \(Int(column.width))pt against a floor of \(Int(column.minimumWidth))pt" + } ?? "no context column at all" + print(" ✗ the context column was dropped: \(shape)") + failures += 1 + } + if drawn == 2, badgeRows == 1, tokenRows == 1 { + print(" ✓ a token count and a badge share the context column and both are drawn " + + "at the smallest pet (\(Int(mixedPet))pt)") + } else { + print(" ✗ the mixed context column lost a row: \(drawn) of 2 cells drawn " + + "(\(badgeRows) badges, \(tokenRows) token counts) at a \(Int(mixedPet))pt pet") + failures += 1 + } + controller.resetActivities() + return failures } diff --git a/Sources/AgentPetApp/UI/SettingsView.swift b/Sources/AgentPetApp/UI/SettingsView.swift index 91783a3..cd9f9b2 100644 --- a/Sources/AgentPetApp/UI/SettingsView.swift +++ b/Sources/AgentPetApp/UI/SettingsView.swift @@ -99,7 +99,7 @@ struct SettingsView: View { Toggle("Scale the panel with the pet", isOn: binding(\.messagePanel.autoScale)) .help("On: the panel draws itself at the default text size for whatever " + "pet size is set, and asks for the default width, so text, rows, " - + "glyph and bar keep their proportions as the pet grows. The width " + + "glyph and ring keep their proportions as the pet grows. The width " + "and text size below are then set for you and do not apply.") LabeledContent("Width") { @@ -150,7 +150,7 @@ struct SettingsView: View { .disabled(model.config.messagePanel.autoScale) .help("The panel's text, in points at the default 112pt pet. Everything " + "the panel draws with scales together — the status wording, the " - + "session's names, the agent's glyph, the usage bar, the row, the " + + "session's names, the agent's glyph, the usage ring, the row, the " + "padding — and the panel and its window grow with it, so a bigger " + "text size zooms the panel rather than squeezing a bigger message " + "into the same box.") @@ -332,7 +332,7 @@ struct SettingsView: View { /// What the size settings mean at the pet size on screen now — the /// numbers the sliders alone cannot tell you, and the size every label, - /// glyph and bar in the panel is drawn at. + /// glyph and ring in the panel is drawn at. private var effectiveSizeSummary: String { let petWidth = CGFloat(model.config.pet.width) let points = MessagePanelLayout.effectiveFontSize(for: model.config.messagePanel) diff --git a/Sources/AgentPetCore/Domain/MessagePanel.swift b/Sources/AgentPetCore/Domain/MessagePanel.swift index 017d497..b64a6d2 100644 --- a/Sources/AgentPetCore/Domain/MessagePanel.swift +++ b/Sources/AgentPetCore/Domain/MessagePanel.swift @@ -3,7 +3,7 @@ import Foundation /// Which parts of a session's row are drawn beside the pet, and in what order. /// /// The panel is a dashboard, and a dashboard that cannot be edited is one the -/// user lives with rather than uses: some people want the context bar and +/// user lives with rather than uses: some people want the context ring and /// nothing else, some want only the sessions that need them. public struct MessagePanelConfig: Codable, Sendable, Equatable { @@ -24,7 +24,7 @@ public struct MessagePanelConfig: Codable, Sendable, Equatable { /// The panel's text size, in points **at the default pet size**. /// /// Every number the panel draws with is this multiple of its baseline — - /// the fonts, the row, the padding, the glyph, the usage bar — so a bigger + /// the fonts, the row, the padding, the glyph, the usage ring — so a bigger /// setting zooms the whole panel and widens it by the same factor. public static let minimumFontSize: Double = 8 public static let maximumFontSize: Double = 20 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9593ed7..0b52fec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -690,12 +690,27 @@ Codex 的 ambient pet 带一个 notification:四种状态、一行标签、可 | `task` | 会话名 > 仓库名 > 目录名 | 状态栏 tap(名字/仓库)或 `focusTarget`(cwd) | | `model` | 模型显示名,有 effort 时拼 `· level` | 状态栏 tap | | `tool` | 当前工具,**仅 `running` 时显示** | 规则的 `toolNameField`(**不是** `summary`) | -| `context` | 百分比 + 荧光绿横条(#39FF14) | 状态栏 tap;无 tap 则该项不画 | +| `context` | 百分比 + 荧光绿圆环(#39FF14) | 状态栏 tap;无 tap 则该项不画 | | `cost` | 会话估计花费 `$x.xx` | 状态栏 tap | | `limits` | `5h 41% · 7d 13%` | 状态栏 tap | | `message` | §6.4 的标签(+ 正文) | 每个会话自己的状态 | -**字号、宽度、对齐(2026-09-14 追加,2026-09-16 扩展)**:基准行高 18pt,agent 11.5pt semibold、其余 10.5pt、session 用等宽;面板宽度 = 宠物宽度的百分比(设置里 100–300%,滑杆 + 数值框 + 加减按钮,默认 200%),文字可宽于宠物本体。**对齐是"面板相对宠物"的锚定边,不是行内文字对齐**(2026-09-18 改语义,用户指出):left = 面板左缘贴宠物左缘向右长,right = 右缘贴右缘向左长,center = 居中(窗口一直以来的行为);**行内文字永远左对齐**。窗口仍然等于面板,所以精灵不再居中于窗口:`Alignment.spriteOriginX(inWindowWidth:petWidth:)` / `windowOriginX(forSpriteX:...)`(Core,带测试)给出偏移与还原,`PetView.spriteRect`(尾巴、hitTest、追踪区、注视原点都跟着它)、`resizeWindow`、`savePosition` 全用它;存档存的仍是**精灵自己的左缘**,否则一次 left、一次 right 地退出就会把宠物一点点挪走。**整块面板按一个比例缩放**(2026-09-16 起,2026-09-18 改为全面板):字号设置以"默认 112pt 宠物下的点数"记(默认 10.5pt = 它一直以来的大小,范围 8–20pt),实际大小 = 设置值 × 宠物宽/112,设置界面同时显示换算后的值。**这个比例乘在面板画出的每一个数字上**(`MessagePanelLayout.Metrics`):四种字体(agent 11.5/其余 10.5/等宽 10.5)、行高 18、行距 2、内边距 9、项间距 8、气泡尾 4、圆角与文字内缩、图标宽 13 与 SF Symbol 点数、上下文条 46×7、各项宽度上限与 `minimumItemWidth`。默认设置 + 默认宠物时比例恰好是 1,所以老用户的面板逐像素不动——这条不变式是硬要求,改完要用 dump 比像素(2026-09-18 实测 200% 全项、255% 用户配置两种都是 0 像素差异)。**面板宽度与窗口也乘同一比例**(2026-09-18,用户决定):字号设置是整块面板的**缩放**,20pt 时同一 255% 面板宽 1.9 倍(286 → 543pt),否则行会被要求在没变宽的盒子里装下放大 1.9 倍的内容——实测就是绿条压到状态措辞上。注意乘的是**设置值相对默认的倍数**(`设置/10.5`),不是 `Metrics.scale`:后者已经含宠物那一份,而面板宽度本来就跟着宠物走,乘两次会让 224pt 宠物的面板变成四倍宽。为什么不是"只跟宠物"(2026-09-16 的原决定):消息单独放大时,一行里出现两种字号(20pt 的措辞旁边是 10.5pt 的任务名与 7pt 的条),宠物越大元数据相对越小,读起来像 bug 而不是像默认(用户报告,2026-09-18)。**字号调大不能让状态措辞变少**(2026-09-18 修正):行的挤压从前把消息和其它灵活项一样压到那一档的常数地板(34pt),于是在 112pt 宠物、255% 宽、20pt 时消息只剩 123pt,而"Needs input"加省略号要 127pt——**把字号调大反而比默认字号少看见一截状态词**(用户报告)。现在消息声明自己的地板:标签宽度 + 省略号宽度(正文可以截,标签不行),挤压先动"只会念名字"的项(任务名、工具名)。**这份声明是全有或全无**:行只有在"其余每一项都保住自己的 `minimumWidth` 之后还剩得下整份措辞"时才付,付不起就退回常数地板 34pt——只付一部分等于什么都没买到(措辞照样被截),却让旁边的项白白让出宽度。 +**上下文占用改成"徽章":环 + 环心里的数字(2026-09-20,用户报告"横条与状态消息重叠",见截图)**:`context` 项从 46×7pt 的横条改为**圆环**,读数(整数百分比,**不带 `%`**)画在环心里 —— `baseContextRingDiameter` 17(行高 18,留 1pt 气)、`baseContextRingLineWidth` 2、`baseContextRingFontRatio` 0.62 × 面板自身字号。而**省下的横向空间是这次改动的本意**:默认设置(112pt 宠物、面板 200% = 224pt 宽)下,横条连间隙要 51pt——占整行的约五分之一——而徽章的宽度就是环的直径,读数不再另外要位置(51 → 17pt,约三分之一)。 + +**数字为什么进环心(2026-09-20 追加,用户问"能不能放进环里的空白、会不会太小")**:先按"环 + 右边数字"做了一版,然后发现那版**永远不会被画出来**。挤压的规则是"非灵活列在付清缺口前一律降到各自的 `minimumWidth`"(§6.5 的挤压顺序),而面板宽度上限一般只有宠物宽的 200%,行本就是超预算的:环画得起(17pt),"环 + 间隔 + 数字"付不起(按数字用面板自身字号的旁置版算:基准 43pt、1.5× 下约 64pt)。实测:9 项全开、面板宽度从 100% 扫到 300%,那一行画出环的 11 个位置里**带数字的 0 个**(作为对照,只有 3 项的那种宽行 11 个里有 9 个带数字)。数字进环心后,这一项的**声明宽度 = 环直径 = 它的地板**,读数与环同生共死,"有环没数"这个状态从架构上消失了。 + +**字号的代价是量出来的,不是选的**:环不能高于行(17pt 环在 18pt 行里),2pt 描边后洞只有 13pt(基准);数字必须塞进洞里,且要按**最宽的读数 "100"** 定字号(字号随位数变会读成 bug)。实测(系统字体,按字号归一):"100" 的墨宽 1.67 倍、墨高 0.74 倍,对角 **1.83** 倍字号 → 13pt 洞装得下 **7.1pt**,即面板 10.5pt 正文的 **0.68**,取 **0.62** 留余量(对角余量 13 − 11.9 ≈ 1pt;四角到洞边约 0.6pt)。也就是说**数字约是同行文字的六成**(用户 1.5× 下约 9.8pt vs 15.75pt)——这是"放进环里"的诚实代价,也正是它换来的东西:唯一一个真的会被画出来的版本。量的是**墨**而不是字体的行盒(数字没有下伸部,行盒多出来的空白落在洞外也没画东西),这条判据由 `MessagePanelLayout.badgeInkFault` 单独给出,`--selftest` 在 80/112/168/224pt 四种宠物上各量一次。 + +**重叠的根因是"画出来的宽度"与"列拿到的宽度"是两套数**:横条按 `metrics.contextBarSize` 的固定宽度画在 `rect.minX`,而挤压只保证列 ≥ 该列的 `minimumWidth`;当时 `context` 的地板是**通用默认值**"标签最小宽的一半"(17pt,且未乘缩放)——一个和横条毫无关系的数——于是窄面板上列被压到 17pt、横条照旧画满 46pt(大面积下 92pt),直接压在下一列的状态文案上,百分比则被算成 0 宽而消失(用户截图:绿色横条与 "Running" 叠在一起)。所以: + +- **项的地板 = 环直径 = 它声明要的宽度**:画多宽就留多宽,低于它整列不画(与 glyph 同一条例外——"13pt 的图标不是被压扁的词");读数在环里,所以地板不再有"环够、数不够"的中间态。 +- **位置只由 `MessagePanelLayout.usageLayout` 一处决定**:view 照它画,`--selftest` 照它核。圆环水平居中(与 agent glyph 同规矩),按 metrics 的尺寸直接画、**没有夹取**——因为"列的地板不低于环的直径"这条不变式成立时那个夹取永远不生效(混列时地板是该列最宽的那格,可能比环大),而死代码会假装有一条并不存在的防线;真正守住它的是 `--selftest` 对"环必须整只在列内"的断言。这一处也**不量文字**——"要一个环"就是布局期已经做出的声明,而这是在宠物每帧动的时候被问的。 +- **数字按墨心居中**:`draw(in:)` 把一行贴它拿到的矩形顶部,而数字没有下伸部,照行盒摆会让它顶到上方的描边上,所以这一行改用 Core Text 的**字形墨界**(`CTLineGetBoundsWithOptions(.useGlyphPathBounds)`)算基线位置,让墨盒中点落在环心(`ink(of:font:)` + `origin(centring:in:)`)。同一条墨界也是"塞不塞得下"的判据(`badgeInkFault`)——绘制与自检量的是同一个东西,不是两套近似。 + +环的**直径、描边与数字字号乘同一个 `Metrics.scale`**,所以 **Pet → Size** 三者一起放大(112pt 宠物上 17pt 环 / 2pt 描边 / 6.5pt 数字,224pt 上全部翻倍),不会出现"字变大、刻度依旧"的两套比例。`--selftest` 钉五条:三者随 2 倍宠物各自翻倍;面板宽度 100%–300% 全扫一遍,满行(9 项)与宽行(3 项)两种配置下环与数字都不越出所在列;"100" 的墨在 80/112/168/224pt 四种宠物上都塞得进洞(负数对照:把直径换回横条那套固定宽度即 FAIL);以及徽章确实被画出来(关掉该项会改变渲染)。第五条是混列回归(2026-09-20 第二轮审查后补):同一列里既有画徽章的行、也有只报 token 数的行时,该列在最小宠物上仍须被画出——地板高于列宽就是整列被丢掉,见上。 + + +**字号、宽度、对齐(2026-09-14 追加,2026-09-16 扩展)**:基准行高 18pt,agent 11.5pt semibold、其余 10.5pt、session 用等宽;面板宽度 = 宠物宽度的百分比(设置里 100–300%,滑杆 + 数值框 + 加减按钮,默认 200%),文字可宽于宠物本体。**对齐是"面板相对宠物"的锚定边,不是行内文字对齐**(2026-09-18 改语义,用户指出):left = 面板左缘贴宠物左缘向右长,right = 右缘贴右缘向左长,center = 居中(窗口一直以来的行为);**行内文字永远左对齐**。窗口仍然等于面板,所以精灵不再居中于窗口:`Alignment.spriteOriginX(inWindowWidth:petWidth:)` / `windowOriginX(forSpriteX:...)`(Core,带测试)给出偏移与还原,`PetView.spriteRect`(尾巴、hitTest、追踪区、注视原点都跟着它)、`resizeWindow`、`savePosition` 全用它;存档存的仍是**精灵自己的左缘**,否则一次 left、一次 right 地退出就会把宠物一点点挪走。**整块面板按一个比例缩放**(2026-09-16 起,2026-09-18 改为全面板):字号设置以"默认 112pt 宠物下的点数"记(默认 10.5pt = 它一直以来的大小,范围 8–20pt),实际大小 = 设置值 × 宠物宽/112,设置界面同时显示换算后的值。**这个比例乘在面板画出的每一个数字上**(`MessagePanelLayout.Metrics`):四种字体(agent 11.5/其余 10.5/等宽 10.5)、行高 18、行距 2、内边距 9、项间距 8、气泡尾 4、圆角与文字内缩、图标宽 13 与 SF Symbol 点数、上下文徽章 17 / 2 / 0.62×(环直径 / 描边 / 数字比例)、各项宽度上限与 `minimumItemWidth`。默认设置 + 默认宠物时比例恰好是 1,所以**比例这一层**没有任何数字变动:老用户的面板不因缩放而移动(这条不变式是硬要求,改完要用 dump 比像素;2026-09-18 实测 200% 全项、255% 用户配置两种都是 0 像素差异)。**它管的是缩放,不是形状** —— `context` 项从横条换成徽章(2026-09-20)就是有意改变默认呈现的一次,那种改动按用户要求做、并在本节与本文件里留痕,不属于"逐像素不动"的范围。**面板宽度与窗口也乘同一比例**(2026-09-18,用户决定):字号设置是整块面板的**缩放**,20pt 时同一 255% 面板宽 1.9 倍(286 → 543pt),否则行会被要求在没变宽的盒子里装下放大 1.9 倍的内容——实测就是绿条压到状态措辞上。注意乘的是**设置值相对默认的倍数**(`设置/10.5`),不是 `Metrics.scale`:后者已经含宠物那一份,而面板宽度本来就跟着宠物走,乘两次会让 224pt 宠物的面板变成四倍宽。为什么不是"只跟宠物"(2026-09-16 的原决定):消息单独放大时,一行里出现两种字号(20pt 的措辞旁边是 10.5pt 的任务名与 7pt 的条),宠物越大元数据相对越小,读起来像 bug 而不是像默认(用户报告,2026-09-18)。**字号调大不能让状态措辞变少**(2026-09-18 修正):行的挤压从前把消息和其它灵活项一样压到那一档的常数地板(34pt),于是在 112pt 宠物、255% 宽、20pt 时消息只剩 123pt,而"Needs input"加省略号要 127pt——**把字号调大反而比默认字号少看见一截状态词**(用户报告)。现在消息声明自己的地板:标签宽度 + 省略号宽度(正文可以截,标签不行),挤压先动"只会念名字"的项(任务名、工具名)。**这份声明是全有或全无**:行只有在"其余每一项都保住自己的 `minimumWidth` 之后还剩得下整份措辞"时才付,付不起就退回常数地板 34pt——只付一部分等于什么都没买到(措辞照样被截),却让旁边的项白白让出宽度。 **管理器的打开不等检测(2026-09-18,用户报告"开窗有几秒延迟")**:`openManager` 从前是 `refreshAll()` 之后才 `show()`,而 `refreshAgents()` 在**主线程**上给每个 agent spawn 一次 `--version` —— 本机实测 **6 个 agent 0.57s**(热缓存)/ 冷机更久(第一次从磁盘读那些 CLI),于是窗口要等到整轮检测结束才出现(实测 openManager ≈ 0.96s,其中 refreshAll 572ms + 建窗 387ms)。现在窗口**先**出现(实测 64ms),`refreshAgents()` 把检测放进 `Task.detached` 立即返回,结果回到主 actor 再发布;`isRefreshingAgents` 让 Agents 页显示"Checking what is installed…"而不是空白页,`detectionGeneration` 保证先发的旧一轮不会覆盖新一轮,`onAgentStatusesChanged` 让**菜单栏**(非 SwiftUI)也重建。`--verbose` 打两行:`manager open: window shown in Nms, detection running behind it` 与 `agents: detection took Nms for N`。启动时那次检测同样变成后台(宠物首帧不再等它)。"