10 Commits
Author SHA1 Message Date
ZacharyZhang-NY 2c29d3ecd5 Bump version to 0.1.2
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
2026-07-18 20:25:51 -04:00
ZacharyZhang-NY de534582c1 Drop the CI workflow; gates run locally, builds only on release tags 2026-07-18 20:25:51 -04:00
ZacharyZhang-NY 54c9177ce2 Texture the welcome moon with lunar maria
Small scattered mare spots: dark holes on the sunlit disc, faint gray
patches on the dark limb.
2026-07-18 20:25:51 -04:00
ZacharyZhang-NY 7bdf50a716 Bump version to 0.1.1
Release / build (aarch64-apple-darwin) (push) Waiting to run
Release / build (x86_64-apple-darwin) (push) Waiting to run
Release / build (aarch64-unknown-linux-gnu) (push) Waiting to run
Release / build (x86_64-pc-windows-msvc) (push) Waiting to run
Release / publish GitHub Release (push) Blocked by required conditions
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 7s
Fix round on top of v0.1.0: installers persist PATH themselves,
install.ps1 strict-mode crash fixed, /feedback opens GitHub issues,
moon-phase waiting spinner, Changelog feature excised.
2026-07-18 12:33:48 -04:00
ZacharyZhang-NY 10d0c0816f Excise the Changelog feature
Kigi never publishes CDN changelogs, so the entire inherited feature
was dead weight: the welcome-menu Changelog row, the hero-box info
slot (bullets + clickable CTA), /release-notes with its /changelog
alias, and the ChangelogManager CDN-fetch/disk-cache pipeline
(Effect::FetchChangelog, TaskResult::ChangelogFetched, startup and
post-login fetch kickoffs, AppView cache fields, mouse hover/click
handling). Welcome menu is now [Import] / New worktree / Resume
session / Quit; the hero box keeps title + version + subtitle and its
layout math simplifies to 3 + menu rows (verified equivalent by the
surviving boundary tests). Action::ShowReleaseNotes and the DocViewer
modal stay — /docs uses them. builtin.rs keeps deleting stale
CHANGELOG.{json,md} caches written by kigi ≤ 0.1.0.

kigi-shell-base drops its reqwest 'blocking' feature (only the deleted
module used it). -1206 lines net.

Gates: fmt clean, workspace check/clippy --all-targets 0/0, kigi-tui
lib 6609 / shell-base 56 / shell util:: 258 all passing, welcome pty
e2e (3 tests incl. braille logo) passing.
2026-07-18 12:31:52 -04:00
ZacharyZhang-NY 6e26d15428 Installers persist PATH themselves instead of printing instructions
install.sh: append the export line to the login shell's rc file
(zsh: $ZDOTDIR/.zshrc; bash: ~/.bash_profile on macOS, ~/.bashrc on
Linux; fish: fish_add_path in config.fish; otherwise ~/.profile).
Idempotent — a second run detects the existing entry and skips. On an
unwritable rc it fails loudly with the manual command.

install.ps1: prepend the bin dir to the per-user PATH via
[Environment]::SetEnvironmentVariable(..., 'User') — registry-backed,
picked up by every new terminal. Strict-mode-safe for a null user Path.

