create-pmos 0.63.0 → 0.63.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/notes.md +8 -1
- package/workspace/lib/notes-whisper.js +73 -11
- package/workspace/lib/routes-notes.js +11 -1
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/mobile.css +17 -0
- package/workspace/public/pm/mobile.js +36 -5
- package/workspace/public/pm/tab-term.js +18 -4
- package/workspace/public/pm/view-notes.js +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.63.
|
|
3
|
+
"version": "0.63.2",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T23:17:22Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T23:17:22Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T23:17:22Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -119,6 +119,8 @@ Every row has a visible ⋯ button. Every control is at least 44 pixels high. No
|
|
|
119
119
|
|
|
120
120
|
You can have the same session open on your phone and your computer. A session has one size, so the screen you type in or tap last sets it. The other screen keeps showing the session and takes the size back as soon as you type in it.
|
|
121
121
|
|
|
122
|
+
On an iPhone or an iPad, the terminal gets smaller when the keyboard opens, so the agent's prompt stays above the keyboard. Tapping the terminal does not zoom the page, and dragging at its top or bottom does not pull or reload the page. A swipe from the left edge of the screen is Safari's Back gesture and leaves the workspace. To avoid that, add the workspace to your Home Screen and open it from there.
|
|
123
|
+
|
|
122
124
|
## The older layout
|
|
123
125
|
|
|
124
126
|
The address options `?layout=legacy` and `?fixture=cockpit` keep the older layouts. The rest of this page describes them. In the cockpit, the workspace has four columns: the repository rail, the list, the detail and the tabs. With `?layout=legacy` it has three columns: navigation, detail and tabs.
|
package/workspace/guide/notes.md
CHANGED
|
@@ -39,7 +39,14 @@ Local transcription uses whisper.cpp, a free program that runs on your machine.
|
|
|
39
39
|
|
|
40
40
|
1. Open the settings and press **Set up in a terminal**. A terminal opens with the setup commands typed in. They install whisper.cpp with Homebrew and download the model into the workspace's own folder.
|
|
41
41
|
2. Read the commands and press Enter in the terminal. The workspace does not run them for you.
|
|
42
|
-
3.
|
|
42
|
+
3. Homebrew then says that whisper.cpp needs a model file. That is expected: the next command downloads it. Keep the terminal open until it says **Whisper is set up**.
|
|
43
|
+
4. Open the settings again. It says **Whisper is set up**.
|
|
44
|
+
|
|
45
|
+
If lines come slowly, choose **Faster (about 550 MB)** under **Model** in the settings, press **Set up in a terminal** and press Enter to download it. It is the same model in a smaller form: it starts and runs faster on an M1 and makes a few more mistakes. **Most accurate (1.6 GB)** is the default.
|
|
46
|
+
|
|
47
|
+
Whisper writes in the language you choose in the settings under **Language spoken in the meeting**: English unless you change it, or Indonesian. **Detect for each line** lets Whisper guess for each few seconds of speech; it can guess wrong and write a line in another language, so choose the language when you know it.
|
|
48
|
+
|
|
49
|
+
If the download stops, press **Set up in a terminal** again and press Enter. The download continues where it stopped, and a part-downloaded model is never used.
|
|
43
50
|
|
|
44
51
|
## Use Jev for labels and checks
|
|
45
52
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* setupCommand(runtimeDir) → the shell commands the PM runs once: install whisper.cpp and download its model
|
|
6
6
|
* createTranscriber({ runtimeDir, onText, run?, detect? }) → { push, flush, pending, stopAll }
|
|
7
7
|
* wav(pcm, rate) · levels(pcm, rate) · cleanText(text)
|
|
8
|
+
* getLanguage(runtimeDir) · setLanguage(runtimeDir, code) the spoken language Whisper writes in (LANGUAGES)
|
|
9
|
+
* getModel(runtimeDir) · setModel(runtimeDir, id) the model: accurate (1.6 GB) or faster (about 550 MB) (MODELS)
|
|
8
10
|
*
|
|
9
11
|
* WHAT IT IS. The page captures the microphone and, when the PM picks it, a meeting tab's audio. It sends each source as
|
|
10
12
|
* 16 kHz mono 16-bit samples to the server on the loopback address. This module keeps a buffer per note and source, cuts it
|
|
@@ -36,8 +38,16 @@ const LEAD_MS = 300; // speech that starts a cut keeps this much
|
|
|
36
38
|
const MIN_RMS = 180; // in 16-bit units; below this a frame is quiet whatever the room
|
|
37
39
|
const MAX_QUEUE = 24;
|
|
38
40
|
const RUN_TIMEOUT_MS = 120000;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
+
const MODEL_BASE = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/';
|
|
42
|
+
// The faster model is the same one quantized to 5 bits: a third of the size, so it loads and runs faster on an M1, and it
|
|
43
|
+
// makes a few more mistakes. Every cut starts the program again, so the time to load the model is paid on every line.
|
|
44
|
+
const MODELS = Object.freeze([
|
|
45
|
+
Object.freeze({ id: 'accurate', file: 'ggml-large-v3-turbo.bin', size: '1.6 GB', label: 'Most accurate (1.6 GB)' }),
|
|
46
|
+
Object.freeze({ id: 'fast', file: 'ggml-large-v3-turbo-q5_0.bin', size: 'about 550 MB', label: 'Faster (about 550 MB)' }),
|
|
47
|
+
]);
|
|
48
|
+
const DEFAULT_MODEL = 'accurate';
|
|
49
|
+
const MODEL_FILE = MODELS[0].file;
|
|
50
|
+
const MODEL_URL = MODEL_BASE + MODEL_FILE;
|
|
41
51
|
const BIN_NAMES = ['whisper-cli', 'whisper-cpp'];
|
|
42
52
|
const BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'];
|
|
43
53
|
|
|
@@ -55,16 +65,31 @@ function findBin(env) {
|
|
|
55
65
|
function findModel(runtimeDir, env) {
|
|
56
66
|
if (env.PMOS_WHISPER_MODEL) return isFile(env.PMOS_WHISPER_MODEL) ? env.PMOS_WHISPER_MODEL : null;
|
|
57
67
|
const dir = modelDir(runtimeDir);
|
|
58
|
-
|
|
68
|
+
const chosen = modelOf(runtimeDir);
|
|
69
|
+
if (isFile(path.join(dir, chosen.file))) return path.join(dir, chosen.file);
|
|
70
|
+
// a model the PM chose and has not downloaded yet is missing, so the setup downloads it; with no choice made, any
|
|
71
|
+
// whole model in the folder will do
|
|
72
|
+
if (readSettings(runtimeDir).model) return null;
|
|
59
73
|
let names = [];
|
|
60
74
|
try { names = fs.readdirSync(dir).filter((f) => /^ggml-.+\.bin$/.test(f)).sort(); } catch { /* none */ }
|
|
61
75
|
return names.length ? path.join(dir, names[names.length - 1]) : null;
|
|
62
76
|
}
|
|
63
77
|
|
|
64
|
-
/** The commands the PM runs once, in their own terminal. The server never runs them.
|
|
78
|
+
/** The commands the PM runs once, in their own terminal. The server never runs them.
|
|
79
|
+
*
|
|
80
|
+
* Homebrew's own note after the install says that no model is downloaded; the next command is that download. It goes to a
|
|
81
|
+
* `.part` file that is renamed only when it is complete, so a stopped download never reads as ready, and `-C -` continues
|
|
82
|
+
* a stopped download when the PM runs the commands again. A program already installed is not installed again. */
|
|
65
83
|
function setupCommand(runtimeDir) {
|
|
66
84
|
const dir = modelDir(runtimeDir);
|
|
67
|
-
|
|
85
|
+
const m = modelOf(runtimeDir);
|
|
86
|
+
const file = path.join(dir, m.file);
|
|
87
|
+
return '{ command -v whisper-cli >/dev/null 2>&1 || brew install whisper-cpp; }'
|
|
88
|
+
+ ' && mkdir -p ' + shq(dir)
|
|
89
|
+
+ ' && echo ' + shq('Downloading the Whisper model (' + m.size + '). Keep this terminal open.')
|
|
90
|
+
+ ' && curl -L --fail -C - -# -o ' + shq(file + '.part') + ' ' + MODEL_BASE + m.file
|
|
91
|
+
+ ' && mv ' + shq(file + '.part') + ' ' + shq(file)
|
|
92
|
+
+ ' && echo ' + shq('Whisper is set up. Open the Notes settings again.');
|
|
68
93
|
}
|
|
69
94
|
|
|
70
95
|
/** Is local transcription ready? `missing` names what is not there: 'program', 'model'. */
|
|
@@ -74,7 +99,7 @@ function detect(runtimeDir, { env = process.env } = {}) {
|
|
|
74
99
|
const missing = [];
|
|
75
100
|
if (!bin) missing.push('program');
|
|
76
101
|
if (!model) missing.push('model');
|
|
77
|
-
return { ready: missing.length === 0, bin, model, missing, setup: setupCommand(runtimeDir), model_dir: modelDir(runtimeDir), model_size:
|
|
102
|
+
return { ready: missing.length === 0, bin, model, missing, setup: setupCommand(runtimeDir), model_dir: modelDir(runtimeDir), model_size: modelOf(runtimeDir).size };
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
/** A mono 16-bit PCM buffer as a WAV file. */
|
|
@@ -109,11 +134,48 @@ function cleanText(text) {
|
|
|
109
134
|
return t;
|
|
110
135
|
}
|
|
111
136
|
|
|
137
|
+
/* THE SPOKEN LANGUAGE. Whisper writes in the language it is told. With 'auto' it guesses the language of each cut, and on a
|
|
138
|
+
* few seconds of accented speech it can guess wrong and write the line in that language, which reads as a translation. So
|
|
139
|
+
* the PM picks the language of the meeting once (English by default) and every cut uses it. PMOS_WHISPER_LANG wins. */
|
|
140
|
+
const LANGUAGES = Object.freeze([['en', 'English'], ['id', 'Indonesian'], ['auto', 'Detect for each line']]);
|
|
141
|
+
const DEFAULT_LANGUAGE = 'en';
|
|
142
|
+
const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'whisper-settings.json');
|
|
143
|
+
function readSettings(runtimeDir) {
|
|
144
|
+
try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s && typeof s === 'object' ? s : {}; } catch { return {}; }
|
|
145
|
+
}
|
|
146
|
+
function writeSettings(runtimeDir, patch) {
|
|
147
|
+
const f = settingsFile(runtimeDir);
|
|
148
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
149
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
150
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...readSettings(runtimeDir), ...patch }), { mode: 0o600 });
|
|
151
|
+
fs.renameSync(tmp, f);
|
|
152
|
+
}
|
|
153
|
+
function getLanguage(runtimeDir, { env = process.env } = {}) {
|
|
154
|
+
if (env.PMOS_WHISPER_LANG) return env.PMOS_WHISPER_LANG;
|
|
155
|
+
const c = readSettings(runtimeDir).language;
|
|
156
|
+
return LANGUAGES.some(([k]) => k === c) ? c : DEFAULT_LANGUAGE;
|
|
157
|
+
}
|
|
158
|
+
function setLanguage(runtimeDir, code) {
|
|
159
|
+
if (!LANGUAGES.some(([k]) => k === code)) return { ok: false, error: 'the language must be one of ' + LANGUAGES.map(([k]) => k).join(', ') };
|
|
160
|
+
writeSettings(runtimeDir, { language: code });
|
|
161
|
+
return { ok: true, language: code };
|
|
162
|
+
}
|
|
163
|
+
function modelOf(runtimeDir) { const id = readSettings(runtimeDir).model; return MODELS.find((m) => m.id === id) || MODELS.find((m) => m.id === DEFAULT_MODEL); }
|
|
164
|
+
function getModel(runtimeDir) { return modelOf(runtimeDir).id; }
|
|
165
|
+
function setModel(runtimeDir, id) {
|
|
166
|
+
if (!MODELS.some((m) => m.id === id)) return { ok: false, error: 'the model must be one of ' + MODELS.map((m) => m.id).join(', ') };
|
|
167
|
+
writeSettings(runtimeDir, { model: id });
|
|
168
|
+
return { ok: true, model: id };
|
|
169
|
+
}
|
|
170
|
+
|
|
112
171
|
/** Run the program on one WAV file. Resolves { ok, text } or { ok: false, error }. */
|
|
113
|
-
function runWhisper({ bin, model, file, lang =
|
|
114
|
-
|
|
172
|
+
function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE }) {
|
|
173
|
+
// The GPU does most of the work on Apple silicon, so four threads are enough; more only compete with the browser.
|
|
174
|
+
// Greedy decoding (-bs 1 -bo 1) instead of the program's default five-way beam search makes each line several times
|
|
175
|
+
// faster for a small loss on hard words.
|
|
176
|
+
const threads = String(Math.max(2, Math.min(4, (os.cpus() || []).length || 4)));
|
|
115
177
|
return new Promise((resolve) => {
|
|
116
|
-
execFile(bin, ['-m', model, '-f', file, '-nt', '-np', '-l', lang, '-t', threads], { timeout: RUN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (e, stdout, stderr) => {
|
|
178
|
+
execFile(bin, ['-m', model, '-f', file, '-nt', '-np', '-l', lang, '-t', threads, '-bs', '1', '-bo', '1'], { timeout: RUN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (e, stdout, stderr) => {
|
|
117
179
|
if (e) resolve({ ok: false, error: e.killed ? 'the transcription took too long' : String((stderr || e.message || 'the program failed')).trim().split('\n').pop().slice(0, 200) });
|
|
118
180
|
else resolve({ ok: true, text: String(stdout || '') });
|
|
119
181
|
});
|
|
@@ -152,7 +214,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
|
|
|
152
214
|
const file = path.join(tmpDir, 'cut-' + crypto.randomBytes(6).toString('hex') + '.wav');
|
|
153
215
|
try {
|
|
154
216
|
fs.writeFileSync(file, wav(pcm), { mode: 0o600 });
|
|
155
|
-
const r = await run({ bin: d.bin, model: d.model, file });
|
|
217
|
+
const r = await run({ bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir) });
|
|
156
218
|
if (!r.ok) { onError({ ...job, error: r.error }); return; }
|
|
157
219
|
const text = cleanText(r.text);
|
|
158
220
|
if (text) await onText({ ...job, text });
|
|
@@ -197,4 +259,4 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
|
|
|
197
259
|
return { push, flush, drop, pending: () => waiting, idle: () => queue };
|
|
198
260
|
}
|
|
199
261
|
|
|
200
|
-
module.exports = { detect, setupCommand, createTranscriber, runWhisper, wav, levels, cleanText, modelDir, RATE, MODEL_FILE, MODEL_URL, MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS };
|
|
262
|
+
module.exports = { detect, setupCommand, getLanguage, setLanguage, LANGUAGES, DEFAULT_LANGUAGE, getModel, setModel, MODELS, DEFAULT_MODEL, createTranscriber, runWhisper, wav, levels, cleanText, modelDir, RATE, MODEL_FILE, MODEL_URL, MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS };
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* POST /api/notes/speaker { id, source, name } → { ok, speakers }
|
|
14
14
|
* GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
|
|
15
15
|
* POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
|
|
16
|
+
* { language?, model? } → { ok, whisper } the spoken language and the model, for this machine
|
|
16
17
|
* POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
|
|
17
18
|
* POST /api/notes/private { id, private } → { ok, private }
|
|
18
19
|
* POST /api/notes/summarize { id } → { ok, summary } the local summary
|
|
@@ -106,7 +107,9 @@ function transcriberFor(ctx) {
|
|
|
106
107
|
}
|
|
107
108
|
function engineInfo(ctx, w) {
|
|
108
109
|
const d = whisper.detect(ctx.runtimeDir);
|
|
109
|
-
const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size
|
|
110
|
+
const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
|
|
111
|
+
language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
|
|
112
|
+
model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })) };
|
|
110
113
|
if (!w) return { whisper: out, jev: null };
|
|
111
114
|
const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
|
|
112
115
|
const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
|
|
@@ -183,6 +186,13 @@ async function handle(req, res, url, ctx) {
|
|
|
183
186
|
if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
|
|
184
187
|
if (p === '/api/notes/switch') {
|
|
185
188
|
const o = wsOf();
|
|
189
|
+
// the spoken language belongs to this machine, not to a repository
|
|
190
|
+
if (body.language !== undefined || body.model !== undefined) {
|
|
191
|
+
const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
|
|
192
|
+
const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
|
|
193
|
+
if (!r.ok || !m.ok) { ctx.json(res, { ok: false, error: r.error || m.error }, 400); return true; }
|
|
194
|
+
if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
|
|
195
|
+
}
|
|
186
196
|
if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
|
|
187
197
|
jevNotes.setSwitch(dir, o.w.root, { jev: typeof body.jev === 'boolean' ? body.jev : undefined, check: typeof body.check === 'boolean' ? body.check : undefined });
|
|
188
198
|
ctx.json(res, { ok: true, ...engineInfo(ctx, o.w) });
|
package/workspace/package.json
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
border-left: 1px solid var(--border);
|
|
44
44
|
border-right: 1px solid var(--border);
|
|
45
45
|
}
|
|
46
|
+
html.pm-vv body { height: var(--fr-vvh, 100vh); }
|
|
46
47
|
|
|
47
48
|
/* Explicit placement, never auto-placement: app.js appends its root LAST, so source order would
|
|
48
49
|
* put the screen underneath the bottom nav. */
|
|
@@ -363,3 +364,19 @@
|
|
|
363
364
|
.mob[hidden], .mobmenu[hidden], .mob-back[hidden], .mob-brand[hidden] { display: none !important; }
|
|
364
365
|
.mob-repo[hidden], .mob-set[hidden], .mob-sync[hidden], .mob-theme[hidden], .mobnav-n[hidden] { display: none !important; }
|
|
365
366
|
.mob-deck[hidden], .mob-pager[hidden], .mob-deck__views[hidden] { display: none !important; }
|
|
367
|
+
|
|
368
|
+
/* ── a touch screen: the visible height, no page bounce, no zoom on focus ─────────────────────
|
|
369
|
+
* pm/mobile.js sets `pm-vv` and `--fr-vvh` (the visual viewport's height) on a touch screen. The
|
|
370
|
+
* page is that tall, so the soft keyboard sits under the terminal and does not cover it. Above the
|
|
371
|
+
* breakpoint (an iPad) that is app.js's root; at the breakpoint and below it is <body>, in the phone rules above.
|
|
372
|
+
* A drag at the top or bottom of the terminal does not pull the page or reload it. Every rule here
|
|
373
|
+
* needs `pm-vv`, so a desktop with a mouse is untouched. */
|
|
374
|
+
html.pm-vv, html.pm-vv body { overscroll-behavior: none; }
|
|
375
|
+
html.pm-vv { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
|
|
376
|
+
@media (min-width: 761px) {
|
|
377
|
+
html.pm-vv body > [data-pm-theme] { height: var(--fr-vvh, 100vh) !important; }
|
|
378
|
+
}
|
|
379
|
+
/* xterm types through a hidden textarea and gives it the terminal's 12.5px font. iOS zooms the
|
|
380
|
+
* page in on a focused field under 16px, which pushes the terminal off the side of the screen. The
|
|
381
|
+
* textarea is invisible, so its size changes nothing else. */
|
|
382
|
+
html.pm-vv .xterm .xterm-helper-textarea { font-size: 16px !important; }
|
|
@@ -475,13 +475,42 @@ function onChromeClick(e) {
|
|
|
475
475
|
|
|
476
476
|
/* The terminal has to re-measure when the soft keyboard shows or hides and after a rotation, or
|
|
477
477
|
* xterm keeps the row count it had and the bottom of the session is clipped. The fit itself belongs
|
|
478
|
-
* to the terminal panel; this only tells it that the box changed.
|
|
478
|
+
* to the terminal panel; this only tells it that the box changed. It calls the terminal's refit
|
|
479
|
+
* directly: a synthetic window `resize` also re-lays the card deck and the report slides. */
|
|
479
480
|
function refit() {
|
|
480
481
|
const t = window.pmTerm;
|
|
481
|
-
if (t && typeof t.
|
|
482
|
+
if (t && typeof t.refitVisible === 'function') { try { t.refitVisible(); return; } catch { /* fall through */ } }
|
|
482
483
|
window.dispatchEvent(new Event('resize'));
|
|
483
484
|
}
|
|
484
485
|
|
|
486
|
+
/* ── the visible height, on a touch screen ───────────────────────────────────────────────────
|
|
487
|
+
* iOS does not shrink the page when the soft keyboard opens. `100vh` stays the full screen, the
|
|
488
|
+
* keyboard covers its bottom, and that bottom is where the agent's prompt and the control keys
|
|
489
|
+
* are. Safari then scrolls the page to show the focused field, so the screen jumps. And with
|
|
490
|
+
* Safari's toolbar shown, `100vh` is already taller than what is visible.
|
|
491
|
+
*
|
|
492
|
+
* So on a touch screen the page height is the VISUAL viewport's: `--fr-vvh` on <html>, read by
|
|
493
|
+
* pm/mobile.css, with the page held at the top. A pinch zoom also changes the visual viewport, and
|
|
494
|
+
* that is not the page getting smaller, so the height is in layout pixels (height × scale). */
|
|
495
|
+
const TOUCH = window.matchMedia('(hover: none) and (pointer: coarse)');
|
|
496
|
+
let vvh = 0;
|
|
497
|
+
|
|
498
|
+
function fitViewport() {
|
|
499
|
+
const vv = window.visualViewport;
|
|
500
|
+
const root = document.documentElement;
|
|
501
|
+
if (!vv || !TOUCH.matches) {
|
|
502
|
+
if (root.classList.contains('pm-vv')) { root.classList.remove('pm-vv'); vvh = 0; refit(); }
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
root.classList.add('pm-vv');
|
|
506
|
+
if (window.scrollY || window.scrollX) window.scrollTo(0, 0);
|
|
507
|
+
const h = Math.round(vv.height * (vv.scale || 1));
|
|
508
|
+
if (!h || h === vvh) return;
|
|
509
|
+
vvh = h;
|
|
510
|
+
root.style.setProperty('--fr-vvh', h + 'px');
|
|
511
|
+
refit();
|
|
512
|
+
}
|
|
513
|
+
|
|
485
514
|
/* ── mount ──────────────────────────────────────────────────────────────────────────────────── */
|
|
486
515
|
|
|
487
516
|
/**
|
|
@@ -515,10 +544,12 @@ export function mountMobile(context) {
|
|
|
515
544
|
document.addEventListener('click', onChromeClick);
|
|
516
545
|
|
|
517
546
|
if (window.visualViewport) {
|
|
518
|
-
window.visualViewport.addEventListener('resize',
|
|
519
|
-
window.visualViewport.addEventListener('scroll',
|
|
547
|
+
window.visualViewport.addEventListener('resize', fitViewport);
|
|
548
|
+
window.visualViewport.addEventListener('scroll', fitViewport);
|
|
520
549
|
}
|
|
521
|
-
|
|
550
|
+
if (TOUCH.addEventListener) TOUCH.addEventListener('change', fitViewport);
|
|
551
|
+
window.addEventListener('orientationchange', () => setTimeout(() => { fitViewport(); refit(); }, 250));
|
|
552
|
+
fitViewport();
|
|
522
553
|
|
|
523
554
|
// Crossing the breakpoint (rotate a tablet, drag a window narrow) has to re-decide whether the
|
|
524
555
|
// phone shell exists at all AND repaint the panels — a shell left mounted above 760px is a second
|
|
@@ -699,6 +699,18 @@ function syncSize(rec, claim) {
|
|
|
699
699
|
if (send({ t: 'resize', id: rec.sessionId, cols, rows })) { rec.sentCols = cols; rec.sentRows = rows; }
|
|
700
700
|
}
|
|
701
701
|
|
|
702
|
+
/**
|
|
703
|
+
* State a grid that a refit changed, once the box stops moving. A phone's soft keyboard and a
|
|
704
|
+
* rotation move the box several times in a few hundred milliseconds, and every grid the PTY hears
|
|
705
|
+
* makes the agent redraw its whole frame. Typing still sends at once: `onData` calls syncSize
|
|
706
|
+
* before the keystroke, which states the latest grid and makes the queued call a no-op.
|
|
707
|
+
*/
|
|
708
|
+
const SIZE_SETTLE_MS = 120;
|
|
709
|
+
function queueSize(rec) {
|
|
710
|
+
clearTimeout(rec.sizeTimer);
|
|
711
|
+
rec.sizeTimer = setTimeout(() => { rec.sizeTimer = null; syncSize(rec, true); }, SIZE_SETTLE_MS);
|
|
712
|
+
}
|
|
713
|
+
|
|
702
714
|
/** Write a status line into the terminal itself — once per distinct message, dimmed. */
|
|
703
715
|
function note(rec, line, force) {
|
|
704
716
|
if (!rec.term) return;
|
|
@@ -726,8 +738,9 @@ export function refitVisible() {
|
|
|
726
738
|
try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
|
|
727
739
|
// …and state the resulting grid to the PTY. `fit()` only fires xterm's `onResize` when the
|
|
728
740
|
// grid MOVES, so a pane that was measured before it had a session would otherwise never tell
|
|
729
|
-
// the shell how wide it is. See the `attached` handler.
|
|
730
|
-
|
|
741
|
+
// the shell how wide it is. See the `attached` handler. A grid change the fit just queued
|
|
742
|
+
// (see queueSize) goes out when the box settles, not here.
|
|
743
|
+
if (!rec.sizeTimer) syncSize(rec);
|
|
731
744
|
}
|
|
732
745
|
};
|
|
733
746
|
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
|
|
@@ -891,12 +904,12 @@ function buildReal(tab) {
|
|
|
891
904
|
term.attachCustomKeyEventHandler((e) => !((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')));
|
|
892
905
|
|
|
893
906
|
const rec = {
|
|
894
|
-
kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null,
|
|
907
|
+
kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null, sizeTimer: null,
|
|
895
908
|
};
|
|
896
909
|
// Typing, focusing or re-sizing THIS pane takes the session's grid back from another screen, and
|
|
897
910
|
// the grid goes out before the keystroke so the agent answers it at this pane's size.
|
|
898
911
|
term.onData((d) => { if (rec.sessionId) { syncSize(rec, true); send({ t: 'input', id: rec.sessionId, data: d }); } });
|
|
899
|
-
term.onResize(() =>
|
|
912
|
+
term.onResize(() => queueSize(rec));
|
|
900
913
|
host.addEventListener('focusin', () => syncSize(rec, true));
|
|
901
914
|
if (typeof ResizeObserver === 'function') {
|
|
902
915
|
rec.ro = new ResizeObserver(() => refitVisible());
|
|
@@ -951,6 +964,7 @@ export function disposeTerm(tab) {
|
|
|
951
964
|
* pane for the id and detaches from the orphan instead. */
|
|
952
965
|
if (rec.kind !== 'real') return;
|
|
953
966
|
if (rec.ro) { try { rec.ro.disconnect(); } catch { /* ignore */ } }
|
|
967
|
+
clearTimeout(rec.sizeTimer);
|
|
954
968
|
if (rec.sessionId) {
|
|
955
969
|
send({ t: 'detach', id: rec.sessionId });
|
|
956
970
|
SOCK.attached.delete(rec.sessionId);
|
|
@@ -504,8 +504,8 @@ async function chooseSources(ctx, n) {
|
|
|
504
504
|
}
|
|
505
505
|
async function setSwitch(ctx, patch) {
|
|
506
506
|
const ws = wsOf(ctx);
|
|
507
|
-
if (!ws) return;
|
|
508
|
-
const r = await pmPost('/api/notes/switch', { ws, ...patch });
|
|
507
|
+
if (!ws && patch.language === undefined && patch.model === undefined) return;
|
|
508
|
+
const r = await pmPost('/api/notes/switch', ws ? { ws, ...patch } : patch);
|
|
509
509
|
if (r && r.ok) engine = { ws, whisper: r.whisper, jev: r.jev, at: Date.now() };
|
|
510
510
|
ctx.setState({});
|
|
511
511
|
return r;
|
|
@@ -527,7 +527,11 @@ function openSettings(ctx, n) {
|
|
|
527
527
|
el('div', { class: 'nt-set__head' }, 'Local transcription'),
|
|
528
528
|
el('p', { class: 'nt-hint' }, !w ? 'Reading …' : w.ready ? 'Whisper is set up. The microphone and the meeting are transcribed on this machine.'
|
|
529
529
|
: 'Whisper is not set up (' + w.missing.map((m) => (m === 'program' ? 'the program' : 'the model, ' + w.model_size)).join(' and ') + ' missing). The setup installs whisper.cpp with Homebrew and downloads the model.'),
|
|
530
|
-
w &&
|
|
530
|
+
w && w.models ? field({ label: 'Model', kind: 'select', value: w.model, options: w.models,
|
|
531
|
+
onCommit: (v) => setSwitch(ctx, { model: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-model': '' } }) : null,
|
|
532
|
+
w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null,
|
|
533
|
+
w && w.ready && w.languages ? field({ label: 'Language spoken in the meeting', kind: 'select', value: w.language, options: w.languages,
|
|
534
|
+
onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null));
|
|
531
535
|
if (!wsOf(ctx)) parts.push(el('p', { class: 'nt-hint' }, 'Choose a repository to check notes against it.'));
|
|
532
536
|
else {
|
|
533
537
|
const jsw = field({ label: 'Label lines with Jev', kind: 'check', value: !!(j && j.jev), onCommit: (v) => setSwitch(ctx, { jev: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-jev': '' } });
|