telecodex 0.1.2 → 0.1.4

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.
@@ -2,34 +2,37 @@ import { presetFromProfile } from "../../config.js";
2
2
  import { generateBindingCode } from "../../runtime/bindingCodes.js";
3
3
  import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
4
4
  import { refreshSessionIfActiveTurnIsStale } from "../inputService.js";
5
- import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, parseSubcommand, } from "../commandSupport.js";
5
+ import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, hasTopicContext, isPrivateChat, parseSubcommand, requireScopedSession, } from "../commandSupport.js";
6
6
  import { formatIsoTimestamp, sessionLogFields } from "../sessionFlow.js";
7
+ import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
7
8
  export function registerOperationalHandlers(deps) {
8
9
  const { bot, config, store, projects, codex, logger } = deps;
9
10
  bot.command(["start", "help"], async (ctx) => {
10
- await ctx.reply(formatHelpText(ctx, projects));
11
+ await replyNotice(ctx, formatHelpText(ctx, projects));
11
12
  });
12
13
  bot.command("admin", async (ctx) => {
13
14
  if (!isPrivateChat(ctx)) {
14
- await ctx.reply("Use /admin in the bot private chat.");
15
+ await replyNotice(ctx, "Use /admin in the bot private chat.");
15
16
  return;
16
17
  }
17
18
  const authorizedUserId = store.getAuthorizedUserId();
18
19
  if (authorizedUserId == null) {
19
- await ctx.reply("Admin binding is not completed yet.");
20
+ await replyNotice(ctx, "Admin binding is not completed yet.");
20
21
  return;
21
22
  }
22
23
  const { command } = parseSubcommand(ctx.match.trim());
23
24
  const binding = store.getBindingCodeState();
24
25
  if (!command || command === "status") {
25
- await ctx.reply([
26
- "Admin status",
27
- `authorized telegram user id: ${authorizedUserId}`,
28
- binding?.mode === "rebind"
29
- ? `pending handoff: active until ${formatIsoTimestamp(binding.expiresAt)} (${binding.maxAttempts - binding.attempts} attempts remaining)`
30
- : "pending handoff: none",
31
- "Usage: /admin | /admin rebind | /admin cancel",
32
- ].join("\n"));
26
+ await replyDocument(ctx, {
27
+ title: "Admin status",
28
+ fields: [
29
+ textField("authorized telegram user id", authorizedUserId),
30
+ textField("pending handoff", binding?.mode === "rebind"
31
+ ? `active until ${formatIsoTimestamp(binding.expiresAt)} (${binding.maxAttempts - binding.attempts} attempts remaining)`
32
+ : "none"),
33
+ ],
34
+ footer: "Usage: /admin | /admin rebind | /admin cancel",
35
+ });
33
36
  return;
34
37
  }
35
38
  if (command === "rebind") {
@@ -38,124 +41,133 @@ export function registerOperationalHandlers(deps) {
38
41
  mode: "rebind",
39
42
  issuedByUserId: authorizedUserId,
40
43
  });
41
- await ctx.reply([
42
- "Admin handoff code created.",
43
- `expires at: ${formatIsoTimestamp(next.expiresAt)}`,
44
- `max failed attempts: ${next.maxAttempts}`,
45
- "",
46
- next.code,
47
- "",
48
- "Send this code from the target Telegram account in this bot's private chat to transfer control.",
49
- ].join("\n"));
44
+ await replyDocument(ctx, {
45
+ title: "Admin handoff code created.",
46
+ fields: [
47
+ textField("expires at", formatIsoTimestamp(next.expiresAt)),
48
+ textField("max failed attempts", next.maxAttempts),
49
+ codeField("code", next.code),
50
+ ],
51
+ footer: "Send this code from the target Telegram account in this bot's private chat to transfer control.",
52
+ });
50
53
  return;
51
54
  }
52
55
  if (command === "cancel") {
53
56
  if (binding?.mode !== "rebind") {
54
- await ctx.reply("No pending admin handoff.");
57
+ await replyNotice(ctx, "No pending admin handoff.");
55
58
  return;
56
59
  }
57
60
  store.clearBindingCode();
58
- await ctx.reply("Cancelled the pending admin handoff.");
61
+ await replyNotice(ctx, "Cancelled the pending admin handoff.");
59
62
  return;
60
63
  }
61
- await ctx.reply("Usage: /admin | /admin rebind | /admin cancel");
64
+ await replyUsage(ctx, "/admin | /admin rebind | /admin cancel");
62
65
  });
