	private showThinkingSelector(): void {
		this.showSelector((done) => {
			const selector = new ThinkingSelectorComponent(
				this.session.thinkingLevel,
				(level) => {
					this.session.setThinkingLevel(level);
					this.updateEditorBorderColor();
					this.chatContainer.addChild(new Spacer(1));
					this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking level: ${level}`), 1, 0));
					done();
					this.ui.requestRender();
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getSelectList() };
		});
	}

	private showQueueModeSelector(): void {
		this.showSelector((done) => {
			const selector = new QueueModeSelectorComponent(
				this.session.queueMode,
				(mode) => {
					this.session.setQueueMode(mode);
					this.chatContainer.addChild(new Spacer(1));
					this.chatContainer.addChild(new Text(theme.fg("dim", `Queue mode: ${mode}`), 1, 0));
					done();
					this.ui.requestRender();
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getSelectList() };
		});
	}

	private showThemeSelector(): void {
		const currentTheme = this.settingsManager.getTheme() || "dark";
		this.showSelector((done) => {
			const selector = new ThemeSelectorComponent(
				currentTheme,
				(themeName) => {
					const result = setTheme(themeName);
					this.settingsManager.setTheme(themeName);
					this.ui.invalidate();
					this.chatContainer.addChild(new Spacer(1));
					if (result.success) {
						this.chatContainer.addChild(new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0));
					} else {
						this.chatContainer.addChild(
							new Text(
								theme.fg(
									"error",
									`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`,
								),
								1,
								0,
							),
						);
					}
					done();
					this.ui.requestRender();
				},
				() => {
					done();
					this.ui.requestRender();
				},
				(themeName) => {
					const result = setTheme(themeName);
					if (result.success) {
						this.ui.invalidate();
						this.ui.requestRender();
					}
				},
			);
			return { component: selector, focus: selector.getSelectList() };
		});
	}

	private showThinkingSelector(): void {
		this.showSelector((done) => {
			const selector = new ThinkingSelectorComponent(
				this.session.thinkingLevel,
				(level) => {
					this.session.setThinkingLevel(level);
					this.updateEditorBorderColor();
					done();
					this.showStatus(`Thinking level: ${level}`);
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getSelectList() };
		});
	}

	private showQueueModeSelector(): void {
		this.showSelector((done) => {
			const selector = new QueueModeSelectorComponent(
				this.session.queueMode,
				(mode) => {
					this.session.setQueueMode(mode);
					done();
					this.showStatus(`Queue mode: ${mode}`);
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getSelectList() };
		});
	}

	private showThemeSelector(): void {
		const currentTheme = this.settingsManager.getTheme() || "dark";
		this.showSelector((done) => {
			const selector = new ThemeSelectorComponent(
				currentTheme,
				(themeName) => {
					const result = setTheme(themeName);
					this.settingsManager.setTheme(themeName);
					this.ui.invalidate();
					done();
					if (result.success) {
						this.showStatus(`Theme: ${themeName}`);
					} else {
						this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
					}
				},
				() => {
					done();
					this.ui.requestRender();
				},
				(themeName) => {
					const result = setTheme(themeName);
					if (result.success) {
						this.ui.invalidate();
						this.ui.requestRender();
					}
				},
			);
			return { component: selector, focus: selector.getSelectList() };
		});
	}


Successfully replaced text in /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts. Changed 2246 characters to 1747 characters.

      ...
  853 				this.session.thinkingLevel,
  854 				(level) => {
  855 					this.session.setThinkingLevel(level);
  856 					this.updateEditorBorderColor();
- 857 					this.chatContainer.addChild(new Spacer(1));
- 858 					this.chatContainer.addChild(new Text(theme.fg("dim", `Thinking level: ${level}`), 1, 0));
  859 					done();
- 860 					this.ui.requestRender();
+ 858 					this.showStatus(`Thinking level: ${level}`);
  861 				},
  862 				() => {
  863 					done();
  864 					this.ui.requestRender();
  865 				},
  866 			);
  867 			return { component: selector, focus: selector.getSelectList() };
  868 		});
  869 	}
  870
  871 	private showQueueModeSelector(): void {
  872 		this.showSelector((done) => {
  873 			const selector = new QueueModeSelectorComponent(
  874 				this.session.queueMode,
  875 				(mode) => {
  876 					this.session.setQueueMode(mode);
- 877 					this.chatContainer.addChild(new Spacer(1));
- 878 					this.chatContainer.addChild(new Text(theme.fg("dim", `Queue mode: ${mode}`), 1, 0));
  879 					done();
- 880 					this.ui.requestRender();
+ 876 					this.showStatus(`Queue mode: ${mode}`);
  881 				},
  882 				() => {
  883 					done();
  884 					this.ui.requestRender();
  885 				},
  886 			);
  887 			return { component: selector, focus: selector.getSelectList() };
  888 		});
  889 	}
  890
  891 	private showThemeSelector(): void {
  892 		const currentTheme = this.settingsManager.getTheme() || "dark";
  893 		this.showSelector((done) => {
  894 			const selector = new ThemeSelectorComponent(
  895 				currentTheme,
  896 				(themeName) => {
  897 					const result = setTheme(themeName);
  898 					this.settingsManager.setTheme(themeName);
  899 					this.ui.invalidate();
- 900 					this.chatContainer.addChild(new Spacer(1));
+ 896 					done();
  901 					if (result.success) {
- 902 						this.chatContainer.addChild(new Text(theme.fg("dim", `Theme: ${themeName}`), 1, 0));
+ 898 						this.showStatus(`Theme: ${themeName}`);
  903 					} else {
- 904 						this.chatContainer.addChild(
- 905 							new Text(
- 906 								theme.fg(
- 907 									"error",
- 908 									`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`,
- 909 								),
- 910 								1,
- 911 								0,
- 912 							),
- 913 						);
+ 900 						this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
  914 					}
- 915 					done();
- 916 					this.ui.requestRender();
  917 				},
  918 				() => {
  919 					done();
  920 					this.ui.requestRender();
      ...


			const selector = new ThemeSelectorComponent(
				currentTheme,
				(themeName) => {
					const result = setTheme(themeName);
					this.settingsManager.setTheme(themeName);
					this.ui.invalidate();
					done();
					if (result.success) {
						this.showStatus(`Theme: ${themeName}`);
					} else {
						this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`);
					}
				},
				() => {
					done();
					this.ui.requestRender();
				},
				(themeName) => {
					const result = setTheme(themeName);
					if (result.success) {
						this.ui.invalidate();
						this.ui.requestRender();
					}
				},
			);
			return { component: selector, focus: selector.getSelectList() };
		});
	}

	private showModelSelector(): void {
		this.showSelector((done) => {
			const selector = new ModelSelectorComponent(
				this.ui,
				this.session.model,
				this.settingsManager,
				(model) => {
					this.agent.setModel(model);
					this.sessionManager.saveModelChange(model.provider, model.id);
					this.chatContainer.addChild(new Spacer(1));
					this.chatContainer.addChild(new Text(theme.fg("dim", `Model: ${model.id}`), 1, 0));
					done();
					this.ui.requestRender();
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector };
		});
	}

	private showUserMessageSelector(): void {
		const userMessages = this.session.getUserMessagesForBranching();

		if (userMessages.length <= 1) {
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", "No messages to branch from"), 1, 0));
			this.ui.requestRender();
			return;
		}

		this.showSelector((done) => {
			const selector = new UserMessageSelectorComponent(
				userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
				(entryIndex) => {
					const selectedText = this.session.branch(entryIndex);
					this.chatContainer.clear();
					this.isFirstUserMessage = true;
					this.renderInitialMessages(this.session.state);
					this.chatContainer.addChild(new Spacer(1));
					this.chatContainer.addChild(new Text(theme.fg("dim", "Branched to new session"), 1, 0));
					this.editor.setText(selectedText);
					done();
					this.ui.requestRender();
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getMessageList() };
		});
	}

	private showSessionSelector(): void {
		this.showSelector((done) => {
			const selector = new SessionSelectorComponent(
				this.sessionManager,
				async (sessionPath) => {
					done();
					await this.handleResumeSession(sessionPath);
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getSessionList() };
		});

[407 more lines in file. Use offset=990 to continue]


edit

	private showModelSelector(): void {
		this.showSelector((done) => {
			const selector = new ModelSelectorComponent(
				this.ui,
				this.session.model,
				this.settingsManager,
				(model) => {
					this.agent.setModel(model);
					this.sessionManager.saveModelChange(model.provider, model.id);
					this.chatContainer.addChild(new Spacer(1));
					this.chatContainer.addChild(new Text(theme.fg("dim", `Model: ${model.id}`), 1, 0));
					done();
					this.ui.requestRender();
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector };
		});
	}

	private showUserMessageSelector(): void {
		const userMessages = this.session.getUserMessagesForBranching();

		if (userMessages.length <= 1) {
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", "No messages to branch from"), 1, 0));
			this.ui.requestRender();
			return;
		}

		this.showSelector((done) => {
			const selector = new UserMessageSelectorComponent(
				userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
				(entryIndex) => {
					const selectedText = this.session.branch(entryIndex);
					this.chatContainer.clear();
					this.isFirstUserMessage = true;
					this.renderInitialMessages(this.session.state);
					this.chatContainer.addChild(new Spacer(1));
					this.chatContainer.addChild(new Text(theme.fg("dim", "Branched to new session"), 1, 0));
					this.editor.setText(selectedText);
					done();
					this.ui.requestRender();
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getMessageList() };
		});
	}

	private showModelSelector(): void {
		this.showSelector((done) => {
			const selector = new ModelSelectorComponent(
				this.ui,
				this.session.model,
				this.settingsManager,
				(model) => {
					this.agent.setModel(model);
					this.sessionManager.saveModelChange(model.provider, model.id);
					done();
					this.showStatus(`Model: ${model.id}`);
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector };
		});
	}

	private showUserMessageSelector(): void {
		const userMessages = this.session.getUserMessagesForBranching();

		if (userMessages.length <= 1) {
			this.showStatus("No messages to branch from");
			return;
		}

		this.showSelector((done) => {
			const selector = new UserMessageSelectorComponent(
				userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
				(entryIndex) => {
					const selectedText = this.session.branch(entryIndex);
					this.chatContainer.clear();
					this.isFirstUserMessage = true;
					this.renderInitialMessages(this.session.state);
					this.editor.setText(selectedText);
					done();
					this.showStatus("Branched to new session");
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getMessageList() };
		});
	}


Successfully replaced text in /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts. Changed 1685 characters to 1317 characters.

      ...
  924 				this.settingsManager,
  925 				(model) => {
  926 					this.agent.setModel(model);
  927 					this.sessionManager.saveModelChange(model.provider, model.id);
- 928 					this.chatContainer.addChild(new Spacer(1));
- 929 					this.chatContainer.addChild(new Text(theme.fg("dim", `Model: ${model.id}`), 1, 0));
  930 					done();
- 931 					this.ui.requestRender();
+ 929 					this.showStatus(`Model: ${model.id}`);
  932 				},
  933 				() => {
  934 					done();
  935 					this.ui.requestRender();
  936 				},
  937 			);
  938 			return { component: selector, focus: selector };
  939 		});
  940 	}
  941
  942 	private showUserMessageSelector(): void {
  943 		const userMessages = this.session.getUserMessagesForBranching();
  944
  945 		if (userMessages.length <= 1) {
- 946 			this.chatContainer.addChild(new Spacer(1));
- 947 			this.chatContainer.addChild(new Text(theme.fg("dim", "No messages to branch from"), 1, 0));
- 948 			this.ui.requestRender();
+ 944 			this.showStatus("No messages to branch from");
  949 			return;
  950 		}
  951
  952 		this.showSelector((done) => {
  953 			const selector = new UserMessageSelectorComponent(
  954 				userMessages.map((m) => ({ index: m.entryIndex, text: m.text })),
  955 				(entryIndex) => {
  956 					const selectedText = this.session.branch(entryIndex);
  957 					this.chatContainer.clear();
  958 					this.isFirstUserMessage = true;
  959 					this.renderInitialMessages(this.session.state);
- 960 					this.chatContainer.addChild(new Spacer(1));
- 961 					this.chatContainer.addChild(new Text(theme.fg("dim", "Branched to new session"), 1, 0));
  962 					this.editor.setText(selectedText);
  963 					done();
- 964 					this.ui.requestRender();
+ 958 					this.showStatus("Branched to new session");
  965 				},
  966 				() => {
  967 					done();
  968 					this.ui.requestRender();
      ...


				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getMessageList() };
		});
	}

	private showSessionSelector(): void {
		this.showSelector((done) => {
			const selector = new SessionSelectorComponent(
				this.sessionManager,
				async (sessionPath) => {
					done();
					await this.handleResumeSession(sessionPath);
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector.getSessionList() };
		});
	}

	private async handleResumeSession(sessionPath: string): Promise<void> {
		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Clear UI state
		this.pendingMessagesContainer.clear();
		this.streamingComponent = null;
		this.pendingTools.clear();

		// Switch session via AgentSession
		await this.session.switchSession(sessionPath);

		// Clear and re-render the chat
		this.chatContainer.clear();
		this.isFirstUserMessage = true;
		this.renderInitialMessages(this.session.state);

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", "Resumed session"), 1, 0));
		this.ui.requestRender();
	}

	private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
		if (mode === "logout") {
			const loggedInProviders = listOAuthProviders();
			if (loggedInProviders.length === 0) {
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(
					new Text(theme.fg("dim", "No OAuth providers logged in. Use /login first."), 1, 0),
				);
				this.ui.requestRender();
				return;
			}
		}

		this.showSelector((done) => {
			const selector = new OAuthSelectorComponent(
				mode,
				async (providerId: string) => {
					done();

					if (mode === "login") {
						this.chatContainer.addChild(new Spacer(1));
						this.chatContainer.addChild(new Text(theme.fg("dim", `Logging in to ${providerId}...`), 1, 0));
						this.ui.requestRender();

						try {
							await login(
								providerId as SupportedOAuthProvider,
								(url: string) => {
									this.chatContainer.addChild(new Spacer(1));
									this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0));
									this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
									this.chatContainer.addChild(new Spacer(1));
									this.chatContainer.addChild(
										new Text(theme.fg("warning", "Paste the authorization code below:"), 1, 0),
									);
									this.ui.requestRender();

									const openCmd =
										process.platform === "darwin"
											? "open"
											: process.platform === "win32"
												? "start"
												: "xdg-open";
									exec(`${openCmd} "${url}"`);
								},
								async () => {
									return new Promise<string>((resolve) => {
										const codeInput = new Input();

[331 more lines in file. Use offset=1060 to continue]


	private async handleResumeSession(sessionPath: string): Promise<void> {
		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Clear UI state
		this.pendingMessagesContainer.clear();
		this.streamingComponent = null;
		this.pendingTools.clear();

		// Switch session via AgentSession
		await this.session.switchSession(sessionPath);

		// Clear and re-render the chat
		this.chatContainer.clear();
		this.isFirstUserMessage = true;
		this.renderInitialMessages(this.session.state);

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", "Resumed session"), 1, 0));
		this.ui.requestRender();
	}

	private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
		if (mode === "logout") {
			const loggedInProviders = listOAuthProviders();
			if (loggedInProviders.length === 0) {
				this.chatContainer.addChild(new Spacer(1));
				this.chatContainer.addChild(
					new Text(theme.fg("dim", "No OAuth providers logged in. Use /login first."), 1, 0),
				);
				this.ui.requestRender();
				return;
			}
		}

		this.showSelector((done) => {
			const selector = new OAuthSelectorComponent(
				mode,
				async (providerId: string) => {
					done();

					if (mode === "login") {
						this.chatContainer.addChild(new Spacer(1));
						this.chatContainer.addChild(new Text(theme.fg("dim", `Logging in to ${providerId}...`), 1, 0));
						this.ui.requestRender();

	private async handleResumeSession(sessionPath: string): Promise<void> {
		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Clear UI state
		this.pendingMessagesContainer.clear();
		this.streamingComponent = null;
		this.pendingTools.clear();

		// Switch session via AgentSession
		await this.session.switchSession(sessionPath);

		// Clear and re-render the chat
		this.chatContainer.clear();
		this.isFirstUserMessage = true;
		this.renderInitialMessages(this.session.state);
		this.showStatus("Resumed session");
	}

	private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
		if (mode === "logout") {
			const loggedInProviders = listOAuthProviders();
			if (loggedInProviders.length === 0) {
				this.showStatus("No OAuth providers logged in. Use /login first.");
				return;
			}
		}

		this.showSelector((done) => {
			const selector = new OAuthSelectorComponent(
				mode,
				async (providerId: string) => {
					done();

					if (mode === "login") {
						this.showStatus(`Logging in to ${providerId}...`);


Successfully replaced text in /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts. Changed 1530 characters to 1151 characters.

      ...
 1002 		// Clear and re-render the chat
 1003 		this.chatContainer.clear();
 1004 		this.isFirstUserMessage = true;
 1005 		this.renderInitialMessages(this.session.state);
-1006
-1007 		this.chatContainer.addChild(new Spacer(1));
-1008 		this.chatContainer.addChild(new Text(theme.fg("dim", "Resumed session"), 1, 0));
-1009 		this.ui.requestRender();
+1006 		this.showStatus("Resumed session");
 1010 	}
 1011
 1012 	private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
 1013 		if (mode === "logout") {
 1014 			const loggedInProviders = listOAuthProviders();
 1015 			if (loggedInProviders.length === 0) {
-1016 				this.chatContainer.addChild(new Spacer(1));
-1017 				this.chatContainer.addChild(
-1018 					new Text(theme.fg("dim", "No OAuth providers logged in. Use /login first."), 1, 0),
-1019 				);
-1020 				this.ui.requestRender();
+1013 				this.showStatus("No OAuth providers logged in. Use /login first.");
 1021 				return;
 1022 			}
 1023 		}
 1024
 1025 		this.showSelector((done) => {
 1026 			const selector = new OAuthSelectorComponent(
 1027 				mode,
 1028 				async (providerId: string) => {
 1029 					done();
 1030
 1031 					if (mode === "login") {
-1032 						this.chatContainer.addChild(new Spacer(1));
-1033 						this.chatContainer.addChild(new Text(theme.fg("dim", `Logging in to ${providerId}...`), 1, 0));
-1034 						this.ui.requestRender();
+1025 						this.showStatus(`Logging in to ${providerId}...`);
 1035
 1036 						try {
 1037 							await login(
 1038 								providerId as SupportedOAuthProvider,
      ...


				mode,
				async (providerId: string) => {
					done();

					if (mode === "login") {
						this.showStatus(`Logging in to ${providerId}...`);

						try {
							await login(
								providerId as SupportedOAuthProvider,
								(url: string) => {
									this.chatContainer.addChild(new Spacer(1));
									this.chatContainer.addChild(new Text(theme.fg("accent", "Opening browser to:"), 1, 0));
									this.chatContainer.addChild(new Text(theme.fg("accent", url), 1, 0));
									this.chatContainer.addChild(new Spacer(1));
									this.chatContainer.addChild(
										new Text(theme.fg("warning", "Paste the authorization code below:"), 1, 0),
									);
									this.ui.requestRender();

									const openCmd =
										process.platform === "darwin"
											? "open"
											: process.platform === "win32"
												? "start"
												: "xdg-open";
									exec(`${openCmd} "${url}"`);
								},
								async () => {
									return new Promise<string>((resolve) => {
										const codeInput = new Input();
										codeInput.onSubmit = () => {
											const code = codeInput.getValue();
											this.editorContainer.clear();
											this.editorContainer.addChild(this.editor);
											this.ui.setFocus(this.editor);
											resolve(code);
										};
										this.editorContainer.clear();
										this.editorContainer.addChild(codeInput);
										this.ui.setFocus(codeInput);
										this.ui.requestRender();
									});
								},
							);

							invalidateOAuthCache();
							this.chatContainer.addChild(new Spacer(1));
							this.chatContainer.addChild(
								new Text(theme.fg("success", `✓ Successfully logged in to ${providerId}`), 1, 0),
							);
							this.chatContainer.addChild(new Text(theme.fg("dim", `Tokens saved to ${getOAuthPath()}`), 1, 0));
							this.ui.requestRender();
						} catch (error: unknown) {
							this.showError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
						}
					} else {
						try {
							await logout(providerId as SupportedOAuthProvider);
							invalidateOAuthCache();
							this.chatContainer.addChild(new Spacer(1));
							this.chatContainer.addChild(
								new Text(theme.fg("success", `✓ Successfully logged out of ${providerId}`), 1, 0),
							);
							this.chatContainer.addChild(
								new Text(theme.fg("dim", `Credentials removed from ${getOAuthPath()}`), 1, 0),
							);
							this.ui.requestRender();
						} catch (error: unknown) {
							this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
						}
					}
				},
				() => {
					done();
					this.ui.requestRender();
				},
			);
			return { component: selector, focus: selector };
		});
	}

	// =========================================================================
	// Command handlers
	// =========================================================================

	private handleExportCommand(text: string): void {
		const parts = text.split(/\s+/);
		const outputPath = parts.length > 1 ? parts[1] : undefined;

		try {
			const filePath = this.session.exportToHtml(outputPath);
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", `Session exported to: ${filePath}`), 1, 0));
			this.ui.requestRender();
		} catch (error: unknown) {
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(
				new Text(
					theme.fg(

[262 more lines in file. Use offset=1120 to continue]


	private handleExportCommand(text: string): void {
		const parts = text.split(/\s+/);
		const outputPath = parts.length > 1 ? parts[1] : undefined;

		try {
			const filePath = this.session.exportToHtml(outputPath);
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", `Session exported to: ${filePath}`), 1, 0));
			this.ui.requestRender();
		} catch (error: unknown) {
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(
				new Text(
					theme.fg(
						"error",
						`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`,
					),
					1,
					0,
				),
			);
			this.ui.requestRender();
		}
	}

	private handleExportCommand(text: string): void {
		const parts = text.split(/\s+/);
		const outputPath = parts.length > 1 ? parts[1] : undefined;

		try {
			const filePath = this.session.exportToHtml(outputPath);
			this.showStatus(`Session exported to: ${filePath}`);
		} catch (error: unknown) {
			this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
		}
	}


Successfully replaced text in /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts. Changed 710 characters to 415 characters.

      ...
 1108 		const outputPath = parts.length > 1 ? parts[1] : undefined;
 1109
 1110 		try {
 1111 			const filePath = this.session.exportToHtml(outputPath);
-1112 			this.chatContainer.addChild(new Spacer(1));
-1113 			this.chatContainer.addChild(new Text(theme.fg("dim", `Session exported to: ${filePath}`), 1, 0));
-1114 			this.ui.requestRender();
+1112 			this.showStatus(`Session exported to: ${filePath}`);
 1115 		} catch (error: unknown) {
-1116 			this.chatContainer.addChild(new Spacer(1));
-1117 			this.chatContainer.addChild(
-1118 				new Text(
-1119 					theme.fg(
-1120 						"error",
-1121 						`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`,
-1122 					),
-1123 					1,
-1124 					0,
-1125 				),
-1126 			);
-1127 			this.ui.requestRender();
+1114 			this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
 1128 		}
 1129 	}
 1130
 1131 	private handleCopyCommand(): void {
      ...


		try {
			const filePath = this.session.exportToHtml(outputPath);
			this.showStatus(`Session exported to: ${filePath}`);
		} catch (error: unknown) {
			this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
		}
	}

	private handleCopyCommand(): void {
		const text = this.session.getLastAssistantText();
		if (!text) {
			this.showError("No agent messages to copy yet.");
			return;
		}

		try {
			copyToClipboard(text);
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", "Copied last agent message to clipboard"), 1, 0));
			this.ui.requestRender();
		} catch (error) {
			this.showError(error instanceof Error ? error.message : String(error));
		}
	}

	private handleSessionCommand(): void {
		const stats = this.session.getSessionStats();

		let info = `${theme.bold("Session Info")}\n\n`;
		info += `${theme.fg("dim", "File:")} ${stats.sessionFile}\n`;
		info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
		info += `${theme.bold("Messages")}\n`;
		info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
		info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`;
		info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;
		info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;
		info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`;
		info += `${theme.bold("Tokens")}\n`;
		info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
		info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
		if (stats.tokens.cacheRead > 0) {
			info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`;
		}
		if (stats.tokens.cacheWrite > 0) {
			info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`;
		}
		info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;

		if (stats.cost > 0) {
			info += `\n${theme.bold("Cost")}\n`;
			info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`;
		}

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(info, 1, 0));
		this.ui.requestRender();
	}

	private handleChangelogCommand(): void {
		const changelogPath = getChangelogPath();
		const allEntries = parseChangelog(changelogPath);

		const changelogMarkdown =
			allEntries.length > 0
				? allEntries
						.reverse()
						.map((e) => e.content)
						.join("\n\n")
				: "No changelog entries found.";

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new DynamicBorder());
		this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
		this.ui.addChild(new Spacer(1));
		this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme()));
		this.chatContainer.addChild(new DynamicBorder());
		this.ui.requestRender();
	}

	private async handleClearCommand(): Promise<void> {

[179 more lines in file. Use offset=1190 to continue]


	private handleCopyCommand(): void {
		const text = this.session.getLastAssistantText();
		if (!text) {
			this.showError("No agent messages to copy yet.");
			return;
		}

		try {
			copyToClipboard(text);
			this.chatContainer.addChild(new Spacer(1));
			this.chatContainer.addChild(new Text(theme.fg("dim", "Copied last agent message to clipboard"), 1, 0));
			this.ui.requestRender();
		} catch (error) {
			this.showError(error instanceof Error ? error.message : String(error));
		}
	}

	private handleCopyCommand(): void {
		const text = this.session.getLastAssistantText();
		if (!text) {
			this.showError("No agent messages to copy yet.");
			return;
		}

		try {
			copyToClipboard(text);
			this.showStatus("Copied last agent message to clipboard");
		} catch (error) {
			this.showError(error instanceof Error ? error.message : String(error));
		}
	}


Successfully replaced text in /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts. Changed 490 characters to 370 characters.

      ...
 1123 		}
 1124
 1125 		try {
 1126 			copyToClipboard(text);
-1127 			this.chatContainer.addChild(new Spacer(1));
-1128 			this.chatContainer.addChild(new Text(theme.fg("dim", "Copied last agent message to clipboard"), 1, 0));
-1129 			this.ui.requestRender();
+1127 			this.showStatus("Copied last agent message to clipboard");
 1130 		} catch (error) {
 1131 			this.showError(error instanceof Error ? error.message : String(error));
 1132 		}
 1133 	}
      ...


		this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
		this.ui.addChild(new Spacer(1));
		this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme()));
		this.chatContainer.addChild(new DynamicBorder());
		this.ui.requestRender();
	}

	private async handleClearCommand(): Promise<void> {
		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Reset via session
		await this.session.reset();

		// Clear UI state
		this.chatContainer.clear();
		this.pendingMessagesContainer.clear();
		this.streamingComponent = null;
		this.pendingTools.clear();
		this.isFirstUserMessage = true;

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(
			new Text(theme.fg("accent", "✓ Context cleared") + "\n" + theme.fg("muted", "Started fresh session"), 1, 1),
		);
		this.ui.requestRender();
	}

	private handleDebugCommand(): void {
		const width = this.ui.terminal.columns;
		const allLines = this.ui.render(width);

		const debugLogPath = getDebugLogPath();
		const debugData = [
			`Debug output at ${new Date().toISOString()}`,
			`Terminal width: ${width}`,
			`Total lines: ${allLines.length}`,
			"",
			"=== All rendered lines with visible widths ===",
			...allLines.map((line, idx) => {
				const vw = visibleWidth(line);
				const escaped = JSON.stringify(line);
				return `[${idx}] (w=${vw}) ${escaped}`;
			}),
			"",
			"=== Agent messages (JSONL) ===",
			...this.session.messages.map((msg) => JSON.stringify(msg)),
			"",
		].join("\n");

		fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
		fs.writeFileSync(debugLogPath, debugData);

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(
			new Text(theme.fg("accent", "✓ Debug log written") + "\n" + theme.fg("muted", debugLogPath), 1, 1),
		);
		this.ui.requestRender();
	}

	private async handleBashCommand(command: string): Promise<void> {
		this.bashComponent = new BashExecutionComponent(command, this.ui);
		this.chatContainer.addChild(this.bashComponent);
		this.ui.requestRender();

		try {
			const result = await this.session.executeBash(command, (chunk) => {
				if (this.bashComponent) {
					this.bashComponent.appendOutput(chunk);
					this.ui.requestRender();
				}
			});

			if (this.bashComponent) {
				this.bashComponent.setComplete(
					result.exitCode,

[107 more lines in file. Use offset=1260 to continue]


					result.cancelled,
					result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined,
					result.fullOutputPath,
				);
			}
		} catch (error) {
			if (this.bashComponent) {
				this.bashComponent.setComplete(null, false);
			}
			this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
		}

		this.bashComponent = null;
		this.ui.requestRender();
	}

	private async handleCompactCommand(customInstructions?: string): Promise<void> {
		const entries = this.sessionManager.loadEntries();
		const messageCount = entries.filter((e) => e.type === "message").length;

		if (messageCount < 2) {
			this.showWarning("Nothing to compact (no messages yet)");
			return;
		}

		await this.executeCompaction(customInstructions, false);
	}

	private handleAutocompactCommand(): void {
		const newState = !this.session.autoCompactionEnabled;
		this.session.setAutoCompactionEnabled(newState);
		this.footer.setAutoCompactEnabled(newState);

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", `Auto-compaction: ${newState ? "on" : "off"}`), 1, 0));
		this.ui.requestRender();
	}

	private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
		// Stop loading animation
		if (this.loadingAnimation) {
			this.loadingAnimation.stop();
			this.loadingAnimation = null;
		}
		this.statusContainer.clear();

		// Set up escape handler during compaction
		const originalOnEscape = this.editor.onEscape;
		this.editor.onEscape = () => {
			this.session.abortCompaction();
		};

		// Show compacting status
		this.chatContainer.addChild(new Spacer(1));
		const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
		const compactingLoader = new Loader(
			this.ui,
			(spinner) => theme.fg("accent", spinner),
			(text) => theme.fg("muted", text),
			label,
		);
		this.statusContainer.addChild(compactingLoader);
		this.ui.requestRender();

		try {
			const result = await this.session.compact(customInstructions);

			// Rebuild UI
			this.chatContainer.clear();
			this.rebuildChatFromMessages();

			// Add compaction component
			const compactionComponent = new CompactionComponent(result.tokensBefore, result.summary);
			compactionComponent.setExpanded(this.toolOutputExpanded);
			this.chatContainer.addChild(compactionComponent);

			this.footer.updateState(this.session.state);
		} catch (error) {
			const message = error instanceof Error ? error.message : String(error);
			if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {

[27 more lines in file. Use offset=1340 to continue]


	private handleAutocompactCommand(): void {
		const newState = !this.session.autoCompactionEnabled;
		this.session.setAutoCompactionEnabled(newState);
		this.footer.setAutoCompactEnabled(newState);

		this.chatContainer.addChild(new Spacer(1));
		this.chatContainer.addChild(new Text(theme.fg("dim", `Auto-compaction: ${newState ? "on" : "off"}`), 1, 0));
		this.ui.requestRender();
	}

	private handleAutocompactCommand(): void {
		const newState = !this.session.autoCompactionEnabled;
		this.session.setAutoCompactionEnabled(newState);
		this.footer.setAutoCompactEnabled(newState);
		this.showStatus(`Auto-compaction: ${newState ? "on" : "off"}`);
	}


Successfully replaced text in /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts. Changed 385 characters to 266 characters.

      ...
 1288 	private handleAutocompactCommand(): void {
 1289 		const newState = !this.session.autoCompactionEnabled;
 1290 		this.session.setAutoCompactionEnabled(newState);
 1291 		this.footer.setAutoCompactEnabled(newState);
-1292
-1293 		this.chatContainer.addChild(new Spacer(1));
-1294 		this.chatContainer.addChild(new Text(theme.fg("dim", `Auto-compaction: ${newState ? "on" : "off"}`), 1, 0));
-1295 		this.ui.requestRender();
+1292 		this.showStatus(`Auto-compaction: ${newState ? "on" : "off"}`);
 1296 	}
 1297
 1298 	private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
 1299 		// Stop loading animation
      ...


Now let me check and run the tests:

bash

cd /Users/badlogic/workspaces/companion-mono && npm run check 2>&1 | grep -E "(error|warning)" | head -20


(no output)


cd /Users/badlogic/workspaces/companion-mono && npm run check 2>&1 | tail -10


Checked 22 files in 23ms. No fixes applied.

> @mariozechner/companion-web-ui@0.14.2 check
> tsc --noEmit && cd example && tsc --noEmit

> companion-web-ui-example@1.2.2 check
> tsc --noEmit


Let me check how much we reduced:


1362 /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/modes/interactive/interactive-mode.ts


Down from 1508 to 1362 lines (~146 lines saved). Let me commit:

cd /Users/badlogic/workspaces/companion-mono && git add -A && git commit -m "Deduplicate interactive-mode: consolidate message rendering, add showStatus helper"


Running formatting, linting, and type checking...

> companion-monorepo@0.0.3 check
> biome check --write . && npm run check --workspaces --if-present && tsgo --noEmit

Checked 238 files in 124ms. No fixes applied.

> @mariozechner/companion-agent-core@0.14.2 check
> tsgo --noEmit

> @mariozechner/companion-ai@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 36 files in 45ms. No fixes applied.

> @mariozechner/companion-coding-agent@0.14.2 check
> tsgo --noEmit

> @mariozechner/companion-mom@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 12 files in 14ms. No fixes applied.

> @mariozechner/companion@0.14.2 check
> biome check --write .

Checked 10 files in 17ms. No fixes applied.

> @mariozechner/companion-proxy@0.14.2 check
> biome check --write .

Checked 3 files in 3ms. No fixes applied.

> @mariozechner/companion-tui@0.14.2 check
> biome check --write . && tsgo --noEmit

Checked 22 files in 23ms. No fixes applied.

> @mariozechner/companion-web-ui@0.14.2 check
> tsc --noEmit && cd example && tsc --noEmit

> companion-web-ui-example@1.2.2 check
> tsc --noEmit

✅ All pre-commit checks passed!
[refactor 109a30b] Deduplicate interactive-mode: consolidate message rendering, add showStatus helper
 2 files changed, 64 insertions(+), 1865 deletions(-)
 delete mode 100644 packages/coding-agent/docs/refactor.md


Done. Summary of changes:

1. **Added `getUserMessageText()` helper** - extracts text from user messages (was duplicated 4+ times)

2. **Added `showStatus()` helper** - replaces the pattern `addChild(Spacer) + addChild(Text(theme.fg("dim",...))) + requestRender()`

3. **Consolidated `renderInitialMessages` and `rebuildChatFromMessages`** into a single `renderMessages()` method with options. The public-facing methods now just call it with different params.

4. **Replaced ~20 instances** of the status message pattern with `showStatus()`

Result: **1508 → 1362 lines** (~10% reduction, 146 lines saved)


alright, let's tackle main-new.ts. read it in full, suggest how we can

- remove any code duplication
- split it into separate modules where appropriate, with a desriptin of each module
- locations where those modules should go


The user wants me to read main-new.ts and analyze it for:
1. Code duplication
2. Opportunities to split into separate modules
3. Where those modules should be placed

Let me read the file first.


import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@mariozechner/companion-agent-core";
import type { Api, KnownProvider, Model } from "@mariozechner/companion-ai";
import { ProcessTerminal, TUI } from "@mariozechner/companion-tui";
import chalk from "chalk";
import { existsSync, readFileSync, statSync } from "fs";
import { homedir } from "os";
import { extname, join, resolve } from "path";
import { AgentSession } from "./core/agent-session.js";
import { exportFromFile } from "./core/export-html.js";
import { messageTransformer } from "./core/messages.js";
import { findModel, getApiKeyForModel, getAvailableModels } from "./core/model-config.js";
import { SessionManager } from "./core/session-manager.js";
import { SettingsManager } from "./core/settings-manager.js";
import { loadSlashCommands } from "./core/slash-commands.js";
import { allTools, codingTools, type ToolName } from "./core/tools/index.js";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
import { SessionSelectorComponent } from "./modes/interactive/components/session-selector.js";
import { initTheme } from "./modes/interactive/theme/theme.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog.js";
import {
	APP_NAME,
	CONFIG_DIR_NAME,
	ENV_AGENT_DIR,
	getAgentDir,
	getModelsPath,
	getReadmePath,
	VERSION,
} from "./utils/config.js";
import { ensureTool } from "./utils/tools-manager.js";

const defaultModelPerProvider: Record<KnownProvider, string> = {
	anthropic: "claude-sonnet-4-5",
	openai: "gpt-5.1-codex",
	google: "gemini-2.5-pro",
	openrouter: "openai/gpt-5.1-codex",
	xai: "grok-4-fast-non-reasoning",
	groq: "openai/gpt-oss-120b",
	cerebras: "zai-glm-4.6",
	zai: "glm-4.6",
};

type Mode = "text" | "json" | "rpc";

interface Args {
	provider?: string;
	model?: string;
	apiKey?: string;
	systemPrompt?: string;
	appendSystemPrompt?: string;
	thinking?: ThinkingLevel;
	continue?: boolean;
	resume?: boolean;
	help?: boolean;
	mode?: Mode;
	noSession?: boolean;
	session?: string;
	models?: string[];
	tools?: ToolName[];
	print?: boolean;
	export?: string;
	messages: string[];
	fileArgs: string[];
}

function parseArgs(args: string[]): Args {
	const result: Args = {
		messages: [],
		fileArgs: [],
	};

	for (let i = 0; i < args.length; i++) {
		const arg = args[i];

		if (arg === "--help" || arg === "-h") {
			result.help = true;
		} else if (arg === "--mode" && i + 1 < args.length) {
			const mode = args[++i];
			if (mode === "text" || mode === "json" || mode === "rpc") {
				result.mode = mode;
			}
		} else if (arg === "--continue" || arg === "-c") {
			result.continue = true;
		} else if (arg === "--resume" || arg === "-r") {
			result.resume = true;
		} else if (arg === "--provider" && i + 1 < args.length) {
			result.provider = args[++i];
		} else if (arg === "--model" && i + 1 < args.length) {
			result.model = args[++i];
		} else if (arg === "--api-key" && i + 1 < args.length) {
			result.apiKey = args[++i];
		} else if (arg === "--system-prompt" && i + 1 < args.length) {
			result.systemPrompt = args[++i];
		} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
			result.appendSystemPrompt = args[++i];
		} else if (arg === "--no-session") {
			result.noSession = true;
		} else if (arg === "--session" && i + 1 < args.length) {
			result.session = args[++i];
		} else if (arg === "--models" && i + 1 < args.length) {
			result.models = args[++i].split(",").map((s) => s.trim());
		} else if (arg === "--tools" && i + 1 < args.length) {
			const toolNames = args[++i].split(",").map((s) => s.trim());
			const validTools: ToolName[] = [];
			for (const name of toolNames) {
				if (name in allTools) {
					validTools.push(name as ToolName);
				} else {
					console.error(
						chalk.yellow(`Warning: Unknown tool "${name}". Valid tools: ${Object.keys(allTools).join(", ")}`),
					);
				}
			}
			result.tools = validTools;
		} else if (arg === "--thinking" && i + 1 < args.length) {
			const level = args[++i];
			if (
				level === "off" ||
				level === "minimal" ||
				level === "low" ||
				level === "medium" ||
				level === "high" ||
				level === "xhigh"
			) {
				result.thinking = level;
			} else {
				console.error(
					chalk.yellow(
						`Warning: Invalid thinking level "${level}". Valid values: off, minimal, low, medium, high, xhigh`,
					),
				);
			}
		} else if (arg === "--print" || arg === "-p") {
			result.print = true;
		} else if (arg === "--export" && i + 1 < args.length) {
			result.export = args[++i];
		} else if (arg.startsWith("@")) {
			result.fileArgs.push(arg.slice(1)); // Remove @ prefix
		} else if (!arg.startsWith("-")) {
			result.messages.push(arg);
		}
	}

	return result;
}

/**
 * Map of file extensions to MIME types for common image formats
 */
const IMAGE_MIME_TYPES: Record<string, string> = {
	".jpg": "image/jpeg",
	".jpeg": "image/jpeg",
	".png": "image/png",
	".gif": "image/gif",
	".webp": "image/webp",
};

/**
 * Check if a file is an image based on its extension
 */
function isImageFile(filePath: string): string | null {
	const ext = extname(filePath).toLowerCase();
	return IMAGE_MIME_TYPES[ext] || null;
}

/**
 * Expand ~ to home directory
 */
function expandPath(filePath: string): string {
	if (filePath === "~") {
		return homedir();
	}
	if (filePath.startsWith("~/")) {
		return homedir() + filePath.slice(1);
	}
	return filePath;
}

/**
 * Process @file arguments into text content and image attachments
 */
function processFileArguments(fileArgs: string[]): { textContent: string; imageAttachments: Attachment[] } {
	let textContent = "";
	const imageAttachments: Attachment[] = [];

	for (const fileArg of fileArgs) {
		// Expand and resolve path
		const expandedPath = expandPath(fileArg);
		const absolutePath = resolve(expandedPath);

		// Check if file exists
		if (!existsSync(absolutePath)) {
			console.error(chalk.red(`Error: File not found: ${absolutePath}`));
			process.exit(1);
		}

		// Check if file is empty
		const stats = statSync(absolutePath);
		if (stats.size === 0) {
			// Skip empty files
			continue;
		}

		const mimeType = isImageFile(absolutePath);

		if (mimeType) {
			// Handle image file
			const content = readFileSync(absolutePath);
			const base64Content = content.toString("base64");

			const attachment: Attachment = {
				id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
				type: "image",
				fileName: absolutePath.split("/").pop() || absolutePath,
				mimeType,
				size: stats.size,
				content: base64Content,
			};

			imageAttachments.push(attachment);

			// Add text reference to image
			textContent += `<file name="${absolutePath}"></file>\n`;
		} else {
			// Handle text file
			try {
				const content = readFileSync(absolutePath, "utf-8");
				textContent += `<file name="${absolutePath}">\n${content}\n</file>\n`;
			} catch (error: any) {
				console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${error.message}`));
				process.exit(1);
			}
		}
	}

	return { textContent, imageAttachments };
}

function printHelp() {
	console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools

${chalk.bold("Usage:")}
  ${APP_NAME} [options] [@files...] [messages...]

${chalk.bold("Options:")}
  --provider <name>              Provider name (default: google)
  --model <id>                   Model ID (default: gemini-2.5-flash)
  --api-key <key>                API key (defaults to env vars)
  --system-prompt <text>         System prompt (default: coding assistant prompt)
  --append-system-prompt <text>  Append text or file contents to the system prompt
  --mode <mode>                  Output mode: text (default), json, or rpc
  --print, -p                    Non-interactive mode: process prompt and exit
  --continue, -c                 Continue previous session
  --resume, -r                   Select a session to resume
  --session <path>               Use specific session file
  --no-session                   Don't save session (ephemeral)
  --models <patterns>            Comma-separated model patterns for quick cycling with Ctrl+P
  --tools <tools>                Comma-separated list of tools to enable (default: read,bash,edit,write)
                                 Available: read, bash, edit, write, grep, find, ls
  --thinking <level>             Set thinking level: off, minimal, low, medium, high, xhigh
  --export <file>                Export session file to HTML and exit
  --help, -h                     Show this help

${chalk.bold("Examples:")}
  # Interactive mode
  ${APP_NAME}

  # Interactive mode with initial prompt
  ${APP_NAME} "List all .ts files in src/"

  # Include files in initial message
  ${APP_NAME} @prompt.md @image.png "What color is the sky?"

  # Non-interactive mode (process and exit)
  ${APP_NAME} -p "List all .ts files in src/"

  # Multiple messages (interactive)
  ${APP_NAME} "Read package.json" "What dependencies do we have?"

  # Continue previous session
  ${APP_NAME} --continue "What did we discuss?"

  # Use different model
  ${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code"

  # Limit model cycling to specific models
  ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o

  # Cycle models with fixed thinking levels
  ${APP_NAME} --models sonnet:high,haiku:low

  # Start with a specific thinking level
  ${APP_NAME} --thinking high "Solve this complex problem"

  # Read-only mode (no file modifications possible)
  ${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/"

  # Export a session file to HTML
  ${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl
  ${APP_NAME} --export session.jsonl output.html

${chalk.bold("Environment Variables:")}
  ANTHROPIC_API_KEY       - Anthropic Claude API key
  ANTHROPIC_OAUTH_TOKEN   - Anthropic OAuth token (alternative to API key)
  OPENAI_API_KEY          - OpenAI GPT API key
  GEMINI_API_KEY          - Google Gemini API key
  GROQ_API_KEY            - Groq API key
  CEREBRAS_API_KEY        - Cerebras API key
  XAI_API_KEY             - xAI Grok API key
  OPENROUTER_API_KEY      - OpenRouter API key
  ZAI_API_KEY             - ZAI API key
  ${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)

${chalk.bold("Available Tools (default: read, bash, edit, write):")}
  read   - Read file contents
  bash   - Execute bash commands
  edit   - Edit files with find/replace
  write  - Write files (creates/overwrites)
  grep   - Search file contents (read-only, off by default)
  find   - Find files by glob pattern (read-only, off by default)
  ls     - List directory contents (read-only, off by default)
`);
}

// Tool descriptions for system prompt
const toolDescriptions: Record<ToolName, string> = {
	read: "Read file contents",
	bash: "Execute bash commands (ls, grep, find, etc.)",
	edit: "Make surgical edits to files (find exact text and replace)",
	write: "Create or overwrite files",
	grep: "Search file contents for patterns (respects .gitignore)",
	find: "Find files by glob pattern (respects .gitignore)",
	ls: "List directory contents",
};

function resolvePromptInput(input: string | undefined, description: string): string | undefined {
	if (!input) {
		return undefined;
	}

	if (existsSync(input)) {
		try {
			return readFileSync(input, "utf-8");
		} catch (error) {
			console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));
			return input;
		}
	}

	return input;
}

function buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[], appendSystemPrompt?: string): string {
	const resolvedCustomPrompt = resolvePromptInput(customPrompt, "system prompt");
	const resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, "append system prompt");

	const now = new Date();
	const dateTime = now.toLocaleString("en-US", {
		weekday: "long",
		year: "numeric",
		month: "long",
		day: "numeric",
		hour: "2-digit",
		minute: "2-digit",
		second: "2-digit",
		timeZoneName: "short",
	});

	const appendSection = resolvedAppendPrompt ? `\n\n${resolvedAppendPrompt}` : "";

	if (resolvedCustomPrompt) {
		let prompt = resolvedCustomPrompt;

		if (appendSection) {
			prompt += appendSection;
		}

		// Append project context files
		const contextFiles = loadProjectContextFiles();
		if (contextFiles.length > 0) {
			prompt += "\n\n# Project Context\n\n";
			prompt += "The following project context files have been loaded:\n\n";
			for (const { path: filePath, content } of contextFiles) {
				prompt += `## ${filePath}\n\n${content}\n\n`;
			}
		}

		// Add date/time and working directory last
		prompt += `\nCurrent date and time: ${dateTime}`;
		prompt += `\nCurrent working directory: ${process.cwd()}`;

		return prompt;
	}

	// Get absolute path to README.md
	const readmePath = getReadmePath();

	// Build tools list based on selected tools
	const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
	const toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join("\n");

	// Build guidelines based on which tools are actually available
	const guidelinesList: string[] = [];

	const hasBash = tools.includes("bash");
	const hasEdit = tools.includes("edit");
	const hasWrite = tools.includes("write");
	const hasGrep = tools.includes("grep");
	const hasFind = tools.includes("find");
	const hasLs = tools.includes("ls");
	const hasRead = tools.includes("read");

	// Read-only mode notice (no bash, edit, or write)
	if (!hasBash && !hasEdit && !hasWrite) {
		guidelinesList.push("You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands");
	}

	// Bash without edit/write = read-only bash mode
	if (hasBash && !hasEdit && !hasWrite) {
		guidelinesList.push(
			"Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files",
		);
	}

	// File exploration guidelines
	if (hasBash && !hasGrep && !hasFind && !hasLs) {
		guidelinesList.push("Use bash for file operations like ls, grep, find");
	} else if (hasBash && (hasGrep || hasFind || hasLs)) {
		guidelinesList.push("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
	}

	// Read before edit guideline
	if (hasRead && hasEdit) {
		guidelinesList.push("Use read to examine files before editing");
	}

	// Edit guideline
	if (hasEdit) {
		guidelinesList.push("Use edit for precise changes (old text must match exactly)");
	}

	// Write guideline
	if (hasWrite) {
		guidelinesList.push("Use write only for new files or complete rewrites");
	}

	// Output guideline (only when actually writing/executing)
	if (hasEdit || hasWrite) {
		guidelinesList.push(
			"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did",
		);
	}

	// Always include these
	guidelinesList.push("Be concise in your responses");
	guidelinesList.push("Show file paths clearly when working with files");

	const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n");

	let prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.

Available tools:
${toolsList}

Guidelines:
${guidelines}

Documentation:
- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}
- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`;

	if (appendSection) {
		prompt += appendSection;
	}

	// Append project context files
	const contextFiles = loadProjectContextFiles();
	if (contextFiles.length > 0) {
		prompt += "\n\n# Project Context\n\n";
		prompt += "The following project context files have been loaded:\n\n";
		for (const { path: filePath, content } of contextFiles) {
			prompt += `## ${filePath}\n\n${content}\n\n`;
		}
	}

	// Add date/time and working directory last
	prompt += `\nCurrent date and time: ${dateTime}`;
	prompt += `\nCurrent working directory: ${process.cwd()}`;

	return prompt;
}

/**
 * Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md)
 */
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {
	const candidates = ["AGENTS.md", "CLAUDE.md"];
	for (const filename of candidates) {
		const filePath = join(dir, filename);
		if (existsSync(filePath)) {
			try {
				return {
					path: filePath,
					content: readFileSync(filePath, "utf-8"),
				};
			} catch (error) {
				console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));
			}
		}
	}
	return null;
}

/**
 * Load all project context files in order:
 * 1. Global: ~/{CONFIG_DIR_NAME}/agent/AGENTS.md or CLAUDE.md
 * 2. Parent directories (top-most first) down to cwd
 * Each returns {path, content} for separate messages
 */
function loadProjectContextFiles(): Array<{ path: string; content: string }> {
	const contextFiles: Array<{ path: string; content: string }> = [];

	// 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/
	const globalContextDir = getAgentDir();
	const globalContext = loadContextFileFromDir(globalContextDir);
	if (globalContext) {
		contextFiles.push(globalContext);
	}

	// 2. Walk up from cwd to root, collecting all context files
	const cwd = process.cwd();
	const ancestorContextFiles: Array<{ path: string; content: string }> = [];

	let currentDir = cwd;
	const root = resolve("/");

	while (true) {
		const contextFile = loadContextFileFromDir(currentDir);
		if (contextFile) {
			// Add to beginning so we get top-most parent first
			ancestorContextFiles.unshift(contextFile);
		}

		// Stop if we've reached root
		if (currentDir === root) break;

		// Move up one directory
		const parentDir = resolve(currentDir, "..");
		if (parentDir === currentDir) break; // Safety check
		currentDir = parentDir;
	}

	// Add ancestor files in order (top-most → cwd)
	contextFiles.push(...ancestorContextFiles);

	return contextFiles;
}

async function checkForNewVersion(currentVersion: string): Promise<string | null> {
	try {
		const response = await fetch("https://registry.npmjs.org/@mariozechner/companion-coding-agent/latest");
		if (!response.ok) return null;

		const data = (await response.json()) as { version?: string };
		const latestVersion = data.version;

		if (latestVersion && latestVersion !== currentVersion) {
			return latestVersion;
		}

		return null;
	} catch (error) {
		// Silently fail - don't disrupt the user experience
		return null;
	}
}

/**
 * Resolve model patterns to actual Model objects with optional thinking levels
 * Format: "pattern:level" where :level is optional
 * For each pattern, finds all matching models and picks the best version:
 * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929)
 * 2. If no alias, pick the latest dated version
 */
async function resolveModelScope(
	patterns: string[],
): Promise<Array<{ model: Model<Api>; thinkingLevel: ThinkingLevel }>> {
	const { models: availableModels, error } = await getAvailableModels();

	if (error) {
		console.warn(chalk.yellow(`Warning: Error loading models: ${error}`));
		return [];
	}

	const scopedModels: Array<{ model: Model<Api>; thinkingLevel: ThinkingLevel }> = [];

	for (const pattern of patterns) {
		// Parse pattern:level format
		const parts = pattern.split(":");
		const modelPattern = parts[0];
		let thinkingLevel: ThinkingLevel = "off";

		if (parts.length > 1) {
			const level = parts[1];
			if (
				level === "off" ||
				level === "minimal" ||
				level === "low" ||
				level === "medium" ||
				level === "high" ||
				level === "xhigh"
			) {
				thinkingLevel = level;
			} else {
				console.warn(
					chalk.yellow(`Warning: Invalid thinking level "${level}" in pattern "${pattern}". Using "off" instead.`),
				);
			}
		}

		// Check for provider/modelId format (provider is everything before the first /)
		const slashIndex = modelPattern.indexOf("/");
		if (slashIndex !== -1) {
			const provider = modelPattern.substring(0, slashIndex);
			const modelId = modelPattern.substring(slashIndex + 1);
			const providerMatch = availableModels.find(
				(m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),
			);
			if (providerMatch) {
				if (
					!scopedModels.find(
						(sm) => sm.model.id === providerMatch.id && sm.model.provider === providerMatch.provider,
					)
				) {
					scopedModels.push({ model: providerMatch, thinkingLevel });
				}
				continue;
			}
			// No exact provider/model match - fall through to other matching
		}

		// Check for exact ID match (case-insensitive)
		const exactMatch = availableModels.find((m) => m.id.toLowerCase() === modelPattern.toLowerCase());
		if (exactMatch) {
			// Exact match found - use it directly
			if (!scopedModels.find((sm) => sm.model.id === exactMatch.id && sm.model.provider === exactMatch.provider)) {
				scopedModels.push({ model: exactMatch, thinkingLevel });
			}
			continue;
		}

		// No exact match - fall back to partial matching
		const matches = availableModels.filter(
			(m) =>
				m.id.toLowerCase().includes(modelPattern.toLowerCase()) ||
				m.name?.toLowerCase().includes(modelPattern.toLowerCase()),
		);

		if (matches.length === 0) {
			console.warn(chalk.yellow(`Warning: No models match pattern "${modelPattern}"`));
			continue;
		}

		// Helper to check if a model ID looks like an alias (no date suffix)
		// Dates are typically in format: -20241022 or -20250929
		const isAlias = (id: string): boolean => {
			// Check if ID ends with -latest
			if (id.endsWith("-latest")) return true;

			// Check if ID ends with a date pattern (-YYYYMMDD)
			const datePattern = /-\d{8}$/;
			return !datePattern.test(id);
		};

		// Separate into aliases and dated versions
		const aliases = matches.filter((m) => isAlias(m.id));
		const datedVersions = matches.filter((m) => !isAlias(m.id));

		let bestMatch: Model<Api>;

		if (aliases.length > 0) {
			// Prefer alias - if multiple aliases, pick the one that sorts highest
			aliases.sort((a, b) => b.id.localeCompare(a.id));
			bestMatch = aliases[0];
		} else {
			// No alias found, pick latest dated version
			datedVersions.sort((a, b) => b.id.localeCompare(a.id));
			bestMatch = datedVersions[0];
		}

		// Avoid duplicates
		if (!scopedModels.find((sm) => sm.model.id === bestMatch.id && sm.model.provider === bestMatch.provider)) {
			scopedModels.push({ model: bestMatch, thinkingLevel });
		}
	}

	return scopedModels;
}

async function selectSession(sessionManager: SessionManager): Promise<string | null> {
	return new Promise((resolve) => {
		const ui = new TUI(new ProcessTerminal());
		let resolved = false;

		const selector = new SessionSelectorComponent(
			sessionManager,
			(path: string) => {
				if (!resolved) {
					resolved = true;
					ui.stop();
					resolve(path);
				}
			},
			() => {
				if (!resolved) {
					resolved = true;
					ui.stop();
					resolve(null);
				}
			},
		);

		ui.addChild(selector);
		ui.setFocus(selector.getSessionList());
		ui.start();
	});
}

async function runInteractiveMode(
	session: AgentSession,
	version: string,
	changelogMarkdown: string | null = null,
	modelFallbackMessage: string | null = null,
	versionCheckPromise: Promise<string | null>,
	initialMessages: string[] = [],
	initialMessage?: string,
	initialAttachments?: Attachment[],
	fdPath: string | null = null,
): Promise<void> {
	const mode = new InteractiveMode(session, version, changelogMarkdown, fdPath);

	// Initialize TUI (subscribes to agent events internally)
	await mode.init();

	// Handle version check result when it completes (don't block)
	versionCheckPromise.then((newVersion) => {
		if (newVersion) {
			mode.showNewVersionNotification(newVersion);
		}
	});

	// Render any existing messages (from --continue mode)
	mode.renderInitialMessages(session.state);

	// Show model fallback warning at the end of the chat if applicable
	if (modelFallbackMessage) {
		mode.showWarning(modelFallbackMessage);
	}

	// Process initial message with attachments if provided (from @file args)
	if (initialMessage) {
		try {
			await session.prompt(initialMessage, { attachments: initialAttachments });
		} catch (error: unknown) {
			const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
			mode.showError(errorMessage);
		}
	}

	// Process remaining initial messages if provided (from CLI args)
	for (const message of initialMessages) {
		try {
			await session.prompt(message);
		} catch (error: unknown) {
			const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
			mode.showError(errorMessage);
		}
	}

	// Interactive loop
	while (true) {
		const userInput = await mode.getUserInput();

		// Process the message
		try {
			await session.prompt(userInput);
		} catch (error: unknown) {
			const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
			mode.showError(errorMessage);
		}
	}
}

export async function main(args: string[]) {
	const parsed = parseArgs(args);

	if (parsed.help) {
		printHelp();
		return;
	}

	// Handle --export flag: convert session file to HTML and exit
	if (parsed.export) {
		try {
			// Use first message as output path if provided
			const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;
			const result = exportFromFile(parsed.export, outputPath);
			console.log(`Exported to: ${result}`);
			return;
		} catch (error: any) {
			console.error(chalk.red(`Error: ${error.message || "Failed to export session"}`));
			process.exit(1);
		}
	}

	// Validate: RPC mode doesn't support @file arguments
	if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
		console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
		process.exit(1);
	}

	// Process @file arguments if any
	let initialMessage: string | undefined;
	let initialAttachments: Attachment[] | undefined;

	if (parsed.fileArgs.length > 0) {
		const { textContent, imageAttachments } = processFileArguments(parsed.fileArgs);

		// Combine file content with first plain text message (if any)
		if (parsed.messages.length > 0) {
			initialMessage = textContent + parsed.messages[0];
			parsed.messages.shift(); // Remove first message as it's been combined
		} else {
			initialMessage = textContent;
		}

		initialAttachments = imageAttachments.length > 0 ? imageAttachments : undefined;
	}

	// Initialize theme (before any TUI rendering)
	const settingsManager = new SettingsManager();
	const themeName = settingsManager.getTheme();
	initTheme(themeName);

	// Setup session manager
	const sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session);

	// Disable session saving if --no-session flag is set
	if (parsed.noSession) {
		sessionManager.disable();
	}

	// Handle --resume flag: show session selector
	if (parsed.resume) {
		const selectedSession = await selectSession(sessionManager);
		if (!selectedSession) {
			console.log(chalk.dim("No session selected"));
			return;
		}
		// Set the selected session as the active session
		sessionManager.setSessionFile(selectedSession);
	}

	// Resolve model scope early if provided (needed for initial model selection)
	let scopedModels: Array<{ model: Model<Api>; thinkingLevel: ThinkingLevel }> = [];
	if (parsed.models && parsed.models.length > 0) {
		scopedModels = await resolveModelScope(parsed.models);
	}

	// Determine initial model using priority system:
	// 1. CLI args (--provider and --model)
	// 2. First model from --models scope
	// 3. Restored from session (if --continue or --resume)
	// 4. Saved default from settings.json
	// 5. First available model with valid API key
	// 6. null (allowed in interactive mode)
	let initialModel: Model<Api> | null = null;
	let initialThinking: ThinkingLevel = "off";

	if (parsed.provider && parsed.model) {
		// 1. CLI args take priority
		const { model, error } = findModel(parsed.provider, parsed.model);
		if (error) {
			console.error(chalk.red(error));
			process.exit(1);
		}
		if (!model) {
			console.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));
			process.exit(1);
		}
		initialModel = model;
	} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
		// 2. Use first model from --models scope (skip if continuing/resuming session)
		initialModel = scopedModels[0].model;
		initialThinking = scopedModels[0].thinkingLevel;
	} else if (parsed.continue || parsed.resume) {
		// 3. Restore from session (will be handled below after loading session)
		// Leave initialModel as null for now
	}

	if (!initialModel) {
		// 3. Try saved default from settings
		const defaultProvider = settingsManager.getDefaultProvider();
		const defaultModel = settingsManager.getDefaultModel();
		if (defaultProvider && defaultModel) {
			const { model, error } = findModel(defaultProvider, defaultModel);
			if (error) {
				console.error(chalk.red(error));
				process.exit(1);
			}
			initialModel = model;

			// Also load saved thinking level if we're using saved model
			const savedThinking = settingsManager.getDefaultThinkingLevel();
			if (savedThinking) {
				initialThinking = savedThinking;
			}
		}
	}

	if (!initialModel) {
		// 4. Try first available model with valid API key
		// Prefer default model for each provider if available
		const { models: availableModels, error } = await getAvailableModels();

		if (error) {
			console.error(chalk.red(error));
			process.exit(1);
		}

		if (availableModels.length > 0) {
			// Try to find a default model from known providers
			for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
				const defaultModelId = defaultModelPerProvider[provider];
				const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);
				if (match) {
					initialModel = match;
					break;
				}
			}

			// If no default found, use first available
			if (!initialModel) {
				initialModel = availableModels[0];
			}
		}
	}

	// Determine mode early to know if we should print messages and fail early
	// Interactive mode: no --print flag and no --mode flag
	// Having initial messages doesn't make it non-interactive anymore
	const isInteractive = !parsed.print && parsed.mode === undefined;
	const mode = parsed.mode || "text";
	// Only print informational messages in interactive mode
	// Non-interactive modes (-p, --mode json, --mode rpc) should be silent except for output
	const shouldPrintMessages = isInteractive;

	// Non-interactive mode: fail early if no model available
	if (!isInteractive && !initialModel) {
		console.error(chalk.red("No models available."));
		console.error(chalk.yellow("\nSet an API key environment variable:"));
		console.error("  ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
		console.error(chalk.yellow(`\nOr create ${getModelsPath()}`));
		process.exit(1);
	}

	// Non-interactive mode: validate API key exists
	if (!isInteractive && initialModel) {
		const apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel));
		if (!apiKey) {
			console.error(chalk.red(`No API key found for ${initialModel.provider}`));
			process.exit(1);
		}
	}

	const systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools, parsed.appendSystemPrompt);

	// Load previous messages if continuing or resuming
	// This may update initialModel if restoring from session
	if (parsed.continue || parsed.resume) {
		// Load and restore model (overrides initialModel if found and has API key)
		const savedModel = sessionManager.loadModel();
		if (savedModel) {
			const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);

			if (error) {
				console.error(chalk.red(error));
				process.exit(1);
			}

			// Check if restored model exists and has a valid API key
			const hasApiKey = restoredModel ? !!(await getApiKeyForModel(restoredModel)) : false;

			if (restoredModel && hasApiKey) {
				initialModel = restoredModel;
				if (shouldPrintMessages) {
					console.log(chalk.dim(`Restored model: ${savedModel.provider}/${savedModel.modelId}`));
				}
			} else {
				// Model not found or no API key - fall back to default selection
				const reason = !restoredModel ? "model no longer exists" : "no API key available";

				if (shouldPrintMessages) {
					console.error(
						chalk.yellow(
							`Warning: Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}).`,
						),
					);
				}

				// Ensure we have a valid model - use the same fallback logic
				if (!initialModel) {
					const { models: availableModels, error: availableError } = await getAvailableModels();
					if (availableError) {
						console.error(chalk.red(availableError));
						process.exit(1);
					}
					if (availableModels.length > 0) {
						// Try to find a default model from known providers
						for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {
							const defaultModelId = defaultModelPerProvider[provider];
							const match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);
							if (match) {
								initialModel = match;
								break;
							}
						}

						// If no default found, use first available
						if (!initialModel) {
							initialModel = availableModels[0];
						}

						if (initialModel && shouldPrintMessages) {
							console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));
						}
					} else {
						// No models available at all
						if (shouldPrintMessages) {
							console.error(chalk.red("\nNo models available."));
							console.error(chalk.yellow("Set an API key environment variable:"));
							console.error("  ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
							console.error(chalk.yellow(`\nOr create ${getModelsPath()}`));
						}
						process.exit(1);
					}
				} else if (shouldPrintMessages) {
					console.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));
				}
			}
		}
	}

	// CLI --thinking flag takes highest priority
	if (parsed.thinking) {
		initialThinking = parsed.thinking;
	}

	// Determine which tools to use
	const selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;

	// Create agent (initialModel can be null in interactive mode)
	const agent = new Agent({
		initialState: {
			systemPrompt,
			model: initialModel as any, // Can be null
			thinkingLevel: initialThinking,
			tools: selectedTools,
		},
		messageTransformer,
		queueMode: settingsManager.getQueueMode(),
		transport: new ProviderTransport({
			// Dynamic API key lookup based on current model's provider
			getApiKey: async () => {
				const currentModel = agent.state.model;
				if (!currentModel) {
					throw new Error("No model selected");
				}

				// Try CLI override first
				if (parsed.apiKey) {
					return parsed.apiKey;
				}

				// Use model-specific key lookup
				const key = await getApiKeyForModel(currentModel);
				if (!key) {
					throw new Error(
						`No API key found for provider "${currentModel.provider}". Please set the appropriate environment variable or update ${getModelsPath()}`,
					);
				}
				return key;
			},
		}),
	});

	// If initial thinking was requested but model doesn't support it, silently reset to off
	if (initialThinking !== "off" && initialModel && !initialModel.reasoning) {
		agent.setThinkingLevel("off");
	}

	// Track if we had to fall back from saved model (to show in chat later)
	let modelFallbackMessage: string | null = null;

	// Load previous messages if continuing or resuming
	if (parsed.continue || parsed.resume) {
		const messages = sessionManager.loadMessages();
		if (messages.length > 0) {
			agent.replaceMessages(messages);
		}

		// Load and restore thinking level
		const thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel;
		if (thinkingLevel) {
			agent.setThinkingLevel(thinkingLevel);
			if (shouldPrintMessages) {
				console.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`));
			}
		}

		// Check if we had to fall back from saved model
		const savedModel = sessionManager.loadModel();
		if (savedModel && initialModel) {
			const savedMatches = initialModel.provider === savedModel.provider && initialModel.id === savedModel.modelId;
			if (!savedMatches) {
				const { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);
				if (error) {
					// Config error - already shown above, just use generic message
					modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}. Using ${initialModel.provider}/${initialModel.id}.`;
				} else {
					const reason = !restoredModel ? "model no longer exists" : "no API key available";
					modelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}). Using ${initialModel.provider}/${initialModel.id}.`;
				}
			}
		}
	}

	// Log loaded context files (they're already in the system prompt)
	if (shouldPrintMessages && !parsed.continue && !parsed.resume) {
		const contextFiles = loadProjectContextFiles();
		if (contextFiles.length > 0) {
			console.log(chalk.dim("Loaded project context from:"));
			for (const { path: filePath } of contextFiles) {
				console.log(chalk.dim(`  - ${filePath}`));
			}
		}
	}

	// Create AgentSession for non-interactive modes

	const fileCommands = loadSlashCommands();

	// Route to appropriate mode
	if (mode === "rpc") {
		// RPC mode - headless operation
		const session = new AgentSession({
			agent,
			sessionManager,
			settingsManager,
			scopedModels,
			fileCommands,
		});
		await runRpcMode(session);
	} else if (isInteractive) {
		// Check for new version in the background (don't block startup)
		const versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);

		// Check if we should show changelog (only in interactive mode, only for new sessions)
		let changelogMarkdown: string | null = null;
		if (!parsed.continue && !parsed.resume) {
			const lastVersion = settingsManager.getLastChangelogVersion();

			// Check if we need to show changelog
			if (!lastVersion) {
				// First run - show all entries
				const changelogPath = getChangelogPath();
				const entries = parseChangelog(changelogPath);
				if (entries.length > 0) {
					changelogMarkdown = entries.map((e) => e.content).join("\n\n");
					settingsManager.setLastChangelogVersion(VERSION);
				}
			} else {
				// Parse current and last versions
				const changelogPath = getChangelogPath();
				const entries = parseChangelog(changelogPath);
				const newEntries = getNewEntries(entries, lastVersion);

				if (newEntries.length > 0) {
					changelogMarkdown = newEntries.map((e) => e.content).join("\n\n");
					settingsManager.setLastChangelogVersion(VERSION);
				}
			}
		}

		// Show model scope if provided
		if (scopedModels.length > 0) {
			const modelList = scopedModels
				.map((sm) => {
					const thinkingStr = sm.thinkingLevel !== "off" ? `:${sm.thinkingLevel}` : "";
					return `${sm.model.id}${thinkingStr}`;
				})
				.join(", ");
			console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
		}

		// Ensure fd tool is available for file autocomplete
		const fdPath = await ensureTool("fd");

		// Interactive mode - use TUI (may have initial messages from CLI args)
		const session = new AgentSession({
			agent,
			sessionManager,
			settingsManager,
			scopedModels,
			fileCommands,
		});
		await runInteractiveMode(
			session,
			VERSION,
			changelogMarkdown,
			modelFallbackMessage,
			versionCheckPromise,
			parsed.messages,
			initialMessage,
			initialAttachments,
			fdPath,
		);
	} else {
		// Non-interactive mode (--print flag or --mode flag)
		const session = new AgentSession({
			agent,
			sessionManager,
			settingsManager,
			scopedModels,
			fileCommands,
		});
		await runPrintMode(session, mode, parsed.messages, initialMessage, initialAttachments);
	}
}


This is a 907-line file. Here's my analysis:

## Code Duplication

1. **Model fallback logic** appears twice (lines ~690-730 and ~750-810) - finding available models, checking for default provider models, etc.

2. **Thinking level validation** appears twice - in `parseArgs()` and in `resolveModelScope()`

3. **Date pattern for model aliases** - the logic for checking dated vs alias models could be extracted

## Proposed Module Split

### 1. `src/cli/args.ts` - CLI Argument Parsing
- `parseArgs()` function
- `Args` interface
- `Mode` type
- `printHelp()` function

**Location:** `src/cli/args.ts`

### 2. `src/cli/file-processor.ts` - File Argument Processing
- `IMAGE_MIME_TYPES` constant
- `isImageFile()` function
- `expandPath()` function
- `processFileArguments()` function

**Location:** `src/cli/file-processor.ts`

### 3. `src/core/system-prompt.ts` - System Prompt Building
- `toolDescriptions` constant
- `resolvePromptInput()` function
- `buildSystemPrompt()` function
- `loadContextFileFromDir()` function
- `loadProjectContextFiles()` function

**Location:** `src/core/system-prompt.ts`

### 4. `src/core/model-resolver.ts` - Model Resolution & Selection
- `defaultModelPerProvider` constant
- `resolveModelScope()` function
- `selectSession()` function (TUI session picker)
- New `findInitialModel()` function (consolidate fallback logic)

**Location:** `src/core/model-resolver.ts`

### 5. `src/cli/version-check.ts` - Version Checking
- `checkForNewVersion()` function

**Location:** `src/cli/version-check.ts`

### 6. `src/main-new.ts` - Main Entry Point (slimmed down)
- `runInteractiveMode()` function
- `main()` function (orchestration only)

**Location:** stays at `src/main-new.ts`

## Summary

| Module | Lines (est.) | Purpose |
|--------|--------------|---------|
| `cli/args.ts` | ~150 | CLI parsing and help |
| `cli/file-processor.ts` | ~80 | @file argument processing |
| `cli/version-check.ts` | ~20 | NPM version check |
| `core/system-prompt.ts` | ~180 | System prompt construction |
| `core/model-resolver.ts` | ~200 | Model resolution, scoping, selection |
| `main-new.ts` | ~280 | Orchestration, mode routing |

Want me to proceed with this refactoring?


- selectSession() function (TUI session picker)

this this belongs in model-resolver?

 - checkForNewVersion() function

i think we only do this in interactive mode no?

rest looks fine


The user is right:

1. `selectSession()` is a TUI session picker - it doesn't belong in model-resolver. It should probably go in `modes/interactive/` somewhere, or maybe a `cli/session-picker.ts` since it's used during CLI startup before entering interactive mode.

2. `checkForNewVersion()` - yes, it's only used in interactive mode. So it could go in `modes/interactive/` or stay in main since it's only called there. Actually looking at the code, it's called in the interactive branch of main(). Could be in `modes/interactive/version-check.ts` or just inline in the interactive mode setup.

Let me revise:

1. `selectSession()` -> `src/cli/session-picker.ts` or `src/modes/interactive/session-picker.ts`
   - Actually it's a CLI startup thing that uses TUI but isn't part of InteractiveMode itself
   - I'd put it in `src/cli/session-picker.ts`

2. `checkForNewVersion()` -> could stay in main-new.ts since it's small and only used in interactive mode, OR move to `src/modes/interactive/version-check.ts`
   - Actually since it's so small (~20 lines) and only used once, it could just stay in main-new.ts

Let me proceed with the refactoring.

You're right on both counts:

1. `selectSession()` - should be `src/cli/session-picker.ts` (it's a CLI startup thing, not model-related)

2. `checkForNewVersion()` - only used in interactive mode. It's small (~20 lines), could just stay in `main-new.ts` or move to `src/modes/interactive/version-check.ts`

Revised plan:

| Module | Purpose |
|--------|---------|
| `cli/args.ts` | CLI parsing and help |
| `cli/file-processor.ts` | @file argument processing |
| `cli/session-picker.ts` | TUI session selector for --resume |
| `core/system-prompt.ts` | System prompt construction |
| `core/model-resolver.ts` | Model resolution, scoping, initial selection |
| `main-new.ts` | Orchestration, mode routing, version check (inline) |

Let me proceed:


write

/**
 * CLI argument parsing and help display
 */

import type { ThinkingLevel } from "@mariozechner/companion-agent-core";
import chalk from "chalk";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getModelsPath } from "../utils/config.js";
import { allTools, type ToolName } from "../core/tools/index.js";

export type Mode = "text" | "json" | "rpc";

export interface Args {
	provider?: string;
	model?: string;
	apiKey?: string;
	systemPrompt?: string;
	appendSystemPrompt?: string;
	thinking?: ThinkingLevel;
	continue?: boolean;
	resume?: boolean;
	help?: boolean;
	mode?: Mode;
	noSession?: boolean;
	session?: string;
	models?: string[];
	tools?: ToolName[];
	print?: boolean;
	export?: string;
	messages: string[];
	fileArgs: string[];
}

const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;

export function isValidThinkingLevel(level: string): level is ThinkingLevel {
	return VALID_THINKING_LEVELS.includes(level as ThinkingLevel);
}

export function parseArgs(args: string[]): Args {
	const result: Args = {
		messages: [],
		fileArgs: [],
	};

	for (let i = 0; i < args.length; i++) {
		const arg = args[i];

		if (arg === "--help" || arg === "-h") {
			result.help = true;
		} else if (arg === "--mode" && i + 1 < args.length) {
			const mode = args[++i];
			if (mode === "text" || mode === "json" || mode === "rpc") {
				result.mode = mode;
			}
		} else if (arg === "--continue" || arg === "-c") {
			result.continue = true;
		} else if (arg === "--resume" || arg === "-r") {
			result.resume = true;
		} else if (arg === "--provider" && i + 1 < args.length) {
			result.provider = args[++i];
		} else if (arg === "--model" && i + 1 < args.length) {
			result.model = args[++i];
		} else if (arg === "--api-key" && i + 1 < args.length) {
			result.apiKey = args[++i];
		} else if (arg === "--system-prompt" && i + 1 < args.length) {
			result.systemPrompt = args[++i];
		} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
			result.appendSystemPrompt = args[++i];
		} else if (arg === "--no-session") {
			result.noSession = true;
		} else if (arg === "--session" && i + 1 < args.length) {
			result.session = args[++i];
		} else if (arg === "--models" && i + 1 < args.length) {
			result.models = args[++i].split(",").map((s) => s.trim());
		} else if (arg === "--tools" && i + 1 < args.length) {
			const toolNames = args[++i].split(",").map((s) => s.trim());
			const validTools: ToolName[] = [];
			for (const name of toolNames) {
				if (name in allTools) {
					validTools.push(name as ToolName);
				} else {
					console.error(
						chalk.yellow(`Warning: Unknown tool "${name}". Valid tools: ${Object.keys(allTools).join(", ")}`),
					);
				}
			}
			result.tools = validTools;
		} else if (arg === "--thinking" && i + 1 < args.length) {
			const level = args[++i];
			if (isValidThinkingLevel(level)) {
				result.thinking = level;
			} else {
				console.error(
					chalk.yellow(
						`Warning: Invalid thinking level "${level}". Valid values: ${VALID_THINKING_LEVELS.join(", ")}`,
					),
				);
			}
		} else if (arg === "--print" || arg === "-p") {
			result.print = true;
		} else if (arg === "--export" && i + 1 < args.length) {
			result.export = args[++i];
		} else if (arg.startsWith("@")) {
			result.fileArgs.push(arg.slice(1)); // Remove @ prefix
		} else if (!arg.startsWith("-")) {
			result.messages.push(arg);
		}
	}

	return result;
}

export function printHelp(): void {
	console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools

${chalk.bold("Usage:")}
  ${APP_NAME} [options] [@files...] [messages...]

${chalk.bold("Options:")}
  --provider <name>              Provider name (default: google)
  --model <id>                   Model ID (default: gemini-2.5-flash)
  --api-key <key>                API key (defaults to env vars)
  --system-prompt <text>         System prompt (default: coding assistant prompt)
  --append-system-prompt <text>  Append text or file contents to the system prompt
  --mode <mode>                  Output mode: text (default), json, or rpc
  --print, -p                    Non-interactive mode: process prompt and exit
  --continue, -c                 Continue previous session
  --resume, -r                   Select a session to resume
  --session <path>               Use specific session file
  --no-session                   Don't save session (ephemeral)
  --models <patterns>            Comma-separated model patterns for quick cycling with Ctrl+P
  --tools <tools>                Comma-separated list of tools to enable (default: read,bash,edit,write)
                                 Available: read, bash, edit, write, grep, find, ls
  --thinking <level>             Set thinking level: off, minimal, low, medium, high, xhigh
  --export <file>                Export session file to HTML and exit
  --help, -h                     Show this help

${chalk.bold("Examples:")}
  # Interactive mode
  ${APP_NAME}

  # Interactive mode with initial prompt
  ${APP_NAME} "List all .ts files in src/"

  # Include files in initial message
  ${APP_NAME} @prompt.md @image.png "What color is the sky?"

  # Non-interactive mode (process and exit)
  ${APP_NAME} -p "List all .ts files in src/"

  # Multiple messages (interactive)
  ${APP_NAME} "Read package.json" "What dependencies do we have?"

  # Continue previous session
  ${APP_NAME} --continue "What did we discuss?"

  # Use different model
  ${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code"

  # Limit model cycling to specific models
  ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o

  # Cycle models with fixed thinking levels
  ${APP_NAME} --models sonnet:high,haiku:low

  # Start with a specific thinking level
  ${APP_NAME} --thinking high "Solve this complex problem"

  # Read-only mode (no file modifications possible)
  ${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/"

  # Export a session file to HTML
  ${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl
  ${APP_NAME} --export session.jsonl output.html

${chalk.bold("Environment Variables:")}
  ANTHROPIC_API_KEY       - Anthropic Claude API key
  ANTHROPIC_OAUTH_TOKEN   - Anthropic OAuth token (alternative to API key)
  OPENAI_API_KEY          - OpenAI GPT API key
  GEMINI_API_KEY          - Google Gemini API key
  GROQ_API_KEY            - Groq API key
  CEREBRAS_API_KEY        - Cerebras API key
  XAI_API_KEY             - xAI Grok API key
  OPENROUTER_API_KEY      - OpenRouter API key
  ZAI_API_KEY             - ZAI API key
  ${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)

${chalk.bold("Available Tools (default: read, bash, edit, write):")}
  read   - Read file contents
  bash   - Execute bash commands
  edit   - Edit files with find/replace
  write  - Write files (creates/overwrites)
  grep   - Search file contents (read-only, off by default)
  find   - Find files by glob pattern (read-only, off by default)
  ls     - List directory contents (read-only, off by default)
`);
}


Successfully wrote 7124 bytes to /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/cli/args.ts


/**
 * Process @file CLI arguments into text content and image attachments
 */

import type { Attachment } from "@mariozechner/companion-agent-core";
import chalk from "chalk";
import { existsSync, readFileSync, statSync } from "fs";
import { homedir } from "os";
import { extname, resolve } from "path";

/** Map of file extensions to MIME types for common image formats */
const IMAGE_MIME_TYPES: Record<string, string> = {
	".jpg": "image/jpeg",
	".jpeg": "image/jpeg",
	".png": "image/png",
	".gif": "image/gif",
	".webp": "image/webp",
};

/** Check if a file is an image based on its extension, returns MIME type or null */
function isImageFile(filePath: string): string | null {
	const ext = extname(filePath).toLowerCase();
	return IMAGE_MIME_TYPES[ext] || null;
}

/** Expand ~ to home directory */
function expandPath(filePath: string): string {
	if (filePath === "~") {
		return homedir();
	}
	if (filePath.startsWith("~/")) {
		return homedir() + filePath.slice(1);
	}
	return filePath;
}

export interface ProcessedFiles {
	textContent: string;
	imageAttachments: Attachment[];
}

/** Process @file arguments into text content and image attachments */
export function processFileArguments(fileArgs: string[]): ProcessedFiles {
	let textContent = "";
	const imageAttachments: Attachment[] = [];

	for (const fileArg of fileArgs) {
		// Expand and resolve path
		const expandedPath = expandPath(fileArg);
		const absolutePath = resolve(expandedPath);

		// Check if file exists
		if (!existsSync(absolutePath)) {
			console.error(chalk.red(`Error: File not found: ${absolutePath}`));
			process.exit(1);
		}

		// Check if file is empty
		const stats = statSync(absolutePath);
		if (stats.size === 0) {
			// Skip empty files
			continue;
		}

		const mimeType = isImageFile(absolutePath);

		if (mimeType) {
			// Handle image file
			const content = readFileSync(absolutePath);
			const base64Content = content.toString("base64");

			const attachment: Attachment = {
				id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
				type: "image",
				fileName: absolutePath.split("/").pop() || absolutePath,
				mimeType,
				size: stats.size,
				content: base64Content,
			};

			imageAttachments.push(attachment);

			// Add text reference to image
			textContent += `<file name="${absolutePath}"></file>\n`;
		} else {
			// Handle text file
			try {
				const content = readFileSync(absolutePath, "utf-8");
				textContent += `<file name="${absolutePath}">\n${content}\n</file>\n`;
			} catch (error: unknown) {
				const message = error instanceof Error ? error.message : String(error);
				console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`));
				process.exit(1);
			}
		}
	}

	return { textContent, imageAttachments };
}


Successfully wrote 2778 bytes to /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/cli/file-processor.ts


/**
 * TUI session selector for --resume flag
 */

import { ProcessTerminal, TUI } from "@mariozechner/companion-tui";
import type { SessionManager } from "../core/session-manager.js";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";

/** Show TUI session selector and return selected session path or null if cancelled */
export async function selectSession(sessionManager: SessionManager): Promise<string | null> {
	return new Promise((resolve) => {
		const ui = new TUI(new ProcessTerminal());
		let resolved = false;

		const selector = new SessionSelectorComponent(
			sessionManager,
			(path: string) => {
				if (!resolved) {
					resolved = true;
					ui.stop();
					resolve(path);
				}
			},
			() => {
				if (!resolved) {
					resolved = true;
					ui.stop();
					resolve(null);
				}
			},
		);

		ui.addChild(selector);
		ui.setFocus(selector.getSessionList());
		ui.start();
	});
}


Successfully wrote 939 bytes to /Users/badlogic/workspaces/companion-mono/packages/coding-agent/src/cli/session-picker.ts