@songsid/agend 2.3.4-beta.2 → 2.3.4-beta.4

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.
Files changed (100) hide show
  1. package/dist/auth-api.js +3 -0
  2. package/dist/auth-api.js.map +1 -1
  3. package/dist/backend/claude-code.js +1 -1
  4. package/dist/backend/claude-code.js.map +1 -1
  5. package/dist/backend/codex-session-lookup.d.ts +35 -0
  6. package/dist/backend/codex-session-lookup.js +93 -0
  7. package/dist/backend/codex-session-lookup.js.map +1 -1
  8. package/dist/backend/codex.d.ts +27 -0
  9. package/dist/backend/codex.js +116 -20
  10. package/dist/backend/codex.js.map +1 -1
  11. package/dist/backend/grok.d.ts +2 -0
  12. package/dist/backend/grok.js +22 -0
  13. package/dist/backend/grok.js.map +1 -1
  14. package/dist/backend/types.d.ts +2 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/adapters/discord.js +3 -1
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/channel/adapters/telegram.js +3 -1
  19. package/dist/channel/adapters/telegram.js.map +1 -1
  20. package/dist/channel/inbox-temp-name.d.ts +9 -0
  21. package/dist/channel/inbox-temp-name.js +13 -0
  22. package/dist/channel/inbox-temp-name.js.map +1 -0
  23. package/dist/channel/mcp-tools.js +1 -1
  24. package/dist/channel/mcp-tools.js.map +1 -1
  25. package/dist/daemon.d.ts +10 -1
  26. package/dist/daemon.js +196 -26
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/delivery-outbox.test.js +2 -2
  29. package/dist/delivery-outbox.test.js.map +1 -1
  30. package/dist/dialog-screen-text.d.ts +16 -0
  31. package/dist/dialog-screen-text.js +51 -0
  32. package/dist/dialog-screen-text.js.map +1 -0
  33. package/dist/fleet-manager.d.ts +4 -0
  34. package/dist/fleet-manager.js +46 -12
  35. package/dist/fleet-manager.js.map +1 -1
  36. package/dist/general-knowledge/skills/web-appearance/SKILL.md +1 -1
  37. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +3 -2
  38. package/dist/install-stream.d.ts +53 -0
  39. package/dist/install-stream.js +134 -0
  40. package/dist/install-stream.js.map +1 -0
  41. package/dist/instance-lifecycle.d.ts +12 -0
  42. package/dist/instance-lifecycle.js +31 -2
  43. package/dist/instance-lifecycle.js.map +1 -1
  44. package/dist/locale.js +26 -6
  45. package/dist/locale.js.map +1 -1
  46. package/dist/log-service.d.ts +2 -0
  47. package/dist/log-service.js +7 -3
  48. package/dist/log-service.js.map +1 -1
  49. package/dist/login-manager.d.ts +6 -0
  50. package/dist/login-manager.js +10 -1
  51. package/dist/login-manager.js.map +1 -1
  52. package/dist/office-share-page.d.ts +1 -1
  53. package/dist/office-share-page.js +6 -2
  54. package/dist/office-share-page.js.map +1 -1
  55. package/dist/outbound-handlers.js +56 -0
  56. package/dist/outbound-handlers.js.map +1 -1
  57. package/dist/outbound-schemas.d.ts +1 -0
  58. package/dist/outbound-schemas.js +4 -1
  59. package/dist/outbound-schemas.js.map +1 -1
  60. package/dist/selection-card.d.ts +2 -3
  61. package/dist/selection-card.js +99 -26
  62. package/dist/selection-card.js.map +1 -1
  63. package/dist/tmux-manager.d.ts +6 -0
  64. package/dist/tmux-manager.js +17 -0
  65. package/dist/tmux-manager.js.map +1 -1
  66. package/dist/ui/chat-store.js +20 -3
  67. package/dist/ui/fleet-canvas.js +4 -3
  68. package/dist/ui/fleet-office-model.js +55 -22
  69. package/dist/ui/fleet-office-overview.js +6 -38
  70. package/dist/ui/fleet-office-pack.js +4 -3
  71. package/dist/ui/fleet-office-skin.js +114 -21
  72. package/dist/ui/fleet-office-store.js +5 -0
  73. package/dist/ui/fleet-office-zones.js +2 -2
  74. package/dist/ui/fleet-office.js +41 -9
  75. package/dist/ui/panel-log.js +7 -1
  76. package/dist/ui/shared/app-i18n.js +2 -2
  77. package/dist/ui/shared/app-shell.js +4 -2
  78. package/dist/ui/shared/app-stream.js +26 -0
  79. package/dist/ui/shared/app.css +21 -1
  80. package/dist/ui/shared/office/guild.png +0 -0
  81. package/dist/ui/shared/office/school.png +0 -0
  82. package/dist/ui/shared/panel-view.js +185 -10
  83. package/dist/ui/shared/view-strings.js +23 -0
  84. package/dist/unattended-install.d.ts +6 -0
  85. package/dist/unattended-install.js +65 -0
  86. package/dist/unattended-install.js.map +1 -0
  87. package/dist/view-api.d.ts +15 -0
  88. package/dist/view-api.js +94 -33
  89. package/dist/view-api.js.map +1 -1
  90. package/dist/web-api.js +7 -2
  91. package/dist/web-api.js.map +1 -1
  92. package/dist/web-chat-history.d.ts +20 -0
  93. package/dist/web-chat-history.js +21 -0
  94. package/dist/web-chat-history.js.map +1 -1
  95. package/dist/web-guest-access.js +5 -1
  96. package/dist/web-guest-access.js.map +1 -1
  97. package/dist/web-office-pack-tools.d.ts +1 -1
  98. package/dist/web-office-pack-tools.js +1 -1
  99. package/dist/web-office-pack-tools.js.map +1 -1
  100. package/package.json +1 -1
@@ -64,7 +64,7 @@ import { nodeText, stateWords } from "./fleet-org.js";
64
64
  import { hits, needle } from "./fleet-canvas-layout.js";
65
65
  import { AUTO_ID, LOBBY, autoLayout, backingScale, coatOf, createFlights, customLayout, deskAt, desksOf, filterOk, lampOf, layoutOk, lightOf, membersOf,
66
66
  officePose, officeRooms, signOf, workOn } from "./fleet-office-model.js";
67
- import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, bubbleBox, loadCozySkin, loadPackSkin, modeOf, skinFor, tagBox, tagFootprint } from "./fleet-office-skin.js";
67
+ import { BUILT_IN_SKINS, CHAR_DEPTH, DEFAULT_PAL, PICTURE_SKINS, bubbleBox, pixelSkin, loadBuiltInSkin, loadPackSkin, modeOf, skinFor, tagBox, tagFootprint } from "./fleet-office-skin.js";
68
68
  import { CALLING, activeFurniture, bubbleAt, hiddenBy, bubbleRank, NIGHT, WANDER, createMail, createWalkers, isNight, linesFor, npcKey, personAt, placeBubbles, thinBubbles, walkTargets, zonesLayout } from "./fleet-office-zones.js";
69
69
  import { actsOk, createOfficeStore } from "./fleet-office-store.js";
70
70
  import { badgeOf, fixedOf } from "./fleet-office-levels.js";
