send method

Future<void> send(
  1. String text, {
  2. SendOptions? options,
  3. List<TextAttachment> attachments = const [],
})

Sends text and streams the reply into messages.

options and attachments are per-turn, mirroring ChatGptSession.send/ChatGptClient.sendWithRotation directly.

Precedence between options and this controller's own model / webSearch: options, when supplied, is used exactly as given — it is never merged with the controller's settings. If options is omitted (the default, and the only form that existed before this parameter was added), this turn uses currentOptions — built fresh from the controller's current model and webSearch at the moment send is called. There is no field-by-field fallback in either direction: a caller who passes SendOptions(model: 'gpt-5-6') gets webSearch: null for that turn (SendOptions' own default) even if this.webSearch is true — not the controller's value quietly filled in. This keeps the rule predictable from the call site alone: pass options and it is the whole story for that turn; omit it and the controller's current settings are the whole story.

Do not build a per-turn options override from a bare SendOptions(...) literal — that drops model back to SendOptions' own 'auto' default, invisibly, because verbatim precedence means nothing fills it back in. Extend currentOptions instead: controller.send(text, options: controller.currentOptions.copyWith(canvas: true)). See currentOptions's doc comment for why this matters.

Implementation

Future<void> send(
  String text, {
  SendOptions? options,
  List<TextAttachment> attachments = const [],
}) async {
  if (_isStreaming || text.trim().isEmpty) return;

  _lastPrompt = text;
  _error = null;
  _isStreaming = true;
  _isWritingReply = true;
  notifyListeners();

  final started = DateTime.now();
  void log(String what) {
    final ms = DateTime.now().difference(started).inMilliseconds;
    onLog?.call('[chatgpt_free] +${ms}ms $what');
  }

  log('turn started');
  var firstDelta = true;

  final completer = Completer<void>();
  _pendingSend = completer;
  _subscription = _client
      .sendWithRotation(
    _session,
    text,
    options: options ?? currentOptions,
    attachments: attachments,
  )
      .listen(
    (event) {
      if (event is TextDelta && firstDelta) {
        firstDelta = false;
        log('first text');
      }
      if (event is ReplyCompleted) {
        _isWritingReply = false;
        log('reply complete — indicator off, stream still open');
      }
      if (event is TurnCompleted) {
        log('turn closed (model: ${event.actualModel})');
      }
      if (event is ConversationTitled) {
        log('titled "${event.title}"');
      }
      if (event is ModelDowngraded) {
        _downgradeNotice =
            'Requested ${event.requested}, answered by ${event.actual}.';
      }
      _messages = _session.history;
      notifyListeners();
    },
    onError: (Object e) {
      _error = e is ChatGptException ? e : TransportException('$e');
      _isStreaming = false;
      _messages = _session.history;
      notifyListeners();
      _completeSend();
    },
    onDone: () {
      _isStreaming = false;
      _messages = _session.history;
      notifyListeners();
      _completeSend();
    },
    cancelOnError: true,
  );

  return completer.future;
}