blob: 981c5e17399c8dfb9822356bb2c2ec184ca4841a [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { spawn } from 'node:child_process';
import {
defineUiMessageCatalog,
formatUiMessage,
resolveUiMessageCatalog,
type UiLocale,
} from '@maka/core/ui-locale';
import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js';
/**
* Nightly trial switch for the fullscreen (alternate-screen) TUI, issue #4136.
*
* The fullscreen path swaps `TuiMainScreen` for `TuiAltScreen`: the composer
* and status line stay anchored to the bottom of the screen while the
* transcript scrolls in an application-owned viewport. This is a scoped
* product experiment, not a stable mode: it defaults on only for nightly
* builds and must not grow a permanent user-facing toggle until the nightly
* evidence is in (issue non-goals).
*
* Precedence, highest first:
* 1. `setting` — an explicit caller decision (`MakaPiTuiInput.tuiFullscreen`),
* used by embeddings and tests.
* 2. `override` — the `MAKA_TUI_FULLSCREEN` environment variable. `1`/`true`
* opts a release build in; `0`/`false` opts a nightly build out.
* 3. `packageVersion` — nightly default: on for `-dev.` versions (the
* Product Nightly identity in scripts/product-nightly.mjs), off otherwise.
*/
export const TUI_FULLSCREEN_ENV = 'MAKA_TUI_FULLSCREEN';
export interface TuiFullscreenResolution {
readonly setting?: boolean;
readonly override?: string;
readonly packageVersion?: string;
}
export function isNightlyPackageVersion(version: string | undefined): boolean {
if (!version) return false;
// Product Nightly versions look like `0.2.0-dev.<runNumber>.<YYYYMMDD>`;
// formal releases are always a stable product version.
return /-dev\.[1-9]\d*\.\d{8}$/u.test(version);
}
export function resolveTuiFullscreen(resolution: TuiFullscreenResolution = {}): boolean {
if (typeof resolution.setting === 'boolean') return resolution.setting;
const override = resolution.override?.trim().toLowerCase();
if (override === '1' || override === 'true') return true;
if (override === '0' || override === 'false') return false;
return isNightlyPackageVersion(resolution.packageVersion);
}
/**
* Per-frame snapshot of the transcript scroll viewport. The chrome reads it
* once per render to drive the unread indicator; the document line count comes
* from the transcript document wrapper that renders inside the scroll view.
*/
export interface TranscriptWindowSnapshot {
/** True while the scroll view is pinned to the newest content. */
readonly followingEnd: boolean;
/** Total rendered transcript document lines this frame. */
readonly documentLines: number;
}
/**
* Bridges the scroll view (which sees fresh scroll state at each frame's
* layout pass) and the anchored chrome (which the layout engine measures
* before the scroll view is laid out, so its view of scroll state lags one
* frame). The scroll view computes the unread count and compares it against
* what the chrome actually rendered, requesting one catch-up frame after any
* change so the indicator settles deterministically.
*/
export interface UnreadOutputFeed {
/** Lines appended since the user left the bottom, as of the latest layout. */
readonly current: () => number;
/** Called by the chrome each frame with the count it rendered. */
readonly present: (unreadLines: number) => void;
}
/**
* Counts transcript lines appended while the user is scrolled away from the
* bottom — the "unread / new output" signal for the anchored-composer trial.
*
* Updated once per frame with the current window snapshot: growth accumulates
* while the user is away, arriving at the bottom clears the count. Shrinks
* (collapsing tool output, re-wraps) never manufacture unread lines; the
* counter is approximate by design — it is an attention hint, not an exact
* diff.
*/
export class UnreadOutputCounter {
private lastDocumentLines: number | undefined;
private unreadLines = 0;
update(window: TranscriptWindowSnapshot): number {
if (
!window.followingEnd &&
this.lastDocumentLines !== undefined &&
window.documentLines > this.lastDocumentLines
) {
this.unreadLines += window.documentLines - this.lastDocumentLines;
}
if (window.followingEnd) this.unreadLines = 0;
this.lastDocumentLines = window.documentLines;
return this.unreadLines;
}
}
/**
* The fullscreen unread/scroll hint, keyed like the sibling pending-queue copy
* so the selected TUI locale selects the string. English carries its own
* singular/plural forms; `zh-CN` and `zh-TW` have a single CLDR plural category,
* so their entries declare only `other` and never mirror the English noun split.
*/
interface TuiUnreadIndicatorCopy {
readonly hint: string;
}
const TUI_UNREAD_INDICATOR_COPY = resolveUiMessageCatalog(
defineUiMessageCatalog<TuiUnreadIndicatorCopy>()(TUI_COPY_RESOURCES['unread-indicator']),
);
/** The rendered unread line: accent-colored, one row, empty when nothing is new. */
export function renderUnreadIndicator(
unreadLines: number,
accent: (text: string) => string,
locale: UiLocale,
): string[] {
if (unreadLines <= 0) return [];
return [
accent(formatUiMessage(TUI_UNREAD_INDICATOR_COPY[locale].hint, { count: unreadLines }, locale)),
];
}
/**
* URL schemes a model-authored OSC 8 link may be opened with, mirroring the
* desktop's external-link guard (apps/desktop/src/main/external-link-guard.ts):
* web and mail only. Assistant Markdown is rendered with the raw href, so
* everything else — `file:`, `javascript:`, unknown handlers, UNC paths —
* must never reach an OS opener from a click.
*/
const OPENABLE_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
export function isOpenableExternalUrl(url: string): boolean {
try {
return OPENABLE_URL_PROTOCOLS.has(new URL(url).protocol);
} catch {
return false;
}
}
/**
* Opens an OSC 8 hyperlink activated by a primary-button click in the
* fullscreen viewport. Model-authored hrefs are untrusted input, so the
* opener is deliberately narrow:
*
* - Only `http:`, `https:`, and `mailto:` targets are handed off at all.
* - Windows never routes the URL through cmd.exe — `spawn`'s argument
* quoting does not escape shell metacharacters (`&` would start a second
* command under `cmd /c start`), so the opener is `rundll32
* url.dll,FileProtocolHandler`, which receives the URL as a single argv
* element and hands it to ShellExecute. The DLL/entrypoint half of the
* command line is a compile-time constant, so a hostile URL cannot
* redirect it.
* - macOS/Linux openers take the URL as a plain argv element (no shell).
*
* Failures are swallowed — a dead link must never take the TUI down.
*/
/**
* Spawns a detached, fire-and-forget opener process. `spawn` reports a
* missing binary (and other spawn failures) asynchronously via the child's
* `error` event — with no listener attached, Node re-emits it as an
* uncaughtException, which the TUI's handler treats as fatal and begins
* session teardown. The child is therefore kept and its error swallowed: a
* dead link must degrade to "nothing opened", never end the session.
* Synchronous throws (invalid arguments) are swallowed here as well.
*/
function spawnDetached(
spawnProcess: typeof spawn,
command: string,
args: string[],
windowsHide = false,
): void {
try {
const child = spawnProcess(command, args, {
detached: true,
stdio: 'ignore',
...(windowsHide ? { windowsHide: true } : {}),
});
child.on('error', () => {});
child.unref();
} catch {
// Best-effort only; the terminal may also offer its own link handling.
}
}
export function openExternalUrl(
url: string,
platform: NodeJS.Platform = process.platform,
spawnProcess: typeof spawn = spawn,
): void {
if (!isOpenableExternalUrl(url)) return;
if (platform === 'darwin') {
spawnDetached(spawnProcess, 'open', [url]);
return;
}
if (platform === 'win32') {
// Never cmd.exe: `spawn`'s argument quoting does not escape shell
// metacharacters, and `cmd /c start` would let a model-authored `&`
// start a second command. rundll32 receives the URL as a single argv
// element and hands it to ShellExecute; the DLL/entrypoint half is a
// compile-time constant, so a hostile URL cannot redirect what runs.
spawnDetached(spawnProcess, 'rundll32', ['url.dll,FileProtocolHandler', url], true);
return;
}
spawnDetached(spawnProcess, 'xdg-open', [url]);
}