tui

package
v0.0.0-...-7143b1a Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 12, 2026 License: MIT Imports: 31 Imported by: 0

Documentation

Overview

Package tui implements the kata terminal UI built on Bubble Tea.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Run

func Run(ctx context.Context, opts Options) error

Run starts the TUI. Blocks until the user quits or ctx is cancelled. Returns nil on clean exit. Returns errNotATTY when stdin or the active output stream is not a terminal so callers can print a friendly message.

Types

type APIError

type APIError struct {
	Method, Path string
	Status       int
	Code         string
	Message      string
	Hint         string
}

APIError is the structured form of the §4.6 error envelope.

func (*APIError) Error

func (e *APIError) Error() string

Error returns "code: message[: hint: ...]" when the daemon supplied a structured envelope. When Code and Message are both empty (a 404 with no body, a 502 from a proxy, etc.) it falls back to a method+path+status summary so toasts stay actionable.

type ChildCounts

type ChildCounts struct {
	Open  int `json:"open"`
	Total int `json:"total"`
}

ChildCounts is the direct-child aggregate attached to queue/detail rows.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the typed adapter the TUI uses to talk to the daemon. Errors include the request method+path so toast messages stay actionable.

func NewClient

func NewClient(base string, hc *http.Client) *Client

NewClient wraps a pre-built *http.Client with a typed daemon adapter. base is the daemon URL — "http://kata.invalid" for unix-socket transport.

func (*Client) AddComment

func (c *Client) AddComment(
	ctx context.Context, projectID int64, ref, body, actor string,
) (*MutationResp, error)

AddComment appends a new comment to the issue.

func (*Client) AddLabel

func (c *Client) AddLabel(
	ctx context.Context, projectID int64, ref, label, actor string,
) (*MutationResp, error)

AddLabel attaches a label to the issue.

func (c *Client) AddLink(
	ctx context.Context, projectID int64, ref string, body LinkBody, actor string,
) (*MutationResp, error)

AddLink creates a typed link from this issue to body.ToRef. The daemon's CreateLinkRequest.Body is {actor, type, to_ref}; ToRef accepts a short_id, qualified short_id ("kata#abc4"), or a 26-char ULID.

func (*Client) Assign

func (c *Client) Assign(
	ctx context.Context, projectID int64, ref, owner, actor string,
) (*MutationResp, error)

Assign sets the issue owner. Empty owner routes to /actions/unassign because the daemon's PATCH endpoint cannot represent the clear case (string vs null) and /actions/assign rejects empty owners with 400.

func (*Client) Close

func (c *Client) Close(
	ctx context.Context, projectID int64, ref, actor string,
) (*MutationResp, error)

Close transitions the issue to status=closed. source=tui signals the daemon to skip the substance and evidence checks that gate CLI-side closes; the structural guards (parent-close completeness, sibling throttle, repeated-message) still apply.

func (*Client) CreateIssue

func (c *Client) CreateIssue(
	ctx context.Context, projectID int64, body CreateIssueBody,
) (*MutationResp, error)

CreateIssue posts a new issue. body.IdempotencyKey rides the Idempotency-Key header per spec §4.4 when non-empty.

func (*Client) EditBody

func (c *Client) EditBody(
	ctx context.Context, projectID int64, ref, body, actor string,
) (*MutationResp, error)

EditBody replaces issue.body via PATCH. v1 only supports body edits from the TUI; title edits would reuse the same endpoint.

func (*Client) GetIssueDetail

func (c *Client) GetIssueDetail(ctx context.Context, projectID int64, ref string) (*IssueDetail, error)

GetIssueDetail fetches a single issue plus hierarchy metadata by ref. ref is a short_id, qualified short_id, or UID — the daemon's path resolver picks the matching column.

func (*Client) ListAllIssues

func (c *Client) ListAllIssues(ctx context.Context, f ListFilter) ([]Issue, error)

ListAllIssues lists issues across every project. The daemon may not yet implement /api/v1/issues; in that case the request surfaces as a 404 APIError that callers can downgrade.

func (*Client) ListComments

func (c *Client) ListComments(
	ctx context.Context, projectID int64, ref string,
) ([]CommentEntry, error)

