blob: e6d2ff1211f75220d9b7485beba1226d93c3730c [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 { clearLine, createInterface, cursorTo, type Interface } from 'node:readline';
import { stripVTControlCharacters } from 'node:util';
import type { Readable, Writable } from 'node:stream';
import type { UiLocale } from '@maka/core/ui-locale';
import {
formatHostHandoff,
type HostHandoffView,
type OpenHostHandoffSurface,
} from '@maka/runtime-host/client';
/** The surface owns terminal input; replacement policy stays in the shared handoff. */
export function createCliHostHandoffSurface(
locale: UiLocale,
input: Readable = process.stdin,
output: Writable & { isTTY?: boolean } = process.stderr,
): OpenHostHandoffSurface {
return (submit) => {
let readline: Interface | undefined;
let current: HostHandoffView | undefined;
let closed = false;
let ended = false;
const releaseReader = () => {
const previous = readline;
readline = undefined;
previous?.close();
};
const cancel = () => {
if (current) submit(current.revision, 'cancel');
};
return {
update(view) {
if (closed || current?.revision === view.revision) return;
current = view;
if (ended) {
cancel();
return;
}
// A new observation gets a new input buffer. Ctrl+U leaves text after
// the cursor intact and is ignored by readline in TERM=dumb.
releaseReader();
if (output.isTTY) {
clearLine(output, 0);
cursorTo(output, 0);
}
const copy = formatHostHandoff(view, locale);
const text = [copy.title, copy.description, copy.detail, view.diagnostic]
.filter(Boolean)
.join('\n');
output.write(
'\n' +
stripVTControlCharacters(text).replace(/[\p{Cc}\p{Cf}]/gu, (character) =>
character === '\n' ? character : '\uFFFD',
) +
'\n',
);
if (view.actions.length === 0) return;
const options = copy.actions.map(
({ action, label }) =>
`${action === 'interrupt' ? 'stop' : action === 'retry' ? 'r' : action === 'replace' ? 'update' : 'Enter'}: ${label}`,
);
// Create the reader only after the explanation is visible, with the
// final action prompt already installed. Never draw readline's default >.
const reader = createInterface({
input,
output,
terminal: output.isTTY === true,
prompt: options.join(' ยท ') + ' > ',
});
readline = reader;
reader.on('SIGINT', cancel);
reader.on('close', () => {
if (readline !== reader) return;
ended = true;
if (!closed) cancel();
});
reader.on('line', (line) => {
if (readline !== reader || current?.revision !== view.revision) return;
const answer = line.trim().toLowerCase();
const action =
answer === 'update'
? 'replace'
: answer === 'r'
? 'retry'
: answer === 'stop'
? 'interrupt'
: answer === ''
? 'cancel'
: undefined;
if (!action || !view.actions.includes(action)) {
reader.prompt();
return;
}
submit(view.revision, action);
});
reader.prompt();
},
close() {
closed = true;
releaseReader();
},
};
};
}