Skip to content
design
Components

Chat input

The field where the crew write to Bosun, with a model picker, suggestions, a / menu of commands and recall of earlier questions.

On this page
Anchor watch armed: 45 m swing circle, 40 m of chain out in 8 m.
How much water have we got under the keel?
6.2 m under the keel, 8.4 m at the transducer. It has held within 0.3 m since we anchored at 21:40.

A night-watch conversation at anchor. Click the empty field for suggestions, type / for commands, press ArrowUp for an earlier question, and choose Claude in the picker to see the line saying the text goes to Anthropic. Each answer names the model that gave it.
Code
import { ChatBubble, type ChatRole } from "@bosunkit/components/chat-bubble";
import { ChatInput, type ChatCommand, type ChatModel, type ChatSuggestion } from "@bosunkit/components/chat-input";
import { AnchorIec } from "@bosunkit/icons/anchor/AnchorIec";
import { Tide } from "@bosunkit/icons/tide/Tide";
import { Weather } from "@bosunkit/icons/weather/Weather";
import {
  useEffect,
  useRef,
  useState,
  type Dispatch,
  type ReactElement,
  type RefObject,
  type SetStateAction,
} from "react";

export const description: string =
  "A night-watch conversation at anchor with every aid: a model picker with Automatic, a Qwen model on board and " +
  "Claude through Anthropic; suggestions in the empty field; a / menu of commands; and ArrowUp for the questions " +
  "already asked. Each answer says which model gave it.";

interface Message {
  readonly id: number;
  readonly role: ChatRole;
  readonly text: string;
  readonly time: string;
}

/** How long a model takes to answer in this example. */
const REPLY_AFTER_MS: number = 1600;

const MODELS: readonly ChatModel[] = [
  { id: "qwen3.5-4b", name: "Qwen3.5 4B", provider: "llama.cpp", local: true, tier: "Reasoning", available: true },
  {
    id: "claude-sonnet-4-6",
    name: "Claude Sonnet 4.6",
    provider: "Anthropic",
    local: false,
    tier: "Premium",
    available: true,
    note: "Draws on this week's budget",
  },
];

const SUGGESTIONS: readonly ChatSuggestion[] = [
  {
    id: "weather",
    label: "Weather for tomorrow's passage",
    prompt: "What's the weather for tomorrow's passage to Ibiza?",
    icon: <Weather size="1.2em" />,
    category: "Weather",
  },
  {
    id: "anchor",
    label: "Check the anchor",
    prompt: "Is the anchor holding?",
    icon: <AnchorIec size="1.2em" />,
    category: "Anchor",
  },
  {
    id: "tide",
    label: "Next high water",
    prompt: "When is the next high water?",
    icon: <Tide size="1.2em" />,
    category: "Weather",
  },
];

const COMMANDS: readonly ChatCommand[] = [
  {
    id: "anchor",
    trigger: "/anchor",
    label: "Anchor watch",
    description: "Holding, swing circle and drag",
    prompt: "How is the anchor holding?",
  },
  {
    id: "wind",
    trigger: "/wind",
    label: "Wind",
    description: "Speed, direction and trend",
    prompt: "What's the wind doing?",
  },
  {
    id: "tide",
    trigger: "/tide",
    label: "Tide",
    description: "Tide state and the next high and low water",
    prompt: "What's the tide doing?",
  },
  {
    id: "briefing",
    trigger: "/briefing",
    label: "Briefing",
    description: "Position, conditions and alerts",
    prompt: "Give me a briefing",
  },
];

const OPENING: readonly Message[] = [
  { id: 1, role: "system", text: "Anchor watch armed: 45 m swing circle, 40 m of chain out in 8 m.", time: "02:05" },
  { id: 2, role: "user", text: "How much water have we got under the keel?", time: "02:10" },
  {
    id: 3,
    role: "assistant",
    text: "6.2 m under the keel, 8.4 m at the transducer. It has held within 0.3 m since we anchored at 21:40.",
    time: "02:10 · Qwen3.5 4B",
  },
];

/** The ship's clock in this example: minutes after midnight, advanced a minute per message. */
function clockText(minutes: number): string {
  return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
}

