Documentation
¶
Overview ¶
Package chatshell composes tui/transcript, tui/sidebar, tui/focus and tui/stream into the reusable chat screen every aichat product runs: history + composer + sidebar + top/status bars, with a split pane when the terminal is wide enough (>= 104 columns, matching DataTug's splitEnabled). Products plug in with a Handler and, optionally, an OnSidebarChange-style observer; see New and Model's methods.
Keybindings: Enter sends (Shift+Enter inserts a newline); "/" at the start of the input opens the slash-command menu (↑↓ choose, Enter insert, Esc close); Shift+Up/Down move focus between transcript stops and back to the input (see tui/focus); Shift+Right/Left move focus into/out of the sidebar; F6 toggles sidebar visibility; Ctrl+Left/Right resize the split; Esc always returns focus to the input.
Index ¶
- type Chip
- type ChipObserver
- type Command
- type GlobalKeysFunc
- type Handler
- type Model
- func (m *Model) AppendAssistant(text string)
- func (m *Model) AppendAssistantMarkdown(text string)
- func (m *Model) AppendBlock(block transcript.Block)
- func (m *Model) AppendBlockWithID(id string, block transcript.Block) bool
- func (m *Model) AppendSystem(text string)
- func (m *Model) AppendUser(text string)
- func (m *Model) Busy() bool
- func (m *Model) Chips() []Chip
- func (m *Model) ClearChips() tea.Cmd
- func (m *Model) ClearTranscript()
- func (m *Model) CloseOverlay(o Overlay) bool
- func (m *Model) FocusEntry(id string) bool
- func (m *Model) FocusedEntryID() string
- func (m *Model) FocusedRef() *session.EntityRef
- func (m *Model) Init() tea.Cmd
- func (m *Model) MouseEnabled() bool
- func (m *Model) PinToSidebar(ref session.EntityRef)
- func (m *Model) PopOverlay() tea.Cmd
- func (m *Model) PushOverlay(o Overlay) tea.Cmd
- func (m *Model) RemoveChip(id string) (cmd tea.Cmd, found bool)
- func (m *Model) ReplaceBlock(entryID string, b transcript.Block)
- func (m *Model) SelectionRefs() []session.EntityRef
- func (m *Model) SetBusy(busy bool) tea.Cmd
- func (m *Model) SetBusyCancel(cancel func())
- func (m *Model) SetChips(chips []Chip)
- func (m *Model) SetComposerText(s string)
- func (m *Model) SetMouseEnabled(enabled bool)
- func (m *Model) SetStatus(text string)
- func (m *Model) SidebarRefs() []session.EntityRef
- func (m *Model) StartStream(id string, open func(ctx context.Context) iter.Seq2[ai.Event, error]) tea.Cmd
- func (m *Model) StartStreamMarkdown(id string, open func(ctx context.Context) iter.Seq2[ai.Event, error]) tea.Cmd
- func (m *Model) UnpinFromSidebar(ref session.EntityRef)
- func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)
- func (m *Model) View() tea.View
- func (m *Model) Zone() focus.Zone
- type MouseMode
- type MsgHandler
- type Option
- func WithChips(chips []Chip) Option
- func WithCommands(commands []Command) Option
- func WithContext(ctx context.Context) Option
- func WithGlobalKeys(fn GlobalKeysFunc) Option
- func WithMarkdownRenderer(r transcript.MarkdownRenderer) Option
- func WithMouse(mode MouseMode) Option
- func WithSidePanel(p SidePanel) Option
- func WithSidebarRenderer(render sidebar.Renderer) Option
- func WithStatusBar(render func(width int) string) Option
- func WithTitle(title string) Option
- func WithTopBar(render func(width int) string) Option
- type Overlay
- type SidePanel
- type SidePanelPinner
- type SidebarObserver
- type StreamObserver
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Chip ¶ added in v0.2.0
Chip is a product-neutral attachment shown as a removable pill above the composer input -- e.g. a table, file, or other entity the user has staged as context for the next turn. Ported from DataTug's own ContextReference-backed attachment chips (datatug-cli#291): Ref carries the product's own entity identity (nil when a chip has none, e.g. a free-form label), Label is what renders in the pill, and ID is the product's stable identifier for the chip itself -- used only to let a product address a specific chip (SetChips) across renders; chatshell never interprets it.
type ChipObserver ¶ added in v0.2.0
ChipObserver is an optional Handler capability: when implemented, chatshell calls OnChipsChange after every chip-list change IT ITSELF performs -- a chip removed via Backspace/Delete/mouse click, or a Shift+Esc restore -- so a product can keep its own attachment/context state (e.g. a session's working set) in sync. It is deliberately NOT called from WithChips or SetChips: those calls already come FROM the product, so echoing them back would be redundant, and -- because a product's own SetChips call in response to an earlier OnChipsChange lands as a separate, later Update (OnChipsChange returns a tea.Cmd, not a synchronous call) -- calling it there too could race a product's SetChips echo against a still-pending Shift+Esc undo. See SetChips's doc.
type GlobalKeysFunc ¶ added in v0.0.3
type GlobalKeysFunc func(msg tea.KeyPressMsg) (cmd tea.Cmd, consumed bool)
GlobalKeysFunc is checked before chatshell's own key handling (so a product can claim keys like F3/F4 pickers ahead of chatshell's defaults). cmd is returned as-is; consumed true stops chatshell from handling the key at all this cycle.
type Model ¶
type Model struct {
// contains filtered or unexported fields
}
Model is the reusable chat screen.
func (*Model) AppendAssistant ¶
AppendAssistant appends a (non-streamed) assistant message.
func (*Model) AppendAssistantMarkdown ¶ added in v0.0.3
AppendAssistantMarkdown appends a non-streamed assistant message rendered through the configured MarkdownRenderer (WithMarkdownRenderer), e.g. agent or HTTP-response markdown via glamour. With no renderer configured it behaves like AppendAssistant (transcript.Entry.Markdown is inert then).
func (*Model) AppendBlock ¶
func (m *Model) AppendBlock(block transcript.Block)
AppendBlock appends a rich transcript.Block (e.g. a tui/grid result).
func (*Model) AppendBlockWithID ¶ added in v0.0.3
func (m *Model) AppendBlockWithID(id string, block transcript.Block) bool
AppendBlockWithID appends a rich transcript.Block under a caller-chosen, stable id, so a product can later FocusEntry(id) (e.g. DataTug's Ctrl+G "jump to latest grid") or ReplaceBlock(id, ...) it. id must be non-empty and unique among live entries (and must not clash with an in-flight StartStream id, since both identify a transcript entry the same way); AppendBlockWithID reports false and does not append on any such collision, so a caller can generate a fresh id and retry instead of silently corrupting FocusEntry/ReplaceBlock addressing.
func (*Model) AppendSystem ¶
AppendSystem appends a system/status message (e.g. an error).
func (*Model) AppendUser ¶
AppendUser appends a user message to the transcript.
func (*Model) Chips ¶ added in v0.2.0
Chips returns a defensive copy of the composer's current attachment chips.
func (*Model) ClearChips ¶ added in v0.2.0
ClearChips detaches every chip -- a product-facing equivalent of Esc's second step (clearComposerStep) -- e.g. a toolbar "clear attachments" button. It snapshots the pre-clear draft first (so Shift+Esc/Ctrl+Y can undo it) and is a no-op (returns nil) when there are no chips to clear.
func (*Model) ClearTranscript ¶ added in v0.0.3
func (m *Model) ClearTranscript()
ClearTranscript cancels any in-flight stream, empties the transcript and returns focus to the composer, e.g. /clear or a session switch. Cancelling first (rather than leaving the stream running against a now-empty transcript) matters because handleStreamEvent only applies an EventMsg whose ID still matches the current stream — a stray delta for the cancelled stream is otherwise silently dropped instead of resurrecting a transcript entry the clear just removed. A product's own SetBusy(true) phase (no stream, e.g. a decision chain) has no DoneMsg to cancel it asynchronously, so ClearTranscript also invokes the registered SetBusyCancel callback directly, same as cancelBusy does for Esc/Ctrl+C.
ClearTranscript also drops any pending Shift+Esc/Ctrl+Y composer-draft snapshot and clears chip focus (M1, r1 review) -- e.g. a session switch, where the OLD session's "undo my last chip removal" and chip-row cursor position no longer mean anything against the NEW session's own chips (which a product typically installs right after via SetChips). It does NOT itself clear m.chips: which chips belong to the new session is the product's call, made via SetChips, not ClearTranscript's.
func (*Model) CloseOverlay ¶ added in v0.1.0
CloseOverlay removes o from the overlay stack WHEREVER IT IS -- not only if it's on top -- matching by POINTER IDENTITY (see Overlay's doc: an Overlay MUST be a pointer type for this to ever find it). It reports whether o was found and removed; false is a no-op. This is the identity-safe replacement for PopOverlay in the async-safe overlay pattern once a second overlay might be stacked on top of the one an async result is meant to close (r3 review, MAJOR -- see PopOverlay's doc).
A non-pointer Overlay (or a nil pointer) can never be matched -- o's pointer identity is extracted via reflection rather than Go's `==` specifically to avoid a runtime panic comparing two interface values whose dynamic type is non-comparable (e.g. one holding a slice or map field); CloseOverlay simply reports false for such an Overlay instead of crashing.
func (*Model) FocusEntry ¶ added in v0.0.3
FocusEntry moves focus to the transcript entry identified by id (e.g. DataTug's Ctrl+G "jump to latest grid"), scrolling it into view. It reports whether such a focusable entry exists; when it doesn't, focus is left unchanged.
func (*Model) FocusedEntryID ¶ added in v0.1.0
FocusedEntryID reports the transcript entry id currently under focus (Zone() == focus.ZoneTranscript), or "" when the transcript isn't focused, no entry is focused, or the focused entry was never given an id (AppendBlockWithID/StartStream's id; a plain AppendUser/AppendAssistant/ AppendBlock entry has none).
func (*Model) FocusedRef ¶
FocusedRef returns the entity ref under focus: the transcript's focused block's Current() when the transcript zone has focus, or the sidebar cursor's ref when the sidebar zone has focus (the default sidebar only — a SidePanel exposes no cursor in its pinned contract, so this is nil while one is active). It is nil when the composer has focus, or nothing is under the cursor.
func (*Model) MouseEnabled ¶ added in v0.1.0
MouseEnabled reports whether mouse reporting is currently requested (see SetMouseEnabled).
func (*Model) PinToSidebar ¶
PinToSidebar adds ref to the sidebar (or, with a SidePanel implementing SidePanelPinner, to it instead) and notifies an OnSidebarChange Handler, if any.
func (*Model) PopOverlay ¶ added in v0.1.0
PopOverlay closes the TOP overlay PROGRAMMATICALLY -- without waiting for its own Update to report done. It is a no-op (returns nil) when no overlay is open.
PopOverlay is TOP-ONLY: it closes whatever happens to be on top at the moment it is called, regardless of which overlay a caller "meant". That is exactly right for the common case (at most one overlay is ever open at a time), but WRONG for the async-safe pattern below once a SECOND overlay can be stacked on top of the first before its async result arrives (r3 review, MAJOR) -- e.g. dialog A's submit is in flight, the user opens dialog B on top of it, and A's result lands: PopOverlay would close B, not A. Use CloseOverlay(o) instead whenever more than one overlay might ever be on the stack at once; PopOverlay remains for the simpler single-overlay case (or for closing "whatever's on top" on purpose, e.g. an Esc-equivalent product action).
Async-safe overlay pattern: an Overlay may need to stay open ACROSS an async round trip (e.g. a form whose Enter submits to a server before it can close). Its own Update returns done: false plus a product tea.Cmd on submit -- exactly like any other command chatshell dispatches. The product's own result message, once it arrives, is NOT itself overlay input (isOverlayInputMsg only classifies key/paste/mouse messages), so it takes the normal Update path and reaches an optional MsgHandler.OnMsg (dispatchUnhandled's default routing) EVEN WHILE THE OVERLAY IS STILL OPEN -- an open overlay only captures key/paste/mouse input, never this. From there the product calls CloseOverlay(o) with the SAME *T it passed to PushOverlay on success (see Overlay's doc: it MUST be a pointer type), or -- to show an error while keeping the user's draft -- updates the overlay in place (e.g. via an optional `interface{ OnResult(any) }` capability the product's own Overlay implements, or simply because the product holds that same pointer and can mutate it directly).
func (*Model) PushOverlay ¶ added in v0.0.3
PushOverlay pushes a modal dialog onto the overlay stack. The top overlay captures every key event (and every other message chatshell would otherwise handle itself) until its Update returns done, and is rendered centred over the screen.
func (*Model) RemoveChip ¶ added in v0.2.0
RemoveChip removes the chip with the given ID -- a product-facing equivalent of Backspace/Delete on a focused chip or a mouse click on its ×, e.g. a "remove attachment" control the product renders elsewhere in its own UI. It snapshots the pre-removal draft first, same as any other removal (so Shift+Esc/Ctrl+Y can undo it), and reports (r2 review, m3) via the second return value whether a chip with that ID was found and removed; found is false (cmd is nil) for an unknown ID, a documented no-op.
func (*Model) ReplaceBlock ¶ added in v0.0.3
func (m *Model) ReplaceBlock(entryID string, b transcript.Block)
ReplaceBlock refreshes/re-runs a grid (or any other transcript.Block) in place: the entry identified by entryID keeps its position and ID, but renders b from now on. If that entry currently holds transcript focus, it keeps it (the focus ring stop is recomputed for the entry, not left pointing at whatever raw index it used to occupy — necessary because replacing a Block can change its own Focusable() answer).
The previously-focused entry is tracked by its RAW POSITION in m.transcript.Entries(), not by transcript.StopForID(fe.ID): that ID-keyed lookup always reports -1 for an entry with an empty ID (which AppendUser/AppendAssistant/plain AppendBlock all leave empty — only AppendBlockWithID sets one), and transcript.ReplaceBlock's own internal focus cursor likewise only re-resolves itself by ID when the focused entry has one. So a no-ID focused entry would otherwise silently look unfocused (or land on the wrong stop) the moment an EARLIER entry's swap shifts stop numbers — even though that entry's own Block never changed. Raw position is safe to track across the call because transcript.ReplaceBlock only mutates one entry's Block in place; it never reorders, inserts or removes entries.
m.focusRing (chatshell's own zone/stop tracker) is resynced to whatever was decided too: syncFocus later reapplies focusRing.Stop() into the transcript on the next zone change or resize, so leaving it stale would silently undo the recomputed focus the next time that happens.
func (*Model) SelectionRefs ¶
SelectionRefs returns the transcript's current selection: the focused block's entity, when any (a Block may later report more than one, e.g. a grid's multi-selected rows; today this is FocusedRef's single entity). It is distinct from the sidebar's pins — see SidebarRefs.
func (*Model) SetBusy ¶
SetBusy marks a product-driven phase that precedes (or stands in for) a stream — e.g. a decision chain or a deterministic query — as in flight: the composer stops accepting input and the spinner runs, exactly as while a stream is in flight. The returned tea.Cmd starts the spinner and must be returned from Update/a command chain when busy is true; it is nil when busy is false. SetBusy(false) clears any SetBusyCancel func registered for the phase that just ended.
func (*Model) SetBusyCancel ¶
func (m *Model) SetBusyCancel(cancel func())
SetBusyCancel registers the cancel func for the current SetBusy(true) phase (e.g. a context.CancelFunc for the ctx a decision chain runs under). Esc/Ctrl+C while busy and no stream is active calls it — see cancelBusy. Products that call SetBusy(true) but have nothing cancellable may leave this unset.
func (*Model) SetChips ¶ added in v0.2.0
SetChips replaces the composer's attachment chips wholesale, e.g. when the product attaches a new entity from its own workspace/sidebar UI. The focused chip index is preserved when it still falls within the new list, and reset to "no chip focused" (returning keyboard focus to the input) when it doesn't -- e.g. the list shrank.
SetChips does NOT clear a pending Shift+Esc/Ctrl+Y undo snapshot (see snapshotComposerUndo): the snapshot exists to let the user recover from a change THEY just made, and a product-driven SetChips call (adding or syncing chips for an unrelated reason) shouldn't silently discard that recovery option out from under them. When a restore does eventually happen, any chip present now that wasn't part of the snapshot is kept, not discarded -- see mergeRestoredChips.
func (*Model) SetComposerText ¶ added in v0.0.3
SetComposerText sets the composer's text and moves the cursor to the end, e.g. an edit-previous-message flow.
func (*Model) SetMouseEnabled ¶ added in v0.1.0
SetMouseEnabled toggles mouse reporting at runtime, e.g. DataTug's F2 capture toggle (a terminal's own native text-selection/copy is unusable while mouse reporting is on, so a product that wants both needs a key to flip between them). enabled true requests the mode configured via WithMouse (MouseCellMotion by default if WithMouse was never called); enabled false requests no mouse reporting at all. The new mode takes effect on the next View() -- chatshell has no way to push it to the terminal outside the normal render cycle.
func (*Model) SidebarRefs ¶
SidebarRefs returns the sidebar's pinned refs: the default sidebar's, or a SidePanelPinner SidePanel's, or nil when a SidePanel is active but doesn't implement SidePanelPinner (see PinToSidebar).
func (*Model) StartStream ¶
func (m *Model) StartStream(id string, open func(ctx context.Context) iter.Seq2[ai.Event, error]) tea.Cmd
StartStream starts a streamed assistant entry. id must be unique per turn; deltas render progressively into the transcript, and a spinner runs until the first delta (or completion) arrives.
The Model owns the per-stream context: it creates a cancellable child of its own context (WithContext) and passes it to open, which must use it to build the actual provider call (e.g. `return provider.Stream(ctx, req)`) so a cancellation reaches the live request, not just this local pump — an adapter observing ctx.Done() aborts its call and yields ai.ErrCodeCanceled. The context is cancelled automatically when a new StartStream call supersedes this one, or explicitly by the user (Esc or Ctrl+C while busy). A cancelled stream ends with a "(stopped)" transcript entry, not an error, and StreamObserver.OnStreamDone (if implemented) always fires once the stream ends, however it ended.
func (*Model) StartStreamMarkdown ¶ added in v0.0.3
func (m *Model) StartStreamMarkdown(id string, open func(ctx context.Context) iter.Seq2[ai.Event, error]) tea.Cmd
StartStreamMarkdown is StartStream, but the streaming entry is rendered through the configured MarkdownRenderer (WithMarkdownRenderer) as it accumulates, same as AppendAssistantMarkdown for a non-streamed message — e.g. DataTug's agent/HTTP markdown responses. With no renderer configured it behaves like StartStream (transcript.Entry.Markdown is inert then).
func (*Model) UnpinFromSidebar ¶
UnpinFromSidebar removes ref from the sidebar (or SidePanelPinner) and notifies an OnSidebarChange Handler, if any.
func (*Model) View ¶
View renders the chat screen. It re-applies resize() FIRST, on every call (r2 review, B1) -- not only in response to WindowSizeMsg/F6/Ctrl+ Left/Right/a chip-list change, the only events that previously called it -- because historyHeight() (and therefore how tall the transcript SHOULD be) also depends on state that changes without going through any of those: typing "/" opens the slash-command menu, SetStatus changes the status segment's height, and SetBusy(true)/StartStream reserves the spinner line. Without this, m.transcript's ACTUAL viewport size (set via SetSize, and otherwise sticky) drifts from the CURRENT historyHeight() value between renders -- both the total rendered line count (over- or under-filling the screen) and chipsTopY's click math (computed fresh from the CURRENT historyHeight() at click time, but answering for whatever was ACTUALLY drawn by the last, possibly stale, render) go wrong. Calling resize() here is cheap (it only sets sizes) and idempotent, so doing it unconditionally on every render is simpler and more robust than hunting down every call site that can change chrome height.
type MouseMode ¶ added in v0.1.0
type MouseMode int
MouseMode selects whether chatshell requests terminal mouse reporting and, if so, which tea.MouseMode it asks for.
const ( // MouseOff requests no mouse reporting (the default: a terminal's own // native text selection/copy keeps working). MouseOff MouseMode = iota // MouseCellMotion requests click, release and wheel events (but not // plain motion/hover) -- enough to scroll the transcript with the wheel // without giving up terminal-native text selection on most terminals. MouseCellMotion )
type MsgHandler ¶
MsgHandler is an optional Handler capability: when implemented, chatshell forwards every message it does not itself recognise (e.g. a product message, or a Block message such as grid.RowActivatedMsg) to OnMsg, in addition to broadcasting it to the transcript's Blocks.
type Option ¶
type Option func(*Model)
Option configures a Model at construction time.
func WithChips ¶ added in v0.2.0
WithChips sets the composer's initial attachment chips (see SetChips).
func WithCommands ¶
WithCommands sets the slash commands the composer offers.
func WithContext ¶
WithContext sets the context streamed responses and Handler calls run under (defaults to context.Background()). StartStream derives a cancellable child of it per stream.
func WithGlobalKeys ¶ added in v0.0.3
func WithGlobalKeys(fn GlobalKeysFunc) Option
WithGlobalKeys sets a product key hook checked before chatshell's own key handling (see GlobalKeysFunc).
func WithMarkdownRenderer ¶ added in v0.0.3
func WithMarkdownRenderer(r transcript.MarkdownRenderer) Option
WithMarkdownRenderer sets the renderer used for entries appended via AppendAssistantMarkdown (or any transcript.Entry with Markdown set), e.g. a glamour-backed renderer for agent or HTTP-response markdown.
func WithMouse ¶ added in v0.1.0
WithMouse sets the initial mouse mode (see MouseMode). Products that want a runtime toggle (e.g. DataTug's F2 capture toggle, which needs the terminal's native mouse selection back while capturing) call SetMouseEnabled after construction; WithMouse only sets the starting state and, for MouseCellMotion, the mode SetMouseEnabled(true) re-enables later. The default (no WithMouse call) is MouseOff.
func WithSidePanel ¶ added in v0.0.3
WithSidePanel installs a product SidePanel in place of the default sidebar. It starts visible, matching the default sidebar's own start state.
func WithSidebarRenderer ¶
WithSidebarRenderer sets how sidebar entries render.
func WithStatusBar ¶ added in v0.0.3
WithStatusBar sets a product-rendered status bar, replacing the default SetStatus-driven status line(s).
func WithTopBar ¶ added in v0.0.3
WithTopBar sets a product-rendered top bar, replacing the default bold title line.
type Overlay ¶ added in v0.0.3
type Overlay interface {
View(width, height int) string
Update(msg tea.Msg) (o Overlay, cmd tea.Cmd, done bool)
}
Overlay is a modal dialog: once pushed (PushOverlay) it captures every key event until its Update returns done, and renders centred over the screen.
An Overlay implementation MUST be a POINTER type (r3 review, minor 1): updateOverlay stores whatever value Update returns back into the stack (m.overlays[top] = updated), so a value-typed Overlay's mutations inside Update are trivially preserved that way regardless -- but CloseOverlay's identity match (see its doc) can only ever find a POINTER back on the stack, since Go's interface equality on a struct value compares fields, not "is this the same logical dialog", and a value Overlay a product still holds a copy of will never == the (possibly mutated, definitely re-wrapped) value Update last returned. A product using the async-safe overlay pattern (see PopOverlay/CloseOverlay) MUST hold and pass the same *T it originally gave PushOverlay.
type SidePanel ¶ added in v0.0.3
type SidePanel interface {
Title() string
View(width, height int, focused bool) string
Update(msg tea.Msg) (SidePanel, tea.Cmd)
}
SidePanel replaces the default sidebar (working-context list) when set, e.g. a product workspace pane with its own tabs, explorer and bookmarks. It participates in the focus ring exactly as the built-in sidebar does (Shift+Right/Left, F6 toggle, Ctrl+←/→ split 40–75%).
type SidePanelPinner ¶ added in v0.0.3
type SidePanelPinner interface {
// PinRef adds ref; it reports whether the set actually changed.
PinRef(ref session.EntityRef) bool
// UnpinRef removes ref; it reports whether the set actually changed.
UnpinRef(ref session.EntityRef) bool
// Refs returns the current pinned refs.
Refs() []session.EntityRef
}
SidePanelPinner is an optional SidePanel capability: when a product's SidePanel implements it, PinToSidebar/UnpinFromSidebar/SidebarRefs and AddToSidebarMsg route to it instead of the built-in sidebar's ref list. A SidePanel that does NOT implement it makes Pin/Unpin/SidebarRefs a documented no-op (never silently falls back to the now-hidden default sidebar's own state).
type SidebarObserver ¶
SidebarObserver is an optional Handler capability: when implemented, chatshell calls OnSidebarChange after every sidebar mutation (pin, unpin, AddToSidebarMsg, sidebar removal).
type StreamObserver ¶
type StreamObserver interface {
OnStreamEvent(id string, ev ai.Event) tea.Cmd
OnStreamDone(id string, err error) tea.Cmd
}
StreamObserver is an optional Handler capability: when implemented, chatshell calls OnStreamEvent for every event a StartStream-driven stream produces (Started/TextDelta/Structured/Usage/Completed/Error), in addition to chatshell's own built-in handling (rendering text deltas, appending non-fatal errors). Its returned tea.Cmd, if any, is batched alongside the stream's own re-arm command.
OnStreamDone is called exactly once per StartStream call, whatever the outcome — success (err nil), a fatal error, or a user/product cancellation (err satisfying chatshell's isCanceled) — so a product can roll back speculative state or record diagnostics. It fires even for a stream that was superseded by a later StartStream call before finishing.