Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/claude/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ class SessionManager {
setWorkingDirectory(sessionKey: string, directory: string): Session {
const existing = this.sessions.get(sessionKey);
if (existing) {
// Clear Claude session ID when directory changes — the Agent SDK session
// is bound to the original cwd and cannot be resumed with a different one.
if (existing.workingDirectory !== directory) {
existing.claudeSessionId = undefined;
}
existing.workingDirectory = directory;
existing.lastActivity = new Date();
// Save updated session
Expand Down
103 changes: 92 additions & 11 deletions src/telegram/message-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,24 @@ interface StreamState {
sessionKey: string;
messageId: number | null;
content: string;
lastUpdate: number;
lastEditMs: number;
updateScheduled: boolean;
typingInterval: NodeJS.Timeout | null;
// Terminal UI mode additions
// Text streaming
textStreamInterval: NodeJS.Timeout | null;
lastEditedContent: string;
// Terminal UI mode
terminalMode: boolean;
spinnerIndex: number;
spinnerInterval: NodeJS.Timeout | null;
currentOperation: ToolOperation | null;
backgroundTasks: Array<{ name: string; status: 'running' | 'complete' | 'error' }>;
rateLimitedUntil: number;
finishing: boolean;
}

const TYPING_INTERVAL_MS = 4000; // Send typing every 4 seconds
const MIN_EDIT_INTERVAL_MS = 10000; // Minimum time between message edits (~5 edits/min safe zone)
const TEXT_STREAM_INTERVAL_MS = 3000; // Interval for streaming text updates to Telegram

export class MessageSender {
private streamStates: Map<string, StreamState> = new Map();
Expand Down Expand Up @@ -182,19 +186,28 @@ export class MessageSender {
sessionKey,
messageId: message.message_id,
content: '',
lastUpdate: Date.now(),
lastEditMs: 0,
updateScheduled: false,
typingInterval,
// Text streaming
textStreamInterval: null,
lastEditedContent: '',
// Terminal UI mode
terminalMode,
spinnerIndex: 0,
spinnerInterval: null,
currentOperation: null,
backgroundTasks: [],
rateLimitedUntil: 0,
finishing: false,
};

this.streamStates.set(sessionKey, state);

// Start periodic text streaming timer
state.textStreamInterval = setInterval(() => {
this.flushTextStream(ctx, state);
}, TEXT_STREAM_INTERVAL_MS);
}

private stopSpinnerAnimation(state: StreamState): void {
Expand Down Expand Up @@ -281,8 +294,8 @@ export class MessageSender {
}

// Throttle edits to avoid rate limits
const timeSinceLastUpdate = now - state.lastUpdate;
if (timeSinceLastUpdate < MIN_EDIT_INTERVAL_MS) {
const timeSinceLastUpdate = now - state.lastEditMs;
if (timeSinceLastUpdate < TEXT_STREAM_INTERVAL_MS) {
return;
}

Expand Down Expand Up @@ -324,7 +337,7 @@ export class MessageSender {
displayContent,
{ parse_mode: undefined }
);
state.lastUpdate = Date.now();
state.lastEditMs = Date.now();
} catch (error: unknown) {
if (error instanceof GrammyError && error.error_code === 429) {
const retryAfter = error.parameters.retry_after ?? 60;
Expand Down Expand Up @@ -360,8 +373,66 @@ export class MessageSender {
}

/**
* Accumulate streamed text content internally without triggering Telegram edits.
* The full content is only displayed when finishStreaming() is called.
* Periodically flush accumulated text to Telegram as plain text.
* Called every TEXT_STREAM_INTERVAL_MS by the timer started in startStreaming().
* When a tool operation is active (terminal mode), delegates to flushTerminalUpdate().
*/
private async flushTextStream(ctx: Context, state: StreamState): Promise<void> {
const currentState = this.streamStates.get(state.sessionKey);
if (!currentState || currentState !== state || !state.messageId || state.finishing) return;

if (Date.now() < state.rateLimitedUntil) return;

// Tool active + terminal mode: advance spinner and show tool status instead of text
if (state.currentOperation !== null && state.terminalMode) {
state.spinnerIndex += 1;
await this.flushTerminalUpdate(ctx, state);
return;
}

if (state.content === '') return;
if (state.content === state.lastEditedContent) return;

// Sliding window for long content
let displayText: string;
if (state.content.length <= 3500) {
displayText = state.content;
} else {
displayText = '...\n\n' + state.content.slice(-3500);
}

// Cursor indicates response is still generating
displayText += ' \u2589';

try {
await ctx.api.editMessageText(
state.chatId,
state.messageId!,
displayText,
{ parse_mode: undefined }
);
state.lastEditedContent = state.content;
state.lastEditMs = Date.now();
} catch (error: unknown) {
if (error instanceof GrammyError && error.error_code === 429) {
const retryAfter = error.parameters.retry_after ?? 60;
state.rateLimitedUntil = Date.now() + retryAfter * 1000;
console.warn(`[TextStream] Rate limited, backing off for ${retryAfter}s`);
return;
}
if (error instanceof Error) {
const msg = error.message.toLowerCase();
if (!msg.includes('message is not modified') && !msg.includes('message_id_invalid')) {
console.error('[TextStream] Error editing message:', error);
}
}
}
}

/**
* Accumulate streamed text content. The periodic timer (flushTextStream)
* picks up changes and edits the Telegram message with plain text.
* Final formatted delivery happens in finishStreaming().
*/
updateStream(_ctx: Context, content: string): void {
const keyInfo = getSessionKeyFromCtx(_ctx);
Expand All @@ -381,7 +452,13 @@ export class MessageSender {
const state = this.streamStates.get(sessionKey);

if (state) {
// Stop typing indicator and spinner
// Mark as finishing first so in-flight flushTextStream calls bail out
state.finishing = true;
// Stop text stream timer, typing indicator, and spinner
if (state.textStreamInterval) {
clearInterval(state.textStreamInterval);
state.textStreamInterval = null;
}
this.stopTypingIndicator(state);
this.stopSpinnerAnimation(state);
state.currentOperation = null;
Expand Down Expand Up @@ -470,7 +547,11 @@ export class MessageSender {

const state = this.streamStates.get(sessionKey);
if (state) {
// Stop typing indicator and spinner
// Stop text stream timer, typing indicator, and spinner
if (state.textStreamInterval) {
clearInterval(state.textStreamInterval);
state.textStreamInterval = null;
}
this.stopTypingIndicator(state);
this.stopSpinnerAnimation(state);

Expand Down
2 changes: 1 addition & 1 deletion src/telegram/terminal-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ function truncateCommand(command: string | undefined, maxLen: number = 50): stri
/**
* Truncate a URL for display
*/
function truncateUrl(url: string | undefined, maxLen: number = 40): string | undefined {
function truncateUrl(url: string | undefined, maxLen: number = 60): string | undefined {
if (!url) return undefined;
if (url.length <= maxLen) return url;
return url.substring(0, maxLen - 3) + '...';
Expand Down