@raingor/pi-web-switch 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,9 +67,10 @@ interface UsageRangeData {
67
67
  totalCost: number;
68
68
  totalRequests: number;
69
69
  }[];
70
+ notice?: "no-config" | "api-error";
70
71
  }
71
72
 
72
- type SourceKey = "all" | "pi" | "cindy-pi" | "claude" | "codex" | "opencode" | "gemini" | "grok";
73
+ type SourceKey = "all" | "pi" | "cindy-pi" | "claude" | "codex" | "opencode" | "gemini" | "grok" | "atomcode" | "copilot";
73
74
  type RangeKey = "today" | "7d" | "30d" | "custom";
74
75
  type TabKey = "log" | "provider" | "model";
75
76
  type SortDir = "asc" | "desc";
@@ -116,21 +117,35 @@ function formatCostShort(n: number): string {
116
117
  }
117
118
 
118
119
  function formatDateShort(dateStr: string): string {
119
- const d = new Date(dateStr + "T00:00:00");
120
- return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
120
+ // Parse as a China-time (UTC+8) calendar date and format in that timezone.
121
+ const [y, m, dNum] = dateStr.split("-").map(Number);
122
+ if (!y || !m || !dNum) return dateStr;
123
+ const d = new Date(Date.UTC(y, m - 1, dNum));
124
+ return d.toLocaleDateString("en-US", {
125
+ month: "short",
126
+ day: "numeric",
127
+ timeZone: "Asia/Shanghai",
128
+ });
121
129
  }
122
130
 