/** The answer to a question in this example, picked by what the question is about. */
function replyTo(question: string): string {
  const asked: string = question.toLowerCase();
  if (asked.includes("wind")) {
    return "Southwest 18 kn, gusting 24. The forecast backs it south and eases it below 15 kn after 04:00.";
  }
  if (asked.includes("anchor") || asked.includes("holding")) {
    return "Holding. We are 12 m from the drop point, well inside the 45 m circle, and the bearing has not moved.";
  }
  if (asked.includes("tide") || asked.includes("high water")) {
    return "High water at Palma is 03:40, 0.4 m. The swing circle shifts northeast as the stream turns.";
  }
  if (asked.includes("weather") || asked.includes("briefing")) {
    return "Tomorrow: northwest 12 to 15 kn, sea slight, good visibility. A fair day for the 55 nm to Ibiza.";
  }
  return "In this example I answer about the wind, the anchor, the tide and the weather. Type / to see them.";
}

export default function Showcase(): ReactElement {
  const [messages, setMessages]: [readonly Message[], Dispatch<SetStateAction<readonly Message[]>>] =
    useState<readonly Message[]>(OPENING);
  const [waiting, setWaiting]: [boolean, Dispatch<SetStateAction<boolean>>] = useState<boolean>(false);
  const [model, setModel]: [string | null, Dispatch<SetStateAction<string | null>>] = useState<string | null>(null);
  const [minutes, setMinutes]: [number, Dispatch<SetStateAction<number>>] = useState<number>(2 * 60 + 14);
  const transcript: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement | null>(null);
  const timer: RefObject<ReturnType<typeof setTimeout> | undefined> = useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined,
  );
  const asked: readonly string[] = messages
    .filter((message: Message): boolean => message.role === "user")
    .map((message: Message): string => message.text);

  // A reply still pending when the example unmounts must not set state afterwards.
  useEffect((): (() => void) => {
    return (): void => {
      clearTimeout(timer.current);
    };
  }, []);

  // Keeps the latest message in view inside the transcript, without scrolling the page.
  useEffect((): void => {
    const element: HTMLDivElement | null = transcript.current;
    if (element !== null) {
      element.scrollTop = element.scrollHeight;
    }
  }, [messages]);

  function send(text: string): void {
    const time: string = clockText(minutes);
    // Automatic answers on board in this example, as bosund does when its models on board are loaded.
    const answeredBy: string =
      MODELS.find((candidate: ChatModel): boolean => candidate.id === model)?.name ?? "Qwen3.5 4B";
    setMinutes((current: number): number => current + 1);
    setMessages((current: readonly Message[]): readonly Message[] => [
      ...current,
      { id: current.length + 1, role: "user", text, time },
    ]);
    setWaiting(true);
    timer.current = setTimeout((): void => {
      setMessages((current: readonly Message[]): readonly Message[] => [
        ...current,
        { id: current.length + 1, role: "assistant", text: replyTo(text), time: `${time} · ${answeredBy}` },
      ]);
      setWaiting(false);
    }, REPLY_AFTER_MS);
  }

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-md)", width: "100%" }}>
      <div
        ref={transcript}
        role="log"
        aria-label="Conversation with Bosun"
        style={{
          display: "flex",
          flexDirection: "column",
          gap: "var(--space-sm)",
          maxHeight: 300,
          overflowY: "auto",
        }}
      >
        {messages.map((message: Message): ReactElement => (
          <ChatBubble key={message.id} role={message.role} timestamp={message.time}>
            {message.text}
          </ChatBubble>
        ))}
      </div>
      <ChatInput
        onSend={send}
        disabled={waiting}
        placeholder={waiting ? "Bosun is answering…" : "Ask, or type / for commands"}
        models={MODELS}
        model={model}
        onModelChange={setModel}
        automaticNote="Answered on board; a cloud model may add to the reply"
        suggestions={SUGGESTIONS}
        commands={COMMANDS}
        history={asked}
      />
    </div>
  );
}

The chat input is where the crew type a message to Bosun. Enter or the send button sends the trimmed text and clears the field; Shift+Enter does not send. The send button stays disabled while the field is empty. While a reply is awaited, the caller sets disabled and the field and the button refuse input.

Four aids join it when their props are given, each on its own: a model picker left of the send button (models), suggestion chips above the empty field (suggestions), a menu of commands that a / at the start of the field opens (commands), and recall of the crew's earlier messages with the arrow keys (history). With none of them the input is the plain field and button.

01Say where the text goesEvery model in the picker says where a message sent to it goes: a model on board keeps it on the vessel, a cloud model names the provider. A chosen cloud model says so under the field.
02Help fills, the crew sendA suggestion, a command or a recalled question puts text in the field for the crew to read and change; only Enter or the send button sends it, unless the caller asks suggestions to send.
03The keyboard reaches everythingThe picker is a select, the / menu is a combobox whose active command the field announces, and the suggestions are buttons in the tab order before the field.

Examples

Ready