63
66
  bot.command("status", async (ctx) => {
64
67
  if (isPrivateChat(ctx)) {
65
- await ctx.reply(formatPrivateStatus(store, projects));
68
+ await replyNotice(ctx, formatPrivateStatus(store, projects));
66
69
  return;
67
70
  }
68
71
  const project = getProjectForContext(ctx, projects);
69
72
  if (!project) {
70
- await ctx.reply("This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
73
+ await replyNotice(ctx, "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
71
74
  return;
72
75
  }
73
76
  if (!hasTopicContext(ctx)) {
74
- await ctx.reply(formatProjectStatus(project));
77
+ await replyNotice(ctx, formatProjectStatus(project));
75
78
  return;
76
79
  }
77
- const session = getScopedSession(ctx, store, projects, config);
80
+ const session = await requireScopedSession(ctx, store, projects, config);
78
81
  if (!session)
79
82
  return;
80
83
  const latestSession = await refreshSessionIfActiveTurnIsStale(session, store, codex, bot, logger);
81
84
  const queueDepth = store.getQueuedInputCount(latestSession.sessionKey);
82
85
  const queuedPreview = store.listQueuedInputs(latestSession.sessionKey, 3);
83
86
  const activeRun = codex.getActiveRun(latestSession.sessionKey);
84
- await ctx.reply([
85
- "Status",
86
- `project: ${project.name}`,
87
- `root: ${project.cwd}`,
88
- `thread: ${latestSession.codexThreadId ?? "not created"}`,
89
- `state: ${formatSessionRuntimeStatus(latestSession.runtimeStatus)}`,
90
- `state detail: ${latestSession.runtimeStatusDetail ?? "none"}`,
91
- `state updated: ${formatIsoTimestamp(latestSession.runtimeStatusUpdatedAt)}`,
92
- `active turn: ${latestSession.activeTurnId ?? "none"}`,
93
- `active run: ${activeRun ? formatIsoTimestamp(activeRun.startedAt) : "none"}`,
94
- `active run thread: ${activeRun?.threadId ?? "none"}`,
95
- `active run last event: ${activeRun?.lastEventType ?? "none"}`,
96
- `active run last update: ${activeRun ? formatIsoTimestamp(activeRun.lastEventAt) : "none"}`,
97
- `queue: ${queueDepth}`,
98
- `queue next: ${formatQueuedPreview(queuedPreview)}`,
99
- `cwd: ${latestSession.cwd}`,
100
- `preset: ${presetFromProfile(latestSession)}`,
101
- `sandbox: ${latestSession.sandboxMode}`,
102
- `approval: ${latestSession.approvalPolicy}`,
103
- `network: ${latestSession.networkAccessEnabled ? "on" : "off"}`,
104
- `web: ${latestSession.webSearchMode ?? "codex-default"}`,
105
- `git check: ${latestSession.skipGitRepoCheck ? "skip" : "enforce"}`,
106
- `add dirs: ${latestSession.additionalDirectories.length}`,
107
- `schema: ${latestSession.outputSchema ? "set" : "none"}`,
108
- `model: ${latestSession.model}`,
109
- `effort: ${formatReasoningEffort(latestSession.reasoningEffort)}`,
110
- ].join("\n"));
87
+ await replyDocument(ctx, {
88
+ title: "Status",
89
+ fields: [
90
+ codeField("project", project.name),
91
+ codeField("root", project.cwd),
92
+ codeField("thread", latestSession.codexThreadId ?? "not created"),
93
+ textField("state", formatSessionRuntimeStatus(latestSession.runtimeStatus)),
94
+ textField("state detail", latestSession.runtimeStatusDetail ?? "none"),
95
+ textField("state updated", formatIsoTimestamp(latestSession.runtimeStatusUpdatedAt)),
96
+ codeField("active turn", latestSession.activeTurnId ?? "none"),
97
+ textField("active run", activeRun ? formatIsoTimestamp(activeRun.startedAt) : "none"),
98
+ codeField("active run thread", activeRun?.threadId ?? "none"),
99
+ textField("active run last event", activeRun?.lastEventType ?? "none"),
100
+ textField("active run last update", activeRun ? formatIsoTimestamp(activeRun.lastEventAt) : "none"),
101
+ textField("queue", queueDepth),
102
+ textField("queue next", formatQueuedPreview(queuedPreview)),
103
+ codeField("cwd", latestSession.cwd),
104
+ textField("preset", presetFromProfile(latestSession)),
105
+ textField("sandbox", latestSession.sandboxMode),
106
+ textField("approval", latestSession.approvalPolicy),
107
+ textField("network", latestSession.networkAccessEnabled ? "on" : "off"),
108
+ textField("web", latestSession.webSearchMode ?? "codex-default"),
109
+ textField("git check", latestSession.skipGitRepoCheck ? "skip" : "enforce"),
110
+ textField("add dirs", latestSession.additionalDirectories.length),
111
+ textField("schema", latestSession.outputSchema ? "set" : "none"),
112
+ textField("model", latestSession.model),
113
+ textField("effort", formatReasoningEffort(latestSession.reasoningEffort)),
114
+ ],
115
+ });
111
116
  });
112
117
  bot.command("queue", async (ctx) => {
113
- const session = getScopedSession(ctx, store, projects, config);
118
+ const session = await requireScopedSession(ctx, store, projects, config);
114
119
  if (!session)
115
120
  return;
116
121
  const { command, args } = parseSubcommand(ctx.match.trim());
117
122
  if (!command) {
118
123
  const queued = store.listQueuedInputs(session.sessionKey, 5);
119
124
  const queueDepth = store.getQueuedInputCount(session.sessionKey);
120
- await ctx.reply([
121
- "Queue",
122
- `state: ${formatSessionRuntimeStatus(session.runtimeStatus)}`,
123
- `active turn: ${session.activeTurnId ?? "none"}`,
124
- `queue: ${queueDepth}`,
125
- queued.length > 0 ? `items:\n${formatQueuedItems(queued)}` : "items: none",
126
- "Usage: /queue | /queue drop <id> | /queue clear",
127
- ].join("\n"));
125
+ await replyDocument(ctx, {
126
+ title: "Queue",
127
+ fields: [
128
+ textField("state", formatSessionRuntimeStatus(session.runtimeStatus)),
129
+ codeField("active turn", session.activeTurnId ?? "none"),
130
+ textField("queue", queueDepth),
131
+ ],
132
+ sections: [
133
+ {
134
+ title: "Items",
135
+ lines: queued.length > 0 ? [formatQueuedItems(queued)] : ["none"],
136
+ },
137
+ ],
138
+ footer: "Usage: /queue | /queue drop <id> | /queue clear",
139
+ });
128
140
  return;
129
141
  }
130
142
  if (command === "clear") {
131
143
  const removed = store.clearQueuedInputs(session.sessionKey);
132
- await ctx.reply(`Cleared the queue and removed ${removed} pending message(s).`);
144
+ await replyNotice(ctx, `Cleared the queue and removed ${removed} pending message(s).`);
133
145
  return;
134
146
  }
135
147
  if (command === "drop") {
136
148
  const id = Number(args);
137
149
  if (!Number.isInteger(id) || id <= 0) {
138
- await ctx.reply("Usage: /queue drop <id>");
150
+ await replyUsage(ctx, "/queue drop <id>");
139
151
  return;
140
152
  }
141
153
  const removed = store.removeQueuedInputForSession(session.sessionKey, id);
142
- await ctx.reply(removed ? `Removed queued item #${id}.` : `Queued item #${id} was not found.`);
154
+ await replyNotice(ctx, removed ? `Removed queued item #${id}.` : `Queued item #${id} was not found.`);
143
155
  return;
144
156
  }
145
- await ctx.reply("Usage: /queue | /queue drop <id> | /queue clear");
157
+ await replyUsage(ctx, "/queue | /queue drop <id> | /queue clear");
146
158
  });
147
159
  bot.command("stop", async (ctx) => {
148
- const session = getScopedSession(ctx, store, projects, config);
160
+ const session = await requireScopedSession(ctx, store, projects, config);
149
161
  if (!session)
150
162
  return;
151
163
  const latest = store.get(session.sessionKey) ?? session;
152
164
  if (!codex.isRunning(session.sessionKey)) {
153
- await ctx.reply("There is no active Codex SDK turn right now.");
165
+ await replyNotice(ctx, "There is no active Codex SDK turn right now.");
154
166
  return;
155
167
  }
156
168
  try {
157
169
  codex.interrupt(session.sessionKey);
158
- await ctx.reply("Interrupt requested for the current run. Waiting for Codex SDK to stop.");
170
+ await replyNotice(ctx, "Interrupt requested for the current run. Waiting for Codex SDK to stop.");
159
171
  }
160
172
  catch (error) {
161
173
  logger?.warn("interrupt turn failed", {
@@ -163,7 +175,7 @@ export function registerOperationalHandlers(deps) {
163
175
  ...sessionLogFields(latest),
164
176
  error,
165
177
  });
166
- await ctx.reply(`Interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
178
+ await replyError(ctx, `Interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
167
179
  }
168
180
  });
169
181
  }
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
- import { contextLogFields, ensureTopicSession, formatPrivateProjectList, formatProjectStatus, formatTopicName, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, isSupergroupChat, parseSubcommand, postTopicReadyMessage, resolveExistingDirectory, } from "../commandSupport.js";
2
+ import { contextLogFields, ensureTopicSession, formatPrivateProjectList, formatProjectStatus, formatTopicName, getProjectForContext, hasTopicContext, isPrivateChat, isSupergroupChat, parseSubcommand, postTopicReadyMessage, requireScopedSession, resolveExistingDirectory, } from "../commandSupport.js";
3
3
  import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
4
+ import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
4
5
  const PROJECT_REQUIRED_MESSAGE = "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.";
5
6
  export function registerProjectHandlers(deps) {
6
7
  const { bot, config, store, projects, logger } = deps;
@@ -8,24 +9,24 @@ export function registerProjectHandlers(deps) {
8
9
  const { command, args } = parseSubcommand(ctx.match.trim());
9
10
  if (isPrivateChat(ctx)) {
10
11
  if (!command || command === "list" || command === "status") {
11
- await ctx.reply(formatPrivateProjectList(projects));
12
+ await replyNotice(ctx, formatPrivateProjectList(projects));
12
13
  return;
13
14
  }
14
- await ctx.reply("Use /project bind inside a supergroup with topics enabled. Private chat is only for admin entry points.");
15
+ await replyNotice(ctx, "Use /project bind inside a supergroup with topics enabled. Private chat is only for admin entry points.");
15
16
  return;
16
17
  }
17
18
  if (!isSupergroupChat(ctx)) {
18
- await ctx.reply("Use telecodex inside a supergroup with forum topics enabled.");
19
+ await replyNotice(ctx, "Use telecodex inside a supergroup with forum topics enabled.");
19
20
  return;
20
21
  }
21
22
  if (!command || command === "status") {
22
23
  const project = getProjectForContext(ctx, projects);
23
- await ctx.reply(project ? formatProjectStatus(project) : PROJECT_REQUIRED_MESSAGE);
24
+ await replyNotice(ctx, project ? formatProjectStatus(project) : PROJECT_REQUIRED_MESSAGE);
24
25
  return;
25
26
  }
26
27
  if (command === "bind") {
27
28
  if (!args) {
28
- await ctx.reply("Usage: /project bind <absolute-path>");
29
+ await replyUsage(ctx, "/project bind <absolute-path>");
29
30
  return;
30
31
  }
31
32
  try {
@@ -39,19 +40,21 @@ export function registerProjectHandlers(deps) {
39
40
  project: project.name,
40
41
  cwd: project.cwd,
41
42
  });
42
- const session = getScopedSession(ctx, store, projects, config, { requireTopic: false });
43
+ const session = await requireScopedSession(ctx, store, projects, config, { requireTopic: false });
43
44
  if (session && session.cwd !== project.cwd) {
44
45
  store.setCwd(session.sessionKey, project.cwd);
45
46
  }
46
- await ctx.reply([
47
- "Project binding updated.",
48
- `project: ${project.name}`,
49
- `root: ${project.cwd}`,
50
- "This supergroup now represents one project, and each topic maps to one Codex thread.",
51
- ].join("\n"));
47
+ await replyDocument(ctx, {
48
+ title: "Project binding updated.",
49
+ fields: [
50
+ codeField("project", project.name),
51
+ codeField("root", project.cwd),
52
+ ],
53
+ footer: "This supergroup now represents one project, and each topic maps to one Codex thread.",
54
+ });
52
55
  }
53
56
  catch (error) {
54
- await ctx.reply(error instanceof Error ? error.message : String(error));
57
+ await replyError(ctx, error instanceof Error ? error.message : String(error));
55
58
  }
56
59
  return;
57
60
  }
@@ -60,36 +63,36 @@ export function registerProjectHandlers(deps) {
60
63
  ...contextLogFields(ctx),
61
64
  });
62
65
  projects.remove(String(ctx.chat.id));
63
- await ctx.reply("Removed the project binding for this supergroup.");
66
+ await replyNotice(ctx, "Removed the project binding for this supergroup.");
64
67
  return;
65
68
  }
66
- await ctx.reply("Usage:\n/project\n/project bind <absolute-path>\n/project unbind");
69
+ await replyUsage(ctx, ["/project", "/project bind <absolute-path>", "/project unbind"]);
67
70
  });
