pi-telegram-plus 0.0.1

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.
@@ -0,0 +1,158 @@
1
+ import { existsSync } from "node:fs";
2
+ import type { CommandRegistry } from "./register.ts";
3
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import { escapeHtml } from "../html.ts";
5
+ import type { CapturedAgentSession } from "../types.ts";
6
+
7
+ /**
8
+ * All command handlers capture ctx.ui at entry and use the captured reference.
9
+ * See model.ts for explanation.
10
+ */
11
+ export function registerInfoCommands(
12
+ registry: CommandRegistry,
13
+ deps: { getSession: () => CapturedAgentSession | undefined },
14
+ ): void {
15
+ // ── /copy ──────────────────────────────────────────────────────────────
16
+ registry.registerCommand("copy", {
17
+ description: "Show last assistant message (for Telegram copy)",
18
+ handler: async (_args, ctx) => {
19
+ const ui = ctx.ui;
20
+ const session = deps.getSession();
21
+ if (!session) {
22
+ ui.notify("No active session", "error");
23
+ return;
24
+ }
25
+ const text = session.getLastAssistantText();
26
+ ui.notify(text ? text : "No assistant message to copy.", "info");
27
+ },
28
+ });
29
+
30
+ // ── /export ────────────────────────────────────────────────────────────
31
+ registry.registerCommand("export", {
32
+ description: "Export session to HTML or JSONL",
33
+ handler: async (args, ctx) => {
34
+ const ui = ctx.ui;
35
+ const session = deps.getSession();
36
+ if (!session) {
37
+ ui.notify("No active session", "error");
38
+ return;
39
+ }
40
+ try {
41
+ if (args?.endsWith(".jsonl")) {
42
+ const path = session.exportToJsonl(args);
43
+ ui.notify(`Exported to: ${path}`, "info");
44
+ } else {
45
+ const path = await session.exportToHtml(args || undefined);
46
+ ui.notify(`Exported to: ${escapeHtml(path)}`, "info");
47
+ }
48
+ } catch (error) {
49
+ ui.notify(`Export failed: ${error instanceof Error ? error.message : String(error)}`, "error");
50
+ }
51
+ },
52
+ });
53
+
54
+ // ── /import ───────────────────────────────────────────────────────────
55
+ registry.registerCommand("import", {
56
+ description: "Import a session JSONL file (path on server)",
57
+ handler: async (args, ctx) => {
58
+ const ui = ctx.ui;
59
+ const session = deps.getSession();
60
+ if (!session) {
61
+ ui.notify("No active session", "error");
62
+ return;
63
+ }
64
+ const path = args || await ui.input("Session file path (JSONL on server)", "/path/to/session.jsonl");
65
+ if (!path) return;
66
+ if (!existsSync(path)) {
67
+ ui.notify(`File not found: ${escapeHtml(path)}`, "error");
68
+ return;
69
+ }
70
+ try {
71
+ const { SessionManager: SM } = await import("@earendil-works/pi-coding-agent");
72
+ const sm = SM.open(path);
73
+ ui.notify(`Imported session: ${sm.getSessionId()}\nUse /resume to switch to it.`, "info");
74
+ } catch (error) {
75
+ ui.notify(`Import failed: ${error instanceof Error ? error.message : String(error)}`, "error");
76
+ }
77
+ },
78
+ });
79
+
80
+ // ── /share ────────────────────────────────────────────────────────────
81
+ registry.registerCommand("share", {
82
+ description: "Export session for sharing (requires gh CLI on server)",
83
+ handler: async (_args, ctx) => {
84
+ const ui = ctx.ui;
85
+ const session = deps.getSession();
86
+ if (!session) {
87
+ ui.notify("No active session", "error");
88
+ return;
89
+ }
90
+ try {
91
+ const path = await session.exportToHtml();
92
+ ui.notify(
93
+ [
94
+ `Session exported to: ${escapeHtml(path)}`,
95
+ "",
96
+ "To share via GitHub Gist, run on the server:",
97
+ `gh gist create --public=false "${path}"`,
98
+ ].join("\n"),
99
+ "info",
100
+ );
101
+ } catch (error) {
102
+ ui.notify(`Export failed: ${error instanceof Error ? error.message : String(error)}`, "error");
103
+ }
104
+ },
105
+ });
106
+
107
+ // ── /changelog ────────────────────────────────────────────────────────
108
+ registry.registerCommand("changelog", {
109
+ description: "Show changelog link",
110
+ handler: async (_args, ctx) => {
111
+ const ui = ctx.ui;
112
+ ui.notify("Changelog: https://github.com/earendil-works/pi-coding-agent/releases", "info");
113
+ },
114
+ });
115
+
116
+ // ── /hotkeys ──────────────────────────────────────────────────────────
117
+ registry.registerCommand("hotkeys", {
118
+ description: "Show keyboard shortcuts (TUI feature list)",
119
+ handler: async (_args, ctx) => {
120
+ const ui = ctx.ui;
121
+ ui.notify(
122
+ [
123
+ "Keyboard Shortcuts (TUI)",
124
+ "These are TUI-only. In Telegram, use slash commands.",
125
+ "",
126
+ "Send / to see available commands.",
127
+ "Send /model, /thinking, /compact, /new, etc.",
128
+ ].join("\n"),
129
+ "info",
130
+ );
131
+ },
132
+ });
133
+
134
+ // ── /debug ────────────────────────────────────────────────────────────
135
+ registry.registerCommand("debug", {
136
+ description: "Show debug information",
137
+ handler: async (_args, ctx) => {
138
+ const ui = ctx.ui;
139
+ const session = deps.getSession();
140
+ if (!session) {
141
+ ui.notify("No active session", "error");
142
+ return;
143
+ }
144
+ const entries = session.sessionManager.getEntries();
145
+ const model = session.model;
146
+ const lines = [
147
+ `Debug`,
148
+ `model: ${model ? `${model.provider}/${model.id}` : "(none)"}`,
149
+ `thinking: ${session.thinkingLevel}`,
150
+ `streaming: ${session.isStreaming}`,
151
+ `compacting: ${session.isCompacting}`,
152
+ `entries: ${entries.length}`,
153
+ `cwd: ${escapeHtml(ctx.cwd)}`,
154
+ ];
155
+ ui.notify(lines.join("\n"), "info");
156
+ },
157
+ });
158
+ }
@@ -0,0 +1,65 @@
1
+ import type { CommandRegistry } from "./register.ts";
2
+
3
+ /**
4
+ * All command handlers capture ctx.ui at entry and use the captured reference.
5
+ * See model.ts for explanation.
6
+ *
7
+ * For /compact specifically: ctx.compact() is fire-and-forget with async
8
+ * callbacks. The captured ui reference keeps TelegramUi alive via its closure
9
+ * over chatId + transport, so onComplete/onError always reach Telegram
10
+ * even after runner.uiContext has been restored to TUI.
11
+ */
12
+ export function registerLifecycleCommands(registry: CommandRegistry): void {
13
+ // ── /compact ──────────────────────────────────────────────────────────
14
+ registry.registerCommand("compact", {
15
+ description: "Compact current session context",
16
+ handler: async (args, ctx) => {
17
+ const ui = ctx.ui;
18
+ ctx.compact({
19
+ customInstructions: args || undefined,
20
+ onComplete: () => {
21
+ ui.notify("✅ Compaction completed.", "info");
22
+ },
23
+ onError: (error: Error) => {
24
+ ui.notify(`Compaction failed: ${error.message}`, "error");
25
+ },
26
+ });
27
+ ui.notify("Compaction started.", "info");
28
+ },
29
+ });
30
+
31
+ // ── /reload ────────────────────────────────────────────────────────────
32
+ registry.registerCommand("reload", {
33
+ description: "Reload extensions, skills, prompts, and themes",
34
+ handler: async (_args, ctx) => {
35
+ const ui = ctx.ui;
36
+ ui.notify("Reloading…", "info");
37
+ await ctx.reload();
38
+ return;
39
+ },
40
+ });
41
+
42
+ // ── /stop ─────────────────────────────────────────────────────────────
43
+ registry.registerCommand("stop", {
44
+ description: "Stop the current agent turn",
45
+ handler: async (_args, ctx) => {
46
+ const ui = ctx.ui;
47
+ ctx.abort();
48
+ ui.notify("⏹ Stopped.", "info");
49
+ },
50
+ });
51
+
52
+ // ── /quit ──────────────────────────────────────────────────────────────
53
+ registry.registerCommand("quit", {
54
+ description: "Shut down pi",
55
+ handler: async (_args, ctx) => {
56
+ const ui = ctx.ui;
57
+ const confirmed = await ui.confirm("Quit", "Shut down pi?");
58
+ if (confirmed) {
59
+ ctx.shutdown();
60
+ ui.notify("Shutdown requested. If pi keeps running, stop it from the TUI/terminal.", "info");
61
+ }
62
+ },
63
+ });
64
+
65
+ }
@@ -0,0 +1,188 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { CommandRegistry } from "./register.ts";
3
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import type { CapturedAgentSession } from "../types.ts";
5
+
6
+ /**
7
+ * Safely set the model on the session.
8
+ * AgentSession.prototype.setModel() throws on auth failure;
9
+ * this helper catches the error and returns a user-friendly message.
10
+ */
11
+ async function trySetModel(
12
+ session: CapturedAgentSession,
13
+ model: Model,
14
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
15
+ try {
16
+ await session.setModel(model);
17
+ return { ok: true };
18
+ } catch (err) {
19
+ const message = err instanceof Error ? err.message : String(err);
20
+ return { ok: false, error: message };
21
+ }
22
+ }
23
+
24
+ function formatModel(model: Model): string {
25
+ return `${model.provider}/${model.id}`;
26
+ }
27
+
28
+ /**
29
+ * All command handlers capture ctx.ui at entry and use the captured reference
30
+ * for notifications. This is critical because ctx.ui is a getter that reads
31
+ * runner.uiContext, which gets reset to TUI UI after rebindSession (triggered
32
+ * by newSession/fork/switchSession). The captured reference keeps the TelegramUi
33
+ * alive via its closure over chatId + transport.
34
+ */
35
+ export function registerModelCommands(
36
+ registry: CommandRegistry,
37
+ deps: { getSession: () => CapturedAgentSession | undefined },
38
+ ): void {
39
+ // ── /model ────────────────────────────────────────────────────────────
40
+ registry.registerCommand("model", {
41
+ description: "Show or change the current model",
42
+ handler: async (args, ctx) => {
43
+ const ui = ctx.ui;
44
+ const session = deps.getSession();
45
+ if (!session) {
46
+ ui.notify("No active session", "error");
47
+ return;
48
+ }
49
+
50
+ if (args) {
51
+ const needle = args.toLowerCase();
52
+ // Prefer available (auth'd) models, fall back to all models so
53
+ // the user gets a clear auth error instead of "not found".
54
+ const model =
55
+ session.modelRegistry.getAvailable().find((m) => {
56
+ const ref = formatModel(m).toLowerCase();
57
+ return ref === needle || m.id.toLowerCase() === needle || ref.includes(needle);
58
+ }) ??
59
+ session.modelRegistry.getAll().find((m) => {
60
+ const ref = formatModel(m).toLowerCase();
61
+ return ref === needle || m.id.toLowerCase() === needle || ref.includes(needle);
62
+ });
63
+ if (!model) {
64
+ ui.notify(`Model not found: ${args}`, "error");
65
+ return;
66
+ }
67
+ const result = await trySetModel(session, model);
68
+ if (result.ok) {
69
+ ui.notify(`Model set: ${formatModel(model)}`, "info");
70
+ } else {
71
+ ui.notify(result.error, "error");
72
+ }
73
+ return;
74
+ }
75
+
76
+ const available = session.modelRegistry.getAvailable();
77
+ if (available.length === 0) {
78
+ ui.notify("No authenticated models available. Use /login to add an API key.", "error");
79
+ return;
80
+ }
81
+
82
+ const shown = available;
83
+ const current = session.model;
84
+ const labels = shown.map((m) =>
85
+ (current && formatModel(current) === formatModel(m) ? "● " : " ") + formatModel(m),
86
+ );
87
+ const choice = await ui.select("Select model", labels);
88
+ if (!choice) return;
89
+ const index = labels.indexOf(choice);
90
+ if (index < 0 || index >= shown.length) return;
91
+ const selected = shown[index];
92
+ const result = await trySetModel(session, selected);
93
+ if (result.ok) {
94
+ ui.notify(`Model set: ${formatModel(selected)}`, "info");
95
+ } else {
96
+ ui.notify(result.error, "error");
97
+ }
98
+ },
99
+ });
100
+
101
+ // ── /scoped-models ───────────────────────────────────────────────────
102
+ registry.registerCommand("scoped-models", {
103
+ description: "Show or select from scoped models",
104
+ handler: async (_args, ctx) => {
105
+ const ui = ctx.ui;
106
+ const session = deps.getSession();
107
+ if (!session) {
108
+ ui.notify("No active session", "error");
109
+ return;
110
+ }
111
+
112
+ const scoped = session.scopedModels;
113
+ if (!scoped || scoped.length === 0) {
114
+ const allModels = session.modelRegistry.getAvailable();
115
+ if (allModels.length === 0) {
116
+ ui.notify("No authenticated models. Use /login to add an API key.", "info");
117
+ return;
118
+ }
119
+ const labels = allModels.map((m) => formatModel(m));
120
+ const choice = await ui.select("No scoped models. Set one?", labels);
121
+ if (!choice) return;
122
+ const idx = labels.indexOf(choice);
123
+ if (idx < 0) return;
124
+ const selected = allModels[idx];
125
+ const setResult = await trySetModel(session, selected);
126
+ if (!setResult.ok) {
127
+ ui.notify(setResult.error, "error");
128
+ return;
129
+ }
130
+ session.setScopedModels([{ model: selected }]);
131
+ ui.notify(`Scoped to ${formatModel(selected)}`, "info");
132
+ return;
133
+ }
134
+
135
+ const labels = scoped.map((sm) =>
136
+ formatModel(sm.model) + (sm.thinkingLevel ? ` (${sm.thinkingLevel})` : ""),
137
+ );
138
+ const choice = await ui.select("Scoped models", labels);
139
+ if (!choice) return;
140
+ const index = labels.indexOf(choice);
141
+ if (index < 0 || index >= scoped.length) return;
142
+ const sm = scoped[index];
143
+ const switchResult = await trySetModel(session, sm.model);
144
+ if (!switchResult.ok) {
145
+ ui.notify(switchResult.error, "error");
146
+ return;
147
+ }
148
+ if (sm.thinkingLevel) session.setThinkingLevel(sm.thinkingLevel);
149
+ ui.notify(`Switched to ${formatModel(sm.model)}`, "info");
150
+ },
151
+ });
152
+
153
+ // ── /thinking ────────────────────────────────────────────────────────
154
+ registry.registerCommand("thinking", {
155
+ description: "Show or change thinking level",
156
+ handler: async (args, ctx) => {
157
+ const ui = ctx.ui;
158
+ const session = deps.getSession();
159
+ if (!session) {
160
+ ui.notify("No active session", "error");
161
+ return;
162
+ }
163
+
164
+ if (args) {
165
+ session.setThinkingLevel(args as any);
166
+ ui.notify(`Thinking level set: ${args}`, "info");
167
+ return;
168
+ }
169
+
170
+ const levels = session.getAvailableThinkingLevels();
171
+ if (levels.length === 0) {
172
+ ui.notify("Current model does not support thinking levels.", "info");
173
+ return;
174
+ }
175
+
176
+ const current = session.thinkingLevel;
177
+ const labels = levels.map((l) =>
178
+ (l === current ? "● " : " ") + String(l),
179
+ );
180
+ const choice = await ui.select("Thinking level", labels);
181
+ if (!choice) return;
182
+ const selected = levels[labels.indexOf(choice)];
183
+ if (selected === undefined) return;
184
+ session.setThinkingLevel(selected);
185
+ ui.notify(`Thinking level set: ${selected}`, "info");
186
+ },
187
+ });
188
+ }
@@ -0,0 +1,40 @@
1
+ import type { CapturedAgentSession } from "../types.ts";
2
+ import { registerModelCommands } from "./model.ts";
3
+ import { registerSessionCommands } from "./session.ts";
4
+ import { registerAuthCommands } from "./auth.ts";
5
+ import { registerInfoCommands } from "./info.ts";
6
+ import { registerLifecycleCommands } from "./lifecycle.ts";
7
+ import { registerSettingsCommands } from "./settings.ts";
8
+ import { registerTgConfigCommands } from "./tg-config.ts";
9
+
10
+ export type CommandRegistry = {
11
+ registerCommand: (name: string, options: { description?: string; handler: (args: string, ctx: any) => Promise<void> }) => void;
12
+ };
13
+
14
+ /** Minimal base deps — most commands only need session access. */
15
+ export type SessionDeps = {
16
+ getSession: () => CapturedAgentSession | undefined;
17
+ };
18
+
19
+ /** Extended deps for session commands that also manage session names. */
20
+ export type SessionNameDeps = SessionDeps & {
21
+ setSessionName: (name: string) => void;
22
+ getSessionName: () => string | undefined;
23
+ };
24
+
25
+ /** Deps for tg-config command. */
26
+ export type TgConfigDeps = SessionDeps & {
27
+ getConfig: () => import("../types.ts").TelegramConfig;
28
+ setConfig: (c: import("../types.ts").TelegramConfig) => void;
29
+ persistConfig: (c: import("../types.ts").TelegramConfig) => Promise<void>;
30
+ };
31
+
32
+ export function registerAllCommands(registry: CommandRegistry, sessionDeps: SessionDeps, sessionNameDeps: SessionNameDeps, tgConfigDeps?: TgConfigDeps): void {
33
+ registerSettingsCommands(registry, sessionDeps);
34
+ registerModelCommands(registry, sessionDeps);
35
+ registerSessionCommands(registry, sessionNameDeps);
36
+ registerAuthCommands(registry, sessionDeps);
37
+ registerInfoCommands(registry, sessionDeps);
38
+ registerLifecycleCommands(registry);
39
+ if (tgConfigDeps) registerTgConfigCommands(registry, tgConfigDeps);
40
+ }