123
- function localDateStr(d: Date): string {
124
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
131
+ function cnTodayStr(): string {
132
+ // "YYYY-MM-DD" in China time (UTC+8), independent of system timezone.
133
+ return new Intl.DateTimeFormat("en-CA", {
134
+ timeZone: "Asia/Shanghai",
135
+ year: "numeric",
136
+ month: "2-digit",
137
+ day: "2-digit",
138
+ }).format(new Date());
125
139
  }
126
140
 
127
141
  /** Previous period of equal length, for period-over-period trends. */
128
142
  function getPrevRange(range: RangeKey): { from: string; to: string } | null {
129
- const now = new Date();
130
143
  const shift = (days: number) => {
131
- const d = new Date(now);
132
- d.setDate(d.getDate() - days);
133
- return localDateStr(d);
144
+ // Start from China-time "today" and shift by whole days using UTC math.
145
+ const [y, m, dNum] = cnTodayStr().split("-").map(Number);
146
+ const t = Date.UTC(y ?? 0, (m ?? 1) - 1, dNum ?? 1) - days * 86400000;
147
+ const d = new Date(t);
148
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
134
149
  };
135
150
  if (range === "today") return { from: shift(1), to: shift(1) };
136
151
  if (range === "7d") return { from: shift(13), to: shift(7) };
@@ -290,7 +305,7 @@ export function DashboardPage() {
290
305
 
291
306
  const customInvalid = range === "custom" && !!customFrom && !!customTo && customFrom > customTo;
292
307
 
293
- const fetchData = useCallback(() => {
308
+ const fetchData = useCallback((force = false) => {
294
309
  if (!initialized || customInvalid) return;
295
310
  let baseUrl = "/api/pi/usage-range";
296
311
  if (source === "all") baseUrl = "/api/pi/all-usage-range";
@@ -300,7 +315,10 @@ export function DashboardPage() {
300
315
  else if (source === "opencode") baseUrl = "/api/pi/opencode-usage-range";
301
316
  else if (source === "gemini") baseUrl = "/api/pi/gemini-usage-range";
302
317
  else if (source === "grok") baseUrl = "/api/pi/grok-usage-range";
303
- let url = `${baseUrl}?range=${range}`;
318
+ else if (source === "atomcode") baseUrl = "/api/pi/atomcode-usage-range";
319
+ else if (source === "copilot") baseUrl = "/api/pi/copilot-usage-range";
320
+ // force=true adds refresh=1 so the API rescan bypasses its 30s session cache
321
+ let url = `${baseUrl}?range=${range}${force ? "&refresh=1" : ""}`;
304
322
  if (range === "custom" && customFrom) {
305
323
  url += `&from=${customFrom}&to=${customTo || customFrom}`;
306
324
  }
@@ -342,7 +360,7 @@ export function DashboardPage() {
342
360
  return () => clearInterval(id);
343
361
  }, [autoRefresh, refreshInterval, fetchData]);
344
362
 
345
- const today = new Date().toISOString().split("T")[0];
363
+ const today = cnTodayStr();
346
364
 
347
365
  // Chart data: hourly for "today", daily for 7d/30d/custom
348
366
  const rawBreakdown = range === "today" ? data?.hourlyBreakdown : data?.dailyBreakdown;
@@ -408,6 +426,8 @@ export function DashboardPage() {
408
426
  { key: "opencode" as SourceKey, label: "dashboard.source_opencode", icon: "🔷" },
409
427
  { key: "gemini" as SourceKey, label: "dashboard.source_gemini", icon: "✨" },
410
428
  { key: "grok" as SourceKey, label: "dashboard.source_grok", icon: "🌀" },
429
+ { key: "atomcode" as SourceKey, label: "dashboard.source_atomcode", icon: "⚛️" },
430
+ { key: "copilot" as SourceKey, label: "dashboard.source_copilot", icon: "🐙" },
411
431
  ]).map((s) => (
412
432
  <button
413
433
  key={s.key}
@@ -432,6 +452,18 @@ export function DashboardPage() {
432
452
  {data ? t("dashboard.requests_count", String(data.requestLog.length), formatCost(data.totalCost, currency)) : ""}
433
453
  {lastUpdated && <span className="ml-2">· {t("dashboard.last_updated", lastUpdated)}</span>}
434
454
  </p>
455
+ {data?.notice && (
456
+ <p
457
+ className="mt-1 inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs"
458
+ style={{
459
+ borderColor: data.notice === "no-config" ? "#f59e0b" : "#ef4444",
460
+ color: data.notice === "no-config" ? "#f59e0b" : "#f87171",
461
+ backgroundColor: data.notice === "no-config" ? "#f59e0b11" : "#ef444411",
462
+ }}
463
+ >
464
+ {data.notice === "no-config" ? t("dashboard.copilot_not_configured") : t("dashboard.copilot_api_error")}
465
+ </p>
466
+ )}
435
467
  </div>
436
468
  <div className="flex items-center gap-2">
437
469
  <button
@@ -444,7 +476,7 @@ export function DashboardPage() {
444
476
  {currency}
445
477
  </button>
446
478
  <button
447
- onClick={() => { fetchData(); }}
479
+ onClick={() => { fetchData(true); }}
448
480
  className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-gray-800/30"
449
481
  style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
450
482
  title={t("dashboard.refresh_now")}
@@ -4,10 +4,9 @@ import {
4
4
  Settings,
5
5
  History,
6
6
  Brain,
7
- Globe,
8
7
  Plug,
9
8
  Users,
10
- MessageSquare,
9
+ Globe,
11
10
  } from "lucide-react";
12
11
  import { cn } from "@/lib/utils";
13
12
  import { useTranslation, LANGUAGES } from "@/lib/i18n";
@@ -15,7 +14,6 @@ import { useState } from "react";
15
14
 
16
15
  const navItems = [
17
16
  { to: "/", icon: LayoutDashboard, key: "nav.dashboard" },
18
- { to: "/chat", icon: MessageSquare, key: "nav.chat" },
19
17
  { to: "/sessions", icon: History, key: "nav.sessions" },
20
18
  { to: "/memory", icon: Brain, key: "nav.memory" },
21
19
  { to: "/providers", icon: Plug, key: "nav.providers_models" },
@@ -35,10 +33,21 @@ export function Sidebar() {
35
33
  borderColor: "var(--sidebar-border)",
36
34
  }}
37
35
  >
38
- {/* Logo */}
36
+ {/* Logo — pt-12 leaves room for the macOS traffic-light buttons in the
37
+ immersive (hiddenInset) title bar so they don't overlap the logo.
38
+ WebkitAppRegion: drag restores window dragging since the native
39
+ title bar is hidden. */}
39
40
  <div
40
- className="flex items-center gap-3 border-b px-6 py-5"
41
- style={{ borderColor: "var(--sidebar-border)" }}
41
+ className="flex items-center gap-3 border-b px-6 pt-12 pb-5"
42
+ style={{
43
+ borderColor: "var(--sidebar-border)",
44
+ userSelect: "none",
45
+ WebkitUserSelect: "none",
46
+ // Electron-only: lets the user drag the frameless (hiddenInset) window
47
+ // from the sidebar header. TypeScript doesn't know the non-standard
48
+ // -webkit-app-region property, so cast it explicitly.
49
+ ...({ WebkitAppRegion: "drag" } as React.CSSProperties),
50
+ }}
42
51
  >
43
52
  <img src="/pi.svg" alt="pi-switch" className="h-9 w-9 rounded-lg" />
44
53
  <div>
@@ -115,11 +115,11 @@ interface ParsedImport {
115
115
  }
116
116
 
117
117
  const IMPORT_LABEL_RE =
118
- /(?<![\w/.\-])(apikey|api_key|api-key|key|token|secret|密钥|金鑰|baseurl|base_url|base-url|url|endpoint|地址|接口|provider|name|名称|名稱|供应商|供應商|model_ids?|modelids?|models?|模型)\s*[::](?!\/\/)/gi;
118
+ /(?<![\w/.\-])(apikey|api_key|api-key|keys?|token|secret|密钥|金鑰|baseurl|base_url|base-url|url|endpoint|地址|接口|provider|name|名称|名稱|供应商|供應商|model_ids?|modelids?|models?|模型)\s*[::](?!\/\/)/gi;
119
119
 
120
120
  function importField(label: string): "name" | "baseUrl" | "apiKey" | "models" {
121
121
  const l = label.toLowerCase();
122
- if (/^(apikey|api_key|api-key|key|token|secret|密钥|金鑰)$/.test(l)) return "apiKey";
122
+ if (/^(apikey|api_key|api-key|keys?|token|secret|密钥|金鑰)$/.test(l)) return "apiKey";
123
123
  if (/^(baseurl|base_url|base-url|url|endpoint|地址|接口)$/.test(l)) return "baseUrl";
124
124
  if (/^(provider|name|名称|名稱|供应商|供應商)$/.test(l)) return "name";
125
125
  return "models";
@@ -658,6 +658,9 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
658
658
  const [supportsDeveloperRole, setSupportsDeveloperRole] = useState(
659
659
  provider.compat?.supportsDeveloperRole ?? false
660
660
  );
661
+ const [supportsFinishReason, setSupportsFinishReason] = useState(
662
+ provider.compat?.supportsFinishReason ?? true
663
+ );
661
664
  const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
662
665
 
663
666
  // ─── Quick add (inline, one-liner) ───
@@ -857,7 +860,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
857
860
  const urlInvalid = baseUrl.trim() !== "" && !isValidHttpUrl(baseUrl.trim());
858
861
 
859
862
  const dirty =
860
- (isCustom && (providerName !== (provider.name ?? "") || baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions") || supportsDeveloperRole !== (provider.compat?.supportsDeveloperRole ?? true))) ||
863
+ (isCustom && (providerName !== (provider.name ?? "") || baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions") || supportsDeveloperRole !== (provider.compat?.supportsDeveloperRole ?? true) || supportsFinishReason !== (provider.compat?.supportsFinishReason ?? true))) ||
861
864
  (!isCustom && (baseUrl !== (provider.baseUrl ?? "") || api !== (provider.api ?? "openai-completions"))) ||
862
865
  apiKey !== savedKey;
863
866
 
@@ -870,7 +873,7 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
870
873
  baseUrl: baseUrl || undefined,
871
874
  api,
872
875
  apiKey: apiKey || undefined,
873
- compat: { supportsDeveloperRole },
876
+ compat: { ...provider.compat, supportsDeveloperRole, supportsFinishReason },
874
877
  };
875
878
  // pi's model picker shows the provider key, not the display name, so
876
879
  // rename the key too when the name changes (references get rewritten).
@@ -1050,6 +1053,21 @@ function ProviderDetail({ provider, onDelete, onDuplicate, onRenamed }: { provid
1050
1053
  </label>
1051
1054
  </div>
1052
1055
 
1056
+ {/* Finish Reason Support */}
1057
+ <div className="flex items-center gap-2">
1058
+ <input
1059
+ id="supports-finish-reason"
1060
+ type="checkbox"
1061
+ checked={supportsFinishReason}
1062
+ onChange={(e) => setSupportsFinishReason(e.target.checked)}
1063
+ className="rounded border-gray-600 bg-gray-800 text-blue-500"
1064
+ />
1065
+ <label htmlFor="supports-finish-reason" className="text-sm text-gray-400">
1066
+ <span>{t("compat.supports_finish_reason")}</span>
1067
+ <span className="ml-2 text-xs text-gray-500">{t("compat.supports_finish_reason_desc")}</span>
1068
+ </label>
1069
+ </div>
1070
+
1053
1071
  {/* Save / Test / Feedback row */}
1054
1072
  <div className="flex flex-wrap items-center gap-3">
1055
1073
  {dirty && (
@@ -1742,6 +1760,23 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1742
1760
  onChange={(e) => setContextWindow(parseInt(e.target.value) || DEFAULT_CONTEXT_WINDOW)}
1743
1761
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
1744
1762
  />
1763
+ <div className="mt-1 flex flex-wrap gap-1">
1764
+ {[32_768, 128_000, 200_000, 1_000_000].map((v) => (
1765
+ <button
1766
+ key={v}
1767
+ type="button"
1768
+ onClick={() => setContextWindow(v)}
1769
+ className={cn(
1770
+ "rounded border px-1.5 py-0.5 text-[10px] font-mono transition-colors",
1771
+ (form.contextWindow ?? DEFAULT_CONTEXT_WINDOW) === v
1772
+ ? "border-blue-500 bg-blue-500/20 text-blue-300"
1773
+ : "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-500 hover:text-gray-200"
1774
+ )}
1775
+ >
1776
+ {formatTokens(v)}
1777
+ </button>
1778
+ ))}
1779
+ </div>
1745
1780
  </div>
1746
1781
  <div>
1747
1782
  <label className="block text-xs font-medium text-gray-400">{t("models.max_tokens")}</label>
@@ -1751,6 +1786,23 @@ function ModelForm({ initial, onSubmit, onCancel }: ModelFormProps) {
1751
1786
  onChange={(e) => setMaxTokens(parseInt(e.target.value) || DEFAULT_MAX_TOKENS)}
1752
1787
  className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white"
1753
1788
  />
1789
+ <div className="mt-1 flex flex-wrap gap-1">
1790
+ {[4096, 8192, 16_384, 32_768, 65_536, 131_072].map((v) => (
1791
+ <button
1792
+ key={v}
1793
+ type="button"
1794
+ onClick={() => setMaxTokens(v)}
1795
+ className={cn(
1796
+ "rounded border px-1.5 py-0.5 text-[10px] font-mono transition-colors",
1797
+ (form.maxTokens ?? DEFAULT_MAX_TOKENS) === v
1798
+ ? "border-blue-500 bg-blue-500/20 text-blue-300"
1799
+ : "border-gray-700 bg-gray-800 text-gray-400 hover:border-gray-500 hover:text-gray-200"
1800
+ )}
1801
+ >
1802
+ {formatTokens(v)}
1803
+ </button>
1804
+ ))}
1805
+ </div>
1754
1806
  </div>
1755
1807
  </div>
1756
1808
 
@@ -3,7 +3,7 @@ import { useConfigStore } from "@/store/config-store";
3
3
  import { useTranslation } from "@/lib/i18n";
4
4
  import {
5
5
  History, MessageSquare, Clock, ChevronDown, ChevronRight, Trash2, AlertTriangle,
6
- Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search,
6
+ Shield, RefreshCw, Undo2, Eye, Folder, FolderOpen, FileText, Search, ArchiveX,
7
7
  } from "lucide-react";
8
8
  import { Modal } from "@/components/ui/Modal";
9
9
 
@@ -466,7 +466,7 @@ function findParentProjectPath(node: TreeNode): string {
466
466
 
467
467
  export function SessionsPage() {
468
468
  const { t } = useTranslation();
469
- const { initialized } = useConfigStore();
469
+ const { initialized, settings } = useConfigStore();
470
470
  const [tab, setTab] = useState<"sessions" | "trash">("sessions");
471
471
  const [groups, setGroups] = useState<ProjectGroup[]>([]);
472
472
  const [trash, setTrash] = useState<TrashEntry[]>([]);
@@ -480,6 +480,7 @@ export function SessionsPage() {
480
480
  const [selectedTrash, setSelectedTrash] = useState<Set<string>>(new Set());
481
481
  const [purgeTarget, setPurgeTarget] = useState<"batch" | TrashEntry | null>(null);
482
482
  const [purging, setPurging] = useState(false);
483
+ const [expiring, setExpiring] = useState(false);
483
484
  // Preview modal state
484
485
  const [previewTarget, setPreviewTarget] = useState<SessionInfo | null>(null);
485
486
  const [preview, setPreview] = useState<{ messages: PreviewMessage[]; total: number } | null>(null);
@@ -575,6 +576,27 @@ export function SessionsPage() {
575
576
  loadAll();
576
577
  };
577
578
 
579
+ const handleAutoExpire = async () => {
580
+ setExpiring(true);
581
+ try {
582
+ const res = await fetch("/api/pi/session/auto-expire", { method: "POST" });
583
+ const result = await res.json();
584
+ if (result.success) {
585
+ const count = result.expired?.length ?? 0;
586
+ alert(count > 0
587
+ ? t("sessions.auto_expire_success", String(count))
588
+ : t("sessions.auto_expire_none"));
589
+ loadAll();
590
+ } else {
591
+ alert(t("sessions.auto_expire_error", result.error || "unknown"));
592
+ }
593
+ } catch {
594
+ alert(t("sessions.auto_expire_error", "network"));
595
+ } finally {
596
+ setExpiring(false);
597
+ }
598
+ };
599
+
578
600
  const openPreview = (session: SessionInfo) => {
579
601
  setPreviewTarget(session);
580
602
  setPreview(null);
@@ -701,6 +723,18 @@ function countExpandableNodes(nodes: TreeNode[]): number {
701
723
  <RefreshCw className={refreshing ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
702
724
  {t("sessions.refresh")}
703
725
  </button>
726
+ {tab === "sessions" && groups.length > 0 && (
727
+ <button
728
+ onClick={handleAutoExpire}
729
+ disabled={expiring}
730
+ className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
731
+ style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)", opacity: expiring ? 0.6 : 1 }}
732
+ title={t("sessions.auto_expire_tooltip", String(settings?.sessionExpiryDays ?? 7))}
733
+ >
734
+ <ArchiveX className={expiring ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
735
+ {t("sessions.auto_expire")}
736
+ </button>
737
+ )}
704
738
  </div>
705
739
  </div>
706
740
 
@@ -27,6 +27,7 @@ import {
27
27
  CloudDownload,
28
28
  RefreshCw,
29
29
  ZoomIn,
30
+ KeyRound,
30
31
  } from "lucide-react";
31
32
 
32
33
  type SettingsTab = "appearance" | "models" | "advanced";
@@ -116,6 +117,37 @@ export function SettingsPage() {
116
117
  const [updateError, setUpdateError] = useState(false);
117
118
  const [applying, setApplying] = useState(false);
118
119
  const [applyMessage, setApplyMessage] = useState<{ ok: number; failNames: string[] } | null>(null);
120
+ // GitHub Copilot usage API config (username + classic PAT).
121
+ const [copilotCfg, setCopilotCfg] = useState<{ username: string; token: string }>({ username: "", token: "" });
122
+ const [copilotSaving, setCopilotSaving] = useState(false);
123
+ const [copilotMsg, setCopilotMsg] = useState<{ ok: boolean; text: string } | null>(null);
124
+
125
+ useEffect(() => {
126
+ fetch("/api/pi/copilot-config")
127
+ .then((r) => r.json())
128
+ .then((cfg: { username?: string; token?: string }) =>
129
+ setCopilotCfg({ username: cfg.username ?? "", token: cfg.token ?? "" })
130
+ )
131
+ .catch(() => {});
132
+ }, []);
133
+
134
+ const handleSaveCopilot = async () => {
135
+ setCopilotSaving(true);
136
+ setCopilotMsg(null);
137
+ try {
138
+ const res = await fetch("/api/pi/copilot-config", {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json" },
141
+ body: JSON.stringify(copilotCfg),
142
+ });
143
+ const { success } = (await res.json()) as { success: boolean };
144
+ setCopilotMsg({ ok: !!success, text: success ? t("settings.copilot_saved") : t("settings.copilot_save_failed") });
145
+ } catch {
146
+ setCopilotMsg({ ok: false, text: t("settings.copilot_save_failed") });
147
+ } finally {
148
+ setCopilotSaving(false);
149
+ }
150
+ };
119
151
 
120
152
  const handleCheckUpdates = async () => {
121
153
  setCheckingUpdates(true);
@@ -563,6 +595,46 @@ export function SettingsPage() {
563
595
  })()}
564
596
  </Card>
565
597
 
598
+ <Card icon={KeyRound} title={t("settings.copilot_title")} desc={t("settings.copilot_desc")}>
599
+ <div className="max-w-md space-y-3">
600
+ <div>
601
+ <label className="mb-1 block text-xs text-gray-500">{t("settings.copilot_username")}</label>
602
+ <input
603
+ type="text"
604
+ value={copilotCfg.username}
605
+ onChange={(e) => setCopilotCfg({ ...copilotCfg, username: e.target.value })}
606
+ placeholder="octocat"
607
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-600"
608
+ />
609
+ </div>
610
+ <div>
611
+ <label className="mb-1 block text-xs text-gray-500">{t("settings.copilot_token")}</label>
612
+ <input
613
+ type="password"
614
+ value={copilotCfg.token}
615
+ onChange={(e) => setCopilotCfg({ ...copilotCfg, token: e.target.value })}
616
+ placeholder="ghp_…"
617
+ className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-600"
618
+ />
619
+ </div>
620
+ <div className="flex items-center gap-3">
621
+ <button
622
+ onClick={handleSaveCopilot}
623
+ disabled={copilotSaving}
624
+ className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-4 py-1.5 text-sm text-gray-300 hover:bg-gray-700 disabled:opacity-60"
625
+ >
626
+ {copilotSaving ? t("settings.copilot_saving") : t("settings.copilot_save")}
627
+ </button>
628
+ {copilotMsg && (
629
+ <span className={cn("text-xs", copilotMsg.ok ? "text-emerald-400" : "text-red-400")}>
630
+ {copilotMsg.text}
631
+ </span>
632
+ )}
633
+ </div>
634
+ <p className="text-[11px] leading-relaxed text-gray-500">{t("settings.copilot_token_hint")}</p>
635
+ </div>
636
+ </Card>
637
+
566
638
  <Card icon={Package} title={t("settings.packages")}>
567
639
  {(settings?.packages ?? []).length > 0 && (
568
640
  <div className="mb-4 flex flex-wrap gap-1.5">
@@ -64,7 +64,6 @@ const en: Record<string, string> = {
64
64
 
65
65
  "nav.sessions": "Sessions",
66
66
  "nav.memory": "Memory",
67
- "nav.chat": "Chat",
68
67
  "nav.settings": "Settings",
69
68
  "app.subtitle": "Configuration Manager",
70
69
  "app.version": "pi-switch v0.1.0",
@@ -110,6 +109,10 @@ const en: Record<string, string> = {
110
109
  "dashboard.source_opencode": "OpenCode",
111
110
  "dashboard.source_gemini": "Gemini",
112
111
  "dashboard.source_grok": "Grok",
112
+ "dashboard.source_atomcode": "AtomCode",
113
+ "dashboard.source_copilot": "Copilot",
114
+ "dashboard.copilot_not_configured": "GitHub Copilot not configured — add username & token in Settings → Advanced",
115
+ "dashboard.copilot_api_error": "Failed to fetch Copilot usage from GitHub API — check token and billing platform",
113
116
  "dashboard.requests_count": "{0} requests · {1} total cost",
114
117
  "dashboard.range.today": "Today",
115
118
  "dashboard.range.7d": "7 Days",
@@ -200,6 +203,8 @@ const en: Record<string, string> = {
200
203
  "compat.desc": "Override default behavior for OpenAI-compatible providers that don't support certain features.",
201
204
  "compat.supports_developer_role": "Supports developer role",
202
205
  "compat.supports_developer_role_desc": "Send system prompt as 'developer' role (for reasoning models). Disable if the API only accepts 'system' role.",
206
+ "compat.supports_finish_reason": "Strict finish_reason check",
207
+ "compat.supports_finish_reason_desc": "Require streamed responses to include finish_reason, otherwise fail. Disable to infer the stop reason automatically when the upstream drops the stream (common with free models).",
203
208
  "compat.supports_reasoning_effort": "Supports reasoning_effort",
204
209
  "compat.supports_reasoning_effort_desc": "Send reasoning_effort parameter. Disable if the API does not support it.",
205
210
 
@@ -237,6 +242,11 @@ const en: Record<string, string> = {
237
242
  "sessions.delete_error": "Error deleting session",
238
243
  "sessions.load_failed": "Failed to load sessions",
239
244
  "sessions.refresh": "Refresh",
245
+ "sessions.auto_expire": "Auto-Expire",
246
+ "sessions.auto_expire_tooltip": "Move sessions inactive for {0} days to trash",
247
+ "sessions.auto_expire_success": "Moved {0} expired sessions to trash",
248
+ "sessions.auto_expire_none": "No expired sessions found",
249
+ "sessions.auto_expire_error": "Auto-expire failed: {0}",
240
250
  "sessions.expand_all": "Expand All",
241
251
  "sessions.collapse_all": "Collapse All",
242
252
  "sessions.more_count": "{0} more",
@@ -302,6 +312,15 @@ const en: Record<string, string> = {
302
312
  "settings.ui_zoom_desc": "Scale the entire interface by percentage (50%–200%)",
303
313
  "settings.ui_zoom_reset": "Reset",
304
314
  "settings.enabled_models_desc": "Click a tag to enable or disable a model",
315
+ "settings.copilot_title": "GitHub Copilot",
316
+ "settings.copilot_desc": "Track Copilot usage from the GitHub billing API (premium requests / AI credits)",
317
+ "settings.copilot_username": "GitHub username",
318
+ "settings.copilot_token": "Token (classic PAT)",
319
+ "settings.copilot_save": "Save",
320
+ "settings.copilot_saving": "Saving…",
321
+ "settings.copilot_saved": "Saved — usage will refresh on next dashboard load",
322
+ "settings.copilot_save_failed": "Save failed",
323
+ "settings.copilot_token_hint": "Create a classic personal access token at github.com/settings/tokens (fine-grained tokens are not supported by the billing usage endpoints). Copilot usage only appears for accounts on the enhanced billing platform.",
305
324
  "settings.updates_title": "Update Check",
306
325
  "settings.updates_desc": "Check npm for new versions of pi core and installed extensions.",
307
326
  "settings.check_updates": "Check for Updates",
@@ -350,63 +369,6 @@ const en: Record<string, string> = {
350
369
  "loading.error_title": "Failed to Load Configuration",
351
370
  "loading.retry": "Retry",
352
371
 
353
- // Chat
354
- "chat.sessions": "Sessions",
355
- "chat.new_session": "New session",
356
- "chat.loading": "Loading...",
357
- "chat.no_sessions": "No sessions yet. Click + to start a new chat.",
358
- "chat.welcome_title": "Welcome to Pi Chat",
359
- "chat.welcome_desc": "Select a session from the sidebar, or click + to start a new chat session in a project directory.",
360
- "chat.select_cwd": "Select Working Directory",
361
- "chat.cancel": "Cancel",
362
- "chat.start_chat": "Start Chat",
363
- "chat.delete_confirm": "Delete this session? This cannot be undone.",
364
- "chat.delete": "Delete",
365
- "chat.loading_session": "Loading session...",
366
- "chat.thinking": "Thinking...",
367
- "chat.running_tool": "Running tool...",
368
- "chat.running_command": "Running command...",
369
- "chat.send_message": "Send a message... (/ for commands, ! for bash)",
370
- "chat.type_to_steer": "Type to steer...",
371
- "chat.compacting": "Compacting context...",
372
- "chat.compaction_failed": "Compaction failed: {0}",
373
- "chat.retrying": "Retrying ({0}/{1}){2}",
374
- "chat.steering_queued": "{0} steering, {1} follow-up queued",
375
- "chat.recall": "Recall",
376
- "chat.default_tools": "Default tools",
377
- "chat.all_tools": "All tools",
378
- "chat.no_tools": "No tools",
379
- "chat.auto": "Auto",
380
- "chat.no_model": "No model",
381
- "chat.no_models_available": "No models available. Check your model configuration.",
382
- "chat.search_models": "Search models...",
383
- "chat.no_models_found": "No models found",
384
- "chat.models_count": "{0} models",
385
- "chat.models_filtered": "{0} of {1} models",
386
- "chat.attach_image": "Attach image",
387
- "chat.tool_preset": "Tool preset",
388
- "chat.compact_context": "Compact context",
389
- "chat.stop": "Stop",
390
- "chat.send": "Send",
391
- "chat.fork_from_here": "Fork from here",
392
- "chat.show_less": "Show less",
393
- "chat.show_more": "Show {0} more characters",
394
- "chat.tool_call": "tool call",
395
- "chat.thinking_collapsed": "Thinking (collapsed)",
396
- "chat.thinking_label": "Thinking",
397
- "chat.result": "result",
398
- "chat.error_label": "(error)",
399
- "chat.exit_code": "exit",
400
- "chat.msg_count": "{0} msg",
401
- "chat.untitled": "Untitled",
402
- "chat.just_now": "just now",
403
- "chat.min_ago": "{0}m ago",
404
- "chat.hr_ago": "{0}h ago",
405
- "chat.day_ago": "{0}d ago",
406
- "chat.invalid_directory": "Invalid directory",
407
- "chat.expand_all": "Expand all",
408
- "chat.collapse_all": "Collapse all",
409
-
410
372
  // Language Switcher
411
373
  "language.label": "Language",
412
374
  "language.en": "English",