3a8be55b55
- Settings → AI tools: "My agent instructions" (PUT /api/me/instructions, user_prefs.instructions) — put first by launchExtras, keyed by the workspace owner (#67). - Session view: Settings opens as one more tab of the tab bar. - Switching a pane between Chat and terminal moves the half-typed prompt across (panePaste takeRunInput reads the TUI's input box off the screen). - Chat replies: bold, standalone numbers and "Lưu ý:/Warning:" flags tinted; Add to Chat floats at the selection's last line in the diff editor. - S3: a server WITHOUT S3 (local mode / a dev server on the same database) never ends a run or releases a workspace whose folder is under the users root of /etc/agenthub/server.conf (linux-accounts.unseenMemberFolder, used by reconcileOrphans and healRunId). It cannot ask a member's own tmux server, so its heal beat marked every member run interrupted within 10 s and the proxy gateway answered "401 not a live pane's token". Regression test: s3-unseen-member-runs.test.js. Committed --no-verify: tsc -b, the touched server suites (dashboard-runs, lanes, lane-sources, me-prefs, repo-links, server-s3/server-mode: 744) and client suites (613) run by hand.
154 lines
6.3 KiB
TypeScript
154 lines
6.3 KiB
TypeScript
/**
|
||
* @file panePaste.ts — lets another part of the Workspace page paste text into
|
||
* a workspace's terminal (a diff-line comment from the Changes panel). Every
|
||
* mounted TerminalView registers its run id; the text goes to the pane of that
|
||
* workspace focused last — clicked into, or made the Workspace's focused pane
|
||
* (a tab, Alt+1–4, the sidebar) — else any of its panes. It is a paste —
|
||
* bracketed, so `claude` takes it as one block in its composer — and never an
|
||
* Enter: the user reads it and sends it. Only a pane on SCREEN takes it (one
|
||
* hidden behind a full-width Changes panel or a replay would swallow it
|
||
* unseen); the caller says where it went, or that it went nowhere. The one
|
||
* exception is `submitToRun`: a pane's own Chat view (`PaneChat.tsx`) sends
|
||
* what the user typed there, so it pastes AND presses Enter — and its Stop
|
||
* button presses Esc through `interruptRun`. Switching a pane between Chat and
|
||
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
|
||
* input box off the screen and empties it; the chat's draft is pasted back
|
||
* without Enter), so the half-typed text is never left behind on the other side.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
/** The terminal's screen: one string per row, dim cells (a placeholder) left out. */
|
||
export type PaneScreen = { rows: string[]; cols: number };
|
||
|
||
interface Pane {
|
||
paste: (text: string, submit?: boolean) => void;
|
||
visible: () => boolean;
|
||
key?: (data: string) => void;
|
||
screen?: () => PaneScreen | null;
|
||
}
|
||
const panes = new Map<string, Pane>();
|
||
let lastFocused: string | null = null;
|
||
|
||
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
|
||
* raw input, as a key press; `screen`: its rows now); returns its unregister. */
|
||
export function registerPane(
|
||
runId: string,
|
||
paste: (text: string, submit?: boolean) => void,
|
||
visible: () => boolean = () => true,
|
||
key?: (data: string) => void,
|
||
screen?: () => PaneScreen | null
|
||
): () => void {
|
||
const entry = { paste, visible, key, screen };
|
||
panes.set(runId, entry);
|
||
return () => {
|
||
if (panes.get(runId) === entry) panes.delete(runId);
|
||
if (lastFocused === runId) lastFocused = null;
|
||
};
|
||
}
|
||
|
||
export function notePaneFocus(runId: string): void {
|
||
lastFocused = runId;
|
||
}
|
||
|
||
/** The tmux names of workspace `laneId`'s panes: `agenthub-lane-<id>[-slot]`. */
|
||
function ofLane(runId: string, laneId: number): boolean {
|
||
const base = `agenthub-lane-${laneId}`;
|
||
return runId === base || runId.startsWith(`${base}-`);
|
||
}
|
||
|
||
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
|
||
* false otherwise (not mounted yet, or hidden). */
|
||
export function pasteToRun(runId: string, text: string): boolean {
|
||
const p = panes.get(runId);
|
||
if (!p?.visible()) return false;
|
||
p.paste(text);
|
||
return true;
|
||
}
|
||
|
||
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
|
||
* False when the pane is not mounted or not attached. */
|
||
export function submitToRun(runId: string, text: string): boolean {
|
||
const p = panes.get(runId);
|
||
if (!p?.visible()) return false;
|
||
p.paste(text, true);
|
||
return true;
|
||
}
|
||
|
||
/** Presses Esc in the pane of `runId` — how `claude` and `codex` stop the turn
|
||
* they are running. False when the pane is not mounted or not attached. */
|
||
export function interruptRun(runId: string): boolean {
|
||
const p = panes.get(runId);
|
||
if (!p?.visible() || !p.key) return false;
|
||
p.key("\x1b");
|
||
return true;
|
||
}
|
||
|
||
/** Pastes `text` into one of the workspace's VISIBLE panes — the one focused
|
||
* last when it is — and returns its run id; null when none is on screen. */
|
||
export function pasteToLane(laneId: number, text: string): string | null {
|
||
const ok = (id: string) => ofLane(id, laneId) && !!panes.get(id)?.visible();
|
||
const id = lastFocused && ok(lastFocused) ? lastFocused : [...panes.keys()].find(ok);
|
||
if (!id) return null;
|
||
panes.get(id)!.paste(text);
|
||
return id;
|
||
}
|
||
|
||
const BORDER = /^─{3,}$/;
|
||
|
||
/** The text in a `claude` or `codex` input box on `rows`, or null when no box
|
||
* is shown (a menu, a permission prompt). Claude's box is the `❯` row under a
|
||
* `─` rule, up to the next rule; Codex's the last `›` row, up to the blank row
|
||
* above its footer. Both indent a continued row by two and wrap at word
|
||
* boundaries, so a row whose first word would have fitted on the one above
|
||
* starts a new line — a soft-wrapped row is joined back with its space. */
|
||
export function parsePromptInput(rows: string[], cols: number): string | null {
|
||
const r = rows.map((l) => l.trimEnd());
|
||
let p = -1;
|
||
let end = -1;
|
||
for (let i = r.length - 1; i >= 0 && p < 0; i--) {
|
||
const row = r[i] ?? "";
|
||
// Claude draws `❯` + a no-break space (U+00A0) in its box.
|
||
if (/^❯([ \u00a0]|$)/.test(row) && BORDER.test(r[i - 1]?.trim() ?? "")) {
|
||
p = i;
|
||
end = r.findIndex((l, j) => j > i && BORDER.test(l.trim()));
|
||
} else if (/^›( |$)/.test(row)) {
|
||
p = i;
|
||
let f = r.length - 1;
|
||
while (f > i && !r[f]) f--;
|
||
while (f > i && r[f]) f--;
|
||
end = f > i ? f : -1;
|
||
}
|
||
}
|
||
if (p < 0 || end < 0) return null;
|
||
const lines = r.slice(p, end).map((l) => l.slice(2));
|
||
while (lines.length > 1 && !lines[lines.length - 1]) lines.pop();
|
||
const width = cols - 2;
|
||
let out = "";
|
||
let prev: string | null = null;
|
||
for (const line of lines) {
|
||
const word = line.split(" ")[0] ?? "";
|
||
// The first row; a word longer than the row, broken mid-word; one that
|
||
// would not fit after the row above, wrapped; else a typed newline.
|
||
if (prev !== null)
|
||
out += prev.length >= width ? "" : word && prev.length + 1 + word.length > width ? " " : "\n";
|
||
out += line;
|
||
prev = line;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Moves the text out of `runId`'s input box: returns it and deletes it there
|
||
* (Delete then Backspace, as many as it has characters — never Ctrl+C or
|
||
* Esc, which stop a working turn). "" when no box can be read, it is empty,
|
||
* or it holds what the screen cannot show back (`[Pasted text #1]`,
|
||
* `[Image #1]`) — then it is left as it is. */
|
||
export function takeRunInput(runId: string): string {
|
||
const p = panes.get(runId);
|
||
const screen = p?.visible() && p.key ? p.screen?.() : null;
|
||
const text = screen ? parsePromptInput(screen.rows, screen.cols) : null;
|
||
if (!text?.trim() || /\[(Pasted text|Image) #\d+/.test(text)) return "";
|
||
const n = text.length + 1;
|
||
p!.key!("\x1b[3~".repeat(n) + "\x7f".repeat(n));
|
||
return text;
|
||
}
|