@oharness/server 0.10.0 → 0.11.0

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 (70) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +152 -8
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.js +1 -0
  7. package/dist/icons.js.map +1 -1
  8. package/dist/model-order.js +1 -0
  9. package/dist/model-order.js.map +1 -1
  10. package/dist/page/client/account.js +140 -4
  11. package/dist/page/client/account.js.map +1 -1
  12. package/dist/page/client/base.js +40 -16
  13. package/dist/page/client/base.js.map +1 -1
  14. package/dist/page/client/browser.js +1 -1
  15. package/dist/page/client/composer.js +111 -7
  16. package/dist/page/client/composer.js.map +1 -1
  17. package/dist/page/client/dialogs.js +75 -0
  18. package/dist/page/client/dialogs.js.map +1 -1
  19. package/dist/page/client/events.js +25 -2
  20. package/dist/page/client/events.js.map +1 -1
  21. package/dist/page/client/index.js +4 -0
  22. package/dist/page/client/index.js.map +1 -1
  23. package/dist/page/client/panels.js +4 -4
  24. package/dist/page/client/ripple.d.ts +13 -0
  25. package/dist/page/client/ripple.js +247 -0
  26. package/dist/page/client/ripple.js.map +1 -0
  27. package/dist/page/client/routines.js +173 -41
  28. package/dist/page/client/routines.js.map +1 -1
  29. package/dist/page/client/shell.js +200 -54
  30. package/dist/page/client/shell.js.map +1 -1
  31. package/dist/page/client/state.js +7 -2
  32. package/dist/page/client/state.js.map +1 -1
  33. package/dist/page/client/terminal.js +34 -7
  34. package/dist/page/client/terminal.js.map +1 -1
  35. package/dist/page/client/theme.d.ts +17 -0
  36. package/dist/page/client/theme.js +365 -0
  37. package/dist/page/client/theme.js.map +1 -0
  38. package/dist/page/client/transcript.js +63 -7
  39. package/dist/page/client/transcript.js.map +1 -1
  40. package/dist/page/style.js +183 -22
  41. package/dist/page/style.js.map +1 -1
  42. package/dist/page.js +4 -5
  43. package/dist/page.js.map +1 -1
  44. package/dist/prefs.d.ts +17 -0
  45. package/dist/prefs.js +5 -0
  46. package/dist/prefs.js.map +1 -1
  47. package/dist/server.js +465 -42
  48. package/dist/server.js.map +1 -1
  49. package/dist/suggest.d.ts +42 -0
  50. package/dist/suggest.js +215 -0
  51. package/dist/suggest.js.map +1 -0
  52. package/dist/terminal.d.ts +28 -2
  53. package/dist/terminal.js +45 -3
  54. package/dist/terminal.js.map +1 -1
  55. package/dist/theme-assets.d.ts +39 -0
  56. package/dist/theme-assets.js +109 -0
  57. package/dist/theme-assets.js.map +1 -0
  58. package/dist/theme-store.d.ts +50 -0
  59. package/dist/theme-store.js +124 -0
  60. package/dist/theme-store.js.map +1 -0
  61. package/dist/theme-tools.d.ts +25 -0
  62. package/dist/theme-tools.js +243 -0
  63. package/dist/theme-tools.js.map +1 -0
  64. package/dist/turn-shots.d.ts +18 -2
  65. package/dist/turn-shots.js +10 -1
  66. package/dist/turn-shots.js.map +1 -1
  67. package/dist/user-theme.d.ts +143 -0
  68. package/dist/user-theme.js +510 -0
  69. package/dist/user-theme.js.map +1 -0
  70. package/package.json +2 -2