The input ready for a message: Enter or the send button sends it.
Code
import { ChatInput } from "@bosunkit/components/chat-input";
import type { ReactElement } from "react";

export const description: string = "The input ready for a message: Enter or the send button sends it.";

function ignore(): void {
  // The example sends nothing.
}

export default function Ready(): ReactElement {
  return <ChatInput onSend={ignore} disabled={false} placeholder="Type a message..." />;
}

Waiting

The input refusing text while the reply to the last message is awaited.
Code
import { ChatInput } from "@bosunkit/components/chat-input";
import type { ReactElement } from "react";

export const description: string = "The input refusing text while the reply to the last message is awaited.";

function ignore(): void {
  // The example sends nothing.
}

export default function Waiting(): ReactElement {
  return <ChatInput onSend={ignore} disabled placeholder="Waiting for response..." />;
}

Model picker

models lists the models the crew can choose. Automatic comes first and is the default (null): bosund then routes each message as it does when the crew choose nothing. The models on board follow under On board, each marked "Stays on board, works offline", then the cloud models under Cloud, each marked with the provider it sends the text to. A model's tier is printed after its name and its note under it. Hold the choice with model and onModelChange, or start it with defaultModel.

Sent to Anthropic

Claude Sonnet 4.6 chosen in the picker left of the send button, so the line under the field says the text is sent to Anthropic. The list offers Automatic, the two models on board and two cloud models.
Code
import { ChatInput, type ChatModel } from "@bosunkit/components/chat-input";
import type { ReactElement } from "react";

export const description: string =
  "Claude Sonnet 4.6 chosen in the picker left of the send button, so the line under the field says the text is " +
  "sent to Anthropic. The list offers Automatic, the two models on board and two cloud models.";

const MODELS: readonly ChatModel[] = [
  { id: "qwen3.5-2b", name: "Qwen3.5 2B", provider: "llama.cpp", local: true, tier: "Fast", available: true },
  { id: "qwen3.5-4b", name: "Qwen3.5 4B", provider: "llama.cpp", local: true, tier: "Reasoning", available: true },
  {
    id: "claude-sonnet-4-6",
    name: "Claude Sonnet 4.6",
    provider: "Anthropic",
    local: false,
    tier: "Premium",
    available: true,
  },
  { id: "deepseek-v4-flash", name: "DeepSeek V4.1 Flash", provider: "DeepSeek", local: false, available: true },
];

function ignore(): void {
  // The example sends nothing.
}

export default function ModelPicker(): ReactElement {
  return <ChatInput onSend={ignore} models={MODELS} defaultModel="claude-sonnet-4-6" />;
}

Offline

A model with available off is listed but cannot be chosen, and its note says why: no connection, or a budget spent. If the chosen model stops answering, the line under the field names it and gives the reason, so the caller can move the choice back to Automatic or the crew can choose another model.

Claude Sonnet 4.6 cannot answer now: No connection

No connection: the cloud models are listed but cannot be chosen, each with its reason, and the chosen Claude model says under the field why it cannot answer. The models on board answer.
Code
import { ChatInput, type ChatModel } from "@bosunkit/components/chat-input";
import type { ReactElement } from "react";

export const description: string =
  "No connection: the cloud models are listed but cannot be chosen, each with its reason, and the chosen Claude " +
  "model says under the field why it cannot answer. The models on board answer.";

const MODELS: readonly ChatModel[] = [
  { id: "qwen3.5-4b", name: "Qwen3.5 4B", provider: "llama.cpp", local: true, tier: "Reasoning", available: true },
  {
    id: "claude-sonnet-4-6",
    name: "Claude Sonnet 4.6",
    provider: "Anthropic",
    local: false,
    available: false,
    note: "No connection",
  },
  {
    id: "gemini-2.5-flash",
    name: "Gemini 2.5 Flash",
    provider: "Google",
    local: false,
    available: false,
    note: "No connection",
  },
];

function ignore(): void {
  // The example sends nothing.
}

export default function Offline(): ReactElement {
  return (
    <ChatInput
      onSend={ignore}
      models={MODELS}
      defaultModel="claude-sonnet-4-6"
      automaticNote="Answered on board while there is no connection"
    />
  );
}

Suggestions

suggestions are requests to start from, drawn as chips above the field while it is empty and has focus. A press puts the suggestion's prompt in the field, with the cursor at its end; suggestionBehavior="send" sends it at once. A suggestion's category groups its chips for a screen reader. The chips come before the field in the tab order, so Shift+Tab from the field reaches them.

