Hire Team

INDUSAGI DOCUMENTATION

React-Ink Widgets

Preserved documentation snapshot. APIs and capabilities differ by edition and version. Check the selected package before implementation. View pinned source.

View Python Framework source on GitHub
On this page

indusagi.react_ink is the terminal-UI component library for the coding agent — a streaming markdown renderer, a colored structured-diff view, twelve focus-trapping modal dialogs, per-role chat widgets, chrome (Footer, StatusLine, TaskPanel), a theme adapter, and a set of framework-free helper modules. Built on Textual + Rich. Imported as import indusagi.react_ink.

The package surface re-exports every widget and helper from its snake_cased submodules so each symbol can be reached from the top level. The pure logic lives in three framework-light subpackages — diff/, markdown/, and utils/ — while the on-screen surfaces live under components/. Chat data flows from the UI Bridge as a SessionSnapshot into MessageList, Footer, StatusLine, TaskPanel, and StreamingMarkdown, and dialogs are pushed onto the Shell App as Textual ModalScreens.

Table of Contents

Layout

Three layers under indusagi/react_ink/:

Path Holds
types.py Frozen view-model dataclasses (SessionSnapshot, StatusMessage, …) and type aliases
theme_adapter.py Palette → Rich painters, Textual theme, and Pygments style
diff/ structured.py (framework-free diff engine), word_diff.py (intra-line emphasis), view.py (the DiffView widget)
markdown/ format_token.py, highlight.py, table.py (pure), view.py + streaming.py (widgets)
components/ Chrome (footer, status_line, task_panel, tool_event, changelog, display_block) and the messages/ + dialogs/ subpackages
utils/ Pure dissection/preview/search helpers (message_groups, tool_display, selection_dialog, session_browser)

The pure subpackages (diff.structured, diff.word_diff, and all of utils/) import no textual or rich. Importing the top-level indusagi.react_ink package does pull in Textual/Rich because the widget modules need them; import from the subpackages directly when you only want the logic.

Two modules are deliberately not re-exported at the package top level: components.editor (PromptEditor) and the session-browser engine utils.session_browser (reachable via indusagi.react_ink.utils). Where the same name is exported by two modules, the package resolves to the defining module: status_marker / split_visible_lines / clamp_content_lines come from components.tool_event, and VISIBLE_SESSION_COUNT / build_session_metadata come from components.dialogs.session.

View-model types

types.py is the UI vocabulary. Every chrome widget renders from a single immutable SessionSnapshot; the rest are frozen data carriers and type aliases.

Name Kind Source Purpose
SessionSnapshot dataclass types.py Full view-model of one session: messages tuple, model, is_streaming / is_compacting flags, SessionStats, ContextUsage
StatusMessage dataclass types.py A status line entry with a StatusKind tone
ToolExecutionState dataclass types.py One in-flight tool execution keyed by tool_call_id, with a ToolExecutionStatus
PendingMessageItem dataclass types.py A queued user input awaiting send, with a PendingMessageMode
SessionStats / ContextUsage dataclass types.py Token/cost stats and context-window usage
UiDisplayBlock dataclass types.py A host- or extension-emitted block merged into the transcript

Type aliases include StatusKind, ToolExecutionStatus, LoginAuthKind, and PendingMessageMode. Field names mirror the TypeScript ground truth verbatim (tool_call_id, is_streaming, updated_at, cache_read) and most dataclasses are frozen=True, slots=True. The agent and AI imports (AgentMessage, Model, the auth prompt types) sit under TYPE_CHECKING to keep types.py dependency-light.

Theme adapter

theme_adapter.py projects one flat host palette (Mapping[str, str]) onto three consumers. create_theme_bundle packs all three into a ThemeBundle.