ListComments and ListLinks route through GET /issues/{ref} because the daemon embeds both slices there. ListEvents filters client-side because the poll endpoint accepts no issue-targeted query filter.

func (*Client) ListEvents

func (c *Client) ListEvents(ctx context.Context, projectID int64, ref string) ([]EventLogEntry, error)

ListEvents returns the events tab data for one issue. See above note on the client-side filter. ref is the issue's short_id; the daemon's event stream embeds issue_short_id on every issue-scoped event, so the filter is project-local and stable for the life of the daemon.

TODO(plan-6/task-8): the 200-event window is a one-shot snapshot; full pagination via next_after_id is deferred. The poll envelope's reset_required is decoded but ignored here because Task 8 fetches once per detail-view open; the SSE consumer (Task 11) handles reset_required for the long-lived stream.

func (*Client) ListIssues

func (c *Client) ListIssues(ctx context.Context, projectID int64, f ListFilter) ([]Issue, error)

ListIssues returns the issues for projectID filtered by f.

func (*Client) ListLabels

func (c *Client) ListLabels(ctx context.Context, projectID int64) ([]LabelCount, error)

ListLabels returns the per-label aggregate counts for projectID. The daemon's GET /api/v1/projects/{id}/labels endpoint backs the + suggestion menu; counts drive the "most-used first" sort.

func (c *Client) ListLinks(ctx context.Context, projectID int64, ref string) ([]LinkEntry, error)

ListLinks returns the links tab data for one issue.

func (*Client) ListProjects

func (c *Client) ListProjects(ctx context.Context) ([]ProjectSummary, error)

ListProjects returns the daemon's known projects.

func (*Client) ListProjectsWithStats

func (c *Client) ListProjectsWithStats(ctx context.Context) ([]ProjectSummaryWithStats, error)

ListProjectsWithStats returns every active project with per-project aggregates {open, closed, last_event_at} populated. Used by the projects view. Spec §7.3.

func (*Client) RemoveLabel

func (c *Client) RemoveLabel(
	ctx context.Context, projectID int64, ref, label, actor string,
) (*MutationResp, error)

RemoveLabel sends actor in the query string because DELETE bodies are non-portable; the label is path-escaped to survive '/' and similar.

func (c *Client) RemoveLink(
	ctx context.Context, projectID int64, ref string, linkID int64, actor string,
) (*MutationResp, error)

RemoveLink deletes a link by id. actor rides the query string per the DELETE-body portability convention.

func (*Client) Reopen

func (c *Client) Reopen(
	ctx context.Context, projectID int64, ref, actor string,
) (*MutationResp, error)

Reopen transitions the issue back to status=open.

func (*Client) ResolveProject

func (c *Client) ResolveProject(ctx context.Context, startPath string) (*ResolveResp, error)

ResolveProject runs the §4.2 resolution flow against startPath.

Wire shape is chosen client-side so the daemon never has to stat the client's filesystem (issue #35): {name, alias?} when .kata.toml is readable, {alias} for a git workspace without .kata.toml, and {start_path} as a legacy local-only fallback. When the daemon returns a canonical name that differs from the local .kata.toml, the client rewrites the file in place.

func (*Client) SetPriority

func (c *Client) SetPriority(
	ctx context.Context, projectID int64, ref string, priority *int64, actor string,
) (*MutationResp, error)

SetPriority sends the issue's priority through /actions/priority. A nil priority clears the field. Mirrors Assign's pattern of routing the optional/clear case through the same endpoint with a nil body field; the daemon distinguishes set-vs-clear from the JSON shape.

type CommentEntry

type CommentEntry struct {
	ID        int64     `json:"id"`
	Author    string    `json:"author"`
	Body      string    `json:"body"`
	CreatedAt time.Time `json:"created_at"`
}

CommentEntry is the per-comment projection rendered in the comments tab.

type CreateInitialLinkBody

type CreateInitialLinkBody struct {
	Type     string `json:"type"`
	ToRef    string `json:"to_ref"`
	Incoming bool   `json:"incoming,omitempty"`
}

CreateInitialLinkBody requests a link created atomically with a new issue. ToRef is a short_id, qualified short_id ("kata#abc4"), or a 26-char ULID; the daemon resolves it to the target issue at request time.