Verified end-to-end on macOS with a fake HOME: install → rc appended →
fresh zsh sources it → kigi resolves and runs. All shell branches,
ZDOTDIR/XDG overrides, idempotency, and the write-failure path covered
by a harness driving the real script tail; install.ps1 parse-checked
and its path-construction logic exercised under pwsh strict mode.
2026-07-18 12:07:57 -04:00
ZacharyZhang-NY 82ebcc38b7 Turn status: moon-phase spinner for Waiting for response
The active-turn spinner next to "Waiting for response…" now cycles
the moon phases 🌑🌒🌓🌔🌕🌖🌗🌘 — the official kimi-cli's lunation
animation (rich's `moon` spinner) — matching the welcome-screen moon
logo. New glyphs::moon_spinner_frames() with the same legacy-ConHost
ASCII fallback as every other spinner set; emoji frames are 2 columns
and the status row already measures the rendered frame, so layout
adapts. The ambient "Starting session…" row and other spinners stay
braille.

Verified: kigi-tui lib 6621 + pager-render 963 tests green; real-binary
PTY e2e waiting_for_model_label_shows_before_first_token passes with
the new spinner rendering in a live vt.
2026-07-18 12:00:52 -04:00
ZacharyZhang-NY 0dc98a34a6 /feedback opens the Kigi GitHub issues page
Feedback about an unofficial community build belongs on its own issue
tracker, not Moonshot's feedback endpoint — and this mirrors the
official kimi-cli, whose /feedback opens its repo's issues page
(ISSUE_URL in ui/shell/slash.py). /feedback now returns
Action::OpenUrl(https://github.com/ZacharyZhang-NY/Kigi-CLI/issues),
the same battle-tested browser path /docs web uses.

The now-dead TUI text-feedback pipeline is excised: PromptInputMode::
Feedback (~ prefix composer mode), Action::{EnterFeedbackMode,
SendFeedback}, Effect::SendFeedback (the kigi/feedback ACP POST),
TaskResult::{FeedbackComplete,FeedbackFailed}, and their dispatchers.
The shell-side kigi/feedback ACP extension stays: it is protocol
surface for editor embeddings, OAuth-gated, and shared with kigi/btw.

Gates: workspace check/clippy --all-targets 0/0, fmt, kigi-tui lib
6621 passed / 0 failed.
2026-07-18 11:51:13 -04:00
ZacharyZhang-NY 0692198719 install.ps1: fix PropertyNotFoundStrict crash in the PATH check
Under the script's own Set-StrictMode -Version Latest, .Count on the
result of Where-Object throws when the filter matches nothing — which
is precisely the fresh-install case (bin dir not on PATH yet), so every
first-time Windows install ended with an error after an otherwise
successful install. Use -contains on the split arrays instead; no
member access on a possibly-null pipeline result.

Repro + fix verified under pwsh 7.5.2 with StrictMode Latest: old
expression reproduces the user's exact error, new one returns
False/True correctly for missing/present PATH entries.
2026-07-18 11:37:08 -04:00
ZacharyZhang-NY 77fd457627 install.sh: print the permanent PATH command for the user's shell
The post-install guidance previously showed a one-time `export PATH=…`
that dies with the terminal. Detect $SHELL and print the persistent
command instead: append to ~/.zshrc / ~/.bash_profile (macOS) /
~/.bashrc (Linux), fish_add_path for fish (already persistent via
universal variables), ~/.profile as the POSIX fallback.
2026-07-18 11:35:39 -04:00
36 changed files with 365 additions and 1597 deletions
-79
View File
@@ -1,79 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
jobs:
gates:
name: check / clippy / fmt / deny (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-14, ubuntu-24.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- uses: Swatinem/rust-cache@v2
- name: cargo fmt
run: cargo fmt --all --check
- name: cargo check
run: cargo check --workspace --all-targets --locked
- name: cargo clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Install cargo-deny
run: cargo install cargo-deny --locked
- name: cargo deny advisories
run: cargo deny check advisories
test:
name: test (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-14, ubuntu-24.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- uses: Swatinem/rust-cache@v2
- name: cargo test
run: cargo test --workspace --locked
perf:
name: performance budgets (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [macos-14, ubuntu-24.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- uses: Swatinem/rust-cache@v2
- name: Install hyperfine (macOS)
if: runner.os == 'macOS'
run: brew install hyperfine
- name: Install hyperfine (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y hyperfine
- name: Build release binaries
run: |
cargo build --release -p kigi-bin --locked
cargo build --release -p kigi-pager-pty-harness --bin pty-scenario --locked
- name: Enforce performance budgets
run: scripts/bench.sh target/release/kigi
Generated
+62 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-acp-lib" name = "kigi-acp-lib"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"async-trait", "async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-agent" name = "kigi-agent"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs 6.0.0", "dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-agent-lifecycle" name = "kigi-agent-lifecycle"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"tokio", "tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-auth" name = "kigi-auth"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"http 1.4.2", "http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-bin" name = "kigi-bin"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-chat-state" name = "kigi-chat-state"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"kigi-compaction", "kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-codebase-graph" name = "kigi-codebase-graph"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"ahash", "ahash",
"clap", "clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-compaction" name = "kigi-compaction"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-config" name = "kigi-config"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"base64", "base64",
"blake3", "blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-config-types" name = "kigi-config-types"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"indexmap", "indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-crash-handler" name = "kigi-crash-handler"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"libc", "libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-env" name = "kigi-env"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"tracing", "tracing",
"url", "url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-fast-worktree" name = "kigi-fast-worktree"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-file-utils" name = "kigi-file-utils"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"aws-config", "aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-fsnotify" name = "kigi-fsnotify"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"criterion", "criterion",
"dunce", "dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-gix-status" name = "kigi-gix-status"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"gix", "gix",
"kigi-test-utils", "kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-hooks" name = "kigi-hooks"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"kigi-config", "kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-hooks-plugins-types" name = "kigi-hooks-plugins-types"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-http" name = "kigi-http"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"kigi-auth", "kigi-auth",
"kigi-log", "kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-hunk-tracker" name = "kigi-hunk-tracker"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"dunce", "dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]] [[package]]
name = "kigi-interjection-core" name = "kigi-interjection-core"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "kigi-log" name = "kigi-log"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-markdown" name = "kigi-markdown"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"anstyle-lossy", "anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]] [[package]]
name = "kigi-markdown-core" name = "kigi-markdown-core"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"pulldown-cmark", "pulldown-cmark",
] ]
[[package]] [[package]]
name = "kigi-mcp" name = "kigi-mcp"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"async-trait", "async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-memory" name = "kigi-memory"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-mermaid" name = "kigi-mermaid"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"fontdb", "fontdb",
"image", "image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-models" name = "kigi-models"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"kigi-env", "kigi-env",
"serde", "serde",
@@ -5969,7 +5969,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-pager-minimal" name = "kigi-pager-minimal"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"chrono", "chrono",
"crossterm", "crossterm",
@@ -5986,7 +5986,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-pager-pty-harness" name = "kigi-pager-pty-harness"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"anyhow", "anyhow",
@@ -6011,7 +6011,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-pager-render" name = "kigi-pager-render"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anstyle", "anstyle",
@@ -6063,7 +6063,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-paths" name = "kigi-paths"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"camino", "camino",
"serde", "serde",
@@ -6073,7 +6073,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-prompt-queue" name = "kigi-prompt-queue"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
@@ -6081,7 +6081,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-proto-build" name = "kigi-proto-build"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pbjson-build", "pbjson-build",
@@ -6092,7 +6092,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-ratatui-inline" name = "kigi-ratatui-inline"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"ansi-width", "ansi-width",
"anstyle-parse 0.2.7", "anstyle-parse 0.2.7",
@@ -6109,7 +6109,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-ratatui-textarea" name = "kigi-ratatui-textarea"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -6130,7 +6130,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sampler" name = "kigi-sampler"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"async-openai", "async-openai",
"async-stream", "async-stream",
@@ -6153,7 +6153,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sampling-types" name = "kigi-sampling-types"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"assert_matches", "assert_matches",
"async-openai", "async-openai",
@@ -6169,7 +6169,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sandbox" name = "kigi-sandbox"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -6190,7 +6190,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-secrets" name = "kigi-secrets"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"regex", "regex",
"serde_json", "serde_json",
@@ -6228,7 +6228,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-shell" name = "kigi-shell"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anyhow", "anyhow",
@@ -6365,7 +6365,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-shell-base" name = "kigi-shell-base"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -6390,7 +6390,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-sqlite-journal" name = "kigi-sqlite-journal"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"libc", "libc",
"rusqlite", "rusqlite",
@@ -6401,7 +6401,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-subagent-resolution" name = "kigi-subagent-resolution"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"kigi-sampling-types", "kigi-sampling-types",
"kigi-tool-types", "kigi-tool-types",
@@ -6416,7 +6416,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-system-power" name = "kigi-system-power"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
"zbus", "zbus",
@@ -6424,7 +6424,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-test-support" name = "kigi-test-support"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anyhow", "anyhow",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-test-utils" name = "kigi-test-utils"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"runfiles", "runfiles",
"tracing", "tracing",
@@ -6455,11 +6455,11 @@ dependencies = [
[[package]] [[package]]
name = "kigi-token-estimation" name = "kigi-token-estimation"
version = "0.1.0" version = "0.1.2"
[[package]] [[package]]
name = "kigi-tool-protocol" name = "kigi-tool-protocol"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"kigi-tool-types", "kigi-tool-types",
"serde", "serde",
@@ -6470,7 +6470,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tool-runtime" name = "kigi-tool-runtime"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tool-types" name = "kigi-tool-types"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"minijinja", "minijinja",
"schemars 1.2.1", "schemars 1.2.1",
@@ -6498,7 +6498,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tools" name = "kigi-tools"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -6575,7 +6575,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tools-api" name = "kigi-tools-api"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"kigi-proto-build", "kigi-proto-build",
"kigi-tool-protocol", "kigi-tool-protocol",
@@ -6588,11 +6588,11 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tracing-macros" name = "kigi-tracing-macros"
version = "0.1.0" version = "0.1.2"
[[package]] [[package]]
name = "kigi-tty-utils" name = "kigi-tty-utils"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"libc", "libc",
"nix 0.30.1", "nix 0.30.1",
@@ -6602,7 +6602,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-tui" name = "kigi-tui"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"ansi-to-tui", "ansi-to-tui",
@@ -6689,7 +6689,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-update" name = "kigi-update"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dunce", "dunce",
@@ -6718,14 +6718,14 @@ dependencies = [
[[package]] [[package]]
name = "kigi-version" name = "kigi-version"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"semver", "semver",
] ]
[[package]] [[package]]
name = "kigi-workspace" name = "kigi-workspace"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"agent-client-protocol", "agent-client-protocol",
"anyhow", "anyhow",
@@ -6804,7 +6804,7 @@ dependencies = [
[[package]] [[package]]
name = "kigi-workspace-types" name = "kigi-workspace-types"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"base64", "base64",
"chrono", "chrono",
@@ -8838,7 +8838,7 @@ dependencies = [
[[package]] [[package]]
name = "ptyctl" name = "ptyctl"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"alacritty_terminal", "alacritty_terminal",
"anyhow", "anyhow",
@@ -8856,7 +8856,7 @@ dependencies = [
[[package]] [[package]]
name = "ptyctl-cli" name = "ptyctl-cli"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.2"
edition = "2024" edition = "2024"
license = "Apache-2.0" license = "Apache-2.0"
+1 -1
View File
@@ -43,7 +43,7 @@ irm https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/install.ps1
``` ```
```sh ```sh
kigi --version # kigi 0.1.0 … unofficial Kimi Code CLI community build kigi --version # kigi 0.1.1 … unofficial Kimi Code CLI community build
kigi login # sign in with your Kimi Code subscription (device-code flow) kigi login # sign in with your Kimi Code subscription (device-code flow)
kigi # start the TUI kigi # start the TUI
``` ```
+34 -4
View File
@@ -218,10 +218,12 @@ pub fn diamond_hollow_char() -> char {
/// 1-column ASCII spinner (`|`, `/`, `-`, `\`) on legacy ConHost. /// 1-column ASCII spinner (`|`, `/`, `-`, `\`) on legacy ConHost.
/// ///
/// The U+2800 Braille Patterns block is not part of CP437 and renders as /// The U+2800 Braille Patterns block is not part of CP437 and renders as
/// tofu on the legacy console raster font, so the turn-status line, the /// tofu on the legacy console raster font, so the starting-session row,
/// MCP-connecting chip, the image-viewer loader, and the `/btw` overlay /// the MCP-connecting chip, the image-viewer loader, and the `/btw`
/// all fall back to the classic ASCII spinner there. Every frame in both /// overlay all fall back to the classic ASCII spinner there. Every frame
/// sets is exactly 1 column so the surrounding layout never shifts. /// in both sets is exactly 1 column so the surrounding layout never
/// shifts. (The turn-status "Waiting for response…" line uses
/// [`moon_spinner_frames`] instead.)
pub fn braille_spinner_frames() -> &'static [&'static str] { pub fn braille_spinner_frames() -> &'static [&'static str] {
const FANCY: &[&str] = &[ const FANCY: &[&str] = &[
"\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}", "\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}",
@@ -235,6 +237,34 @@ pub fn braille_spinner_frames() -> &'static [&'static str] {
} }
} }
/// Moon-phase spinner frames (`🌑🌒🌓🌔🌕🌖🌗🌘`) normally; the ASCII
/// spinner on legacy ConHost.
///
/// The turn-status "Waiting for response…" line reuses the official
/// kimi-cli's lunation animation (rich's `moon` spinner) — one full
/// lunation per cycle, matching the welcome-screen moon logo. Emoji
/// frames are 2 columns wide; the caller measures the rendered frame
/// (`spinner_str.width()`) so the layout adapts. Legacy ConHost's raster
/// font has no emoji, so it falls back to the 1-column ASCII spinner.
pub fn moon_spinner_frames() -> &'static [&'static str] {
const FANCY: &[&str] = &[
"\u{1f311}",
"\u{1f312}",
"\u{1f313}",
"\u{1f314}",
"\u{1f315}",
"\u{1f316}",
"\u{1f317}",
"\u{1f318}",
];
const FALLBACK: &[&str] = &["|", "/", "-", "\\"];
if is_legacy_windows_console() {
FALLBACK
} else {
FANCY
}
}
/// Pulsing dot progress-spinner frames (`⋅ : ⸬ ⁙`) normally; a quiet /// Pulsing dot progress-spinner frames (`⋅ : ⸬ ⁙`) normally; a quiet
/// 1-column dot cycle (`.`, `:`, `·`) on legacy ConHost. /// 1-column dot cycle (`.`, `:`, `·`) on legacy ConHost.
/// ///
+1 -1
View File
@@ -13,7 +13,7 @@ default-bazel = []
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
reqwest = { workspace = true, features = ["blocking"] } reqwest = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@@ -1,362 +0,0 @@
//! Changelog fetching from CDN with local disk cache.
//!
//! Both markdown (`*.external.md`) and JSON (`*.external.json`) changelogs
//! are published per-version alongside the Kigi GitHub distribution.
//!
//! `ChangelogManager::fetch()` retrieves both formats in parallel and
//! returns a `Changelog` with optional markdown + structured entries.
//! Consumers pick the format they need:
//! - `/release-notes` uses `changelog.markdown` for rich scrollback display
//! - Welcome screen uses `changelog.entries` for bullet rendering
use std::path::PathBuf;
/// Base URL for published changelogs. Kigi distributes via GitHub, so
/// per-version changelogs live in the release repository. Unreachable or
/// missing files degrade gracefully to the on-disk cache (see `fetch_with`).
const CHANGELOG_BASE: &str =
"https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main/changelogs";
const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// A single structured changelog entry from the published JSON changelog.
///
/// Shape must match the output of `render_external_json` in `changelog.sh`:
/// `{category, description, breaking_change}`
/// If you change fields here, update `changelog.sh:render_external_json` too.
///
/// All fields use `#[serde(default)]` so a single malformed entry doesn't
/// kill the entire array parse. Entries with an empty description are
/// filtered out by `bullets_from_entries`.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ChangelogEntry {
/// Category label (e.g. "features", "fixes", "breaking", "performance").
#[serde(default)]
pub category: String,
/// Human-readable description (may contain `**bold**` or backticks).
#[serde(default)]
pub description: String,
/// Whether this entry represents a breaking change.
#[serde(default)]
pub breaking_change: bool,
}
/// Both formats of a version's changelog, fetched together.
pub struct Changelog {
/// Rendered markdown (for `/release-notes` display).
pub markdown: Option<String>,
/// Structured entries (for welcome screen bullets).
pub entries: Option<Vec<ChangelogEntry>>,
}
/// Manages changelog retrieval from CDN with local disk caching.
///
/// Single entry point: `fetch()` returns both markdown and JSON in one
/// `Changelog` struct. Each format is fetched independently with its own
/// cache file, so a failure in one doesn't block the other.
pub struct ChangelogManager {
md_cache: PathBuf,
json_cache: PathBuf,
}
impl Default for ChangelogManager {
fn default() -> Self {
Self::new()
}
}
impl ChangelogManager {
pub fn new() -> Self {
// Prefer live `$KIGI_SHARE_DIR` so harness-injected homes (PTY e2e) always
// win over a OnceLock that may have been initialised earlier with a
// different path in the same process graph.
Self::from_env_home()
}
/// Resolve cache paths from the live process environment (not the
/// `kigi_home()` OnceLock). A seeded `$KIGI_SHARE_DIR` set on the pager
/// process is always honoured even if some earlier init path cached a
/// different home.
fn from_env_home() -> Self {
let home = std::env::var_os("KIGI_SHARE_DIR")
.map(std::path::PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(crate::util::kigi_home::kigi_home);
Self {
md_cache: home.join("CHANGELOG.md"),
json_cache: home.join("CHANGELOG.json"),
}
}
/// Fetch both markdown and JSON changelogs for the current version.
///
/// Each format is fetched independently (CDN, 3 s timeout) and cached
/// to disk. On failure, falls back to the cached copy. Either field
/// may be `None` if offline with no cache.
///
/// When `KIGI_CHANGELOG_OFFLINE` is set (PTY / integration tests), skip
/// the CDN entirely and read only the disk cache so seeded fixtures win
/// deterministically without network races. Paths are re-resolved from
/// `$KIGI_SHARE_DIR` so harness-injected env always applies.
///
/// JSON is only cached after a successful parse to avoid poisoning the
/// disk cache with malformed content (the markdown cache is write-through
/// since it's consumed as raw text).
pub fn fetch(&self) -> Changelog {
// Always re-resolve from env so a caller holding an older manager
// (or OnceLock lag) still reads the live harness home.
Self::from_env_home().fetch_with(changelog_offline(), CHANGELOG_BASE)
}
/// Fetch using this manager's already-resolved cache paths, an explicit
/// offline flag, and an explicit CDN base.
///
/// Split out of [`fetch`] so unit tests can drive it against a temp home
/// without mutating process-global env (`KIGI_SHARE_DIR` /
/// `KIGI_CHANGELOG_OFFLINE`), which races across the parallel test
/// harness. Passing an unreachable `base` lets a test force a
/// deterministic CDN miss instead of depending on whether the sandbox
/// happens to block network. Production callers always go through
/// [`fetch`], so behaviour is unchanged.
fn fetch_with(&self, offline: bool, base: &str) -> Changelog {
if offline {
return Changelog {
markdown: read_cache(&self.md_cache),
entries: self.read_json_cache(),
};
}
let version = kigi_version::VERSION;
let md_url = format!("{}/{}.external.md", base, version);
// Fetch both formats in parallel (3s timeout each → 3s total, not 6s).
let mut markdown = None;
let mut entries = None;
std::thread::scope(|s| {
let md_handle = s.spawn(|| self.fetch_and_cache(&md_url, &self.md_cache));
let json_handle = s.spawn(|| self.fetch_json(base, version));
markdown = md_handle.join().ok().flatten();
entries = json_handle.join().ok().flatten();
});
// If CDN is unreachable (CI sandboxes, airplane mode), fall back to
// any on-disk seed under `$KIGI_SHARE_DIR` even when offline mode was not
// explicitly requested — keeps PTY/integration tests deterministic.
if markdown.is_none() {
markdown = read_cache(&self.md_cache);
}
if entries.is_none() {
entries = self.read_json_cache();
}
Changelog { markdown, entries }
}
/// Fetch and parse JSON changelog, caching only after successful parse.
fn fetch_json(&self, base: &str, version: &str) -> Option<Vec<ChangelogEntry>> {
let url = format!("{}/{}.external.json", base, version);
// Try remote first — only cache after successful parse.
if let Ok(raw) = fetch_blocking(&url)
&& !raw.trim().is_empty()
{
match serde_json::from_str::<Vec<ChangelogEntry>>(&raw) {
Ok(entries) => {
if let Err(e) = std::fs::write(&self.json_cache, &raw) {
tracing::debug!(error = %e, "JSON changelog cache write failed");
}
return Some(entries);
}
Err(e) => {
tracing::debug!(error = %e, "failed to parse JSON changelog from CDN");
}
}
}
self.read_json_cache()
}
fn read_json_cache(&self) -> Option<Vec<ChangelogEntry>> {
let cached = read_cache(&self.json_cache)?;
match serde_json::from_str(&cached) {
Ok(entries) => Some(entries),
Err(e) => {
tracing::debug!(error = %e, "failed to parse cached JSON changelog");
None
}
}
}
/// Shared fetch-and-cache: try remote (3 s timeout), cache on success,
/// fall back to disk cache on failure.
fn fetch_and_cache(&self, url: &str, cache_path: &std::path::Path) -> Option<String> {
if let Ok(content) = fetch_blocking(url)
&& !content.trim().is_empty()
{
if let Err(e) = std::fs::write(cache_path, &content) {
tracing::debug!(error = %e, path = %cache_path.display(), "cache write failed");
}
return Some(content);
}
read_cache(cache_path)
}
}
/// When set, `ChangelogManager::fetch` skips the CDN and only reads disk cache.
/// Used by PTY harness tests that seed `CHANGELOG.{md,json}` under a temp home.
fn changelog_offline() -> bool {
std::env::var_os("KIGI_CHANGELOG_OFFLINE").is_some_and(|v| !v.is_empty() && v != "0")
}
fn read_cache(path: &std::path::Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.filter(|c| !c.trim().is_empty())
}
/// Strip `**bold**` markers and backticks from a description string.
fn strip_markdown_inline(s: &str) -> String {
s.replace("**", "").replace('`', "")
}
/// Convert changelog entries to plain-text bullet strings.
///
/// Strips `**bold**` and backtick formatting from each description,
/// skips entries with empty descriptions (from tolerant deserialization),
/// and returns at most `max` entries.
pub fn bullets_from_entries(entries: &[ChangelogEntry], max: usize) -> Vec<String> {
entries
.iter()
.filter(|e| !e.description.is_empty())
.take(max)
.map(|e| strip_markdown_inline(&e.description))
.collect()
}
/// Blocking HTTP fetch. Callers (`std::thread::scope` threads) are already
/// off the tokio runtime, so no extra thread spawn is needed.
fn fetch_blocking(url: &str) -> anyhow::Result<String> {
let client = reqwest::blocking::Client::builder()
.timeout(FETCH_TIMEOUT)
.build()?;
let resp = client.get(url).send()?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status());
}
Ok(resp.text()?)
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a manager pointing at `home` directly, bypassing the global
/// `$KIGI_SHARE_DIR` env so tests never race the parallel harness.
fn manager_for(home: &std::path::Path) -> ChangelogManager {
ChangelogManager {
md_cache: home.join("CHANGELOG.md"),
json_cache: home.join("CHANGELOG.json"),
}
}
#[test]
fn offline_mode_reads_seeded_disk_cache_only() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("kigi-home");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("CHANGELOG.md"), "# seeded offline md\n").unwrap();
std::fs::write(
home.join("CHANGELOG.json"),
r#"[{"category":"features","description":"seeded entry","breaking_change":false}]"#,
)
.unwrap();
// Offline path: read only the seeded disk cache, no network.
let changelog = manager_for(&home).fetch_with(true, CHANGELOG_BASE);
assert_eq!(
changelog.markdown.as_deref(),
Some("# seeded offline md\n"),
"offline mode must return seeded markdown"
);
let entries = changelog.entries.expect("seeded json entries");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].description, "seeded entry");
}
#[test]
fn cdn_miss_falls_back_to_env_home_disk_cache() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("kigi-home-fallback");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("CHANGELOG.md"), "# fallback md\n").unwrap();
// Non-offline path with an unreachable CDN base: the remote fetch
// fails deterministically (no dependency on the sandbox blocking
// network), so the on-disk cache must win.
let changelog = manager_for(&home).fetch_with(false, "http://127.0.0.1:1");
assert_eq!(
changelog.markdown.as_deref(),
Some("# fallback md\n"),
"CDN miss must fall back to the seeded CHANGELOG.md"
);
}
#[test]
fn bullets_strips_markdown_and_respects_max() {
let entries = vec![
ChangelogEntry {
category: "features".into(),
description: "Added **dark mode** support".into(),
breaking_change: false,
},
ChangelogEntry {
category: "fixes".into(),
description: "Fixed `crash` on startup".into(),
breaking_change: false,
},
ChangelogEntry {
category: "performance".into(),
description: "Faster **rendering** of `code` blocks".into(),
breaking_change: false,
},
];
let bullets = bullets_from_entries(&entries, 2);
assert_eq!(bullets.len(), 2);
assert_eq!(bullets[0], "Added dark mode support");
assert_eq!(bullets[1], "Fixed crash on startup");
}
#[test]
fn bullets_skips_empty_descriptions() {
let entries = vec![
ChangelogEntry {
category: "features".into(),
description: "Good entry".into(),
breaking_change: false,
},
ChangelogEntry {
category: String::new(),
description: String::new(), // bad entry from tolerant deser
breaking_change: false,
},
ChangelogEntry {
category: "fixes".into(),
description: "Another good one".into(),
breaking_change: false,
},
];
let bullets = bullets_from_entries(&entries, 10);
assert_eq!(bullets, vec!["Good entry", "Another good one"]);
}
#[test]
fn tolerant_deserialization_partial_entry() {
// Missing description field → defaults to empty string, not a parse error
let json = r#"[{"category":"features"},{"description":"ok"}]"#;
let entries: Vec<ChangelogEntry> = serde_json::from_str(json).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].description, "");
assert_eq!(entries[1].category, "");
assert_eq!(entries[1].description, "ok");
}
}
@@ -1,4 +1,3 @@
pub mod changelog;
pub mod event_id; pub mod event_id;
pub mod kigi_home; pub mod kigi_home;
pub mod secure_file; pub mod secure_file;
+2 -2
View File
@@ -116,8 +116,8 @@ pub fn extract_bundled_files(kigi_home: &std::path::Path) {
let _ = std::fs::create_dir_all(kigi_home); let _ = std::fs::create_dir_all(kigi_home);
// Clean up cached changelog files from previous version so // Clean up changelog caches written by the removed changelog feature
// /release-notes fetches fresh content for the new version. // (kigi <= 0.1.0 cached CDN release notes in the kigi home).
for stale in &["CHANGELOG.json", "CHANGELOG.md"] { for stale in &["CHANGELOG.json", "CHANGELOG.md"] {
let _ = std::fs::remove_file(kigi_home.join(stale)); let _ = std::fs::remove_file(kigi_home.join(stale));
} }
@@ -395,16 +395,6 @@ Show terminal capability detection and setup info — including color level, whi
Aliases: `/terminal-check`, `/terminal-info` Aliases: `/terminal-check`, `/terminal-info`
### `/release-notes`
View release notes for the current version.
```
/release-notes
```
Aliases: `/changelog`
### `/docs` ### `/docs`
Browse in-TUI How-to Guides, open online Build docs, or jump to a guide by title. Browse in-TUI How-to Guides, open online Build docs, or jump to a guide by title.
@@ -2,7 +2,7 @@
> **Status: alpha.** The schema below is versioned (`kigi_code.schema.version = v1`); > **Status: alpha.** The schema below is versioned (`kigi_code.schema.version = v1`);
> additive changes may occur without notice, renames/removals will bump the > additive changes may occur without notice, renames/removals will bump the
> version and be called out in the changelog. > version.
Kigi CLI can export usage **metrics** and **events** to your organization's Kigi CLI can export usage **metrics** and **events** to your organization's
own OpenTelemetry collector, so platform teams can monitor adoption, token own OpenTelemetry collector, so platform teams can monitor adoption, token
@@ -623,10 +623,6 @@ pub enum Action {
/// to config.toml). `/plan <desc>` uses `EnterPlanMode` instead /// to config.toml). `/plan <desc>` uses `EnterPlanMode` instead
/// because it also starts a turn. /// because it also starts a turn.
SetPlanMode(PlanModeKind), SetPlanMode(PlanModeKind),
/// Enter feedback mode (visual prompt change, not a send).
EnterFeedbackMode,
/// Send feedback text collected in feedback mode.
SendFeedback(String),
/// Enter remember mode (visual prompt change, not a send). /// Enter remember mode (visual prompt change, not a send).
EnterRememberMode, EnterRememberMode,
/// Send a remember note from # mode. Routes through LLM rewrite when a /// Send a remember note from # mode. Routes through LLM rewrite when a
@@ -1475,10 +1471,6 @@ pub enum Effect {
/// `SwitchModelComplete` so `IncompatibleAgent` can roll back. /// `SwitchModelComplete` so `IncompatibleAgent` can roll back.
prev_model_id: Option<acp::ModelId>, prev_model_id: Option<acp::ModelId>,
}, },
/// Fetch changelog from CDN (both markdown + structured JSON).
/// Runs off the render path via `spawn_blocking`. Result is cached
/// on `AppView` so `/release-notes` and the welcome screen share it.
FetchChangelog,
/// Persist memory modal fullscreen preference to `[hints]` in config.toml. /// Persist memory modal fullscreen preference to `[hints]` in config.toml.
PersistMemoryFullscreen { fullscreen: bool }, PersistMemoryFullscreen { fullscreen: bool },
/// Persist the project-picker opt-out to `[hints] project_picker_disabled`. /// Persist the project-picker opt-out to `[hints] project_picker_disabled`.
@@ -1717,12 +1709,6 @@ pub enum Effect {
FetchBundleStatus, FetchBundleStatus,
/// Fetch a bundled entry's raw content via `kigi/bundle/entry/get`. /// Fetch a bundled entry's raw content via `kigi/bundle/entry/get`.
FetchCatalogEntry { kind: String, name: String }, FetchCatalogEntry { kind: String, name: String },
/// Send feedback about the current session (fire-and-forget POST).
SendFeedback {
agent_id: AgentId,
session_id: acp::SessionId,
feedback_text: String,
},
/// Save a remember note to global MEMORY.md (async file write). /// Save a remember note to global MEMORY.md (async file write).
SaveMemoryNote { SaveMemoryNote {
agent_id: AgentId, agent_id: AgentId,
@@ -2140,11 +2126,6 @@ pub enum TaskResult {
/// rollback on `IncompatibleAgent`. /// rollback on `IncompatibleAgent`.
prev_model_id: Option<acp::ModelId>, prev_model_id: Option<acp::ModelId>,
}, },
/// Changelog fetched from CDN (both formats).
ChangelogFetched {
markdown: Option<String>,
entries: Vec<kigi_shell::util::changelog::ChangelogEntry>,
},
/// Cross-session prompt history loaded from ACP. /// Cross-session prompt history loaded from ACP.
PromptHistoryLoaded { PromptHistoryLoaded {
agent_id: AgentId, agent_id: AgentId,
@@ -2267,15 +2248,6 @@ pub enum TaskResult {
agent_id: AgentId, agent_id: AgentId,
error: String, error: String,
}, },
/// Feedback submitted successfully (fire-and-forget).
FeedbackComplete {
agent_id: AgentId,
},
/// Feedback submission failed.
FeedbackFailed {
agent_id: AgentId,
error: String,
},
/// Memory note saved to global MEMORY.md. /// Memory note saved to global MEMORY.md.
MemoryNoteSaved { MemoryNoteSaved {
agent_id: AgentId, agent_id: AgentId,
@@ -287,8 +287,6 @@ pub enum PromptInputMode {
Normal, Normal,
/// Bash mode (`!` prefix): Enter sends `Action::SendBashCommand`. /// Bash mode (`!` prefix): Enter sends `Action::SendBashCommand`.
Bash, Bash,
/// Feedback mode (`~` prefix, teal accent): Enter sends `Action::SendFeedback`.
Feedback,
/// Remember mode (`#` prefix, green accent): Enter sends `Action::SendRememberNote`. /// Remember mode (`#` prefix, green accent): Enter sends `Action::SendRememberNote`.
Remember, Remember,
} }
@@ -297,7 +295,6 @@ impl PromptInputMode {
match self { match self {
PromptInputMode::Normal => None, PromptInputMode::Normal => None,
PromptInputMode::Bash => Some(theme.command), PromptInputMode::Bash => Some(theme.command),
PromptInputMode::Feedback => Some(theme.accent_feedback),
PromptInputMode::Remember => Some(theme.accent_remember), PromptInputMode::Remember => Some(theme.accent_remember),
} }
} }
@@ -305,14 +302,12 @@ impl PromptInputMode {
match self { match self {
PromptInputMode::Normal => None, PromptInputMode::Normal => None,
PromptInputMode::Bash => Some(("! ", theme.command)), PromptInputMode::Bash => Some(("! ", theme.command)),
PromptInputMode::Feedback => Some(("~ ", theme.accent_feedback)),
PromptInputMode::Remember => Some(("# ", theme.accent_remember)), PromptInputMode::Remember => Some(("# ", theme.accent_remember)),
} }
} }
pub fn placeholder_override(self, multiline: bool) -> Option<&'static str> { pub fn placeholder_override(self, multiline: bool) -> Option<&'static str> {
match self { match self {
PromptInputMode::Normal | PromptInputMode::Bash => None, PromptInputMode::Normal | PromptInputMode::Bash => None,
PromptInputMode::Feedback => Some("Type your feedback..."),
PromptInputMode::Remember => { PromptInputMode::Remember => {
if multiline { if multiline {
Some("Save a memory note... (Enter for newline, Shift+Enter to save)") Some("Save a memory note... (Enter for newline, Shift+Enter to save)")
@@ -326,7 +321,6 @@ impl PromptInputMode {
match self { match self {
PromptInputMode::Normal => None, PromptInputMode::Normal => None,
PromptInputMode::Bash => Some("Run shell command"), PromptInputMode::Bash => Some("Run shell command"),
PromptInputMode::Feedback => Some("Send feedback"),
PromptInputMode::Remember => Some("Save memory note"), PromptInputMode::Remember => Some("Save memory note"),
} }
} }
@@ -334,7 +328,6 @@ impl PromptInputMode {
match self { match self {
PromptInputMode::Normal => Action::SendPrompt(text), PromptInputMode::Normal => Action::SendPrompt(text),
PromptInputMode::Bash => Action::SendBashCommand(text), PromptInputMode::Bash => Action::SendBashCommand(text),
PromptInputMode::Feedback => Action::SendFeedback(text),
PromptInputMode::Remember => Action::SendRememberNote(text), PromptInputMode::Remember => Action::SendRememberNote(text),
} }
} }
@@ -351,7 +344,6 @@ impl PromptInputMode {
|| ctrl_u || ctrl_u
|| ctrl_c || ctrl_c
} }
PromptInputMode::Feedback => key.code == KeyCode::Backspace || key.code == KeyCode::Esc,
} }
} }
} }
@@ -3078,10 +3070,6 @@ mod prompt_input_mode_tests {
PromptInputMode::Bash.accent_color(&theme), PromptInputMode::Bash.accent_color(&theme),
Some(theme.command) Some(theme.command)
); );
assert_eq!(
PromptInputMode::Feedback.accent_color(&theme),
Some(theme.accent_feedback)
);
assert_eq!( assert_eq!(
PromptInputMode::Remember.accent_color(&theme), PromptInputMode::Remember.accent_color(&theme),
Some(theme.accent_remember) Some(theme.accent_remember)
@@ -3095,10 +3083,6 @@ mod prompt_input_mode_tests {
PromptInputMode::Bash.prefix_override(&theme), PromptInputMode::Bash.prefix_override(&theme),
Some(("! ", theme.command)) Some(("! ", theme.command))
); );
assert_eq!(
PromptInputMode::Feedback.prefix_override(&theme),
Some(("~ ", theme.accent_feedback))
);
assert_eq!( assert_eq!(
PromptInputMode::Remember.prefix_override(&theme), PromptInputMode::Remember.prefix_override(&theme),
Some(("# ", theme.accent_remember)) Some(("# ", theme.accent_remember))
@@ -3110,14 +3094,6 @@ mod prompt_input_mode_tests {
assert_eq!(PromptInputMode::Normal.placeholder_override(true), None); assert_eq!(PromptInputMode::Normal.placeholder_override(true), None);
assert_eq!(PromptInputMode::Bash.placeholder_override(false), None); assert_eq!(PromptInputMode::Bash.placeholder_override(false), None);
assert_eq!(PromptInputMode::Bash.placeholder_override(true), None); assert_eq!(PromptInputMode::Bash.placeholder_override(true), None);
assert_eq!(
PromptInputMode::Feedback.placeholder_override(false),
Some("Type your feedback...")
);
assert_eq!(
PromptInputMode::Feedback.placeholder_override(true),
Some("Type your feedback...")
);
assert_eq!( assert_eq!(
PromptInputMode::Remember.placeholder_override(false), PromptInputMode::Remember.placeholder_override(false),
Some("Save a memory note... (Shift+Enter for multiline)") Some("Save a memory note... (Shift+Enter for multiline)")
@@ -3134,10 +3110,6 @@ mod prompt_input_mode_tests {
PromptInputMode::Bash.prompt_info_override(), PromptInputMode::Bash.prompt_info_override(),
Some("Run shell command") Some("Run shell command")
); );
assert_eq!(
PromptInputMode::Feedback.prompt_info_override(),
Some("Send feedback")
);
assert_eq!( assert_eq!(
PromptInputMode::Remember.prompt_info_override(), PromptInputMode::Remember.prompt_info_override(),
Some("Save memory note") Some("Save memory note")
@@ -3151,9 +3123,6 @@ mod prompt_input_mode_tests {
let t2 = "ls -l".to_string(); let t2 = "ls -l".to_string();
assert!(matches!(PromptInputMode::Bash.send_action(t2.clone()), assert!(matches!(PromptInputMode::Bash.send_action(t2.clone()),
Action::SendBashCommand(t) if t == t2)); Action::SendBashCommand(t) if t == t2));
let t3 = "this is feedback".to_string();
assert!(matches!(PromptInputMode::Feedback.send_action(t3.clone()),
Action::SendFeedback(t) if t == t3));
let t4 = "remember this".to_string(); let t4 = "remember this".to_string();
assert!(matches!(PromptInputMode::Remember.send_action(t4.clone()), assert!(matches!(PromptInputMode::Remember.send_action(t4.clone()),
Action::SendRememberNote(t) if t == t4)); Action::SendRememberNote(t) if t == t4));
@@ -3182,15 +3151,4 @@ mod prompt_input_mode_tests {
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE))); assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
} }
} }
#[test]
fn is_exit_key_feedback_uses_stricter_set() {
let mode = PromptInputMode::Feedback;
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)));
assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)));
}
} }
@@ -868,10 +868,9 @@ impl AgentView {
{ {
self.prompt.history_search.deactivate(); self.prompt.history_search.deactivate();
// Detect `! ` prefix to restore bash mode. Refined: only reset to Normal // Detect `! ` prefix to restore bash mode. Refined: only reset to Normal
// if currently in Bash (preserve Feedback/Remember if active). The ! prefix // if currently in Bash (preserve Remember if active). The ! prefix
// restore only applies when not in Feedback/Remember. // restore only applies when not in Remember.
if self.prompt_input_mode != PromptInputMode::Feedback if self.prompt_input_mode != PromptInputMode::Remember
&& self.prompt_input_mode != PromptInputMode::Remember
&& let Some(cmd) = text.strip_prefix("! ") && let Some(cmd) = text.strip_prefix("! ")
{ {
self.prompt_input_mode = PromptInputMode::Bash; self.prompt_input_mode = PromptInputMode::Bash;
+16 -133
View File
@@ -596,12 +596,6 @@ pub struct AppView {
/// Release-safe FPS HUD (`/debug fps`; `KIGI_FPS` env on release /// Release-safe FPS HUD (`/debug fps`; `KIGI_FPS` env on release
/// builds, where the dev overlay is compiled out) — see the module doc. /// builds, where the dev overlay is compiled out) — see the module doc.
pub fps_hud: crate::views::fps_hud::FpsHud, pub fps_hud: crate::views::fps_hud::FpsHud,
/// Cached changelog markdown (for `/release-notes`). Populated by
/// `FetchChangelog` at startup; `None` until the fetch completes.
pub changelog_markdown: Option<String>,
/// Cached changelog bullets (for welcome screen). Populated by
/// `FetchChangelog` at startup; empty until the fetch completes.
pub changelog_bullets: Vec<String>,
/// Resolved tip list from config layers. /// Resolved tip list from config layers.
pub tips: Vec<String>, pub tips: Vec<String>,
/// Selected tip for the current launch/session. /// Selected tip for the current launch/session.
@@ -709,10 +703,6 @@ pub struct AppView {
pub welcome_menu_index: Option<usize>, pub welcome_menu_index: Option<usize>,
/// Hit-test rects for welcome menu items (populated during render). /// Hit-test rects for welcome menu items (populated during render).
pub welcome_menu_rects: Vec<ratatui::layout::Rect>, pub welcome_menu_rects: Vec<ratatui::layout::Rect>,
/// Whether the welcome menu currently includes a "Changelog" row (above
/// Quit). Set during render; the input handler uses it to size the menu and
/// map the extra row to the release-notes action.
pub welcome_show_changelog_action: bool,
/// Hit-test rect for the import-claude banner on the welcome screen. /// Hit-test rect for the import-claude banner on the welcome screen.
pub welcome_import_banner_rect: Option<ratatui::layout::Rect>, pub welcome_import_banner_rect: Option<ratatui::layout::Rect>,
/// Last known mouse position (column, row), updated on every Mouse event. /// Last known mouse position (column, row), updated on every Mouse event.
@@ -734,14 +724,8 @@ pub struct AppView {
pub welcome_auth_url_rect: Option<ratatui::layout::Rect>, pub welcome_auth_url_rect: Option<ratatui::layout::Rect>,
/// Whether the mouse pointer was last over the auth URL (for OSC 22 cursor shape). /// Whether the mouse pointer was last over the auth URL (for OSC 22 cursor shape).
pub welcome_on_auth_url: bool, pub welcome_on_auth_url: bool,
/// Mouse last over the changelog block (drives hover color + redraws).
pub welcome_on_changelog_cta: bool,
/// Hit-test rect for the "show full URL" fallback link. /// Hit-test rect for the "show full URL" fallback link.
pub welcome_auth_fallback_rect: Option<ratatui::layout::Rect>, pub welcome_auth_fallback_rect: Option<ratatui::layout::Rect>,
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
/// Hit-test rect for the gate URL link on the paywall CTA.
/// Hit-test rect for the clickable changelog info block (opens release notes).
pub welcome_changelog_cta_rect: Option<ratatui::layout::Rect>,
/// Show the raw auth URL with mouse capture disabled for manual copy. /// Show the raw auth URL with mouse capture disabled for manual copy.
pub auth_show_raw_url: bool, pub auth_show_raw_url: bool,
/// Whether mouse capture is currently disabled for raw URL mode. /// Whether mouse capture is currently disabled for raw URL mode.
@@ -1015,8 +999,6 @@ impl AppView {
tracing_rx: None, tracing_rx: None,
scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(), scroll_debug_hud: crate::views::scroll_debug_hud::ScrollDebugHud::new(),
fps_hud: crate::views::fps_hud::FpsHud::new(), fps_hud: crate::views::fps_hud::FpsHud::new(),
changelog_markdown: None,
changelog_bullets: Vec::new(),
tips: Vec::new(), tips: Vec::new(),
tip: None, tip: None,
welcome_prompt, welcome_prompt,
@@ -1031,7 +1013,6 @@ impl AppView {
minimal_state: crate::minimal_api::MinimalState::default(), minimal_state: crate::minimal_api::MinimalState::default(),
welcome_menu_index: None, welcome_menu_index: None,
welcome_menu_rects: Vec::new(), welcome_menu_rects: Vec::new(),
welcome_show_changelog_action: false,
welcome_import_banner_rect: None, welcome_import_banner_rect: None,
last_mouse_pos: None, last_mouse_pos: None,
last_scroll_pos: None, last_scroll_pos: None,
@@ -1039,9 +1020,7 @@ impl AppView {
welcome_prompt_rect: None, welcome_prompt_rect: None,
welcome_auth_url_rect: None, welcome_auth_url_rect: None,
welcome_on_auth_url: false, welcome_on_auth_url: false,
welcome_on_changelog_cta: false,
welcome_auth_fallback_rect: None, welcome_auth_fallback_rect: None,
welcome_changelog_cta_rect: None,
auth_show_raw_url: false, auth_show_raw_url: false,
auth_mouse_disabled: false, auth_mouse_disabled: false,
session_picker_entries: None, session_picker_entries: None,
@@ -1635,19 +1614,11 @@ impl AppView {
new_worktree_dialog: &mut self.new_worktree_dialog, new_worktree_dialog: &mut self.new_worktree_dialog,
menu_index: &mut self.welcome_menu_index, menu_index: &mut self.welcome_menu_index,
menu_rects: &self.welcome_menu_rects, menu_rects: &self.welcome_menu_rects,
menu_count: 3 menu_count: 3 + if self.has_claude_import { 1 } else { 0 },
+ if self.has_claude_import { 1 } else { 0 }
+ if self.welcome_show_changelog_action {
1
} else {
0
},
prompt_rect: self.welcome_prompt_rect.as_ref(), prompt_rect: self.welcome_prompt_rect.as_ref(),
import_banner_rect: self.welcome_import_banner_rect.as_ref(), import_banner_rect: self.welcome_import_banner_rect.as_ref(),
auth_url_rect: self.welcome_auth_url_rect.as_ref(), auth_url_rect: self.welcome_auth_url_rect.as_ref(),
auth_fallback_rect: self.welcome_auth_fallback_rect.as_ref(), auth_fallback_rect: self.welcome_auth_fallback_rect.as_ref(),
changelog_cta_rect: self.welcome_changelog_cta_rect.as_ref(),
on_changelog_cta: &mut self.welcome_on_changelog_cta,
show_raw_url: &mut self.auth_show_raw_url, show_raw_url: &mut self.auth_show_raw_url,
sp_entries: &mut self.session_picker_entries, sp_entries: &mut self.session_picker_entries,
sp_state: &mut self.session_picker_state, sp_state: &mut self.session_picker_state,
@@ -1657,8 +1628,6 @@ impl AppView {
has_claude_import: self.has_claude_import, has_claude_import: self.has_claude_import,
import_claude_modal: &mut self.import_claude_modal, import_claude_modal: &mut self.import_claude_modal,
welcome_doc_viewer: &mut self.welcome_doc_viewer, welcome_doc_viewer: &mut self.welcome_doc_viewer,
changelog_markdown: &self.changelog_markdown,
show_changelog_action: self.welcome_show_changelog_action,
has_pending_update: self.pending_update_version.is_some(), has_pending_update: self.pending_update_version.is_some(),
has_foreign_resume, has_foreign_resume,
cwd_has_git_ancestor: self.cwd_has_git_ancestor, cwd_has_git_ancestor: self.cwd_has_git_ancestor,
@@ -2175,10 +2144,6 @@ struct WelcomeInputCtx<'a> {
import_banner_rect: Option<&'a ratatui::layout::Rect>, import_banner_rect: Option<&'a ratatui::layout::Rect>,
auth_url_rect: Option<&'a ratatui::layout::Rect>, auth_url_rect: Option<&'a ratatui::layout::Rect>,
auth_fallback_rect: Option<&'a ratatui::layout::Rect>, auth_fallback_rect: Option<&'a ratatui::layout::Rect>,
/// Hit-test rect for the clickable changelog info block (opens release notes).
changelog_cta_rect: Option<&'a ratatui::layout::Rect>,
/// Sticky hover flag for the changelog block (redraw on enter/leave).
on_changelog_cta: &'a mut bool,
show_raw_url: &'a mut bool, show_raw_url: &'a mut bool,
sp_entries: &'a mut Option<Vec<SessionPickerEntry>>, sp_entries: &'a mut Option<Vec<SessionPickerEntry>>,
sp_state: &'a mut crate::views::picker::PickerState, sp_state: &'a mut crate::views::picker::PickerState,
@@ -2190,10 +2155,6 @@ struct WelcomeInputCtx<'a> {
has_claude_import: bool, has_claude_import: bool,
import_claude_modal: &'a mut Option<crate::views::import_claude_modal::ImportClaudeModalState>, import_claude_modal: &'a mut Option<crate::views::import_claude_modal::ImportClaudeModalState>,
welcome_doc_viewer: &'a mut Option<crate::views::modal::ActiveModal>, welcome_doc_viewer: &'a mut Option<crate::views::modal::ActiveModal>,
changelog_markdown: &'a Option<String>,
/// Whether the welcome menu currently includes a "Changelog" row (above
/// Quit), so index→action mapping accounts for it.
show_changelog_action: bool,
has_pending_update: bool, has_pending_update: bool,
/// A recent foreign session is available to resume when no update is pending. /// A recent foreign session is available to resume when no update is pending.
has_foreign_resume: bool, has_foreign_resume: bool,
@@ -2601,12 +2562,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
if key!(Enter).matches(key) if key!(Enter).matches(key)
&& let Some(idx) = *ctx.menu_index && let Some(idx) = *ctx.menu_index
{ {
return dispatch_menu_action( return dispatch_menu_action(idx, ctx.has_claude_import);
idx,
ctx.has_claude_import,
ctx.show_changelog_action,
ctx.changelog_markdown.as_deref(),
);
} }
if crate::input::key::is_text_input_key(key) { if crate::input::key::is_text_input_key(key) {
*ctx.prompt_focused = true; *ctx.prompt_focused = true;
@@ -2762,23 +2718,9 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
{ {
return InputOutcome::Action(Action::DismissClaudeImport); return InputOutcome::Action(Action::DismissClaudeImport);
} }
return dispatch_menu_action( return dispatch_menu_action(i, ctx.has_claude_import);
i,
ctx.has_claude_import,
ctx.show_changelog_action,
ctx.changelog_markdown.as_deref(),
);
} }
} }
if let Some(rect) = ctx.changelog_cta_rect
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
&& let Some(md) = ctx.changelog_markdown.as_deref()
{
return InputOutcome::Action(Action::ShowReleaseNotes {
title: "Release Notes".to_string(),
content: md.trim().to_string(),
});
}
if let Some(rect) = ctx.auth_url_rect if let Some(rect) = ctx.auth_url_rect
&& matches!(ctx.auth_state, AuthState::Authenticating { .. }) && matches!(ctx.auth_state, AuthState::Authenticating { .. })
&& rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row)) && rect.contains(ratatui::layout::Position::new(mouse.column, mouse.row))
@@ -2830,12 +2772,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
if ctx.has_claude_import && new_index == Some(0) { if ctx.has_claude_import && new_index == Some(0) {
return InputOutcome::Changed; return InputOutcome::Changed;
} }
let pos = ratatui::layout::Position::new(mouse.column, mouse.row);
let over_cta = ctx.changelog_cta_rect.is_some_and(|r| r.contains(pos));
if over_cta != *ctx.on_changelog_cta {
*ctx.on_changelog_cta = over_cta;
return InputOutcome::Changed;
}
if matches!(ctx.auth_state, AuthState::Authenticating { .. }) if matches!(ctx.auth_state, AuthState::Authenticating { .. })
&& ctx.auth_url_rect.is_some() && ctx.auth_url_rect.is_some()
{ {
@@ -2885,23 +2821,12 @@ fn dispatch_pending_menu_action(items: &[PendingMenuItem], index: usize) -> Inpu
} }
/// Dispatch an action for a welcome menu item by index. /// Dispatch an action for a welcome menu item by index.
/// ///
/// Menu order: `[Import]`, New worktree, Resume session, `[Changelog]`, Quit. /// Menu order: `[Import]`, New worktree, Resume session, Quit.
/// `show_changelog_action` is true when the Changelog row is rendered; release fn dispatch_menu_action(index: usize, has_claude_import: bool) -> InputOutcome {
/// notes open only once `changelog_md` is available.
fn dispatch_menu_action(
index: usize,
has_claude_import: bool,
show_changelog_action: bool,
changelog_md: Option<&str>,
) -> InputOutcome {
let base = if has_claude_import { 1 } else { 0 }; let base = if has_claude_import { 1 } else { 0 };
let worktree_idx = base; let worktree_idx = base;
let resume_idx = base + 1; let resume_idx = base + 1;
let (changelog_idx, quit_idx) = if show_changelog_action { let quit_idx = base + 2;
(Some(base + 2), base + 3)
} else {
(None, base + 2)
};
if has_claude_import && index == 0 { if has_claude_import && index == 0 {
return InputOutcome::Action(Action::ImportClaudeSettings); return InputOutcome::Action(Action::ImportClaudeSettings);
} }
@@ -2911,15 +2836,6 @@ fn dispatch_menu_action(
if index == resume_idx { if index == resume_idx {
return InputOutcome::Action(Action::FetchSessionList); return InputOutcome::Action(Action::FetchSessionList);
} }
if Some(index) == changelog_idx {
if let Some(md) = changelog_md {
return InputOutcome::Action(Action::ShowReleaseNotes {
title: "Release Notes".to_string(),
content: md.trim().to_string(),
});
}
return InputOutcome::Unchanged;
}
if index == quit_idx { if index == quit_idx {
return InputOutcome::Action(Action::Quit); return InputOutcome::Action(Action::Quit);
} }
@@ -3264,8 +3180,6 @@ impl AppView {
session_picker_source_filter: self.session_picker_source_filter, session_picker_source_filter: self.session_picker_source_filter,
chat_mode: self.chat_mode, chat_mode: self.chat_mode,
is_api_key_auth: self.is_api_key_auth, is_api_key_auth: self.is_api_key_auth,
changelog_bullets: &self.changelog_bullets,
changelog_has_full_notes: self.changelog_markdown.is_some(),
}; };
let result = crate::views::welcome::render_welcome( let result = crate::views::welcome::render_welcome(
view_area, view_area,
@@ -3275,12 +3189,10 @@ impl AppView {
&mut self.session_picker_state, &mut self.session_picker_state,
); );
self.welcome_menu_rects = result.menu_rects; self.welcome_menu_rects = result.menu_rects;
self.welcome_show_changelog_action = result.changelog_action_present;
self.welcome_prompt_rect = result.prompt_rect; self.welcome_prompt_rect = result.prompt_rect;
self.welcome_import_banner_rect = result.import_banner_rect; self.welcome_import_banner_rect = result.import_banner_rect;
self.welcome_auth_url_rect = result.auth_url_rect; self.welcome_auth_url_rect = result.auth_url_rect;
self.welcome_auth_fallback_rect = result.auth_fallback_rect; self.welcome_auth_fallback_rect = result.auth_fallback_rect;
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
self.session_picker_state.hit_areas = result.session_picker_hit_areas; self.session_picker_state.hit_areas = result.session_picker_hit_areas;
if let Some(modal) = self.import_claude_modal.as_mut() { if let Some(modal) = self.import_claude_modal.as_mut() {
let theme = crate::theme::Theme::current(); let theme = crate::theme::Theme::current();
@@ -4324,8 +4236,6 @@ pub(crate) mod tests {
pending_notification_escapes: None, pending_notification_escapes: None,
deferred_notification: None, deferred_notification: None,
tracing_rx: None, tracing_rx: None,
changelog_markdown: None,
changelog_bullets: Vec::new(),
tips: Vec::new(), tips: Vec::new(),
tip: None, tip: None,
cli_model_override: None, cli_model_override: None,
@@ -4379,7 +4289,6 @@ pub(crate) mod tests {
welcome_tip_typing_dismissed: false, welcome_tip_typing_dismissed: false,
welcome_menu_index: None, welcome_menu_index: None,
welcome_menu_rects: Vec::new(), welcome_menu_rects: Vec::new(),
welcome_show_changelog_action: false,
welcome_import_banner_rect: None, welcome_import_banner_rect: None,
last_mouse_pos: None, last_mouse_pos: None,
last_scroll_pos: None, last_scroll_pos: None,
@@ -4387,9 +4296,7 @@ pub(crate) mod tests {
welcome_prompt_rect: None, welcome_prompt_rect: None,
welcome_auth_url_rect: None, welcome_auth_url_rect: None,
welcome_on_auth_url: false, welcome_on_auth_url: false,
welcome_on_changelog_cta: false,
welcome_auth_fallback_rect: None, welcome_auth_fallback_rect: None,
welcome_changelog_cta_rect: None,
auth_show_raw_url: false, auth_show_raw_url: false,
auth_mouse_disabled: false, auth_mouse_disabled: false,
session_picker_entries: None, session_picker_entries: None,
@@ -5756,64 +5663,40 @@ pub(crate) mod tests {
); );
} }
#[test] #[test]
fn menu_action_indices_without_changelog() { fn menu_action_indices() {
assert!(matches!( assert!(matches!(
dispatch_menu_action(0, false, false, None), dispatch_menu_action(0, false),
InputOutcome::Action(Action::OpenNewWorktreeDialog) InputOutcome::Action(Action::OpenNewWorktreeDialog)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(1, false, false, None), dispatch_menu_action(1, false),
InputOutcome::Action(Action::FetchSessionList) InputOutcome::Action(Action::FetchSessionList)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(2, false, false, None), dispatch_menu_action(2, false),
InputOutcome::Action(Action::Quit) InputOutcome::Action(Action::Quit)
)); ));
}
#[test]
fn menu_action_changelog_sits_above_quit() {
let md = Some("# notes");
assert!(matches!( assert!(matches!(
dispatch_menu_action(1, false, true, md), dispatch_menu_action(3, false),
InputOutcome::Action(Action::FetchSessionList)
));
assert!(matches!(
dispatch_menu_action(2, false, true, md),
InputOutcome::Action(Action::ShowReleaseNotes { .. })
));
assert!(matches!(
dispatch_menu_action(3, false, true, md),
InputOutcome::Action(Action::Quit)
));
}
#[test]
fn menu_action_changelog_before_fetch_is_noop() {
assert!(matches!(
dispatch_menu_action(2, false, true, None),
InputOutcome::Unchanged InputOutcome::Unchanged
)); ));
} }
#[test] #[test]
fn menu_action_indices_with_import_and_changelog() { fn menu_action_indices_with_import() {
let md = Some("# notes");
assert!(matches!( assert!(matches!(
dispatch_menu_action(0, true, true, md), dispatch_menu_action(0, true),
InputOutcome::Action(Action::ImportClaudeSettings) InputOutcome::Action(Action::ImportClaudeSettings)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(1, true, true, md), dispatch_menu_action(1, true),
InputOutcome::Action(Action::OpenNewWorktreeDialog) InputOutcome::Action(Action::OpenNewWorktreeDialog)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(2, true, true, md), dispatch_menu_action(2, true),
InputOutcome::Action(Action::FetchSessionList) InputOutcome::Action(Action::FetchSessionList)
)); ));
assert!(matches!( assert!(matches!(
dispatch_menu_action(3, true, true, md), dispatch_menu_action(3, true),
InputOutcome::Action(Action::ShowReleaseNotes { .. })
));
assert!(matches!(
dispatch_menu_action(4, true, true, md),
InputOutcome::Action(Action::Quit) InputOutcome::Action(Action::Quit)
)); ));
} }
@@ -387,9 +387,6 @@ pub(super) fn handle_auth_complete(
// status only; shell auto-syncs post-auth // status only; shell auto-syncs post-auth
let mut effects = dispatch(Action::RequestBundleStatus, app); let mut effects = dispatch(Action::RequestBundleStatus, app);
// Fetch changelog (mirrors startup path for interactive login).
effects.push(Effect::FetchChangelog);
// Replay deferred session startup once BOTH gates are open. Auth // Replay deferred session startup once BOTH gates are open. Auth
// is now Done, so `session_startup_allowed()` here means "is trust // is now Done, so `session_startup_allowed()` here means "is trust
// also resolved?" -- if trust is still Pending its question renders // also resolved?" -- if trust is still Pending its question renders
@@ -1,4 +1,4 @@
//! Feedback, remember-note, btw, and recap dispatchers. //! Remember-note, btw, and recap dispatchers.
use super::ctx::with_active_agent; use super::ctx::with_active_agent;
use crate::app::actions::Effect; use crate::app::actions::Effect;
@@ -18,16 +18,6 @@ fn next_rewrite_nonce() -> u64 {
REWRITE_NONCE.fetch_add(1, Ordering::Relaxed) REWRITE_NONCE.fetch_add(1, Ordering::Relaxed)
} }
/// Enter feedback mode: visual change to prompt bar (teal accent, pencil prefix).
/// No side effects — the user types feedback text and presses Enter to send.
pub(super) fn dispatch_enter_feedback_mode(app: &mut AppView) -> Vec<Effect> {
with_active_agent(app, |agent| {
agent.prompt_input_mode = PromptInputMode::Feedback;
agent.prompt.set_text("");
});
vec![]
}
/// Enter remember mode: visual change to prompt bar (remember accent, `#` prefix). /// Enter remember mode: visual change to prompt bar (remember accent, `#` prefix).
/// No side effects — the user types a memory note and presses Enter to send. /// No side effects — the user types a memory note and presses Enter to send.
pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> { pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
@@ -38,47 +28,6 @@ pub(super) fn dispatch_enter_remember_mode(app: &mut AppView) -> Vec<Effect> {
vec![] vec![]
} }
/// Send feedback text to the server. Shows a thank-you message immediately
/// and fires the HTTP POST as a background effect.
pub(super) fn dispatch_send_feedback(app: &mut AppView, text: String) -> Vec<Effect> {
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
agent.prompt_input_mode = PromptInputMode::Normal;
agent.prompt.set_text("");
// Submitting feedback retires any edit-contextual ephemeral tip.
agent.ephemeral_tip.clear_on_submit();
let trimmed = text.trim().to_string();
if trimmed.is_empty() {
agent.scrollback.push_block(RenderBlock::system(
"Please provide feedback text.".to_string(),
));
return vec![];
}
let Some(session_id) = agent.session.session_id.clone() else {
agent
.scrollback
.push_block(RenderBlock::system("No active session.".to_string()));
return vec![];
};
agent.scrollback.push_block(RenderBlock::system(
"Thanks for the feedback! The Kigi team is on it.".to_string(),
));
vec![Effect::SendFeedback {
agent_id: id,
session_id,
feedback_text: trimmed,
}]
}
/// Send a raw remember note for LLM-powered rewriting via `kigi/memory/rewrite`. /// Send a raw remember note for LLM-powered rewriting via `kigi/memory/rewrite`.
/// Clears remember mode and prompts the LLM to reformat the note with session /// Clears remember mode and prompts the LLM to reformat the note with session
/// context. Falls back to direct `SaveMemoryNote` when no session is available. /// context. Falls back to direct `SaveMemoryNote` when no session is available.
@@ -33,8 +33,7 @@ use super::modes::{
set_permission_mode, set_plan_mode, set_yolo_mode, set_permission_mode, set_plan_mode, set_yolo_mode,
}; };
use super::notes::{ use super::notes::{
dispatch_enter_feedback_mode, dispatch_enter_remember_mode, dispatch_enter_remember_mode, dispatch_save_remember_note_from_modal, dispatch_send_btw,
dispatch_save_remember_note_from_modal, dispatch_send_btw, dispatch_send_feedback,
dispatch_send_recap, dispatch_send_remember_note, dispatch_send_recap, dispatch_send_remember_note,
}; };
use super::permissions::{ use super::permissions::{
@@ -784,8 +783,6 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::ShowPlan => dispatch_show_plan(app), Action::ShowPlan => dispatch_show_plan(app),
Action::EnterPlanMode { description } => dispatch_enter_plan_mode(app, description), Action::EnterPlanMode { description } => dispatch_enter_plan_mode(app, description),
Action::SetPlanMode(kind) => set_plan_mode(app, kind), Action::SetPlanMode(kind) => set_plan_mode(app, kind),
Action::EnterFeedbackMode => dispatch_enter_feedback_mode(app),
Action::SendFeedback(text) => dispatch_send_feedback(app, text),
Action::EnterRememberMode => dispatch_enter_remember_mode(app), Action::EnterRememberMode => dispatch_enter_remember_mode(app),
Action::SendRememberNote(text) => dispatch_send_remember_note(app, text), Action::SendRememberNote(text) => dispatch_send_remember_note(app, text),
Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app), Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app),
@@ -442,11 +442,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
} }
vec![] vec![]
} }
TaskResult::ChangelogFetched { markdown, entries } => {
app.changelog_markdown = markdown;
app.changelog_bullets = kigi_shell::util::changelog::bullets_from_entries(&entries, 3);
vec![]
}
TaskResult::ClipboardAttachmentProbed { TaskResult::ClipboardAttachmentProbed {
ctx, ctx,
image, image,
@@ -663,17 +658,6 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
} }
vec![] vec![]
} }
TaskResult::FeedbackComplete { .. } => vec![],
TaskResult::FeedbackFailed { agent_id, error } => {
if let Some(agent) = app.agents.get_mut(&agent_id) {
agent
.scrollback
.push_block(crate::scrollback::block::RenderBlock::system(format!(
"Couldn't send feedback: {error}"
)));
}
vec![]
}
TaskResult::MemoryNoteSaved { agent_id, result } => { TaskResult::MemoryNoteSaved { agent_id, result } => {
handle_memory_note_saved(app, agent_id, result) handle_memory_note_saved(app, agent_id, result)
} }
@@ -85,8 +85,6 @@ fn test_app() -> AppView {
pending_notification_escapes: None, pending_notification_escapes: None,
deferred_notification: None, deferred_notification: None,
tracing_rx: None, tracing_rx: None,
changelog_markdown: None,
changelog_bullets: Vec::new(),
tips: Vec::new(), tips: Vec::new(),
tip: None, tip: None,
cli_model_override: None, cli_model_override: None,
@@ -143,7 +141,6 @@ fn test_app() -> AppView {
welcome_tip_typing_dismissed: false, welcome_tip_typing_dismissed: false,
welcome_menu_index: None, welcome_menu_index: None,
welcome_menu_rects: Vec::new(), welcome_menu_rects: Vec::new(),
welcome_show_changelog_action: false,
welcome_import_banner_rect: None, welcome_import_banner_rect: None,
last_mouse_pos: None, last_mouse_pos: None,
last_scroll_pos: None, last_scroll_pos: None,
@@ -151,9 +148,7 @@ fn test_app() -> AppView {
welcome_prompt_rect: None, welcome_prompt_rect: None,
welcome_auth_url_rect: None, welcome_auth_url_rect: None,
welcome_on_auth_url: false, welcome_on_auth_url: false,
welcome_on_changelog_cta: false,
welcome_auth_fallback_rect: None, welcome_auth_fallback_rect: None,
welcome_changelog_cta_rect: None,
auth_show_raw_url: false, auth_show_raw_url: false,
auth_mouse_disabled: false, auth_mouse_disabled: false,
session_picker_entries: None, session_picker_entries: None,
@@ -32,23 +32,6 @@ fn seed_foreign_resume_hint(
}), }),
); );
} }
/// Sending feedback is a submit: it retires the active ephemeral tip.
#[test]
fn send_feedback_clears_active_ephemeral_tip() {
let mut app = test_app_with_agent();
let id = AgentId(0);
let agent = app.agents.get_mut(&id).unwrap();
let _ = agent.ephemeral_tip.show(
crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint")),
&mut std::collections::HashMap::new(),
);
assert!(agent.ephemeral_tip.is_active());
let _ = dispatch(Action::SendFeedback("it broke".into()), &mut app);
assert!(
!app.agents.get(&id).unwrap().ephemeral_tip.is_active(),
"feedback submit must clear the tip"
);
}
/// Sending a remember note is a submit: it retires the active ephemeral tip. /// Sending a remember note is a submit: it retires the active ephemeral tip.
#[test] #[test]
fn send_remember_note_clears_active_ephemeral_tip() { fn send_remember_note_clears_active_ephemeral_tip() {
@@ -1712,27 +1712,6 @@ pub(crate) fn execute(
TaskResult::PromptImagePreviewPrepared TaskResult::PromptImagePreviewPrepared
}); });
} }
Effect::FetchChangelog => {
tasks
.spawn(async move {
let changelog = tokio::task::spawn_blocking(|| {
kigi_shell::util::changelog::ChangelogManager::new()
.fetch()
})
.await
.unwrap_or_else(|e| {
tracing::warn!(error = % e, "changelog fetch task failed");
kigi_shell::util::changelog::Changelog {
markdown: None,
entries: None,
}
});
TaskResult::ChangelogFetched {
markdown: changelog.markdown,
entries: changelog.entries.unwrap_or_default(),
}
});
}
Effect::PersistMemoryFullscreen { fullscreen } => { Effect::PersistMemoryFullscreen { fullscreen } => {
persist_hint( persist_hint(
tasks, tasks,
@@ -2599,61 +2578,6 @@ pub(crate) fn execute(
} }
}); });
} }
Effect::SendFeedback { agent_id, session_id, feedback_text } => {
use kigi_shell::session::ClientType;
use kigi_shell::session::acp_types::ClientFeedbackInput;
let terminal_info = Some(
crate::terminal::terminal_context().feedback_info(),
);
let tx = acp_tx.clone();
tasks
.spawn(async move {
let input = ClientFeedbackInput {
session_id: session_id.0.to_string(),
client_type: ClientType::Tui,
rating_type: None,
rating_value: None,
feedback_text: Some(feedback_text),
feedback_categories: vec![],
context_type: None,
turn_number: None,
request_id: None,
client_version: Some(kigi_version::VERSION.to_string()),
metadata: None,
terminal_info,
};
let raw_params = match serde_json::value::to_raw_value(&input) {
Ok(v) => v,
Err(e) => {
return TaskResult::FeedbackFailed {
agent_id,
error: sanitize_user_error(
&format!("couldn't serialize feedback: {e}"),
),
};
}
};
let request = acp::ExtRequest::new(
"kigi/feedback",
raw_params.into(),
);
match acp_send(request, &tx).await {
Ok(_) => {
TaskResult::FeedbackComplete {
agent_id,
}
}
Err(e) => {
TaskResult::FeedbackFailed {
agent_id,
error: sanitize_user_error(
&format!("couldn't send feedback: {e}"),
),
}
}
}
});
}
Effect::RewriteMemoryNote { Effect::RewriteMemoryNote {
agent_id, agent_id,
session_id, session_id,
@@ -1136,12 +1136,6 @@ pub(crate) async fn run(
if process_effects(effs, &mut tasks, &mut app, &progress_tx) { if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
return Ok(make_run_result(&app)); return Ok(make_run_result(&app));
} }
// Fetch changelog off the render path so the welcome screen
// can display bullets and /release-notes uses the cached result.
let effs = vec![super::actions::Effect::FetchChangelog];
if process_effects(effs, &mut tasks, &mut app, &progress_tx) {
return Ok(make_run_result(&app));
}
} }
if !post_render_effects.is_empty() if !post_render_effects.is_empty()
+1 -2
View File
@@ -169,8 +169,7 @@ impl AgentView {
.map(str::to_owned) .map(str::to_owned)
{ {
self.prompt.history_search.deactivate(); self.prompt.history_search.deactivate();
if self.prompt_input_mode != PromptInputMode::Feedback if self.prompt_input_mode != PromptInputMode::Remember
&& self.prompt_input_mode != PromptInputMode::Remember
&& let Some(cmd) = text.strip_prefix("! ") && let Some(cmd) = text.strip_prefix("! ")
{ {
self.prompt_input_mode = PromptInputMode::Bash; self.prompt_input_mode = PromptInputMode::Bash;
@@ -1,9 +1,14 @@
//! `/feedback` -- send session feedback. //! `/feedback` -- open the Kigi GitHub issues page.
use crate::app::actions::Action; use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand}; use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Send session feedback inline or enter feedback mode. /// Where feedback goes: the project's own issue tracker. Kigi is a community
/// build, so its feedback belongs on its GitHub repo — mirroring the official
/// kimi-cli, whose `/feedback` opens its repo's issues page.
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
/// Open the Kigi issue tracker in the browser.
pub struct FeedbackCommand; pub struct FeedbackCommand;
impl SlashCommand for FeedbackCommand { impl SlashCommand for FeedbackCommand {
@@ -12,27 +17,75 @@ impl SlashCommand for FeedbackCommand {
} }
fn description(&self) -> &str { fn description(&self) -> &str {
"Send feedback about the current session" "Report feedback on the Kigi GitHub issues page"
} }
fn usage(&self) -> &str { fn usage(&self) -> &str {
"/feedback [text]" "/feedback"
} }
fn takes_args(&self) -> bool { fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
true CommandResult::Action(Action::OpenUrl(FEEDBACK_ISSUES_URL.into()))
} }
}
fn arg_placeholder(&self) -> Option<&str> { #[cfg(test)]
Some("[feedback text]") mod tests {
use super::*;
use crate::acp::model_state::ModelState;
static DEFAULT_BUNDLE_STATE: crate::app::bundle::BundleState =
crate::app::bundle::BundleState {
has_cache: false,
version: String::new(),
personas: Vec::new(),
roles: Vec::new(),
agents: Vec::new(),
skills: Vec::new(),
persona_details: Vec::new(),
role_details: Vec::new(),
};
fn make_ctx<'a>(models: &'a ModelState) -> CommandExecCtx<'a> {
CommandExecCtx {
models,
session_id: None,
bundle_state: &DEFAULT_BUNDLE_STATE,
screen_mode: crate::app::ScreenMode::Inline,
pager_state: crate::settings::PagerLocalSnapshot {
multiline_mode: false,
yolo_mode: false,
..crate::settings::PagerLocalSnapshot::default()
},
}
}
#[test]
fn feedback_opens_github_issues() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);
match FeedbackCommand.run(&mut ctx, "") {
CommandResult::Action(Action::OpenUrl(url)) => {
assert_eq!(url, FEEDBACK_ISSUES_URL);
}
other => panic!("expected OpenUrl, got {other:?}"),
} }
}
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult { #[test]
let trimmed = args.trim(); fn feedback_ignores_stray_args() {
if trimmed.is_empty() { let models = ModelState::default();
CommandResult::Action(Action::EnterFeedbackMode) let mut ctx = make_ctx(&models);
} else { assert!(matches!(
CommandResult::Action(Action::SendFeedback(trimmed.to_string())) FeedbackCommand.run(&mut ctx, "some typed text"),
CommandResult::Action(Action::OpenUrl(_))
));
} }
#[test]
fn feedback_metadata() {
let cmd = FeedbackCommand;
assert_eq!(cmd.name(), "feedback");
assert!(!cmd.takes_args());
} }
} }
@@ -41,7 +41,6 @@ pub mod plan;
pub mod plugin; pub mod plugin;
pub mod queue; pub mod queue;
pub mod recap; pub mod recap;
pub mod release_notes;
pub mod remember; pub mod remember;
pub mod rename; pub mod rename;
pub mod resume; pub mod resume;
@@ -121,7 +120,6 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
Arc::new(usage::UsageCommand), Arc::new(usage::UsageCommand),
Arc::new(queue::QueueCommand), Arc::new(queue::QueueCommand),
Arc::new(tasks::TasksCommand), Arc::new(tasks::TasksCommand),
Arc::new(release_notes::ReleaseNotesCommand),
Arc::new(config_agents::ConfigAgentsCommand), Arc::new(config_agents::ConfigAgentsCommand),
Arc::new(personas::PersonasCommand), Arc::new(personas::PersonasCommand),
Arc::new(gboom::GboomCommand), Arc::new(gboom::GboomCommand),
@@ -1,60 +0,0 @@
//! `/release-notes` -- view release notes for the current version.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Show release notes for the current pager version.
pub struct ReleaseNotesCommand;
impl SlashCommand for ReleaseNotesCommand {
fn name(&self) -> &str {
"release-notes"
}
fn aliases(&self) -> &[&str] {
&["changelog"]
}
fn description(&self) -> &str {
"View release notes for the current version"
}
fn usage(&self) -> &str {
"/release-notes"
}
fn run(&self, _ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
let changelog = kigi_shell::util::changelog::ChangelogManager::new().fetch();
match changelog.markdown {
Some(content) => CommandResult::Action(Action::ShowReleaseNotes {
title: "Release Notes".to_string(),
content: content.trim().to_string(),
}),
None => CommandResult::Error("No release notes available (offline).".to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_notes_metadata() {
let cmd = ReleaseNotesCommand;
assert_eq!(cmd.name(), "release-notes");
assert_eq!(cmd.aliases(), &["changelog"]);
assert!(!cmd.takes_args());
}
#[test]
fn release_notes_returns_action_or_error() {
let models = crate::acp::model_state::ModelState::default();
let mut ctx = super::super::tests::make_ctx(&models);
let result = ReleaseNotesCommand.run(&mut ctx, "");
assert!(
matches!(result, CommandResult::Action(_) | CommandResult::Error(_)),
"expected Action or Error, got {result:?}"
);
}
}
@@ -174,7 +174,7 @@ pub struct PromptStyle {
pub prefix_override: Option<(&'static str, ratatui::style::Color)>, pub prefix_override: Option<(&'static str, ratatui::style::Color)>,
/// Override the placeholder text shown when the textarea is empty. /// Override the placeholder text shown when the textarea is empty.
/// When `Some(text)`, uses this instead of the default `"Build anything"`. /// When `Some(text)`, uses this instead of the default `"Build anything"`.
/// Used for feedback mode (`"Type your feedback..."`). /// Used for remember mode (`"Save a memory note..."`).
pub placeholder_override: Option<&'static str>, pub placeholder_override: Option<&'static str>,
/// Compact mode (currently unused for info_block sizing). /// Compact mode (currently unused for info_block sizing).
pub compact: bool, pub compact: bool,
@@ -351,13 +351,13 @@ pub fn render_turn_status(
// ── Build components ── // ── Build components ──
// While a tool is blocked on a permission prompt or `ask_user_question`, // While a tool is blocked on a permission prompt or `ask_user_question`,
// swap the running braille spinner for a pulsing `◆`. Same animation // swap the spinning moon for a pulsing `◆`. Same animation shape the
// shape the drain-blocked and plan-approval indicators already use, // drain-blocked and plan-approval indicators already use, so every
// so every "your turn" status reads with one consistent visual cue. // "your turn" status reads with one consistent visual cue.
let spinner_str = if is_pending_user_input { let spinner_str = if is_pending_user_input {
format!("{} ", crate::glyphs::diamond_filled()) format!("{} ", crate::glyphs::diamond_filled())
} else { } else {
let frames = crate::glyphs::braille_spinner_frames(); let frames = crate::glyphs::moon_spinner_frames();
let frame_idx = (tick / SPINNER_DIVISOR) as usize % frames.len(); let frame_idx = (tick / SPINNER_DIVISOR) as usize % frames.len();
format!("{} ", frames[frame_idx]) format!("{} ", frames[frame_idx])
}; };
@@ -1,8 +1,8 @@
//! Hero box component — side-by-side logo + menu inside a bordered box. //! Hero box component — side-by-side logo + menu inside a bordered box.
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Flex, Layout, Position, Rect}; use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::Style;
use ratatui::text::Span; use ratatui::text::Span;
use ratatui::widgets::{Block, BorderType, Borders, Widget}; use ratatui::widgets::{Block, BorderType, Borders, Widget};
@@ -27,18 +27,11 @@ const HERO_SUBTITLE: &str = "Thanks for trying Kigi, give feedback with /feedbac
use super::{PROMPT_HEIGHT, VERSION_GAP}; use super::{PROMPT_HEIGHT, VERSION_GAP};
/// Rows the "thanks" subtitle occupies. Hidden when the in-box info slot /// Height of the hero box's right column: version + subtitle + the gap
/// (changelog) is shown, to keep the box compact. /// before the menu + the menu itself.
fn subtitle_rows(info_height: u16) -> u16 { fn right_col_height(menu_height: u16) -> u16 {
if info_height > 0 { 0 } else { 1 } // version(1) + subtitle(1) + gap-before-menu(1) + menu
} 3 + menu_height
/// Height of the hero box's right column: version + optional subtitle +
/// optional info block + the gap before the menu + the menu itself.
fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
let info_gap = if info_height > 0 { 1u16 } else { 0 };
// version(1) + subtitle + [info_gap + info] + gap-before-menu(1) + menu
1 + subtitle_rows(info_height) + info_gap + info_height + 1 + menu_height
} }
/// Minimum content-area height the hero box needs to render without truncating: /// Minimum content-area height the hero box needs to render without truncating:
@@ -46,13 +39,8 @@ fn right_col_height(menu_height: u16, info_height: u16) -> u16 {
/// below (tip + prompt + version). The box always shows the full-height logo, /// below (tip + prompt + version). The box always shows the full-height logo,
/// so a terminal shorter than this falls back to the stacked layout instead of /// so a terminal shorter than this falls back to the stacked layout instead of
/// overflowing. /// overflowing.
pub(super) fn min_content_height( pub(super) fn min_content_height(error_height: u16, menu_height: u16, tip_height: u16) -> u16 {
error_height: u16, let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height));
menu_height: u16,
tip_height: u16,
info_height: u16,
) -> u16 {
let inner = super::logo::full_logo_line_count().max(right_col_height(menu_height, info_height));
let hero_box_height = 2 + V_PAD * 2 + inner; let hero_box_height = 2 + V_PAD * 2 + inner;
let gap_after_error = if error_height > 0 { 1u16 } else { 0 }; let gap_after_error = if error_height > 0 { 1u16 } else { 0 };
gap_after_error + error_height + hero_box_height + 1 + WelcomeLayout::fixed_below(tip_height) gap_after_error + error_height + hero_box_height + 1 + WelcomeLayout::fixed_below(tip_height)
@@ -70,33 +58,26 @@ fn left_col_width() -> u16 {
} }
/// Compute the hero box layout: bordered box with logo left, version + menu right. /// Compute the hero box layout: bordered box with logo left, version + menu right.
///
/// Sizes the in-box info slot here (the fixed `changelog_height`) so the
/// renderer just draws into `hero_info`.
pub(super) fn compute_hero_box( pub(super) fn compute_hero_box(
content_area: Rect, content_area: Rect,
error_height: u16, error_height: u16,
menu_height: u16, menu_height: u16,
tip_height: u16, tip_height: u16,
changelog_height: u16,
) -> WelcomeLayout { ) -> WelcomeLayout {
let zero = Rect::default(); let zero = Rect::default();
let tip_gap = if tip_height > 0 { 1u16 } else { 0 }; let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
let fixed_below = WelcomeLayout::fixed_below(tip_height); let fixed_below = WelcomeLayout::fixed_below(tip_height);
// Column widths are height-independent, so derive them once and reuse for // Column widths are height-independent, so derive them once and reuse for
// both the measurement and the rects: `hero_info.width == info_slot_width`, // both the measurement and the rects.
// i.e. measured == drawn.
let box_width = content_area.width.saturating_sub(6).min(120); let box_width = content_area.width.saturating_sub(6).min(120);
let inner_width = box_width.saturating_sub(2); let inner_width = box_width.saturating_sub(2);
let left_col_width = left_col_width(); let left_col_width = left_col_width();
let right_width = inner_width.saturating_sub(left_col_width); let right_width = inner_width.saturating_sub(left_col_width);
let info_slot_width = right_width.saturating_sub(H_INSET); let menu_slot_width = right_width.saturating_sub(H_INSET);
let info_height = changelog_height;
let logo_rows = super::logo::full_logo_line_count(); let logo_rows = super::logo::full_logo_line_count();
let info_gap = if info_height > 0 { 1u16 } else { 0 }; let inner_height = logo_rows.max(right_col_height(menu_height));
let inner_height = logo_rows.max(right_col_height(menu_height, info_height));
let hero_box_height = 2 + V_PAD * 2 + inner_height; let hero_box_height = 2 + V_PAD * 2 + inner_height;
let gap_after_error = if error_height > 0 { 1 } else { 0 }; let gap_after_error = if error_height > 0 { 1 } else { 0 };
@@ -105,7 +86,7 @@ pub(super) fn compute_hero_box(
// Top padding for vertical centering (use the default menu height so the // Top padding for vertical centering (use the default menu height so the
// logo position stays constant regardless of picker/focus state). // logo position stays constant regardless of picker/focus state).
let default_menu_height = 4u16; let default_menu_height = 4u16;
let default_inner = logo_rows.max(right_col_height(default_menu_height, info_height)); let default_inner = logo_rows.max(right_col_height(default_menu_height));
let default_hero = 2 + V_PAD * 2 + default_inner; let default_hero = 2 + V_PAD * 2 + default_inner;
let remaining = content_area.height.saturating_sub(fixed_above); let remaining = content_area.height.saturating_sub(fixed_above);
let top_pad = remaining let top_pad = remaining
@@ -190,39 +171,22 @@ pub(super) fn compute_hero_box(
height: 1, height: 1,
}; };
// Subtitle line below version — hidden when the info slot is shown. // Subtitle line below version.
let hero_subtitle = if subtitle_rows(info_height) > 0 { let hero_subtitle = Rect {
Rect {
x: right_x, x: right_x,
y: inner.y + 1, y: inner.y + 1,
width: right_width, width: right_width,
height: 1, height: 1,
}
} else {
zero
}; };
// Info block (changelog) below version + optional subtitle. // version + subtitle + gap-before-menu
let info_y = inner.y + 1 + subtitle_rows(info_height) + info_gap; let right_header_rows = 3;
let hero_info = if info_height > 0 {
Rect {
x: right_x,
y: info_y,
width: info_slot_width,
height: info_height,
}
} else {
zero
};
// version + subtitle + info_gap + info + gap-before-menu
let right_header_rows = 1 + subtitle_rows(info_height) + info_gap + info_height + 1;
// Menu below the header rows, left-aligned in right column. // Menu below the header rows, left-aligned in right column.
let hero_menu = Rect { let hero_menu = Rect {
x: right_x, x: right_x,
y: inner.y + right_header_rows, y: inner.y + right_header_rows,
width: info_slot_width, width: menu_slot_width,
height: menu_height.min(inner.height.saturating_sub(right_header_rows)), height: menu_height.min(inner.height.saturating_sub(right_header_rows)),
}; };
@@ -230,7 +194,6 @@ pub(super) fn compute_hero_box(
logo: zero, logo: zero,
error, error,
menu: zero, menu: zero,
changelog: zero,
tip, tip,
prompt, prompt,
version: version_slot, version: version_slot,
@@ -238,26 +201,12 @@ pub(super) fn compute_hero_box(
hero_logo, hero_logo,
hero_version, hero_version,
hero_subtitle, hero_subtitle,
hero_info,
hero_menu, hero_menu,
} }
} }
/// Changelog content shown in the hero box info slot.
pub(super) struct ChangelogDisplay<'a> {
pub(super) bullets: &'a [String],
pub(super) has_full_notes: bool,
}
/// Hit-test rects produced by [`render_hero_box`].
pub(super) struct HeroBoxRects {
/// Hit-test rect per menu item row (for click/hover).
pub(super) menu_rects: Vec<Rect>,
/// Clickable changelog info block, if drawn.
pub(super) changelog_cta_rect: Option<Rect>,
}
/// Render the bordered hero box with logo left, version + subtitle + menu right. /// Render the bordered hero box with logo left, version + subtitle + menu right.
/// Returns the hit-test rect per menu item row (for click/hover).
pub(super) fn render_hero_box( pub(super) fn render_hero_box(
layout: &WelcomeLayout, layout: &WelcomeLayout,
buf: &mut Buffer, buf: &mut Buffer,
@@ -265,8 +214,7 @@ pub(super) fn render_hero_box(
menu_items: &[(&str, &str)], menu_items: &[(&str, &str)],
selected: Option<usize>, selected: Option<usize>,
mouse_pos: Option<(u16, u16)>, mouse_pos: Option<(u16, u16)>,
changelog: ChangelogDisplay<'_>, ) -> Vec<Rect> {
) -> HeroBoxRects {
// Dim the box border toward the background for a softer, dimmer gray. // Dim the box border toward the background for a softer, dimmer gray.
let border_color = crate::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45) let border_color = crate::render::color::blend_color(theme.bg_base, theme.gray_dim, 0.45)
.unwrap_or(theme.gray_dim); .unwrap_or(theme.gray_dim);
@@ -289,7 +237,6 @@ pub(super) fn render_hero_box(
); );
// Subtitle line below the version. // Subtitle line below the version.
if layout.hero_subtitle.height > 0 {
let subtitle_style = Style::default().fg(theme.gray); let subtitle_style = Style::default().fg(theme.gray);
buf.set_span( buf.set_span(
layout.hero_subtitle.x, layout.hero_subtitle.x,
@@ -297,22 +244,8 @@ pub(super) fn render_hero_box(
&Span::styled(HERO_SUBTITLE, subtitle_style), &Span::styled(HERO_SUBTITLE, subtitle_style),
layout.hero_subtitle.width, layout.hero_subtitle.width,
); );
}
// In-box info slot: the changelog, always in this same position. super::menu::render_menu(
let mut changelog_cta_rect = None;
if layout.hero_info.height > 0 && !changelog.bullets.is_empty() {
changelog_cta_rect = render_hero_changelog(
buf,
theme,
layout.hero_info,
changelog.bullets,
changelog.has_full_notes,
mouse_pos,
);
}
let menu_rects = super::menu::render_menu(
layout.hero_menu, layout.hero_menu,
buf, buf,
theme, theme,
@@ -320,58 +253,5 @@ pub(super) fn render_hero_box(
selected, selected,
mouse_pos, mouse_pos,
layout.hero_menu.width, layout.hero_menu.width,
); )
HeroBoxRects {
menu_rects,
changelog_cta_rect,
}
}
/// Render the changelog block (header + bullets) in the info slot. When
/// `clickable` (full notes exist), the whole block opens the notes on click and
/// brightens while hovered; returns that clickable rect.
fn render_hero_changelog(
buf: &mut Buffer,
theme: &Theme,
area: Rect,
bullets: &[String],
clickable: bool,
mouse_pos: Option<(u16, u16)>,
) -> Option<Rect> {
if area.width == 0 || area.height == 0 {
return None;
}
let hovered =
clickable && mouse_pos.is_some_and(|(mx, my)| area.contains(Position::new(mx, my)));
let header_style = super::hover_style(
theme,
hovered,
Style::default()
.fg(theme.gray_bright)
.add_modifier(Modifier::DIM),
);
let title = "Changelog";
buf.set_span(
area.x,
area.y,
&Span::styled(title, header_style),
area.width,
);
// Bullets start 2 rows down (header + blank), matching the height budget.
let bullet_style = super::hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
let max_text_width = area.width.saturating_sub(4) as usize; // " • " prefix + pad
for (i, bullet) in bullets.iter().enumerate() {
let row = area.y + 2 + i as u16;
if row >= area.y + area.height {
break;
}
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
let text = format!(" \u{2022} {truncated}");
buf.set_span(area.x, row, &Span::styled(text, bullet_style), area.width);
}
clickable.then_some(area)
} }
@@ -7,7 +7,8 @@
//! phase model: the terminator is the ellipse `x = cos(2πp)·√(1y²)`, with //! phase model: the terminator is the ellipse `x = cos(2πp)·√(1y²)`, with
//! sunlight arriving from the right while waxing and from the left while //! sunlight arriving from the right while waxing and from the left while
//! waning. The dark limb keeps a faint outline ring so the silhouette never //! waning. The dark limb keeps a faint outline ring so the silhouette never
//! disappears at new moon. //! disappears at new moon. A fixed map of lunar maria textures the disc:
//! dark blotches on the sunlit side, faint gray patches on the dark limb.
//! //!
//! Hidden entirely on legacy Windows consoles: the U+2800 braille block is //! Hidden entirely on legacy Windows consoles: the U+2800 braille block is
//! not covered by the ConHost raster fonts and would render as tofu. //! not covered by the ConHost raster fonts and would render as tofu.
@@ -52,6 +53,30 @@ const PULSE_SECS: f32 = 5.0;
/// Squared inner radius (normalized) of the dark-limb outline ring. /// Squared inner radius (normalized) of the dark-limb outline ring.
const RING_INNER_SQ: f32 = 0.82; const RING_INNER_SQ: f32 = 0.82;
/// Lunar maria as `(cx, cy, radius²)` in normalized disc coordinates
/// (x right, y down), loosely after the near side's real maria. On the
/// sunlit disc a mare dot is drawn in the resting gray (a dark blotch);
/// on the dark limb it is drawn in the same gray, which reads as a faint
/// light patch against the empty limb.
const MARIA: &[(f32, f32, f32)] = &[
(-0.40, -0.42, 0.018), // Imbrium
(0.12, -0.50, 0.008), // Serenitatis
(0.40, -0.22, 0.012), // Tranquillitatis
(0.55, 0.20, 0.005), // Fecunditatis
(0.62, -0.42, 0.004), // Crisium
(-0.58, 0.05, 0.010), // Procellarum
(-0.28, 0.40, 0.006), // Nubium
(0.08, 0.12, 0.004), // Vaporum
];
fn in_mare(dx: f32, dy: f32) -> bool {
MARIA.iter().any(|&(mx, my, r_sq)| {
let ex = dx - mx;
let ey = dy - my;
ex * ex + ey * ey <= r_sq
})
}
/// One logo size tier, in braille cells. /// One logo size tier, in braille cells.
#[derive(Clone, Copy, PartialEq, Eq, Debug)] #[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct MoonSize { struct MoonSize {
@@ -152,8 +177,8 @@ fn moon_cells(size: MoonSize, p: f32) -> Vec<Vec<Option<MoonCell>>> {
.map(|cell_row| { .map(|cell_row| {
(0..size.cols as i32) (0..size.cols as i32)
.map(|cell_col| { .map(|cell_col| {
let mut mask = 0u32; let mut lit_mask = 0u32;
let mut lit = false; let mut dark_mask = 0u32;
for (dot_col, col_bits) in DOT_BITS.iter().enumerate() { for (dot_col, col_bits) in DOT_BITS.iter().enumerate() {
for (dot_row, bit) in col_bits.iter().enumerate() { for (dot_row, bit) in col_bits.iter().enumerate() {
let x = cell_col * 2 + dot_col as i32; let x = cell_col * 2 + dot_col as i32;
@@ -164,15 +189,28 @@ fn moon_cells(size: MoonSize, p: f32) -> Vec<Vec<Option<MoonCell>>> {
if d_sq > 1.0 { if d_sq > 1.0 {
continue; continue;
} }
let mare = in_mare(dx, dy);
if dot_lit(dx, dy, p) { if dot_lit(dx, dy, p) {
mask |= bit; if mare {
lit = true; // Dark blotch on the sunlit disc.
} else if d_sq >= RING_INNER_SQ { dark_mask |= bit;
// Dark limb: keep the outline ring visible. } else {
mask |= bit; lit_mask |= bit;
}
} else if mare || d_sq >= RING_INNER_SQ {
// Dark limb: outline ring plus faint maria.
dark_mask |= bit;
} }
} }
} }
// A braille cell holds a single color, so a cell with any
// sunlit dots renders only those (mare dots in it stay
// background-dark); otherwise its dark dots render gray.
let (mask, lit) = if lit_mask != 0 {
(lit_mask, true)
} else {
(dark_mask, false)
};
(mask != 0).then(|| MoonCell { (mask != 0).then(|| MoonCell {
ch: char::from_u32(0x2800 + mask).expect("braille block"), ch: char::from_u32(0x2800 + mask).expect("braille block"),
lit, lit,
@@ -407,6 +445,32 @@ mod tests {
); );
} }
#[test]
fn maria_texture_the_disc_in_both_extremes() {
// Full moon: mare dots stay dark, so the drawn glyphs must cover
// fewer dots than the geometric disc (lit_dots ignores maria).
let full = moon_cells(FULL, 0.5);
let drawn_dots: u32 = full
.iter()
.flatten()
.flatten()
.map(|c| (c.ch as u32 - 0x2800).count_ones())
.sum();
assert!(
(drawn_dots as usize) < lit_dots(0.5),
"full moon must keep dark maria holes"
);
// New moon: maria show as drawn (gray) cells well inside the outline
// ring — Procellarum sits around cell (5, 4) on the full-size grid.
let new = moon_cells(FULL, 0.0);
assert!(
new[5][4].is_some(),
"new moon must show maria inside the ring"
);
assert!(in_mare(-0.58, 0.05), "Procellarum anchors the maria map");
assert!(!in_mare(0.0, 0.85), "south pole stays mare-free");
}
#[test] #[test]
fn moon_raster_is_round_and_fills_the_grid() { fn moon_raster_is_round_and_fills_the_grid() {
// The disc must span (nearly) the whole cell grid in both axes at // The disc must span (nearly) the whole cell grid in both axes at
+13 -374
View File
@@ -7,7 +7,7 @@
//! - Bottom margin //! - Bottom margin
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Position, Rect}; use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap}; use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
@@ -56,22 +56,12 @@ fn quit_hint_spans(theme: &Theme) -> Vec<Span<'static>> {
] ]
} }
/// Style for a clickable welcome block: bright primary while `hovered`, else
/// `base`. Shared by the changelog renderer.
pub(super) fn hover_style(theme: &Theme, hovered: bool, base: Style) -> Style {
if hovered {
Style::default().fg(theme.text_primary)
} else {
base
}
}
/// Horizontal margin (left and right) in normal mode. /// Horizontal margin (left and right) in normal mode.
const H_MARGIN: u16 = 2; const H_MARGIN: u16 = 2;
/// Horizontal margin in compact mode. /// Horizontal margin in compact mode.
const H_MARGIN_COMPACT: u16 = 1; const H_MARGIN_COMPACT: u16 = 1;
/// Minimum width for menu + changelog sections so they don't resize when the import row toggles. /// Minimum width for the menu section so it doesn't resize when the import row toggles.
/// Derivation: "[ " (2) + import-claude label (22) + gap (4) + "ctrl+i [x]" (11) + " ]" (2) = 41. /// Derivation: "[ " (2) + import-claude label (22) + gap (4) + "ctrl+i [x]" (11) + " ]" (2) = 41.
/// Bumped to 51 for comfortable breathing room. /// Bumped to 51 for comfortable breathing room.
const MENU_MIN_WIDTH: u16 = 51; const MENU_MIN_WIDTH: u16 = 51;
@@ -103,13 +93,6 @@ pub struct WelcomeRenderResult {
pub auth_url_rect: Option<Rect>, pub auth_url_rect: Option<Rect>,
/// Hit-test rect for the "show full URL" fallback link. /// Hit-test rect for the "show full URL" fallback link.
pub auth_fallback_rect: Option<Rect>, pub auth_fallback_rect: Option<Rect>,
/// Hit-test rect for the "[Refresh]" button on the paywall tier line.
/// Whether a "Changelog" menu action was rendered (above Quit), so the
/// input handler can map the extra menu row to the release-notes action
/// once markdown is available.
pub changelog_action_present: bool,
/// Hit-test rect for the clickable changelog info block (opens release notes).
pub changelog_cta_rect: Option<Rect>,
} }
use hero_box::HERO_BOX_MIN_WIDTH; use hero_box::HERO_BOX_MIN_WIDTH;
@@ -124,9 +107,6 @@ pub(super) struct WelcomeLayout {
pub(super) logo: Rect, pub(super) logo: Rect,
pub(super) error: Rect, pub(super) error: Rect,
pub(super) menu: Rect, pub(super) menu: Rect,
/// Stacked info slot below the menu (narrow layout only) — shows the
/// changelog. Zero in the hero box layout, which uses `hero_info` instead.
pub(super) changelog: Rect,
pub(super) tip: Rect, pub(super) tip: Rect,
pub(super) prompt: Rect, pub(super) prompt: Rect,
pub(super) version: Rect, pub(super) version: Rect,
@@ -135,8 +115,6 @@ pub(super) struct WelcomeLayout {
pub(super) hero_logo: Rect, pub(super) hero_logo: Rect,
pub(super) hero_version: Rect, pub(super) hero_version: Rect,
pub(super) hero_subtitle: Rect, pub(super) hero_subtitle: Rect,
/// In-box info slot — shows the changelog.
pub(super) hero_info: Rect,
pub(super) hero_menu: Rect, pub(super) hero_menu: Rect,
} }
@@ -151,9 +129,7 @@ struct WelcomeLayoutInput {
error_height: u16, error_height: u16,
menu_height: u16, menu_height: u16,
tip_height: u16, tip_height: u16,
/// Desired changelog height (collapsed to 0 if the terminal is too short). /// Vertical compaction (session picker visible): skip the logo.
changelog_height: u16,
/// Vertical compaction (session picker visible): skip the logo + info slot.
compact: bool, compact: bool,
/// Horizontal-inset compaction (appearance setting) for the stacked slot. /// Horizontal-inset compaction (appearance setting) for the stacked slot.
prompt_compact: bool, prompt_compact: bool,
@@ -170,22 +146,6 @@ impl WelcomeLayout {
tip_height + tip_gap + PROMPT_HEIGHT + VERSION_GAP + 1 tip_height + tip_gap + PROMPT_HEIGHT + VERSION_GAP + 1
} }
pub(super) fn effective_changelog(
content_height: u16,
fixed_above: u16,
content_slot: u16,
fixed_below: u16,
requested: u16,
) -> (u16, u16) {
let gap = if requested > 0 { 1u16 } else { 0 };
let min_without = fixed_above + content_slot + 1 + fixed_below;
if requested > 0 && content_height >= min_without + gap + requested {
(requested, 1)
} else {
(0, 0)
}
}
/// Compute the welcome screen layout, allowing the wide hero-box variant. /// Compute the welcome screen layout, allowing the wide hero-box variant.
fn compute(input: WelcomeLayoutInput) -> Self { fn compute(input: WelcomeLayoutInput) -> Self {
Self::compute_inner(input, true) Self::compute_inner(input, true)
@@ -203,17 +163,14 @@ impl WelcomeLayout {
/// Compute the welcome screen layout. /// Compute the welcome screen layout.
/// ///
/// Picks hero vs stacked, then measures the info slot (the changelog) at /// Picks hero vs stacked. `allow_hero_box` gates the wide variant;
/// that layout's slot width before placing rects — width is /// stacked-only callers pass `false`.
/// content-size-only, so it's a clean two-phase computation. `allow_hero_box`
/// gates the wide variant; stacked-only callers pass `false`.
fn compute_inner(input: WelcomeLayoutInput, allow_hero_box: bool) -> Self { fn compute_inner(input: WelcomeLayoutInput, allow_hero_box: bool) -> Self {
let WelcomeLayoutInput { let WelcomeLayoutInput {
content_area, content_area,
error_height, error_height,
menu_height, menu_height,
tip_height, tip_height,
changelog_height,
compact, compact,
prompt_compact, prompt_compact,
} = input; } = input;
@@ -224,26 +181,12 @@ impl WelcomeLayout {
&& content_area.width >= HERO_BOX_MIN_WIDTH && content_area.width >= HERO_BOX_MIN_WIDTH
&& menu_height > 0 && menu_height > 0
&& content_area.height && content_area.height
>= hero_box::min_content_height( >= hero_box::min_content_height(error_height, menu_height, tip_height);
error_height,
menu_height,
tip_height,
changelog_height,
);
if use_hero_box { if use_hero_box {
return hero_box::compute_hero_box( return hero_box::compute_hero_box(content_area, error_height, menu_height, tip_height);
content_area,
error_height,
menu_height,
tip_height,
changelog_height,
);
} }
// Stacked info slot: the changelog.
let info_height = changelog_height;
// Stacked layout: skip the logo in compact mode (the session picker // Stacked layout: skip the logo in compact mode (the session picker
// needs the space); otherwise pick small/full/none by height. // needs the space); otherwise pick small/full/none by height.
let logo_rows = if compact { let logo_rows = if compact {
@@ -256,19 +199,6 @@ impl WelcomeLayout {
let tip_gap = if tip_height > 0 { 1u16 } else { 0 }; let tip_gap = if tip_height > 0 { 1u16 } else { 0 };
let fixed_below = Self::fixed_below(tip_height); let fixed_below = Self::fixed_below(tip_height);
let fixed_above = logo_rows + 1 + gap_after_logo + error_height; // +1 for gap after logo let fixed_above = logo_rows + 1 + gap_after_logo + error_height; // +1 for gap after logo
// The stacked info slot below the menu holds the changelog.
let (eff_changelog_height, _) = if !compact {
Self::effective_changelog(
content_area.height,
fixed_above,
menu_height,
fixed_below,
info_height,
)
} else {
(0, 0)
};
let eff_changelog_gap = if eff_changelog_height > 0 { 1u16 } else { 0 };
// Compute top_pad using the *default* menu height (4 items = 7 rows) so // Compute top_pad using the *default* menu height (4 items = 7 rows) so
// the logo position stays constant regardless of picker/focus state. // the logo position stays constant regardless of picker/focus state.
let top_pad = if compact { let top_pad = if compact {
@@ -278,36 +208,18 @@ impl WelcomeLayout {
let remaining = content_area.height.saturating_sub(fixed_above); let remaining = content_area.height.saturating_sub(fixed_above);
remaining remaining
.saturating_sub(default_menu_height) .saturating_sub(default_menu_height)
.saturating_sub(eff_changelog_gap + eff_changelog_height)
.saturating_sub(fixed_below) .saturating_sub(fixed_below)
/ 3 / 3
}; };
let logo_gap = 1u16; let logo_gap = 1u16;
let flex_gap = 1u16; let flex_gap = 1u16;
let [ let [_, logo, _, _, error, menu, _, tip, _, prompt, _, version] = Layout::vertical([
_,
logo,
_,
_,
error,
menu,
_,
changelog,
_,
tip,
_,
prompt,
_,
version,
] = Layout::vertical([
Constraint::Length(top_pad), Constraint::Length(top_pad),
Constraint::Length(logo_rows), Constraint::Length(logo_rows),
Constraint::Length(logo_gap), // gap after logo Constraint::Length(logo_gap), // gap after logo
Constraint::Length(gap_after_logo), Constraint::Length(gap_after_logo),
Constraint::Length(error_height), Constraint::Length(error_height),
Constraint::Length(menu_height), Constraint::Length(menu_height),
Constraint::Length(eff_changelog_gap),
Constraint::Length(eff_changelog_height),
Constraint::Min(flex_gap), Constraint::Min(flex_gap),
Constraint::Length(tip_height), Constraint::Length(tip_height),
Constraint::Length(tip_gap), Constraint::Length(tip_gap),
@@ -320,7 +232,6 @@ impl WelcomeLayout {
logo, logo,
error, error,
menu, menu,
changelog,
tip, tip,
prompt, prompt,
version, version,
@@ -328,7 +239,6 @@ impl WelcomeLayout {
hero_logo: zero, hero_logo: zero,
hero_version: zero, hero_version: zero,
hero_subtitle: zero, hero_subtitle: zero,
hero_info: zero,
hero_menu: zero, hero_menu: zero,
} }
} }
@@ -566,10 +476,6 @@ pub struct WelcomeRenderParams<'a> {
/// Live working directory (tracks `Effect::SetWorkingDir`), used to pin /// Live working directory (tracks `Effect::SetWorkingDir`), used to pin
/// the current repo's session group to the top of the picker. /// the current repo's session group to the top of the picker.
pub cwd: &'a std::path::Path, pub cwd: &'a std::path::Path,
/// Cached changelog bullets for the welcome screen (up to 3).
pub changelog_bullets: &'a [String],
/// Whether full release notes markdown is available (controls the CTA hint).
pub changelog_has_full_notes: bool,
} }
/// Render the welcome screen. /// Render the welcome screen.
@@ -642,8 +548,6 @@ pub fn render_welcome(
import_banner_rect: None, import_banner_rect: None,
auth_url_rect: None, auth_url_rect: None,
auth_fallback_rect: None, auth_fallback_rect: None,
changelog_action_present: false,
changelog_cta_rect: None,
} }
} }
AuthState::Authenticating { auth_url, mode, .. } => { AuthState::Authenticating { auth_url, mode, .. } => {
@@ -668,8 +572,6 @@ pub fn render_welcome(
import_banner_rect: None, import_banner_rect: None,
auth_url_rect: url_rect, auth_url_rect: url_rect,
auth_fallback_rect: fallback_rect, auth_fallback_rect: fallback_rect,
changelog_action_present: false,
changelog_cta_rect: None,
} }
} }
// Folder-trust question: shown after auth, before any session is // Folder-trust question: shown after auth, before any session is
@@ -1450,73 +1352,6 @@ fn inset_horizontal(rect: Rect, inset: u16) -> Rect {
} }
} }
/// Render the changelog section (header + bullets), centered to the menu width.
/// When `clickable` (full notes exist) the whole block opens the notes on click
/// and brightens while hovered; returns that clickable rect.
#[allow(clippy::too_many_arguments)]
fn render_changelog_section(
area: Rect,
buf: &mut Buffer,
theme: &Theme,
bullets: &[String],
min_width_hint: u16,
content_height: u16,
clickable: bool,
mouse_pos: Option<(u16, u16)>,
) -> Option<Rect> {
let menu_width = logo::logo_visual_width(content_height)
.max(30)
.max(min_width_hint);
let [_, centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(menu_width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(area);
if centered.width < 20 || centered.height == 0 {
return None;
}
let hovered =
clickable && mouse_pos.is_some_and(|(mx, my)| centered.contains(Position::new(mx, my)));
let header_style = hover_style(
theme,
hovered,
Style::default()
.fg(theme.gray_bright)
.add_modifier(Modifier::DIM),
);
let title = "Changelog";
buf.set_span(
centered.x,
centered.y,
&Span::styled(title, header_style),
centered.width,
);
let bullet_style = hover_style(theme, hovered, Style::default().fg(theme.gray_bright));
let max_text_width = centered.width.saturating_sub(2) as usize; // "• " prefix = 2 cols
for (i, bullet) in bullets.iter().enumerate() {
let row = centered.y + 2 + i as u16;
if row >= centered.y + centered.height {
break;
}
let truncated = crate::render::line_utils::truncate_str(bullet, max_text_width);
let text = format!("\u{2022} {truncated}");
buf.set_span(
centered.x,
row,
&Span::styled(text, bullet_style),
centered.width,
);
}
clickable.then_some(centered)
}
/// Render the normal welcome screen (Done state -- already authenticated). /// Render the normal welcome screen (Done state -- already authenticated).
fn render_welcome_done( fn render_welcome_done(
content_area: Rect, content_area: Rect,
@@ -1535,8 +1370,6 @@ fn render_welcome_done(
let in_vscode_family = welcome_in_vscode_family(); let in_vscode_family = welcome_in_vscode_family();
// Heights that don't depend on the menu — computed first so the menu
// builder can probe the layout to decide whether to add a Changelog row.
// Startup-warning hint height (multi-line aware). // Startup-warning hint height (multi-line aware).
let hint_height = p.startup_warnings.first().map_or(0u16, |w| { let hint_height = p.startup_warnings.first().map_or(0u16, |w| {
let msg_lines = w.message.lines().count() as u16; let msg_lines = w.message.lines().count() as u16;
@@ -1558,14 +1391,6 @@ fn render_welcome_done(
} else { } else {
0 0
}; };
let changelog_height = if !show_picker && !p.changelog_bullets.is_empty() {
2 + p.changelog_bullets.len() as u16
} else {
0
};
// Changelog is reachable via this menu row (ctrl+l). Show from the first
// frame so the menu doesn't shift while the CDN fetch completes.
let show_changelog_action = !show_picker;
let owned_menu; let owned_menu;
let menu_items: &[(&str, &str)] = { let menu_items: &[(&str, &str)] = {
@@ -1577,7 +1402,7 @@ fn render_welcome_done(
); );
// Insert the import row at the top when there are pending `.claude/` // Insert the import row at the top when there are pending `.claude/`
// settings to import — it's the most actionable item right now. // settings to import — it's the most actionable item right now.
let mut items: Vec<(&str, &str)> = Vec::with_capacity(5); let mut items: Vec<(&str, &str)> = Vec::with_capacity(4);
if p.has_claude_import { if p.has_claude_import {
// The trailing "[x]" is a clickable dismiss affordance — the // The trailing "[x]" is a clickable dismiss affordance — the
// welcome screen mouse handler treats clicks on the rightmost // welcome screen mouse handler treats clicks on the rightmost
@@ -1588,10 +1413,6 @@ fn render_welcome_done(
} }
items.push((key_w, "New worktree")); items.push((key_w, "New worktree"));
items.push((key_s, "Resume session")); items.push((key_s, "Resume session"));
// "Changelog" above Quit; no shortcut — opened by click (row or block).
if show_changelog_action {
items.push(("", "Changelog"));
}
items.push((key_q, "Quit")); items.push((key_q, "Quit"));
owned_menu = items; owned_menu = items;
owned_menu.as_slice() owned_menu.as_slice()
@@ -1620,7 +1441,6 @@ fn render_welcome_done(
error_height: hint_height, error_height: hint_height,
menu_height: content_height, menu_height: content_height,
tip_height, tip_height,
changelog_height,
compact: welcome_compact, compact: welcome_compact,
prompt_compact: p.compact, prompt_compact: p.compact,
}); });
@@ -1628,9 +1448,6 @@ fn render_welcome_done(
// Render startup warning in the error area (same slot as auth errors). // Render startup warning in the error area (same slot as auth errors).
let import_banner_rect = render_startup_warnings(layout.error, buf, theme, p.startup_warnings); let import_banner_rect = render_startup_warnings(layout.error, buf, theme, p.startup_warnings);
// Hit-rects, set by whichever layout draws each block.
let mut changelog_cta_rect: Option<Rect> = None;
let (menu_rects, picker_close_button) = if show_picker { let (menu_rects, picker_close_button) = if show_picker {
// Use the full area since logo/menu are hidden and shortcuts // Use the full area since logo/menu are hidden and shortcuts
// are now rendered inside the picker content area. // are now rendered inside the picker content area.
@@ -1663,20 +1480,9 @@ fn render_welcome_done(
(vec![], Some(hit_areas)) (vec![], Some(hit_areas))
} else if layout.has_hero_box() { } else if layout.has_hero_box() {
// Wide layout: render bordered hero box with logo left, version + menu right. // Wide layout: render bordered hero box with logo left, version + menu right.
let rects = hero_box::render_hero_box( let menu_rects =
&layout, hero_box::render_hero_box(&layout, buf, theme, menu_items, p.selected, p.mouse_pos);
buf, (menu_rects, None)
theme,
menu_items,
p.selected,
p.mouse_pos,
hero_box::ChangelogDisplay {
bullets: p.changelog_bullets,
has_full_notes: p.changelog_has_full_notes,
},
);
changelog_cta_rect = rects.changelog_cta_rect;
(rects.menu_rects, None)
} else { } else {
// Narrow layout: stacked logo above, menu below. Inset the menu the // Narrow layout: stacked logo above, menu below. Inset the menu the
// same as the input bar (`prompt_inset`) so it keeps side spacing // same as the input bar (`prompt_inset`) so it keeps side spacing
@@ -1697,23 +1503,6 @@ fn render_welcome_done(
) )
}; };
// Stacked info slot below the menu (narrow layout): show the changelog,
// mirroring the hero box. Inset to match the input bar so it lines up with
// the menu above.
if layout.changelog.height > 0 {
let info_area = inset_horizontal(layout.changelog, prompt::prompt_inset(p.compact));
changelog_cta_rect = render_changelog_section(
info_area,
buf,
theme,
p.changelog_bullets,
MENU_MIN_WIDTH,
content_area.height,
p.changelog_has_full_notes,
p.mouse_pos,
);
}
// Skip the prompt input when picker is visible to save space; // Skip the prompt input when picker is visible to save space;
// shortcuts are rendered inside the picker content area. // shortcuts are rendered inside the picker content area.
let (cursor_pos, post_flush_escapes) = if show_picker { let (cursor_pos, post_flush_escapes) = if show_picker {
@@ -1840,8 +1629,6 @@ fn render_welcome_done(
import_banner_rect, import_banner_rect,
auth_url_rect: None, auth_url_rect: None,
auth_fallback_rect: None, auth_fallback_rect: None,
changelog_action_present: show_changelog_action,
changelog_cta_rect,
} }
} }
@@ -2278,8 +2065,6 @@ mod tests {
session_picker_source_filter: crate::views::session_picker::SourceFilter::All, session_picker_source_filter: crate::views::session_picker::SourceFilter::All,
chat_mode: false, chat_mode: false,
cwd: std::path::Path::new("/repo"), cwd: std::path::Path::new("/repo"),
changelog_bullets: &[],
changelog_has_full_notes: false,
} }
} }
@@ -2747,106 +2532,6 @@ mod tests {
assert_eq!(state.query, "e"); assert_eq!(state.query, "e");
} }
#[test]
fn changelog_hidden_on_short_terminal() {
let area = Rect::new(0, 0, 80, 15);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
}
#[test]
fn changelog_shown_on_tall_terminal() {
let area = Rect::new(0, 0, 80, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 5);
}
#[test]
fn changelog_hidden_when_compact() {
let area = Rect::new(0, 0, 80, 60);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
changelog_height: 5,
compact: true,
prompt_compact: true,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
}
#[test]
fn changelog_hidden_when_zero_requested() {
let area = Rect::new(0, 0, 80, 60);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
}
#[test]
fn changelog_boundary_exact_fit() {
// No logo at h < 22. fixed_above = 0 + 1 + 0 + 0 = 1.
// fixed_below = 0 (tip) + 0 (tip_gap) + 3 (prompt) + 1 (ver_gap) + 1 (ver) = 5.
// min_without_changelog = 1 + 4 (menu) + 1 (flex) + 5 = 11.
// changelog slot = 1 (gap) + 5 (height) = 6. Threshold = 11 + 6 = 17.
let just_fits = Rect::new(0, 0, 80, 17);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: just_fits,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 5);
let too_short = Rect::new(0, 0, 80, 16);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: too_short,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
}
#[test]
fn changelog_hidden_when_tip_steals_space() {
// Use narrow width to avoid hero box path, keeping stacked layout.
// With tip_height=2: fixed_below(2) = 8. min = 1 + 4 + 1 + 8 = 14.
// Threshold = 14 + 6 = 20. At h=19 the tip pushes changelog out.
let with_tip = Rect::new(0, 0, 60, 19);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: with_tip,
menu_height: 4,
tip_height: 2,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 0);
// Same size without tip: threshold = 17 <= 19, changelog fits.
let without_tip = Rect::new(0, 0, 60, 19);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: without_tip,
menu_height: 4,
changelog_height: 5,
..Default::default()
});
assert_eq!(layout.changelog.height, 5);
}
#[test] #[test]
fn hero_box_active_on_wide_tall_terminal() { fn hero_box_active_on_wide_tall_terminal() {
// 90 cols, 50 rows: meets the minimum for the hero box. // 90 cols, 50 rows: meets the minimum for the hero box.
@@ -2866,6 +2551,7 @@ mod tests {
assert!(layout.hero_logo.height > 0); assert!(layout.hero_logo.height > 0);
assert!(layout.hero_menu.height > 0); assert!(layout.hero_menu.height > 0);
assert_eq!(layout.hero_version.height, 1); assert_eq!(layout.hero_version.height, 1);
assert_eq!(layout.hero_subtitle.height, 1);
} }
#[test] #[test]
@@ -3056,53 +2742,6 @@ mod tests {
assert_eq!(layout.hero_logo.y, layout.hero_box.y + 2); assert_eq!(layout.hero_logo.y, layout.hero_box.y + 2);
} }
#[test]
fn hero_box_with_changelog() {
// The changelog renders inside the box (info slot), not in a
// separate area below it.
let area = Rect::new(0, 0, 100, 50);
let layout = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 3,
changelog_height: 5,
..Default::default()
});
assert!(layout.has_hero_box());
assert_eq!(layout.changelog.height, 0);
assert_eq!(layout.hero_info.height, 5);
// The subtitle is hidden when the info slot is shown.
assert_eq!(layout.hero_subtitle.height, 0);
assert!(layout.hero_info.y > layout.hero_version.y);
}
#[test]
fn hero_box_keeps_one_bottom_pad_below_actions() {
// With a changelog the subtitle is hidden, but there's still exactly
// one padding row between the actions and the bottom border.
// (menu=4 + info=3 fills the inner, so the menu reaches the pad.)
let area = Rect::new(0, 0, 100, 50);
let no_info = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
..Default::default()
});
let with_info = WelcomeLayout::compute(WelcomeLayoutInput {
content_area: area,
menu_height: 4,
changelog_height: 3,
..Default::default()
});
assert_eq!(no_info.hero_subtitle.height, 1);
assert_eq!(with_info.hero_subtitle.height, 0);
let menu_bottom = with_info.hero_menu.y + with_info.hero_menu.height;
let border_bottom = with_info.hero_box.y + with_info.hero_box.height - 1;
assert_eq!(
border_bottom - menu_bottom,
1,
"one pad row below the actions"
);
}
/// Flatten a rendered buffer into one string for substring assertions. /// Flatten a rendered buffer into one string for substring assertions.
fn buffer_text(buf: &Buffer) -> String { fn buffer_text(buf: &Buffer) -> String {
let area = *buf.area(); let area = *buf.area();
@@ -1,52 +0,0 @@
name: release-notes-scroll
description: >
Open /release-notes (DocViewer modal) and verify keyboard + mouse-wheel scrolling
moves the changelog body. Seeds CHANGELOG cache offline via setup is not available
in YAML, so this scenario relies on CDN/cache if present and still asserts the
modal chrome + scroll affordances. Prefer the pty_e2e Rust tests for deterministic
offline seeding; this YAML exercises the scripted ptyctl runner path.
terminal:
rows: 40
cols: 110
mock:
response: "SCRIPTED_RELEASE_NOTES_SCROLL unused for this scenario."
steps:
- action: wait_for_text
text: Quit
timeout_ms: 20000
# Promote welcome → session and open release notes (uses CDN or disk cache).
- action: type_text
text: "/release-notes"
- action: keys
keys: "<Enter>"
- action: wait_for_text
text: Release Notes
timeout_ms: 20000
- action: assert_contains
text: scroll
# Keyboard scroll through the modal body.
- action: keys
keys: "<Down><Down><Down><Down><Down><Down><Down><Down><Down><Down>"
- action: wait
millis: 200
- action: keys
keys: "jjjjjjjjjj"
- action: wait
millis: 200
# Mouse wheel at the center of the modal.
- action: scroll
row: 20
col: 55
direction: down
count: 15
- action: wait
millis: 250
- action: assert_contains
text: Release Notes
- action: assert_not_contains
text: panicked
- action: keys
keys: "<Esc>"
- action: screenshot
name: release-notes-after-scroll
note: Release notes modal after keyboard + wheel scroll.
@@ -64,12 +64,6 @@ async fn scripted_slash_resize_storm() {
run_scenario("slash_resize_storm.yaml").await; run_scenario("slash_resize_storm.yaml").await;
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn scripted_release_notes_scroll() {
run_scenario("release_notes_scroll.yaml").await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore] #[ignore]
async fn scripted_mock_response() { async fn scripted_mock_response() {
+11 -7
View File
@@ -131,16 +131,20 @@ try {
Write-Host "" Write-Host ""
Write-Host "kigi v$ResolvedVersion installed to $Dest" Write-Host "kigi v$ResolvedVersion installed to $Dest"
# -contains instead of Where-Object/.Count: under Set-StrictMode, .Count
# on an empty (null) filter result throws PropertyNotFoundStrict — which
# fired on every fresh install, since that's exactly the not-on-PATH case.
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") $UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
$OnPath = ($UserPath -split ";" | Where-Object { $_ -eq $BinDir }).Count -gt 0 -or $OnPath = (($UserPath -split ";") -contains $BinDir) -or
($env:Path -split ";" | Where-Object { $_ -eq $BinDir }).Count -gt 0 (($env:Path -split ";") -contains $BinDir)
if (-not $OnPath) { if (-not $OnPath) {
# Persist the bin dir on the per-user PATH so the user doesn't have
# to. Registry-backed; every new terminal picks it up automatically.
$NewUserPath = if ($UserPath) { "$BinDir;$UserPath" } else { $BinDir }
[Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User")
Write-Host "" Write-Host ""
Write-Host "$BinDir is not on your PATH. Add it for the current user with:" Write-Host "Added $BinDir to your user PATH."
Write-Host "" Write-Host "Open a new terminal, then run 'kigi' to get started."
Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$BinDir;`" + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')"
Write-Host ""
Write-Host "Then open a new terminal and run 'kigi' to get started."
} else { } else {
Write-Host "Run 'kigi' to get started." Write-Host "Run 'kigi' to get started."
} }
+40 -4
View File
@@ -191,9 +191,45 @@ case ":$PATH:" in
printf 'Run `kigi` to get started.\n' printf 'Run `kigi` to get started.\n'
;; ;;
*) *)
printf '\n%s is not on your PATH. Add it with:\n\n' "$BIN_DIR" # Persist BIN_DIR on PATH in the login shell's rc file, so the user
printf ' export PATH="%s:$PATH" # sh / bash / zsh (add to your shell rc)\n' "$BIN_DIR" # doesn't have to. Idempotent: skipped when the rc already mentions
printf ' fish_add_path %s # fish\n\n' "$BIN_DIR" # the bin dir. On write failure the manual command is printed and the
printf 'Then run `kigi` to get started.\n' # script fails loudly (the binary itself is already installed).
persist_line() {
rc="$1"
line="$2"
if [ -f "$rc" ] && grep -qF "$BIN_DIR" "$rc"; then
printf '\n%s is already configured in %s.\n' "$BIN_DIR" "$rc"
return 0
fi
printf '\n# Added by the kigi installer\n%s\n' "$line" >> "$rc" \
|| err "could not write $rc — add kigi to your PATH manually: $line"
printf '\nAdded %s to your PATH in %s.\n' "$BIN_DIR" "$rc"
}
EXPORT_LINE="export PATH=\"$BIN_DIR:\$PATH\""
case "${SHELL:-}" in
*/zsh)
persist_line "${ZDOTDIR:-$HOME}/.zshrc" "$EXPORT_LINE"
;;
*/bash)
# macOS login shells read ~/.bash_profile; Linux reads ~/.bashrc.
if [ "$PLATFORM_OS" = "macos" ]; then
persist_line "$HOME/.bash_profile" "$EXPORT_LINE"
else
persist_line "$HOME/.bashrc" "$EXPORT_LINE"
fi
;;
*/fish)
# fish_add_path in config.fish is fish's own idempotent way
# to persist a PATH entry.
FISH_CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/fish"
mkdir -p "$FISH_CONF_DIR"
persist_line "$FISH_CONF_DIR/config.fish" "fish_add_path $BIN_DIR"
;;
*)
persist_line "$HOME/.profile" "$EXPORT_LINE"
;;
esac
printf 'Open a new terminal, then run `kigi` to get started.\n'
;; ;;
esac esac