Name Kind Source Purpose
ThemeAdapter (alias InkThemeAdapter) dataclass theme_adapter.py Role/color resolver + Rich painters; color/background/dim/muted/role/role_background return rich.text.Text, the *_style variants return rich.style.Style
create_theme_adapter function theme_adapter.py Build the adapter over a palette, seeded from DEFAULT_ROLE_KEYS and overlaid with role_overrides
to_textual_theme function theme_adapter.py A textual.theme.Theme exposing every palette key and the resolved roles as kebab-cased CSS vars like $diff-added-bg
to_pygments_style function theme_adapter.py A pygments Style subclass mapping syn* roles onto token families
theme_variable_defaults function theme_adapter.py Feeds App.get_theme_variable_defaults so the stylesheet parses
create_theme_bundle function theme_adapter.py Pack adapter + Textual theme + Pygments style

Role resolution is a role-key → fallback-key → text-fallback dict lookup (DEFAULT_ROLE_KEYS, ROLE_FALLBACK_KEYS). Degradation is deliberate: invalid colors are caught via Rich's ColorParseError, and non-hex colors simply drop out of the Pygments style.

from indusagi.react_ink import create_theme_bundle

bundle = create_theme_bundle("midnight", {
    "text": "#e5e5e7",
    "background": "#1e1e2e",
    "diffAddedBg": "#1b3a2a",
    "diffRemovedBg": "#3a1b1b",
})
app.register_theme(bundle.theme)            # textual.theme.Theme
text = bundle.adapter.role("assistant", "hello")   # rich.text.Text

Markdown stream

The markdown stack renders both settled history and live streaming turns.

Name Kind Source Purpose
MarkdownView class markdown/view.py textual.widgets.Markdown subclass with strikethrough disabled and prompt-XML scrubbing on update/append
PlainMarkdownFence class markdown/view.py Renders fenced code uncolored
StreamingMarkdown class markdown/streaming.py A MarkdownView that grows by streamed deltas over Textual's retained-mode MarkdownStream
create_markdown_parser (alias parser_factory) function markdown/format_token.py markdown-it parser factory (strikethrough off)
strip_prompt_xml_tags / split_partial_prompt_xml_tag function markdown/format_token.py Scrub prompt-XML tags; hold back a partial tag across deltas
CliHighlight / create_highlighter / highlight_by_path / language_from_path / token_type_to_role function markdown/highlight.py Pygments code highlighter mapping token families onto syn* theme roles
build_table_text / compute_column_widths / format_table_row / table_separator_row function markdown/table.py Plain-text markdown table renderer

A streaming assistant turn calls await write(delta) per chunk and await settle() once the turn ends. write prepends the held suffix from the previous delta so prompt-XML tags are always stripped whole, then enqueues the safe portion on the stream (which coalesces bursts internally — settled blocks never re-render). settle flushes any held suffix as literal text and stops the stream task; the widget keeps the full document as the permanent history entry. asyncio.CancelledError is never swallowed.

from indusagi.react_ink import StreamingMarkdown

view = StreamingMarkdown()
await view.mount()  # inside a Textual app
async for delta in channel:        # gateway text deltas
    await view.write(delta)
await view.settle()

Helpers has_markdown_syntax, get_list_number, pad_aligned, and string_width are pure; constants BLOCKQUOTE_BAR, PROMPT_XML_TAG_NAMES, and Alignment ship alongside.

Diff view

diff/ is a structured-patch engine plus its renderer.

Name Kind Source Purpose
build_structured_diff function diff/structured.py Build a classified, line-numbered, word-paired diff over difflib.SequenceMatcher; returns None when there is nothing to show
StructuredDiff / DiffHunk / DiffLine dataclass diff/structured.py The diff model; CONTEXT_LINES = 3
word_diff_line / WordSpan function diff/word_diff.py Intra-line word diff producing emphasis spans, suppressed above CHANGE_THRESHOLD = 0.4
DiffView class diff/view.py textual.widgets.Static that draws a StructuredDiff with themed +/- backgrounds, a line-number gutter, and word-level emphasis
build_diff_text / gutter_width / line_marker function diff/view.py Pure render helpers under DiffView