@@ -86,7 +86,7 @@ register("orgo", {
86
86
  people: "People", peopleN: "People ({0})", findPerson: "Find a person", everyone: "Show everyone", nobody: "No one here matches this filter.",
87
87
  unseated: "{0} without a seat", skinUnknown: "Its style “{0}” is not available here: drawn as Desks.", closed: "Closed", desks: "Desks", tapAgain: "Tap again to open the chat",
88
88
  capWorking: "Typing away", capIdle: "Tea break", capPaused: "Asleep", capNeeds: "Calling for you", capAway: "Desk empty", capStarting: "Just arriving", capQueued: "Waiting to start",
89
- styleLabel: "Style", style_pixel: "Desks", style_studio: "Studio", style_cozy: "Cozy", cozyNotice: "Add your skin", area_desks: "Work", area_lounge: "Lounge", area_alert: "Help desk", area_nap: "Nap corner",
89
+ styleLabel: "Style", style_pixel: "Desks", style_studio: "Studio", style_cozy: "Cozy", style_guild: "Guild", style_school: "School", cozyNotice: "Add your skin", area_desks: "Work", area_lounge: "Lounge", area_alert: "Help desk", area_nap: "Nap corner",
90
90
  b_working: "On it.|Typing…|Almost there|In the zone|One more test", b_idle: "Tea time|Ready when you are|Stretching|Anything for me?",
91
91
  b_paused: "Zzz…|Back later", b_stopped: "Off duty|Zzz…", b_needs: "Need you!|A question for you|Over here!",
92
92
  b_crashed: "Ouch, crashed|Help!", b_stuck: "Hmm, stuck?|Still thinking…",
@@ -96,6 +96,8 @@ register("orgo", {
96
96
  fit: "Fit the office", overview: "Overview: click to move there", fullscreen: "Fullscreen", exitFullscreen: "Exit fullscreen",
97
97
  fill: "Fill", fillTitle: "Fill the panel (the scene's overflow is cropped; drag to see it)", zoomLevel: "Zoom {0}",
98
98
  allOffices: "All offices", labelAll: "All offices: each office is a tile, drawn in its own style. Drag to pan; the wheel, a pinch, + and − zoom, 0 fits, F is fullscreen. Double-click a tile to open that office.",
99
+ key: "Key", keyTitle: "What the items carried between desks are", keyLabel: "Carried between desks",
100
+ keyTask: "Folder: a task", keySend: "Envelope: a message", keyReport: "Paper: a report", keyBroadcast: "Horn: a broadcast",
99
101
  tileLabel: "{0} ({1})", openOffice: "Open the office {0}", lobbyTile: "Lobby tile (in no office)", lobbyEveryone: "Everyone is in an office", overviewNoEdit: "Pick one office to edit offices or a layout",
100
102
  },
101
103
  "zh-TW": {
@@ -104,7 +106,7 @@ register("orgo", {
104
106
  people: "成員", peopleN: "成員({0})", findPerson: "尋找成員", everyone: "顯示全部", nobody: "沒有符合這個篩選的成員。",
105
107
  unseated: "{0} 位沒有座位", skinUnknown: "這裡沒有它的風格「{0}」:以辦公桌顯示。", closed: "休息中", desks: "桌子", tapAgain: "再點一下開啟聊天",
106
108
  capWorking: "埋頭打字中", capIdle: "喝茶休息", capPaused: "睡著了", capNeeds: "在呼叫你", capAway: "空位", capStarting: "剛到座位", capQueued: "等待啟動",
107
- styleLabel: "風格", style_pixel: "辦公桌", style_studio: "工作室", style_cozy: "溫馨", cozyNotice: "換上你的外觀包", area_desks: "工作區", area_lounge: "休息區", area_alert: "求救區", area_nap: "午睡角",
109
+ styleLabel: "風格", style_pixel: "辦公桌", style_studio: "工作室", style_cozy: "溫馨", style_guild: "冒險者公會", style_school: "學校", cozyNotice: "換上你的外觀包", area_desks: "工作區", area_lounge: "休息區", area_alert: "求救區", area_nap: "午睡角",
108
110
  b_working: "處理中|打字中…|快好了|專心中|再測一次", b_idle: "喝茶中|隨時可以開工|伸個懶腰|有事找我嗎?",
109
111
  b_paused: "Zzz…|晚點回來", b_stopped: "下班了|Zzz…", b_needs: "需要你!|有個問題|在這裡!",
110
112
  b_crashed: "糟糕,當掉了|救命!", b_stuck: "嗯,卡住了?|還在想…",
@@ -114,6 +116,8 @@ register("orgo", {
114
116
  fit: "顯示整個辦公室", overview: "總覽:點一下移到那裡", fullscreen: "全螢幕", exitFullscreen: "結束全螢幕",
115
117
  fill: "填滿", fillTitle: "填滿整個面板(超出的部分會被裁切,拖曳即可看到)", zoomLevel: "縮放 {0}",
116
118
  allOffices: "所有辦公室", labelAll: "所有辦公室:每間辦公室是一格,以它自己的風格顯示。拖曳平移;滾輪、雙指、+ 和 − 縮放,0 顯示全部,F 切換全螢幕。雙擊一格即可開啟那間辦公室。",
119
+ key: "圖例", keyTitle: "桌子之間傳遞的物品代表什麼", keyLabel: "桌子之間傳遞的物品",
120
+ keyTask: "資料夾:任務", keySend: "信封:訊息", keyReport: "紙張:回報", keyBroadcast: "喇叭:廣播",
117
121
  tileLabel: "{0}({1})", openOffice: "開啟辦公室「{0}」", lobbyTile: "大廳格(不在任何辦公室)", lobbyEveryone: "每個成員都已在某間辦公室", overviewNoEdit: "請先選一間辦公室,再編輯辦公室或配置",
118
122
  },
119
123
  });
@@ -584,6 +588,28 @@ function People({ nodes, pinned, onChange }) {
584
588
  </div>`;
585
589
  }
586
590
 
591
+ /** #2946: the key's rows — each kind's item as the scene draws it (the built-in prop, in the theme's colour), and its words. */
592
+ export const KEY_KINDS = [["task", "keyTask"], ["send", "keySend"], ["report", "keyReport"], ["broadcast", "keyBroadcast"]];
593
+ const KEY_PX = 32;
594
+ /**
595
+ * #2946 (hxhsandy): the key to the items carried between desks — a strip under the Office's bar (never over the scene),
596
+ * opened by its Key toggle. The shapes are drawn by the built-in skin, so they are the ones in the air, at their size.
597
+ */
598
+ export function OfficeKey({ pal }) {
599
+ const paint = (hue) => (cv) => {
600
+ const g = cv && typeof cv.getContext === "function" ? cv.getContext("2d") : null;
601
+ if (!g) return;
602
+ const r = Math.max(1, Math.round(globalThis.devicePixelRatio || 1));
603
+ if (cv.width !== KEY_PX * r) { cv.width = KEY_PX * r; cv.height = KEY_PX * r; }
604
+ g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, KEY_PX, KEY_PX);
605
+ try { pixelSkin.drawProp(g, { hue, kind: hue, x: KEY_PX / 2, y: KEY_PX / 2 + 4, t: 0 }, 0, { pal }); } catch { /* left blank */ }
606
+ };
607
+ return html`<div class="oo-key" role="region" aria-label=${to("keyLabel")}>
608
+ <ul class="oo-key-rows">${KEY_KINDS.map(([hue, k]) => html`<li key=${hue} class="oo-key-row" data-kind=${hue}>
609
+ <canvas class="oo-key-icon" width=${KEY_PX} height=${KEY_PX} aria-hidden="true" ref=${paint(hue)}></canvas><span>${to(k)}</span></li>`)}</ul>
610
+ </div>`;
611
+ }
612
+
587
613
  /**
588
614
  * The view: `view` buildOrgView's answer; `traffic` the live items (null: Live is off); `q` the Find box's needle;
589
615
  * `lease` the tab's (its reads); `onOpen(name)` what a desk opens (the canvas's: the chat; none: a desk opens nothing,
@@ -609,13 +635,14 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
609
635
  const [hideAsleep, setHideAsleep] = useState(() => store.hideAsleep());
610
636
  // Bedtime (#2568), when it is on here: the idle sleep from NIGHT.FROM to NIGHT.TO on this device's clock (read every NIGHT.TICK).
611
637
  const [nightOn, setNightOn] = useState(() => store.nightOn());
638
+ const [keyOpen, setKeyOpen] = useState(() => store.keyOpen());
612
639
  const [nightNow, setNightNow] = useState(() => isNight());
613
640
  useEffect(() => { if (!nightOn) return undefined; setNightNow(isNight()); const h = setInterval(() => setNightNow(isNight()), NIGHT.TICK); return () => clearInterval(h); }, [nightOn]);
614
641
  const night = nightOn && nightNow;
615
642
  const [lobbyOn, setLobbyOn] = useState(() => store.lobbyTile()); // #2554: All offices' lobby tile
616
643
  const [packs, setPacks] = useState([]); // the skin packs the host keeps: [{ id, name, sha }]
617
644
  const [packSkins, setPackSkins] = useState(() => new Map()); // id@sha → a loaded skin, or null (not a pack)
618
- const [cozyRev, setCozyRev] = useState(0); // #2631: the cozy's picture came in (skinFor has it)
645
+ const [cozyRev, setCozyRev] = useState(0); // #2631/#2947: a built-in's picture came in (skinFor has it)
619
646
  // Edit layout's session while it is open: { id, officeId, skin, pack (the style's identity), base (the pack as kept),
620
647
  // start, draft, rev, list (the offices and rev read when it opened — what Save sends) }; null when closed.
621
648
  const [layout, setLayout] = useState(null);
@@ -801,14 +828,16 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
801
828
  }, [neededKey]);
802
829
  const loading = !!packInfo && !packSkins.has(packKey);
803
830
  const baseSkin = packInfo ? packSkins.get(packKey) || skinFor("pixel") : skinFor(STYLES.includes(wanted) ? wanted : custom ? wanted : "pixel");
804
- // #2631: the cozy's picture, once, when this office (or a tile, or the lobby tile) is drawn in it; until then its bare floors.
805
- const cozyWanted = (overview ? [...stored.map((o) => o.skin), ...(lobbyOn ? [style] : [])] : [wanted]).includes("cozy");
831
+ // #2631/#2947: a picture-backed built-in's picture (cozy, guild, school), once, when this office (or a tile, or the lobby
832
+ // tile) is drawn in it and no kept pack has its id; until then its bare floors.
833
+ const picturesWanted = [...new Set((overview ? [...stored.map((o) => o.skin), ...(lobbyOn ? [style] : [])] : [wanted])
834
+ .filter((id) => PICTURE_SKINS.includes(id) && !packOf(id)))].sort().join(",");
806
835
  useEffect(() => {
807
- if (!cozyWanted) return undefined;
836
+ if (!picturesWanted) return undefined;
808
837
  let live = true;
809
- loadCozySkin().then((sk) => { if (live && sk && sk !== baseSkin) setCozyRev((n) => n + 1); });
838
+ for (const id of picturesWanted.split(",")) loadBuiltInSkin(id).then((sk) => { if (live && sk) setCozyRev((n) => n + 1); });
810
839
  return () => { live = false; };
811
- }, [cozyWanted]);
840
+ }, [picturesWanted]);
812
841
  const zones = !!(baseSkin.pack && baseSkin.pack.stateZones);
813
842
  /** The style's identity: a pack's id and sha (a pack replaced is another), or the built-in skin's id. */
814
843
  const styleKey = packKey || baseSkin.id;
@@ -1456,6 +1485,8 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1456
1485
  ${custom && baseSkin.id !== custom.skin && !loading ? html`<span class="muted oo-note">${to("skinUnknown", styleName(custom.skin))}</span>` : null}
1457
1486
  ${placed.ignored ? html`<span class="muted oo-note">${to("layoutIgnored")}</span>` : null}
1458
1487
  ${styleErr && auto ? html`<span class="note oo-note oo-style-err" role="status">${styleErr}</span>` : null}
1488
+ ${traffic ? html`<button type="button" class=${`btn btn-sm oo-key-btn${keyOpen ? " on" : ""}`} aria-expanded=${keyOpen ? "true" : "false"} title=${to("keyTitle")}
1489
+ onClick=${() => { const v = !keyOpen; store.saveKeyOpen(v); setKeyOpen(v); }}>${to("key")}</button>` : null}
1459
1490
  ${L.unseated ? html`<span class="muted oo-note">${to("unseated", L.unseated)}</span>` : null}
1460
1491
  ${canEdit && storedRev !== null ? html`<button type="button" class="btn btn-sm oo-edit" onClick=${() => { if (!overview) setEditing(true); }} disabled=${!!layout || overview}
1461
1492
  title=${overview ? to("overviewNoEdit") : undefined}>${t("orge.edit")}</button>` : null}
@@ -1463,6 +1494,7 @@ export function Office({ view, traffic, q = "", lease, onOpen, assetUrl, assetIm
1463
1494
  ${canShare ? html`<${ShareButton} offices=${stored} current=${overview ? "all" : custom ? custom.id : null} disabled=${!!layout}
1464
1495
  get=${lease ? (path, o) => lease.fetch(path, o) : undefined} />` : null}
1465
1496
  </div>
1497
+ ${traffic && keyOpen ? html`<${OfficeKey} pal=${pal} />` : null}
1466
1498
  ${editing && !overview ? html`<${OfficeEditor} offices=${stored} rev=${storedRev} view=${view} store=${store} packs=${packs}
1467
1499
  ask=${ask} current=${() => shown.current} onSaved=${adopt} onClose=${() => setEditing(false)} />` : null}
1468
1500
  ${overview || people.length || L.rooms.length ? null : html`<p class="note oo-empty">${to("nobody")} ${auto && (f.team || f.pinned.length) ? html`<button type="button" class="btn btn-sm" onClick=${() => setF({ team: "", pinned: [] })}>${to("everyone")}</button>` : null}</p>`}
@@ -49,6 +49,7 @@ register("log", {
49
49
  user: "Asked", assistant: "Replied", error: "Error", errorCode: "Error {0}", turnEnd: "Turn ended", turnEndIn: "Turn ended · {0}",
50
50
  compacted: "Conversation compacted", turn: "Turn",
51
51
  running: "running", done: "done", failed: "failed", interrupted: "interrupted", exit: "exit {0}",
52
+ showScreen: "Show what was on screen", hideScreen: "Hide what was on screen",
52
53
  showOutput: "Show output", hideOutput: "Hide output", args: "Arguments", output: "Output", noOutput: "No output.",
53
54
  showAll: "Show all", showLess: "Show less", cut: "… {0} more characters not shown.", tooLarge: "Output of {0}: only its start is shown.",
54
55
  times: "×{0}", newer: "{0} new",
@@ -87,6 +88,7 @@ register("log", {
87
88
  user: "收到要求", assistant: "回覆", error: "錯誤", errorCode: "錯誤 {0}", turnEnd: "回合結束", turnEndIn: "回合結束 · {0}",
88
89
  compacted: "對話已壓縮", turn: "回合",
89
90
  running: "執行中", done: "完成", failed: "失敗", interrupted: "中斷", exit: "結束碼 {0}",
91
+ showScreen: "顯示當時的畫面", hideScreen: "隱藏當時的畫面",
90
92
  showOutput: "顯示輸出", hideOutput: "隱藏輸出", args: "參數", output: "輸出", noOutput: "沒有輸出。",
91
93
  showAll: "顯示全部", showLess: "收合", cut: "… 還有 {0} 個字元沒有顯示。", tooLarge: "輸出共 {0},只顯示開頭。",
92
94
  times: "×{0}", newer: "{0} 則新的",
@@ -402,7 +404,11 @@ function Row({ x, prev }) {
402
404
  };
403
405
  let row;
404
406
  if (x.kind === "event") {
405
- row = html`${head(KIND_ICON.event, eventLabel(x.type))}${x.detail ? html`<div class="log-text muted">${x.detail}</div>` : null}`;
407
+ // M-2269: a dialog or prompt AgEnD saw (the CLI never writes it to its transcript): its text as it was on screen,
408
+ // folded until asked for. Text only (a text node), masked by the server.
409
+ row = html`${head(KIND_ICON.event, eventLabel(x.type))}${x.detail ? html`<div class="log-text muted">${x.detail}</div>` : null}
410
+ ${x.screen ? html`<button type="button" class="link-btn log-toggle log-screen-toggle" aria-expanded=${open ? "true" : "false"} onClick=${() => setOpen(!open)}>${open ? tl("hideScreen") : tl("showScreen")}</button>` : null}
411
+ ${x.screen && open ? html`<div class="log-detail"><div class="log-text mono log-screen">${x.screen}</div></div>` : null}`;
406
412
  } else if (x.kind === "user" || x.kind === "assistant") {
407
413
  row = html`${head(KIND_ICON[x.kind], tl(x.kind))}${text(x.body, x.bodyCut)}`;
408
414
  } else if (x.kind === "error") {
@@ -78,7 +78,7 @@ register("app", {
78
78
  statusQueued: "queued", statusStarting: "starting", fleetStarting: "Fleet starting {0}/{1}…", statusRunning: "running", statusStopped: "stopped", statusPaused: "paused", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
79
79
  connReconnecting: "Can't reach AgEnD — it may be restarting. Reconnecting…", connReconnectingIn: "Can't reach AgEnD — it may be restarting. Trying again in {0} s.",
80
80
  hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
81
- skip: "Skip to the conversation", loadFailed: "This could not be loaded.", retry: "Try again", loading: "Loading…",
81
+ skip: "Skip to the conversation", loadFailed: "This could not be loaded.", retry: "Try again", reloadPage: "Reload the page", loading: "Loading…",
82
82
  noInstances: "No instances yet", noInstancesHint: "Create one to start chatting.",
83
83
  guestChip: "Guest", guestAs: "Guest · {0}", guestAsOn: "Guest · {0} ({1})", guestHint: "You see only the agents you can already use in chat.",
84
84
  session: "Session", signIn: "Sign in", signedIn: "Signed in", signedOut: "Not signed in", thisDevice: "This device", devicePrefs: "Theme and language",
@@ -126,7 +126,7 @@ register("app", {
126
126
  statusQueued: "等待啟動", statusStarting: "啟動中", fleetStarting: "Fleet 啟動中 {0}/{1}…", statusRunning: "執行中", statusStopped: "已停止", statusPaused: "已暫停", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
127
127
  connReconnecting: "連不上 AgEnD——可能正在重新啟動。正在重新連線…", connReconnectingIn: "連不上 AgEnD——可能正在重新啟動。{0} 秒後再試。",
128
128
  hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
129
- skip: "跳到對話", loadFailed: "無法載入。", retry: "再試一次", loading: "載入中…",
129
+ skip: "跳到對話", loadFailed: "無法載入。", retry: "再試一次", reloadPage: "重新載入頁面", loading: "載入中…",
130
130
  noInstances: "還沒有 instance", noInstancesHint: "建立一個就能開始聊天。",
131
131
  guestChip: "訪客", guestAs: "訪客 · {0}", guestAsOn: "訪客 · {0}({1})", guestHint: "你只會看到在聊天中本來就能用的 agent。",
132
132
  session: "登入狀態", signIn: "登入", signedIn: "已登入", signedOut: "尚未登入", thisDevice: "這台裝置", devicePrefs: "主題與語言",
@@ -508,12 +508,14 @@ function BottomTabs({ route, viewOnly }) {
508
508
  </nav>`;
509
509
  }
510
510
 
511
- function ConnectionLine() {
511
+ export function ConnectionLine() {
512
512
  const { connection, hydration, retryHydration } = useStore(appStore);
513
513
  // A late catch-up that is not done: the chat may be missing what was open before it loaded (#1425 review).
514
+ // M-2260: never a dead end — beside Try again, a reload reads everything current, whatever kept the catch-up from it.
514
515
  if (hydration === "retrying" || hydration === "failed") {
515
516
  return html`<div data-trust="status" class="conn" role="status">${hydration === "failed" ? t("app.hydrateFailed") : t("app.hydrating")}
516
- ${hydration === "failed" && retryHydration ? html` <button type="button" class="btn btn-sm" onClick=${() => retryHydration()}>${t("app.retry")}</button>` : null}</div>`;
517
+ ${hydration === "failed" && retryHydration ? html` <button type="button" class="btn btn-sm" onClick=${() => retryHydration()}>${t("app.retry")}</button>` : null}
518
+ ${hydration === "failed" ? html` <button type="button" class="btn btn-sm" onClick=${() => location.reload()}>${t("app.reloadPage")}</button>` : null}</div>`;
517
519
  }
518
520
  if (connection === "reconnecting") return html`<${Reconnecting} />`;
519
521
  // #1603: polling is a working transport (the public link polls by design; a buffering proxy makes a page poll), not
@@ -27,6 +27,9 @@
27
27
  // - every reconnect catches up once the new stream speaks (catchUp(), with the page's cursor — #1566/#1578), so what
28
28
  // was said meanwhile is shown, once.
29
29
  // - a 401 is the session ending: nothing is retried (agend-auth.js says so).
30
+ // - M-2260: a catch-up after a long absence carries only the newest messages (the server's budget) and says so
31
+ // (`truncated`): "gap" tells the chats to read their newest history again — it converges like a reload, never by
32
+ // asking a slow link for hours of replies in one answer.
30
33
  // - suspend()/resume(): the page is leaving (pagehide) / came back from the back-forward cache.
31
34
 
32
35
  // #1218: steps_tick — an instance's transcript moved while a Log watches it (SSE only: a poll page asks the Log itself).
@@ -79,6 +82,22 @@ export function createStream(opts) {
79
82
  try { return Object.freeze({ source: "sse", receivedAt: now() }); } catch { return undefined; }
80
83
  }
81
84
  function setConnection(c) { if (state.connection !== c) { state.connection = c; emit("connection", c); } }
85
+
86
+ // M-2260: a tab the browser froze for hours gets every heartbeat it missed at once when it wakes — thousands of status
87
+ // frames, each a whole snapshot, each redrawing the page (seconds of work before the newest message shows). A status
88
+ // that comes after a quiet moment is delivered at once, as before; more statuses before the next turn of the page's
89
+ // task loop replace the waiting one, delivered then — or before any other event, so the order of events never changes.
90
+ // The next turn is a 0 ms timer: in a hidden tab the browser may run it late (a minute when throttled) — a status
91
+ // then waits that long while nobody looks (Needs you, the title's count and notifications ride `needs`, never held).
92
+ let pendingStatus = null, statusBurst = false;
93
+ const turn = () => env.setTimeout(endBurst, 0);
94
+ function endBurst() { statusBurst = false; flushStatus(); }
95
+ function flushStatus() { const d = pendingStatus; pendingStatus = null; if (d !== null) live("status", d, null); }
96
+ function liveStatus(data) {
97
+ if (statusBurst) { pendingStatus = data; return; }
98
+ statusBurst = true; turn();
99
+ live("status", data, null);
100
+ }
82
101
  function setHydration(h) { if (state.hydration !== h) { state.hydration = h; emit("hydration", h); } }
83
102
 
84
103
  /** A live event: delivered now, or held (in order) while a catch-up snapshot is on its way. */
@@ -115,6 +134,7 @@ export function createStream(opts) {
115
134
  if (polling) { if (!opts.join) pollAgain = true; return polling; }
116
135
  const mine = ++pollSeq, ep = readEpoch;
117
136
  let status = 0; // this read's HTTP status — its own, never another read's
137
+ flushStatus(); // a status that came before this read is older than its snapshot
118
138
  if (sse && !held) held = [];
119
139
  let limit = null;
120
140
  const read = (async () => {
@@ -128,6 +148,9 @@ export function createStream(opts) {
128
148
  if (d.status) emit("status", d.status);
129
149
  for (const m of d.messages || []) emit("message", m, { source: "poll" });
130
150
  if (typeof d.cursor === "string") state.cursor = d.cursor;
151
+ // M-2260: the server sent only the newest messages of a long absence (its catch-up budget): the chats read their
152
+ // newest history again, as a reload would, instead of this read carrying everything that was missed.
153
+ if (d.truncated === true) emit("gap", {});
131
154
  if (Array.isArray(d.deliveries)) emit("deliveries", d.deliveries);
132
155
  if (Array.isArray(d.prompts)) emit("prompts", d.prompts);
133
156
  if (Array.isArray(d.reply_buttons)) for (const u of d.reply_buttons) emit("reply_buttons", u); // #1266
@@ -157,6 +180,7 @@ export function createStream(opts) {
157
180
  }
158
181
  /** #1580 review: the page lets go of everything in flight — reads, the queued poll, the catch-up and its retry wait. */
159
182
  function cancelWork() {
183
+ pendingStatus = null; // a page let go of (suspend, end, close) draws no waiting status
160
184
  readEpoch++; epoch++;
161
185
  pollSeq++; polling = null; pollAgain = false; held = null;
162
186
  env.clearTimeout(retryTimer); retryTimer = null;
@@ -225,6 +249,8 @@ export function createStream(opts) {
225
249
  else alive();
226
250
  // #1823: a clock reading is about now, not about order: it is never held behind a catch-up (it would be stale).
227
251
  if (name === "clock") { emit(name, data); return; }
252
+ if (name === "status") { liveStatus(data); return; }
253
+ flushStatus(); // a status waiting goes first: the order never changes
228
254
  live(name, data, name === "message" ? e.lastEventId : null, ingress);
229
255
  });
230
256
  }
@@ -1335,6 +1335,20 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1335
1335
  /* #1844 Fleet overview → Docs: the arrow says "opens a new tab". */
1336
1336
  .hd-docs .hd-ext { color: var(--text-3); }
1337
1337
  .v-term { flex: 1; min-height: 0; overflow: auto; scrollbar-gutter: stable; padding: 5px 6px; background: #000000; position: relative; }
1338
+ /* M-2262: the terminal's box — the scrolling terminal, and its scrollback controls over it (they never scroll with it). */
1339
+ .v-term-box { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
1340
+ .v-term:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
1341
+ .v-term-earlier, .v-term-latest { position: absolute; right: var(--s-4); z-index: 2; display: inline-flex; align-items: center; gap: 4px; opacity: .88; }
1342
+ .v-term-earlier { top: var(--s-2); }
1343
+ .v-term-latest { bottom: var(--s-3); opacity: 1; }
1344
+ .v-term-logbar { flex-shrink: 0; margin: 0; padding: var(--s-2) var(--s-4); background: #111111; border-bottom: 1px solid #2a2a2a; text-align: right; font-size: var(--fs-sm); }
1345
+ .v-term-logbar a { display: inline-flex; align-items: center; gap: 4px; color: #9cc8ff; text-decoration: underline; text-underline-offset: 2px; min-height: 28px; }
1346
+ @media (pointer: coarse) { .v-term-logbar a { min-height: 44px; } }
1347
+ .v-term-latest.has-new { background: var(--accent-fill); border-color: transparent; color: var(--on-accent); }
1348
+ .v-term-earlier:hover, .v-term-earlier:focus-visible { opacity: 1; }
1349
+ .v-term-history-note { position: absolute; left: var(--s-3); right: var(--s-3); bottom: var(--s-3); z-index: 2; margin: 0; padding: var(--s-1) var(--s-3); border-radius: var(--r-sm); background: rgba(20, 20, 20, .92); border: 1px solid #3a3a3a; color: #e0e0e0; font-size: var(--fs-sm); }
1350
+ @media (max-width: 600px) { .v-term-history-note { left: var(--s-4); right: var(--s-4); } }
1351
+ @media (pointer: coarse) { .v-term-earlier, .v-term-latest { min-height: 44px; padding-left: var(--s-3); padding-right: var(--s-3); } }
1338
1352
  .v-pre { margin: 0; min-width: 100%; min-height: 100%; font-family: var(--font-mono); line-height: 1.2; white-space: pre; color: #d0d0d0; }
1339
1353
  .v-pre .ansi[data-b] { font-weight: var(--fw-bold); }
1340
1354
  /* #1582: faint text (SGR 2) — a CLI's suggestion or placeholder, never input — stays faint here. */
@@ -1872,6 +1886,12 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1872
1886
  .oo-canvas:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
1873
1887
  .oo-canvas.hot { cursor: pointer; }
1874
1888
  /* The canvas controls, as the org chart canvas's (.oc-zoom, .oc-mini): over the stage's corners. */
1889
+ /* #2946: the key to the items carried between desks — a strip under the bar, as the org chart's (.oc-legend). */
1890
+ .oo-key { font-size: var(--fs-xs); color: var(--text-2); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); padding: 4px 10px; }
1891
+ .oo-key-rows { display: flex; flex-wrap: wrap; gap: var(--s-1) var(--s-4); margin: 0; padding: 0; list-style: none; }
1892
+ .oo-key-row { display: inline-flex; align-items: center; gap: 6px; }
1893
+ .oo-key-icon { width: 32px; height: 32px; flex: none; image-rendering: pixelated; }
1894
+ .oo-key-btn.on { border-color: var(--accent); }
1875
1895
  .oo-zoom { z-index: 6; }
1876
1896
  .oo-zoom .icon-btn { color: var(--text); }
1877
1897
  /* The overview's box (left, top, width, height) is set by the page, inside the stage and scaled to it (miniBox). */
@@ -2298,7 +2318,7 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
2298
2318
  .p-fleet > .seg, .p-settings > .seg,
2299
2319
  .p-fleet > .panel-body, .p-needs > .panel-body, .p-notices > .panel-body, .p-settings > .panel-body,
2300
2320
  .p-log > .panel-body, .p-details > .panel-body, .p-view > .panel-body,
2301
- .p-log > .log-bar, .p-view > .v-term, .p-view > .v-id, .p-view > .v-watching { position: relative; z-index: 1; }
2321
+ .p-log > .log-bar, .p-view > .v-term-box, .p-view > .v-id, .p-view > .v-watching { position: relative; z-index: 1; }
2302
2322
  /* Keep the existing Apply bar's sticky behavior, in its own bounded stock band. */
2303
2323
  .p-settings > .s-pending { z-index: 1; }
2304
2324
 
Binary file
Binary file
@@ -26,7 +26,7 @@ import { viewStore } from "./view-roster-store.js";
26
26
  /** #1580: the app's stream says the fleet is not answering (a restart): the View's polls skip their turn until it is. */
27
27
  const fleetUnreachable = () => appStore.get().connection === "reconnecting";
28
28
  import { navigate } from "./app-nav.js";
29
- import { viewPath } from "./app-route.js";
29
+ import { logPath, sectionPath, viewPath } from "./app-route.js";
30
30
  import { Dialog } from "./ui-dialog.js";
31
31
  import { Empty, ErrorState, Skeleton } from "./ui-states.js";
32
32
  import { Icon } from "./ui-icons.js";
@@ -198,8 +198,8 @@ export function ViewPanel({ route, navKey }) {
198
198
  else if (!it) body = html`<div data-trust="panel-content" class="panel-body center"><${Empty} icon="alert" title=${tn("notFound", { name })} hint=${tn("notFoundHint")} /></div>`;
199
199
  else if (guest && !trusted) body = html`<div data-trust="panel-content" class="panel-body center"><${Empty} icon="view" title=${tn("guestNeedsTrusted")} hint=${tn("guestNeedsTrustedHint")} /></div>`;
200
200
  else if (guest) body = html`<p data-trust="panel-content" class="v-watching" role="note">${tn("guestWatching")}</p>
201
- <${Terminal} key=${name} name=${name} lease=${lease} density=${density} every=${GUEST_PANE_MS} />`;
202
- else body = html`<${Terminal} key=${name} name=${name} lease=${lease} density=${density} />
201
+ <${Terminal} key=${name} name=${name} lease=${lease} density=${density} every=${GUEST_PANE_MS} backend=${it.backend || ""} signedIn=${false} />`;
202
+ else body = html`<${Terminal} key=${name} name=${name} lease=${lease} density=${density} backend=${it.backend || ""} signedIn=${!viewOnly} />
203
203
  <${ViewIdentity} it=${it} viewOnly=${viewOnly} onEdit=${() => setDialog("edit")} />`;
204
204
 
205
205
  return html`<div data-effect-panel="view" class="panel p-view">
@@ -214,11 +214,73 @@ export function ViewPanel({ route, navKey }) {
214
214
 
215
215
  /** #2855: how often a guest's View reads the pane (the server allows it two reads a second). */
216
216
  const GUEST_PANE_MS = 1000;
217
- /** The live terminal: the pane every 800 ms (passive), drawn as escaped markup, its font fitted to the pane's grid. */
218
- function Terminal({ name, lease, density, every = 800 }) {
217
+ /**
218
+ * The live terminal: the pane every 800 ms (passive), drawn as escaped markup, its font fitted to the pane's grid.
219
+ *
220
+ * M-2262 (the user, hmsg-435f6df6): scrolling back. Following (the default), it shows the pane's screen as it is now.
221
+ * Scrolling up — the wheel, a touch drag, PageUp/↑/Home, or "Earlier output" — reads the pane's scrollback once
222
+ * (`?history=1`: tmux's history above the screen, bounded) and shows it: reading. While reading, nothing moves the
223
+ * person: the refresh keeps reading the screen only to say "New output below", and never redraws. "Jump to latest"
224
+ * (or End), or scrolling to the bottom, follows again. Everything happens in this page, over text tmux was asked to
225
+ * capture: never copy-mode and never a key to the pane (hmsg-b8a170c0 — scrolling must never affect delivery).
226
+ */
227
+ /** How long a scrollback note stays (it covers the terminal's bottom rows). */
228
+ const NOTE_MS = 6000;
229
+ /** A scroll this soon after the person's own wheel, touch, key or pointer is theirs (not one this page made). */
230
+ const GESTURE_MS = 1000;
231
+ /**
232
+ * #2969 review (Prism P3): is `live` (a screen read) still what `shown` ends with — the screen as it was when reading
233
+ * began (the scrollback's last lines, or the screen itself)? Trailing blank rows are not content.
234
+ */
235
+ export function sameScreen(shown, live) {
236
+ if (shown == null || live == null) return shown === live;
237
+ const trim = (t) => String(t).replace(/\n+$/, "");
238
+ const s = trim(shown).split("\n"), l = trim(live).split("\n");
239
+ return s.length >= l.length && s.slice(s.length - l.length).join("\n") === l.join("\n");
240
+ }
241
+ /** M-2262 survey: CLIs that redraw their whole window (no tmux scrollback even outside the alternate screen). */
242
+ const REDRAWS_IN_PLACE = new Set(["grok"]);
243
+ /** M-2262 survey: the full-screen CLIs (the alternate screen, or redrawn in place) — tmux keeps nothing above their screen. */
244
+ const FULL_SCREEN = new Set(["claude-code", "opencode", "grok"]); // agy: its chat UI unverified — found by the read
245
+ /** The full-screen CLIs whose earlier output the Log tab shows (it reads their transcript: log-service.ts). */
246
+ const LOG_HAS_HISTORY = new Set(["claude-code"]);
247
+ /**
248
+ * M-2267 option (2), the leader's safe default: where a full-screen CLI's earlier output is — its Log tab (or that the
249
+ * Log tab is off, and where to turn it on). Only for whom the Log tab is (a signed-in session on the full app; never a
250
+ * guest or a View-only reader: they get the plain words), and only for a CLI the Log tab reads.
251
+ */
252
+ function logLinkFor(name, backend, signedIn) {
253
+ if (!signedIn || !LOG_HAS_HISTORY.has(backend)) return null;
254
+ const on = typeof document !== "undefined" && document.body?.dataset?.logTab === "1";
255
+ return on ? { href: logPath(name), text: tn("earlierInLog") } : { href: sectionPath("web-dashboard"), text: tn("logTabOff") };
256
+ }
257
+ function Terminal({ name, lease, density, every = 800, backend = "", signedIn = false }) {
219
258
  const term = useRef(null), pre = useRef(null);
220
259
  const grid = useRef({ cols: 0, rows: 0, applied: 0, key: "" });
221
260
  const [problem, setProblem] = useState(null);
261
+ // "follow" | "loading" | "reading"; the screen's text when reading began, the newest read since; a note about history.
262
+ const [mode, setModeState] = useState("follow");
263
+ const modeRef = useRef("follow");
264
+ const setMode = (m) => { modeRef.current = m; setModeState(m); };
265
+ const [newBelow, setNewBelow] = useState(false);
266
+ const [historyNote, setHistoryNoteState] = useState(null);
267
+ // A CLI found to keep no scrollback (full screen): nothing is offered or read again for it; the note says why, briefly.
268
+ const [noScrollback, setNoScrollback] = useState(FULL_SCREEN.has(backend));
269
+ const noScrollbackRef = useRef(FULL_SCREEN.has(backend));
270
+ const logLink = logLinkFor(name, backend, signedIn);
271
+ const noteTimer = useRef(null);
272
+ const setHistoryNote = (note, brief = false) => {
273
+ lease.clear(noteTimer.current); noteTimer.current = null;
274
+ setHistoryNoteState(note);
275
+ if (note && brief) noteTimer.current = lease.timeout(() => setHistoryNoteState(null), NOTE_MS);
276
+ };
277
+ // `reading`: the text shown while reading (the scrollback, or the screen as it was when the person left the bottom);
278
+ // `latest`: the newest screen read; `history`: whether what is shown has the scrollback in it.
279
+ // `drawn` (#2969 review P3): the text actually on the page now — what reading starts from, never a newer read not drawn.
280
+ const screenAt = useRef({ reading: null, latest: null, history: false, drawn: null });
281
+ // #2969 review (Prism P2): when the person last moved the terminal themselves (wheel, touch, key, pointer).
282
+ const gestureAt = useRef(-Infinity);
283
+ const recentGesture = () => performance.now() - gestureAt.current < GESTURE_MS;
222
284
  // #1674: the text size as of this render. The pane refresh and the resize handlers below are set up once per mount
223
285
  // and keep the fit() of that render; reading the size here, not from that render's props, keeps them on the current
224
286
  // choice (they used to put the mount-time size back within one 800 ms refresh).
@@ -245,7 +307,27 @@ function Terminal({ name, lease, density, every = 800 }) {
245
307
  g.applied = size;
246
308
  p.style.fontSize = `${size.toFixed(2)}px`; // CSSOM, never a style attribute (#1300)
247
309
  };
310
+ /** Keep a following view on the newest line (a grid taller than the box at the larger sizes scrolls). */
311
+ const toBottom = () => { const box = term.current; if (box) box.scrollTop = box.scrollHeight; };
312
+ const draw = (text) => {
313
+ pre.current.innerHTML = ansiToHtml(text); // escaped by construction: markup only from ansiToHtml's own spans
314
+ paintAnsi(pre.current);
315
+ screenAt.current.drawn = text;
316
+ };
248
317
  useEffect(() => { grid.current.key = ""; fit(); }, [density]);
318
+ const atBottom = () => { const box = term.current; return !box || box.scrollHeight - box.scrollTop - box.clientHeight <= 2; };
319
+ /** Reading what is shown now (`text`, with or without the scrollback): the refresh only says whether there is more. */
320
+ const startReading = (text, history) => {
321
+ screenAt.current.reading = text; screenAt.current.history = history;
322
+ setNewBelow(false); setMode("reading");
323
+ };
324
+ const grid1 = (r) => {
325
+ const c = Number(r.headers.get("X-Pane-Cols")), rw = Number(r.headers.get("X-Pane-Rows"));
326
+ if (c >= 1 && rw >= 1 && (c !== grid.current.cols || rw !== grid.current.rows)) { grid.current.cols = c; grid.current.rows = rw; grid.current.key = ""; }
327
+ };
328
+ // The person asked for what scrolled off: one bounded read, then reading (or why there is nothing to read).
329
+ const loadEarlier = useRef(null);
330
+ const follow = useRef(null);
249
331
  useEffect(() => {
250
332
  const reads = readStream(lease);
251
333
  const refresh = async () => {
@@ -263,15 +345,72 @@ function Terminal({ name, lease, density, every = 800 }) {
263
345
  if (!r.ok) { setProblem(r.status === 404 ? tn("paneUnavailable") : r.status === 403 && guestPage() ? tn("guestNeedsTrusted") : tn("paneFailed", { name, status: r.status })); return; }
264
346
  const text = await r.text();
265
347
  if (!reads.live(token) || !pre.current) return;
266
- const c = Number(r.headers.get("X-Pane-Cols")), rw = Number(r.headers.get("X-Pane-Rows"));
267
- if (c >= 1 && rw >= 1 && (c !== grid.current.cols || rw !== grid.current.rows)) { grid.current.cols = c; grid.current.rows = rw; grid.current.key = ""; }
268
348
  setProblem(null);
269
- pre.current.innerHTML = ansiToHtml(text); // escaped by construction: markup only from ansiToHtml's own spans
270
- paintAnsi(pre.current);
349
+ screenAt.current.latest = text;
350
+ // Reading earlier output: nothing is redrawn under the person — the read only says whether there is more.
351
+ if (modeRef.current !== "follow") { if (modeRef.current === "reading") setNewBelow(!sameScreen(screenAt.current.reading, text)); return; }
352
+ // #2969 review (Prism P2): the person is moving a screen taller than its box away from the bottom right now —
353
+ // this is them leaving it, not the latest to follow: read on, nothing redrawn.
354
+ // Reading starts from what is drawn; this newer read (not drawn) is then the new output below.
355
+ if (recentGesture() && !atBottom()) { startReading(screenAt.current.drawn, false); setNewBelow(!sameScreen(screenAt.current.drawn, text)); return; }
356
+ grid1(r);
357
+ draw(text);
271
358
  fit();
359
+ toBottom();
272
360
  } catch { /* a transient error keeps the last frame */ }
273
361
  finally { reads.end(token); }
274
362
  };
363
+ loadEarlier.current = async () => {
364
+ // From following, or from reading what was on the screen (the person reached its top): never twice, never over it.
365
+ const from = modeRef.current;
366
+ if (from !== "follow" && !(from === "reading" && !screenAt.current.history)) return;
367
+ if (noScrollbackRef.current) { setHistoryNote(tn("noEarlier"), true); return; } // known: nothing to read
368
+ const box0 = term.current;
369
+ // Where the person is, measured from the end: the screen's lines stay where they are under the scrollback added.
370
+ const fromEnd = box0 ? box0.scrollHeight - box0.scrollTop : 0;
371
+ const back = () => { setMode(from); if (from === "follow") toBottom(); };
372
+ setMode("loading"); setHistoryNote(tn("loadingEarlier"));
373
+ try {
374
+ const r = await lease.fetch(`/api/pane/${encodeURIComponent(name)}?history=1`);
375
+ if (!lease.current() || modeRef.current !== "loading") return;
376
+ // #2969 review (Prism P2): a scrollback read that failed — or answered without its facts — changes nothing shown.
377
+ if (!r.ok || r.headers.get("X-Pane-History-Lines") == null) { back(); setHistoryNote(r.status === 429 ? null : tn("earlierFailed"), true); return; }
378
+ const text = await r.text();
379
+ if (!lease.current() || modeRef.current !== "loading" || !pre.current) return;
380
+ // A full-screen CLI (the alternate screen, or one that redraws its whole window) leaves nothing above its screen;
381
+ // any other with no history yet has simply not scrolled: say which, keep following.
382
+ if (r.headers.get("X-Pane-Alternate") === "1" || REDRAWS_IN_PLACE.has(backend)) {
383
+ noScrollbackRef.current = true; setNoScrollback(true);
384
+ back(); setHistoryNote(tn("noEarlier"), true); return;
385
+ }
386
+ if (r.headers.get("X-Pane-History-Lines") === "0") { back(); setHistoryNote(tn("nothingEarlier"), true); return; }
387
+ grid1(r);
388
+ const lines = text.split("\n").length, rows = grid.current.rows || 0;
389
+ // #2969 review (Prism P3): new output is judged against what is shown — the scrollback's own last screen — and
390
+ // a screen read while this one was out already counts.
391
+ screenAt.current.reading = text; screenAt.current.history = true;
392
+ setNewBelow(screenAt.current.latest != null && !sameScreen(text, screenAt.current.latest));
393
+ setMode("reading"); setHistoryNote(null);
394
+ draw(text);
395
+ fit();
396
+ const box = term.current;
397
+ if (box) {
398
+ const lineH = lines > 0 ? pre.current.scrollHeight / lines : 16;
399
+ // From following (at the bottom): a few lines up — the earlier output right above. From reading: the same lines.
400
+ box.scrollTop = from === "follow"
401
+ ? Math.max(0, box.scrollHeight - box.clientHeight - lineH * Math.min(3, Math.max(1, lines - rows)))
402
+ : Math.max(0, box.scrollHeight - fromEnd);
403
+ }
404
+ } catch { if (lease.current()) { back(); setHistoryNote(tn("earlierFailed"), true); } }
405
+ };
406
+ follow.current = () => {
407
+ if (modeRef.current === "follow") return;
408
+ setMode("follow"); setNewBelow(false); setHistoryNote(null); screenAt.current.history = false;
409
+ const latest = screenAt.current.latest;
410
+ if (latest != null && pre.current) { draw(latest); fit(); }
411
+ toBottom();
412
+ refresh();
413
+ };
275
414
  refresh();
276
415
  lease.interval(refresh, every);
277
416
  let timer = null;
@@ -282,8 +421,44 @@ function Terminal({ name, lease, density, every = 800 }) {
282
421
  ro.observe(term.current);
283
422
  lease.hold(() => ro.disconnect());
284
423
  }
424
+ // Scrolling up asks for the earlier output; reaching the bottom while reading follows again.
425
+ const box = term.current;
426
+ if (box) {
427
+ // Asking for the earlier output: from following, or from reading the screen as it was — at the top.
428
+ const mayLoad = () => (modeRef.current === "follow" || (modeRef.current === "reading" && !screenAt.current.history)) && box.scrollTop <= 0;
429
+ const gesture = () => { gestureAt.current = performance.now(); };
430
+ lease.on(box, "wheel", (e) => { gesture(); if (e.deltaY < 0 && mayLoad()) loadEarlier.current(); }, { passive: true });
431
+ let touchY = null;
432
+ lease.on(box, "touchstart", (e) => { gesture(); touchY = e.touches && e.touches[0] ? e.touches[0].clientY : null; }, { passive: true });
433
+ lease.on(box, "touchmove", (e) => {
434
+ gesture();
435
+ const y = e.touches && e.touches[0] ? e.touches[0].clientY : null;
436
+ if (touchY == null || y == null) return;
437
+ if (y - touchY > 24 && mayLoad()) { touchY = null; loadEarlier.current(); }
438
+ }, { passive: true });
439
+ lease.on(box, "pointerdown", gesture, { passive: true }); // the scrollbar, dragged
440
+ lease.on(box, "keydown", (e) => {
441
+ gesture();
442
+ if ((e.key === "PageUp" || e.key === "ArrowUp" || e.key === "Home") && mayLoad()) { e.preventDefault(); loadEarlier.current(); }
443
+ else if (modeRef.current === "reading" && e.key === "End") { e.preventDefault(); follow.current(); }
444
+ });
445
+ lease.on(box, "scroll", () => {
446
+ if (modeRef.current === "reading") { if (atBottom()) follow.current(); return; }
447
+ // #2969 review (Prism P2): a screen taller than its box (a larger Aa), moved up from the bottom by the person:
448
+ // reading what is on it — the refresh no longer pulls them back down.
449
+ if (modeRef.current === "follow" && recentGesture() && !atBottom()) startReading(screenAt.current.drawn, false);
450
+ }, { passive: true });
451
+ }
285
452
  }, [lease, name]);
286
- return html`<div data-trust="panel-content" class="v-term" ref=${term}>${problem ? html`<p class="v-term-note" role="status">${problem}</p>` : null}<pre ref=${pre} class="v-pre"></pre></div>`;
453
+ const reading = mode === "reading";
454
+ // A full-screen CLI's Log link is a bar above the terminal, not over it: its first row is never covered.
455
+ return html`<div class="v-term-box">
456
+ ${noScrollback && logLink ? html`<p class="v-term-logbar"><a class="v-term-log" href=${logLink.href}>${logLink.text}<${Icon} name="forward" size=${14} /></a></p>` : null}
457
+ <div data-trust="panel-content" class=${`v-term${reading ? " is-reading" : ""}`} ref=${term} tabindex="0" aria-label=${tn(reading ? "readingEarlier" : "earlierHint")}>${problem ? html`<p class="v-term-note" role="status">${problem}</p>` : null}<pre ref=${pre} class="v-pre"></pre></div>
458
+ ${historyNote ? html`<p class="v-term-history-note" role="status">${historyNote}</p>` : null}
459
+ ${mode === "follow" && !noScrollback ? html`<button type="button" class="v-term-earlier btn btn-sm" title=${tn("earlierHint")} onClick=${() => loadEarlier.current && loadEarlier.current()}><${Icon} name="up" size=${14} />${tn("earlier")}</button>` : null}
460
+ ${reading ? html`<button type="button" class=${`v-term-latest btn btn-sm${newBelow ? " has-new" : ""}`} onClick=${() => follow.current && follow.current()}>${newBelow ? html`<span class="v-term-new">${tn("newBelow")} · </span>` : null}${tn("jumpLatest")}<${Icon} name="down" size=${14} /></button>` : null}
461
+ </div>`;
287
462
  }
288
463
 
289
464
  // The advance of one monospace cell as a fraction of font-size, measured once on this page.
@@ -215,6 +215,18 @@ register("view", {
215
215
  "guestNeedsTrustedHint": "Your host gave you chat access to this agent. Ask them to make you Trusted in Settings → Guests.",
216
216
  "noInstances": "No instances yet",
217
217
  "paneUnavailable": "This instance's terminal is not available (is it stopped?)",
218
+ // M-2262: the terminal's scrollback.
219
+ "earlier": "Earlier output",
220
+ "earlierHint": "Scroll up to read what has scrolled off. Live output keeps coming in below.",
221
+ "loadingEarlier": "Loading earlier output…",
222
+ "earlierFailed": "Could not load earlier output.",
223
+ "noEarlier": "This CLI redraws its whole screen, so the terminal keeps no earlier output to scroll back to.",
224
+ "nothingEarlier": "Nothing has scrolled off the screen yet.",
225
+ "earlierInLog": "Earlier output is in the Log tab",
226
+ "logTabOff": "Earlier output: the Log tab is off. Turn it on in Settings",
227
+ "readingEarlier": "Reading earlier output. Live output continues; scroll to the bottom to follow it again.",
228
+ "jumpLatest": "Jump to latest",
229
+ "newBelow": "New output below",
218
230
  "rosterFailedShort": "The instance list could not be loaded.",
219
231
  "collapseGroup": "Fold {group}",
220
232
  "expandGroup": "Open {group}",
@@ -461,6 +473,17 @@ register("view", {
461
473
  "guestNeedsTrustedHint": "主人給你的是這個 agent 的聊天權限。請他在設定 → 訪客把你改成「信任」。",
462
474
  "noInstances": "還沒有 instance",
463
475
  "paneUnavailable": "無法取得這個 instance 的終端畫面(它是否已停止?)",
476
+ "earlier": "較早的輸出",
477
+ "earlierHint": "往上捲動可以看到已經捲走的內容,新的輸出會繼續出現在下方。",
478
+ "loadingEarlier": "正在載入較早的輸出…",
479
+ "earlierFailed": "無法載入較早的輸出。",
480
+ "noEarlier": "這個 CLI 會重繪整個畫面,終端沒有保留可以往上捲動的較早輸出。",
481
+ "nothingEarlier": "還沒有內容捲出畫面。",
482
+ "earlierInLog": "較早的輸出在「紀錄」分頁",
483
+ "logTabOff": "較早的輸出:「紀錄」分頁目前關閉,請到設定開啟",
484
+ "readingEarlier": "正在看較早的輸出。新的輸出仍會繼續;捲到最下面即可再次跟著最新內容。",
485
+ "jumpLatest": "跳到最新",
486
+ "newBelow": "下方有新的輸出",
464
487
  "rosterFailedShort": "無法載入 instance 清單。",
465
488
  "collapseGroup": "收合 {group}",
466
489
  "expandGroup": "展開 {group}",
@@ -0,0 +1,6 @@
1
+ /** Runs `$1` detached from any terminal, at low priority; HUP/INT/TERM on this shell reach its whole process group. */
2
+ export declare const UNATTENDED_INSTALL_SCRIPT: string;
3
+ /** The account's login shell, when it is an absolute path to an executable; else undefined (keep the inherited one). */
4
+ export declare function loginShell(read?: () => string | null | undefined): string | undefined;
5
+ /** The window's command for an install command. `shell`: the $SHELL to give it; null keeps the inherited one. */
6
+ export declare function unattendedInstallCommand(command: string, shell?: string | null): string;