@turri-dev/cli 0.1.16 → 0.1.17-next.63f454c

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.
Files changed (46) hide show
  1. package/dist/ask.d.ts +2 -0
  2. package/dist/ask.js +2 -0
  3. package/dist/chat-commands.d.ts +25 -0
  4. package/dist/chat-commands.js +162 -0
  5. package/dist/chat-records.d.ts +44 -0
  6. package/dist/chat-records.js +102 -0
  7. package/dist/chat-scope.d.ts +14 -0
  8. package/dist/chat-scope.js +15 -0
  9. package/dist/chat-terminal.d.ts +23 -0
  10. package/dist/chat-terminal.js +163 -0
  11. package/dist/chat-ui.d.ts +33 -0
  12. package/dist/chat-ui.js +192 -0
  13. package/dist/chats.d.ts +192 -0
  14. package/dist/chats.js +378 -0
  15. package/dist/cli.js +5 -7
  16. package/dist/config.d.ts +8 -2
  17. package/dist/config.js +18 -4
  18. package/dist/extension.d.ts +20 -33
  19. package/dist/extension.js +427 -272
  20. package/dist/genome.d.ts +13 -7
  21. package/dist/genome.js +13 -3
  22. package/dist/host-session.d.ts +58 -0
  23. package/dist/host-session.js +72 -0
  24. package/dist/host.d.ts +5 -4
  25. package/dist/host.js +6 -5
  26. package/dist/identity.js +5 -0
  27. package/dist/pi-launch.js +23 -1
  28. package/dist/question-dialog.d.ts +4 -0
  29. package/dist/question-dialog.js +13 -2
  30. package/dist/regenerate/activity.d.ts +8 -1
  31. package/dist/regenerate/activity.js +23 -2
  32. package/dist/regenerate/jobs.d.ts +2 -0
  33. package/dist/regenerate/jobs.js +3 -0
  34. package/dist/regenerate/ledger.d.ts +7 -0
  35. package/dist/regenerate/ledger.js +8 -1
  36. package/dist/regenerate/test-scripts.d.ts +1 -1
  37. package/dist/stacks.d.ts +2 -1
  38. package/dist/stacks.js +4 -3
  39. package/docs/README.md +4 -2
  40. package/docs/chats.md +75 -0
  41. package/docs/config.md +3 -3
  42. package/docs/development.md +4 -1
  43. package/docs/hosting.md +37 -28
  44. package/docs/skills-and-gates.md +2 -2
  45. package/docs/using.md +2 -2
  46. package/package.json +1 -1
package/dist/ask.d.ts CHANGED
@@ -46,6 +46,8 @@ export declare const DONE_OPTION = "Done \u2014 that's all of them";
46
46
  export declare const TICKED = "\u2611";
47
47
  export declare const UNTICKED = "\u2610";
48
48
  export declare const DISMISSED = "The user dismissed the question without answering.";
49
+ /** ctrl+c at a question: the person would not answer it - or stopped the turn that asked it. */
50
+ export declare const DECLINED = "The user declined to answer the question.";
49
51
  /** How an answer that is no answer starts: the person chose to talk about the question first. */
50
52
  export declare const CHAT_PREFIX = "The user has not answered this question yet: they chose to talk about it first.";
51
53
  /** What comes back when the person chose "Chat about this": what they said, and what they had picked so far. */
package/dist/ask.js CHANGED
@@ -13,6 +13,8 @@ export const DONE_OPTION = "Done — that's all of them";
13
13
  export const TICKED = "☑";
14
14
  export const UNTICKED = "☐";
15
15
  export const DISMISSED = "The user dismissed the question without answering.";
16
+ /** ctrl+c at a question: the person would not answer it - or stopped the turn that asked it. */
17
+ export const DECLINED = "The user declined to answer the question.";
16
18
  /** How an answer that is no answer starts: the person chose to talk about the question first. */
17
19
  export const CHAT_PREFIX = "The user has not answered this question yet: they chose to talk about it first.";
18
20
  /** What comes back when the person chose "Chat about this": what they said, and what they had picked so far. */