Incoming reverses direction for type=blocks: when true, the new issue is the link's "to" side (i.e. the new issue is BLOCKED BY the named target — the `kata create --blocked-by N` shape). Default (false) leaves the new issue as the link's "from" side (`--blocks N`). Rejected by the daemon for type=parent (no inverse parent direction is exposed) and meaningless for type=related (which is symmetric).

type CreateIssueBody

type CreateIssueBody struct {
	Title          string                  `json:"title"`
	Body           string                  `json:"body,omitempty"`
	Actor          string                  `json:"actor"`
	Owner          *string                 `json:"owner,omitempty"`
	Labels         []string                `json:"labels,omitempty"`
	Links          []CreateInitialLinkBody `json:"links,omitempty"`
	ForceNew       bool                    `json:"force_new,omitempty"`
	IdempotencyKey string                  `json:"-"`
}

CreateIssueBody is the input to CreateIssue. IdempotencyKey rides the Idempotency-Key header per spec §4.4 instead of the JSON body.

Owner and Labels are populated by the new-issue form (Plan 8 commit 4) when the user fills in the optional fields; they are omitted from the payload when zero so an inline-row commit does not promise the daemon fields it has no value for.

type EventEnvelope

type EventEnvelope struct {
	ID              int64     `json:"id"`
	Type            string    `json:"type"`
	ProjectUID      string    `json:"project_uid,omitempty"`
	IssueUID        string    `json:"issue_uid,omitempty"`
	IssueShortID    *string   `json:"issue_short_id,omitempty"`
	RelatedIssueUID string    `json:"related_issue_uid,omitempty"`
	CreatedAt       time.Time `json:"created_at"`
}

EventEnvelope is the minimal event projection embedded in mutation responses. The richer poll/SSE shape uses EventLogEntry.

type EventLogEntry

type EventLogEntry struct {
	ID                  int64          `json:"event_id"`
	Type                string         `json:"type"`
	Actor               string         `json:"actor"`
	ProjectUID          string         `json:"project_uid,omitempty"`
	IssueUID            string         `json:"issue_uid,omitempty"`
	IssueShortID        *string        `json:"issue_short_id,omitempty"`
	RelatedIssueUID     string         `json:"related_issue_uid,omitempty"`
	RelatedIssueShortID *string        `json:"related_issue_short_id,omitempty"`
	CreatedAt           time.Time      `json:"created_at"`
	Payload             map[string]any `json:"payload,omitempty"`
}

EventLogEntry is the per-event projection used by the events tab. IssueShortID is the project-scoped display ref for the subject issue; IssueUID is the canonical reference and survives short_id cutovers.

type Issue

type Issue struct {
	ID            int64        `json:"id"`
	UID           string       `json:"uid,omitempty"`
	ProjectID     int64        `json:"project_id"`
	ProjectUID    string       `json:"project_uid,omitempty"`
	ShortID       string       `json:"short_id"`
	QualifiedID   string       `json:"qualified_id,omitempty"`
	Title         string       `json:"title"`
	Body          string       `json:"body"`
	Status        string       `json:"status"`
	ClosedReason  *string      `json:"closed_reason,omitempty"`
	Owner         *string      `json:"owner,omitempty"`
	Author        string       `json:"author"`
	CreatedAt     time.Time    `json:"created_at"`
	UpdatedAt     time.Time    `json:"updated_at"`
	ClosedAt      *time.Time   `json:"closed_at,omitempty"`
	DeletedAt     *time.Time   `json:"deleted_at,omitempty"`
	Labels        []string     `json:"labels,omitempty"`
	ParentShortID *string      `json:"parent_short_id,omitempty"`
	ChildCounts   *ChildCounts `json:"child_counts,omitempty"`
	Blocks        []LinkPeer   `json:"blocks,omitempty"`
	BlockedBy     []LinkPeer   `json:"blocked_by,omitempty"`
	Related       []LinkPeer   `json:"related,omitempty"`
	Priority      *int64       `json:"priority,omitempty"`
}