build_structured_diff(old_str, new_str, file_path="") walks the raw hunk lines into typed DiffLines carrying gutter line numbers, then pairs adjacent removed→added runs index-for-index and hands them to word_diff_line so the renderer emphasizes only the changed sub-range of each line. file_path is accepted for signature parity only. The diff engine uses difflib (Ratcliff-Obershelp) rather than a Myers diff, so hunk boundaries can differ on adversarial input; tests/react_ink/test_diff.py is the declared arbiter.

from indusagi.react_ink import build_structured_diff, DiffView

diff = build_structured_diff("line one\nold\n", "line one\nnew\n", "x.py")
if diff is not None:
    panel = DiffView(diff)   # mount in a Textual app

Message list and per-role widgets

The transcript is an anchored, append-only VerticalScroll reconciled by a stable entry key, so settled history never re-renders.

Name Kind Source Purpose
MessageList class components/messages/list.py The transcript widget; sync(...) reconciles mounted rows against a SessionSnapshot
group_messages / visible_entries / entry_keys function components/messages/list.py Group tool-calling assistant messages with their following tool results, merge display blocks, clamp
MessageEntry / DisplayBlockEntry dataclass components/messages/list.py Reconciliation entries
has_tool_calls / get_entry_timestamp function components/messages/list.py Predicate and timestamp sort key
create_message_row (alias MessageRow) function components/messages/row.py Dispatcher that builds the right per-role widget for one AgentMessage
AssistantMessageView / UserMessageView / BashMessageView class components/messages/*.py Per-role transcript widgets
BranchSummaryMessageView / CompactionMessageView / CustomMessageView class components/messages/*.py Branch, compaction, and host-custom message widgets
SkillInvocationMessage / ToolCallMessage / ToolResultBlock class components/messages/*.py Skill, tool-call, and tool-result widgets

group_messages/visible_entries/entry_keys group each tool-calling assistant message with its following toolResult run, merge UiDisplayBlocks, and sort by timestamp; MessageList.sync then reconciles mounted rows by stable key. create_message_row dispatches each AgentMessage by role (user / assistant / toolResult / bash / branch / compaction / custom) to the matching widget. Most widgets ship a pure build_*_text(message, theme) renderer returning Rich Text: build_user_message_text, build_bash_text (+bash_status), build_compaction_text, build_branch_summary_text, build_custom_text, build_skill_invocation_text.

Chrome widgets

Persistent UI surfaces driven by the snapshot.

Name Kind Source Purpose
TaskPanel class components/task_panel.py Live panel of in-flight tool executions keyed by tool_call_id
build_tool_event_lines / recent_tool_executions function components/task_panel.py Render and selection helpers
StatusLine class components/status_line.py Single persistent status line (Static)
derive_status function components/status_line.py Pure text/tone derivation from a SessionSnapshot + StatusMessage
Footer class components/footer.py Bottom stats bar (Static) with reactive snapshot/branch/provider-count inputs
footer_cost / format_tokens / shorten_path / chunk_lines / sanitize_status_text / to_display_text function components/footer.py Pure formatting helpers
ToolEventBlock class components/tool_event.py Generic tool-event card (Static) with status marker and line clamping
build_tool_event_text / status_marker / split_visible_lines / clamp_content_lines / plain_tool_text function components/tool_event.py Tool-event rendering; ClampedContent is a NamedTuple
ChangelogBlock / build_changelog_text class components/changelog.py Changelog rendering with render_inline / render_markdown_line
DisplayBlockView / display_block_view class components/display_block.py Dispatcher widget for host/extension-emitted blocks merged into the transcript

ToolCallMessage subclasses ToolEventBlock; TaskPanel keeps private status_marker/split_visible_lines/clamp_content_lines variants while the top-level package re-exports the tool_event originals.

The twelve dialogs

All dialogs are Textual ModalScreens pushed via app.push_screen and resolved with dismiss(result). DialogFrame and SelectableDialog are the reusable base; WrapCursorMixin gives wrap-around cursors. The dialogs package uses lazy importlib re-export so importing the group-A pickers never pulls in the group-B auth dialogs.

Name Kind Source Purpose
DialogFrame class components/dialogs/frame.py Shared bordered modal frame
SelectableDialog / WrappingOptionList / WrapCursorMixin class components/dialogs/selectable.py Generic searchable single-select picker with wrap-around cursor
ModelDialog class components/dialogs/model.py Model picker; helpers matches_model, model_search_text
ThemeDialog class components/dialogs/theme.py Theme picker; ThemeDialogItem, to_item
SettingsDialog class components/dialogs/settings.py Settings editor; SettingsDialogItem, get_next_value
ScopedModelsDialog class components/dialogs/scoped_models.py Per-scope model selection; model_key
LoginDialog class components/dialogs/login.py API-key/provider login; LoginDialogMode, LoginDialogResult, login_provider_options, saved_account_options
OAuthDialog class components/dialogs/oauth.py OAuth flow; OAuthFlow, OAuthDialogResult, AuthInputPrompt, AuthRedirectInfo
SessionDialog class components/dialogs/session.py Session browser; VISIBLE_SESSION_COUNT, build_session_metadata
StartupSessionPicker class components/dialogs/startup_picker.py Boot-time session chooser
TreeDialog class components/dialogs/tree.py Session-tree navigator
UserMessageDialog class components/dialogs/user_message.py Queued-message picker

ModelDialog, ThemeDialog, SettingsDialog, and ScopedModelsDialog are the group-A pickers built on SelectableDialog; LoginDialog, OAuthDialog, SessionDialog, StartupSessionPicker, TreeDialog, and UserMessageDialog are the group-B auth/navigation modals.

from indusagi.react_ink import ModelDialog

chosen = await app.push_screen_wait(ModelDialog(snapshot.model))
if chosen is not None:
    ...  # the dialog resolves via dismiss(result)

Pure helpers

Framework-free logic in utils/. These feed the widgets and dialogs.

Name Kind Source Purpose
describe_tool_call / describe_tool_result / describe_tool_execution / describe_tool_source function utils/tool_display.py Turn a tool call/result into a renderable ToolCardDescriptor (title, summary, body, optional StructuredDiff)
split_assistant_message / format_user_content / format_tool_call / format_tool_result / format_custom_content function utils/message_groups.py Message dissection feeding the per-role widgets
extract_tool_text / extract_message_preview / parse_skill_invocation / preview_text / preview_multiline / safe_stringify / format_message_timestamp function utils/message_groups.py Preview and stringification helpers
matches_search_query / wrap_selection_index function utils/selection_dialog.py AND-term case-insensitive search match and modular cursor wrap used by SelectableDialog
filter_and_sort_sessions / parse_session_search_query / format_session_age / format_session_size / shrink_session_id function utils/session_browser.py The session-browser fuzzy search/sort engine (via indusagi.react_ink.utils, not the top-level package)

A ToolCardDescriptor can carry a StructuredDiff, which DiffView draws. The session-browser names (ParsedSearchQuery, SearchToken, SessionFilterResult, SessionBrowserProgress, and the functions above) are exported from indusagi.react_ink.utils rather than the package root.

Relationship to neighbors

react_ink is the presentation layer of the rebuild. It depends on Agent for AgentMessage, ToolCall, and the role-specific message types, and on AI for Model, the auth prompt types, and the model catalog that backs ModelDialog. It is driven by the UI Bridge, which builds a SessionSnapshot per agent event and pushes the dialogs onto the Shell App. The theme adapter registers its Theme and Pygments style with the Textual app and shares the host-palette layer described in TUI. session_browser backs SessionDialog/StartupSessionPicker against the Runtime sessions subsystem, and the login/oauth dialogs drive the auth subsystem.

Back to the Architecture overview.