@@ -0,0 +1,25 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { type ChatInfo, type TurriCore } from "./chats.ts";
3
+ /**
4
+ * The terminal's chats, for the person: `/chat` - new, close, a number, or a pick - its keys, and the strip above the
5
+ * editor naming each chat with what it is doing.
6
+ */
7
+ /** A fresh chat. */
8
+ export declare const NEW_CHAT_KEY = "alt+n";
9
+ /** The chat shown, closed. */
10
+ export declare const CLOSE_CHAT_KEY = "alt+w";
11
+ /**
12
+ * On a Mac, alt is Option, which types ¡ and ñ unless the terminal sends it as Meta: said once a session, as the second
13
+ * chat opens - when the keys are first worth having.
14
+ */
15
+ export declare const OPTION_AS_META: string;
16
+ /** Said once a session, as its second chat opens, on a Mac. */
17
+ export declare function tellOfOptionOnce(session: TurriCore, platform?: NodeJS.Platform): void;
18
+ /** What a chat's mark in the strip and the picker says it is doing. */
19
+ export declare function chatMark(chat: ChatInfo, now?: number): string;
20
+ /** The strip: each chat by its number, its title and its mark - the one shown set apart - on one line, a rule between them. */
21
+ export declare function chatStrip(chats: ChatInfo[], width: number, style: {
22
+ shown: (text: string) => string;
23
+ muted: (text: string) => string;
24
+ }, now?: number): string[];
25
+ export declare function registerChats(pi: ExtensionAPI, session: TurriCore): void;
@@ -0,0 +1,162 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { MAX_CHATS } from "./chats.js";
3
+ import { spinner } from "./regenerate/job-panel.js";
4
+ /**
5
+ * The terminal's chats, for the person: `/chat` - new, close, a number, or a pick - its keys, and the strip above the
6
+ * editor naming each chat with what it is doing.
7
+ */
8
+ /** A fresh chat. */
9
+ export const NEW_CHAT_KEY = "alt+n";
10
+ /** The chat shown, closed. */
11
+ export const CLOSE_CHAT_KEY = "alt+w";
12
+ /** alt+1 … alt+9: that chat, shown. */
13
+ const SHOW_KEYS = Array.from({ length: MAX_CHATS }, (_, index) => `alt+${index + 1}`);
14
+ const STRIP_WIDGET = "turri-chats";
15
+ /**
16
+ * On a Mac, alt is Option, which types ¡ and ñ unless the terminal sends it as Meta: said once a session, as the second
17
+ * chat opens - when the keys are first worth having.
18
+ */
19
+ export const OPTION_AS_META = "Chats: ⌥1…⌥9 switch, ⌥N opens one, ⌥W closes the one shown - on macOS only once your terminal sends Option as Meta: " +
20
+ "Ghostty, macos-option-as-alt = true; iTerm2, Profiles → Keys → Left Option key: Esc+; Terminal.app, Profiles → Keyboard → " +
21
+ "Use Option as Meta key; Kitty, macos_option_as_alt yes. /chat works without it.";
22
+ /** The sessions told already. */
23
+ const toldOfOption = new WeakSet();
24
+ /** Said once a session, as its second chat opens, on a Mac. */
25
+ export function tellOfOptionOnce(session, platform = process.platform) {
26
+ if (platform !== "darwin" || toldOfOption.has(session))
27
+ return;
28
+ toldOfOption.add(session);
29
+ const stop = session.onChats(() => {
30
+ if (session.chats().length < 2)
31
+ return;
32
+ stop();
33
+ // Once the new chat is on the screen: said there.
34
+ setTimeout(() => session.notify(OPTION_AS_META, "info"), 0);
35
+ });
36
+ }
37
+ /** What a chat's mark in the strip and the picker says it is doing. */
38
+ export function chatMark(chat, now = Date.now()) {
39
+ switch (chat.status) {
40
+ case "working":
41
+ return spinner(now);
42
+ case "question":
43
+ return "?";
44
+ case "unread":
45
+ return "•";
46
+ default:
47
+ return " ";
48
+ }
49
+ }
50
+ /** Between two chats in the strip. */
51
+ const STRIP_DELIMITER = " │ ";
52
+ /** The strip: each chat by its number, its title and its mark - the one shown set apart - on one line, a rule between them. */
53
+ export function chatStrip(chats, width, style, now = Date.now()) {
54
+ if (chats.length < 2)
55
+ return [];
56
+ const room = Math.max(8, Math.floor((width - 2) / chats.length) - STRIP_DELIMITER.length - 4);
57
+ const parts = chats.map((chat, index) => {
58
+ const mark = chatMark(chat, now).trim();
59
+ const label = `${index + 1} ${truncateToWidth(chat.title, room, "…")}${mark !== "" ? ` ${mark}` : ""}`;
60
+ return chat.shown ? style.shown(label) : style.muted(label);
61
+ });
62
+ return [truncateToWidth(` ${parts.join(style.muted(STRIP_DELIMITER))}`, width, "…")];
63
+ }
64
+ const label = (chat, index) => `${index + 1} ${chatMark(chat)} ${chat.title}${chat.shown ? " (shown)" : ""}`;
65
+ /** What the person asked of the chats, done once pi has finished with the command or key that asked it. */
66
+ function later(ctx, session, work) {
67
+ setTimeout(() => {
68
+ work().catch((error) => {
69
+ try {
70
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
71
+ }
72
+ catch {
73
+ session.notify(error instanceof Error ? error.message : String(error), "warning");
74
+ }
75
+ });
76
+ }, 0);
77
+ }
78
+ /** The chat `which` names - its number, or the shown one when none - or why there is none. */
79
+ function chatNamed(session, which) {
80
+ const chats = session.chats();
81
+ if (which === "")
82
+ return chats.find((chat) => chat.shown) ?? "No chat is shown.";
83
+ const number = Number.parseInt(which, 10);
84
+ const chat = Number.isInteger(number) ? chats[number - 1] : undefined;
85
+ return chat ?? `There is no chat ${which}: ${chats.length === 1 ? "there is one" : `there are ${chats.length}`}.`;
86
+ }
87
+ function closeChat(ctx, session, which) {
88
+ const chat = chatNamed(session, which);
89
+ if (typeof chat === "string")
90
+ return ctx.ui.notify(chat, "warning");
91
+ if (session.chats().length === 1)
92
+ return ctx.ui.notify("This is the only chat - /quit leaves turri.", "info");
93
+ later(ctx, session, () => session.close(chat.id));
94
+ }
95
+ function showChat(ctx, session, which) {
96
+ const chat = chatNamed(session, which);
97
+ if (typeof chat === "string")
98
+ return ctx.ui.notify(chat, "warning");
99
+ later(ctx, session, () => session.show(chat.id));
100
+ }
101
+ export function registerChats(pi, session) {
102
+ tellOfOptionOnce(session);
103
+ pi.registerCommand("chat", {
104
+ description: `Chats beside this one, each working while another is shown: /chat [new | close [n] | n] - ${NEW_CHAT_KEY} a new one, alt+1…9 to switch, ${CLOSE_CHAT_KEY} to close${process.platform === "darwin" ? " (alt is ⌥, once the terminal sends Option as Meta)" : ""}`,
105
+ getArgumentCompletions: (prefix) => {
106
+ const items = ["new", "close", ...session.chats().map((_, index) => `${index + 1}`)].filter((item) => item.startsWith(prefix)).map((item) => ({ value: item, label: item }));
107
+ return items.length > 0 ? items : null;
108
+ },
109
+ handler: async (args, ctx) => {
110
+ const [verb = "", rest = ""] = args.trim().split(/\s+(.*)/s);
111
+ if (verb === "new")
112
+ return later(ctx, session, () => session.open());
113
+ if (verb === "close")
114
+ return closeChat(ctx, session, rest.trim());
115
+ if (verb !== "")
116
+ return showChat(ctx, session, verb);
117
+ const chats = session.chats();
118
+ const fresh = `New chat (${NEW_CHAT_KEY})`;
119
+ const picked = await ctx.ui.select(`Chats - ${chats.length} of ${MAX_CHATS}`, [...chats.map(label), ...(chats.length < MAX_CHATS ? [fresh] : [])]);
120
+ if (picked === undefined)
121
+ return;
122
+ if (picked === fresh)
123
+ return later(ctx, session, () => session.open());
124
+ const chosen = chats.find((chat, index) => label(chat, index) === picked);
125
+ if (chosen !== undefined)
126
+ later(ctx, session, () => session.show(chosen.id));
127
+ },
128
+ });
129
+ pi.registerShortcut(NEW_CHAT_KEY, {
130
+ description: "A new chat beside this one",
131
+ handler: async (ctx) => later(ctx, session, () => session.open()),
132
+ });
133
+ pi.registerShortcut(CLOSE_CHAT_KEY, {
134
+ description: "Close this chat",
135
+ handler: async (ctx) => closeChat(ctx, session, ""),
136
+ });
137
+ SHOW_KEYS.forEach((key, index) => pi.registerShortcut(key, {
138
+ description: `Show chat ${index + 1}`,
139
+ handler: async (ctx) => showChat(ctx, session, `${index + 1}`),
140
+ }));
141
+ // The strip above the editor, while there are two chats or more: drawn again as they change, and while one works.
142
+ pi.on("session_start", (_event, ctx) => {
143
+ if (!ctx.hasUI)
144
+ return;
145
+ ctx.ui.setWidget(STRIP_WIDGET, (tui, theme) => {
146
+ const stop = session.onChats(() => tui.requestRender());
147
+ const ticker = setInterval(() => {
148
+ if (session.chats().some((chat) => chat.status === "working"))
149
+ tui.requestRender();
150
+ }, 250);
151
+ ticker.unref?.();
152
+ return {
153
+ render: (width) => chatStrip(session.chats(), width, { shown: (text) => theme.bold(theme.fg("accent", text)), muted: (text) => theme.fg("muted", text) }),
154
+ invalidate: () => { },
155
+ dispose: () => {
156
+ stop();
157
+ clearInterval(ticker);
158
+ },
159
+ };
160
+ });
161
+ });
162
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Which chats a turri session has open, so `turri --continue` opens them all again, in their order: `.turri/chats.json`
3
+ * in the project, a run a turri process. A chat is pi's session file - its conversation - with the title turri shows
4
+ * it by; one with nothing said in it has no file, and is not recorded.
5
+ *
6
+ * Nothing piles up: a chat closed is gone from it, its session file deleted; a run's chats are kept until five newer
7
+ * runs have been recorded, then deleted with it. In debug mode nothing is deleted.
8
+ */
9
+ export interface ChatRecord {
10
+ id: string;
11
+ title: string;
12
+ /** pi's session file: the chat's conversation. */
13
+ sessionFile: string;
14
+ }
15
+ export interface RunRecord {
16
+ run: string;
17
+ /** The turri process: a run whose process still runs is that turri's, and is never reopened by another. */
18
+ pid: number;
19
+ started: string;
20
+ chats: ChatRecord[];
21
+ }
22
+ /** How many runs' chats are kept: the most recent, the one `--continue` reopens, among them. */
23
+ export declare const RUNS_KEPT = 5;
24
+ export declare function chatsFile(project: string): string;
25
+ /** The runs recorded here, the most recent first. */
26
+ export declare function recordedRuns(project: string): RunRecord[];
27
+ /**
28
+ * The run `--continue` reopens: the most recent whose turri has ended and that has a chat whose conversation is still
29
+ * there. Its chats, in their order, those still there alone.
30
+ */
31
+ export declare function lastRun(project: string): RunRecord | undefined;
32
+ /**
33
+ * This run's record: its chats written as they are now, in their order, and the runs past the newest RUNS_KEPT dropped
34
+ * with their conversations - unless `keep`, in debug mode.
35
+ */
36
+ export declare function recordRun(project: string, run: RunRecord, options?: {
37
+ keep?: boolean;
38
+ }): void;
39
+ /** A run's record taken over by this one: its chats are this run's now, and it is gone. */
40
+ export declare function adoptRun(project: string, adopted: string): void;
41
+ /** A chat closed: out of its run's record, and its conversation deleted - unless `keep`, in debug mode. */
42
+ export declare function forgetChat(project: string, run: string, chat: ChatRecord | undefined, options?: {
43
+ keep?: boolean;
44
+ }): void;
@@ -0,0 +1,102 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ /** How many runs' chats are kept: the most recent, the one `--continue` reopens, among them. */
4
+ export const RUNS_KEPT = 5;
5
+ export function chatsFile(project) {
6
+ return join(project, ".turri", "chats.json");
7
+ }
8
+ function read(project) {
9
+ try {
10
+ const parsed = JSON.parse(readFileSync(chatsFile(project), "utf8"));
11
+ return { runs: Array.isArray(parsed.runs) ? parsed.runs.filter((run) => typeof run?.run === "string" && Array.isArray(run.chats)) : [] };
12
+ }
13
+ catch {
14
+ return { runs: [] };
15
+ }
16
+ }
17
+ function write(project, file) {
18
+ const path = chatsFile(project);
19
+ try {
20
+ if (file.runs.length === 0) {
21
+ rmSync(path, { force: true });
22
+ return;
23
+ }
24
+ mkdirSync(dirname(path), { recursive: true });
25
+ // Whole or not at all: two turris may write it.
26
+ const partial = `${path}.${process.pid}.tmp`;
27
+ writeFileSync(partial, `${JSON.stringify(file, null, 2)}\n`);
28
+ renameSync(partial, path);
29
+ }
30
+ catch {
31
+ // A chat not recorded is not reopened; nothing else is lost.
32
+ }
33
+ }
34
+ function deleteSession(file) {
35
+ try {
36
+ rmSync(file, { force: true });
37
+ }
38
+ catch {
39
+ /* gone already, or not ours to delete */
40
+ }
41
+ }
42
+ function alive(pid) {
43
+ if (pid === process.pid)
44
+ return true;
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ }
49
+ catch (error) {
50
+ return error.code === "EPERM";
51
+ }
52
+ }
53
+ /** The runs recorded here, the most recent first. */
54
+ export function recordedRuns(project) {
55
+ return [...read(project).runs].sort((a, b) => b.started.localeCompare(a.started));
56
+ }
57
+ /**
58
+ * The run `--continue` reopens: the most recent whose turri has ended and that has a chat whose conversation is still
59
+ * there. Its chats, in their order, those still there alone.
60
+ */
61
+ export function lastRun(project) {
62
+ for (const run of recordedRuns(project)) {
63
+ if (alive(run.pid))
64
+ continue;
65
+ const chats = run.chats.filter((chat) => existsSync(chat.sessionFile));
66
+ if (chats.length > 0)
67
+ return { ...run, chats };
68
+ }
69
+ return undefined;
70
+ }
71
+ /**
72
+ * This run's record: its chats written as they are now, in their order, and the runs past the newest RUNS_KEPT dropped
73
+ * with their conversations - unless `keep`, in debug mode.
74
+ */
75
+ export function recordRun(project, run, options = {}) {
76
+ const file = read(project);
77
+ const others = file.runs.filter((other) => other.run !== run.run).sort((a, b) => b.started.localeCompare(a.started));
78
+ const runs = [run, ...others].filter((other) => other.chats.length > 0);
79
+ const kept = options.keep === true ? runs : runs.filter((other, index) => index < RUNS_KEPT || alive(other.pid));
80
+ if (options.keep !== true) {
81
+ for (const dropped of runs.filter((other) => !kept.includes(other)))
82
+ for (const chat of dropped.chats)
83
+ deleteSession(chat.sessionFile);
84
+ }
85
+ write(project, { runs: kept });
86
+ }
87
+ /** A run's record taken over by this one: its chats are this run's now, and it is gone. */
88
+ export function adoptRun(project, adopted) {
89
+ const file = read(project);
90
+ write(project, { runs: file.runs.filter((run) => run.run !== adopted) });
91
+ }
92
+ /** A chat closed: out of its run's record, and its conversation deleted - unless `keep`, in debug mode. */
93
+ export function forgetChat(project, run, chat, options = {}) {
94
+ if (chat === undefined)
95
+ return;
96
+ const file = read(project);
97
+ write(project, {
98
+ runs: file.runs.map((other) => (other.run === run ? { ...other, chats: other.chats.filter((kept) => kept.id !== chat.id) } : other)).filter((other) => other.chats.length > 0),
99
+ });
100
+ if (options.keep !== true)
101
+ deleteSession(chat.sessionFile);
102
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The chat a piece of work runs for: its tools, its commands, its event handlers - and what they start, a pulse
3
+ * queued from them among it. A turri session holds several chats (chats.ts); what each keeps for itself rides along
4
+ * with its work here: its id, which pulses and the ledger's events carry, and the stack it chose.
5
+ */
6
+ export interface ChatScope {
7
+ id: string;
8
+ /** The stack this chat chose with /stack or the stack tool - none: the project's config alone. */
9
+ stack: string | undefined;
10
+ }
11
+ /** The chat this code runs for - undefined outside every chat: the command line, a test. */
12
+ export declare function currentChat(): ChatScope | undefined;
13
+ /** Run `work` for `chat`: what it starts, and what that starts, is the chat's. */
14
+ export declare function inChat<T>(chat: ChatScope, work: () => T): T;
@@ -0,0 +1,15 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /**
3
+ * One store for the whole process. pi loads an extension through its own module loader when it is given a path rather
4
+ * than a factory - an older turri installed into every pi - and that copy of this module must find the same chat.
5
+ */
6
+ const STORE = Symbol.for("turri.chat-scope");
7
+ const scope = (globalThis[STORE] ??= new AsyncLocalStorage());
8
+ /** The chat this code runs for - undefined outside every chat: the command line, a test. */
9
+ export function currentChat() {
10
+ return scope.getStore();
11
+ }
12
+ /** Run `work` for `chat`: what it starts, and what that starts, is the chat's. */
13
+ export function inChat(chat, work) {
14
+ return scope.run(chat, work);
15
+ }
@@ -0,0 +1,23 @@
1
+ import type { RunRecord } from "./chat-records.ts";
2
+ import { type Stoppable, type TurriCore } from "./chats.ts";
3
+ /** What ctrl+c reaches of pi's screen: the chat shown, its editor, and leaving. */
4
+ export interface CtrlCScreen {
5
+ session: Stoppable;
6
+ editor: {
7
+ getText(): string;
8
+ };
9
+ clearEditor(): void;
10
+ /** Its queued messages back in the editor - and, with `abort`, the turn stopped: pi's own esc. */
11
+ restoreQueuedMessagesToEditor(options?: {
12
+ abort?: boolean;
13
+ }): number;
14
+ shutdown(): Promise<void>;
15
+ }
16
+ /**
17
+ * ctrl+c, in the chat shown: what it is doing stopped, the other chats left as they are (`interruptChat`). In a chat
18
+ * doing nothing it clears a draft in the editor, and with none leaves turri, every chat with it.
19
+ */
20
+ export declare function pressCtrlC(screen: CtrlCScreen, lastStop: number, now?: number): "stopped" | "ignored" | "cleared" | "left";
21
+ export declare function driveTerminal(core: TurriCore, options?: {
22
+ restore?: RunRecord;
23
+ }): void;
@@ -0,0 +1,163 @@
1
+ import { AgentSessionRuntime, InteractiveMode, SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import { interruptChat, terminalUi } from "./chats.js";
3
+ /**
4
+ * ctrl+c, in the chat shown: what it is doing stopped, the other chats left as they are (`interruptChat`). In a chat
5
+ * doing nothing it clears a draft in the editor, and with none leaves turri, every chat with it.
6
+ */
7
+ export function pressCtrlC(screen, lastStop, now = Date.now()) {
8
+ return interruptChat(screen.session, {
9
+ lastStop,
10
+ now,
11
+ stopTurn: () => void screen.restoreQueuedMessagesToEditor({ abort: true }),
12
+ idle: () => {
13
+ if (screen.editor.getText().trim() !== "") {
14
+ screen.clearEditor();
15
+ return "cleared";
16
+ }
17
+ void screen.shutdown();
18
+ return "left";
19
+ },
20
+ });
21
+ }
22
+ export function driveTerminal(core, options = {}) {
23
+ let screen;
24
+ let runtime;
25
+ let real;
26
+ let restore = options.restore;
27
+ /** The screen has drawn a session: the chat it is, shown - drawing on its stand-in from now on. */
28
+ const bound = () => {
29
+ const session = runtime.session;
30
+ const chat = core.adopt(session, runtime._services);
31
+ const runner = session.extensionRunner;
32
+ real = runner.getUIContext();
33
+ const ui = terminalUi(chat, () => real, () => core.changed());
34
+ ui.show(real);
35
+ runner.setUIContext(ui.ui, runner.mode);
36
+ catchUp(session);
37
+ core.showing(chat);
38
+ // The run `--continue` reopens: its first chat is pi's own, the rest are opened beside it.
39
+ if (restore !== undefined) {
40
+ const saved = restore;
41
+ restore = undefined;
42
+ void core.restore(saved, { skipFirst: true });
43
+ }
44
+ };
45
+ /**
46
+ * A chat shown mid-turn: the screen draws what its session has saved, and the reply being written is not saved yet -
47
+ * the screen is told the turn and the reply began, and follows it from there.
48
+ */
49
+ const catchUp = (session) => {
50
+ if (!session.isStreaming || screen === undefined)
51
+ return;
52
+ void screen.handleEvent({ type: "agent_start" });
53
+ const writing = session.agent.state.streamingMessage;
54
+ if (writing !== undefined && writing.role === "assistant")
55
+ void screen.handleEvent({ type: "message_start", message: writing });
56
+ };
57
+ const prototype = InteractiveMode.prototype;
58
+ const rebind = prototype.rebindCurrentSession;
59
+ prototype.rebindCurrentSession = async function (...args) {
60
+ await rebind.apply(this, args);
61
+ screen = this;
62
+ runtime = this.runtimeHost;
63
+ bound();
64
+ };
65
+ const runtimes = AgentSessionRuntime.prototype;
66
+ const opening = async (open) => {
67
+ try {
68
+ await open();
69
+ return { cancelled: false };
70
+ }
71
+ catch (error) {
72
+ core.notify(error instanceof Error ? error.message : String(error), "warning");
73
+ return { cancelled: true };
74
+ }
75
+ };
76
+ // ctrl+c: pi's clears the editor and leaves on a second press; here it stops what the chat shown is doing, and leaves
77
+ // when it is doing nothing.
78
+ let lastStop = 0;
79
+ prototype.handleCtrlC = function () {
80
+ const did = pressCtrlC(this, lastStop);
81
+ if (did === "stopped")
82
+ lastStop = Date.now();
83
+ };
84
+ // /new: a fresh chat beside the others.
85
+ const newSession = runtimes.newSession;
86
+ runtimes.newSession = function (asked) {
87
+ if (this !== runtime || asked?.parentSession !== undefined || asked?.setup !== undefined)
88
+ return newSession.call(this, asked);
89
+ return opening(() => core.open());
90
+ };
91
+ // /resume: the saved chat, as a chat of its own - or shown, when one is open on it.
92
+ const switchSession = runtimes.switchSession;
93
+ runtimes.switchSession = function (path, asked) {
94
+ if (this !== runtime)
95
+ return switchSession.call(this, path, asked);
96
+ return opening(() => core.open({ sessionFile: path }));
97
+ };
98
+ // Leaving turri: every chat let go - the one shown by pi, as it always is.
99
+ const dispose = runtimes.dispose;
100
+ runtimes.dispose = async function () {
101
+ if (this === runtime)
102
+ await core.dispose({ except: runtime.session });
103
+ return dispose.call(this);
104
+ };
105
+ core.setDriver({
106
+ terminal: true,
107
+ async create(chat, sessionFile) {
108
+ if (runtime === undefined)
109
+ throw new Error("turri's screen is not up yet: open a chat once it is.");
110
+ const now = runtime;
111
+ const manager = now.session.sessionManager;
112
+ const sessionManager = sessionFile !== undefined
113
+ ? SessionManager.open(sessionFile, manager.getSessionDir())
114
+ : manager.isPersisted()
115
+ ? SessionManager.create(now.cwd, manager.getSessionDir())
116
+ : SessionManager.inMemory(now.cwd);
117
+ const made = await now.createRuntime({
118
+ cwd: now.cwd,
119
+ agentDir: now.services.agentDir,
120
+ sessionManager,
121
+ sessionStartEvent: { type: "session_start", reason: sessionFile !== undefined ? "resume" : "new", previousSessionFile: now.session.sessionFile },
122
+ });
123
+ chat.session = made.session;
124
+ chat.services = made.services;
125
+ // Up and working before it is ever shown: on its stand-in.
126
+ const ui = terminalUi(chat, () => real, () => core.changed());
127
+ await made.session.bindExtensions({ uiContext: ui.ui, mode: "tui" });
128
+ },
129
+ async show(chat, from) {
130
+ if (runtime === undefined || chat.session === undefined)
131
+ return;
132
+ from?.ui?.hide();
133
+ // pi takes the shown chat's widgets and dialogs off the screen, as it does before replacing a session - but not the
134
+ // overlay on it: the side panel, the session's and not the chat's, stays where it is, beside the chat or over it.
135
+ const tui = screen?.ui;
136
+ const hideOverlay = tui?.hideOverlay;
137
+ if (tui !== undefined)
138
+ tui.hideOverlay = () => { };
139
+ try {
140
+ runtime.beforeSessionInvalidate?.();
141
+ }
142
+ finally {
143
+ if (tui !== undefined && hideOverlay !== undefined)
144
+ tui.hideOverlay = hideOverlay;
145
+ }
146
+ runtime._session = chat.session;
147
+ runtime._services = chat.services;
148
+ // The screen drawn from the chat's session, and bound to it: `bound` above.
149
+ await runtime.rebindSession?.(chat.session);
150
+ },
151
+ async dispose(chat) {
152
+ const session = chat.session;
153
+ if (session === undefined)
154
+ return;
155
+ try {
156
+ await session.abort();
157
+ }
158
+ finally {
159
+ session.dispose();
160
+ }
161
+ },
162
+ });
163
+ }
@@ -0,0 +1,33 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ /**
3
+ * What a chat draws on, in a terminal of several chats: pi's screen while the chat is shown, and a stand-in while it is
4
+ * not. pi has one screen; a hidden chat goes on working - its turns, its tools, a pulse it hears of - and what it
5
+ * would draw must neither land on the chat shown nor be lost:
6
+ *
7
+ * - its questions - a dialog, a pick, a confirm - wait, and are asked once it is shown, one after another;
8
+ * - what it says in passing - a notice - is said when it is shown;
9
+ * - what it keeps on the screen - its widgets, statuses, the working line - is kept, and put back each time it is
10
+ * shown: pi clears an extension's widgets as it shows another session;
11
+ * - what is typed in its editor and not sent stays its own.
12
+ *
13
+ * Everything else - the theme, how tools are drawn - is the screen's, read where it is.
14
+ */
15
+ export interface ChatUi {
16
+ /** What the chat's extension is given as its ui, shown or not. */
17
+ readonly ui: ExtensionUIContext;
18
+ readonly shown: boolean;
19
+ /** Shown on `screen`: what it keeps put back, its draft in the editor, what it said told, its questions asked. */
20
+ show(screen: ExtensionUIContext): void;
21
+ /** Another chat is shown: its draft kept, what it draws from now on held back. */
22
+ hide(): void;
23
+ /** The questions waiting for it to be shown. */
24
+ waiting(): number;
25
+ /** The chat is closed: every question waiting is answered with nothing. */
26
+ close(): void;
27
+ }
28
+ export declare function createChatUi(options: {
29
+ /** The screen to read the theme and the like from before the chat was ever shown. */
30
+ screen: () => ExtensionUIContext | undefined;
31
+ /** Its questions waiting changed, or it was shown or hidden. */
32
+ onChange?: () => void;
33
+ }): ChatUi;