Three requests to start from, shown as chips above the field while it is empty and has focus: click the field to see them, and press one to put its question in the field.
Code
import { ChatInput, type ChatSuggestion } from "@bosunkit/components/chat-input";
import { AnchorIec } from "@bosunkit/icons/anchor/AnchorIec";
import { Weather } from "@bosunkit/icons/weather/Weather";
import { Gauge } from "lucide-react";
import type { ReactElement } from "react";

export const description: string =
  "Three requests to start from, shown as chips above the field while it is empty and has focus: click the field " +
  "to see them, and press one to put its question in the field.";

const SUGGESTIONS: readonly ChatSuggestion[] = [
  {
    id: "weather",
    label: "Weather for tomorrow's passage",
    prompt: "What's the weather for tomorrow's passage?",
    icon: <Weather size="1.2em" />,
  },
  { id: "anchor", label: "Check the anchor", prompt: "Is the anchor holding?", icon: <AnchorIec size="1.2em" /> },
  {
    id: "engine",
    label: "Engine hours until service",
    prompt: "How many engine hours until the next service?",
    icon: <Gauge size="1.2em" />,
  },
];

function ignore(): void {
  // The example sends nothing.
}

export default function Suggestions(): ReactElement {
  return <ChatInput onSend={ignore} suggestions={SUGGESTIONS} placeholder="Ask Bosun" />;
}

Commands

commands are short names for whole requests, as bosund's Telegram bot offers /status, /anchor and /wind. A / at the start of the field lists them above it, narrowed to those whose trigger, or a word of whose name, begins with the letters typed after the /. ArrowDown and ArrowUp move through the list and wrap at either end, Enter or Tab puts the command's prompt in the field, and Escape closes the list and keeps the text. The field keeps focus throughout and names the active command with aria-activedescendant.

Type / in the field to list the commands, then more letters to narrow them; the arrows move through the list, Enter or Tab puts the command's question in the field, and Escape closes it.
Code
import { ChatInput, type ChatCommand } from "@bosunkit/components/chat-input";
import type { ReactElement } from "react";

export const description: string =
  "Type / in the field to list the commands, then more letters to narrow them; the arrows move through the list, " +
  "Enter or Tab puts the command's question in the field, and Escape closes it.";

const COMMANDS: readonly ChatCommand[] = [
  {
    id: "status",
    trigger: "/status",
    label: "Vessel status",
    description: "Position, conditions and alerts",
    prompt: "What's the vessel status?",
  },
  {
    id: "anchor",
    trigger: "/anchor",
    label: "Anchor watch",
    description: "Holding, swing circle and drag",
    prompt: "How is the anchor holding?",
  },
  {
    id: "wind",
    trigger: "/wind",
    label: "Wind",
    description: "Speed, direction and trend",
    prompt: "What's the wind doing?",
  },
  {
    id: "weather",
    trigger: "/weather",
    label: "Weather briefing",
    description: "Current weather and forecast",
    prompt: "Give me a weather briefing",
  },
  {
    id: "safety",
    trigger: "/safety",
    label: "Safety check",
    description: "Equipment, certificates and what is due",
    prompt: "Run a safety check",
  },
];

function ignore(): void {
  // The example sends nothing.
}

export default function Commands(): ReactElement {
  return <ChatInput onSend={ignore} commands={COMMANDS} placeholder="Type / for commands" />;
}

Recalling earlier questions

history holds the crew's earlier messages, oldest first. In an empty field ArrowUp brings back the last one and steps back through the earlier ones; ArrowDown steps forward again, to an empty field after the last. The arrows leave text the crew typed alone, and editing a recalled question ends the walk.

Three questions asked earlier on this watch: press ArrowUp in the empty field to bring back the last, again for the one before, and ArrowDown to come forward.
Code
import { ChatInput } from "@bosunkit/components/chat-input";
import type { ReactElement } from "react";

export const description: string =
  "Three questions asked earlier on this watch: press ArrowUp in the empty field to bring back the last, again for " +
  "the one before, and ArrowDown to come forward.";

const ASKED: readonly string[] = ["How deep is it?", "When is high water?", "Is the anchor holding?"];

function ignore(): void {
  // The example sends nothing.
}

export default function History(): ReactElement {
  return <ChatInput onSend={ignore} history={ASKED} placeholder="Press ArrowUp for an earlier question" />;
}

When text leaves the vessel