Issue is a strict subset of the daemon's wire shape. Labels rides on list-row decode (the daemon embeds them per row) and on a manual copy from showIssue's body.labels for detail open; the omitempty tag keeps absence on a show response from blanking a previously-populated slice. The deleted bool is derived from DeletedAt being non-nil.

UID is the canonical reference; ShortID is the per-project display snapshot (4+ chars of the ULID suffix, per spec §4); QualifiedID is the human-facing "<project>#<short_id>" form populated on list rows (api.IssueOut.QualifiedID).

type IssueDetail

type IssueDetail struct {
	Issue    *Issue
	Parent   *IssueRef
	Children []Issue
}

IssueDetail is the hydrated detail payload used by the TUI detail view.

type IssueRef

type IssueRef struct {
	UID         string `json:"uid"`
	ShortID     string `json:"short_id"`
	QualifiedID string `json:"qualified_id"`
	Title       string `json:"title"`
	Status      string `json:"status"`
}

IssueRef is the compact parent issue projection on detail responses. Mirrors api.IssueRef: UID is canonical, ShortID and QualifiedID are display projections rendered at the API boundary.

type KataAPI

type KataAPI interface {
	ListProjects(ctx context.Context) ([]ProjectSummary, error)
	ListProjectsWithStats(ctx context.Context) ([]ProjectSummaryWithStats, error)
	ListLabels(ctx context.Context, projectID int64) ([]LabelCount, error)
	ResolveProject(ctx context.Context, startPath string) (*ResolveResp, error)
	// contains filtered or unexported methods
}

KataAPI is the daemon surface the TUI consumes. It is owned by this package (the consumer) and covers exactly the methods the TUI calls today — no speculative future-proofing for a remote engine. When that engine lands it will satisfy this interface structurally and the surface can grow alongside concrete need.

The narrower listAPI / detailAPI / labelLister interfaces continue to type the call sites that only need a slice of the surface. KataAPI is the union held by Model.api so a single value can be passed to those narrower seams.

type LabelCount

type LabelCount struct {
	Label string `json:"label"`
	Count int64  `json:"count"`
}

LabelCount mirrors the daemon's LabelsListResponse.Body.Labels wire shape (db.LabelCount). Local definition keeps the TUI free of an internal/db import — the package boundary stays at the wire layer.

type LinkBody

type LinkBody struct {
	Type  string `json:"type"`
	ToRef string `json:"to_ref"`
}

LinkBody is the request projection for POST /links. ToRef accepts any shape the daemon's resolver understands (short_id, qualified, or 26-char ULID).

type LinkEntry

type LinkEntry struct {
	ID        int64     `json:"id"`
	Type      string    `json:"type"`
	From      LinkPeer  `json:"from"`
	To        LinkPeer  `json:"to"`
	Author    string    `json:"author"`
	CreatedAt time.Time `json:"created_at"`
}

LinkEntry mirrors api.LinkOut: each endpoint is a LinkPeer (UID + short_id) so the TUI can correlate either by canonical ID or by display snapshot.

type LinkPeer

type LinkPeer struct {
	UID     string `json:"uid"`
	ShortID string `json:"short_id"`
}

LinkPeer mirrors api.LinkPeer: the canonical UID plus the rendered short_id snapshot for one end of a link.

type ListFilter

type ListFilter struct {
	Status, Owner, Author, Search string
	Labels                        []string
	Limit                         int
}

ListFilter is the union of filters used by list views. Limit is sent on the wire for capped working-set fetches. Status/Owner/Author/ Labels/Search are applied client-side after the daemon returns results.

IncludeDeleted is deliberately absent: the daemon's list endpoint hard-codes deleted_at IS NULL (internal/db/queries.go::ListIssues) and has no include_deleted query param, so a client-side flag would promise something the wire cannot deliver. Re-introducing it requires daemon work and is deferred to a future task.

type Model

type Model struct {
	// contains filtered or unexported fields
}

Model is the top-level Bubble Tea model. Sub-views are embedded by value so Update can mutate them in place without indirection. The detail sub-view is held by value (not pointer) so its scroll/tab state lives across opens of the same issue, and so popDetailMsg returns to a list whose cursor and filters are unchanged.

