You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by "UncertaintyDeterminesYou4ndMe (via GitHub)" <gi...@apache.org> on 2026/10/18 02:17:59 UTC
[PR] feat(desktop): add a Terminal tool to the Workbar [maka]
UncertaintyDeterminesYou4ndMe opened a new pull request, #6146:
URL: https://github.com/apache/maka/pull/6146
## Summary
The Workbar's third tool, **Terminal** (`ctrl-\``), in the desktop (`products/maka/desktop-shell`), on the workspace `terminal` endpoint (`coordinatorTerminal` in WorkHub's conversation). Built on #6144; until #6143 and #6144 merge, this PR also shows their commits.
- **Tabs**: one per terminal of the conversation, as `watchList` lists them, with a running or ended dot. The agent's PTYs show their command and an Agent badge; shells started here are numbered. "+" starts a shell in the task folder at the pane's grid and selects it. Stop, after a confirmation, is offered on this conversation's running terminals.
- **Output**: a native component (`TerminalState`/`Terminal` in `maka-desktop`, ported from my earlier GPUI client's alacritty-based terminal) parses the Host's base64 output off the UI thread; the script never decodes bytes. The terminal shown is painted from the Host's retained output when it still has the start, otherwise from its screen (`read`), then followed with `watchOutput`. After a `gap` it walks the output to the newest event, reads and paints the screen and follows from there; while gaps keep coming it waits 100 ms more each time, up to 500 ms. A run without a PTY shows its output read only.
- **Typing**: typing or a paste acquires the controller, and in a shell started here a click does too. Input goes in `control` calls of at most 64 actions, one call at a time; a shell started here follows the pane's size. The agent's PTYs are taken only by typing and keep their size, because while a user holds the controller the agent's own writes fail: a bar says so, with Release. The controller is released when the tool hides, another terminal is selected, the Workbar closes, the conversation changes or following the output fails (input could not be shown then, and the agent would stay blocked). A refused acquire shows the Host's reason, with Try again only where asking again can help.
- **Keys**: a focused terminal gets every key without the platform's command key (F2, Ctrl-Shift-G and, off macOS, the Ctrl shortcuts no longer reach the app from inside it); Cmd shortcuts on macOS and Ctrl-` (which hides it) still work. Mouse reports go to a program that asked for SGR mouse mode, the one encoding the Host takes; otherwise a click selects and the wheel scrolls the scrollback. Cmd-C copies a selection, and output does not move a view scrolled up ("Jump to bottom").
- **Bridge fix**: `maka-host`'s `next()` dropped the value a subscription finished with. The terminal methods finish with an outcome (`{kind:'failed', message}` for a refused acquire or a failed watch), so `next()` now returns a non-null final result once, then null. The only other subscription that finishes with a value, `watchFiles`, repeats an `unavailable` item that Files already stops at.
Refs #6078
### What the Host limits
Reported in #6078 (HOST_COMMENT):
- On macOS a PTY read returns about 1 KiB, and the output ring keeps 8 events, so it holds about 2.5 ms of fast output. The desktop's follower goes through the script and falls behind on any fast stream; it then repaints from `read` a few times a second instead of scrolling every line (`seq 1 300000`: 4 gaps in 3.2 s, about 55 ms per recovery).
- `read` returns the screen as plain text without an output sequence, so a repaint after a gap loses colours and soft wraps until the program redraws, and cannot join the output at an exact byte.
- `watchList` sends a full page on every PTY read. The tool pulls pages as they come, keeps the latest and applies it at most every 150 ms.
## Verification
CI does not run for pull requests into `rust-preview`; run locally on macOS (Apple silicon):
- `cargo fmt --all -- --check`, Biome format and lint, `node scripts/asf-license-headers.mjs check`, `node scripts/rust/notices.mjs check` (Node 24) on each commit.
- `cargo clippy --locked -p maka-desktop-shell --all-targets -- -D warnings` on each commit.
- `cargo test -p maka-desktop-shell`: the input mapping (keys, IME, paste pieces, mouse), when the mouse, the wheel and Shift-Page go to the program, the emulator fed with known bytes, size limits, painting a snapshot with the alternate screen, box drawing geometry, contrast, the keymap.
A driver script (not part of this change) ran the window against a Host built at 8a224fe3b with a fake model whose turn starts a PTY: starting, typing into and stopping a shell; colours; the agent's PTY live, clicked without taking it, typed into, released; refusals by another controller and by an ended PTY; a list and a follow failing and retried; starting a shell while another streams; F2 and Ctrl-Shift-G reaching `cat -v`; resize; release on hide; switching conversations closing every subscription; WorkHub; `seq 1 300000`; in English and Simplified Chinese, light and dark. The earlier drivers for the Workbar, Find, Search, WorkHub, the transcript rows and the window chrome still pass with the bridge change.
## AI use
- [ ] No generative tool made a substantive contribution
- [x] Generative tooling made a substantive contribution
Tool(s) and scope: Claude Code (Claude Opus) wrote the code, the driver script and this description, under my direction and review. Every commit carries a `Generated-by: Claude Code` trailer.
## Checklist
- [x] Tests cover the change and fail without it (the native component and the bridge; the script is checked by the out-of-tree driver)
- [x] Lint, format and the affected suites pass locally
Does this PR entail a change in behavior?
- [x] Yes — described under Summary above
- [ ] No
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(desktop): add a Terminal tool to the Workbar [maka]
Posted by "M4n5ter (via GitHub)" <gi...@apache.org>.
M4n5ter merged PR #6146:
URL: https://github.com/apache/maka/pull/6146
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(desktop): add a Terminal tool to the Workbar [maka]
Posted by "UncertaintyDeterminesYou4ndMe (via GitHub)" <gi...@apache.org>.
UncertaintyDeterminesYou4ndMe commented on PR #6146:
URL: https://github.com/apache/maka/pull/6146#issuecomment-6272878290
Rebased onto rust-preview 6292e0674, with #6143 and #6144 below it.
- **The P3 is fixed** in b5f6a4c26: text an input method commits now goes to the controller in pieces of at most 8 KiB, split on character boundaries, as a paste already did. A test splits 6,000 "中" (18,000 bytes) into three pieces.
- **New since the last review:** the terminal follows the Host's complete replay (M4n5ter's 26739b141 and 3804a229e): `watchOutput` from 0, reset once to `initialSize`; the gap recovery and the list pacing are removed. A terminal that ends while the tool is hidden is repainted from `read` when shown again, and one already followed to its end is not followed again. The description is updated.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org
Re: [PR] feat(desktop): add a Terminal tool to the Workbar [maka]
Posted by "Astro-Han (via GitHub)" <gi...@apache.org>.
Astro-Han commented on code in PR #6146:
URL: https://github.com/apache/maka/pull/6146#discussion_r4305527738
##########
products/maka/desktop-shell/app/terminal.js:
##########
@@ -0,0 +1,1428 @@
+/*
+ * 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.
+ */
+
+// The Workbar's Terminal tool: the conversation's terminals, the PTYs the
+// agent started in the background and shells started here, one shown at a
+// time in `maka-desktop`'s native Terminal. The Host's `terminal` endpoint
+// (WorkHub's conversation: `coordinatorTerminal`) answers every call with an
+// outcome, `{ kind: 'completed', value }` or `{ kind: 'failed', message }`.
+//
+// The tabs follow `watchList`, which lists the terminals again on every
+// change to one, its output included: a page each time a shell prints. Every
+// page is pulled as it comes, only the latest is kept, and it is applied at
+// most every LIST_PACE ms; the tool is redrawn only when what the tabs show
+// changed. The terminal shown is painted, then followed
+// with `watchOutput`: when the Host still holds its output from the start,
+// it is replayed from there; otherwise the Host's screen (`read`) is painted
+// and the output followed from where that screen is. The Host's ring holds
+// only its last 8 events and `read` carries no output sequence, so the
+// output since a `gap` cannot be joined exactly: the output is walked with
+// `readOutput` to the newest event first, then the screen is read and
+// painted, then the output is followed from that event. Only what arrived
+// between the walk and the read can show twice. The output goes to the
+// native component as the Host's base64, unread here.
+//
+// Typing or a paste in the terminal acquires its controller (`acquire`), and
+// in a shell started here a press does too; input then goes in `control`
+// calls with consecutive sequences, at most 64 actions each and one at a
+// time. A shell started here also gets the grid the element holds as the
+// PTY's size. The agent's terminals are taken only by typing, keep the size
+// the agent gave them, and say so while held, with Release: holding one keeps
+// the agent from typing into it. The controller is released when the
+// terminal hides, another is selected, the Workbar closes or the
+// conversation changes. The Host offers no takeover: while another client
+// holds a terminal it is view only, with the Host's reason, and Try again
+// where asking again can help.
+// Everything the tool opened (watches, acquire subscriptions, controllers)
+// closes when it hides; the terminals' screens stay, to show at once when it
+// shows again, until the conversation changes.
+
+import { div } from 'gpui-kit';
+import { v_flex, h_flex } from 'gpui-base';
+import { Buffer } from 'buffer';
+import { TerminalState, Terminal } from 'maka-desktop';
+import { query, subscribe, next, close, operation, log } from './host.js';
+import {
+ palette,
+ terminalColors,
+ typeset,
+ text,
+ icon,
+ ghostButton,
+ iconButton,
+ controlButton,
+} from './theme.js';
+import { t } from './locale.js';
+
+/** The most actions in one `control` call (the Host's limit). */
+const MAX_ACTIONS = 64;
+
+/** The most text in one `control` call: the Host takes 32 KiB a call. */
+const MAX_TEXT_BYTES = 24 * 1024;
+
+/** The most `readOutput` steps a walk to the newest event takes: the ring holds 8. */
+const WALK_LIMIT = 32;
+
+/** The size a shell starts at before any terminal has measured its pane. */
+const DEFAULT_GRID = { cols: 80, rows: 24 };
+
+/**
+ * The wait before following again after a `gap`, in ms: none after the
+ * first, then from `first` doubling up to `max` while each comes within
+ * `quiet` of following again, and none again after `quiet` without one.
+ */
+const BACKOFF = { first: 100, max: 500, quiet: 2000 };
+
+/**
+ * The least time between two listings applied, in ms. The Host lists the
+ * terminals again whenever one prints, so a busy shell would otherwise have
+ * the list sorted and compared hundreds of times a second. Pages are still
+ * pulled as they come: the Host holds the next back until the last is
+ * pulled, so pulling at this pace left the tabs seconds behind.
+ */
+const LIST_PACE = 150;
+
+/** How long a shell started here stays selected before a page lists it, in ms. */
+const START_LISTED = 5000;
+
+/** The Host's refusal while another client holds the controller (its controller.rs). */
+const TAKEN = 'Terminal already has a connected controller';
+
+/**
+ * The Host's refusals that asking again can get past: the controller free
+ * again, the terminal ready, or a call cut short made again. Any other (an
+ * archived Session, a PTY started under other permissions, one that ended)
+ * stays until the terminal is shown again.
+ */
+const PASSING = new Set([
+ TAKEN,
+ 'Terminal is not ready',
+ 'Terminal acquire cancelled',
+ 'Terminal input cancelled before admission',
+ 'Terminal controller is not held by this caller',
+ 'Terminal controller is held by another caller or subscription',
+ 'Terminal controller subscription ended',
+ 'Controller sequence is out of order',
+]);
+
+/** A refusal as the notice says it: the Host's words, but the tool's for another controller. */
+function refusalOf(message, retry = PASSING.has(message)) {
+ return { text: message === TAKEN ? t('terminalTaken') : message, retry };
+}
+
+/** The subscriptions open now, every conversation's. */
+const SUBSCRIPTIONS = new Set();
+
+/** How many terminal subscriptions are open (watches and acquires), for the dev driver. */
+export function openTerminalSubscriptions() {
+ return SUBSCRIPTIONS.size;
+}
+
+/**
+ * Closed TerminalStates, to use again. A state lives as long as the
+ * application, so a conversation that leaves closes its terminals' states
+ * (their emulators and scrollback go) and the next one takes them from here.
+ */
+const POOL = [];
+
+function takeState() {
+ return POOL.pop() ?? TerminalState();
+}
+
+function giveBack(state) {
+ state.close();
+ POOL.push(state);
+}
+
+/** The value of a terminal method's outcome; a failure throws its message. */
+function unwrap(outcome) {
+ if (outcome?.kind === 'completed') return outcome.value;
+ throw new Error(outcome?.message ?? `unexpected answer ${JSON.stringify(outcome).slice(0, 200)}`);
+}
+
+function untrack(handle) {
+ if (handle === null) return;
+ close(handle)
+ .catch(() => {})
+ .finally(() => SUBSCRIPTIONS.delete(handle));
+}
+
+/** Milliseconds, as finely as the engine tells them. */
+const now = typeof globalThis.performance?.now === 'function' ? () => performance.now() : Date.now;
+
+const RUNNING = new Set(['starting', 'running']);
+const FAILED = new Set(['failed', 'timed_out', 'orphaned']);
+
+/**
+ * One terminal: its resource as last listed, the TerminalState it shows in
+ * while it has one, how far its output has been applied, and its
+ * controller.
+ */
+class Term {
+ constructor(owner, entry) {
+ this.owner = owner;
+ this.ref = entry.result.ref;
+ /** The ResourceUpdate as last listed or read. */
+ this.entry = entry;
+ this.state = null;
+ /** The last output sequence applied to `state`, and whether it has been painted at all. */
+ this.after = 0;
+ this.painted = false;
+ /** A pipes run's output as last painted: the listed resource carries none. */
+ this.pipes = null;
+ /** The PTY's size as `state` has it, and the grid the element holds. */
+ this.size = null;
+ this.grid = null;
+ /** Whether the element shows the bottom of the output. */
+ this.atBottom = true;
+ /** 'idle', 'loading', 'live', 'ended' (closed), 'unavailable' (not retained) or 'failed'. */
+ this.live = 'idle';
+ this.problem = null;
+ /** The output follow running now: `{ closed, handle }`, or null. */
+ this.follow = null;
+ /** When the output was last followed again after a gap, and the wait before the next. */
+ this.followedAt = 0;
+ this.backoff = 0;
+ /** The controller: `{ handle, closed, held, controllerId, next }`, or null. */
+ this.lease = null;
+ /** 'none', 'acquiring', 'held', or 'refused' (view only, `refusal` says why). */
+ this.control = 'none';
+ /** Why it is view only: `{ text, retry }`, retry when asking again can help. */
+ this.refusal = null;
+ /** Actions waiting for the controller or for the call before them. */
+ this.queue = [];
+ this.sending = false;
+ /** The size last asked of the PTY while held. */
+ this.sentSize = null;
+ /** The script's share of following the output, for the dev driver. */
+ this.stats = { events: 0, bytes: 0, ms: 0, gaps: 0, recoveries: 0, recoverMs: 0, waited: 0 };
+ }
+
+ target() {
+ return { sessionId: this.owner.sessionId, ref: this.ref };
+ }
+
+ get endpoint() {
+ return this.owner.endpoint;
+ }
+
+ mode() {
+ return this.entry.result.mode;
+ }
+
+ running() {
+ return RUNNING.has(this.entry.result.status);
+ }
+
+ /** Started by the agent's Shell, not by `start`: a shell started here is its own source. */
+ agents() {
+ return this.entry.sourceTurnId !== this.entry.sourceToolCallId;
+ }
+
+ /** Whether this conversation owns it; one shared from another is read only. */
+ local() {
+ return this.entry.ownership?.kind === 'local';
+ }
+
+ /** Whether typing here may reach it. */
+ controllable() {
+ return this.mode() === 'pty' && this.running() && this.local();
+ }
+
+ update(entry) {
+ this.entry = entry;
+ // An ended process takes its controller with it.
+ if (!this.running() && this.control === 'refused') this.control = 'none';
+ }
+
+ touch() {
+ this.owner.touch();
+ }
+
+ /** Starts showing: paints and follows the output. Called while it is selected and the tool shows. */
+ show() {
+ if (this.follow !== null || this.owner.closed) return;
+ if (this.live === 'failed') {
+ this.live = 'idle';
+ this.problem = null;
+ }
+ if (this.state === null) {
+ this.state = takeState();
+ this.after = 0;
+ this.painted = false;
+ this.size = null;
+ this.atBottom = true;
+ }
+ const run = { closed: false, handle: null };
+ this.follow = run;
+ if (this.mode() === 'pty') this.followPty(run);
+ else this.followPipes(run);
+ }
+
+ /** Stops following and releases the controller; the screen stays. */
+ hide() {
+ const run = this.follow;
+ if (run !== null) {
+ this.follow = null;
+ run.closed = true;
+ untrack(run.handle);
+ run.handle = null;
+ }
+ this.release();
+ if (this.control === 'refused') this.control = 'none';
+ }
+
+ /** Lets the screen go too: the conversation is leaving. */
+ dispose() {
+ this.hide();
+ if (this.state !== null) {
+ giveBack(this.state);
+ this.state = null;
+ }
+ }
+
+ /**
+ * Paints and follows a PTY's output until hidden, or until it ends
+ * (`closed`), the Host lets its output go (`unavailable`) or a call fails.
+ */
+ async followPty(run) {
+ try {
+ let after = this.after;
+ if (!this.painted) {
+ this.live = 'loading';
+ const first = unwrap(
+ await query(this.endpoint, 'readOutput', { target: this.target(), after: 0 }),
+ );
+ if (run.closed) return;
+ if (first.kind === 'gap') {
+ after = await this.recover(run, first.firstAvailable - 1);
+ if (after === null) return;
+ } else if (first.kind === 'unavailable') {
+ await this.paintRead(run);
+ if (run.closed) return;
+ this.live = 'unavailable';
+ this.touch();
+ return;
+ } else {
+ // Everything it wrote is still held: replay it from an empty screen.
+ const size = first.size ?? DEFAULT_GRID;
+ this.state.reset(size.cols, size.rows);
+ this.size = size;
+ this.after = 0;
+ this.painted = true;
+ after = 0;
+ }
+ }
+ for (;;) {
+ const handle = await subscribe(this.endpoint, 'watchOutput', {
+ target: this.target(),
+ after,
+ });
+ if (run.closed) {
+ close(handle).catch(() => {});
+ return;
+ }
+ run.handle = handle;
+ SUBSCRIPTIONS.add(handle);
+ if (this.live !== 'live') {
+ this.live = 'live';
+ this.touch();
+ }
+ let end = null;
+ for (;;) {
+ const item = await next(handle);
+ if (run.closed) return;
+ if (item === null) {
+ end = { kind: 'finished' };
+ break;
+ }
+ const started = now();
+ const event = unwrap(item);
+ if (event.kind === 'data') {
+ this.apply(event);
+ this.stats.events += 1;
+ this.stats.bytes += event.dataBase64.length;
+ this.stats.ms += now() - started;
+ } else if (event.kind === 'resize') {
+ this.resizeTo(event.size);
+ this.after = event.sequence;
+ } else if (event.kind === 'pending') {
+ this.resizeTo(event.size);
+ } else {
+ end = event;
+ break;
+ }
+ }
+ run.handle = null;
+ untrack(handle);
+ if (end.kind === 'gap') {
+ this.stats.gaps += 1;
+ await this.pause(run);
+ if (run.closed) return;
+ after = await this.recover(run, end.firstAvailable - 1);
+ if (after === null) return;
+ continue;
+ }
+ if (end.kind === 'closed') {
+ this.resizeTo(end.size);
+ this.live = 'ended';
+ } else if (end.kind === 'unavailable') {
+ if (!this.painted) await this.paintRead(run);
+ if (run.closed) return;
+ this.live = 'unavailable';
+ } else {
+ // The Host ended the watch without saying why.
+ this.failed(t('terminalWatchEnded'));
+ }
+ log(`terminal: ${this.ref} output ${this.live} after ${this.after}`);
+ this.touch();
+ return;
+ }
+ } catch (error) {
+ if (run.closed) return;
+ log(`terminal: following ${this.ref} failed: ${error.message}`);
+ this.failed(error.message);
+ } finally {
+ if (run.handle !== null) {
+ untrack(run.handle);
+ run.handle = null;
+ }
+ if (this.follow === run && this.live !== 'live' && this.live !== 'loading') {
+ this.follow = null;
+ }
+ }
+ }
+
+ /**
+ * Following the output failed (its watch, or a read to join it again):
+ * says so, with Retry, and releases the controller. Input is not taken
+ * while the output is not followed, so a held controller would only keep
+ * the agent from typing into its own terminal; after Retry, typing takes
+ * it again.
+ */
+ failed(message) {
+ this.live = 'failed';
+ this.problem = message;
+ this.release();
+ this.touch();
+ }
+
+ /** One `data` event: at the PTY's size it was written at, after everything before it. */
+ apply(event) {
+ if (event.sequence <= this.after) return;
+ this.resizeTo(event.size);
+ this.state.feed(event.dataBase64);
+ this.after = event.sequence;
+ }
+
+ resizeTo(size) {
+ if (size === undefined || size === null) return;
+ if (this.size?.cols === size.cols && this.size?.rows === size.rows) return;
+ this.size = { cols: size.cols, rows: size.rows };
+ this.state.resize(size.cols, size.rows);
+ }
+
+ /**
+ * After a gap that came soon after following again, waits a little longer
+ * each time (up to BACKOFF.max, and there while gaps keep coming): output
+ * faster than it can be followed is shown as the Host's screen every so
+ * often instead of one recovery after another. The time is counted from
+ * following again, so neither the wait nor the recovery counts as quiet.
+ */
+ async pause(run) {
+ const quiet = Date.now() - this.followedAt >= BACKOFF.quiet;
+ this.backoff = quiet ? 0 : Math.min(BACKOFF.max, Math.max(BACKOFF.first, this.backoff * 2));
+ if (this.backoff > 0 && !run.closed) {
+ this.stats.waited += this.backoff;
+ await this.owner.cx.sleep(this.backoff);
+ }
+ }
+
+ /**
+ * Joins the output again after a gap (or on first showing one whose start
+ * is gone): walks `readOutput` from `from` to the newest event, then
+ * paints the Host's screen, and returns the sequence to follow from; null
+ * when hidden meanwhile. A walk that does not reach the newest event
+ * (output faster than the walk) follows from where it stopped.
+ */
+ async recover(run, from) {
+ const started = now();
+ let after = from;
+ for (let step = 0; step < WALK_LIMIT; step += 1) {
+ const event = unwrap(
+ await query(this.endpoint, 'readOutput', { target: this.target(), after }),
+ );
+ if (run.closed) return null;
+ if (event.kind === 'data' || event.kind === 'resize') after = event.sequence;
+ else if (event.kind === 'gap') after = event.firstAvailable - 1;
+ else break;
+ }
+ await this.paintRead(run);
+ if (run.closed) return null;
+ this.after = after;
+ this.followedAt = Date.now();
+ this.stats.recoveries += 1;
+ this.stats.recoverMs += now() - started;
+ log(`terminal: ${this.ref} painted from the Host's screen; following from ${after}`);
+ return after;
+ }
+
+ /** Paints the Host's screen of it (`read`). */
+ async paintRead(run) {
+ const resource = unwrap(await query(this.endpoint, 'read', this.target()));
+ if (run.closed) return;
+ this.update(resource);
+ const output = resource.result.output;
+ if (output?.mode !== 'pty') return;
+ this.state.paint(output);
+ this.size = { cols: output.cols, rows: output.rows };
+ this.painted = true;
+ }
+
+ /**
+ * A background run without a PTY: its output as the Host keeps it, the
+ * tail of stdout and stderr, painted again on each change (`watch`).
+ */
+ async followPipes(run) {
+ try {
+ const handle = await subscribe(this.endpoint, 'watch', this.target());
+ if (run.closed) {
+ close(handle).catch(() => {});
+ return;
+ }
+ run.handle = handle;
+ SUBSCRIPTIONS.add(handle);
+ this.live = 'live';
+ for (;;) {
+ const item = await next(handle);
+ if (run.closed) return;
+ if (item === null) break;
+ const resource = unwrap(item);
+ this.update(resource);
+ this.paintPipes(resource.result.output);
+ this.touch();
+ }
+ this.live = 'ended';
+ this.touch();
+ } catch (error) {
+ if (run.closed) return;
+ this.failed(error.message);
+ } finally {
+ if (run.handle !== null) {
+ untrack(run.handle);
+ run.handle = null;
+ }
+ if (this.follow === run) this.follow = null;
+ }
+ }
+
+ paintPipes(output) {
+ if (output?.mode !== 'pipes') return;
+ this.pipes = output;
+ const grid = this.grid ?? this.owner.grid ?? DEFAULT_GRID;
+ const lines = [output.stdout ?? '', output.stderr ?? '']
+ .filter((part) => part.length > 0)
+ .join('\n')
+ .replace(/\n$/, '')
+ .split('\n');
+ const screen = lines.slice(-grid.rows);
+ this.state.paint({
+ screen: screen.join('\n'),
+ scrollback: lines.slice(0, lines.length - screen.length).join('\n'),
+ cols: grid.cols,
+ rows: grid.rows,
+ cursor: { x: 0, y: Math.max(0, screen.length - 1), visible: false },
+ });
+ this.size = { cols: grid.cols, rows: grid.rows };
+ this.painted = true;
+ }
+
+ // The controller.
+
+ /**
+ * Keys, text, a paste or the mouse: sent once the controller is held.
+ * The mouse never asks for it: the element reports the mouse only while it
+ * is held (`controlled`), and a report from just before a release is
+ * dropped.
+ */
+ input(actions) {
+ if (!this.controllable() || this.control === 'refused' || this.follow === null) return;
+ if (this.control !== 'held' && actions.every((action) => action.type === 'mouse')) return;
+ this.queue.push(...actions);
+ if (this.control === 'held') this.pump();
+ else if (this.control === 'none') this.acquire();
+ }
+
+ /**
+ * A press in the terminal, which took the keyboard: a shell started here
+ * takes its controller, again after a refusal asking again can get past.
+ * The agent's terminal waits for typing: a press to select or scroll
+ * leaves the agent able to type into it.
+ */
+ activate() {
+ if (this.agents() || !this.controllable()) return;
+ if (this.control === 'none' || (this.control === 'refused' && this.refusal?.retry)) {
+ this.acquire();
+ }
+ }
+
+ /** The notice's Try again: asks for the controller again. */
+ retry() {
+ if (this.controllable() && this.control === 'refused') this.acquire();
+ }
+
+ /** The bar's Release: the agent can type into its terminal again, and typing here takes it back. */
+ letGo() {
+ this.release();
+ this.touch();
+ }
+
+ /** The notice's Retry after following the output failed: follows it again. */
+ refollow() {
+ if (this.live !== 'failed' || this.owner.current() !== this || !this.owner.shown) return;
+ this.show();
+ this.touch();
+ }
+
+ /** The element measured the grid its pane holds: the PTY's size while a shell started here is held. */
+ resized(grid) {
+ this.grid = grid;
+ if (this.control === 'held') this.pump();
+ else if (this.mode() === 'pipes' && this.pipes !== null) this.paintPipes(this.pipes);
+ }
+
+ async acquire() {
+ if (this.lease !== null || this.follow === null || this.owner.closed) return;
+ const lease = { handle: null, closed: false, held: false, controllerId: null, next: 1 };
+ this.lease = lease;
+ this.control = 'acquiring';
+ this.refusal = null;
+ this.touch();
+ let refusal = null;
+ try {
+ const handle = await subscribe(this.endpoint, 'acquire', this.target());
+ if (lease.closed) {
+ close(handle).catch(() => {});
+ return;
+ }
+ lease.handle = handle;
+ SUBSCRIPTIONS.add(handle);
+ const first = await next(handle);
+ if (lease.closed) return;
+ // A refused `acquire` finishes with a failed outcome, which `next`
+ // gives as its last item.
+ if (first === null) refusal = refusalOf(t('terminalLeaseLost'), true);
+ else if (first.kind === 'failed') refusal = refusalOf(first.message);
+ else if (!first.value.held || first.value.nextSequence == null) {
+ refusal = refusalOf(t('terminalLeaseLost'), true);
+ } else {
+ lease.held = true;
+ lease.controllerId = first.value.controllerId;
+ lease.next = first.value.nextSequence;
+ this.control = 'held';
+ this.sentSize = null;
+ log(`terminal: holding ${this.ref} from sequence ${lease.next}`);
+ this.touch();
+ this.pump();
+ for (;;) {
+ const item = await next(handle);
+ if (lease.closed) return;
+ if (item === null || item.kind === 'failed' || !item.value?.held) {
+ refusal =
+ item?.kind === 'failed'
+ ? refusalOf(item.message)
+ : refusalOf(t('terminalLeaseLost'), true);
+ break;
+ }
+ }
+ }
+ } catch (error) {
+ if (lease.closed) return;
+ refusal = refusalOf(error.message);
+ } finally {
+ if (!lease.closed) {
+ lease.closed = true;
+ untrack(lease.handle);
+ }
+ if (this.lease === lease) this.lease = null;
+ }
+ if (this.lease !== null || this.owner.closed) return;
+ log(`terminal: no controller for ${this.ref}: ${refusal.text}`);
+ this.queue = [];
+ this.control = this.controllable() ? 'refused' : 'none';
+ this.refusal = refusal;
+ this.touch();
+ }
+
+ /** Releases the controller, if held, and closes its subscription. */
+ release() {
+ const lease = this.lease;
+ this.queue = [];
+ if (lease === null) return;
+ this.lease = null;
+ lease.closed = true;
+ this.control = 'none';
+ const handle = lease.handle;
+ lease.handle = null;
+ const target = this.target();
+ const endpoint = this.endpoint;
+ (async () => {
+ if (lease.held) {
+ try {
+ const outcome = await operation(endpoint, 'release', {
+ target,
+ controllerId: lease.controllerId,
+ });
+ if (outcome.kind === 'failed')
+ log(`terminal: release of ${target.ref}: ${outcome.message}`);
+ else log(`terminal: released ${target.ref}`);
+ } catch (error) {
+ log(`terminal: release of ${target.ref} failed: ${error.message}`);
+ }
+ }
+ untrack(handle);
+ })();
+ }
+
+ /**
+ * The size to ask of the PTY with the next call, or null when it has it
+ * already. The agent's terminal keeps the size the agent gave it: what it
+ * prints is laid out for that, and the element shows its bottom rows when
+ * the pane holds fewer.
+ */
+ wantedSize() {
+ const grid = this.grid;
+ if (grid === null || this.agents()) return null;
+ const current = this.sentSize ?? this.size;
+ if (current?.cols === grid.cols && current?.rows === grid.rows) return null;
+ return { cols: grid.cols, rows: grid.rows };
+ }
+
+ /** Takes the next call's actions off the queue: at most MAX_ACTIONS and MAX_TEXT_BYTES of text. */
+ batch() {
+ const actions = [];
+ let bytes = 0;
+ while (this.queue.length > 0 && actions.length < MAX_ACTIONS) {
+ const action = this.queue[0];
+ const size = typeof action.text === 'string' ? Buffer.byteLength(action.text, 'utf8') : 16;
+ if (actions.length > 0 && bytes + size > MAX_TEXT_BYTES) break;
Review Comment:
P3: the first action is always admitted, even when it is over `MAX_TEXT_BYTES`. Pastes are already chunked at 8 KiB, but `text_actions` (IME / platform `insertText` via `replace_text_in_range`) is not. A single committed text over 32 KiB would make the Host refuse `control` and drop the terminal to 'refused'. This is an unlikely edge; chunking `text_actions` like `paste_actions` would close it.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org