@@ -275,8 +275,8 @@ async function showRoutines() {
275
275
 
276
276
  const remove = el("button", "ghost", t("routines.remove"));
277
277
  remove.type = "button";
278
- remove.addEventListener("click", () => {
279
- if (!confirm(t("routines.removeConfirm", { name: routine.name }))) return;
278
+ remove.addEventListener("click", async () => {
279
+ if (!(await askConfirm({ body: t("routines.removeConfirm", { name: routine.name }), ok: t("routines.remove"), danger: true }))) return;
280
280
  remove.disabled = true;
281
281
  void api("/routine/remove", { name: routine.name }).then((result) => {
282
282
  pushNotice(result);
@@ -315,7 +315,10 @@ async function showRoutines() {
315
315
  * because a switch you have to open a row to reach is a switch that has been
316
316
  * hidden rather than tidied.
317
317
  */
318
- async function showSettings() {
318
+ async function showSettings(opts) {
319
+ // A row whose controls live inside it is reopened after a change redraws
320
+ // the page; called from a click handler, opts is the event, and opens none.
321
+ const opened = opts && typeof opts.open === "string" ? opts.open : undefined;
319
322
  const settings = await getJson("/settings");
320
323
  panelBody.replaceChildren();
321
324
  if (settings.error) return void panelBody.appendChild(panelError(settings.error));
@@ -439,6 +442,7 @@ async function showSettings() {
439
442
  change.addEventListener("click", openProjectPicker);
440
443
  field(t("settings.cwd"), settings.cwd || "—", t("settings.cwdHint"), change);
441
444
  permissionsField(settings);
445
+ claudeAgentField(settings.claudeAgent || { on: false, available: false });
442
446
 
443
447
  // A door, not a report: the numbers live on a page of their own, and the row
444
448
  // here carries no total of its own because working one out means walking
@@ -588,6 +592,34 @@ async function showSettings() {
588
592
  * and a "Show trail" you had to find after that was one click too many for
589
593
  * the only thing anybody opens this row to see.
590
594
  */
595
+ /**
596
+ * Claude Agent: the user's own Claude Code, on their own Claude account, as
597
+ * a subagent and as @claude.
598
+ *
599
+ * Off until switched on here, because it spends someone's Claude plan and
600
+ * that is theirs to decide — the hint says so in so many words before the
601
+ * switch is flipped, not after. With no Claude Code on this machine the
602
+ * switch is shown but shut, and the row says where to get it.
603
+ */
604
+ function claudeAgentField(status) {
605
+ const value = !status.available
606
+ ? t("settings.claudeAgentMissingValue")
607
+ : t(status.on ? "settings.claudeAgentOnValue" : "settings.claudeAgentOffValue");
608
+ const body = field(
609
+ t("settings.claudeAgent"),
610
+ value,
611
+ t("settings.claudeAgentHint"),
612
+ toggle(status.on, t("settings.claudeAgent"), (next) => {
613
+ void api("/settings/claude-agent", { enabled: next }).then((result) => {
614
+ if (result && result.error) addNotice(result.error, "err");
615
+ void showSettings();
616
+ });
617
+ }, !status.available && !status.on),
618
+ );
619
+ if (!status.available) body.appendChild(el("div", "muted small", t("settings.claudeAgentMissing")));
620
+ else if (status.unavailable) body.appendChild(el("div", "muted small", status.unavailable));
621
+ }
622
+
591
623
  function auditField(audit) {
592
624
  const mine = audit.source !== "project";
593
625
 
@@ -792,7 +824,9 @@ async function showSettings() {
792
824
  // a switch a browser tab cannot honour would be one that lies.
793
825
  const keepAwake = await getJson("/keep-awake");
794
826
  if (keepAwake && !keepAwake.error && keepAwake.available) {
795
- target = groups.workspace;
827
+ // Set once, if ever: under Advanced, folded away with the other rows
828
+ // nobody comes to Settings to change.
829
+ target = groups.advanced;
796
830
  field(
797
831
  t("settings.keepAwake"),
798
832
  t(keepAwake.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
@@ -890,44 +924,9 @@ async function showSettings() {
890
924
  }
891
925
  body.appendChild(phones);
892
926
  }
893
- // Computer use: only in the desktop app, which ships the helper. The switch
894
- // is whether it may ever take the real mouse and keyboard; the list below
895
- // it is the person's own additions to the apps it refuses.
927
+ // Computer use: only in the desktop app, which ships the helper. Read here,
928
+ // drawn after the browser row, its neighbour in kind.
896
929
  const computer = await getJson("/computer-use/settings");
897
- if (computer && !computer.error && computer.available) {
898
- target = groups.workspace;
899
- const takeover = computer.takeover !== "never";
900
- const body = field(
901
- t("settings.computerTakeover"),
902
- t(takeover ? "settings.computerTakeoverAsk" : "settings.computerTakeoverNever"),
903
- t("settings.computerTakeoverHint"),
904
- toggle(takeover, t("settings.computerTakeover"), (next) => {
905
- void api("/computer-use/settings", { takeover: next ? "ask" : "never" }).then(showSettings);
906
- }),
907
- );
908
- const blocked = el("div", "setting-list");
909
- blocked.appendChild(el("div", "muted small", t("settings.computerBlocked")));
910
- const box = el("textarea", "boxed");
911
- box.value = (computer.blockedApps || []).join("\n");
912
- box.rows = Math.max(2, (computer.blockedApps || []).length + 1);
913
- box.spellcheck = false;
914
- box.style.width = "100%";
915
- box.placeholder = t("settings.computerBlockedPlaceholder");
916
- blocked.appendChild(box);
917
- const save = el("button", "ghost", t("perms.save"));
918
- save.type = "button";
919
- save.addEventListener("click", () => {
920
- save.disabled = true;
921
- const apps = box.value.split("\n").map((line) => line.trim()).filter(Boolean);
922
- void api("/computer-use/settings", { blockedApps: apps }).then((result) => {
923
- save.disabled = false;
924
- if (result && result.error) return void addNotice(result.error, "err");
925
- addNotice(t("perms.saved"));
926
- });
927
- });
928
- blocked.appendChild(save);
929
- body.appendChild(blocked);
930
- }
931
930
  // Which browsers the agent may use. Three choices, so a segmented control
932
931
  // rather than a switch; absent when the browser tools are off altogether,
933
932
  // since then there is nothing for it to move.
@@ -961,6 +960,105 @@ async function showSettings() {
961
960
  body.appendChild(status);
962
961
  body.appendChild(extensionHelp(ext));
963
962
  }
963
+ // Computer use, as one row: the row says how it gets switched on, and opens
964
+ // onto the three things a person owns about it — whether the agent may
965
+ // switch it on itself, whether it may ever take the real mouse and
966
+ // keyboard, and their own additions to the apps it refuses.
967
+ if (computer && !computer.error && computer.available) {
968
+ target = groups.workspace;
969
+ const enableModes = ["ask", "auto", "off"];
970
+ const enable = enableModes.includes(computer.enable) ? computer.enable : "ask";
971
+ const body = field(t("settings.computer"), t("settings.computerEnable." + enable), "");
972
+ const sub = (label, control, hint) => {
973
+ const part = el("div", "setting-part");
974
+ const line = el("div", "setting-part-line");
975
+ line.appendChild(el("div", "setting-part-name", label));
976
+ line.appendChild(control);
977
+ part.appendChild(line);
978
+ if (hint) part.appendChild(el("div", "muted small", hint));
979
+ body.appendChild(part);
980
+ };
981
+
982
+ const enableSeg = el("div", "seg setting-seg");
983
+ enableSeg.setAttribute("role", "radiogroup");
984
+ enableSeg.setAttribute("aria-label", t("settings.computerEnable"));
985
+ for (const mode of enableModes) {
986
+ const chosen = mode === enable;
987
+ const button = el("button", "seg-item" + (chosen ? " on" : ""), t("settings.computerEnable." + mode));
988
+ button.type = "button";
989
+ button.setAttribute("role", "radio");
990
+ button.setAttribute("aria-checked", chosen ? "true" : "false");
991
+ button.addEventListener("click", () => {
992
+ if (chosen) return;
993
+ enableSeg.querySelectorAll("button").forEach((b) => (b.disabled = true));
994
+ void api("/computer-use/settings", { enable: mode }).then(() => showSettings({ open: "computer" }));
995
+ });
996
+ enableSeg.appendChild(button);
997
+ }
998
+ sub(t("settings.computerEnable"), enableSeg, t("settings.computerEnableHint." + enable));
999
+
1000
+ const takeover = computer.takeover !== "never";
1001
+ sub(
1002
+ t("settings.computerTakeover"),
1003
+ toggle(takeover, t("settings.computerTakeover"), (next) => {
1004
+ void api("/computer-use/settings", { takeover: next ? "ask" : "never" }).then(() => showSettings({ open: "computer" }));
1005
+ }),
1006
+ t("settings.computerTakeoverHint"),
1007
+ );
1008
+
1009
+ const blocked = el("div", "setting-list setting-part");
1010
+ blocked.appendChild(el("div", "setting-part-name", t("settings.computerBlockedTitle")));
1011
+ blocked.appendChild(el("div", "muted small", t("settings.computerBlocked")));
1012
+ const box = el("textarea", "boxed");
1013
+ box.value = (computer.blockedApps || []).join("\n");
1014
+ box.rows = Math.max(2, (computer.blockedApps || []).length + 1);
1015
+ box.spellcheck = false;
1016
+ box.style.width = "100%";
1017
+ box.placeholder = t("settings.computerBlockedPlaceholder");
1018
+ blocked.appendChild(box);
1019
+ const save = el("button", "ghost", t("perms.save"));
1020
+ save.type = "button";
1021
+ save.addEventListener("click", () => {
1022
+ save.disabled = true;
1023
+ const apps = box.value.split("\n").map((line) => line.trim()).filter(Boolean);
1024
+ void api("/computer-use/settings", { blockedApps: apps }).then((result) => {
1025
+ save.disabled = false;
1026
+ if (result && result.error) return void addNotice(result.error, "err");
1027
+ addNotice(t("perms.saved"));
1028
+ });
1029
+ });
1030
+ blocked.appendChild(save);
1031
+ body.appendChild(blocked);
1032
+ body.parentElement.dataset.row = "computer";
1033
+ if (opened === "computer") body.parentElement.open = true;
1034
+ }
1035
+ // One switch for both: the pictures of what a turn left in the browser and
1036
+ // on the computer, under its reply. Shown wherever either can run.
1037
+ const shots = await getJson("/turn-shots");
1038
+ if (shots && !shots.error && (browserModes.includes(settings.browserMode) || (computer && computer.available))) {
1039
+ target = groups.advanced;
1040
+ field(
1041
+ t("settings.turnShots"),
1042
+ t(shots.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
1043
+ t("settings.turnShotsHint"),
1044
+ toggle(shots.enabled, t("settings.turnShots"), (next) => {
1045
+ void api("/turn-shots", { enabled: next }).then(showSettings);
1046
+ }),
1047
+ );
1048
+ }
1049
+ // The grey next message in the empty composer, written after each turn.
1050
+ const suggestions = await getJson("/suggestions");
1051
+ if (suggestions && !suggestions.error) {
1052
+ target = groups.workspace;
1053
+ field(
1054
+ t("settings.suggestions"),
1055
+ t(suggestions.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
1056
+ t("settings.suggestionsHint"),
1057
+ toggle(suggestions.enabled, t("settings.suggestions"), (next) => {
1058
+ void api("/suggestions", { enabled: next }).then(showSettings);
1059
+ }),
1060
+ );
1061
+ }
964
1062
  // The desktop app's version, with its release notes beside it. A row that
965
1063
  // is always there, because the menu bar is not where people look and the
966
1064
  // post-update hint is gone after one click.
@@ -988,12 +1086,46 @@ async function showSettings() {
988
1086
  ];
989
1087
  for (const [title, parts] of sections) {
990
1088
  if (!parts.some((part) => part.childNodes.length)) continue;
1089
+ if (title === "settings.section.advanced") {
1090
+ panel.appendChild(advancedSection(parts));
1091
+ continue;
1092
+ }
991
1093
  panel.appendChild(el("div", "section-title", t(title)));
992
1094
  for (const part of parts) panel.appendChild(part);
993
1095
  }
994
1096
  panelBody.appendChild(panel);
995
1097
  }
996
1098
 
1099
+ /**
1100
+ * Advanced: the rows nobody opens Settings to change, folded under their
1101
+ * heading so the ones people do are the page. Shut until opened, and then
1102
+ * open on every visit in this browser until shut again — a person who went
1103
+ * looking once is likely to go looking again.
1104
+ */
1105
+ const ADVANCED_OPEN_KEY = "oh.settings.advancedOpen";
1106
+
1107
+ function advancedSection(parts) {
1108
+ const section = el("details", "settings-advanced");
1109
+ const head = el("summary", "section-title");
1110
+ head.appendChild(el("span", "chev"));
1111
+ head.appendChild(document.createTextNode(t("settings.section.advanced")));
1112
+ section.appendChild(head);
1113
+ for (const part of parts) section.appendChild(part);
1114
+ try {
1115
+ section.open = localStorage.getItem(ADVANCED_OPEN_KEY) === "1";
1116
+ } catch {
1117
+ // Storage refused: shut, as on a first visit.
1118
+ }
1119
+ section.addEventListener("toggle", () => {
1120
+ try {
1121
+ localStorage.setItem(ADVANCED_OPEN_KEY, section.open ? "1" : "0");
1122
+ } catch {
1123
+ // Not remembered, which costs one click next time.
1124
+ }
1125
+ });
1126
+ return section;
1127
+ }
1128
+
997
1129
  /**
998
1130
  * Where the money went, and on which model.
999
1131
  *
@@ -1 +1 @@
1
- {"version":3,"file":"routines.js","sourceRoot":"","sources":["../../../src/page/client/routines.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kFAwpCgD,CAAC"}
1
+ {"version":3,"file":"routines.js","sourceRoot":"","sources":["../../../src/page/client/routines.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kFA4xCgD,CAAC"}
@@ -8,6 +8,8 @@ export const SHELL = String.raw `
8
8
 
9
9
  const projectBtn = document.getElementById("project");
10
10
  let projects = [];
11
+ /** The server's home directory, which the picker writes as ~. */
12
+ let projectHome = "";
11
13
  /** Session directories that are worktrees, each naming the project it came from. */
12
14
  let worktrees = [];
13
15
  const worktreeAt = (cwd) => worktrees.find((one) => one.path === cwd);
@@ -54,6 +56,7 @@ function repaintPanel() {
54
56
  async function loadProjects() {
55
57
  const body = await getJson("/projects");
56
58
  projects = body.projects || [];
59
+ projectHome = body.home || "";
57
60
  worktrees = body.worktrees || [];
58
61
  projectOrder = body.order || [];
59
62
  paintProject();
@@ -64,9 +67,9 @@ async function loadProjects() {
64
67
  }
65
68
 
66
69
  async function renameProjectUi(cwd, name) {
67
- const next = window.prompt(t("projects.renamePrompt"), name);
68
- if (!next || next.trim() === name) return;
69
- const body = await api("/project", { path: cwd, rename: next.trim() });
70
+ const next = await askText({ title: t("projects.rename"), body: t("projects.renamePrompt"), value: name, ok: t("dialog.save") });
71
+ if (!next || next === name) return;
72
+ const body = await api("/project", { path: cwd, rename: next });
70
73
  if (body && body.error) return void addNotice(body.error, "err");
71
74
  addNotice(t("projects.renamed", { name: body.project.name }));
72
75
  // Paths key tabs, local open/closed state and drag order. Reloading after
@@ -81,73 +84,124 @@ function exportProjectUi(cwd) {
81
84
  link.click();
82
85
  }
83
86
 
87
+ /** A project's directory as people say it: under home, from ~. */
88
+ function projectDir(path) {
89
+ const home = projectHome.replace(/[\\/]+$/, "");
90
+ if (home && (path === home || path.startsWith(home + "/") || path.startsWith(home + "\\"))) return "~" + path.slice(home.length);
91
+ return path;
92
+ }
93
+
84
94
  function openProjectPicker() {
85
95
  const dialog = document.getElementById("project-picker");
86
96
  const field = document.getElementById("proj-search");
87
97
  const listEl = document.getElementById("proj-list");
88
98
  field.placeholder = t("projects.search");
89
99
 
90
- let first = null;
100
+ // The row menu lives in <body>, which a modal dialog makes inert: while the
101
+ // picker is open the menu is moved inside it, and put back when it closes.
102
+ const menuHome = rowMenu.parentNode;
103
+ dialog.appendChild(rowMenu);
104
+ dialog.addEventListener("close", () => {
105
+ closeRowMenu();
106
+ menuHome.appendChild(rowMenu);
107
+ }, { once: true });
108
+
109
+ /** The rows that open a project, in order, and which one Enter opens. */
110
+ let rows = [];
111
+ let active = 0;
112
+ const light = (index, scroll) => {
113
+ if (!rows.length) return;
114
+ active = (index + rows.length) % rows.length;
115
+ rows.forEach((row, i) => row.parentNode.classList.toggle("active", i === active));
116
+ if (scroll) rows[active].scrollIntoView({ block: "nearest" });
117
+ };
118
+
119
+ const menuFor = (project, more, line) => {
120
+ const items = [
121
+ menuItem(t("projects.rename"), {
122
+ key: "r",
123
+ run: () => {
124
+ dialog.close();
125
+ void renameProjectUi(project.path, project.name);
126
+ },
127
+ }),
128
+ menuItem(t("projects.export"), {
129
+ key: "e",
130
+ run: () => {
131
+ closeRowMenu();
132
+ exportProjectUi(project.path);
133
+ },
134
+ }),
135
+ ];
136
+ // Not offered for the project this window is standing in: the next
137
+ // switch calls addProject and puts it straight back, so the control
138
+ // would appear to do nothing. Nothing recorded is lost either way —
139
+ // this forgets a path, and the sessions stay where they are.
140
+ if (!project.current) {
141
+ items.push(
142
+ menuSep(),
143
+ menuItem(t("projects.forget"), {
144
+ danger: true,
145
+ run: () => {
146
+ closeRowMenu();
147
+ void api("/project", { path: project.path, remove: true }).then(async (body) => {
148
+ if (body && body.error) return void addNotice(body.error, "err");
149
+ await loadProjects();
150
+ paint();
151
+ });
152
+ },
153
+ }),
154
+ );
155
+ }
156
+ openRowMenu(more, line, items);
157
+ };
158
+
91
159
  const paint = () => {
92
160
  const query = field.value.trim().toLowerCase();
93
161
  listEl.replaceChildren();
94
- first = null;
162
+ rows = [];
163
+ let here = 0;
95
164
  for (const project of projects) {
96
165
  if (query && !(project.name + " " + project.path).toLowerCase().includes(query)) continue;
166
+ const line = el("div", "proj-row" + (project.current ? " current" : ""));
97
167
  const row = el("button", "proj-item");
98
168
  row.type = "button";
169
+ row.title = project.path;
99
170
  row.appendChild(el("span", "avatar", project.name.slice(0, 1).toUpperCase()));
100
- row.appendChild(el("span", "path", project.name));
171
+ const text = el("span", "proj-text");
172
+ text.appendChild(el("span", "path", project.name));
173
+ text.appendChild(el("span", "proj-dir", projectDir(project.path)));
174
+ row.appendChild(text);
101
175
  if (project.missing) row.appendChild(el("span", "tag", t("projects.missing")));
102
- if (project.current) row.appendChild(el("span", "muted", "✓"));
176
+ const tick = el("span", "proj-check");
177
+ if (project.current) tick.appendChild(svgIcon("check"));
178
+ row.appendChild(tick);
103
179
  row.addEventListener("click", () => {
104
180
  dialog.close();
105
181
  if (!project.current) void chooseProject(project.path);
106
182
  });
107
-
108
- const line = el("div", "proj-row");
183
+ const index = rows.length;
184
+ row.addEventListener("mousemove", () => { if (active !== index) light(index); });
109
185
  line.appendChild(row);
110
- const rename = el("button", "proj-forget", "✎");
111
- rename.type = "button";
112
- rename.title = t("projects.rename");
113
- rename.setAttribute("aria-label", t("projects.rename") + ": " + project.name);
114
- rename.addEventListener("click", (e) => {
115
- e.stopPropagation();
116
- dialog.close();
117
- void renameProjectUi(project.path, project.name);
118
- });
119
- line.appendChild(rename);
120
- const archive = el("button", "proj-forget", "↓");
121
- archive.type = "button";
122
- archive.title = t("projects.export");
123
- archive.setAttribute("aria-label", t("projects.export") + ": " + project.name);
124
- archive.addEventListener("click", (e) => {
186
+
187
+ const more = el("button", "proj-more");
188
+ more.type = "button";
189
+ more.title = t("session.more");
190
+ more.setAttribute("aria-label", t("session.more") + ": " + project.name);
191
+ more.appendChild(svgIcon("more"));
192
+ more.addEventListener("click", (e) => {
125
193
  e.stopPropagation();
126
- exportProjectUi(project.path);
194
+ if (menuRow === line) return closeRowMenu();
195
+ menuFor(project, more, line);
127
196
  });
128
- line.appendChild(archive);
129
- // Not offered for the project this window is standing in: the next
130
- // switch calls addProject and puts it straight back, so the control
131
- // would appear to do nothing. Nothing recorded is lost either way —
132
- // this forgets a path, and the sessions stay where they are.
133
- if (!project.current) {
134
- const forget = el("button", "proj-forget", "✕");
135
- forget.type = "button";
136
- forget.title = t("projects.forget");
137
- forget.setAttribute("aria-label", t("projects.forget") + ": " + project.name);
138
- forget.addEventListener("click", (e) => {
139
- e.stopPropagation();
140
- void api("/project", { path: project.path, remove: true }).then(async (body) => {
141
- if (body && body.error) return void addNotice(body.error, "err");
142
- await loadProjects();
143
- paint();
144
- });
145
- });
146
- line.appendChild(forget);
147
- }
197
+ line.appendChild(more);
148
198
  listEl.appendChild(line);
149
- first = first || row;
199
+ if (project.current && !query) here = rows.length;
200
+ rows.push(row);
150
201
  }
202
+ if (!rows.length) listEl.appendChild(el("div", "proj-empty", t("projects.none")));
203
+ // Searching starts from the first match; an empty query from where you are.
204
+ light(here, true);
151
205
  };
152
206
 
153
207
  const add = document.getElementById("proj-add");
@@ -158,20 +212,38 @@ function openProjectPicker() {
158
212
  };
159
213
 
160
214
  field.value = "";
161
- field.oninput = paint;
162
- // Typing enough of the name and pressing Enter is what a list with a search
163
- // box above it promises; without this the field swallows the key.
215
+ field.oninput = () => {
216
+ closeRowMenu();
217
+ paint();
218
+ };
219
+ // A list with a search box above it promises the keys of one: arrows move
220
+ // through what matched, Enter opens it, Escape shuts a menu before the picker.
164
221
  field.onkeydown = (e) => {
165
- if (e.key !== "Enter" || !first) return;
166
- e.preventDefault();
167
- first.click();
222
+ if (e.isComposing) return;
223
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
224
+ e.preventDefault();
225
+ closeRowMenu();
226
+ light(active + (e.key === "ArrowDown" ? 1 : -1), true);
227
+ } else if (e.key === "Enter" && rows[active]) {
228
+ e.preventDefault();
229
+ rows[active].click();
230
+ }
231
+ };
232
+ dialog.onkeydown = (e) => {
233
+ if (e.key === "Escape" && !rowMenu.hidden) {
234
+ e.preventDefault();
235
+ e.stopPropagation();
236
+ closeRowMenu();
237
+ field.focus();
238
+ }
168
239
  };
169
240
  paint();
170
241
  dialog.showModal();
171
242
  field.focus();
243
+ // Again now that it has a size: before showModal there is nothing to scroll.
244
+ light(active, true);
172
245
  }
173
246
 
174
-
175
247
  /**
176
248
  * A directory browser, for hosts with no folder dialog.
177
249
  *
@@ -753,6 +825,80 @@ if (IS_DESKTOP && window.oharness.updates) {
753
825
  // Asked once as well: a release found before this page loaded (or before a
754
826
  // reopened window) was announced to nobody.
755
827
  window.oharness.updates.state().then(paintUpdateHint, () => {});
828
+ if (window.oharness.updates.onPrompt) window.oharness.updates.onPrompt(showUpdatePrompt);
829
+ }
830
+
831
+ /**
832
+ * The update questions, asked in the app's own dialog rather than the OS's.
833
+ * The main process decides when to ask and what a button does; the page only
834
+ * words the question and hands back which button it was — 0 is the one that
835
+ * acts, and Escape answers -1, the same as Later.
836
+ */
837
+ function showUpdatePrompt(prompt) {
838
+ const kind = prompt && prompt.kind;
839
+ const vars = { version: prompt.version || "", current: prompt.current || "" };
840
+ const dialog = el("dialog", "upd-dialog");
841
+ if (prompt.important) dialog.classList.add("important");
842
+ const head = el("div", "upd-head");
843
+ head.appendChild(el("span", "upd-mark", kind === "error" ? "!" : "↑"));
844
+ const title =
845
+ kind === "download"
846
+ ? t(prompt.important ? "update.prompt.importantTitle" : "update.prompt.availableTitle", vars)
847
+ : kind === "restart"
848
+ ? t("update.prompt.readyTitle", vars)
849
+ : kind === "current"
850
+ ? t("update.prompt.currentTitle")
851
+ : kind === "error"
852
+ ? t("update.prompt.errorTitle")
853
+ : t("update.prompt.devTitle");
854
+ head.appendChild(el("div", "upd-title", title));
855
+ dialog.appendChild(head);
856
+
857
+ if (kind === "download" && prompt.notes) dialog.appendChild(el("div", "upd-notes", prompt.notes));
858
+ const body =
859
+ kind === "download"
860
+ ? t("update.prompt.availableBody", vars)
861
+ : kind === "restart"
862
+ ? t("update.prompt.readyBody")
863
+ : kind === "current"
864
+ ? t("update.prompt.currentBody", vars)
865
+ : kind === "error"
866
+ ? prompt.detail || ""
867
+ : t("update.prompt.devBody");
868
+ if (body) dialog.appendChild(el("div", kind === "error" ? "upd-body mono" : "upd-body", body));
869
+
870
+ const actions = el("div", "dlg-actions");
871
+ const finish = (response) => {
872
+ window.oharness.updates.answer(prompt.id, response);
873
+ if (dialog.open) dialog.close();
874
+ dialog.remove();
875
+ };
876
+ let main;
877
+ let focus;
878
+ if (kind === "download" || kind === "restart") {
879
+ const later = el("button", "quiet", t("update.prompt.later"));
880
+ main = el("button", "approve", t(kind === "download" ? "update.prompt.download" : "update.prompt.restart"));
881
+ later.type = main.type = "button";
882
+ later.onclick = () => finish(1);
883
+ main.onclick = () => finish(0);
884
+ actions.append(later, main);
885
+ // Restarting ends whatever is running, so Enter must not do it by accident.
886
+ focus = kind === "restart" ? later : main;
887
+ } else {
888
+ main = el("button", "approve", t("update.prompt.ok"));
889
+ main.type = "button";
890
+ main.onclick = () => finish(0);
891
+ actions.append(main);
892
+ focus = main;
893
+ }
894
+ dialog.appendChild(actions);
895
+ dialog.addEventListener("cancel", (e) => {
896
+ e.preventDefault();
897
+ finish(-1);
898
+ });
899
+ document.body.appendChild(dialog);
900
+ dialog.showModal();
901
+ focus.focus();
756
902
  }
757
903
 
758
904
  /**
@@ -785,7 +931,7 @@ async function takeDeepLink() {
785
931
  }
786
932
  if (typeof text !== "string" || !text) return;
787
933
  const draft = input.value.trim();
788
- if (draft && draft !== text && !confirm(t("deepLink.replaceDraft"))) return;
934
+ if (draft && draft !== text && !(await askConfirm({ body: t("deepLink.replaceDraft"), ok: t("dialog.replace") }))) return;
789
935
  await switchTo({ action: "new" });
790
936
  input.value = text;
791
937
  // Called rather than an input event, which would also open the command
@@ -1 +1 @@
1
- {"version":3,"file":"shell.js","sourceRoot":"","sources":["../../../src/page/client/shell.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MAy2BzB,CAAC"}
1
+ {"version":3,"file":"shell.js","sourceRoot":"","sources":["../../../src/page/client/shell.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MA2/BzB,CAAC"}
@@ -460,6 +460,10 @@ function projectHead(cwd, name, count, open) {
460
460
  closeRowMenu();
461
461
  void openTerminalIn(cwd, true);
462
462
  } }),
463
+ menuItem(t("projects.claudeCode"), { key: "c", run: () => {
464
+ closeRowMenu();
465
+ void openClaudeCode(cwd);
466
+ } }),
463
467
  menuItem(t(IS_MAC ? "projects.revealMac" : IS_WIN ? "projects.revealWin" : "projects.reveal"), { key: "f", run: () => {
464
468
  closeRowMenu();
465
469
  void api("/reveal", { path: cwd }).then((body) => {
@@ -968,11 +972,11 @@ async function showWorktreeFinish(cwd, anchor, head) {
968
972
  async function finishWorktreeUi(wt, action, name, clean) {
969
973
  const body = { path: wt.path, action };
970
974
  if (action === "commit") {
971
- const message = window.prompt(t("worktree.commitPrompt"), wt.task || "");
975
+ const message = await askText({ title: t("worktree.commitPrompt"), value: wt.task || "", ok: t("dialog.ok") });
972
976
  if (!message) return;
973
977
  body.message = message;
974
978
  }
975
- if (action === "discard" && !clean && !window.confirm(t("worktree.discardConfirm", { name }))) return;
979
+ if (action === "discard" && !clean && !(await askConfirm({ body: t("worktree.discardConfirm", { name }), ok: t("dialog.discard"), danger: true }))) return;
976
980
  const answer = await api("/worktree/finish", body, 5 * 60_000);
977
981
  if (!answer || answer.error) return void addNotice((answer && answer.error) || t("worktree.failed"), "notice err");
978
982
  addNotice(answer.result.message);
@@ -1487,6 +1491,7 @@ function renderHistory(entries, into) {
1487
1491
  if (card) assistantTurn().appendChild(card);
1488
1492
  }
1489
1493
  if (entry.shots) turnShots(assistantTurn(), entry.shots);
1494
+ if (entry.agent) agentReply(assistantTurn(), entry.agent);
1490
1495
  if (P.turn) turnActions(P.turn, entry.keepMessages);
1491
1496
  }
1492
1497
  for (const card of connectionCards.values()) {