GitHub Pull Requests
This tutorial builds a GitHub pull request viewer with an async data table, detail modal with markdown rendering, and shimmer animations — showcasing Tachikoma's async task system.
What We'll Build
A DataTable showing open pull requests for a GitHub repository, fetched asynchronously. Press Enter to open a detail modal with the PR body rendered as markdown. The modal border uses border_shimmer! for visual polish.
<!– tachi:begin github_prs_app –>
Step 1: Data Types and Mock Data
using Tachikoma
struct PullRequest
number::Int
title::String
url::String
author::String
body::String
state::String
created::String
updated::String
labels::Vector{String}
end
@enum LoadingState Idle Loading Loaded ErrorState
short_date(s::String) = length(s) >= 10 ? s[1:10] : sWe define a PullRequest struct to hold all the fields we care about. The LoadingState enum tracks where we are in the fetch lifecycle: Idle before any request, Loading during the async task, Loaded when data arrives, and ErrorState if the fetch fails. Note we use ErrorState instead of Error to avoid shadowing Base.error.
In a real app you would call the GitHub API. For this tutorial we use mock data so the demo runs without network access:
MOCK_PRS = [
PullRequest(142, "Add WebSocket support for live updates", "",
"motoko",
"## Summary\nImplements WebSocket transport layer.\n\n- Handles reconnection\n- Binary frame support\n- Heartbeat mechanism",
"open", "2025-01-15", "2025-01-18", ["enhancement", "networking"]),
PullRequest(139, "Fix memory leak in connection pool", "",
"batou",
"## Problem\nConnections not released on timeout.\n\n## Fix\nAdded finalizer to pool entries.",
"open", "2025-01-14", "2025-01-17", ["bug", "critical"]),
PullRequest(137, "Refactor auth middleware stack", "",
"kusanagi",
"## Changes\nUnified OAuth and API-key paths.\n\n- Single `authenticate()` entry point\n- Token refresh handled transparently",
"open", "2025-01-13", "2025-01-16", ["refactor"]),
PullRequest(135, "Add dark mode to dashboard", "",
"togusa",
"## Overview\nCSS variables for theme switching.\n\n- Respects `prefers-color-scheme`\n- Manual toggle in settings",
"open", "2025-01-12", "2025-01-15", ["enhancement", "ui"]),
PullRequest(133, "Upgrade TLS to 1.3 across services", "",
"ishikawa",
"## Security\nForce TLS 1.3 minimum.\n\n- Updated certificate chain\n- Removed legacy cipher suites",
"open", "2025-01-11", "2025-01-14", ["security"]),
PullRequest(131, "Add rate limiting to public API", "",
"saito",
"## Implementation\nToken bucket algorithm.\n\n- 100 req/min default\n- Configurable per-route\n- Returns `Retry-After` header",
"open", "2025-01-10", "2025-01-13", ["enhancement", "api"]),
]Step 2: Building the DataTable
function build_table(prs)
DataTable([
DataColumn("#", [pr.number for pr in prs]; width=6, align=col_right),
DataColumn("Author", [pr.author for pr in prs]; width=16),
DataColumn("Title", [pr.title for pr in prs]),
DataColumn("Updated", [short_date(pr.updated) for pr in prs]; width=10),
];
selected = 1,
block = Block(title="Pull Requests"),
)
endDataTable handles keyboard navigation, column layout, and alternating row styles. Each DataColumn has a header, data vector, and optional width and alignment. The selected field tracks which row has keyboard focus.
Step 3: Define the Model
@kwdef mutable struct PRModel <: Model
quit::Bool = false
tick::Int = 0
pull_requests::Vector{PullRequest} = PullRequest[]
loading::LoadingState = Idle
table::DataTable = DataTable([DataColumn("", String[])]; selected=1)
tasks::TaskQueue = TaskQueue()
detail_idx::Int = 0
detail_pane::MarkdownPane = MarkdownPane("")
error_msg::String = ""
end
should_quit(m::PRModel) = m.quit
task_queue(m::PRModel) = m.tasksKey fields:
tasksis theTaskQueuethat Tachikoma drains each framedetail_idxis zero when no modal is showing; set it to a row index to open the detail viewdetail_paneis aMarkdownPanethat renders the PR body as styled markdown
Step 4: Async Data Fetching
function init!(m::PRModel, ::Terminal)
m.loading = Loading
spawn_task!(m.tasks, :pulls) do
sleep(0.05) # simulate network latency
MOCK_PRS
end
endThe init! callback runs once when the app starts. We spawn a background task that returns our mock data. In a real app you would replace the body with an HTTP call to the GitHub API — the async pattern is identical.
When the task completes, Tachikoma delivers a TaskEvent on the main thread:
function update!(m::PRModel, evt::TaskEvent)
evt.id == :pulls || return
if evt.value isa Exception
m.loading = ErrorState
m.error_msg = sprint(showerror, evt.value)
else
m.loading = Loaded
m.pull_requests = evt.value
m.table = build_table(m.pull_requests)
end
endIf the task returned an exception, we switch to ErrorState and store the message. Otherwise we store the pull requests and build the DataTable.
Step 5: Handle Keyboard Events
function update!(m::PRModel, evt::KeyEvent)
if m.detail_idx > 0
# Modal is open — Escape closes it, arrows scroll the markdown
if evt.key == :escape
m.detail_idx = 0
else
handle_key!(m.detail_pane, evt)
end
return
end
# No modal — handle table navigation and app keys
if evt.key == :escape
m.quit = true
elseif evt.key == :enter && m.loading == Loaded
idx = m.table.selected
if 1 <= idx <= length(m.pull_requests)
m.detail_idx = idx
pr = m.pull_requests[idx]
m.detail_pane = MarkdownPane(pr.body;
block=Block(title="$(pr.title)",
border_style=tstyle(:border),
title_style=tstyle(:title, bold=true)))
end
else
handle_key!(m.table, evt)
end
endWhen the modal is open, Escape closes it and arrow keys scroll the MarkdownPane. When the modal is closed, Enter opens the detail view for the selected PR, and all other navigation keys are forwarded to the DataTable via handle_key!.
Step 6: Render Helpers
The body area dispatches on LoadingState:
function render_body(m, buf, area)
if m.loading == Loading
si = mod1(m.tick ÷ 3, length(SPINNER_BRAILLE))
msg = " $(SPINNER_BRAILLE[si]) Fetching pull requests..."
tx = area.x + max(0, (area.width - length(msg)) ÷ 2)
ty = area.y + area.height ÷ 2
set_string!(buf, tx, ty, msg, tstyle(:text_dim))
elseif m.loading == ErrorState
render(Paragraph(m.error_msg; style=tstyle(:error),
block=Block(title="Error")), area, buf)
else
render(m.table, area, buf)
end
endDuring Loading we show a centered braille spinner. On ErrorState we render the error message in a Paragraph. Once Loaded, we render the DataTable.
The detail modal overlays the full screen with an animated border:
function render_detail_modal(m, buf, area)
pr = m.pull_requests[m.detail_idx]
# Dim the background
for row in area.y:bottom(area)
for col in area.x:right(area)
set_char!(buf, col, row, ' ', tstyle(:text_dim))
end
end
# Center modal at ~80% of screen
mw = clamp(area.width * 4 ÷ 5, 40, area.width - 2)
mh = clamp(area.height * 3 ÷ 4, 12, area.height - 2)
modal_rect = center(area, mw, mh)
# Animated border
border_shimmer!(buf, modal_rect, to_rgb(theme().accent), m.tick; intensity=0.10)
inner = shrink(modal_rect, 1)
inner.height < 4 && return
# Metadata header
y = inner.y
set_string!(buf, inner.x, y, "#$(pr.number)", tstyle(:accent, bold=true);
max_x=right(inner))
set_string!(buf, inner.x + length("#$(pr.number)") + 1, y,
"by $(pr.author)", tstyle(:text_dim); max_x=right(inner))
y += 1
if !isempty(pr.labels)
label_str = join(pr.labels, " · ")
set_string!(buf, inner.x, y, label_str, tstyle(:primary); max_x=right(inner))
y += 1
end
y += 1
# Scrollable markdown body
body_area = Rect(inner.x, y, inner.width, inner.height - (y - inner.y))
body_area.height > 0 && render(m.detail_pane, body_area, buf)
endThe modal dims the background, draws a border_shimmer! border, shows PR metadata (number, author, labels), and renders the body markdown in a scrollable MarkdownPane.
Step 7: The View
function view(m::PRModel, f::Frame)
m.tick += 1
buf = f.buffer
# Layout: header | body | status bar
rows = split_layout(Layout(Vertical, [Fixed(1), Fill(), Fixed(1)]), f.area)
length(rows) < 3 && return
# Header
si = mod1(m.tick ÷ 3, length(SPINNER_BRAILLE))
set_char!(buf, rows[1].x, rows[1].y, SPINNER_BRAILLE[si], tstyle(:accent))
set_string!(buf, rows[1].x + 2, rows[1].y,
"GitHub PRs $(DOT) $(f.area.width)×$(f.area.height)",
tstyle(:title, bold=true))
# Body
render_body(m, buf, rows[2])
# Status bar
if m.detail_idx > 0
render(StatusBar(
left=[Span(" [↑↓] scroll [Esc] close ", tstyle(:text_dim))],
right=[Span("PR #$(m.pull_requests[m.detail_idx].number) ", tstyle(:accent))],
), rows[3], buf)
else
render(StatusBar(
left=[Span(" [↑↓] navigate [Enter] details [Esc] quit ", tstyle(:text_dim))],
right=[Span("$(length(m.pull_requests)) PRs ", tstyle(:text_dim))],
), rows[3], buf)
end
# Modal overlay
if m.detail_idx > 0
render_detail_modal(m, buf, f.area)
end
endThe view has three rows: a header with a spinner, the body dispatched by render_body, and a context-sensitive status bar. When detail_idx > 0, the modal is drawn on top of everything.
Step 8: Run It
app(PRModel())
Key Techniques
TaskQueue+spawn_task!— non-blocking data fetch that keeps the UI responsiveTaskEventhandling — update the model when async work completes or failsDataTable— scrollable, navigable data display with configurable columnsMarkdownPane— rich CommonMark rendering in the terminal with keyboard scrollingborder_shimmer!— animated modal border driven by noise for organic visual polishModal overlay pattern — dim the background, center a rect, draw content on top
Exercises
Add column sorting by clicking headers
Add a search/filter input above the table
Add label-based filtering (show only PRs with specific labels)
Add refresh with automatic polling using a timer task