68
71
  bot.command("thread", async (ctx) => {
69
72
  if (isPrivateChat(ctx)) {
70
- await ctx.reply("The thread command is only available inside project supergroups.");
73
+ await replyNotice(ctx, "The thread command is only available inside project supergroups.");
71
74
  return;
72
75
  }
73
76
  const { command, args } = parseSubcommand(ctx.match.trim());
74
77
  if (!command) {
75
78
  if (hasTopicContext(ctx)) {
76
- const session = getScopedSession(ctx, store, projects, config);
79
+ const session = await requireScopedSession(ctx, store, projects, config);
77
80
  if (!session)
78
81
  return;
79
- await ctx.reply([
80
- `Current thread: ${session.codexThreadId ?? "not created"}`,
81
- `state: ${formatSessionRuntimeStatus(session.runtimeStatus)}`,
82
- `state detail: ${session.runtimeStatusDetail ?? "none"}`,
83
- `queue: ${store.getQueuedInputCount(session.sessionKey)}`,
84
- `cwd: ${session.cwd}`,
85
- "Manage threads in this project:",
86
- "/thread list",
87
- "/thread resume <threadId>",
88
- "/thread new <topic-name>",
89
- ].join("\n"));
82
+ await replyDocument(ctx, {
83
+ title: "Current thread",
84
+ fields: [
85
+ codeField("thread", session.codexThreadId ?? "not created"),
86
+ textField("state", formatSessionRuntimeStatus(session.runtimeStatus)),
87
+ textField("state detail", session.runtimeStatusDetail ?? "none"),
88
+ textField("queue", store.getQueuedInputCount(session.sessionKey)),
89
+ codeField("cwd", session.cwd),
90
+ ],
91
+ footer: ["Manage threads in this project:", "/thread list", "/thread resume <threadId>", "/thread new <topic-name>"],
92
+ });
90
93
  return;
91
94
  }
92
- await ctx.reply("Usage:\n/thread list\n/thread resume <threadId>\n/thread new <topic-name>");
95
+ await replyUsage(ctx, ["/thread list", "/thread resume <threadId>", "/thread new <topic-name>"]);
93
96
  return;
94
97
  }
95
98
  if (command === "list") {
@@ -98,7 +101,7 @@ export function registerProjectHandlers(deps) {
98
101
  }
99
102
  if (command === "resume") {
100
103
  if (!args) {
101
- await ctx.reply("Usage: /thread resume <threadId>");
104
+ await replyUsage(ctx, "/thread resume <threadId>");
102
105
  return;
103
106
  }
104
107
  await resumeThreadIntoTopic(ctx, deps, args);
@@ -108,7 +111,7 @@ export function registerProjectHandlers(deps) {
108
111
  await createFreshThreadTopic(ctx, deps, args);
109
112
  return;
110
113
  }
111
- await ctx.reply("Usage:\n/thread list\n/thread resume <threadId>\n/thread new <topic-name>");
114
+ await replyUsage(ctx, ["/thread list", "/thread resume <threadId>", "/thread new <topic-name>"]);
112
115
  });
113
116
  bot.on(["message:forum_topic_created", "message:forum_topic_edited"], async (ctx) => {
114
117
  const threadId = ctx.message.message_thread_id;
@@ -128,7 +131,7 @@ async function resumeThreadIntoTopic(ctx, deps, threadId) {
128
131
  const { bot, config, store, projects, logger, threadCatalog } = deps;
129
132
  const project = getProjectForContext(ctx, projects);
130
133
  if (!project) {
131
- await ctx.reply(PROJECT_REQUIRED_MESSAGE);
134
+ await replyNotice(ctx, PROJECT_REQUIRED_MESSAGE);
132
135
  return;
133
136
  }
134
137
  const thread = await threadCatalog.findProjectThreadById({
@@ -136,12 +139,14 @@ async function resumeThreadIntoTopic(ctx, deps, threadId) {
136
139
  threadId,
137
140
  });
138
141
  if (!thread) {
139
- await ctx.reply([
140
- "Could not find a saved Codex thread with that id under this project.",
141
- `project root: ${project.cwd}`,
142
- `thread: ${threadId}`,
143
- "Run /thread list to inspect the saved project threads first.",
144
- ].join("\n"));
142
+ await replyDocument(ctx, {
143
+ title: "Could not find a saved Codex thread with that id under this project.",
144
+ fields: [
145
+ codeField("project root", project.cwd),
146
+ codeField("thread", threadId),
147
+ ],
148
+ footer: "Run /thread list to inspect the saved project threads first.",
149
+ });
145
150
  return;
146
151
  }
147
152
  const topicName = formatTopicName(thread.preview, `Resumed ${thread.id.slice(0, 8)}`);
@@ -161,14 +166,16 @@ async function resumeThreadIntoTopic(ctx, deps, threadId) {
161
166
  threadId: thread.id,
162
167
  topicName: forumTopic.name,
163
168
  });
164
- await ctx.reply([
165
- "Created a topic and bound it to the existing thread id.",
166
- `topic: ${forumTopic.name}`,
167
- `topic id: ${forumTopic.message_thread_id}`,
168
- `thread: ${thread.id}`,
169
- `cwd: ${thread.cwd}`,
170
- "Future messages in this topic will continue on that thread through the Codex SDK.",
171
- ].join("\n"));
169
+ await replyDocument(ctx, {
170
+ title: "Created a topic and bound it to the existing thread id.",
171
+ fields: [
172
+ textField("topic", forumTopic.name),
173
+ textField("topic id", forumTopic.message_thread_id),
174
+ codeField("thread", thread.id),
175
+ codeField("cwd", thread.cwd),
176
+ ],
177
+ footer: "Future messages in this topic will continue on that thread through the Codex SDK.",
178
+ });
172
179
  await postTopicReadyMessage(bot, session, [
173
180
  "This topic is now bound to an existing Codex thread id.",
174
181
  `thread: ${thread.id}`,
@@ -179,7 +186,7 @@ async function createFreshThreadTopic(ctx, deps, requestedName) {
179
186
  const { bot, config, store, projects, logger } = deps;
180
187
  const project = getProjectForContext(ctx, projects);
181
188
  if (!project) {
182
- await ctx.reply(PROJECT_REQUIRED_MESSAGE);
189
+ await replyNotice(ctx, PROJECT_REQUIRED_MESSAGE);
183
190
  return;
184
191
  }
185
192
  const topicName = formatTopicName(requestedName, "New Thread");
@@ -197,12 +204,14 @@ async function createFreshThreadTopic(ctx, deps, requestedName) {
197
204
  sessionKey: session.sessionKey,
198
205
  topicName: forumTopic.name,
199
206
  });
200
- await ctx.reply([
201
- "Created a new topic.",
202
- `topic: ${forumTopic.name}`,
203
- `topic id: ${forumTopic.message_thread_id}`,
204
- "Your first normal message will start a new Codex SDK thread.",
205
- ].join("\n"));
207
+ await replyDocument(ctx, {
208
+ title: "Created a new topic.",
209
+ fields: [
210
+ textField("topic", forumTopic.name),
211
+ textField("topic id", forumTopic.message_thread_id),
212
+ ],
213
+ footer: "Your first normal message will start a new Codex SDK thread.",
214
+ });
206
215
  await postTopicReadyMessage(bot, session, [
207
216
  "New topic created.",
208
217
  "Send a message to start a new Codex thread.",
@@ -214,7 +223,7 @@ async function listProjectThreads(ctx, deps) {
214
223
  const { projects, store, threadCatalog } = deps;
215
224
  const project = getProjectForContext(ctx, projects);
216
225
  if (!project) {
217
- await ctx.reply(PROJECT_REQUIRED_MESSAGE);
226
+ await replyNotice(ctx, PROJECT_REQUIRED_MESSAGE);
218
227
  return;
219
228
  }
220
229
  const threads = await threadCatalog.listProjectThreads({
@@ -222,30 +231,35 @@ async function listProjectThreads(ctx, deps) {
222
231
  limit: 8,
223
232
  });
224
233
  if (threads.length === 0) {
225
- await ctx.reply([
226
- "No saved Codex threads were found for this project yet.",
227
- `project root: ${project.cwd}`,
228
- ].join("\n"));
234
+ await replyDocument(ctx, {
235
+ title: "No saved Codex threads were found for this project yet.",
236
+ fields: [codeField("project root", project.cwd)],
237
+ });
229
238
  return;
230
239
  }
231
- const lines = [
232
- `Saved Codex threads for ${project.name}:`,
233
- ...threads.flatMap((thread, index) => {
240
+ await replyDocument(ctx, {
241
+ title: "Saved Codex threads",
242
+ fields: [
243
+ codeField("project", project.name),
244
+ codeField("root", project.cwd),
245
+ ],
246
+ sections: threads.map((thread, index) => {
234
247
  const relativeCwd = path.relative(project.cwd, thread.cwd) || ".";
235
248
  const bound = store.getByThreadId(thread.id);
236
- return [
237
- `${index + 1}. ${thread.preview}`,
238
- ` id: ${thread.id}`,
239
- ` cwd: ${relativeCwd}`,
240
- ` updated: ${thread.updatedAt}`,
241
- ` source: ${thread.source ?? "unknown"}`,
242
- ...(bound
243
- ? [` bound: ${bound.telegramTopicName ?? bound.messageThreadId ?? bound.sessionKey}`]
244
- : []),
245
- ];
249
+ return {
250
+ title: `${index + 1}. ${thread.preview}`,
251
+ fields: [
252
+ codeField("id", thread.id),
253
+ codeField("resume", `/thread resume ${thread.id}`),
254
+ codeField("cwd", relativeCwd),
255
+ textField("updated", thread.updatedAt),
256
+ textField("source", thread.source ?? "unknown"),
257
+ ...(bound
258
+ ? [textField("bound", bound.telegramTopicName ?? bound.messageThreadId ?? bound.sessionKey)]
259
+ : []),
260
+ ],
261
+ };
246
262
  }),
247
- "",
248
- "Resume one with /thread resume <threadId>",
249
- ];
250
- await ctx.reply(lines.join("\n"));
263
+ footer: "Copy an id or resume command from the code-formatted fields above.",
264
+ });
251
265
  }