@alisio/alisio-code 0.1.0-alpha.9 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -16
- package/dist/analysis.d.ts +27 -0
- package/dist/analysis.js +145 -0
- package/dist/banner.js +2 -1
- package/dist/builtin.d.ts +3 -1
- package/dist/builtin.js +0 -24
- package/dist/main.js +133 -6
- package/dist/serve.d.ts +15 -0
- package/dist/serve.js +99 -0
- package/dist/tui/agent-manager.d.ts +105 -0
- package/dist/tui/agent-manager.js +269 -0
- package/dist/tui/agents.d.ts +5 -0
- package/dist/tui/agents.js +5 -0
- package/dist/tui/app.d.ts +42 -1
- package/dist/tui/app.js +1895 -97
- package/dist/tui/artifact-preview.d.ts +52 -0
- package/dist/tui/artifact-preview.js +212 -0
- package/dist/tui/artifacts.d.ts +54 -0
- package/dist/tui/artifacts.js +207 -0
- package/dist/tui/btw.d.ts +57 -0
- package/dist/tui/btw.js +76 -0
- package/dist/tui/components.d.ts +174 -12
- package/dist/tui/components.js +962 -66
- package/dist/tui/connect-input.d.ts +2 -0
- package/dist/tui/connect-input.js +1 -1
- package/dist/tui/exit.d.ts +10 -0
- package/dist/tui/exit.js +15 -0
- package/dist/tui/git-branch.d.ts +48 -0
- package/dist/tui/git-branch.js +99 -0
- package/dist/tui/goal.d.ts +75 -0
- package/dist/tui/goal.js +171 -0
- package/dist/tui/modes.d.ts +71 -0
- package/dist/tui/modes.js +90 -0
- package/dist/tui/open-path.d.ts +38 -0
- package/dist/tui/open-path.js +80 -0
- package/dist/tui/plan-review.d.ts +48 -0
- package/dist/tui/plan-review.js +76 -0
- package/dist/tui/queue.d.ts +2 -0
- package/dist/tui/queue.js +5 -1
- package/dist/tui/scroll.d.ts +8 -0
- package/dist/tui/scroll.js +12 -0
- package/dist/tui/settings-menu.d.ts +40 -0
- package/dist/tui/settings-menu.js +132 -0
- package/dist/tui/settings.d.ts +194 -0
- package/dist/tui/settings.js +482 -0
- package/dist/tui/state.d.ts +343 -7
- package/dist/tui/state.js +823 -54
- package/dist/tui/tasks.d.ts +123 -0
- package/dist/tui/tasks.js +369 -0
- package/dist/tui/theme.d.ts +42 -1
- package/dist/tui/theme.js +516 -1
- package/dist/tui/viewer-state.d.ts +7 -0
- package/dist/tui/viewer-state.js +36 -0
- package/dist/version.d.ts +7 -5
- package/dist/version.js +44 -8
- package/package.json +7 -9
|
@@ -26,6 +26,8 @@ export declare class ConnectInputPrompt implements Component {
|
|
|
26
26
|
hint?: string;
|
|
27
27
|
step: number;
|
|
28
28
|
steps: number;
|
|
29
|
+
/** Dialog title (default `Connect · <provider>`). */
|
|
30
|
+
title?: string;
|
|
29
31
|
onSubmit: (value: string) => void;
|
|
30
32
|
onCancel: () => void;
|
|
31
33
|
});
|
|
@@ -92,7 +92,7 @@ export class ConnectInputPrompt {
|
|
|
92
92
|
const rule = "─".repeat(Math.max(1, Math.min(56, width - 2)));
|
|
93
93
|
return [
|
|
94
94
|
truncateToWidth(style.cyan(`┌${rule}┐`), width),
|
|
95
|
-
truncateToWidth(`│ ${style.bold(`Connect · ${this.options.provider}`)} ${style.dim(`(${this.options.step}/${this.options.steps})`)}`, width),
|
|
95
|
+
truncateToWidth(`│ ${style.bold(this.options.title ?? `Connect · ${this.options.provider}`)}${this.options.title && this.options.steps <= 1 ? "" : ` ${style.dim(`(${this.options.step}/${this.options.steps})`)}`}`, width),
|
|
96
96
|
truncateToWidth(`│ ${style.brightCyan("❯")} ${style.bold(this.options.label)}`, width),
|
|
97
97
|
truncateToWidth(`│ ${field}`, width),
|
|
98
98
|
...(this.options.hint ? [truncateToWidth(`│ ${style.dim(this.options.hint)}`, width)] : []),
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive-exit bounds: `/exit`, double Ctrl+C on an empty input, Ctrl+D and SIGINT/SIGTERM
|
|
3
|
+
* must feel instant, so each shutdown step is raced against a cap instead of awaiting teardown
|
|
4
|
+
* without limits. The plugin host's own hook timeout (`pluginHooks.sessionEndTimeoutMs`) still
|
|
5
|
+
* applies to `/clear`; exit deliberately uses shorter user-facing caps on the same hooks.
|
|
6
|
+
*/
|
|
7
|
+
export declare const EXIT_PENDING_CAP_MS = 3000;
|
|
8
|
+
export declare const EXIT_SESSION_END_CAP_MS = 1500;
|
|
9
|
+
/** Resolves when `work` settles or after `capMs`, whichever comes first; never rejects. */
|
|
10
|
+
export declare function bounded(work: Promise<unknown> | undefined, capMs: number): Promise<void>;
|
package/dist/tui/exit.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive-exit bounds: `/exit`, double Ctrl+C on an empty input, Ctrl+D and SIGINT/SIGTERM
|
|
3
|
+
* must feel instant, so each shutdown step is raced against a cap instead of awaiting teardown
|
|
4
|
+
* without limits. The plugin host's own hook timeout (`pluginHooks.sessionEndTimeoutMs`) still
|
|
5
|
+
* applies to `/clear`; exit deliberately uses shorter user-facing caps on the same hooks.
|
|
6
|
+
*/
|
|
7
|
+
export const EXIT_PENDING_CAP_MS = 3_000;
|
|
8
|
+
export const EXIT_SESSION_END_CAP_MS = 1_500;
|
|
9
|
+
/** Resolves when `work` settles or after `capMs`, whichever comes first; never rejects. */
|
|
10
|
+
export function bounded(work, capMs) {
|
|
11
|
+
return Promise.race([
|
|
12
|
+
Promise.resolve(work).catch(() => { }),
|
|
13
|
+
new Promise((resolve) => setTimeout(resolve, capMs)),
|
|
14
|
+
]).then(() => undefined);
|
|
15
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
/** Hard cap for branch names shown in the header, so a hostile/long ref never breaks layout. */
|
|
3
|
+
export declare const BRANCH_MAX_LENGTH = 24;
|
|
4
|
+
/** How long a resolved branch is reused before git is asked again (avoids spawning per frame). */
|
|
5
|
+
export declare const BRANCH_TTL_MS = 10000;
|
|
6
|
+
/** Re-check cadence; matches the cache TTL so the header follows branch switches within ~10s. */
|
|
7
|
+
export declare const BRANCH_REFRESH_MS = 10000;
|
|
8
|
+
/** Bounded spawn: a slow or hung git must never stall the TUI. */
|
|
9
|
+
export declare const BRANCH_TIMEOUT_MS = 800;
|
|
10
|
+
type Spawn = typeof execFile;
|
|
11
|
+
/**
|
|
12
|
+
* Strips ANSI escapes and control characters, then truncates to `BRANCH_MAX_LENGTH` chars with an
|
|
13
|
+
* ellipsis. Branch names come from the workspace repository, not from user input, but a hostile or
|
|
14
|
+
* corrupted ref must never inject escape sequences or overflow the header line.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sanitizeBranchName(name: string): string;
|
|
17
|
+
/** Header segment text: `⎇ main` on unicode terminals, `branch main` otherwise. */
|
|
18
|
+
export declare function branchDisplay(branch: string, unicode: boolean): string;
|
|
19
|
+
export interface ReadBranchOptions {
|
|
20
|
+
timeoutMs?: number;
|
|
21
|
+
/** Injectable for tests; defaults to node:child_process execFile. */
|
|
22
|
+
spawn?: Spawn;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Reads the branch of the git repository at `workspace` (read-only ref query, safe under
|
|
26
|
+
* `--read-only`). Returns the branch name, the short commit SHA on a detached HEAD, or `undefined`
|
|
27
|
+
* when the directory is missing, is not a git repository, git itself is unavailable, or the call
|
|
28
|
+
* times out. All failures are silent: stderr is captured and never printed.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readGitBranch(workspace: string, options?: ReadBranchOptions): Promise<string | undefined>;
|
|
31
|
+
export interface BranchCache {
|
|
32
|
+
/** Cached branch for `workspace`, or a fresh read when the entry expired; dedupes in-flight reads. */
|
|
33
|
+
read(workspace: string): Promise<string | undefined>;
|
|
34
|
+
clear(workspace: string): void;
|
|
35
|
+
}
|
|
36
|
+
export interface BranchCacheOptions {
|
|
37
|
+
ttlMs?: number;
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
spawn?: Spawn;
|
|
40
|
+
/** Injectable clock for expiry tests. */
|
|
41
|
+
now?: () => number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* TTL cache keyed by workspace so the TUI never spawns git on every frame: one read per workspace
|
|
45
|
+
* per `ttlMs` (hits and misses are both cached), with a single in-flight promise per key.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createBranchCache(options?: BranchCacheOptions): BranchCache;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
/** Hard cap for branch names shown in the header, so a hostile/long ref never breaks layout. */
|
|
4
|
+
export const BRANCH_MAX_LENGTH = 24;
|
|
5
|
+
/** How long a resolved branch is reused before git is asked again (avoids spawning per frame). */
|
|
6
|
+
export const BRANCH_TTL_MS = 10_000;
|
|
7
|
+
/** Re-check cadence; matches the cache TTL so the header follows branch switches within ~10s. */
|
|
8
|
+
export const BRANCH_REFRESH_MS = BRANCH_TTL_MS;
|
|
9
|
+
/** Bounded spawn: a slow or hung git must never stall the TUI. */
|
|
10
|
+
export const BRANCH_TIMEOUT_MS = 800;
|
|
11
|
+
/**
|
|
12
|
+
* Strips ANSI escapes and control characters, then truncates to `BRANCH_MAX_LENGTH` chars with an
|
|
13
|
+
* ellipsis. Branch names come from the workspace repository, not from user input, but a hostile or
|
|
14
|
+
* corrupted ref must never inject escape sequences or overflow the header line.
|
|
15
|
+
*/
|
|
16
|
+
export function sanitizeBranchName(name) {
|
|
17
|
+
const clean = name
|
|
18
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
|
|
19
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
20
|
+
.trim();
|
|
21
|
+
const chars = [...clean];
|
|
22
|
+
return chars.length > BRANCH_MAX_LENGTH
|
|
23
|
+
? `${chars.slice(0, BRANCH_MAX_LENGTH - 1).join("")}…`
|
|
24
|
+
: clean;
|
|
25
|
+
}
|
|
26
|
+
/** Header segment text: `⎇ main` on unicode terminals, `branch main` otherwise. */
|
|
27
|
+
export function branchDisplay(branch, unicode) {
|
|
28
|
+
return `${unicode ? "⎇" : "branch"} ${branch}`;
|
|
29
|
+
}
|
|
30
|
+
function runGit(spawn, args, cwd, timeoutMs) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
spawn("git", args, { cwd, timeout: timeoutMs, windowsHide: true }, (error, stdout) => {
|
|
33
|
+
if (error)
|
|
34
|
+
reject(error);
|
|
35
|
+
else
|
|
36
|
+
resolve(stdout);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Reads the branch of the git repository at `workspace` (read-only ref query, safe under
|
|
42
|
+
* `--read-only`). Returns the branch name, the short commit SHA on a detached HEAD, or `undefined`
|
|
43
|
+
* when the directory is missing, is not a git repository, git itself is unavailable, or the call
|
|
44
|
+
* times out. All failures are silent: stderr is captured and never printed.
|
|
45
|
+
*/
|
|
46
|
+
export async function readGitBranch(workspace, options = {}) {
|
|
47
|
+
const timeoutMs = options.timeoutMs ?? BRANCH_TIMEOUT_MS;
|
|
48
|
+
if (!existsSync(workspace))
|
|
49
|
+
return undefined;
|
|
50
|
+
const spawn = options.spawn ?? execFile;
|
|
51
|
+
try {
|
|
52
|
+
const ref = (await runGit(spawn, ["rev-parse", "--abbrev-ref", "HEAD"], workspace, timeoutMs)).trim();
|
|
53
|
+
if (ref && ref !== "HEAD")
|
|
54
|
+
return sanitizeBranchName(ref);
|
|
55
|
+
if (ref === "HEAD") {
|
|
56
|
+
const sha = (await runGit(spawn, ["rev-parse", "--short", "HEAD"], workspace, timeoutMs)).trim();
|
|
57
|
+
return sha ? sanitizeBranchName(sha) : undefined;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Not a repository, git missing, or timed out: the header simply shows no branch segment.
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* TTL cache keyed by workspace so the TUI never spawns git on every frame: one read per workspace
|
|
68
|
+
* per `ttlMs` (hits and misses are both cached), with a single in-flight promise per key.
|
|
69
|
+
*/
|
|
70
|
+
export function createBranchCache(options = {}) {
|
|
71
|
+
const ttlMs = options.ttlMs ?? BRANCH_TTL_MS;
|
|
72
|
+
const now = options.now ?? Date.now;
|
|
73
|
+
const entries = new Map();
|
|
74
|
+
const inflight = new Map();
|
|
75
|
+
const read = (workspace) => {
|
|
76
|
+
const hit = entries.get(workspace);
|
|
77
|
+
if (hit && hit.expires > now())
|
|
78
|
+
return Promise.resolve(hit.branch);
|
|
79
|
+
const pending = inflight.get(workspace);
|
|
80
|
+
if (pending)
|
|
81
|
+
return pending;
|
|
82
|
+
const promise = readGitBranch(workspace, {
|
|
83
|
+
timeoutMs: options.timeoutMs,
|
|
84
|
+
spawn: options.spawn,
|
|
85
|
+
})
|
|
86
|
+
.then((branch) => {
|
|
87
|
+
entries.set(workspace, { branch, expires: now() + ttlMs });
|
|
88
|
+
inflight.delete(workspace);
|
|
89
|
+
return branch;
|
|
90
|
+
})
|
|
91
|
+
.catch(() => {
|
|
92
|
+
inflight.delete(workspace);
|
|
93
|
+
return undefined;
|
|
94
|
+
});
|
|
95
|
+
inflight.set(workspace, promise);
|
|
96
|
+
return promise;
|
|
97
|
+
};
|
|
98
|
+
return { read, clear: (workspace) => void entries.delete(workspace) };
|
|
99
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/goal` in the terminal. Pure logic first (what a typed command does, the picker, the bar), then
|
|
3
|
+
* the `GoalBar` component. The goal itself (state machine, caps, breakers, the one-continuation
|
|
4
|
+
* claim) lives in `@alisio/core`; this module only decides what to SHOW and which action a command
|
|
5
|
+
* means, so it can be tested without a terminal.
|
|
6
|
+
*/
|
|
7
|
+
import { formatGoalStatus, type GoalCommand } from "@alisio/core";
|
|
8
|
+
import type { GoalInfo } from "@alisio/sdk";
|
|
9
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
10
|
+
/** What a typed `/goal …` means, given the session's current goal. */
|
|
11
|
+
export type GoalPlan = {
|
|
12
|
+
kind: "say";
|
|
13
|
+
text: string;
|
|
14
|
+
tone: "info" | "notice";
|
|
15
|
+
}
|
|
16
|
+
/** `/goal` with a goal: the action picker. */
|
|
17
|
+
| {
|
|
18
|
+
kind: "picker";
|
|
19
|
+
}
|
|
20
|
+
/** `/goal <objective>` over an existing goal: ask before replacing it. */
|
|
21
|
+
| {
|
|
22
|
+
kind: "confirm-replace";
|
|
23
|
+
objective: string;
|
|
24
|
+
tokenBudget?: number;
|
|
25
|
+
} | {
|
|
26
|
+
kind: "create";
|
|
27
|
+
objective: string;
|
|
28
|
+
tokenBudget?: number;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "act";
|
|
31
|
+
action: "pause" | "resume" | "clear";
|
|
32
|
+
} | {
|
|
33
|
+
kind: "edit";
|
|
34
|
+
objective: string;
|
|
35
|
+
}
|
|
36
|
+
/** `/goal edit` without text: put the current objective in the editor to change it. */
|
|
37
|
+
| {
|
|
38
|
+
kind: "edit-prefill";
|
|
39
|
+
text: string;
|
|
40
|
+
} | {
|
|
41
|
+
kind: "budget";
|
|
42
|
+
tokens: number | null;
|
|
43
|
+
};
|
|
44
|
+
export declare const NO_GOAL_TEXT = "This session has no goal. Start one with /goal <objective>.";
|
|
45
|
+
export declare const GOALS_OFF_TEXT = "Goals are turned off (goal.enabled).";
|
|
46
|
+
export declare function planGoalCommand(command: GoalCommand, context: {
|
|
47
|
+
goal: GoalInfo | undefined;
|
|
48
|
+
enabled: boolean;
|
|
49
|
+
}): GoalPlan;
|
|
50
|
+
export interface GoalPickerItem {
|
|
51
|
+
value: "status" | "pause" | "resume" | "edit" | "budget" | "clear" | "help";
|
|
52
|
+
label: string;
|
|
53
|
+
description: string;
|
|
54
|
+
}
|
|
55
|
+
/** The rows of `/goal` ("Start or manage the current Session Goal") for a goal in this state. */
|
|
56
|
+
export declare function goalPickerItems(goal: GoalInfo): GoalPickerItem[];
|
|
57
|
+
/** The editor text a picker row leaves for the user to finish (`undefined` = act at once). */
|
|
58
|
+
export declare function goalPickerPrefill(value: GoalPickerItem["value"], goal: GoalInfo): string | undefined;
|
|
59
|
+
export type GoalTone = "ok" | "warn" | "danger" | "done";
|
|
60
|
+
export declare function goalTone(goal: GoalInfo): GoalTone;
|
|
61
|
+
/** The one or two lines of the bar, unstyled; `width` only decides how the objective is cut. */
|
|
62
|
+
export declare function goalBarText(goal: GoalInfo): {
|
|
63
|
+
head: string;
|
|
64
|
+
detail: string;
|
|
65
|
+
};
|
|
66
|
+
/** The goal bar above the editor: nothing without a goal, two lines with one. */
|
|
67
|
+
export declare class GoalBar implements Component {
|
|
68
|
+
private readonly goal;
|
|
69
|
+
constructor(goal: () => GoalInfo | undefined);
|
|
70
|
+
invalidate(): void;
|
|
71
|
+
render(width: number): string[];
|
|
72
|
+
}
|
|
73
|
+
/** One transcript line when a goal stops by itself (the bar shows the same, but scrolls away). */
|
|
74
|
+
export declare function goalStopNotice(goal: GoalInfo): string | undefined;
|
|
75
|
+
export { formatGoalStatus };
|
package/dist/tui/goal.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/goal` in the terminal. Pure logic first (what a typed command does, the picker, the bar), then
|
|
3
|
+
* the `GoalBar` component. The goal itself (state machine, caps, breakers, the one-continuation
|
|
4
|
+
* claim) lives in `@alisio/core`; this module only decides what to SHOW and which action a command
|
|
5
|
+
* means, so it can be tested without a terminal.
|
|
6
|
+
*/
|
|
7
|
+
import { formatGoalElapsed, formatGoalStatus, GOAL_HELP, GOAL_STATUS_LABEL, GOAL_USAGE, GOAL_WAITING_LABEL, goalActionHints, goalReasonText, goalTokensText, } from "@alisio/core";
|
|
8
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
import { style } from "./theme.js";
|
|
10
|
+
export const NO_GOAL_TEXT = "This session has no goal. Start one with /goal <objective>.";
|
|
11
|
+
export const GOALS_OFF_TEXT = "Goals are turned off (goal.enabled).";
|
|
12
|
+
export function planGoalCommand(command, context) {
|
|
13
|
+
const { goal } = context;
|
|
14
|
+
const say = (text, tone = "notice") => ({
|
|
15
|
+
kind: "say",
|
|
16
|
+
text,
|
|
17
|
+
tone,
|
|
18
|
+
});
|
|
19
|
+
switch (command.type) {
|
|
20
|
+
case "error":
|
|
21
|
+
return say(`${command.message}\n${GOAL_USAGE}`);
|
|
22
|
+
case "help":
|
|
23
|
+
return say(GOAL_HELP, "info");
|
|
24
|
+
case "show":
|
|
25
|
+
if (!goal)
|
|
26
|
+
return say(`${NO_GOAL_TEXT}\n${GOAL_USAGE}`);
|
|
27
|
+
return { kind: "picker" };
|
|
28
|
+
case "create":
|
|
29
|
+
if (!context.enabled)
|
|
30
|
+
return say(GOALS_OFF_TEXT);
|
|
31
|
+
return goal
|
|
32
|
+
? {
|
|
33
|
+
kind: "confirm-replace",
|
|
34
|
+
objective: command.objective,
|
|
35
|
+
...(command.tokenBudget ? { tokenBudget: command.tokenBudget } : {}),
|
|
36
|
+
}
|
|
37
|
+
: {
|
|
38
|
+
kind: "create",
|
|
39
|
+
objective: command.objective,
|
|
40
|
+
...(command.tokenBudget ? { tokenBudget: command.tokenBudget } : {}),
|
|
41
|
+
};
|
|
42
|
+
case "pause":
|
|
43
|
+
case "resume":
|
|
44
|
+
case "clear":
|
|
45
|
+
return goal ? { kind: "act", action: command.type } : say(NO_GOAL_TEXT);
|
|
46
|
+
case "edit":
|
|
47
|
+
if (!goal)
|
|
48
|
+
return say(NO_GOAL_TEXT);
|
|
49
|
+
return command.objective === undefined
|
|
50
|
+
? { kind: "edit-prefill", text: `/goal edit ${goal.objective}` }
|
|
51
|
+
: { kind: "edit", objective: command.objective };
|
|
52
|
+
case "budget":
|
|
53
|
+
return goal ? { kind: "budget", tokens: command.tokens } : say(NO_GOAL_TEXT);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The rows of `/goal` ("Start or manage the current Session Goal") for a goal in this state. */
|
|
57
|
+
export function goalPickerItems(goal) {
|
|
58
|
+
const items = [
|
|
59
|
+
{
|
|
60
|
+
value: "status",
|
|
61
|
+
label: "Show status",
|
|
62
|
+
description: "Objective, usage and the agent's report",
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
if (goal.actions.includes("pause"))
|
|
66
|
+
items.push({
|
|
67
|
+
value: "pause",
|
|
68
|
+
label: "Pause",
|
|
69
|
+
description: "Stop continuing automatically (the current turn finishes)",
|
|
70
|
+
});
|
|
71
|
+
if (goal.actions.includes("resume"))
|
|
72
|
+
items.push({
|
|
73
|
+
value: "resume",
|
|
74
|
+
label: "Resume",
|
|
75
|
+
description: goal.reason === "max_turns" || goal.reason === "max_wall"
|
|
76
|
+
? "Continue with one more allowance of the limit that stopped it"
|
|
77
|
+
: "Continue working on the goal",
|
|
78
|
+
});
|
|
79
|
+
items.push({ value: "edit", label: "Edit objective…", description: "Change the objective text" });
|
|
80
|
+
items.push({
|
|
81
|
+
value: "budget",
|
|
82
|
+
label: goal.status === "budget_limited" ? "Raise budget…" : "Set token budget…",
|
|
83
|
+
description: "e.g. 50k or 1.5M; budget=clear removes it",
|
|
84
|
+
});
|
|
85
|
+
items.push({
|
|
86
|
+
value: "clear",
|
|
87
|
+
label: "Clear goal",
|
|
88
|
+
description: "Remove the goal from this session",
|
|
89
|
+
});
|
|
90
|
+
items.push({ value: "help", label: "Help", description: "Syntax and budget examples" });
|
|
91
|
+
return items;
|
|
92
|
+
}
|
|
93
|
+
/** The editor text a picker row leaves for the user to finish (`undefined` = act at once). */
|
|
94
|
+
export function goalPickerPrefill(value, goal) {
|
|
95
|
+
if (value === "edit")
|
|
96
|
+
return `/goal edit ${goal.objective}`;
|
|
97
|
+
if (value === "budget")
|
|
98
|
+
return `/goal budget=${goal.tokenBudget !== undefined ? goal.tokenBudget : ""}`;
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
export function goalTone(goal) {
|
|
102
|
+
switch (goal.status) {
|
|
103
|
+
case "active":
|
|
104
|
+
return "ok";
|
|
105
|
+
case "complete":
|
|
106
|
+
return "done";
|
|
107
|
+
case "blocked":
|
|
108
|
+
return "danger";
|
|
109
|
+
default:
|
|
110
|
+
return "warn";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** The one or two lines of the bar, unstyled; `width` only decides how the objective is cut. */
|
|
114
|
+
export function goalBarText(goal) {
|
|
115
|
+
const label = GOAL_STATUS_LABEL[goal.status];
|
|
116
|
+
const why = goal.waiting
|
|
117
|
+
? GOAL_WAITING_LABEL[goal.waiting]
|
|
118
|
+
: goal.status === "active"
|
|
119
|
+
? ""
|
|
120
|
+
: goalReasonText(goal);
|
|
121
|
+
const parts = [
|
|
122
|
+
goalTokensText(goal),
|
|
123
|
+
`turn ${goal.turnsUsed}/${goal.maxTurns}`,
|
|
124
|
+
formatGoalElapsed(goal.activeMs),
|
|
125
|
+
...(why ? [why] : []),
|
|
126
|
+
goalActionHints(goal.actions),
|
|
127
|
+
].filter(Boolean);
|
|
128
|
+
return {
|
|
129
|
+
head: `◎ Goal · ${label} · ${goal.objective.replace(/\s+/g, " ").trim()}`,
|
|
130
|
+
detail: ` ${parts.join(" · ")}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const TONE_STYLE = {
|
|
134
|
+
ok: style.cyan,
|
|
135
|
+
warn: style.yellow,
|
|
136
|
+
danger: style.red,
|
|
137
|
+
done: style.green,
|
|
138
|
+
};
|
|
139
|
+
/** The goal bar above the editor: nothing without a goal, two lines with one. */
|
|
140
|
+
export class GoalBar {
|
|
141
|
+
goal;
|
|
142
|
+
constructor(goal) {
|
|
143
|
+
this.goal = goal;
|
|
144
|
+
}
|
|
145
|
+
invalidate() { }
|
|
146
|
+
render(width) {
|
|
147
|
+
const goal = this.goal();
|
|
148
|
+
if (!goal)
|
|
149
|
+
return [];
|
|
150
|
+
const { head, detail } = goalBarText(goal);
|
|
151
|
+
const paint = TONE_STYLE[goalTone(goal)];
|
|
152
|
+
return [
|
|
153
|
+
truncateToWidth(style.bold(paint(head)), width),
|
|
154
|
+
truncateToWidth(style.dim(detail), width),
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** One transcript line when a goal stops by itself (the bar shows the same, but scrolls away). */
|
|
159
|
+
export function goalStopNotice(goal) {
|
|
160
|
+
if (goal.status === "active")
|
|
161
|
+
return undefined;
|
|
162
|
+
const base = `Goal ${GOAL_STATUS_LABEL[goal.status].toLowerCase()}`;
|
|
163
|
+
const why = goalReasonText(goal);
|
|
164
|
+
const next = goal.status === "paused" || goal.status === "blocked"
|
|
165
|
+
? " · /goal resume to continue"
|
|
166
|
+
: goal.status === "budget_limited"
|
|
167
|
+
? " · /goal budget=<n> to raise it"
|
|
168
|
+
: "";
|
|
169
|
+
return `${base}${why ? `: ${why}` : ""}${next}`;
|
|
170
|
+
}
|
|
171
|
+
export { formatGoalStatus };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic of the TUI modes (spec: modes, goal and background, Phase 1): the Shift+Tab agent
|
|
3
|
+
* cycle, the `/permission` menu and transitions, and the `/reload` guard. No terminal output and
|
|
4
|
+
* no I/O, so it is unit-testable without a TTY; `app.ts` wires it to the editor and the pickers.
|
|
5
|
+
*/
|
|
6
|
+
import { type ActiveAgent, type PermissionMode, type PermissionModeLabel } from "@alisio/core";
|
|
7
|
+
export declare const CYCLE_BLOCKED_HINT = "A turn is running: wait for it to finish before switching agents";
|
|
8
|
+
/** What the input listener must do with a keypress. */
|
|
9
|
+
export type ShiftTabDecision = {
|
|
10
|
+
type: "ignore";
|
|
11
|
+
}
|
|
12
|
+
/** A turn is running: say so and consume the key. */
|
|
13
|
+
| {
|
|
14
|
+
type: "blocked";
|
|
15
|
+
hint: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: "cycle";
|
|
18
|
+
};
|
|
19
|
+
export interface ShiftTabState {
|
|
20
|
+
/** A turn (or compaction) is running. */
|
|
21
|
+
busy: boolean;
|
|
22
|
+
/** A picker, menu or panel owns the keyboard. */
|
|
23
|
+
picker: boolean;
|
|
24
|
+
/** The slash/file autocomplete list is visible in the editor. */
|
|
25
|
+
autocomplete: boolean;
|
|
26
|
+
/** The subagent tree panel has the focus instead of the editor. */
|
|
27
|
+
panelFocused: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Shift+Tab cycles the main agents only when the editor owns the keyboard: with a picker, the
|
|
31
|
+
* autocomplete list or the agents panel open the key is left alone (not consumed), and during a
|
|
32
|
+
* turn it is consumed with a hint, because an agent switch applies from the next prompt.
|
|
33
|
+
*/
|
|
34
|
+
export declare function shiftTabDecision(data: string, state: ShiftTabState): ShiftTabDecision;
|
|
35
|
+
/** The one-line hint after cycling to `agent`; it names the model the agent declares, if any. */
|
|
36
|
+
export declare function agentCycleHint(agent: ActiveAgent, currentModel: string): string;
|
|
37
|
+
/** The mode name shown in the header and the status row (`locked` is shown as `read-only`). */
|
|
38
|
+
export declare const modeDisplay: (mode: PermissionModeLabel) => string;
|
|
39
|
+
export interface PermissionMenuRow {
|
|
40
|
+
value: PermissionMode | "status" | "manage";
|
|
41
|
+
label: string;
|
|
42
|
+
description: string;
|
|
43
|
+
}
|
|
44
|
+
/** The five rows of the `/permission` menu, in order (decision P2). */
|
|
45
|
+
export declare function permissionMenuRows(current: PermissionModeLabel): PermissionMenuRow[];
|
|
46
|
+
export type ModeTransition = {
|
|
47
|
+
ok: true;
|
|
48
|
+
mode: PermissionMode;
|
|
49
|
+
message: string;
|
|
50
|
+
warning?: string;
|
|
51
|
+
changed: boolean;
|
|
52
|
+
} | {
|
|
53
|
+
ok: false;
|
|
54
|
+
message: string;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Moves to `target`. Refused under `--read-only` (there is no approval handler to widen); setting
|
|
58
|
+
* the mode that is already active still succeeds, because it also resets "allow for this session"
|
|
59
|
+
* widenings. Full access always carries the not-a-sandbox warning.
|
|
60
|
+
*/
|
|
61
|
+
export declare function permissionTransition(current: PermissionModeLabel, target: PermissionMode): ModeTransition;
|
|
62
|
+
export declare const isPermissionMode: (value: string) => value is PermissionMode;
|
|
63
|
+
export declare const RELOAD_BUSY = "Reload is only available between turns: wait for the current turn to finish.";
|
|
64
|
+
/** A reason to refuse `/reload` right now, or undefined when it is safe. */
|
|
65
|
+
export declare function reloadGuard(state: {
|
|
66
|
+
busy: boolean;
|
|
67
|
+
runningChildren: number;
|
|
68
|
+
queuedPrompts: boolean;
|
|
69
|
+
/** Live background tasks (`bg_run`): closing the old application would kill them. */
|
|
70
|
+
backgroundTasks?: number;
|
|
71
|
+
}): string | undefined;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic of the TUI modes (spec: modes, goal and background, Phase 1): the Shift+Tab agent
|
|
3
|
+
* cycle, the `/permission` menu and transitions, and the `/reload` guard. No terminal output and
|
|
4
|
+
* no I/O, so it is unit-testable without a TTY; `app.ts` wires it to the editor and the pickers.
|
|
5
|
+
*/
|
|
6
|
+
import { FULL_ACCESS_WARNING, PERMISSION_MODE_TABLE, PERMISSION_MODES, PERMISSION_MODES_LOCKED, } from "@alisio/core";
|
|
7
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
8
|
+
export const CYCLE_BLOCKED_HINT = "A turn is running: wait for it to finish before switching agents";
|
|
9
|
+
/**
|
|
10
|
+
* Shift+Tab cycles the main agents only when the editor owns the keyboard: with a picker, the
|
|
11
|
+
* autocomplete list or the agents panel open the key is left alone (not consumed), and during a
|
|
12
|
+
* turn it is consumed with a hint, because an agent switch applies from the next prompt.
|
|
13
|
+
*/
|
|
14
|
+
export function shiftTabDecision(data, state) {
|
|
15
|
+
if (!matchesKey(data, Key.shift("tab")))
|
|
16
|
+
return { type: "ignore" };
|
|
17
|
+
if (state.picker || state.autocomplete || state.panelFocused)
|
|
18
|
+
return { type: "ignore" };
|
|
19
|
+
if (state.busy)
|
|
20
|
+
return { type: "blocked", hint: CYCLE_BLOCKED_HINT };
|
|
21
|
+
return { type: "cycle" };
|
|
22
|
+
}
|
|
23
|
+
/** The one-line hint after cycling to `agent`; it names the model the agent declares, if any. */
|
|
24
|
+
export function agentCycleHint(agent, currentModel) {
|
|
25
|
+
const base = `Agent: ${agent.name}${agent.readOnly ? " (read-only)" : ""} · Shift+Tab cycles agents`;
|
|
26
|
+
return agent.model && agent.model !== currentModel
|
|
27
|
+
? `${base} · it declares model ${agent.model}: run /agents to apply it`
|
|
28
|
+
: base;
|
|
29
|
+
}
|
|
30
|
+
/** The mode name shown in the header and the status row (`locked` is shown as `read-only`). */
|
|
31
|
+
export const modeDisplay = (mode) => mode === "locked" ? "read-only" : mode;
|
|
32
|
+
/** The five rows of the `/permission` menu, in order (decision P2). */
|
|
33
|
+
export function permissionMenuRows(current) {
|
|
34
|
+
const locked = current === "locked";
|
|
35
|
+
const mode = (id, label) => ({
|
|
36
|
+
value: id,
|
|
37
|
+
label: `${label}${current === id ? " (current)" : ""}`,
|
|
38
|
+
description: locked
|
|
39
|
+
? "Locked: Alisio was started with --read-only"
|
|
40
|
+
: id === "full"
|
|
41
|
+
? `${PERMISSION_MODE_TABLE[id].summary} Not a sandbox.`
|
|
42
|
+
: PERMISSION_MODE_TABLE[id].summary,
|
|
43
|
+
});
|
|
44
|
+
return [
|
|
45
|
+
mode("ask", "Use ask mode"),
|
|
46
|
+
mode("auto", "Use auto mode"),
|
|
47
|
+
mode("full", "Use full access mode"),
|
|
48
|
+
{
|
|
49
|
+
value: "status",
|
|
50
|
+
label: "Status",
|
|
51
|
+
description: "Show the current mode and what runs without asking",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
value: "manage",
|
|
55
|
+
label: "Manage saved permissions…",
|
|
56
|
+
description: "Review and revoke the permissions saved for this session",
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Moves to `target`. Refused under `--read-only` (there is no approval handler to widen); setting
|
|
62
|
+
* the mode that is already active still succeeds, because it also resets "allow for this session"
|
|
63
|
+
* widenings. Full access always carries the not-a-sandbox warning.
|
|
64
|
+
*/
|
|
65
|
+
export function permissionTransition(current, target) {
|
|
66
|
+
if (current === "locked")
|
|
67
|
+
return { ok: false, message: PERMISSION_MODES_LOCKED };
|
|
68
|
+
const spec = PERMISSION_MODE_TABLE[target];
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
mode: target,
|
|
72
|
+
changed: current !== target,
|
|
73
|
+
message: `Permission mode: ${target}. ${spec.summary}`,
|
|
74
|
+
...(target === "full" ? { warning: FULL_ACCESS_WARNING } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export const isPermissionMode = (value) => PERMISSION_MODES.includes(value);
|
|
78
|
+
export const RELOAD_BUSY = "Reload is only available between turns: wait for the current turn to finish.";
|
|
79
|
+
/** A reason to refuse `/reload` right now, or undefined when it is safe. */
|
|
80
|
+
export function reloadGuard(state) {
|
|
81
|
+
if (state.busy)
|
|
82
|
+
return RELOAD_BUSY;
|
|
83
|
+
if (state.backgroundTasks)
|
|
84
|
+
return `Reload would stop ${state.backgroundTasks} running background task${state.backgroundTasks === 1 ? "" : "s"}: stop ${state.backgroundTasks === 1 ? "it" : "them"} with /tasks or wait for ${state.backgroundTasks === 1 ? "it" : "them"} to finish, then reload.`;
|
|
85
|
+
if (state.runningChildren > 0)
|
|
86
|
+
return `Reload is unavailable while ${state.runningChildren} subagent${state.runningChildren === 1 ? " is" : "s are"} running: wait for ${state.runningChildren === 1 ? "it" : "them"} to finish or cancel with Ctrl+K in the agents panel.`;
|
|
87
|
+
if (state.queuedPrompts)
|
|
88
|
+
return "Reload is unavailable while an approval or question is waiting.";
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening and revealing local files with the desktop's default application, portably and without
|
|
3
|
+
* a shell: arguments are an array (no `cmd /c start`, no `sh -c`), so a file name can never inject
|
|
4
|
+
* a command. Only paths returned by the artifact store are opened, never a path the model wrote.
|
|
5
|
+
*/
|
|
6
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
7
|
+
export interface Launch {
|
|
8
|
+
command: string;
|
|
9
|
+
args: string[];
|
|
10
|
+
}
|
|
11
|
+
export type OpenOutcome = {
|
|
12
|
+
ok: true;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
reason: "no-desktop" | "unavailable";
|
|
16
|
+
};
|
|
17
|
+
export interface OpenDeps {
|
|
18
|
+
platform?: NodeJS.Platform;
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
spawn?: (command: string, args: string[], options: Parameters<typeof spawn>[2]) => ChildProcess;
|
|
21
|
+
}
|
|
22
|
+
/** Commands that open `path` with the default app, in fallback order. */
|
|
23
|
+
export declare function openCommands(path: string, platform: NodeJS.Platform): Launch[];
|
|
24
|
+
/** Commands that show `path` selected in the file manager (its folder on Linux/BSD). */
|
|
25
|
+
export declare function revealCommands(path: string, platform: NodeJS.Platform): Launch[];
|
|
26
|
+
/**
|
|
27
|
+
* Whether a desktop session can show a window: never over SSH; on Linux/BSD only with a display
|
|
28
|
+
* (`DISPLAY` or `WAYLAND_DISPLAY`). macOS and Windows always have one locally.
|
|
29
|
+
*/
|
|
30
|
+
export declare function desktopAvailable(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean;
|
|
31
|
+
/** Opens a file with the default application. */
|
|
32
|
+
export declare const openPath: (path: string, deps?: OpenDeps) => Promise<OpenOutcome>;
|
|
33
|
+
/** Shows a file in the system file manager. */
|
|
34
|
+
export declare const revealPath: (path: string, deps?: OpenDeps) => Promise<OpenOutcome>;
|
|
35
|
+
/** The notice shown when nothing could be opened (the caller offers "Copy path"). */
|
|
36
|
+
export declare const cannotOpenMessage: (path: string, outcome: Exclude<OpenOutcome, {
|
|
37
|
+
ok: true;
|
|
38
|
+
}>) => string;
|