feat: v2.7.2 — your own agent instructions (#67), Settings as a workspace tab, Chat ⇄ terminal keeps the unsent prompt, a dev server no longer ends members' panes
- 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.
This commit is contained in:
@@ -4,12 +4,38 @@
|
||||
* focused last, else any of its panes, never another workspace's (lane 1 is
|
||||
* not lane 12), nowhere once the pane unmounted, and never into a pane that
|
||||
* is not on screen (a hidden console behind a full-width Changes panel); and
|
||||
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane.
|
||||
* `interruptRun`, the Chat view's Stop, presses Esc only in a visible pane;
|
||||
* and the input box read off a real `claude`/`codex` screen (`parsePromptInput`)
|
||||
* and moved out of it (`takeRunInput`) when the pane switches to Chat.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { interruptRun, notePaneFocus, pasteToLane, registerPane } from "../panePaste";
|
||||
import {
|
||||
interruptRun,
|
||||
notePaneFocus,
|
||||
parsePromptInput,
|
||||
pasteToLane,
|
||||
registerPane,
|
||||
takeRunInput,
|
||||
} from "../panePaste";
|
||||
|
||||
const RULE = "─".repeat(60);
|
||||
// Claude Code at 60 columns: a wrapped first line, then a typed newline. Its
|
||||
// box draws `❯` and a NO-BREAK space (U+00A0), never a plain one.
|
||||
const CLAUDE = [
|
||||
"❯ Use the Bash tool to run: sleep 20",
|
||||
"",
|
||||
RULE,
|
||||
"❯\u00a0alpha beta gamma delta epsilon zeta eta theta iota kappa",
|
||||
" lambda mu nu xi omicron pi rho sigma tau",
|
||||
" Tiếng Việt có dấu",
|
||||
RULE,
|
||||
" Model: Haiku 5.5",
|
||||
" ⏵⏵ auto mode on (shift+tab to cycle)",
|
||||
];
|
||||
// Codex at 60 columns (its dim placeholder already left out of an empty box).
|
||||
const CODEX = ["› earlier message", "", "› alpha beta", " indented", "", " GPT-6 · /tmp", ""];
|
||||
|
||||
describe("panePaste", () => {
|
||||
it("pastes into the workspace's last-focused pane, and only its own", () => {
|
||||
@@ -65,4 +91,35 @@ describe("panePaste", () => {
|
||||
expect(keys).toEqual(["\x1b"]);
|
||||
off();
|
||||
});
|
||||
|
||||
it("reads claude's and codex's input box, joining soft-wrapped rows back", () => {
|
||||
expect(parsePromptInput(CLAUDE, 60)).toBe(
|
||||
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau\nTiếng Việt có dấu"
|
||||
);
|
||||
expect(parsePromptInput(CODEX, 60)).toBe("alpha beta\n indented");
|
||||
expect(parsePromptInput([RULE, "❯\u00a0", RULE, " ⏵⏵ auto mode on"], 60)).toBe("");
|
||||
expect(parsePromptInput(["› ", "", " GPT-6 · /tmp", " ← for agents"], 60)).toBe("");
|
||||
// A menu or permission prompt in the box's place: nothing to read.
|
||||
expect(parsePromptInput(["Do you want to proceed?", "❯ 1. Yes", " 2. No"], 60)).toBeNull();
|
||||
});
|
||||
|
||||
it("takeRunInput deletes what it read, and leaves a paste placeholder alone", () => {
|
||||
const keys: string[] = [];
|
||||
let rows = CODEX;
|
||||
const off = registerPane(
|
||||
"agenthub-lane-7",
|
||||
() => {},
|
||||
() => true,
|
||||
(d) => keys.push(d),
|
||||
() => ({ rows, cols: 60 })
|
||||
);
|
||||
expect(takeRunInput("agenthub-lane-7")).toBe("alpha beta\n indented");
|
||||
const n = "alpha beta\n indented".length + 1;
|
||||
expect(keys).toEqual(["\x1b[3~".repeat(n) + "\x7f".repeat(n)]);
|
||||
rows = [RULE, "❯ [Pasted text #1 +40 lines] and more", RULE];
|
||||
expect(takeRunInput("agenthub-lane-7")).toBe("");
|
||||
expect(keys).toHaveLength(1);
|
||||
off();
|
||||
expect(takeRunInput("agenthub-lane-7")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @file Tests for promptTokens: `/command` and `@path` at a word start become
|
||||
* tokens; paths, e-mails, mid-word slashes and trailing punctuation do not.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { promptTokens } from "../promptTokens";
|
||||
|
||||
const kinds = (s: string) => promptTokens(s).map((t) => `${t.kind}:${t.text}`);
|
||||
|
||||
describe("promptTokens", () => {
|
||||
it("splits commands and mentions out of the text", () => {
|
||||
expect(kinds("/review @src/a.ts now")).toEqual([
|
||||
"slash:/review",
|
||||
"text: ",
|
||||
"mention:@src/a.ts",
|
||||
"text: now",
|
||||
]);
|
||||
expect(kinds("run /agenthub:file-headers\nok")).toEqual([
|
||||
"text:run ",
|
||||
"slash:/agenthub:file-headers",
|
||||
"text:\nok",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves paths, e-mails and mid-word slashes as text", () => {
|
||||
expect(kinds("see /home/x and/or me@x.io")).toEqual(["text:see /home/x and/or me@x.io"]);
|
||||
});
|
||||
|
||||
it("drops trailing prose punctuation from a mention", () => {
|
||||
expect(kinds("read @a.ts, then")).toEqual(["text:read ", "mention:@a.ts", "text:, then"]);
|
||||
});
|
||||
|
||||
it("keeps the text whole, so it can be laid over the input", () => {
|
||||
const s = " /x @y.\n@z ";
|
||||
expect(
|
||||
promptTokens(s)
|
||||
.map((t) => t.text)
|
||||
.join("")
|
||||
).toBe(s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @file Tests for settingsHref: the Settings page off a workspace, the
|
||||
* workspace's Settings tab on one (lane and run kept, a replay and a previous
|
||||
* sub-tab dropped, the section's own query and anchor carried).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { settingsHref } from "../settingsLink";
|
||||
|
||||
describe("settingsHref", () => {
|
||||
it("opens the Settings page off a workspace", () => {
|
||||
const at = { pathname: "/sessions", search: "" };
|
||||
expect(settingsHref("", at)).toBe("/settings");
|
||||
expect(settingsHref("agents/claude-code?tab=mcp", at)).toBe(
|
||||
"/settings/agents/claude-code?tab=mcp"
|
||||
);
|
||||
expect(settingsHref("appearance#pet", at)).toBe("/settings/appearance#pet");
|
||||
// The Workspace page with no workspace yet has no tab bar to open it in.
|
||||
expect(settingsHref("agents", { pathname: "/workspace", search: "" })).toBe("/settings/agents");
|
||||
});
|
||||
|
||||
it("opens the workspace's Settings tab on a workspace", () => {
|
||||
const at = { pathname: "/workspace", search: "?lane=3&run=agenthub-lane-3-1&tab=skills" };
|
||||
expect(settingsHref("agents/claude-code?tab=mcp", at)).toBe(
|
||||
"/workspace?lane=3&run=agenthub-lane-3-1&settings=agents%2Fclaude-code&tab=mcp"
|
||||
);
|
||||
expect(settingsHref("", { pathname: "/workspace", search: "?lane=3&session=abc" })).toBe(
|
||||
"/workspace?lane=3&settings="
|
||||
);
|
||||
expect(
|
||||
settingsHref("pricing#pricing-openai", { pathname: "/workspace", search: "?lane=3" })
|
||||
).toBe("/workspace?lane=3&settings=pricing#pricing-openai");
|
||||
});
|
||||
});
|
||||
@@ -692,6 +692,14 @@ export const api = {
|
||||
/** PUT /api/me/prefs — merge; `null` deletes a key. */
|
||||
savePrefs: (prefs: Record<string, string | null>) =>
|
||||
request<UserPrefs>("/me/prefs", { method: "PUT", body: JSON.stringify({ prefs }) }),
|
||||
/** GET /api/me/instructions — this account's own agent instructions (issue #67). */
|
||||
instructions: () => request<AgentInstructionsView>("/me/instructions"),
|
||||
/** PUT /api/me/instructions — blank clears; put before every workspace's own. */
|
||||
saveInstructions: (instructions: string) =>
|
||||
request<AgentInstructionsView>("/me/instructions", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ instructions }),
|
||||
}),
|
||||
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||
},
|
||||
@@ -3566,6 +3574,13 @@ export interface GitAuthNeeded {
|
||||
}
|
||||
|
||||
/** One AI coding CLI from the server's AI Tools catalog (GET /api/ai-tools). */
|
||||
/** GET/PUT /api/me/instructions — `tools`: the AI tool ids whose new panes receive them. */
|
||||
export interface AgentInstructionsView {
|
||||
instructions: string;
|
||||
max: number;
|
||||
tools: string[];
|
||||
}
|
||||
|
||||
export interface AiTool {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -10,27 +10,35 @@
|
||||
* 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`.
|
||||
* 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); returns its unregister. */
|
||||
* 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
|
||||
key?: (data: string) => void,
|
||||
screen?: () => PaneScreen | null
|
||||
): () => void {
|
||||
const entry = { paste, visible, key };
|
||||
const entry = { paste, visible, key, screen };
|
||||
panes.set(runId, entry);
|
||||
return () => {
|
||||
if (panes.get(runId) === entry) panes.delete(runId);
|
||||
@@ -84,3 +92,62 @@ export function pasteToLane(laneId: number, text: string): string | 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file promptTokens — splits a prompt into plain text, `/command` and
|
||||
* `@path` tokens, so the prompt box and a sent message colour them the same
|
||||
* way. A token starts a word (the line start or after whitespace), like the
|
||||
* prompt box's autocomplete, so `a/b`, `me@x.io` and `/home/x` stay text.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export type PromptToken = { kind: "text" | "slash" | "mention"; text: string };
|
||||
|
||||
/** A token's colours, shared by the prompt box and a sent message; `@path`
|
||||
* takes the chat's path-link violet. */
|
||||
export const TOKEN_TONE = {
|
||||
slash: "bg-accent/15 text-accent",
|
||||
mention: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
||||
} as const;
|
||||
|
||||
// `/name` (plugin skills are `plugin:name`) ending the word; `@path` minus a
|
||||
// trailing full stop or comma, which is prose.
|
||||
const TOKEN_RE = /(?<=^|\s)(?:\/[\w:.-]+(?=\s|$)|@[^\s]*[^\s.,;:!?)])/g;
|
||||
|
||||
export function promptTokens(text: string): PromptToken[] {
|
||||
const out: PromptToken[] = [];
|
||||
let at = 0;
|
||||
for (const m of text.matchAll(TOKEN_RE)) {
|
||||
if (m.index > at) out.push({ kind: "text", text: text.slice(at, m.index) });
|
||||
out.push({ kind: m[0][0] === "/" ? "slash" : "mention", text: m[0] });
|
||||
at = m.index + m[0].length;
|
||||
}
|
||||
if (at < text.length) out.push({ kind: "text", text: text.slice(at) });
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @file settingsLink — where a Settings link opens. On a workspace (the
|
||||
* Workspace page with `?lane=`) it is that page's Settings tab
|
||||
* (`?settings=<section>`, keeping `lane` and `run`), so the terminals and chats
|
||||
* stay mounted behind it; anywhere else it is the Settings page
|
||||
* (`/settings/<section>`). A path is what the page takes after `/settings/`:
|
||||
* `<section>[?query][#anchor]`, `""` for the default section.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
export function settingsHref(path: string, at: { pathname: string; search: string }): string {
|
||||
const [rest = "", anchor = ""] = path.split("#");
|
||||
const [section = "", query = ""] = rest.split("?");
|
||||
const hash = anchor ? `#${anchor}` : "";
|
||||
const here = new URLSearchParams(at.search);
|
||||
const lane = here.get("lane");
|
||||
if (at.pathname !== "/workspace" || !lane) {
|
||||
return `/settings${section ? `/${section}` : ""}${query ? `?${query}` : ""}${hash}`;
|
||||
}
|
||||
// A section's own query (a sub-tab) and the session replay do not carry over.
|
||||
const next = new URLSearchParams({ lane });
|
||||
const run = here.get("run");
|
||||
if (run) next.set("run", run);
|
||||
next.set("settings", section);
|
||||
for (const [k, v] of new URLSearchParams(query)) next.set(k, v);
|
||||
return `/workspace?${next}${hash}`;
|
||||
}
|
||||
|
||||
/** `settingsHref` from the current location. */
|
||||
export function useSettingsHref(): (path: string) => string {
|
||||
const location = useLocation();
|
||||
return (path) => settingsHref(path, location);
|
||||
}
|
||||
Reference in New Issue
Block a user