A message the crew type is sent to a language model. A model on board (llama.cpp running Qwen3.5) keeps the text and the vessel's situation on the vessel and answers with no connection. A cloud model sends the text, the conversation so far and a redacted summary of the situation to its provider: bosund's egress policy coarsens the position and leaves out the crew's names before anything leaves.

  • A model on board is marked "Stays on board, works offline", and nothing is shown under the field.
  • A cloud model is marked "Sent to" its provider in the list, and while it is chosen the line under the field says so. The field points at that line with aria-describedby, so a screen reader hears it on focus, and the line is announced when the choice changes.
  • Automatic is bosund's own routing. With models on board and a cloud provider set up, bosund answers a typed message on board and may then send the conversation to a cloud model to add to the reply; with no models on board every message goes to the cloud. Pass automaticNote to say which of these applies on the vessel, such as "Answered on board; a cloud model may add to the reply".

A note says where the text goes in the crew's terms: the provider's name, never a host or an API. Keep "Sent to" on every cloud model, including one that cannot answer, so the list reads the same at sea and in harbor.

Data from bosund

The component fetches nothing; the caller maps bosund's data onto the props.

Propbosund source
modelsNot exposed to the chat: each provider's MessageService.ListModels (llm.Model.ID, DisplayName) and the llama service's fast and reasoning models
models[].available, noteProviderPool.Stats() (Blocked, ConsecutiveErrors), the compute budget (BudgetStats) and the connection
model, onModelChangeNot accepted: POST /dialogue/send takes text, session_id, is_voice and channel. Utterance.ModelHint reaches llm.Request.ModelID for scheduled prompts
automaticNoteThe routing bosund chose at start: llama+cloud, cloud or llama
suggestionscapabilities/intent.HelpExamples, the requests help offers, with help.example.* text in six languages and the activity each suits; on no endpoint
commandsThe Telegram bot's commands (comms/telegram/commands.go), in English and on no endpoint
historyThe crew's messages in the conversation the caller holds

Props

PropTypeDefaultDescription
onSendrequired(message: string) => void–Called with the trimmed message when the crew press Enter or the send button.
disabledboolean | undefinedfalseWhether the field and the send button refuse input, as while a reply is awaited.
placeholderstring | undefined–The hint shown in the empty field; Bosun's own "Type a message..." when not set.
modelsreadonly ChatModel[] | undefined–The models the crew can choose from. With any given, a picker sits left of the send button, offering Automatic (bosund routes each message, as it does without a choice) and these models; with none there is no picker.
modelstring | null | undefined–The chosen model's id, for a caller that holds the choice; null for Automatic. Pair it with onModelChange. bosund's POST /dialogue/send takes no model yet: the caller sends this choice once it does.
defaultModelstring | null | undefinednullThe model chosen at first, when the caller does not hold the choice; Automatic (null) when unset.
onModelChange((model: string | null) => void) | undefined–Called with the model the crew choose: its id, or null for Automatic.
automaticNotestring | undefined–What Automatic means for where a message goes, translated by the caller from bosund's routing ("Answered on board; a cloud model may add to the reply"); Bosun's own "Bosun chooses the model for each message" when unset.
automaticModelstring | undefined–The id of the model Automatic will send the next message to, from bosund's routing; the picker then reads "Automatic · Qwen3.5 4B", so the crew know before they send which model answers and whether it is on board.
suggestionsreadonly ChatSuggestion[] | undefined–Requests to start from, shown as chips above the field while it is empty and has focus, and while disabled is off. With none there are no chips.
suggestionBehaviorChatSuggestionBehavior | undefined"fill"What pressing a suggestion does: fill (the default) puts its prompt in the field; send sends it at once.
commandsreadonly ChatCommand[] | undefined–The commands a "/" at the start of the field offers, in a list above it that narrows as the crew type: the arrows move through it, Enter or Tab puts the command's prompt in the field, Escape closes it. With none the field is a plain text box.
historyreadonly string[] | undefined–The crew's earlier messages in this conversation, oldest first. In an empty field, ArrowUp puts the last one in it and steps back through the earlier ones, and ArrowDown steps forward again, to an empty field after the last, as a shell does; editing a recalled message ends the walk. With none the arrows do nothing.
classNamestring | undefined–Classes added to the input row's own.
refRef<HTMLDivElement> | undefined–The input row's element.
Do

Sent to Anthropic

Give a cloud model its provider and local off. Chosen, it says under the field that the text goes to Anthropic.
Don't

Don't mark a cloud model local to keep the line away. The crew read that their text stays on board while it goes to Anthropic.
  • LLM status: which model answers, each provider's health and the budget behind it.
  • Select: the list the model picker opens.
  • Chat bubble: the messages the input adds to.
  • Accessibility: the target sizes and the accessible names a field needs.

With automaticModel, the id of the model bosund's routing would send the next message to, the picker reads "Automatic · Qwen3.5 4B", so the crew know before they send which model answers and whether it stays on board.