macOS: show friendly device names in Instances

This commit is contained in:
Peter Steinberger
2025-12-17 22:23:57 +01:00
parent 09819f8b2e
commit 7950a646c3
4 changed files with 1385 additions and 1313 deletions

View File

@@ -0,0 +1,69 @@
import Foundation
struct DevicePresentation: Sendable {
let title: String
let symbol: String?
}
enum DeviceModelCatalog {
static func presentation(deviceFamily: String?, modelIdentifier: String?) -> DevicePresentation? {
let family = (deviceFamily ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let model = (modelIdentifier ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let modelEntry = model.isEmpty ? nil : modelIdentifierTable[model]
let symbol = modelEntry?.symbol ?? fallbackSymbol(for: family, modelIdentifier: model)
let title = if let name = modelEntry?.name, !name.isEmpty {
name
} else if !family.isEmpty, !model.isEmpty {
"\(family) (\(model))"
} else if !family.isEmpty {
family
} else if !model.isEmpty {
model
} else {
""
}
if title.isEmpty { return nil }
return DevicePresentation(title: title, symbol: symbol)
}
private static func fallbackSymbol(for familyRaw: String, modelIdentifier: String) -> String? {
let family = familyRaw.trimmingCharacters(in: .whitespacesAndNewlines)
if family.isEmpty { return nil }
switch family.lowercased() {
case "ipad":
return "ipad"
case "iphone":
return "iphone"
case "mac":
return "laptopcomputer"
case "android":
// Prefer tablet glyph when we know it's an Android tablet. (No attempt to infer phone/tablet here.)
return "cpu"
case "linux":
return "cpu"
default:
return "cpu"
}
}
private struct ModelEntry: Sendable {
let name: String
let symbol: String?
}
// Friendly model names for a small set of known identifiers.
// Extend this table as needed; unknown identifiers fall back to the raw value.
private static let modelIdentifierTable: [String: ModelEntry] = [
// iPad
"iPad16,5": .init(name: "iPad Pro 11-inch (M4)", symbol: "ipad"),
"iPad16,6": .init(name: "iPad Pro 13-inch (M4)", symbol: "ipad"),
// Mac
"Mac16,6": .init(name: "MacBook Pro (14-inch, 2024)", symbol: "laptopcomputer"),
"Mac16,8": .init(name: "MacBook Pro (16-inch, 2024)", symbol: "laptopcomputer"),
]
}

View File

@@ -70,10 +70,11 @@ struct InstancesSettings: View {
if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) { if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) {
self.label(icon: self.platformIcon(platform), text: prettyPlatform) self.label(icon: self.platformIcon(platform), text: prettyPlatform)
} }
if let deviceText = self.deviceDescription(inst), if let device = DeviceModelCatalog.presentation(
let deviceIcon = self.deviceIcon(inst) deviceFamily: inst.deviceFamily,
modelIdentifier: inst.modelIdentifier)
{ {
self.label(icon: deviceIcon, text: deviceText) self.label(icon: device.symbol, text: device.title)
} }
self.label(icon: "clock", text: inst.lastInputDescription) self.label(icon: "clock", text: inst.lastInputDescription)
if let mode = inst.mode { self.label(icon: "network", text: mode) } if let mode = inst.mode { self.label(icon: "network", text: mode) }
@@ -94,9 +95,11 @@ struct InstancesSettings: View {
.padding(.vertical, 6) .padding(.vertical, 6)
} }
private func label(icon: String, text: String) -> some View { private func label(icon: String?, text: String) -> some View {
HStack(spacing: 4) { HStack(spacing: 4) {
if let icon {
Image(systemName: icon).foregroundStyle(.secondary).font(.caption) Image(systemName: icon).foregroundStyle(.secondary).font(.caption)
}
Text(text) Text(text)
} }
.font(.footnote) .font(.footnote)
@@ -120,28 +123,6 @@ struct InstancesSettings: View {
} }
} }
private func deviceIcon(_ inst: InstanceInfo) -> String? {
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if family.isEmpty { return nil }
switch family.lowercased() {
case "ipad":
return "ipad"
case "iphone":
return "iphone"
case "mac":
return "laptopcomputer"
default:
return "cpu"
}
}
private func deviceDescription(_ inst: InstanceInfo) -> String? {
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let model = inst.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !family.isEmpty, !model.isEmpty { return "\(family) (\(model))" }
if !model.isEmpty { return model }
return family.isEmpty ? nil : family
}
private func prettyPlatform(_ raw: String) -> String? { private func prettyPlatform(_ raw: String) -> String? {
let (prefix, version) = self.parsePlatform(raw) let (prefix, version) = self.parsePlatform(raw)
if prefix.isEmpty { return nil } if prefix.isEmpty { return nil }

View File

@@ -1,6 +1,6 @@
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai";
import type { TSchema } from "@sinclair/typebox";
import { codingTools, readTool } from "@mariozechner/pi-coding-agent"; import { codingTools, readTool } from "@mariozechner/pi-coding-agent";
import type { TSchema } from "@sinclair/typebox";
import { detectMime } from "../media/mime.js"; import { detectMime } from "../media/mime.js";

View File

@@ -949,7 +949,10 @@ export async function startGatewayServer(
if (!key) { if (!key) {
return { return {
ok: false, ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: "key required" }, error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
}; };
} }
@@ -1102,7 +1105,10 @@ export async function startGatewayServer(
chatAbortControllers.delete(runId); chatAbortControllers.delete(runId);
chatRunBuffers.delete(runId); chatRunBuffers.delete(runId);
const current = chatRunSessions.get(active.sessionId); const current = chatRunSessions.get(active.sessionId);
if (current?.clientRunId === runId && current.sessionKey === sessionKey) { if (
current?.clientRunId === runId &&
current.sessionKey === sessionKey
) {
chatRunSessions.delete(active.sessionId); chatRunSessions.delete(active.sessionId);
} }
@@ -2126,10 +2132,13 @@ export async function startGatewayServer(
: []; : [];
const hardMax = 1000; const hardMax = 1000;
const defaultLimit = 200; const defaultLimit = 200;
const requested = typeof limit === "number" ? limit : defaultLimit; const requested =
typeof limit === "number" ? limit : defaultLimit;
const max = Math.min(hardMax, requested); const max = Math.min(hardMax, requested);
const sliced = const sliced =
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; rawMessages.length > max
? rawMessages.slice(-max)
: rawMessages;
const capped = capArrayByJsonBytes( const capped = capArrayByJsonBytes(
sliced, sliced,
MAX_CHAT_HISTORY_MESSAGES_BYTES, MAX_CHAT_HISTORY_MESSAGES_BYTES,
@@ -2268,7 +2277,9 @@ export async function startGatewayServer(
break; break;
} }
} }
const { storePath, store, entry } = loadSessionEntry(p.sessionKey); const { storePath, store, entry } = loadSessionEntry(
p.sessionKey,
);
const now = Date.now(); const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID(); const sessionId = entry?.sessionId ?? randomUUID();
const sessionEntry: SessionEntry = { const sessionEntry: SessionEntry = {
@@ -2441,7 +2452,10 @@ export async function startGatewayServer(
); );
break; break;
} }
const p = params as { id: string; patch: Record<string, unknown> }; const p = params as {
id: string;
patch: Record<string, unknown>;
};
const job = await cron.update( const job = await cron.update(
p.id, p.id,
p.patch as unknown as CronJobPatch, p.patch as unknown as CronJobPatch,
@@ -2683,7 +2697,9 @@ export async function startGatewayServer(
const version = const version =
typeof params.version === "string" ? params.version : undefined; typeof params.version === "string" ? params.version : undefined;
const platform = const platform =
typeof params.platform === "string" ? params.platform : undefined; typeof params.platform === "string"
? params.platform
: undefined;
const deviceFamily = const deviceFamily =
typeof params.deviceFamily === "string" typeof params.deviceFamily === "string"
? params.deviceFamily ? params.deviceFamily
@@ -2725,7 +2741,8 @@ export async function startGatewayServer(
normalizedReason === "heartbeat"; normalizedReason === "heartbeat";
if (!(isNodePresenceLine && looksPeriodic)) { if (!(isNodePresenceLine && looksPeriodic)) {
const compactNodeText = const compactNodeText =
isNodePresenceLine && (host || ip || version || mode || reason) isNodePresenceLine &&
(host || ip || version || mode || reason)
? `Node: ${host?.trim() || "Unknown"}${ip ? ` (${ip})` : ""} · app ${version?.trim() || "unknown"} · mode ${mode?.trim() || "unknown"} · reason ${reason?.trim() || "event"}` ? `Node: ${host?.trim() || "Unknown"}${ip ? ` (${ip})` : ""} · app ${version?.trim() || "unknown"} · mode ${mode?.trim() || "unknown"} · reason ${reason?.trim() || "event"}`
: text; : text;
enqueueSystemEvent(compactNodeText); enqueueSystemEvent(compactNodeText);
@@ -3137,7 +3154,11 @@ export async function startGatewayServer(
} }
} catch (err) { } catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
dedupe.set(`send:${idem}`, { ts: Date.now(), ok: false, error }); dedupe.set(`send:${idem}`, {
ts: Date.now(),
ok: false,
error,
});
respond(false, undefined, error, { respond(false, undefined, error, {
provider, provider,
error: formatForLog(err), error: formatForLog(err),
@@ -3180,7 +3201,8 @@ export async function startGatewayServer(
const message = params.message.trim(); const message = params.message.trim();
const requestedSessionKey = const requestedSessionKey =
typeof params.sessionKey === "string" && params.sessionKey.trim() typeof params.sessionKey === "string" &&
params.sessionKey.trim()
? params.sessionKey.trim() ? params.sessionKey.trim()
: undefined; : undefined;
let resolvedSessionId = params.sessionId?.trim() || undefined; let resolvedSessionId = params.sessionId?.trim() || undefined;