SSE state lives on the parent model so the consumer goroutine has a fixed channel to push into and the detail/list sub-views can route invalidation. sseCh bridges the long-lived goroutine into the TEA loop via waitForSSE; sseStatus drives the status-bar reconnect indicator; pendingRefetch coalesces bursts of events into a single 150ms-debounced list refetch; cache holds the current list snapshot so a stale-mark + clean refetch can short-circuit redundant work.

toastNow is a clock injection point: production uses time.Now, tests replace it to drive deterministic toast expiry.

func (Model) Init

func (m Model) Init() tea.Cmd

Init dispatches the initial fetch unless boot landed on the empty state or no client is wired (the latter happens in unit tests that drive the model directly via teatest.NewTestModel and feed initialFetchMsg by hand). The list view sets loading=true at construction so the spinner shows until initialFetchMsg arrives.

waitForSSE is registered alongside fetchInitial so the SSE goroutine (spawned by Run after this Init returns) has a reader the moment its first frame is ready. The reader is replenished on every SSE message in Update so the channel is continuously drained.

func (Model) Update

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update routes messages to the active sub-view. Quit is handled at the top level so it works from every view, EXCEPT while a list-view inline prompt or a detail-view modal is active: typing 'q' into a prompt or modal must reach the buffer instead of quitting. The same gate applies to ?, R, and any future global key.

openDetailMsg / popDetailMsg are intercepted before the per-view dispatch because the view switch lives at this level. The detail sub-model is reset on open so a new issue starts at scroll=0 with the comments tab — but the list sub-model is untouched on pop, preserving the user's cursor and filter state across the round trip.

func (Model) View

func (m Model) View() string

View returns the rendered string for the active sub-view. The list view consumes its own SSE state + toast inline (via the M1 chrome); other views still get the SSE/toast extras appended below since they don't carry a status line of their own. Both extras render as empty strings in the steady state so the view does not gain spurious blank lines.

type MutationResp

type MutationResp struct {
	Issue   *Issue         `json:"issue"`
	Event   *EventEnvelope `json:"event,omitempty"`
	Changed bool           `json:"changed"`
	Reused  bool           `json:"reused,omitempty"`
}

MutationResp mirrors the §4.5 mutation envelope.

type Options

type Options struct {
	Stdout           io.Writer // typically os.Stdout
	Stderr           io.Writer // typically os.Stderr
	DisplayUIDFormat string    // none, short, or full
	Mouse            bool      // opt-in mouse capture and mouse-driven navigation
}

Options controls TUI behavior. Stable across versions; new fields must be optional.

IncludeDeleted is intentionally absent: the daemon's ListIssuesRequest (internal/api/types.go) does not accept include_deleted, and db.ListIssues hard-codes deleted_at IS NULL, so there is no way for the TUI to surface soft-deleted rows today. Re-introducing the flag is deferred to a follow-up that adds wire + handler support.

AllProjects is intentionally absent from Options: the boot flow always starts in single-project mode (resolved from the cwd) or empty state, and users toggle to all-projects via the R binding at runtime. Adding a CLI flag is reasonable as a future ergonomic but isn't required for the navigation surface.

type ProjectStatsSummary

type ProjectStatsSummary struct {
	Open        int        `json:"open"`
	Closed      int        `json:"closed"`
	LastEventAt *time.Time `json:"last_event_at"`
}

ProjectStatsSummary is the per-project aggregate carried by /api/v1/projects?include=stats. LastEventAt is nil for a project with zero events. Spec §7.2.

type ProjectSummary

type ProjectSummary struct {
	ID   int64  `json:"id"`
	Name string `json:"name"`
}

ProjectSummary is one row of GET /projects.

type ProjectSummaryWithStats

type ProjectSummaryWithStats struct {
	ProjectSummary
	Stats *ProjectStatsSummary `json:"stats,omitempty"`
}

ProjectSummaryWithStats extends ProjectSummary with the stats triple. The boot project-name cache uses ProjectSummary; viewProjects uses this shape.

type ResolveResp

type ResolveResp struct {
	Project struct {
		ID   int64  `json:"id"`
		Name string `json:"name"`
	} `json:"project"`
	WorkspaceRoot string `json:"workspace_root"`
}

ResolveResp is the body of POST /projects/resolve.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL