diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index 74083f5..0000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-version: 2
-updates:
- - package-ecosystem: gomod
- directory: /
- schedule:
- interval: weekly
- commit-message:
- prefix: deps
- labels:
- - dependencies
- groups:
- go-modules:
- patterns:
- - "*"
diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml
deleted file mode 100644
index 8296a83..0000000
--- a/.github/workflows/automerge.yml
+++ /dev/null
@@ -1,16 +0,0 @@
-name: automerge
-
-on:
- pull_request:
- types: [opened, synchronize, reopened]
-
-permissions:
- contents: write
- pull-requests: write
-
-jobs:
- automerge:
- uses: ivoronin/github-workflows/.github/workflows/automerge.yml@main
- with:
- dependabot: true
- secrets: inherit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 524e543..3041d2c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,7 +3,7 @@ name: release
on:
push:
tags:
- - 'v*'
+ - "v*"
permissions:
contents: write
@@ -11,8 +11,8 @@ permissions:
jobs:
release:
- uses: ivoronin/github-workflows/.github/workflows/release.yml@main
+ uses: ivoronin/github-workflows/.github/workflows/release.yml@a56926c483d814dd0ab9543491d07ac64fd49b2f # main
with:
- language: go
+ language: zig
brew: true
secrets: inherit
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 024f78b..09abf8b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -6,8 +6,11 @@ on:
pull_request:
branches: [main]
+permissions:
+ contents: read
+
jobs:
test:
- uses: ivoronin/github-workflows/.github/workflows/test.yml@main
+ uses: ivoronin/github-workflows/.github/workflows/test.yml@a56926c483d814dd0ab9543491d07ac64fd49b2f # main
with:
- language: go
+ language: zig
diff --git a/.gitignore b/.gitignore
index d9de979..378c6be 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,48 +1,7 @@
-# Binaries
-bin/
+.zig-cache/
+zig-out/
dist/
-*.exe
-*.exe~
-*.dll
-*.so
-*.dylib
-
-# Test binary
-*.test
-
-# Coverage
-*.out
-coverage.html
-
-# IDE / AI
-.idea/
-.vscode/
+zig-pkg/
+/release/
.claude/
-*.swp
-*.swo
-*~
-
-# OS
-.DS_Store
-Thumbs.db
-
-# Go
-vendor/
-
-# wch recordings
-*.wch.jsonl
-
-# Python
-.venv/
-__pycache__/
-*.pyc
-*.pyo
-.ruff_cache/
-*.egg-info/
-
-# Node
-node_modules/
-
-# Docs (keep only README)
-*.md
-!README.md
+.wayfinder/
diff --git a/.golangci.yml b/.golangci.yml
deleted file mode 100644
index 18e6693..0000000
--- a/.golangci.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-version: "2"
-
-formatters:
- enable:
- - gofmt
-
-linters:
- enable:
- - govet
- - errcheck
- - staticcheck
- - unused
- - ineffassign
-
-linters-settings:
- errcheck:
- check-type-assertions: true
-
-issues:
- exclude-use-default: false
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index ed2a81c..2985145 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -1,49 +1,16 @@
-# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
-before:
- hooks:
- - go mod tidy
-
builds:
- - main: ./cmd/{{ .ProjectName }}
- binary: "{{ .ProjectName }}"
- env:
- - CGO_ENABLED=0
- goos:
- - linux
- - darwin
- - windows
- goarch:
- - amd64
- - arm64
- ldflags:
- - -s -w
- - -X main.version={{ .Version }}
+ - builder: zig
+ binary: wch
+ flags:
+ - -Doptimize=ReleaseSafe
+ - "-Dversion={{ .Version }}"
+ targets:
+ - x86_64-linux
+ - aarch64-linux
+ - x86_64-macos
+ - aarch64-macos
archives:
- - formats:
- - tar.gz
- format_overrides:
- - goos: windows
- formats:
- - zip
- name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
- files:
- - LICENSE
- - README.md
-
-checksum:
- name_template: checksums.txt
-
-changelog:
- sort: asc
- filters:
- exclude:
- - '^docs:'
- - '^test:'
- - '^ci:'
- - '^chore:'
-
-snapshot:
- version_template: "{{ incpatch .Version }}-next"
+ - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
diff --git a/.tool-versions b/.tool-versions
deleted file mode 100644
index 8f33385..0000000
--- a/.tool-versions
+++ /dev/null
@@ -1 +0,0 @@
-golangci-lint 2.8.0
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..fd0b702
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,35 @@
+# Watch TUI
+
+`wch` repeatedly runs one command and keeps recent output available in a terminal interface.
+
+## Language
+
+**Run**:
+One execution attempt of the watched command, with its completion time and captured output.
+_Avoid_: Sample, result
+
+**Output**:
+The normalized, display-ready form of one run.
+_Avoid_: Picture, pane content
+
+**History**:
+The retained sequence of distinct runs, ordered from oldest to newest.
+_Avoid_: Log, archive
+
+**History cursor**:
+A stable selection that can be resolved while its run remains in history.
+_Avoid_: Anchor, index
+
+**Live mode**:
+The mode that follows the newest run.
+
+**History mode**:
+The mode that shows one selected run instead of following the newest run.
+
+**Viewport**:
+The scrollable visible region of output.
+_Avoid_: Pane, picture
+
+**Status bar**:
+The bottom row that reports mode, run time, history position, and available keys.
+_Avoid_: Dock, footer
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index f288702..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,674 +0,0 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc.
- Everyone is permitted to copy and distribute verbatim copies
- of this license document, but changing it is not allowed.
-
- Preamble
-
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
-share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
-
- When we speak of free software, we are referring to freedom, not
-price. Our General Public Licenses are designed to make sure that you
-have the freedom to distribute copies of free software (and charge for
-them if you wish), that you receive source code or can get it if you
-want it, that you can change the software or use pieces of it in new
-free programs, and that you know you can do these things.
-
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
-
- The precise terms and conditions for copying, distribution and
-modification follow.
-
- TERMS AND CONDITIONS
-
- 0. Definitions.
-
- "This License" refers to version 3 of the GNU General Public License.
-
- "Copyright" also means copyright-like laws that apply to other kinds of
-works, such as semiconductor masks.
-
- "The Program" refers to any copyrightable work licensed under this
-License. Each licensee is addressed as "you". "Licensees" and
-"recipients" may be individuals or organizations.
-
- To "modify" a work means to copy from or adapt all or part of the work
-in a fashion requiring copyright permission, other than the making of an
-exact copy. The resulting work is called a "modified version" of the
-earlier work or a work "based on" the earlier work.
-
- A "covered work" means either the unmodified Program or a work based
-on the Program.
-
- To "propagate" a work means to do anything with it that, without
-permission, would make you directly or secondarily liable for
-infringement under applicable copyright law, except executing it on a
-computer or modifying a private copy. Propagation includes copying,
-distribution (with or without modification), making available to the
-public, and in some countries other activities as well.
-
- To "convey" a work means any kind of propagation that enables other
-parties to make or receive copies. Mere interaction with a user through
-a computer network, with no transfer of a copy, is not conveying.
-
- An interactive user interface displays "Appropriate Legal Notices"
-to the extent that it includes a convenient and prominently visible
-feature that (1) displays an appropriate copyright notice, and (2)
-tells the user that there is no warranty for the work (except to the
-extent that warranties are provided), that licensees may convey the
-work under this License, and how to view a copy of this License. If
-the interface presents a list of user commands or options, such as a
-menu, a prominent item in the list meets this criterion.
-
- 1. Source Code.
-
- The "source code" for a work means the preferred form of the work
-for making modifications to it. "Object code" means any non-source
-form of a work.
-
- A "Standard Interface" means an interface that either is an official
-standard defined by a recognized standards body, or, in the case of
-interfaces specified for a particular programming language, one that
-is widely used among developers working in that language.
-
- The "System Libraries" of an executable work include anything, other
-than the work as a whole, that (a) is included in the normal form of
-packaging a Major Component, but which is not part of that Major
-Component, and (b) serves only to enable use of the work with that
-Major Component, or to implement a Standard Interface for which an
-implementation is available to the public in source code form. A
-"Major Component", in this context, means a major essential component
-(kernel, window system, and so on) of the specific operating system
-(if any) on which the executable work runs, or a compiler used to
-produce the work, or an object code interpreter used to run it.
-
- The "Corresponding Source" for a work in object code form means all
-the source code needed to generate, install, and (for an executable
-work) run the object code and to modify the work, including scripts to
-control those activities. However, it does not include the work's
-System Libraries, or general-purpose tools or generally available free
-programs which are used unmodified in performing those activities but
-which are not part of the work. For example, Corresponding Source
-includes interface definition files associated with source files for
-the work, and the source code for shared libraries and dynamically
-linked subprograms that the work is specifically designed to require,
-such as by intimate data communication or control flow between those
-subprograms and other parts of the work.
-
- The Corresponding Source need not include anything that users
-can regenerate automatically from other parts of the Corresponding
-Source.
-
- The Corresponding Source for a work in source code form is that
-same work.
-
- 2. Basic Permissions.
-
- All rights granted under this License are granted for the term of
-copyright on the Program, and are irrevocable provided the stated
-conditions are met. This License explicitly affirms your unlimited
-permission to run the unmodified Program. The output from running a
-covered work is covered by this License only if the output, given its
-content, constitutes a covered work. This License acknowledges your
-rights of fair use or other equivalent, as provided by copyright law.
-
- You may make, run and propagate covered works that you do not
-convey, without conditions so long as your license otherwise remains
-in force. You may convey covered works to others for the sole purpose
-of having them make modifications exclusively for you, or provide you
-with facilities for running those works, provided that you comply with
-the terms of this License in conveying all material for which you do
-not control copyright. Those thus making or running the covered works
-for you must do so exclusively on your behalf, under your direction
-and control, on terms that prohibit them from making any copies of
-your copyrighted material outside their relationship with you.
-
- Conveying under any other circumstances is permitted solely under
-the conditions stated below. Sublicensing is not allowed; section 10
-makes it unnecessary.
-
- 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
-
- No covered work shall be deemed part of an effective technological
-measure under any applicable law fulfilling obligations under article
-11 of the WIPO copyright treaty adopted on 20 December 1996, or
-similar laws prohibiting or restricting circumvention of such
-measures.
-
- When you convey a covered work, you waive any legal power to forbid
-circumvention of technological measures to the extent such circumvention
-is effected by exercising rights under this License with respect to
-the covered work, and you disclaim any intention to limit operation or
-modification of the work as a means of enforcing, against the work's
-users, your or third parties' legal rights to forbid circumvention of
-technological measures.
-
- 4. Conveying Verbatim Copies.
-
- You may convey verbatim copies of the Program's source code as you
-receive it, in any medium, provided that you conspicuously and
-appropriately publish on each copy an appropriate copyright notice;
-keep intact all notices stating that this License and any
-non-permissive terms added in accord with section 7 apply to the code;
-keep intact all notices of the absence of any warranty; and give all
-recipients a copy of this License along with the Program.
-
- You may charge any price or no price for each copy that you convey,
-and you may offer support or warranty protection for a fee.
-
- 5. Conveying Modified Source Versions.
-
- You may convey a work based on the Program, or the modifications to
-produce it from the Program, in the form of source code under the
-terms of section 4, provided that you also meet all of these conditions:
-
- a) The work must carry prominent notices stating that you modified
- it, and giving a relevant date.
-
- b) The work must carry prominent notices stating that it is
- released under this License and any conditions added under section
- 7. This requirement modifies the requirement in section 4 to
- "keep intact all notices".
-
- c) You must license the entire work, as a whole, under this
- License to anyone who comes into possession of a copy. This
- License will therefore apply, along with any applicable section 7
- additional terms, to the whole of the work, and all its parts,
- regardless of how they are packaged. This License gives no
- permission to license the work in any other way, but it does not
- invalidate such permission if you have separately received it.
-
- d) If the work has interactive user interfaces, each must display
- Appropriate Legal Notices; however, if the Program has interactive
- interfaces that do not display Appropriate Legal Notices, your
- work need not make them do so.
-
- A compilation of a covered work with other separate and independent
-works, which are not by their nature extensions of the covered work,
-and which are not combined with it such as to form a larger program,
-in or on a volume of a storage or distribution medium, is called an
-"aggregate" if the compilation and its resulting copyright are not
-used to limit the access or legal rights of the compilation's users
-beyond what the individual works permit. Inclusion of a covered work
-in an aggregate does not cause this License to apply to the other
-parts of the aggregate.
-
- 6. Conveying Non-Source Forms.
-
- You may convey a covered work in object code form under the terms
-of sections 4 and 5, provided that you also convey the
-machine-readable Corresponding Source under the terms of this License,
-in one of these ways:
-
- a) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by the
- Corresponding Source fixed on a durable physical medium
- customarily used for software interchange.
-
- b) Convey the object code in, or embodied in, a physical product
- (including a physical distribution medium), accompanied by a
- written offer, valid for at least three years and valid for as
- long as you offer spare parts or customer support for that product
- model, to give anyone who possesses the object code either (1) a
- copy of the Corresponding Source for all the software in the
- product that is covered by this License, on a durable physical
- medium customarily used for software interchange, for a price no
- more than your reasonable cost of physically performing this
- conveying of source, or (2) access to copy the
- Corresponding Source from a network server at no charge.
-
- c) Convey individual copies of the object code with a copy of the
- written offer to provide the Corresponding Source. This
- alternative is allowed only occasionally and noncommercially, and
- only if you received the object code with such an offer, in accord
- with subsection 6b.
-
- d) Convey the object code by offering access from a designated
- place (gratis or for a charge), and offer equivalent access to the
- Corresponding Source in the same way through the same place at no
- further charge. You need not require recipients to copy the
- Corresponding Source along with the object code. If the place to
- copy the object code is a network server, the Corresponding Source
- may be on a different server (operated by you or a third party)
- that supports equivalent copying facilities, provided you maintain
- clear directions next to the object code saying where to find the
- Corresponding Source. Regardless of what server hosts the
- Corresponding Source, you remain obligated to ensure that it is
- available for as long as needed to satisfy these requirements.
-
- e) Convey the object code using peer-to-peer transmission, provided
- you inform other peers where the object code and Corresponding
- Source of the work are being offered to the general public at no
- charge under subsection 6d.
-
- A separable portion of the object code, whose source code is excluded
-from the Corresponding Source as a System Library, need not be
-included in conveying the object code work.
-
- A "User Product" is either (1) a "consumer product", which means any
-tangible personal property which is normally used for personal, family,
-or household purposes, or (2) anything designed or sold for incorporation
-into a dwelling. In determining whether a product is a consumer product,
-doubtful cases shall be resolved in favor of coverage. For a particular
-product received by a particular user, "normally used" refers to a
-typical or common use of that class of product, regardless of the status
-of the particular user or of the way in which the particular user
-actually uses, or expects or is expected to use, the product. A product
-is a consumer product regardless of whether the product has substantial
-commercial, industrial or non-consumer uses, unless such uses represent
-the only significant mode of use of the product.
-
- "Installation Information" for a User Product means any methods,
-procedures, authorization keys, or other information required to install
-and execute modified versions of a covered work in that User Product from
-a modified version of its Corresponding Source. The information must
-suffice to ensure that the continued functioning of the modified object
-code is in no case prevented or interfered with solely because
-modification has been made.
-
- If you convey an object code work under this section in, or with, or
-specifically for use in, a User Product, and the conveying occurs as
-part of a transaction in which the right of possession and use of the
-User Product is transferred to the recipient in perpetuity or for a
-fixed term (regardless of how the transaction is characterized), the
-Corresponding Source conveyed under this section must be accompanied
-by the Installation Information. But this requirement does not apply
-if neither you nor any third party retains the ability to install
-modified object code on the User Product (for example, the work has
-been installed in ROM).
-
- The requirement to provide Installation Information does not include a
-requirement to continue to provide support service, warranty, or updates
-for a work that has been modified or installed by the recipient, or for
-the User Product in which it has been modified or installed. Access to a
-network may be denied when the modification itself materially and
-adversely affects the operation of the network or violates the rules and
-protocols for communication across the network.
-
- Corresponding Source conveyed, and Installation Information provided,
-in accord with this section must be in a format that is publicly
-documented (and with an implementation available to the public in
-source code form), and must require no special password or key for
-unpacking, reading or copying.
-
- 7. Additional Terms.
-
- "Additional permissions" are terms that supplement the terms of this
-License by making exceptions from one or more of its conditions.
-Additional permissions that are applicable to the entire Program shall
-be treated as though they were included in this License, to the extent
-that they are valid under applicable law. If additional permissions
-apply only to part of the Program, that part may be used separately
-under those permissions, but the entire Program remains governed by
-this License without regard to the additional permissions.
-
- When you convey a copy of a covered work, you may at your option
-remove any additional permissions from that copy, or from any part of
-it. (Additional permissions may be written to require their own
-removal in certain cases when you modify the work.) You may place
-additional permissions on material, added by you to a covered work,
-for which you have or can give appropriate copyright permission.
-
- Notwithstanding any other provision of this License, for material you
-add to a covered work, you may (if authorized by the copyright holders of
-that material) supplement the terms of this License with terms:
-
- a) Disclaiming warranty or limiting liability differently from the
- terms of sections 15 and 16 of this License; or
-
- b) Requiring preservation of specified reasonable legal notices or
- author attributions in that material or in the Appropriate Legal
- Notices displayed by works containing it; or
-
- c) Prohibiting misrepresentation of the origin of that material, or
- requiring that modified versions of such material be marked in
- reasonable ways as different from the original version; or
-
- d) Limiting the use for publicity purposes of names of licensors or
- authors of the material; or
-
- e) Declining to grant rights under trademark law for use of some
- trade names, trademarks, or service marks; or
-
- f) Requiring indemnification of licensors and authors of that
- material by anyone who conveys the material (or modified versions of
- it) with contractual assumptions of liability to the recipient, for
- any liability that these contractual assumptions directly impose on
- those licensors and authors.
-
- All other non-permissive additional terms are considered "further
-restrictions" within the meaning of section 10. If the Program as you
-received it, or any part of it, contains a notice stating that it is
-governed by this License along with a term that is a further
-restriction, you may remove that term. If a license document contains
-a further restriction but permits relicensing or conveying under this
-License, you may add to a covered work material governed by the terms
-of that license document, provided that the further restriction does
-not survive such relicensing or conveying.
-
- If you add terms to a covered work in accord with this section, you
-must place, in the relevant source files, a statement of the
-additional terms that apply to those files, or a notice indicating
-where to find the applicable terms.
-
- Additional terms, permissive or non-permissive, may be stated in the
-form of a separately written license, or stated as exceptions;
-the above requirements apply either way.
-
- 8. Termination.
-
- You may not propagate or modify a covered work except as expressly
-provided under this License. Any attempt otherwise to propagate or
-modify it is void, and will automatically terminate your rights under
-this License (including any patent licenses granted under the third
-paragraph of section 11).
-
- However, if you cease all violation of this License, then your
-license from a particular copyright holder is reinstated (a)
-provisionally, unless and until the copyright holder explicitly and
-finally terminates your license, and (b) permanently, if the copyright
-holder fails to notify you of the violation by some reasonable means
-prior to 60 days after the cessation.
-
- Moreover, your license from a particular copyright holder is
-reinstated permanently if the copyright holder notifies you of the
-violation by some reasonable means, this is the first time you have
-received notice of violation of this License (for any work) from that
-copyright holder, and you cure the violation prior to 30 days after
-your receipt of the notice.
-
- Termination of your rights under this section does not terminate the
-licenses of parties who have received copies or rights from you under
-this License. If your rights have been terminated and not permanently
-reinstated, you do not qualify to receive new licenses for the same
-material under section 10.
-
- 9. Acceptance Not Required for Having Copies.
-
- You are not required to accept this License in order to receive or
-run a copy of the Program. Ancillary propagation of a covered work
-occurring solely as a consequence of using peer-to-peer transmission
-to receive a copy likewise does not require acceptance. However,
-nothing other than this License grants you permission to propagate or
-modify any covered work. These actions infringe copyright if you do
-not accept this License. Therefore, by modifying or propagating a
-covered work, you indicate your acceptance of this License to do so.
-
- 10. Automatic Licensing of Downstream Recipients.
-
- Each time you convey a covered work, the recipient automatically
-receives a license from the original licensors, to run, modify and
-propagate that work, subject to this License. You are not responsible
-for enforcing compliance by third parties with this License.
-
- An "entity transaction" is a transaction transferring control of an
-organization, or substantially all assets of one, or subdividing an
-organization, or merging organizations. If propagation of a covered
-work results from an entity transaction, each party to that
-transaction who receives a copy of the work also receives whatever
-licenses to the work the party's predecessor in interest had or could
-give under the previous paragraph, plus a right to possession of the
-Corresponding Source of the work from the predecessor in interest, if
-the predecessor has it or can get it with reasonable efforts.
-
- You may not impose any further restrictions on the exercise of the
-rights granted or affirmed under this License. For example, you may
-not impose a license fee, royalty, or other charge for exercise of
-rights granted under this License, and you may not initiate litigation
-(including a cross-claim or counterclaim in a lawsuit) alleging that
-any patent claim is infringed by making, using, selling, offering for
-sale, or importing the Program or any portion of it.
-
- 11. Patents.
-
- A "contributor" is a copyright holder who authorizes use under this
-License of the Program or a work on which the Program is based. The
-work thus licensed is called the contributor's "contributor version".
-
- A contributor's "essential patent claims" are all patent claims
-owned or controlled by the contributor, whether already acquired or
-hereafter acquired, that would be infringed by some manner, permitted
-by this License, of making, using, or selling its contributor version,
-but do not include claims that would be infringed only as a
-consequence of further modification of the contributor version. For
-purposes of this definition, "control" includes the right to grant
-patent sublicenses in a manner consistent with the requirements of
-this License.
-
- Each contributor grants you a non-exclusive, worldwide, royalty-free
-patent license under the contributor's essential patent claims, to
-make, use, sell, offer for sale, import and otherwise run, modify and
-propagate the contents of its contributor version.
-
- In the following three paragraphs, a "patent license" is any express
-agreement or commitment, however denominated, not to enforce a patent
-(such as an express permission to practice a patent or covenant not to
-sue for patent infringement). To "grant" such a patent license to a
-party means to make such an agreement or commitment not to enforce a
-patent against the party.
-
- If you convey a covered work, knowingly relying on a patent license,
-and the Corresponding Source of the work is not available for anyone
-to copy, free of charge and under the terms of this License, through a
-publicly available network server or other readily accessible means,
-then you must either (1) cause the Corresponding Source to be so
-available, or (2) arrange to deprive yourself of the benefit of the
-patent license for this particular work, or (3) arrange, in a manner
-consistent with the requirements of this License, to extend the patent
-license to downstream recipients. "Knowingly relying" means you have
-actual knowledge that, but for the patent license, your conveying the
-covered work in a country, or your recipient's use of the covered work
-in a country, would infringe one or more identifiable patents in that
-country that you have reason to believe are valid.
-
- If, pursuant to or in connection with a single transaction or
-arrangement, you convey, or propagate by procuring conveyance of, a
-covered work, and grant a patent license to some of the parties
-receiving the covered work authorizing them to use, propagate, modify
-or convey a specific copy of the covered work, then the patent license
-you grant is automatically extended to all recipients of the covered
-work and works based on it.
-
- A patent license is "discriminatory" if it does not include within
-the scope of its coverage, prohibits the exercise of, or is
-conditioned on the non-exercise of one or more of the rights that are
-specifically granted under this License. You may not convey a covered
-work if you are a party to an arrangement with a third party that is
-in the business of distributing software, under which you make payment
-to the third party based on the extent of your activity of conveying
-the work, and under which the third party grants, to any of the
-parties who would receive the covered work from you, a discriminatory
-patent license (a) in connection with copies of the covered work
-conveyed by you (or copies made from those copies), or (b) primarily
-for and in connection with specific products or compilations that
-contain the covered work, unless you entered into that arrangement,
-or that patent license was granted, prior to 28 March 2007.
-
- Nothing in this License shall be construed as excluding or limiting
-any implied license or other defenses to infringement that may
-otherwise be available to you under applicable patent law.
-
- 12. No Surrender of Others' Freedom.
-
- If conditions are imposed on you (whether by court order, agreement or
-otherwise) that contradict the conditions of this License, they do not
-excuse you from the conditions of this License. If you cannot convey a
-covered work so as to satisfy simultaneously your obligations under this
-License and any other pertinent obligations, then as a consequence you may
-not convey it at all. For example, if you agree to terms that obligate you
-to collect a royalty for further conveying from those to whom you convey
-the Program, the only way you could satisfy both those terms and this
-License would be to refrain entirely from conveying the Program.
-
- 13. Use with the GNU Affero General Public License.
-
- Notwithstanding any other provision of this License, you have
-permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
-combined work, and to convey the resulting work. The terms of this
-License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
-address new problems or concerns.
-
- Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
-Public License "or any later version" applies to it, you have the
-option of following the terms and conditions either of that numbered
-version or of any later version published by the Free Software
-Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
-by the Free Software Foundation.
-
- If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
-public statement of acceptance of a version permanently authorizes you
-to choose that version for the Program.
-
- Later license versions may give you additional or different
-permissions. However, no additional obligations are imposed on any
-author or copyright holder as a result of your choosing to follow a
-later version.
-
- 15. Disclaimer of Warranty.
-
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
-APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
-HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
-OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
-THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
-PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
-IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
-ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
-
- 16. Limitation of Liability.
-
- IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
-WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
-THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
-GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
-USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
-DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
-PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
-EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
-SUCH DAMAGES.
-
- 17. Interpretation of Sections 15 and 16.
-
- If the disclaimer of warranty and limitation of liability provided
-above cannot be given local legal effect according to their terms,
-reviewing courts shall apply local law that most closely approximates
-an absolute waiver of all civil liability in connection with the
-Program, unless a warranty or assumption of liability accompanies a
-copy of the Program in return for a fee.
-
- END OF TERMS AND CONDITIONS
-
- How to Apply These Terms to Your New Programs
-
- If you develop a new program, and you want it to be of the greatest
-possible use to the public, the best way to achieve this is to make it
-free software which everyone can redistribute and change under these terms.
-
- To do so, attach the following notices to the program. It is safest
-to attach them to the start of each source file to most effectively
-state the exclusion of warranty; and each file should have at least
-the "copyright" line and a pointer to where the full notice is found.
-
-
- Copyright (C)
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
-
- You should also get your employer (if you work as a programmer) or school,
-if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
-.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
diff --git a/Makefile b/Makefile
index 50da824..702edab 100644
--- a/Makefile
+++ b/Makefile
@@ -1,38 +1,13 @@
-PROJECT := $(shell basename $(CURDIR))
-VERSION ?= dev
-GO_VERSION := $(shell grep '^go ' go.mod | awk '{print $$2}')
-LDFLAGS := -s -w -X main.version=$(VERSION)
+.PHONY: build test-all release clean
-.PHONY: build test test-integration test-e2e test-all lint release clean
-
-# Build binary locally
build:
- go build -ldflags "$(LDFLAGS)" -o bin/$(PROJECT) ./cmd/$(PROJECT)
-
-# Unit tests
-test:
- go test -race ./...
-
-# Integration tests (optional)
-test-integration: build
- go test -tags=integration ./...
-
-# E2E tests (optional)
-test-e2e: build
- go test -tags=e2e ./...
-
-# CI calls this target
-test-all: lint test
- @echo "All tests passed"
+ zig build -Doptimize=ReleaseSafe
-# Linting
-lint:
- golangci-lint run
+test-all:
+ zig build test
-# CI calls this target for releases
release:
goreleaser release --clean
-# Clean build artifacts
clean:
- rm -rf bin/ dist/
+ rm -rf .zig-cache zig-out dist
diff --git a/README.md b/README.md
deleted file mode 100644
index 62c4120..0000000
--- a/README.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# wch
-
-Watch command output with history you can rewind
-
-[](https://github.com/ivoronin/wch/actions/workflows/test.yml)
-[](https://github.com/ivoronin/wch/releases)
-
-## Table of Contents
-
-[Overview](#overview) · [Features](#features) · [Installation](#installation) · [Usage](#usage) · [Configuration](#configuration) · [License](#license)
-
-```bash
-# Instead of:
-watch -n 1 kubectl get pods
-
-# Use:
-wch kubectl get pods
-```
-
-## Overview
-
-wch runs a command on an interval and displays the scrollable output. Two design choices set it apart from `watch(1)` and other modern replacements. Rows in the output are matched across refreshes by identity (a stable token like a pod `NAME`) instead of exact line equality, so the line you're reading stays anchored in place when rows insert above the viewport, and a row whose `AGE` ticks every refresh reads as one cell change rather than a delete + insert. The TUI itself shows only the command's output and a single status line — no border, no line numbers, no help banner, no config file, no keymap rebinding, no theme to pick (light/dark is detected from the terminal background at startup).
-
-## Features
-
-- Scroll position anchored to content (row identity, not line offset)
-- Minimal UI surface (no border, line numbers, help banner, config file, keymap rebinding; theme auto-detected)
-- Word-level diff highlighting between executions, tolerant of volatile fields (`AGE`, `RESTARTS`) so a row whose value ticks each refresh doesn't read as a delete + insert
-- History keeps up to `-l` past executions (default 86400 ≈ 24h at 1s interval; `-l 0` for unlimited), navigable with arrow keys
-- Record sessions to a JSONL file (`-w `) and replay them offline with full history navigation (`-r `)
-- Scrollable view for output that exceeds terminal height (unlike `watch(1)`)
-- Terminal notifications on output change (OSC 9, supported by iTerm2 and others)
-- Keyboard navigation (arrow keys, PgUp/PgDn, Home/End)
-- Pause/resume execution
-- Toggleable status bar and diff highlighting
-- Horizontal scrolling for wide output
-- Configurable refresh interval
-
-## Installation
-
-### GitHub Releases
-
-Download from [Releases](https://github.com/ivoronin/wch/releases).
-
-### Homebrew
-
-```bash
-brew install ivoronin/ivoronin/wch
-```
-
-## Usage
-
-### Basic
-
-```bash
-wch kubectl get pods # watch with 1s interval
-wch -i 5s kubectl get pods # 5 second interval
-wch -d kubectl get pods # disable diff highlighting
-wch -t kubectl get pods # hide status bar
-wch -b kubectl get pods # enable notifications
-wch -w session.wch.jsonl kubectl get pods # record session while watching
-wch -r session.wch.jsonl # replay recorded session offline
-```
-
-## Configuration
-
-### Flags
-
-| Flag | Description | Default |
-|------|-------------|---------|
-| `-i` | Refresh interval | `1s` |
-| `-d` | Disable diff highlighting | `false` |
-| `-t` | Hide status bar | `false` |
-| `-b` | Enable notifications | `false` |
-| `-w` | Write recording to path (must not exist) | — |
-| `-r` | Read a recorded session (offline replay) | — |
-
-## License
-
-[GPL-3.0](LICENSE)
diff --git a/build.zig b/build.zig
new file mode 100644
index 0000000..c6e977d
--- /dev/null
+++ b/build.zig
@@ -0,0 +1,60 @@
+const std = @import("std");
+
+/// Configure the executable, dependencies, and developer commands.
+pub fn build(build_system: *std.Build) void {
+ const target = build_system.standardTargetOptions(.{});
+ const optimization = build_system.standardOptimizeOption(.{});
+ const version = build_system.option([]const u8, "version", "Application version") orelse "dev";
+
+ // One module. The program is the only thing that reads this source, so
+ // there is nobody to hand a library module to.
+ const executable = build_system.addExecutable(.{
+ .name = "wch",
+ .root_module = build_system.createModule(.{
+ .root_source_file = build_system.path("src/main.zig"),
+ .target = target,
+ .optimize = optimization,
+ }),
+ });
+
+ const build_metadata = build_system.addOptions();
+ build_metadata.addOption([]const u8, "version", version);
+ executable.root_module.addOptions("build_metadata", build_metadata);
+
+ const dizzy = build_system.dependency("dizzy", .{});
+ const clap = build_system.dependency("clap", .{});
+ const zeit = build_system.dependency("zeit", .{});
+ const vaxis = build_system.dependency("vaxis", .{
+ .target = target,
+ .optimize = optimization,
+ });
+
+ // diff.zig aligns text with dizzy.
+ executable.root_module.addImport("dizzy", dizzy.module("dizzy"));
+
+ // args.zig reads the command line with clap.
+ executable.root_module.addImport("clap", clap.module("clap"));
+
+ // bar.zig shows a clock, which means a local time zone.
+ executable.root_module.addImport("zeit", zeit.module("zeit"));
+
+ // output.zig prepares vaxis segments, and viewport.zig paints them.
+ executable.root_module.addImport("vaxis", vaxis.module("vaxis"));
+
+ build_system.installArtifact(executable);
+
+ // Depends on the install step, so it runs from the install directory
+ // rather than from within the cache.
+ const run_command = build_system.addRunArtifact(executable);
+ run_command.step.dependOn(build_system.getInstallStep());
+ // `zig build run -- arg1 arg2`.
+ if (build_system.args) |application_arguments| run_command.addArgs(application_arguments);
+
+ build_system.step("run", "Run the app").dependOn(&run_command.step);
+
+ const test_executable = build_system.addTest(.{ .root_module = executable.root_module });
+
+ build_system.step("test", "Run tests").dependOn(
+ &build_system.addRunArtifact(test_executable).step,
+ );
+}
diff --git a/build.zig.zon b/build.zig.zon
new file mode 100644
index 0000000..c641b6e
--- /dev/null
+++ b/build.zig.zon
@@ -0,0 +1,29 @@
+.{
+ .name = .wch,
+ .version = "0.0.0",
+ .fingerprint = 0xa34676c985ea5406, // Changing this has security and trust implications.
+ .minimum_zig_version = "0.16.0",
+ .dependencies = .{
+ .dizzy = .{
+ .url = "git+https://github.com/neurocyte/dizzy#4e7c47d882540f6ca6b2d8321451430ba2b6f55d",
+ .hash = "dizzy-1.0.0-q40X4aGRAADKbMiB82-6NSGBDG4Cd4wZxQN4kiYDQu-8",
+ },
+ .clap = .{
+ .url = "git+https://github.com/Hejsil/zig-clap?ref=0.12.0#8d97efa1ee1e575443c7888d5c38e1c3fc145cf5",
+ .hash = "clap-0.12.0-oBajB7foAQDqlSwaSG5g0yq7xGbQARUsBk5T64gAOqP5",
+ },
+ .vaxis = .{
+ .url = "git+https://github.com/rockorager/libvaxis.git#7d4aed7fef1944c4446b8e70d6b71b7229794281",
+ .hash = "vaxis-0.6.0-BWNV_MK5CgCkRxCML4hTf4nb1Ma18mZeT8swSprge7f1",
+ },
+ .zeit = .{
+ .url = "git+https://github.com/rockorager/zeit#b1c1c2fcbc71fd7799a316bbcf0ff88d06d80ccc",
+ .hash = "zeit-0.9.0-5I6bk2m9AgBSMH8-L6rYJkwuQAyhXplnfxnvTSGzVHUR",
+ },
+ },
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "src",
+ },
+}
diff --git a/cmd/wch/main.go b/cmd/wch/main.go
deleted file mode 100644
index 8c34ba5..0000000
--- a/cmd/wch/main.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package main
-
-import (
- "flag"
- "fmt"
- "os"
- "strings"
- "time"
-
- tea "charm.land/bubbletea/v2"
-
- "github.com/ivoronin/wch/internal/recording"
- "github.com/ivoronin/wch/internal/tui"
-)
-
-var version = "dev"
-
-func main() {
- interval := flag.Duration("i", time.Second, "refresh interval")
- historyLimit := flag.Int("l", 86400, "history limit (executions retained in memory; 0 = unlimited)")
- disableDiff := flag.Bool("d", false, "disable diff highlighting")
- hideStatus := flag.Bool("t", false, "hide status bar")
- enableNotify := flag.Bool("b", false, "enable terminal notification on change")
- openPath := flag.String("r", "", "read a recorded session in replay mode (offline)")
- writePath := flag.String("w", "", "write a recording to (started immediately; file must not already exist)")
- showVersion := flag.Bool("version", false, "show version")
-
- flag.Usage = func() {
- fmt.Fprintf(os.Stderr, "Usage: wch [flags] \n wch -r \n\nFlags:\n")
- flag.PrintDefaults()
- }
-
- flag.Parse()
-
- if *showVersion {
- fmt.Printf("wch %s\n", version)
- os.Exit(0)
- }
-
- if *openPath != "" && *writePath != "" {
- fmt.Fprintln(os.Stderr, "Error: -r and -w are mutually exclusive")
- flag.Usage()
- os.Exit(1)
- }
-
- var model tea.Model
-
- if *openPath != "" {
- if len(flag.Args()) > 0 {
- fmt.Fprintln(os.Stderr, "Error: -r is exclusive with a command")
- flag.Usage()
- os.Exit(1)
- }
- s, err := recording.Load(*openPath)
- if err != nil {
- if s == nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
- }
- // Partial load (corrupt/truncated lines): surface as a warning but proceed
- // with the frames that did decode.
- fmt.Fprintf(os.Stderr, "wch: warning: %v\n", err)
- }
- model = tui.NewReplay(tui.Config{
- Command: s.Command,
- Interval: s.Interval,
- DiffEnabled: !*disableDiff,
- ShowStatus: !*hideStatus,
- }, s)
- } else {
- args := flag.Args()
- if len(args) == 0 {
- fmt.Fprintln(os.Stderr, "Error: command required")
- flag.Usage()
- os.Exit(1)
- }
- var autoStart *recording.AutoStartRequest
- if *writePath != "" {
- p, err := recording.NormalizePath(*writePath)
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
- }
- // Best-effort fast-fail before launching the TUI. The atomic guard against
- // clobber is JSONLRecorder's O_EXCL open; a race here still surfaces as an
- // in-TUI warning rather than data loss.
- if err := recording.PreflightCheck(p); err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
- }
- autoStart = &recording.AutoStartRequest{Path: p}
- }
- command := strings.Join(args, " ")
- model = tui.New(tui.Config{
- Command: command,
- Interval: *interval,
- DiffEnabled: !*disableDiff,
- ShowStatus: !*hideStatus,
- NotifyOnChange: *enableNotify,
- AutoStart: autoStart,
- MaxHistory: *historyLimit,
- })
- }
-
- p := tea.NewProgram(model)
-
- finalModel, err := p.Run()
- // Bubble Tea v2 short-circuits Model.Update on QuitMsg, so the TUI cannot finalise its
- // own recording on quit. Cleanup runs unconditionally — even when p.Run returned an
- // error — so an active recording is closed before we exit. Cleanup is idempotent
- // (no-op when no recording is active).
- var cleanupErr error
- if m, ok := finalModel.(tui.Model); ok {
- cleanupErr = m.Cleanup()
- }
- if err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
- }
- if cleanupErr != nil {
- fmt.Fprintf(os.Stderr, "wch: recording: %v\n", cleanupErr)
- os.Exit(1)
- }
-}
diff --git a/go.mod b/go.mod
deleted file mode 100644
index 528d06a..0000000
--- a/go.mod
+++ /dev/null
@@ -1,29 +0,0 @@
-module github.com/ivoronin/wch
-
-go 1.25.6
-
-require (
- charm.land/bubbles/v2 v2.1.0
- charm.land/bubbletea/v2 v2.0.2
- charm.land/lipgloss/v2 v2.0.2
- github.com/charmbracelet/x/ansi v0.11.6
- github.com/charmbracelet/x/cellbuf v0.0.15
-)
-
-require (
- github.com/atotto/clipboard v0.1.4 // indirect
- github.com/charmbracelet/colorprofile v0.4.2 // indirect
- github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
- github.com/charmbracelet/x/term v0.2.2 // indirect
- github.com/charmbracelet/x/termios v0.1.1 // indirect
- github.com/charmbracelet/x/windows v0.2.2 // indirect
- github.com/clipperhouse/displaywidth v0.11.0 // indirect
- github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
- github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
- github.com/mattn/go-runewidth v0.0.21 // indirect
- github.com/muesli/cancelreader v0.2.2 // indirect
- github.com/rivo/uniseg v0.4.7 // indirect
- github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
- golang.org/x/sync v0.19.0 // indirect
- golang.org/x/sys v0.42.0 // indirect
-)
diff --git a/go.sum b/go.sum
deleted file mode 100644
index 43ef0b3..0000000
--- a/go.sum
+++ /dev/null
@@ -1,46 +0,0 @@
-charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
-charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
-charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
-charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
-charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
-charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
-github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
-github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
-github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
-github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
-github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
-github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
-github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
-github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
-github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
-github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
-github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
-github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
-github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
-github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
-github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
-github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
-github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
-github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
-github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
-github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
-github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
-github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
-github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
-github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
-github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
-github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
-github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
-github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
-github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
-github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
-golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/internal/diff/align.go b/internal/diff/align.go
deleted file mode 100644
index 6fd0b17..0000000
--- a/internal/diff/align.go
+++ /dev/null
@@ -1,206 +0,0 @@
-// Package diff turns two snapshots of a command's output into a structured description of
-// what changed. It is tuned for live "watch"-style output where most lines persist across
-// refreshes but their volatile fields (a kubectl AGE, a counter) change every time.
-//
-// The package is dependency-free (stdlib only) and does no rendering: it reports what
-// changed and where lines moved, leaving all styling to the caller.
-//
-// - Line alignment (Align): anchor on lines that are byte-identical and unique in both
-// snapshots, then align the gaps between anchors by token similarity (Jaccard), so a row
-// whose AGE ticked still pairs with its old self instead of looking like a delete+insert.
-// This is patience diff with a similarity gap-filler; anchoring keeps it linear on large
-// output.
-// - Structured diff (Lines): each new-output line tagged Equal/Changed/Added, with a
-// word-level Span breakdown for changed lines. The caller styles the Changed spans.
-// - Position mapping (MapLine): where an old line moved to, for preserving a scroll or
-// cursor position across refreshes.
-//
-// The design is order-preserving: it does not track moves, so reordered output (e.g.
-// `kubectl top --sort-by`) shows moved rows as changed.
-//
-// Internally the concerns form a one-directional chain:
-//
-// {lcs, similarity, token} -> align -> { mapline, worddiff } -> diff
-package diff
-
-import "strings"
-
-const (
- // simThreshold is the minimum token-overlap (Jaccard) score for two lines to be treated
- // as the same row across a refresh. The identifier token (e.g. a pod NAME) dominates the
- // score, so volatile fields like AGE ticking still match. Tunable.
- simThreshold = 0.5
- // alignCellCap bounds a single gap's similarity DP (and a single line's word DP). A gap
- // with no usable anchors that is larger than this falls back to positional pairing (and
- // MapLine to a similarity scan). With anchoring, this is reached only by mostly-changed
- // output that has no stable lines to anchor on.
- alignCellCap = 2_000_000
-)
-
-type opKind uint8
-
-const (
- opMatch opKind = iota
- opInsert
- opDelete
-)
-
-type op struct {
- kind opKind
- oldIdx int // set for opMatch and opDelete, else -1
- newIdx int // set for opMatch and opInsert, else -1
-}
-
-// Alignment is the order-preserving correspondence between the lines of two outputs, built
-// by anchoring on unchanged unique lines and similarity-aligning the gaps between them. It
-// answers where a line moved (MapLine) and what changed per line (Lines).
-type Alignment struct {
- ops []op
- oldLines []string
- newLines []string
- coarse bool
-}
-
-func splitLines(s string) []string {
- if s == "" {
- return nil
- }
- return strings.Split(s, "\n")
-}
-
-// Align builds a similarity-based, order-preserving alignment of oldText to newText.
-func Align(oldText, newText string) Alignment {
- a := Alignment{oldLines: splitLines(oldText), newLines: splitLines(newText)}
- n, m := len(a.oldLines), len(a.newLines)
-
- // Peel byte-identical common prefix/suffix (cheap; also handles duplicate runs).
- p := 0
- for p < n && p < m && a.oldLines[p] == a.newLines[p] {
- p++
- }
- s := 0
- for s < n-p && s < m-p && a.oldLines[n-1-s] == a.newLines[m-1-s] {
- s++
- }
-
- for i := range p {
- a.ops = append(a.ops, op{opMatch, i, i})
- }
- a.ops = append(a.ops, a.alignRange(p, n-s, p, m-s)...)
- for k := range s {
- a.ops = append(a.ops, op{opMatch, n - s + k, m - s + k})
- }
- return a
-}
-
-// anchor pairs an old-line index with the identical new-line index it pins to.
-type anchor struct{ oi, ni int }
-
-// findAnchors returns, increasing on both sides, the lines that are byte-identical and
-// appear exactly once in each range. These are unambiguous correspondences: an unchanged row
-// pins to its twin no matter how large or scattered the surrounding diff is. The greedy
-// increasing scan is optimal for stable-ordered output (insert/delete preserve the relative
-// order of survivors); reordered lines simply do not anchor.
-func (a *Alignment) findAnchors(oLo, oHi, nLo, nHi int) []anchor {
- oCount := make(map[string]int, oHi-oLo)
- for i := oLo; i < oHi; i++ {
- oCount[a.oldLines[i]]++
- }
- nCount := make(map[string]int, nHi-nLo)
- nFirst := make(map[string]int, nHi-nLo)
- for j := nLo; j < nHi; j++ {
- nl := a.newLines[j]
- nCount[nl]++
- if _, ok := nFirst[nl]; !ok {
- nFirst[nl] = j
- }
- }
- var anchors []anchor
- lastNi := nLo - 1
- for i := oLo; i < oHi; i++ {
- line := a.oldLines[i]
- if oCount[line] == 1 && nCount[line] == 1 {
- if ni := nFirst[line]; ni > lastNi {
- anchors = append(anchors, anchor{i, ni})
- lastNi = ni
- }
- }
- }
- return anchors
-}
-
-// alignRange anchors on unchanged unique lines, then similarity-aligns the small gaps
-// between them. Anchoring keeps each per-segment alignment tiny even for a huge, scattered
-// diff, so the expensive DP never runs over the whole output and unchanged rows are never
-// paired positionally with the wrong neighbour.
-func (a *Alignment) alignRange(oLo, oHi, nLo, nHi int) []op {
- anchors := a.findAnchors(oLo, oHi, nLo, nHi)
- if len(anchors) == 0 {
- return a.alignGap(oLo, oHi, nLo, nHi)
- }
- var ops []op
- prevO, prevN := oLo, nLo
- for _, an := range anchors {
- ops = append(ops, a.alignGap(prevO, an.oi, prevN, an.ni)...)
- ops = append(ops, op{opMatch, an.oi, an.ni})
- prevO, prevN = an.oi+1, an.ni+1
- }
- ops = append(ops, a.alignGap(prevO, oHi, prevN, nHi)...)
- return ops
-}
-
-// alignGap aligns a gap between anchors by token similarity, maximizing total similarity (via
-// lcs) so an exact match wins over a look-alike. Within a gap the deletes are emitted before
-// the inserts, so a replace block lets Lines pair an old line with its new counterpart for a
-// word-level diff. A pathologically large gap (no anchors at all, mostly-changed output)
-// falls back to positional pairing, bounded by alignCellCap.
-func (a *Alignment) alignGap(oLo, oHi, nLo, nHi int) []op {
- r, c := oHi-oLo, nHi-nLo
- if r == 0 && c == 0 {
- return nil
- }
- if r*c > alignCellCap {
- a.coarse = true
- ops := make([]op, 0, r+c)
- for i := oLo; i < oHi; i++ {
- ops = append(ops, op{opDelete, i, -1})
- }
- for j := nLo; j < nHi; j++ {
- ops = append(ops, op{opInsert, -1, j})
- }
- return ops
- }
-
- oldSets := make([]map[string]struct{}, r)
- for i := range oldSets {
- oldSets[i] = tokenSet(a.oldLines[oLo+i])
- }
- newSets := make([]map[string]struct{}, c)
- for j := range newSets {
- newSets[j] = tokenSet(a.newLines[nLo+j])
- }
- pairs := lcs(r, c, func(i, j int) float64 {
- if s := jaccard(oldSets[i], newSets[j]); s >= simThreshold {
- return s // weight by similarity so an exact twin (1) beats a look-alike
- }
- return 0
- })
-
- ops := make([]op, 0, r+c)
- oi, nj := 0, 0
- emitGap := func(oEnd, nEnd int) {
- for ; oi < oEnd; oi++ {
- ops = append(ops, op{opDelete, oLo + oi, -1})
- }
- for ; nj < nEnd; nj++ {
- ops = append(ops, op{opInsert, -1, nLo + nj})
- }
- }
- for _, pr := range pairs {
- emitGap(pr[0], pr[1])
- ops = append(ops, op{opMatch, oLo + pr[0], nLo + pr[1]})
- oi, nj = pr[0]+1, pr[1]+1
- }
- emitGap(r, c)
- return ops
-}
diff --git a/internal/diff/align_test.go b/internal/diff/align_test.go
deleted file mode 100644
index 9037e87..0000000
--- a/internal/diff/align_test.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package diff
-
-import (
- "strings"
- "testing"
-)
-
-func text(lines ...string) string { return strings.Join(lines, "\n") }
-
-// changedSpans returns the texts of the Changed spans of a line, for concise assertions.
-func changedSpans(spans []Span) []string {
- var out []string
- for _, s := range spans {
- if s.Changed {
- out = append(out, s.Text)
- }
- }
- return out
-}
-
-func TestSimilarity(t *testing.T) {
- sim := func(a, b string) float64 { return jaccard(tokenSet(a), tokenSet(b)) }
- exact := []struct {
- name string
- a, b string
- want float64
- }{
- {"identical", "a b c", "a b c", 1},
- {"bothEmpty", "", "", 1},
- {"oneEmpty", "a b", "", 0},
- {"disjoint", "pod-1 Running", "svc-9 Pending", 0},
- }
- for _, c := range exact {
- if got := sim(c.a, c.b); got != c.want {
- t.Errorf("%s: similarity=%v want %v", c.name, got, c.want)
- }
- }
-
- atLeast := []struct {
- name string
- a, b string
- }{
- {"ageTick", "pod-1 1/1 Running 5m", "pod-1 1/1 Running 6m"},
- {"timestampTick", "2024-01-01 12:00:01 starting job", "2024-01-01 12:00:02 starting job"},
- }
- for _, c := range atLeast {
- if got := sim(c.a, c.b); got < simThreshold {
- t.Errorf("%s: similarity=%v want >= %v", c.name, got, simThreshold)
- }
- }
-}
-
-func TestMapLineIdentical(t *testing.T) {
- s := text("a", "b", "c")
- a := Align(s, s)
- for i := 0; i < 3; i++ {
- if got := a.MapLine(i); got != i {
- t.Errorf("MapLine(%d)=%d want %d", i, got, i)
- }
- }
-}
-
-func TestMapLinePrepend(t *testing.T) {
- a := Align(text("b", "c", "d"), text("a", "b", "c", "d"))
- for old, want := range map[int]int{0: 1, 1: 2, 2: 3} {
- if got := a.MapLine(old); got != want {
- t.Errorf("MapLine(%d)=%d want %d", old, got, want)
- }
- }
-}
-
-func TestMapLineAppend(t *testing.T) {
- a := Align(text("a", "b"), text("a", "b", "c"))
- if got := a.MapLine(0); got != 0 {
- t.Errorf("MapLine(0)=%d want 0", got)
- }
- if got := a.MapLine(1); got != 1 {
- t.Errorf("MapLine(1)=%d want 1", got)
- }
-}
-
-func TestMapLineKubectlInsertWithAgeTick(t *testing.T) {
- old := text(
- "NAME READY STATUS AGE",
- "pod-1 1/1 Running 5m",
- "pod-2 1/1 Running 5m",
- "pod-3 1/1 Running 5m",
- )
- updated := text(
- "NAME READY STATUS AGE",
- "pod-0 1/1 Running 1s",
- "pod-1 1/1 Running 6m",
- "pod-2 1/1 Running 6m",
- "pod-3 1/1 Running 6m",
- )
- a := Align(old, updated)
- // Anchored on pod-2 (old idx 2); despite the AGE tick and the inserted pod-0,
- // it relocates to its new home at idx 3.
- if got := a.MapLine(2); got != 3 {
- t.Errorf("MapLine(2)=%d want 3", got)
- }
-}
-
-func TestMapLineAnchorDeleted(t *testing.T) {
- a := Align(text("h", "p1", "p2", "p3"), text("h", "p1", "p3"))
- // p2 (old idx 2) removed; anchor lands where it was (new idx 2, now p3) - no teleport.
- if got := a.MapLine(2); got != 2 {
- t.Errorf("MapLine(2)=%d want 2", got)
- }
-}
-
-func TestMapLinePastEnd(t *testing.T) {
- a := Align(text("a", "b"), text("a", "b", "c"))
- if got := a.MapLine(100); got != 3 {
- t.Errorf("MapLine(100)=%d want 3", got)
- }
-}
-
-func TestMapLineCoarseScan(t *testing.T) {
- a := Alignment{
- coarse: true,
- oldLines: []string{"alpha 1", "bravo 2", "charlie 3"},
- newLines: []string{"zero 0", "alpha 1", "bravo 2", "charlie 3"},
- }
- if got := a.MapLine(1); got != 2 {
- t.Errorf("coarse MapLine(1)=%d want 2", got)
- }
-}
-
-func TestMapLineCoarseNotFound(t *testing.T) {
- a := Alignment{
- coarse: true,
- oldLines: []string{"unique-xyz abc"},
- newLines: []string{"totally different here"},
- }
- if got := a.MapLine(0); got != 0 {
- t.Errorf("coarse MapLine(0)=%d want 0 (keep offset)", got)
- }
-}
-
-func TestLinesPrependAddsOnlyNew(t *testing.T) {
- lines := Align(text("a", "b", "c"), text("x", "a", "b", "c")).Lines()
- want := []LineKind{LineAdded, LineEqual, LineEqual, LineEqual}
- if len(lines) != len(want) {
- t.Fatalf("got %d lines want %d", len(lines), len(want))
- }
- for i, k := range want {
- if lines[i].Kind != k {
- t.Errorf("line %d kind=%d want %d", i, lines[i].Kind, k)
- }
- }
- if lines[0].Text != "x" {
- t.Errorf("added line=%q want %q", lines[0].Text, "x")
- }
-}
-
-func TestLinesInPlaceWordChange(t *testing.T) {
- lines := Align("count 5", "count 6").Lines()
- if len(lines) != 1 || lines[0].Kind != LineChanged {
- t.Fatalf("got %+v want one LineChanged", lines)
- }
- if got := changedSpans(lines[0].Spans); len(got) != 1 || got[0] != "6" {
- t.Errorf("changed spans=%v want [6]", got)
- }
-}
-
-func TestLinesDeletionDropped(t *testing.T) {
- lines := Align(text("a", "b", "c"), text("a", "c")).Lines()
- if len(lines) != 2 {
- t.Fatalf("got %d lines want 2 (b dropped)", len(lines))
- }
- if lines[0].Text != "a" || lines[1].Text != "c" {
- t.Errorf("texts=%q,%q want a,c", lines[0].Text, lines[1].Text)
- }
- for _, ln := range lines {
- if ln.Kind != LineEqual {
- t.Errorf("line %q kind=%d want LineEqual", ln.Text, ln.Kind)
- }
- }
-}
-
-func TestLinesCountEqualsNewLines(t *testing.T) {
- updated := text("a", "X", "Y", "c", "d", "e")
- lines := Align(text("a", "b", "c", "d"), updated).Lines()
- if want := strings.Count(updated, "\n") + 1; len(lines) != want {
- t.Errorf("Lines()=%d want %d", len(lines), want)
- }
-}
diff --git a/internal/diff/diff.go b/internal/diff/diff.go
deleted file mode 100644
index a61baf1..0000000
--- a/internal/diff/diff.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package diff
-
-// LineKind classifies a new-output line in the diff.
-type LineKind uint8
-
-const (
- LineEqual LineKind = iota // unchanged from the old snapshot
- LineChanged // same row, some tokens changed (see Spans)
- LineAdded // no counterpart in the old snapshot
-)
-
-// Line is the diff of one new-output line. For LineChanged, Spans is the word-level
-// breakdown (concatenating back to Text); for LineEqual and LineAdded it is nil. Deleted old
-// lines are not represented - the diff describes the new snapshot.
-type Line struct {
- Kind LineKind
- Text string // the new-output line
- OldIndex int // matched old-output line index, or -1
- Spans []Span
-}
-
-// Lines returns the structured diff of the new output: one Line per new-output line, in
-// order. Callers render it however they like (Equal plain, Added whole-line highlighted,
-// Changed with its Changed spans highlighted). Output length equals the number of new lines.
-func (a Alignment) Lines() []Line {
- lines := make([]Line, 0, len(a.newLines))
- var pendDel []int // deletes awaiting an insert to pair with (an in-place replace)
- di := 0
- for _, o := range a.ops {
- switch o.kind {
- case opDelete:
- pendDel = append(pendDel, o.oldIdx)
- case opMatch:
- pendDel, di = pendDel[:0], 0 // unpaired deletes are dropped
- nl := a.newLines[o.newIdx]
- if a.oldLines[o.oldIdx] == nl {
- lines = append(lines, Line{Kind: LineEqual, Text: nl, OldIndex: o.oldIdx})
- } else {
- lines = append(lines, Line{Kind: LineChanged, Text: nl, OldIndex: o.oldIdx, Spans: WordDiff(a.oldLines[o.oldIdx], nl)})
- }
- case opInsert:
- nl := a.newLines[o.newIdx]
- if di < len(pendDel) {
- oldIdx := pendDel[di]
- di++
- lines = append(lines, Line{Kind: LineChanged, Text: nl, OldIndex: oldIdx, Spans: WordDiff(a.oldLines[oldIdx], nl)})
- } else {
- lines = append(lines, Line{Kind: LineAdded, Text: nl, OldIndex: -1})
- }
- }
- }
- return lines
-}
diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go
deleted file mode 100644
index 8890d25..0000000
--- a/internal/diff/diff_test.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package diff
-
-import (
- "fmt"
- "reflect"
- "strings"
- "testing"
-)
-
-func TestLCS(t *testing.T) {
- eq := func(a, b []string) func(i, j int) float64 {
- return func(i, j int) float64 {
- if a[i] == b[j] {
- return 1
- }
- return 0
- }
- }
- cases := []struct {
- name string
- a, b []string
- want [][2]int
- }{
- {"bothEmpty", nil, nil, [][2]int{}},
- {"oldEmpty", nil, []string{"a", "b"}, [][2]int{}},
- {"newEmpty", []string{"a", "b"}, nil, [][2]int{}},
- {"identity", []string{"x", "y", "z"}, []string{"x", "y", "z"}, [][2]int{{0, 0}, {1, 1}, {2, 2}}},
- {"disjoint", []string{"a", "b"}, []string{"c", "d"}, [][2]int{}},
- {"interior", []string{"x", "M", "y"}, []string{"p", "M", "q"}, [][2]int{{1, 1}}},
- {"prepend", []string{"b", "c"}, []string{"a", "b", "c"}, [][2]int{{0, 1}, {1, 2}}},
- }
- for _, c := range cases {
- got := lcs(len(c.a), len(c.b), eq(c.a, c.b))
- if !reflect.DeepEqual(got, c.want) {
- t.Errorf("%s: lcs=%v want %v", c.name, got, c.want)
- }
- }
-}
-
-func TestTokenize(t *testing.T) {
- cases := []struct {
- in string
- want []string
- }{
- {"1/1", []string{"1", "/", "1"}},
- {"a b", []string{"a", " ", "b"}},
- {"pod-1 1/1 Running 5m", []string{"pod", "-", "1", " ", "1", "/", "1", " ", "Running", " ", "5m"}},
- {"café", []string{"café"}},
- {"", nil},
- }
- for _, c := range cases {
- if got := tokenize(c.in); !reflect.DeepEqual(got, c.want) {
- t.Errorf("tokenize(%q)=%v want %v", c.in, got, c.want)
- }
- }
-}
-
-func TestWordDiff(t *testing.T) {
- cases := []struct {
- name string
- old, new string
- want []string // texts of the Changed spans
- }{
- {"wholeWordReplace", "icecream", "beer", []string{"beer"}},
- {"wholeWordReplaceLong", "Running", "Pending", []string{"Pending"}},
- {"subTokenPunct", "1/1", "1/2", []string{"2"}},
- {"ageTick", "pod-1 1/1 Running 5m", "pod-1 1/1 Running 6m", []string{"6m"}},
- {"whitespaceOnly", "a b", "a b", nil}, // shifted padding is not a highlightable change
- {"identical", "abc", "abc", nil},
- }
- for _, c := range cases {
- spans := WordDiff(c.old, c.new)
- if got := changedSpans(spans); !reflect.DeepEqual(got, c.want) {
- t.Errorf("%s: changed spans=%v want %v", c.name, got, c.want)
- }
- // Spans must reconstruct the new line exactly (incl. spacing).
- var b strings.Builder
- for _, s := range spans {
- b.WriteString(s.Text)
- }
- if b.String() != c.new {
- t.Errorf("%s: spans concat=%q want %q", c.name, b.String(), c.new)
- }
- }
-}
-
-// The headline bug: character LCS used to keep the shared 'ee' plain. Word-level must mark
-// the whole replaced word as one changed span - no plain fragment left behind.
-func TestWordDiffNoOrphanChars(t *testing.T) {
- spans := WordDiff("icecream", "beer")
- if len(spans) != 1 || !spans[0].Changed || spans[0].Text != "beer" {
- t.Errorf("got %+v want one changed span \"beer\"", spans)
- }
-}
-
-// An inserted duplicate token must mark the inserted (second) instance, not the unchanged
-// earlier one. "a b" -> "a a b" tokenizes to [a, " ", a, " ", b]; index 2 is the inserted "a".
-func TestTokenDiffDuplicateInsertion(t *testing.T) {
- spans := tokenDiff(tokenize("a b"), tokenize("a a b"))
- if spans[0].Changed {
- t.Errorf("first 'a' (unchanged) must not be Changed")
- }
- if !spans[2].Changed {
- t.Errorf("inserted second 'a' must be Changed")
- }
- if spans[4].Changed {
- t.Errorf("trailing 'b' (unchanged) must not be Changed")
- }
-}
-
-// Churn among similar rows: a pod is removed and another added (same age), with one pod
-// unchanged. The rows share "1/1 Running 3d" so they are >0.5 similar; the alignment must
-// pair the unchanged pod with its exact twin (not a weakly-similar neighbour), so it stays
-// plain rather than being word-diffed against an unrelated line.
-func TestRenderChurnKeepsUnchangedRowsPlain(t *testing.T) {
- old := text(
- "pod-a 1/1 Running 5m",
- "pod-b 1/1 Running 3d",
- )
- updated := text(
- "pod-b 1/1 Running 3d", // unchanged, shifted up (pod-a removed)
- "pod-c 1/1 Running 3d", // new pod, same age
- )
- lines := Align(old, updated).Lines()
- if lines[0].Kind != LineEqual || lines[0].Text != "pod-b 1/1 Running 3d" {
- t.Errorf("unchanged pod-b must be LineEqual, got kind=%d text=%q", lines[0].Kind, lines[0].Text)
- }
-}
-
-// Regression for the coarse false-diff bug: with enough scattered changes that a
-// whole-middle DP would exceed alignCellCap, anchoring on the unchanged unique rows must
-// keep them plain instead of falling back to positional pairing (which mis-highlighted ~800
-// unchanged rows on a real 1900-line `kubectl get pods -A`).
-func TestRenderLargeScatteredNoFalseDiffs(t *testing.T) {
- const n = 1600 // n*n > alignCellCap, so a single-segment DP would go coarse
- oldRows := make([]string, n)
- newRows := make([]string, n)
- for i := range oldRows {
- row := fmt.Sprintf("pod-%04d 1/1 Running 9d", i)
- oldRows[i] = row
- newRows[i] = row
- }
- // Scatter changes at top and bottom (forcing a wide middle) plus a mid insertion.
- newRows[0] = "pod-0000 1/1 Running 10d"
- newRows[n-1] = fmt.Sprintf("pod-%04d 1/1 Running 10d", n-1)
- newRows = append(newRows[:800:800], append([]string{"pod-NEWX 0/1 Pending 1s"}, newRows[800:]...)...)
-
- lines := Align(strings.Join(oldRows, "\n"), strings.Join(newRows, "\n")).Lines()
- kind := make(map[string]LineKind, len(lines))
- for _, ln := range lines {
- kind[ln.Text] = ln.Kind
- }
- for i := 1; i < n-1; i++ { // rows 1..n-2 are unchanged and must stay LineEqual
- row := fmt.Sprintf("pod-%04d 1/1 Running 9d", i)
- if kind[row] != LineEqual {
- t.Fatalf("unchanged row %q kind=%d want LineEqual", row, kind[row])
- }
- }
-}
-
-// The primary case: a new row inserts (sorted into place) while neighbouring rows' volatile
-// AGE field ticks. The similarity alignment must still pair each shifted row with its old
-// self so only the age token highlights, and the new row is fully highlighted.
-func TestRenderInsertWithVolatileFields(t *testing.T) {
- old := text(
- "NAME READY STATUS AGE",
- "pod-1 1/1 Running 5m",
- "pod-2 1/1 Running 5m",
- )
- updated := text(
- "NAME READY STATUS AGE",
- "pod-0 1/1 Running 1s", // new pod, sorts to top
- "pod-1 1/1 Running 6m", // shifted down, AGE ticked
- "pod-2 1/1 Running 6m",
- )
- lines := Align(old, updated).Lines()
- if len(lines) != 4 {
- t.Fatalf("got %d lines want 4", len(lines))
- }
- if lines[0].Kind != LineEqual {
- t.Errorf("header kind=%d want LineEqual", lines[0].Kind)
- }
- if lines[1].Kind != LineAdded || lines[1].Text != "pod-0 1/1 Running 1s" {
- t.Errorf("pod-0 must be LineAdded, got kind=%d text=%q", lines[1].Kind, lines[1].Text)
- }
- for _, i := range []int{2, 3} { // pod-1, pod-2: only the age token changed
- if lines[i].Kind != LineChanged {
- t.Errorf("line %d kind=%d want LineChanged", i, lines[i].Kind)
- continue
- }
- if got := changedSpans(lines[i].Spans); len(got) != 1 || got[0] != "6m" {
- t.Errorf("line %d changed spans=%v want [6m]", i, got)
- }
- }
-}
diff --git a/internal/diff/lcs.go b/internal/diff/lcs.go
deleted file mode 100644
index 27eb478..0000000
--- a/internal/diff/lcs.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package diff
-
-import "slices"
-
-// lcs returns matched index pairs {i, j} in increasing order: an order-preserving set of
-// correspondences that maximizes the total weight, where weight(i, j) > 0 means i and j may
-// be matched (and how strongly) and weight <= 0 means they may not. The caller decides how
-// to treat the gaps between matched pairs (deletes on the old side, inserts on the new).
-//
-// Maximizing weight rather than match count is what makes an exact counterpart (weight 1)
-// win over a merely-similar one (weight < 1): an unchanged row is always paired with its
-// identical twin instead of a look-alike neighbour, so it is never spuriously highlighted.
-func lcs(r, c int, weight func(i, j int) float64) [][2]int {
- dp := make([][]float64, r+1)
- for i := range dp {
- dp[i] = make([]float64, c+1)
- }
- for i := 1; i <= r; i++ {
- for j := 1; j <= c; j++ {
- best := max(dp[i-1][j], dp[i][j-1])
- if w := weight(i-1, j-1); w > 0 {
- if diag := dp[i-1][j-1] + w; diag > best {
- best = diag
- }
- }
- dp[i][j] = best
- }
- }
-
- pairs := make([][2]int, 0, min(r, c))
- for i, j := r, c; i > 0 && j > 0; {
- switch w := weight(i-1, j-1); {
- case w > 0 && dp[i][j] == dp[i-1][j-1]+w:
- pairs = append(pairs, [2]int{i - 1, j - 1})
- i--
- j--
- case dp[i-1][j] >= dp[i][j-1]:
- i--
- default:
- j--
- }
- }
- slices.Reverse(pairs)
- return pairs
-}
diff --git a/internal/diff/mapline.go b/internal/diff/mapline.go
deleted file mode 100644
index 74f7bad..0000000
--- a/internal/diff/mapline.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package diff
-
-// MapLine returns the new-output line index corresponding to old-output line idx, so a
-// caller can keep a scroll or cursor position anchored to the same content across a refresh.
-// A matched line maps to its counterpart; a deleted line maps to the position where it was;
-// an unrelocatable line (coarse fallback, nothing similar) returns idx unchanged.
-func (a Alignment) MapLine(idx int) int {
- if idx < 0 {
- return idx
- }
- if a.coarse {
- return a.mapLineByScan(idx)
- }
- curNew := 0
- for _, o := range a.ops {
- switch o.kind {
- case opMatch:
- if o.oldIdx == idx {
- return o.newIdx
- }
- curNew++
- case opInsert:
- curNew++
- case opDelete:
- if o.oldIdx == idx {
- return curNew
- }
- }
- }
- return curNew
-}
-
-// mapLineByScan is MapLine's coarse fallback: relocate a single old line by best token
-// similarity, biased toward its old index, returning idx unchanged when nothing clears the
-// threshold.
-func (a Alignment) mapLineByScan(idx int) int {
- if idx >= len(a.oldLines) {
- return min(idx, len(a.newLines))
- }
- target := tokenSet(a.oldLines[idx])
- best := -1
- bestScore := 0.0
- for j, nl := range a.newLines {
- score := jaccard(target, tokenSet(nl))
- if score < simThreshold {
- continue
- }
- if best == -1 || score > bestScore || (score == bestScore && max(j-idx, idx-j) < max(best-idx, idx-best)) {
- best, bestScore = j, score
- }
- }
- if best == -1 {
- return idx
- }
- return best
-}
diff --git a/internal/diff/similarity.go b/internal/diff/similarity.go
deleted file mode 100644
index 188b1e8..0000000
--- a/internal/diff/similarity.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package diff
-
-import "strings"
-
-// tokenSet returns the set of whitespace-separated tokens in line. This coarse, set-based
-// tokenization is the basis for row identity: two rows are "the same" when their token sets
-// overlap enough (jaccard), so an identifier token (e.g. a pod NAME) keeps a row matched to
-// its old self even as volatile fields change. It is deliberately coarser than the word
-// tokenizer used for highlighting (token.go).
-func tokenSet(line string) map[string]struct{} {
- fields := strings.Fields(line)
- set := make(map[string]struct{}, len(fields))
- for _, f := range fields {
- set[f] = struct{}{}
- }
- return set
-}
-
-// jaccard is the overlap of two token sets, |A∩B| / |A∪B|.
-func jaccard(a, b map[string]struct{}) float64 {
- if len(a) == 0 && len(b) == 0 {
- return 1 // two empty lines are identical
- }
- if len(a) == 0 || len(b) == 0 {
- return 0 // one empty, one not: no overlap
- }
- inter := 0
- for t := range a {
- if _, ok := b[t]; ok {
- inter++
- }
- }
- union := len(a) + len(b) - inter
- return float64(inter) / float64(union)
-}
diff --git a/internal/diff/token.go b/internal/diff/token.go
deleted file mode 100644
index 9471b18..0000000
--- a/internal/diff/token.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package diff
-
-import "unicode"
-
-// tokenize splits s into maximal word-runs, maximal whitespace-runs, and each remaining
-// rune as its own token (GitHub's \w+|\s+|[^\w\s], unicode-aware). This fine, ordered
-// tokenization drives word-level highlighting (worddiff.go); it is deliberately finer than
-// the whitespace token sets used for row identity (similarity.go), which must stay coarse so
-// shared punctuation does not blur unrelated rows together.
-func tokenize(s string) []string {
- var tokens []string
- runes := []rune(s)
- for i := 0; i < len(runes); {
- switch r := runes[i]; {
- case isWord(r):
- j := i + 1
- for j < len(runes) && isWord(runes[j]) {
- j++
- }
- tokens = append(tokens, string(runes[i:j]))
- i = j
- case unicode.IsSpace(r):
- j := i + 1
- for j < len(runes) && unicode.IsSpace(runes[j]) {
- j++
- }
- tokens = append(tokens, string(runes[i:j]))
- i = j
- default:
- tokens = append(tokens, string(r))
- i++
- }
- }
- return tokens
-}
-
-func isWord(r rune) bool {
- return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
-}
diff --git a/internal/diff/worddiff.go b/internal/diff/worddiff.go
deleted file mode 100644
index 6fe1daa..0000000
--- a/internal/diff/worddiff.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package diff
-
-import "strings"
-
-// Span is a run of text within a line together with whether it is a meaningful change worth
-// highlighting. Whitespace-only spans are never marked Changed (so shifting column padding
-// does not light up), even when they differ.
-type Span struct {
- Text string
- Changed bool
-}
-
-// WordDiff breaks newLine into spans against oldLine: a token with no counterpart in oldLine
-// (and that is not whitespace-only) is marked Changed. newLine's exact text is preserved, so
-// the spans concatenate back to it. A pathologically long line falls back to a single
-// changed span.
-func WordDiff(oldLine, newLine string) []Span {
- o, n := tokenize(oldLine), tokenize(newLine)
- if len(o)*len(n) > alignCellCap {
- return []Span{{Text: newLine, Changed: true}}
- }
- return tokenDiff(o, n)
-}
-
-// tokenDiff returns the new tokens as spans. Byte-identical prefix and suffix tokens are
-// peeled before the LCS so that an inserted duplicate marks the inserted instance rather
-// than an unchanged earlier one (e.g. "a b" -> "a a b" marks the second "a").
-func tokenDiff(old, new []string) []Span {
- n, m := len(old), len(new)
- p := 0
- for p < n && p < m && old[p] == new[p] {
- p++
- }
- s := 0
- for s < n-p && s < m-p && old[n-1-s] == new[m-1-s] {
- s++
- }
-
- pairs := lcs(n-s-p, m-s-p, func(i, j int) float64 {
- if old[p+i] == new[p+j] {
- return 1
- }
- return 0
- })
- matchedMid := make([]bool, m-s-p)
- for _, pr := range pairs {
- matchedMid[pr[1]] = true
- }
-
- spans := make([]Span, m)
- for j, t := range new {
- changed := j >= p && j < m-s && !matchedMid[j-p] && strings.TrimSpace(t) != ""
- spans[j] = Span{Text: t, Changed: changed}
- }
- return spans
-}
diff --git a/internal/pathutil/pathutil.go b/internal/pathutil/pathutil.go
deleted file mode 100644
index 12fc132..0000000
--- a/internal/pathutil/pathutil.go
+++ /dev/null
@@ -1,26 +0,0 @@
-// Package pathutil holds small filesystem-path helpers shared between the CLI and the TUI.
-package pathutil
-
-import (
- "os"
- "path/filepath"
- "strings"
-)
-
-// ExpandTilde replaces a leading "~" or "~/" with the user's home directory. Other forms
-// (including "~user/...") pass through unchanged. The original input is returned alongside
-// the error on home-lookup failure so callers can choose between aborting and proceeding
-// with the literal path.
-func ExpandTilde(p string) (string, error) {
- if p != "~" && !strings.HasPrefix(p, "~/") {
- return p, nil
- }
- home, err := os.UserHomeDir()
- if err != nil {
- return p, err
- }
- if p == "~" {
- return home, nil
- }
- return filepath.Join(home, p[2:]), nil
-}
diff --git a/internal/pathutil/pathutil_test.go b/internal/pathutil/pathutil_test.go
deleted file mode 100644
index 1379c09..0000000
--- a/internal/pathutil/pathutil_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package pathutil
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestExpandTilde(t *testing.T) {
- home, err := os.UserHomeDir()
- if err != nil {
- t.Skipf("os.UserHomeDir not available: %v", err)
- }
-
- cases := []struct {
- in, want string
- }{
- {"~", home},
- {"~/foo/bar", filepath.Join(home, "foo/bar")},
- {"~user/foo", "~user/foo"},
- {"/abs/path", "/abs/path"},
- {"relative/path", "relative/path"},
- {"$VAR/foo", "$VAR/foo"},
- {"", ""},
- }
- for _, c := range cases {
- got, err := ExpandTilde(c.in)
- if err != nil {
- t.Errorf("ExpandTilde(%q) returned error: %v", c.in, err)
- continue
- }
- if got != c.want {
- t.Errorf("ExpandTilde(%q) = %q, want %q", c.in, got, c.want)
- }
- }
-}
diff --git a/internal/recording/flow.go b/internal/recording/flow.go
deleted file mode 100644
index 18662bd..0000000
--- a/internal/recording/flow.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package recording
-
-import (
- "errors"
- "fmt"
- "os"
- "strings"
- "time"
-
- "github.com/ivoronin/wch/internal/pathutil"
- "github.com/ivoronin/wch/internal/session"
-)
-
-// ErrPathExists is returned by PreflightCheck (and by Flow.Start, wrapping the underlying
-// os.ErrExist from the recorder factory) when a recording target path already exists.
-// Detect with errors.Is.
-var ErrPathExists = errors.New("recording: path exists")
-
-// AutoStartRequest signals a desire to begin recording immediately on TUI startup -- the
-// typed alternative to the empty-string-means-no convention. nil means no request.
-// Built by the CLI from the validated `-w` flag value; passed through tui.Config; the TUI
-// fires a deferred message at Init time that calls Flow.Start(Path).
-type AutoStartRequest struct {
- Path string
-}
-
-// maxSanitizedCommandLen caps the command-derived portion of a default recording filename
-// so the full name (command + timestamp + extension) stays well under typical FS limits.
-const maxSanitizedCommandLen = 80
-
-// NormalizePath trims input whitespace, expands a leading "~" or "~/" via the user's
-// home directory, and rejects empty or whitespace-only input. The only string-shape
-// canonicalization recording does -- no Clean, no Abs.
-func NormalizePath(input string) (string, error) {
- trimmed := strings.TrimSpace(input)
- expanded, err := pathutil.ExpandTilde(trimmed)
- if err != nil {
- return "", err
- }
- if expanded == "" {
- return "", errors.New("recording: empty path")
- }
- return expanded, nil
-}
-
-// PreflightCheck reports whether a recording can be created at path. Today the only
-// failure mode is ErrPathExists -- JSONLRecorder's O_EXCL open is the atomic guard,
-// but the CLI uses PreflightCheck to refuse early (before launching the TUI) with a
-// clear stderr message. Returns nil when the path is available.
-func PreflightCheck(path string) error {
- if _, err := os.Stat(path); err == nil {
- return fmt.Errorf("%w: %s", ErrPathExists, path)
- }
- return nil
-}
-
-// DefaultFilename builds a CWD-relative filename from the watched command and a moment
-// in time, e.g. "kubectl_get_pods_A_20260530-153045.wch.jsonl".
-func DefaultFilename(command string, now time.Time) string {
- base := sanitizeCommand(command)
- if base == "" {
- base = "wch"
- }
- return base + "_" + now.Format("20060102-150405") + ".wch.jsonl"
-}
-
-// sanitizeCommand collapses runs of non-alphanumerics into a single underscore, trims
-// leading/trailing underscores, and caps the result at maxSanitizedCommandLen.
-func sanitizeCommand(s string) string {
- var b strings.Builder
- prevUnderscore := false
- for _, r := range s {
- if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
- b.WriteRune(r)
- prevUnderscore = false
- continue
- }
- if !prevUnderscore {
- b.WriteByte('_')
- prevUnderscore = true
- }
- }
- out := strings.Trim(b.String(), "_")
- if len(out) > maxSanitizedCommandLen {
- out = strings.TrimRight(out[:maxSanitizedCommandLen], "_")
- }
- return out
-}
-
-// Flow is the single entry point CLI and TUI use to drive a recording. It owns adapter
-// construction and error classification; the underlying Session keeps the active Recorder
-// and writes frames through RecordIfChanged.
-//
-// The factory field is unexported and accessible only to in-package tests via newFlowWith
-// -- production callers use New, which wires the JSONLRecorder factory.
-type Flow struct {
- session *session.Session
- factory func(path string) (session.Recorder, error)
-}
-
-// New constructs a Flow that opens JSONL recordings on disk.
-func New(s *session.Session) *Flow {
- return &Flow{
- session: s,
- factory: func(path string) (session.Recorder, error) { return NewJSONLRecorder(path) },
- }
-}
-
-// Start opens a recorder at path and arms the session. Returns an error classified via
-// errors.Is(err, ErrPathExists) for the "file exists" case. The IsActive guard precedes
-// the factory call so an already-active flow never creates an orphan file that would
-// then block a future Start with os.ErrExist.
-func (f *Flow) Start(path string) error {
- if f.IsActive() {
- return errors.New("recording: already in progress")
- }
- rec, err := f.factory(path)
- if err != nil {
- if errors.Is(err, os.ErrExist) {
- return fmt.Errorf("%w: %s", ErrPathExists, path)
- }
- return err
- }
- if err := f.session.StartRecording(rec); err != nil {
- _ = rec.Close()
- return err
- }
- return nil
-}
-
-// Stop closes the active recording, if any. Idempotent -- safe to call when nothing is
-// recording.
-func (f *Flow) Stop() error {
- return f.session.StopRecording()
-}
-
-// IsActive reports whether a recording is currently in progress. Reads through to the
-// session so it stays correct after RecordIfChanged auto-finalizes on a write error.
-func (f *Flow) IsActive() bool {
- return f.session.IsRecording()
-}
diff --git a/internal/recording/flow_test.go b/internal/recording/flow_test.go
deleted file mode 100644
index 692eeda..0000000
--- a/internal/recording/flow_test.go
+++ /dev/null
@@ -1,205 +0,0 @@
-package recording
-
-import (
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-func TestNormalizePathRejectsEmpty(t *testing.T) {
- if _, err := NormalizePath(""); err == nil {
- t.Errorf("empty input should error")
- }
- if _, err := NormalizePath(" "); err == nil {
- t.Errorf("whitespace-only input should error")
- }
-}
-
-func TestNormalizePathTrimsAndPassesThrough(t *testing.T) {
- got, err := NormalizePath(" /tmp/foo.jsonl ")
- if err != nil {
- t.Fatalf("unexpected err: %v", err)
- }
- if got != "/tmp/foo.jsonl" {
- t.Errorf("got %q want %q", got, "/tmp/foo.jsonl")
- }
-}
-
-func TestNormalizePathExpandsTilde(t *testing.T) {
- home, err := os.UserHomeDir()
- if err != nil {
- t.Skip("no home directory")
- }
- got, err := NormalizePath("~/recordings/x.jsonl")
- if err != nil {
- t.Fatalf("unexpected err: %v", err)
- }
- want := filepath.Join(home, "recordings/x.jsonl")
- if got != want {
- t.Errorf("got %q want %q", got, want)
- }
-}
-
-func TestPreflightCheckMissingFileOK(t *testing.T) {
- path := filepath.Join(t.TempDir(), "absent.jsonl")
- if err := PreflightCheck(path); err != nil {
- t.Errorf("missing file should pass: %v", err)
- }
-}
-
-func TestPreflightCheckExistingFileFails(t *testing.T) {
- path := filepath.Join(t.TempDir(), "present.jsonl")
- if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
- t.Fatal(err)
- }
- err := PreflightCheck(path)
- if err == nil {
- t.Fatal("existing file should fail PreflightCheck")
- }
- if !errors.Is(err, ErrPathExists) {
- t.Errorf("err = %v want ErrPathExists", err)
- }
- if !strings.Contains(err.Error(), path) {
- t.Errorf("err message should include path; got %q", err.Error())
- }
-}
-
-func TestSanitizeCommand(t *testing.T) {
- cases := []struct {
- in, want string
- }{
- {"kubectl get pods -A", "kubectl_get_pods_A"},
- {"", ""},
- {" ", ""},
- {"---a---", "a"},
- {"cat /tmp/foo | grep bar", "cat_tmp_foo_grep_bar"},
- {"df -h | awk '{print $1}'", "df_h_awk_print_1"},
- {"top", "top"},
- // Runs of non-alphanumerics collapse to a single underscore.
- {"a b\t\tc", "a_b_c"},
- }
- for _, c := range cases {
- got := sanitizeCommand(c.in)
- if got != c.want {
- t.Errorf("sanitizeCommand(%q) = %q, want %q", c.in, got, c.want)
- }
- }
-
- long := strings.Repeat("a", 200)
- got := sanitizeCommand(long)
- if len(got) != maxSanitizedCommandLen {
- t.Errorf("sanitizeCommand(200×'a') length = %d, want %d", len(got), maxSanitizedCommandLen)
- }
- if strings.HasSuffix(got, "_") {
- t.Errorf("sanitizeCommand of long alpha left a trailing underscore: %q", got)
- }
-
- // Truncated tail that lands on a run of separators should also drop trailing underscores.
- mix := strings.Repeat("a", maxSanitizedCommandLen-2) + "--tail"
- got = sanitizeCommand(mix)
- if strings.HasSuffix(got, "_") {
- t.Errorf("sanitizeCommand(%q) left trailing underscore: %q", mix, got)
- }
-}
-
-func TestDefaultFilename(t *testing.T) {
- when := time.Date(2026, 5, 30, 15, 30, 45, 0, time.Local)
-
- got := DefaultFilename("kubectl get pods -A", when)
- want := "kubectl_get_pods_A_20260530-153045.wch.jsonl"
- if got != want {
- t.Errorf("DefaultFilename(kubectl, ...) = %q, want %q", got, want)
- }
-
- got = DefaultFilename("", when)
- want = "wch_20260530-153045.wch.jsonl"
- if got != want {
- t.Errorf("DefaultFilename(empty, ...) = %q, want %q", got, want)
- }
-}
-
-// newFlowWith injects a recorder factory so Flow.Start can be exercised against an
-// InMemoryRecorder rather than the on-disk JSONL one.
-func newFlowWith(s *session.Session, factory func(path string) (session.Recorder, error)) *Flow {
- f := New(s)
- f.factory = factory
- return f
-}
-
-func TestFlowStartSuccessOnInMemory(t *testing.T) {
- s := session.NewSession("cmd", time.Second)
- rec := NewInMemoryRecorder()
- flow := newFlowWith(s, func(path string) (session.Recorder, error) { return rec, nil })
-
- if err := flow.Start("/tmp/anything.jsonl"); err != nil {
- t.Fatalf("Start err = %v", err)
- }
- if !flow.IsActive() {
- t.Errorf("IsActive() should be true")
- }
- if rec.Header().Command != "cmd" {
- t.Errorf("recorder Header.Command=%q want cmd", rec.Header().Command)
- }
-}
-
-func TestFlowStartFileExists(t *testing.T) {
- s := session.NewSession("cmd", time.Second)
- flow := newFlowWith(s, func(path string) (session.Recorder, error) {
- return nil, os.ErrExist
- })
- err := flow.Start("/tmp/existing.jsonl")
- if !errors.Is(err, ErrPathExists) {
- t.Errorf("err=%v want wraps ErrPathExists", err)
- }
- if flow.IsActive() {
- t.Errorf("IsActive() should be false after ErrPathExists")
- }
-}
-
-func TestFlowStartIOError(t *testing.T) {
- s := session.NewSession("cmd", time.Second)
- boom := errors.New("disk explode")
- flow := newFlowWith(s, func(path string) (session.Recorder, error) { return nil, boom })
- err := flow.Start("/tmp/err.jsonl")
- if !errors.Is(err, boom) {
- t.Errorf("err=%v want wraps boom", err)
- }
- if errors.Is(err, ErrPathExists) {
- t.Errorf("err should not match ErrPathExists")
- }
-}
-
-func TestFlowStopIdempotent(t *testing.T) {
- s := session.NewSession("cmd", time.Second)
- rec := NewInMemoryRecorder()
- flow := newFlowWith(s, func(path string) (session.Recorder, error) { return rec, nil })
- if err := flow.Start("/tmp/x.jsonl"); err != nil {
- t.Fatalf("Start: %v", err)
- }
- if err := flow.Stop(); err != nil {
- t.Errorf("first Stop: %v", err)
- }
- if err := flow.Stop(); err != nil {
- t.Errorf("second Stop: %v", err)
- }
- if flow.IsActive() {
- t.Errorf("IsActive() should be false after Stop")
- }
-}
-
-func TestFlowStartDefaultFactoryIsJSONL(t *testing.T) {
- s := session.NewSession("cmd", time.Second)
- flow := New(s)
- path := filepath.Join(t.TempDir(), "y.jsonl")
- if err := flow.Start(path); err != nil {
- t.Fatalf("Start err = %v", err)
- }
- if err := flow.Stop(); err != nil {
- t.Fatal(err)
- }
-}
diff --git a/internal/recording/inmem.go b/internal/recording/inmem.go
deleted file mode 100644
index 596201e..0000000
--- a/internal/recording/inmem.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package recording
-
-import (
- "errors"
- "slices"
- "time"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// ErrInjectedWriteFailure is the error InMemoryRecorder returns from WriteFrame after
-// the count configured via FailWriteAfter has been exhausted. Use errors.Is to detect.
-var ErrInjectedWriteFailure = errors.New("recording: injected write failure")
-
-// InMemoryRecorder is a session.Recorder that retains every header + frame in memory.
-// Useful for tests (in this package and in session tests) and any in-process caller
-// that wants to inspect recording behavior without touching the filesystem.
-type InMemoryRecorder struct {
- header Header
- frames []Frame
- closed bool
- writesBefore int // FailWriteAfter target; -1 = never fail
- writesDone int
-}
-
-// NewInMemoryRecorder returns an empty in-memory recorder. Initialize must be called
-// before WriteFrame; the zero state is intentionally invalid to surface misuse.
-func NewInMemoryRecorder() *InMemoryRecorder {
- return &InMemoryRecorder{writesBefore: -1}
-}
-
-// FailWriteAfter arms the recorder to return ErrInjectedWriteFailure from the
-// (n+1)-th WriteFrame call onward. Used by session.RecordIfChanged auto-finalize
-// tests. Call before Initialize.
-func (r *InMemoryRecorder) FailWriteAfter(n int) {
- r.writesBefore = n
-}
-
-// Initialize records the header (Command, Interval), resets the in-memory frame slice,
-// and seeds it with the supplied backlog. The wch JSONL format is described by
-// Header.Format/Version, so those are filled in from the package-level constants.
-func (r *InMemoryRecorder) Initialize(command string, interval time.Duration, backlog []session.Execution) error {
- r.header = Header{
- Format: FormatTag,
- Version: SupportedVersion,
- Command: command,
- Interval: interval.String(),
- }
- r.frames = r.frames[:0]
- for _, e := range backlog {
- r.frames = append(r.frames, frameFrom(e))
- }
- return nil
-}
-
-// WriteFrame appends a frame to the in-memory log, honoring FailWriteAfter if set.
-func (r *InMemoryRecorder) WriteFrame(exec session.Execution) error {
- if r.writesBefore >= 0 && r.writesDone >= r.writesBefore {
- return ErrInjectedWriteFailure
- }
- r.writesDone++
- r.frames = append(r.frames, frameFrom(exec))
- return nil
-}
-
-// Close marks the recorder closed. Idempotent.
-func (r *InMemoryRecorder) Close() error {
- r.closed = true
- return nil
-}
-
-// InMemoryHeader is the inspected view returned by Header(). Only the parsed Interval
-// and Command are exposed; Format and Version are wch-internal constants.
-type InMemoryHeader struct {
- Command string
- Interval time.Duration
-}
-
-// Header returns the header recorded at Initialize time.
-func (r *InMemoryRecorder) Header() InMemoryHeader {
- d, _ := time.ParseDuration(r.header.Interval)
- return InMemoryHeader{Command: r.header.Command, Interval: d}
-}
-
-// Frames returns a defensive copy of every frame captured (backlog + WriteFrame).
-func (r *InMemoryRecorder) Frames() []Frame {
- return slices.Clone(r.frames)
-}
-
-// Closed reports whether Close has been called.
-func (r *InMemoryRecorder) Closed() bool {
- return r.closed
-}
-
-// Compile-time assertion: InMemoryRecorder must satisfy session.Recorder.
-var _ session.Recorder = (*InMemoryRecorder)(nil)
diff --git a/internal/recording/inmem_test.go b/internal/recording/inmem_test.go
deleted file mode 100644
index a20d98f..0000000
--- a/internal/recording/inmem_test.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package recording
-
-import (
- "errors"
- "testing"
- "time"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-func TestInMemoryRecorderInitializeStoresBacklog(t *testing.T) {
- r := NewInMemoryRecorder()
- backlog := []session.Execution{
- {Stdout: "a\n"},
- {Stdout: "b\n"},
- }
- if err := r.Initialize("cmd", time.Second, backlog); err != nil {
- t.Fatalf("Initialize: %v", err)
- }
- if got := r.Frames(); len(got) != 2 || got[0].Stdout != "a\n" || got[1].Stdout != "b\n" {
- t.Errorf("Frames after Initialize: %+v", got)
- }
- h := r.Header()
- if h.Command != "cmd" || h.Interval != time.Second {
- t.Errorf("Header after Initialize: %+v", h)
- }
-}
-
-func TestInMemoryRecorderWriteFrameAppends(t *testing.T) {
- r := NewInMemoryRecorder()
- _ = r.Initialize("x", time.Second, nil)
- if err := r.WriteFrame(session.Execution{Stdout: "c\n"}); err != nil {
- t.Fatalf("WriteFrame: %v", err)
- }
- if got := r.Frames(); len(got) != 1 || got[0].Stdout != "c\n" {
- t.Errorf("Frames after WriteFrame: %+v", got)
- }
-}
-
-func TestInMemoryRecorderCloseIdempotent(t *testing.T) {
- r := NewInMemoryRecorder()
- _ = r.Initialize("x", time.Second, nil)
- if err := r.Close(); err != nil {
- t.Errorf("first Close: %v", err)
- }
- if err := r.Close(); err != nil {
- t.Errorf("second Close: %v", err)
- }
- if !r.Closed() {
- t.Errorf("Closed() should be true after Close")
- }
-}
-
-func TestInMemoryRecorderFailWriteAfter(t *testing.T) {
- r := NewInMemoryRecorder()
- r.FailWriteAfter(1)
- _ = r.Initialize("x", time.Second, nil)
- if err := r.WriteFrame(session.Execution{Stdout: "ok\n"}); err != nil {
- t.Fatalf("first WriteFrame should succeed: %v", err)
- }
- err := r.WriteFrame(session.Execution{Stdout: "boom\n"})
- if err == nil {
- t.Fatal("second WriteFrame should fail")
- }
- if !errors.Is(err, ErrInjectedWriteFailure) {
- t.Errorf("err = %v want ErrInjectedWriteFailure", err)
- }
-}
diff --git a/internal/recording/jsonl.go b/internal/recording/jsonl.go
deleted file mode 100644
index 3fe2c49..0000000
--- a/internal/recording/jsonl.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package recording
-
-import (
- "encoding/json"
- "os"
- "time"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// JSONLRecorder is the on-disk session.Recorder: one JSON value per line, header first.
-// The file is opened with O_EXCL — refusing to clobber an existing recording.
-type JSONLRecorder struct {
- path string
- f *os.File
- enc *json.Encoder
-}
-
-// NewJSONLRecorder opens path with O_EXCL, ready for Initialize to be called next.
-// On os.ErrExist the caller (Flow.Start) is responsible for the user-facing message.
-func NewJSONLRecorder(path string) (*JSONLRecorder, error) {
- f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
- if err != nil {
- return nil, err
- }
- enc := json.NewEncoder(f)
- enc.SetEscapeHTML(false)
- return &JSONLRecorder{path: path, f: f, enc: enc}, nil
-}
-
-// Initialize writes the header followed by every backlog frame. On any write error
-// during initialization the file is closed AND removed — otherwise the orphan would
-// block a same-path retry under O_EXCL until the user manually deletes it.
-func (r *JSONLRecorder) Initialize(command string, interval time.Duration, backlog []session.Execution) error {
- if err := r.enc.Encode(Header{
- Format: FormatTag,
- Version: SupportedVersion,
- Command: command,
- Interval: interval.String(),
- }); err != nil {
- r.abortAndRemove()
- return err
- }
- for _, e := range backlog {
- if err := r.enc.Encode(frameFrom(e)); err != nil {
- r.abortAndRemove()
- return err
- }
- }
- return nil
-}
-
-// WriteFrame persists one novel execution.
-func (r *JSONLRecorder) WriteFrame(exec session.Execution) error {
- return r.enc.Encode(frameFrom(exec))
-}
-
-// Close releases the file handle. Idempotent.
-func (r *JSONLRecorder) Close() error {
- if r.f == nil {
- return nil
- }
- err := r.f.Close()
- r.f = nil
- return err
-}
-
-// abortAndRemove closes the file, zeroes the handle (so Close is a true no-op after this),
-// and unlinks the path. Used by Initialize on any write failure so the O_EXCL guard doesn't
-// stay stuck on an orphan and so a subsequent Close call from the caller's error path is a
-// clean no-op.
-func (r *JSONLRecorder) abortAndRemove() {
- if r.f == nil {
- return
- }
- _ = r.f.Close()
- r.f = nil
- _ = os.Remove(r.path)
-}
-
-// Compile-time guarantee that JSONLRecorder satisfies session.Recorder.
-var _ session.Recorder = (*JSONLRecorder)(nil)
diff --git a/internal/recording/jsonl_test.go b/internal/recording/jsonl_test.go
deleted file mode 100644
index b69183f..0000000
--- a/internal/recording/jsonl_test.go
+++ /dev/null
@@ -1,241 +0,0 @@
-package recording
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// helpers
-
-func mustStartJSONL(t *testing.T, s *session.Session, path string) *JSONLRecorder {
- t.Helper()
- rec, err := NewJSONLRecorder(path)
- if err != nil {
- t.Fatalf("NewJSONLRecorder: %v", err)
- }
- if err := s.StartRecording(rec); err != nil {
- t.Fatalf("StartRecording: %v", err)
- }
- return rec
-}
-
-func mustRecord(t *testing.T, s *session.Session, exec session.Execution) {
- t.Helper()
- if _, _, err := s.RecordIfChanged(exec); err != nil {
- t.Fatalf("RecordIfChanged: %v", err)
- }
-}
-
-// A full round-trip preserves ANSI in stdout, separate stderr, non-zero exit, and the error
-// message string. The recording is closed before Load, replay never re-arms.
-func TestRecordingRoundTrip(t *testing.T) {
- path := filepath.Join(t.TempDir(), "wch.jsonl")
-
- s := session.NewSession("kubectl get pods", 5*time.Second)
- mustStartJSONL(t, s, path)
-
- mustRecord(t, s, session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC),
- Stdout: "\x1b[1mNAME\x1b[0m\npod-1 \x1b[32mRunning\x1b[0m\n",
- })
- mustRecord(t, s, session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 2, 0, time.UTC),
- Stdout: "out\n",
- Stderr: "warn\n",
- ExitCode: 2,
- Error: errors.New("exit status 2"),
- })
-
- if err := s.StopRecording(); err != nil {
- t.Fatalf("StopRecording: %v", err)
- }
- if s.IsRecording() {
- t.Errorf("IsRecording() should be false after Stop")
- }
-
- got, err := Load(path)
- if err != nil {
- t.Fatalf("Load: %v", err)
- }
- if got.Command != "kubectl get pods" {
- t.Errorf("Command=%q want %q", got.Command, "kubectl get pods")
- }
- if got.Interval != 5*time.Second {
- t.Errorf("Interval=%v want %v", got.Interval, 5*time.Second)
- }
- if len(got.History) != 2 {
- t.Fatalf("History len=%d want 2", len(got.History))
- }
- if got.History[0].Stdout != s.History[0].Stdout {
- t.Errorf("ANSI stdout not preserved: %q vs %q", got.History[0].Stdout, s.History[0].Stdout)
- }
- if got.History[1].Stderr != "warn\n" {
- t.Errorf("Stderr=%q want %q", got.History[1].Stderr, "warn\n")
- }
- if got.History[1].ExitCode != 2 {
- t.Errorf("ExitCode=%d want 2", got.History[1].ExitCode)
- }
- if got.History[1].Error == nil || got.History[1].Error.Error() != "exit status 2" {
- t.Errorf("Error not preserved: %v", got.History[1].Error)
- }
- if got.IsRecording() {
- t.Errorf("Loaded session must not be armed for recording")
- }
-}
-
-// StartRecording dumps every frame already in History.
-func TestRecordingStartDumpsBacklog(t *testing.T) {
- path := filepath.Join(t.TempDir(), "wch.jsonl")
-
- s := session.NewSession("x", time.Second)
- for i := 0; i < 4; i++ {
- mustRecord(t, s, session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, i, 0, time.UTC),
- Stdout: fmt.Sprintf("frame %d\n", i),
- })
- }
- mustStartJSONL(t, s, path)
- if err := s.StopRecording(); err != nil {
- t.Fatal(err)
- }
-
- got, err := Load(path)
- if err != nil {
- t.Fatalf("Load: %v", err)
- }
- if len(got.History) != 4 {
- t.Fatalf("backlog dump frames=%d want 4", len(got.History))
- }
- for i, f := range got.History {
- wantStdout := fmt.Sprintf("frame %d\n", i)
- if f.Stdout != wantStdout {
- t.Errorf("frame %d stdout=%q want %q", i, f.Stdout, wantStdout)
- }
- }
-}
-
-// A frame larger than bufio.Scanner's 64KB token cap round-trips because Load calls
-// scanner.Buffer with maxLineSize (256 MB).
-func TestRecordingLargeFrameRoundTrip(t *testing.T) {
- path := filepath.Join(t.TempDir(), "wch.jsonl")
- big := strings.Repeat("kubernetes is large\n", 5000) // > 64KB
-
- s := session.NewSession("x", time.Second)
- mustStartJSONL(t, s, path)
- mustRecord(t, s, session.Execution{Timestamp: time.Now().UTC(), Stdout: big})
- if err := s.StopRecording(); err != nil {
- t.Fatal(err)
- }
-
- got, err := Load(path)
- if err != nil {
- t.Fatalf("Load: %v", err)
- }
- if len(got.History) != 1 {
- t.Fatalf("History len=%d want 1", len(got.History))
- }
- if got.History[0].Stdout != big {
- t.Errorf("large stdout did not round-trip (lens %d vs %d)", len(got.History[0].Stdout), len(big))
- }
-}
-
-// A crash-truncated final line is tolerated: Load returns every fully-decoded frame and no
-// error.
-func TestRecordingTruncatedTail(t *testing.T) {
- path := filepath.Join(t.TempDir(), "wch.jsonl")
-
- s := session.NewSession("x", time.Second)
- mustStartJSONL(t, s, path)
- mustRecord(t, s, session.Execution{Timestamp: time.Now().UTC(), Stdout: "first\n"})
- mustRecord(t, s, session.Execution{Timestamp: time.Now().UTC(), Stdout: "second\n"})
- if err := s.StopRecording(); err != nil {
- t.Fatal(err)
- }
-
- // Append a partial JSON object (no closing brace, no newline).
- f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := f.WriteString(`{"ts":"2026-`); err != nil {
- t.Fatal(err)
- }
- if err := f.Close(); err != nil {
- t.Fatal(err)
- }
-
- got, err := Load(path)
- if err == nil {
- t.Fatal("Load should report the skipped corrupt tail as a non-fatal warning")
- }
- if got == nil {
- t.Fatalf("Load returned nil session despite recoverable error: %v", err)
- }
- if len(got.History) != 2 {
- t.Errorf("History len=%d want 2 (truncated tail should be dropped)", len(got.History))
- }
-}
-
-// NewJSONLRecorder opens the file with O_EXCL, so a second call at the same path refuses
-// with os.ErrExist rather than clobbering the existing recording.
-func TestRecordingRefusesExistingFile(t *testing.T) {
- path := filepath.Join(t.TempDir(), "wch.jsonl")
-
- first := session.NewSession("first", time.Second)
- mustStartJSONL(t, first, path)
- mustRecord(t, first, session.Execution{Timestamp: time.Now().UTC(), Stdout: "FIRST\n"})
- if err := first.StopRecording(); err != nil {
- t.Fatal(err)
- }
-
- _, err := NewJSONLRecorder(path)
- if !errors.Is(err, os.ErrExist) {
- t.Fatalf("second NewJSONLRecorder err = %v, want os.ErrExist", err)
- }
-
- // First recording should still be readable, untouched.
- got, err := Load(path)
- if err != nil {
- t.Fatalf("Load: %v", err)
- }
- if got.Command != "first" {
- t.Errorf("Command=%q want %q (first recording must survive)", got.Command, "first")
- }
- if len(got.History) != 1 || got.History[0].Stdout != "FIRST\n" {
- t.Errorf("History=%+v want one FIRST frame", got.History)
- }
-}
-
-// Sanity: the encoded file is JSONL (one well-formed JSON value per line, no extra escapes).
-func TestRecordingIsJSONL(t *testing.T) {
- path := filepath.Join(t.TempDir(), "wch.jsonl")
- s := session.NewSession("kubectl", time.Second)
- mustStartJSONL(t, s, path)
- mustRecord(t, s, session.Execution{Timestamp: time.Now().UTC(), Stdout: "hi\n"})
- if err := s.StopRecording(); err != nil {
- t.Fatal(err)
- }
-
- raw, err := os.ReadFile(path)
- if err != nil {
- t.Fatal(err)
- }
- lines := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
- if len(lines) != 2 {
- t.Fatalf("expected 2 lines (header + 1 frame), got %d:\n%s", len(lines), raw)
- }
- for i, line := range lines {
- var v map[string]any
- if err := json.Unmarshal([]byte(line), &v); err != nil {
- t.Errorf("line %d is not valid JSON: %v\nraw=%q", i, err, line)
- }
- }
-}
diff --git a/internal/recording/load.go b/internal/recording/load.go
deleted file mode 100644
index 6ffeb1f..0000000
--- a/internal/recording/load.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package recording
-
-import (
- "bufio"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "time"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// maxLineSize caps a single JSONL line. 256 MB is generous enough to load recordings made
-// before the runner's per-stream cap landed, while bounding the buffer's worst-case growth.
-const maxLineSize = 256 * 1024 * 1024
-
-// Load reads a wch-history JSONL file into a Session. The header is validated for both
-// format tag and version; mismatch returns a typed error so older binaries fail loud rather
-// than silently mis-parsing a future format.
-//
-// Frame decoding is line-based and recoverable: a single malformed line (mid-stream corruption
-// or a crash-truncated trailing line) is skipped so any fully-decoded frames after it still
-// load. The skipped-line count and any I/O error are surfaced via the returned error so the
-// caller can warn — Load still returns a non-nil Session in that case, signalling "partial
-// load, here's what survived" rather than "load failed".
-//
-// The returned Session is not armed for recording — replay never persists.
-func Load(path string) (*session.Session, error) {
- f, err := os.Open(path)
- if err != nil {
- return nil, err
- }
- defer func() { _ = f.Close() }()
-
- scanner := bufio.NewScanner(f)
- scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
-
- if !scanner.Scan() {
- if err := scanner.Err(); err != nil {
- return nil, fmt.Errorf("recording: read header: %w", err)
- }
- return nil, errors.New("recording: empty file")
- }
- var header Header
- if err := json.Unmarshal(scanner.Bytes(), &header); err != nil {
- return nil, fmt.Errorf("recording: invalid header: %w", err)
- }
- if header.Format != FormatTag {
- return nil, fmt.Errorf("recording: unknown format %q (expected %q)", header.Format, FormatTag)
- }
- if header.Version != SupportedVersion {
- return nil, fmt.Errorf("recording: unsupported version %d (this build supports %d)", header.Version, SupportedVersion)
- }
- interval, err := time.ParseDuration(header.Interval)
- if err != nil {
- return nil, fmt.Errorf("recording: invalid interval %q: %w", header.Interval, err)
- }
- s := session.NewSession(header.Command, interval)
- var skipped int
- for scanner.Scan() {
- var frame Frame
- if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil {
- skipped++
- continue
- }
- s.History = append(s.History, executionFrom(frame))
- }
- if err := scanner.Err(); err != nil {
- return s, fmt.Errorf("recording: read: %w", err)
- }
- if skipped > 0 {
- return s, fmt.Errorf("recording: skipped %d corrupt frame(s)", skipped)
- }
- return s, nil
-}
-
-// executionFrom converts a Frame back to a session.Execution. The inverse of frameFrom
-// (in schema.go).
-func executionFrom(f Frame) session.Execution {
- var err error
- if f.Error != "" {
- err = errors.New(f.Error)
- }
- return session.Execution{
- Timestamp: f.Ts,
- Stdout: f.Stdout,
- Stderr: f.Stderr,
- ExitCode: f.Exit,
- Error: err,
- }
-}
diff --git a/internal/recording/load_test.go b/internal/recording/load_test.go
deleted file mode 100644
index f979d46..0000000
--- a/internal/recording/load_test.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package recording
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestLoadRejectsBadFormat(t *testing.T) {
- path := filepath.Join(t.TempDir(), "bad.jsonl")
- if err := os.WriteFile(path, []byte(`{"format":"other","version":1,"command":"x","interval":"1s"}`+"\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := Load(path); err == nil {
- t.Errorf("expected Load to reject unknown format")
- }
-}
-
-func TestLoadRejectsBadVersion(t *testing.T) {
- path := filepath.Join(t.TempDir(), "future.jsonl")
- if err := os.WriteFile(path, []byte(`{"format":"wch-history","version":99,"command":"x","interval":"1s"}`+"\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := Load(path); err == nil {
- t.Errorf("expected Load to reject unsupported version")
- }
-}
-
-func TestLoadRejectsBadInterval(t *testing.T) {
- path := filepath.Join(t.TempDir(), "bad-interval.jsonl")
- if err := os.WriteFile(path, []byte(`{"format":"wch-history","version":1,"command":"x","interval":"not-a-duration"}`+"\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := Load(path); err == nil {
- t.Errorf("expected Load to reject bad interval")
- }
-}
diff --git a/internal/recording/schema.go b/internal/recording/schema.go
deleted file mode 100644
index fb75aa4..0000000
--- a/internal/recording/schema.go
+++ /dev/null
@@ -1,52 +0,0 @@
-// Package recording owns the on-disk recording format and the recording lifecycle.
-// The session.Recorder port lives in internal/session; the adapters (JSONL on disk and
-// in-memory) live here, alongside Flow — the single entry point CLI and TUI use to
-// start, stop, and inspect a recording.
-package recording
-
-import (
- "time"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// FormatTag identifies a wch-history file. Every recording begins with a header line
-// containing this tag.
-const FormatTag = "wch-history"
-
-// SupportedVersion is the only file-format version this build accepts.
-const SupportedVersion = 1
-
-// Header is the first JSONL line of every wch-history recording.
-type Header struct {
- Format string `json:"format"`
- Version int `json:"version"`
- Command string `json:"command"`
- Interval string `json:"interval"`
-}
-
-// Frame is one captured execution as it sits on disk.
-type Frame struct {
- Ts time.Time `json:"ts"`
- Exit int `json:"exit"`
- Stdout string `json:"stdout"`
- Stderr string `json:"stderr,omitempty"`
- Error string `json:"error,omitempty"`
-}
-
-// frameFrom converts a session.Execution to a Frame. The conversion lives in the
-// recording package because session does not know about the on-disk schema; both
-// JSONLRecorder and InMemoryRecorder use it.
-func frameFrom(e session.Execution) Frame {
- errMsg := ""
- if e.Error != nil {
- errMsg = e.Error.Error()
- }
- return Frame{
- Ts: e.Timestamp,
- Exit: e.ExitCode,
- Stdout: e.Stdout,
- Stderr: e.Stderr,
- Error: errMsg,
- }
-}
diff --git a/internal/runner/runner.go b/internal/runner/runner.go
deleted file mode 100644
index 6a1a800..0000000
--- a/internal/runner/runner.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package runner
-
-import (
- "bytes"
- "context"
- "os/exec"
- "time"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-const errorExitCode = -1 // Used when error is not an ExitError
-
-// Runner executes commands
-type Runner struct {
- command string
-}
-
-// New creates a new runner
-func New(command string) *Runner {
- return &Runner{
- command: command,
- }
-}
-
-// maxOutputBytes caps the captured stdout/stderr per stream so a runaway streaming command
-// (e.g. `journalctl -f`, `cat /dev/urandom`) cannot exhaust memory through the history.
-// 4 MB comfortably absorbs typical kubectl/ps/etc. output while bounding worst-case usage.
-// Excess bytes are silently dropped — no marker is inserted into the captured stream
-// because the same buffer ends up in the recording file, where a synthetic suffix would be
-// indistinguishable from real output to a later replay or downstream consumer.
-const maxOutputBytes = 4 * 1024 * 1024
-
-// limitedBuffer is a bytes.Buffer that stops growing past maxOutputBytes, dropping the
-// excess. exec.Cmd treats a short write as a failure that aborts the command, so Write must
-// always report n == len(p) to keep the process running even after we've stopped recording.
-// String is promoted from the embedded buffer.
-type limitedBuffer struct {
- bytes.Buffer
-}
-
-func (l *limitedBuffer) Write(p []byte) (int, error) {
- if space := maxOutputBytes - l.Len(); space > 0 {
- l.Buffer.Write(p[:min(len(p), space)])
- }
- return len(p), nil
-}
-
-// Execute runs the command and returns the result. Timestamp is the start time of the
-// invocation (when wch decided to run the command), not the finish time — finish-time
-// stamps drift further from "what wch did" the slower the command is.
-func (r *Runner) Execute(ctx context.Context) session.Execution {
- cmd := exec.CommandContext(ctx, "sh", "-c", r.command)
-
- var stdout, stderr limitedBuffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- result := session.Execution{Timestamp: time.Now()}
- err := cmd.Run()
- result.Stdout = stdout.String()
- result.Stderr = stderr.String()
-
- if err != nil {
- result.Error = err
- if exitErr, ok := err.(*exec.ExitError); ok {
- result.ExitCode = exitErr.ExitCode()
- } else {
- result.ExitCode = errorExitCode
- }
- }
-
- return result
-}
diff --git a/internal/session/port.go b/internal/session/port.go
deleted file mode 100644
index b1b2bdf..0000000
--- a/internal/session/port.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package session
-
-import "time"
-
-// Recorder is the persistence port a Session writes to when armed for recording.
-// Adapters live in internal/recording: JSONLRecorder for the on-disk format, and
-// InMemoryRecorder for tests and general in-process callers. Outside this package,
-// callers should drive recording through recording.Flow rather than this port directly.
-type Recorder interface {
- // Initialize is called exactly once when the Session arms for recording. The adapter
- // is responsible for writing any header it needs and for persisting the supplied
- // backlog (every execution already in History at the moment recording begins).
- Initialize(command string, interval time.Duration, backlog []Execution) error
-
- // WriteFrame persists one novel execution. Called for every execution
- // RecordIfChanged accepted into History after Initialize has run.
- WriteFrame(exec Execution) error
-
- // Close releases any resources the adapter holds. Idempotent.
- Close() error
-}
diff --git a/internal/session/session.go b/internal/session/session.go
deleted file mode 100644
index 15d084b..0000000
--- a/internal/session/session.go
+++ /dev/null
@@ -1,124 +0,0 @@
-package session
-
-import (
- "errors"
- "slices"
- "time"
-)
-
-// Execution represents a single command execution result
-type Execution struct {
- Timestamp time.Time
- Stdout string
- Stderr string
- ExitCode int
- Error error
-}
-
-// Output returns combined stdout and stderr
-func (e *Execution) Output() string {
- if e.Stderr == "" {
- return e.Stdout
- }
- if e.Stdout == "" {
- return e.Stderr
- }
- return e.Stdout + "\n" + e.Stderr
-}
-
-// Session holds execution history and the active recorder, if any. The Recorder port is
-// defined here (port.go); concrete adapters and the persistence format live in
-// internal/recording. The session owns the recorder's lifetime (same lifetime as the
-// session itself) but knows nothing about JSONL, file I/O, or path normalization —
-// recording.Flow is the entry point external callers should use.
-type Session struct {
- Command string
- Interval time.Duration
- History []Execution
- MaxHistory int
- recorder Recorder // nil ⇔ not recording
-}
-
-// NewSession creates a new session
-func NewSession(command string, interval time.Duration) *Session {
- return &Session{
- Command: command,
- Interval: interval,
- }
-}
-
-// RecordIfChanged adds an execution to history only if it differs materially from the
-// previous one — output, exit code, OR error string. A frame that prints the same text but
-// changes exit code or error must not be dropped, otherwise downstream UI (exit-code
-// annotation, OSC9 bell, replay) loses the transition. When a recording is active and the
-// frame is added, the frame is also written to the file; a write error auto-finalizes the
-// recording (close + clear) and is returned. added reports whether the execution was novel;
-// evicted reports whether MaxHistory just rotated the oldest entry out (callers tracking a
-// history cursor need to decrement it).
-func (s *Session) RecordIfChanged(exec Execution) (added bool, evicted bool, err error) {
- if len(s.History) > 0 {
- last := s.History[len(s.History)-1]
- if exec.Output() == last.Output() &&
- exec.ExitCode == last.ExitCode &&
- errString(exec.Error) == errString(last.Error) {
- return false, false, nil
- }
- }
- s.History = append(s.History, exec)
- if s.MaxHistory > 0 && len(s.History) > s.MaxHistory {
- // slices.Delete (rather than History[1:]) copy-shifts and zeros the freed tail slot,
- // so the evicted Execution's stdout/stderr strings are released for GC instead of
- // staying pinned in the backing array until the next slice growth.
- s.History = slices.Delete(s.History, 0, 1)
- evicted = true
- }
- if s.recorder != nil {
- if writeErr := s.recorder.WriteFrame(exec); writeErr != nil {
- _ = s.recorder.Close()
- s.recorder = nil
- return true, evicted, writeErr
- }
- }
- return true, evicted, nil
-}
-
-func errString(e error) string {
- if e == nil {
- return ""
- }
- return e.Error()
-}
-
-// StartRecording arms the session to persist subsequent additions through rec.
-// rec.Initialize is called with the current Command, Interval, and the History backlog.
-// External callers should drive recording through recording.Flow rather than constructing
-// the Recorder + calling StartRecording directly; this signature exists for Flow's use
-// and for in-package tests.
-func (s *Session) StartRecording(rec Recorder) error {
- if s.recorder != nil {
- return errors.New("session: already recording")
- }
- if err := rec.Initialize(s.Command, s.Interval, s.History); err != nil {
- return err
- }
- s.recorder = rec
- return nil
-}
-
-// StopRecording closes the recording and clears the handle. Idempotent.
-func (s *Session) StopRecording() error {
- if s.recorder == nil {
- return nil
- }
- err := s.recorder.Close()
- s.recorder = nil
- return err
-}
-
-// IsRecording reports whether a recording is currently active. External callers
-// (cmd/wch, internal/tui) should query recording.Flow.IsActive() instead — this method
-// exists so the recording package's Flow can read through to the session's single
-// source of truth. Used directly only by Flow and by in-package tests.
-func (s *Session) IsRecording() bool {
- return s.recorder != nil
-}
diff --git a/internal/session/session_test.go b/internal/session/session_test.go
deleted file mode 100644
index 7c3d1a5..0000000
--- a/internal/session/session_test.go
+++ /dev/null
@@ -1,140 +0,0 @@
-package session_test
-
-import (
- "testing"
- "time"
-
- "github.com/ivoronin/wch/internal/recording"
- "github.com/ivoronin/wch/internal/session"
-)
-
-// RecordIfChanged appends the first execution unconditionally.
-func TestRecordIfChangedFirstExecution(t *testing.T) {
- s := session.NewSession("x", time.Second)
- added, _, err := s.RecordIfChanged(session.Execution{Stdout: "hello"})
- if err != nil {
- t.Fatalf("unexpected err: %v", err)
- }
- if !added {
- t.Errorf("first execution should be added")
- }
- if len(s.History) != 1 {
- t.Errorf("History len=%d want 1", len(s.History))
- }
-}
-
-// Duplicate output (same Output()) is dropped, History stays the same length.
-func TestRecordIfChangedDeduplicates(t *testing.T) {
- s := session.NewSession("x", time.Second)
- _, _, _ = s.RecordIfChanged(session.Execution{Stdout: "a\n"})
- added, _, err := s.RecordIfChanged(session.Execution{Stdout: "a\n"})
- if err != nil {
- t.Fatalf("unexpected err: %v", err)
- }
- if added {
- t.Errorf("duplicate output should not be added")
- }
- if len(s.History) != 1 {
- t.Errorf("History len=%d want 1", len(s.History))
- }
-}
-
-// A changed output is added.
-func TestRecordIfChangedAddsChanged(t *testing.T) {
- s := session.NewSession("x", time.Second)
- _, _, _ = s.RecordIfChanged(session.Execution{Stdout: "a\n"})
- added, _, err := s.RecordIfChanged(session.Execution{Stdout: "b\n"})
- if err != nil {
- t.Fatalf("unexpected err: %v", err)
- }
- if !added {
- t.Errorf("changed output should be added")
- }
- if len(s.History) != 2 {
- t.Errorf("History len=%d want 2", len(s.History))
- }
-}
-
-// Once MaxHistory is set and exceeded, the oldest frame is trimmed.
-func TestRecordIfChangedTrimsAtMaxHistory(t *testing.T) {
- s := session.NewSession("x", time.Second)
- s.MaxHistory = 3
- for i := 0; i < 5; i++ {
- _, _, _ = s.RecordIfChanged(session.Execution{Stdout: string(rune('a' + i))})
- }
- if got, want := len(s.History), 3; got != want {
- t.Fatalf("History len=%d want %d", got, want)
- }
- // We should have kept the *last* three.
- if s.History[0].Stdout != "c" || s.History[2].Stdout != "e" {
- t.Errorf("trimmed wrong end; got %q..%q", s.History[0].Stdout, s.History[2].Stdout)
- }
-}
-
-// StopRecording is idempotent — safe to call on a session that isn't recording.
-func TestRecordingStopIdempotent(t *testing.T) {
- s := session.NewSession("x", time.Second)
- if err := s.StopRecording(); err != nil {
- t.Errorf("Stop on non-recording session: %v", err)
- }
-
- rec := recording.NewInMemoryRecorder()
- if err := s.StartRecording(rec); err != nil {
- t.Fatalf("StartRecording: %v", err)
- }
- if err := s.StopRecording(); err != nil {
- t.Errorf("first Stop: %v", err)
- }
- if err := s.StopRecording(); err != nil {
- t.Errorf("second Stop: %v", err)
- }
- if s.IsRecording() {
- t.Errorf("IsRecording() should be false")
- }
-}
-
-// StartRecording while a recording is already active returns an error and leaves the existing
-// recording untouched.
-func TestRecordingStartWhileActiveErrors(t *testing.T) {
- s := session.NewSession("x", time.Second)
- rec1 := recording.NewInMemoryRecorder()
- if err := s.StartRecording(rec1); err != nil {
- t.Fatalf("first Start: %v", err)
- }
- rec2 := recording.NewInMemoryRecorder()
- if err := s.StartRecording(rec2); err == nil {
- t.Errorf("expected Start while already recording to return an error")
- }
- if !s.IsRecording() {
- t.Errorf("IsRecording() should still be true (existing recording untouched)")
- }
- if rec2.Closed() {
- t.Errorf("second recorder must not have been touched")
- }
- _ = s.StopRecording()
-}
-
-// When a write fails mid-recording, RecordIfChanged returns the error, the session auto-
-// finalizes (recorder closed, handle cleared), IsRecording() flips to false.
-func TestRecordingAutoFinalizeOnWriteError(t *testing.T) {
- s := session.NewSession("x", time.Second)
- rec := recording.NewInMemoryRecorder()
- rec.FailWriteAfter(0) // any WriteFrame fails immediately
- if err := s.StartRecording(rec); err != nil {
- t.Fatalf("StartRecording: %v", err)
- }
- added, _, err := s.RecordIfChanged(session.Execution{Stdout: "boom\n"})
- if err == nil {
- t.Fatal("expected write error, got nil")
- }
- if !added {
- t.Errorf("frame should still count as added to in-memory History before the write attempt")
- }
- if s.IsRecording() {
- t.Errorf("IsRecording() should be false after auto-finalize")
- }
- // And the frame *is* in History — persistence failed but in-memory dedupe succeeded.
- if len(s.History) != 1 {
- t.Errorf("History len=%d want 1", len(s.History))
- }
-}
diff --git a/internal/tui/bar.go b/internal/tui/bar.go
deleted file mode 100644
index 7aa6d6e..0000000
--- a/internal/tui/bar.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package tui
-
-import (
- "strings"
-
- "charm.land/bubbles/v2/key"
- "charm.land/lipgloss/v2"
-)
-
-// barShown reports whether the bottom bar takes up a screen row right now —
-// either because the user enabled it (m.prefs.StatusBar) or because the current
-// state needs unconditional bar feedback (signalled by state.ShowsBar). Each
-// state decides for itself: inputState always wants the bar regardless of
-// host; searchState's ShowsBar is false so search hides under -t by design,
-// consistent with the user's "I asked for it off" intent.
-func (m Model) barShown() bool {
- return m.prefs.StatusBar || m.state.ShowsBar()
-}
-
-// renderBarLayout composes the standard three-column bar from the given slot contents. Left
-// is right-aligned within its zone (truncated/padded), the centered timestamp + activity
-// indicator stay centered (with the optional REC dot charged to the left so the clock does
-// not drift), and the help text fills the right zone.
-func (m Model) renderBarLayout(leftContent, indicator, helpText string) string {
- layout := calcThreeColumnLayout(m.width, centerBlockWidth)
- left := barInnerStyle.Render(renderLeft(leftContent, layout.leftWidth))
- center := m.renderCenterBlock(indicator)
- right := barInnerStyle.Render(renderRight(helpText, layout.rightWidth))
- content := lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
- return statusBarStyle.Width(m.width).Render(content)
-}
-
-// centerBlockWidth is the fixed cell width of the centered clock-group:
-//
-// [2 left-pad][rec slot 1][1 gap][timestamp][1 gap][indicator 1][2 right-pad]
-//
-// Each adjacent element on the bar has a 1-cell base gap; REC adds an extra cell on its
-// left, indicator adds an extra cell on its right — so the clock group is bracketed by
-// 2-cell gutters and stays visually distinct from the help and content zones. REC and
-// indicator slots are always-on (blank-padded when REC is idle) so toggling recording
-// mid-session never re-flows the left zone. Derived from timestampLen so a change to
-// timestampFmt propagates without re-deriving constants.
-var centerBlockWidth = 2 + 1 + 1 + timestampLen + 1 + 1 + 2
-
-// renderCenterBlock composes the always-on clock-group at exactly centerBlockWidth visible
-// cells: REC slot stays 1 cell whether recording or not. Gaps and pads are rendered inline
-// per call so an adaptive-theme switch is picked up on the next frame.
-func (m Model) renderCenterBlock(indicator string) string {
- oneSpace := barInnerStyle.Width(1).Render("")
- twoSpaces := barInnerStyle.Width(2).Render("")
- rec := oneSpace
- if m.flow.IsActive() {
- rec = recStyle.Render("●")
- }
- timestamp := barInnerStyle.Width(timestampLen).Render(m.renderTimestamp())
- return lipgloss.JoinHorizontal(lipgloss.Top,
- twoSpaces, rec, oneSpace, timestamp, oneSpace, indicator, twoSpaces,
- )
-}
-
-// renderTimestamp formats the timestamp the active state asks for in the bar center.
-// view/picker return the frame at the cursor; search returns the captured snapshot;
-// input delegates to its prev.
-func (m Model) renderTimestamp() string {
- t, ok := m.state.Timestamp(m)
- if !ok {
- return ""
- }
- return t.Format(timestampFmt)
-}
-
-// renderIndicator renders the activity indicator shown in the bar center (right of the
-// clock). searchState replaces it with ❄ via its own RenderBar.
-func (m Model) renderIndicator() string {
- var indicator string
- switch {
- case !m.isLive():
- indicator = "▶"
- case !m.isFollowing():
- indicator = "⎌"
- case m.prefs.Paused:
- indicator = "⏸"
- case m.executing:
- indicator = "*"
- default:
- indicator = "·"
- }
- return indicatorStyle.Render(indicator)
-}
-
-// renderHelp builds the bar's right-side hint as " desc" segments joined with
-// " • " and wrapped in helpStyle. Keys are bolded via boldKeep so the outer fg/bg survive
-// to the end of the line.
-func renderHelp(bindings []key.Binding) string {
- parts := make([]string, len(bindings))
- for i, b := range bindings {
- h := b.Help()
- parts[i] = boldKeep(h.Key) + " " + h.Desc
- }
- return helpStyle.Render(strings.Join(parts, " • "))
-}
diff --git a/internal/tui/bar_test.go b/internal/tui/bar_test.go
deleted file mode 100644
index cf1a081..0000000
--- a/internal/tui/bar_test.go
+++ /dev/null
@@ -1,175 +0,0 @@
-package tui
-
-import (
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// The status bar must render on a single row. If the left/center/right zones together exceed
-// the terminal's contentWidth, the outer statusBarStyle.Width(...).Render wraps the overflow
-// onto a second row — the help tail (' quit') visibly disappears off-screen. The center block
-// in particular is fragile: its visible width must equal centerBlockWidth exactly, regardless
-// of recording state, or the bar tips over the contentWidth budget.
-func TestBarSingleLineAtNormalWidths(t *testing.T) {
- for _, w := range []int{60, 80, 100, 120} {
- m := New(Config{Command: "x", Interval: time.Second})
- out, _ := m.Update(tea.WindowSizeMsg{Width: w, Height: 24})
- m = out.(Model)
- bar := m.state.RenderBar(m)
- if got := strings.Count(bar, "\n"); got != 0 {
- t.Errorf("width=%d: bar has %d newlines (want 0); bar wrapped onto a second row", w, got)
- }
- }
-}
-
-// renderCenterBlock's visible width must equal centerBlockWidth — the layout math in
-// renderBarLayout depends on this. Test both idle and recording states so a future style
-// tweak (padding added to recStyle or indicatorStyle) trips the test instead of silently
-// stealing cells from the help zone.
-func TestCenterBlockWidthMatchesConstant(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- out, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
- m = out.(Model)
- if got := lipgloss.Width(m.renderCenterBlock(m.renderIndicator())); got != centerBlockWidth {
- t.Errorf("idle: center block visible width = %d, want %d", got, centerBlockWidth)
- }
-}
-
-// TestBarShown documents the single source of truth for whether the bottom bar
-// is taking a row of the screen right now. Either the user-configured
-// preference (m.prefs.StatusBar) is on, or the active state needs unconditional bar
-// feedback (input prompt, picker timeline). searchState is intentionally NOT
-// included — under -t we keep its query and counter hidden, consistent with
-// the user's "I asked for it off" intent.
-func TestBarShown(t *testing.T) {
- tests := []struct {
- name string
- statusBar bool
- state state
- want bool
- }{
- {"view + statusBar on", true, viewState{}, true},
- {"view + statusBar off", false, viewState{}, false},
- {"picker + statusBar off", false, pickerState{}, true},
- {"picker + statusBar on", true, pickerState{}, true},
- {"input on view + statusBar off", false, inputState{prev: viewState{}}, true},
- {"input on view + statusBar on", true, inputState{prev: viewState{}}, true},
- {"input on picker + statusBar off", false, inputState{prev: pickerState{}}, true},
- {"search + statusBar off", false, searchState{prev: viewState{}}, false},
- {"search + statusBar on", true, searchState{prev: viewState{}}, true},
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m.prefs.StatusBar = tc.statusBar
- m.state = tc.state
- if got := m.barShown(); got != tc.want {
- t.Errorf("barShown() = %v, want %v", got, tc.want)
- }
- })
- }
-}
-
-// TestSearchInputBarVisibleWithStatusBarOff: pressing '/' with the status bar
-// disabled must reveal the input prompt in place of the bottom viewport row.
-// Without the barShown wiring, the bar render gate is m.prefs.StatusBar=false and
-// the prompt is hidden — leaving typed characters with no visual feedback.
-func TestSearchInputBarVisibleWithStatusBarOff(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m.prefs.StatusBar = false
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", podNames(20))}})
-
- // Sanity: with statusBar off and viewState, no bar at the bottom.
- pre := m.View().Content
- if got := lipgloss.Height(pre); got != 10 {
- t.Fatalf("pre-press rendered height = %d, want 10", got)
- }
- if got := trailingBarRow(pre); got == m.state.RenderBar(m) {
- t.Fatalf("pre-press: last row already matches m.state.RenderBar(m); test setup is wrong")
- }
-
- m = pressKey(t, m, '/')
-
- if _, ok := m.state.(inputState); !ok {
- t.Fatalf("after '/': m.state = %T, want inputState", m.state)
- }
- post := m.View().Content
- if got := lipgloss.Height(post); got != 10 {
- t.Fatalf("post-press rendered height = %d, want 10 (bar must steal a viewport row)", got)
- }
- if got := trailingBarRow(post); got != m.state.RenderBar(m) {
- t.Fatalf("post-press: trailing row does not match m.state.RenderBar(m)\n got: %q\nwant: %q", got, m.state.RenderBar(m))
- }
-}
-
-// TestPickerBarVisibleWithStatusBarOff: pressing 'b' (enter picker) with the
-// status bar disabled must reveal the picker timeline. On Esc the bar must
-// disappear and the viewport must reclaim its row.
-func TestPickerBarVisibleWithStatusBarOff(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m.prefs.StatusBar = false
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", podNames(20))}})
-
- pre := m.View().Content
- if got := trailingBarRow(pre); got == m.state.RenderBar(m) {
- t.Fatalf("pre-press: last row already matches m.state.RenderBar(m); test setup is wrong")
- }
-
- m = pressKey(t, m, 'b')
- if _, ok := m.state.(pickerState); !ok {
- t.Fatalf("after 'b': m.state = %T, want pickerState", m.state)
- }
- post := m.View().Content
- if got := lipgloss.Height(post); got != 10 {
- t.Fatalf("after 'b': rendered height = %d, want 10", got)
- }
- if got := trailingBarRow(post); got != m.state.RenderBar(m) {
- t.Fatalf("after 'b': trailing row does not match m.state.RenderBar(m)\n got: %q\nwant: %q", got, m.state.RenderBar(m))
- }
-
- m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
- if _, ok := m.state.(viewState); !ok {
- t.Fatalf("after Esc: m.state = %T, want viewState", m.state)
- }
- back := m.View().Content
- if got := trailingBarRow(back); got == m.state.RenderBar(m) {
- t.Fatalf("after Esc: bar still present in trailing row\n got: %q", got)
- }
-}
-
-// trailingBarRow returns the last visible row of the rendered View content.
-// We compare it directly against m.state.RenderBar(m) to assert that the bar is in fact
-// laid out at the bottom of the screen, not just somewhere in the buffer.
-func trailingBarRow(content string) string {
- lines := strings.Split(content, "\n")
- return lines[len(lines)-1]
-}
-
-// In replay mode, the activity indicator is the ▶ play triangle.
-func TestRenderIndicatorReplay(t *testing.T) {
- m := NewReplay(Config{}, preloadedReplaySession(1))
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- got := m.renderIndicator()
- if !strings.Contains(got, "▶") {
- t.Errorf("replay indicator should contain ▶; got %q", got)
- }
-}
-
-// In live mode, the indicator is one of the live glyphs (no ▶).
-func TestRenderIndicatorLiveNotReplay(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- got := m.renderIndicator()
- if strings.Contains(got, "▶") {
- t.Errorf("live indicator must not contain ▶; got %q", got)
- }
-}
diff --git a/internal/tui/cursor.go b/internal/tui/cursor.go
deleted file mode 100644
index 1404c1a..0000000
--- a/internal/tui/cursor.go
+++ /dev/null
@@ -1,68 +0,0 @@
-package tui
-
-// Cursor is the position into Session.History that the viewport renders from. The -1
-// idx means "no frame yet" -- live mode before the first execution, or any time the
-// session has zero entries. All transitions go through methods so the evict-shift,
-// follow-tail, clamp, and sentinel rules live next to each other instead of scattered
-// across the bar, the state files, and dispatchExec.
-//
-// Constructors must be used: the zero value Cursor{idx: 0} would read as "at frame 0",
-// which is invalid before any execution has landed. Use noCursor or cursorAtTail.
-type Cursor struct{ idx int }
-
-// noCursor is the initial cursor for a live model: no frame to display yet.
-func noCursor() Cursor { return Cursor{idx: -1} }
-
-// cursorAtTail returns a cursor positioned at the last frame of a history of length n.
-// Returns noCursor when n == 0.
-func cursorAtTail(historyLen int) Cursor {
- if historyLen == 0 {
- return noCursor()
- }
- return Cursor{idx: historyLen - 1}
-}
-
-// cursorAt returns a cursor at the given index without clamping. Used by tests and by
-// preloaded-session initialization where the caller already knows the index is valid
-// for the corresponding history.
-func cursorAt(idx int) Cursor { return Cursor{idx: idx} }
-
-// At returns the current index and a "valid" flag. ok == false when the cursor sits at
-// the no-frame-yet sentinel.
-func (c Cursor) At() (int, bool) { return c.idx, c.Valid() }
-
-// Index returns the raw cursor index. Useful for callers that have a downstream guard
-// (e.g. FrameViewModel.Frame handles i < 0 by returning ""), or that arithmetically
-// transform the index before passing it back through Move.
-func (c Cursor) Index() int { return c.idx }
-
-// Valid reports whether the cursor points at a real frame.
-func (c Cursor) Valid() bool { return c.idx >= 0 }
-
-// Following reports whether the cursor sits at the tail of a history of length n. An
-// empty history (n == 0) is considered "following" so the first execution becomes the
-// tail under view/picker's FollowsTail policy.
-func (c Cursor) Following(historyLen int) bool {
- return historyLen == 0 || c.idx == historyLen-1
-}
-
-// AfterEvict shifts the cursor down by 1, clamping at 0, after a MaxHistory eviction
-// removed slot 0. Keeps a non-tail viewer reading the same frame; clamps to 0 when the
-// frame they were on was the one evicted.
-func (c Cursor) AfterEvict() Cursor {
- return Cursor{idx: max(0, c.idx-1)}
-}
-
-// ToTail returns a cursor at the last frame of a history of length n. Equivalent to
-// cursorAtTail; provided as a method so transition sites (dispatchExec's follow-tail
-// branch) read as "advance to tail".
-func (c Cursor) ToTail(historyLen int) Cursor { return cursorAtTail(historyLen) }
-
-// Move returns a cursor clamped to [0, historyLen-1]. Used by picker navigation.
-// Returns noCursor when historyLen == 0.
-func (c Cursor) Move(to, historyLen int) Cursor {
- if historyLen == 0 {
- return noCursor()
- }
- return Cursor{idx: max(0, min(historyLen-1, to))}
-}
diff --git a/internal/tui/cursor_test.go b/internal/tui/cursor_test.go
deleted file mode 100644
index d5433b6..0000000
--- a/internal/tui/cursor_test.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package tui
-
-import "testing"
-
-func TestCursorConstructors(t *testing.T) {
- if i, ok := noCursor().At(); ok || i != -1 {
- t.Errorf("noCursor().At() = (%d, %v), want (-1, false)", i, ok)
- }
- if i, ok := cursorAtTail(0).At(); ok || i != -1 {
- t.Errorf("cursorAtTail(0).At() = (%d, %v), want (-1, false)", i, ok)
- }
- if i, ok := cursorAtTail(5).At(); !ok || i != 4 {
- t.Errorf("cursorAtTail(5).At() = (%d, %v), want (4, true)", i, ok)
- }
- if i, ok := cursorAt(2).At(); !ok || i != 2 {
- t.Errorf("cursorAt(2).At() = (%d, %v), want (2, true)", i, ok)
- }
-}
-
-func TestCursorFollowing(t *testing.T) {
- cases := []struct {
- name string
- c Cursor
- n int
- want bool
- }{
- {"empty history with noCursor", noCursor(), 0, true},
- {"empty history with stale cursor", cursorAt(3), 0, true},
- {"at tail of n=5", cursorAt(4), 5, true},
- {"middle of n=5", cursorAt(2), 5, false},
- {"head of n=5", cursorAt(0), 5, false},
- {"noCursor with non-empty", noCursor(), 5, false},
- }
- for _, c := range cases {
- if got := c.c.Following(c.n); got != c.want {
- t.Errorf("%s: Following(%d) = %v, want %v", c.name, c.n, got, c.want)
- }
- }
-}
-
-func TestCursorAfterEvict(t *testing.T) {
- cases := []struct {
- in, want int
- }{
- {-1, 0}, // noCursor clamps to 0 after eviction (rare; usually only fires when valid)
- {0, 0}, // the frame they were on was evicted
- {1, 0},
- {4, 3},
- }
- for _, c := range cases {
- got := cursorAt(c.in).AfterEvict()
- if got.Index() != c.want {
- t.Errorf("cursorAt(%d).AfterEvict().Index() = %d, want %d", c.in, got.Index(), c.want)
- }
- }
-}
-
-func TestCursorToTail(t *testing.T) {
- if c := noCursor().ToTail(0); c.Valid() {
- t.Errorf("ToTail(0) should be invalid (no frames), got %+v", c)
- }
- if c := cursorAt(0).ToTail(5); c.Index() != 4 {
- t.Errorf("ToTail(5).Index() = %d, want 4", c.Index())
- }
-}
-
-func TestCursorMove(t *testing.T) {
- cases := []struct {
- name string
- from Cursor
- to, n int
- wantIdx int
- wantValid bool
- }{
- {"clamp below", cursorAt(2), -3, 5, 0, true},
- {"clamp above", cursorAt(2), 99, 5, 4, true},
- {"middle", cursorAt(2), 3, 5, 3, true},
- {"empty history", cursorAt(2), 1, 0, -1, false},
- {"from noCursor to valid", noCursor(), 2, 5, 2, true},
- }
- for _, c := range cases {
- got := c.from.Move(c.to, c.n)
- if got.Index() != c.wantIdx || got.Valid() != c.wantValid {
- t.Errorf("%s: Move(%d, %d) = {idx=%d valid=%v}, want {idx=%d valid=%v}",
- c.name, c.to, c.n, got.Index(), got.Valid(), c.wantIdx, c.wantValid)
- }
- }
-}
diff --git a/internal/tui/diffrender/diffrender.go b/internal/tui/diffrender/diffrender.go
deleted file mode 100644
index 73d5772..0000000
--- a/internal/tui/diffrender/diffrender.go
+++ /dev/null
@@ -1,79 +0,0 @@
-// Package diffrender renders a diff (from internal/diff) into styled terminal output: it
-// overlays a highlight foreground on the changed cells while preserving the command's own
-// ANSI background and attributes, including state carried across line boundaries. It is the
-// optional terminal renderer companion to the presentation-free diff package - kept separate
-// so diff stays dependency-free.
-package diffrender
-
-import (
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/cellbuf"
-
- "github.com/ivoronin/wch/internal/diff"
- "github.com/ivoronin/wch/internal/tui/overlay"
-)
-
-// Render turns lines (the diff of the ANSI-stripped new output, one Line per new line in
-// order) into a styled string by overlaying onto styledOutput (the raw, styled new output):
-// changed cells get fg as their foreground, the command's background/attributes are
-// preserved, and SGR state carried across lines is honoured. styledOutput's rows align with
-// lines by index.
-func Render(lines []diff.Line, styledOutput string, fg ansi.Color) string {
- if len(lines) == 0 {
- return ""
- }
- return overlay.Walk(styledOutput, overlay.MaxDisplayWidth(styledOutput), len(lines), func(buf *cellbuf.Buffer) {
- for y, ln := range lines {
- highlightRow(buf, y, ln, fg)
- }
- })
-}
-
-// highlightRow sets fg on the cells of row y that correspond to changed visible runes of ln.
-// Cells are walked left-to-right, each consuming its grapheme's runes (base + combining), to
-// stay aligned with the diff's rune-indexed spans.
-func highlightRow(buf *cellbuf.Buffer, y int, ln diff.Line, fg ansi.Color) {
- if ln.Kind == diff.LineEqual {
- return // unchanged: keep the command's styling, no highlight
- }
-
- runes := []rune(ln.Text)
- changed := make([]bool, len(runes))
- switch ln.Kind {
- case diff.LineAdded:
- for i := range changed {
- changed[i] = true
- }
- case diff.LineChanged:
- off := 0
- for _, s := range ln.Spans {
- n := len([]rune(s.Text))
- if s.Changed {
- for i := off; i < off+n && i < len(changed); i++ {
- changed[i] = true
- }
- }
- off += n
- }
- }
-
- ri := 0
- for x := 0; x < buf.Width() && ri < len(changed); x++ {
- c := buf.Cell(x, y)
- if c == nil || c.Width == 0 {
- continue // padding or the continuation column of a wide rune: not a visible rune
- }
- cnt := 1 + len(c.Comb)
- hot := false
- for i := ri; i < ri+cnt && i < len(changed); i++ {
- if changed[i] {
- hot = true
- break
- }
- }
- if hot {
- c.Style.Fg = fg
- }
- ri += cnt
- }
-}
diff --git a/internal/tui/diffrender/diffrender_test.go b/internal/tui/diffrender/diffrender_test.go
deleted file mode 100644
index 14c0994..0000000
--- a/internal/tui/diffrender/diffrender_test.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package diffrender
-
-import (
- "strings"
- "testing"
-
- "github.com/charmbracelet/x/ansi"
-
- "github.com/ivoronin/wch/internal/diff"
-)
-
-var testFg = ansi.RGBColor{R: 0x2E, G: 0x7D, B: 0x32}
-
-const greenFg = "38;2;46;125;50" // truecolor SGR params for testFg (foreground)
-
-// render runs the real path: diff on stripped text, overlay onto the styled new output.
-func render(old, neu string) string {
- a := diff.Align(ansi.Strip(old), ansi.Strip(neu))
- return Render(a.Lines(), neu, testFg)
-}
-
-func TestRenderPlainChange(t *testing.T) {
- body := render("a b", "a c")
- if got := ansi.Strip(body); got != "a c" {
- t.Fatalf("visible=%q want %q\nraw=%q", got, "a c", body)
- }
- if !strings.Contains(body, greenFg) {
- t.Errorf("expected highlight, raw=%q", body)
- }
-}
-
-func TestRenderPreservesUnchangedFg(t *testing.T) {
- // whole line yellow; only "6" changes -> unchanged "load " keeps yellow fg, "6" turns green.
- body := render("\x1b[33mload 5\x1b[0m", "\x1b[33mload 6\x1b[0m")
- if got := ansi.Strip(body); got != "load 6" {
- t.Fatalf("visible=%q want %q\nraw=%q", got, "load 6", body)
- }
- if !strings.Contains(body, "33") {
- t.Errorf("yellow fg lost on unchanged text, raw=%q", body)
- }
- if !strings.Contains(body, greenFg) {
- t.Errorf("no highlight, raw=%q", body)
- }
-}
-
-func TestRenderPreservesBackground(t *testing.T) {
- // red background across the line; middle token changes -> bg untouched, "X" gets green fg.
- body := render("\x1b[41mA B C\x1b[0m", "\x1b[41mA X C\x1b[0m")
- if got := ansi.Strip(body); got != "A X C" {
- t.Fatalf("visible=%q want %q\nraw=%q", got, "A X C", body)
- }
- if !strings.Contains(body, "41") {
- t.Errorf("red background not preserved, raw=%q", body)
- }
- if !strings.Contains(body, greenFg) {
- t.Errorf("no highlight, raw=%q", body)
- }
-}
-
-func TestRenderCarriesColorAcrossLines(t *testing.T) {
- // red opened on line 1, carried to line 2 (no reset until the end); only line 2 changes.
- body := render("\x1b[31mfoo\nbar 5\x1b[0m", "\x1b[31mfoo\nbar 6\x1b[0m")
- if got := ansi.Strip(body); got != "foo\nbar 6" {
- t.Fatalf("visible=%q want %q\nraw=%q", got, "foo\nbar 6", body)
- }
- rows := strings.Split(body, "\n")
- if len(rows) != 2 || !strings.Contains(rows[1], "31") {
- t.Errorf("red not carried onto line 2, raw=%q", body)
- }
- if !strings.Contains(body, greenFg) {
- t.Errorf("no highlight, raw=%q", body)
- }
-}
-
-func TestRenderAddedLineFullyHighlighted(t *testing.T) {
- body := render("a", "a\nNEW")
- if got := ansi.Strip(body); got != "a\nNEW" {
- t.Fatalf("visible=%q want %q\nraw=%q", got, "a\nNEW", body)
- }
- rows := strings.Split(body, "\n")
- if len(rows) != 2 || !strings.Contains(rows[1], greenFg) {
- t.Errorf("added line not highlighted, raw=%q", body)
- }
-}
-
-func TestRenderColorOnlyChangeIsNotADiff(t *testing.T) {
- // same visible text, different color -> not a change; shown with the new color, no highlight.
- body := render("\x1b[31mERR\x1b[0m", "\x1b[32mERR\x1b[0m")
- if got := ansi.Strip(body); got != "ERR" {
- t.Fatalf("visible=%q want %q\nraw=%q", got, "ERR", body)
- }
- if strings.Contains(body, greenFg) {
- t.Errorf("color-only change must not be highlighted, raw=%q", body)
- }
- if !strings.Contains(body, "32") {
- t.Errorf("new color not shown, raw=%q", body)
- }
-}
diff --git a/internal/tui/framemodel.go b/internal/tui/framemodel.go
deleted file mode 100644
index e5f84b8..0000000
--- a/internal/tui/framemodel.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package tui
-
-import (
- "fmt"
-
- "github.com/charmbracelet/x/ansi"
-
- "github.com/ivoronin/wch/internal/diff"
- "github.com/ivoronin/wch/internal/session"
- "github.com/ivoronin/wch/internal/tui/diffrender"
- "github.com/ivoronin/wch/internal/tui/scrollview"
-)
-
-// FrameViewModel owns the rendered viewport: it turns a history Execution into a styled
-// body (Frame) and commits a body to the viewport with optional anchor preservation
-// (ShowAnchored). The embedded scrollview provides navigation, geometry queries, and
-// scrollbar predicates directly on the type, so callers reach for the viewport through
-// one seam. The state interface returns "what to display" (state.Body); FrameViewModel
-// decides "how to display it".
-type FrameViewModel struct {
- scrollview.Scrollview
- session *session.Session
-}
-
-// newFrameViewModel constructs the type with a zero-sized viewport; geometry comes from
-// the first WindowSizeMsg via SetSize (promoted from the embedded scrollview).
-func newFrameViewModel(s *session.Session) FrameViewModel {
- return FrameViewModel{
- Scrollview: scrollview.NewScrollview(0, 0),
- session: s,
- }
-}
-
-// Frame renders the styled body for history index i: command output, optional diff
-// highlights against the previous recorded frame (when diffEnabled), and an exit-code
-// annotation for non-zero exits. Out-of-range i returns "" so callers can treat it as
-// "nothing to display" without a separate predicate.
-func (f *FrameViewModel) Frame(i int, diffEnabled bool) string {
- if i < 0 || i >= len(f.session.History) {
- return ""
- }
- exec := f.session.History[i]
- output := exec.Output()
- body := output
- if diffEnabled && i > 0 {
- align := diff.Align(ansi.Strip(f.session.History[i-1].Output()), ansi.Strip(output))
- body = diffrender.Render(align.Lines(), output, insertFg)
- }
- if exec.Error != nil && exec.ExitCode != 0 {
- annot := errorStyle.Render(fmt.Sprintf("Exit code: %d", exec.ExitCode))
- if body == "" {
- body = annot
- } else {
- body += "\n" + annot
- }
- }
- return body
-}
-
-// ShowAnchored commits newBody to the viewport while preserving the user's
-// eye-on-line invariant relative to prevBody: at the top/bottom edges the
-// sticky-edge rule wins (YOffset=0 / GotoBottom); in the middle, diff.Align +
-// MapLine translates the old YOffset to its new line after inserts/deletes
-// shift content above. Used on every site where the displayed frame changes
-// while the cursor (historyIndex) advances or moves: per-exec dispatch and
-// per-cursor-move.
-//
-// Trade-off note: when called as part of a per-exec repaint, this re-derives
-// the diff alignment that Frame's diff highlight already computed once for the
-// same pair of frames. The dedup gate in Model.dispatchExec keeps duplicate
-// ticks from reaching here, so the duplicate only runs on real frame changes
-// -- the same frequency the body would have re-aligned at anyway. Resist
-// threading a precomputed alignment back through: it poisons the signature
-// with a parameter only one caller can supply.
-func (f *FrameViewModel) ShowAnchored(newBody, prevBody string) {
- atTop := f.YOffset() == 0
- atBottom := f.AtBottom()
-
- var newOffset int
- if !atTop && !atBottom {
- anchor := diff.Align(ansi.Strip(prevBody), ansi.Strip(newBody))
- newOffset = anchor.MapLine(f.YOffset())
- }
-
- f.SetContent(newBody)
-
- switch {
- case atTop:
- f.SetYOffset(0)
- case atBottom:
- f.GotoBottom()
- default:
- f.SetYOffset(newOffset)
- }
-}
diff --git a/internal/tui/framemodel_test.go b/internal/tui/framemodel_test.go
deleted file mode 100644
index d0b21f7..0000000
--- a/internal/tui/framemodel_test.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package tui
-
-import (
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// A new row inserted at the top while every row's age ticks must keep the mid-screen row
-// pinned under the top edge (the core fix).
-func TestAnchorKeepsRowOnPrepend(t *testing.T) {
- old := podNames(20)
- updated := append([]string{"pod-00"}, old...)
-
- m := newSizedModel(t, podTable("5m", old))
- m.frames.SetYOffset(5)
- if m.frames.YOffset() != 5 {
- t.Fatalf("setup offset=%d want 5", m.frames.YOffset())
- }
-
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("6m", updated)}})
- if got := m.frames.YOffset(); got != 6 {
- t.Errorf("anchored offset=%d want 6 (pod-05 shifted down by inserted pod-00)", got)
- }
-}
-
-// At the very top, newly prepended rows stay visible (sticky top wins over the anchor).
-func TestStickyTop(t *testing.T) {
- old := podNames(20)
- updated := append([]string{"pod-00"}, old...)
-
- m := newSizedModel(t, podTable("5m", old))
- if m.frames.YOffset() != 0 {
- t.Fatalf("setup offset=%d want 0", m.frames.YOffset())
- }
-
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("6m", updated)}})
- if got := m.frames.YOffset(); got != 0 {
- t.Errorf("sticky top offset=%d want 0", got)
- }
-}
-
-// While viewing a past frame, its highlights are intrinsic (diff vs the previous recorded
-// frame) and must survive a command run.
-func TestHistoryHighlightsSurviveRefresh(t *testing.T) {
- names := podNames(3)
- m := New(Config{Command: "x", Interval: time.Second, DiffEnabled: true})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", names)}})
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("6m", names)}})
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("7m", names)}})
-
- m = m.withCursor(1)
- if m.isFollowing() {
- t.Fatal("setup: expected to be viewing a past frame, not following")
- }
-
- plain := m
- plain.prefs.Diff = false
- before := m.frames.Frame(1, m.prefs.Diff)
- if before == plain.frames.Frame(1, plain.prefs.Diff) {
- t.Fatalf("frame 1 should be highlighted (diff vs frame 0); body=%q", before)
- }
-
- idx, off := m.cursor.Index(), m.frames.YOffset()
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("8m", names)}})
-
- if m.cursor.Index() != idx {
- t.Errorf("historyIndex moved to %d, want %d (frozen while viewing past)", m.cursor.Index(), idx)
- }
- if got := m.frames.YOffset(); got != off {
- t.Errorf("YOffset moved to %d, want %d", got, off)
- }
- if after := m.frames.Frame(1, m.prefs.Diff); after != before {
- t.Errorf("highlights reset after refresh:\n before=%q\n after =%q", before, after)
- }
-}
-
-// At the very bottom, the view follows the tail on refresh.
-func TestStickyBottom(t *testing.T) {
- old := podNames(20)
- updated := append(podNames(20), "pod-21")
-
- m := newSizedModel(t, podTable("5m", old))
- m.frames.GotoBottom()
- if !m.frames.AtBottom() {
- t.Fatalf("setup: expected to start at bottom")
- }
-
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("6m", updated)}})
- if !m.frames.AtBottom() {
- t.Errorf("sticky bottom: expected to follow the tail, offset=%d", m.frames.YOffset())
- }
-}
diff --git a/internal/tui/help.go b/internal/tui/help.go
deleted file mode 100644
index 7a076e3..0000000
--- a/internal/tui/help.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package tui
-
-import (
- "fmt"
- "strings"
-
- "charm.land/lipgloss/v2"
-)
-
-// helpBinding is a single entry in the help overlay's keybinding table. keys is the
-// human-readable key (or composite like "Enter b" / "Shift+←→"); desc is the description.
-type helpBinding struct {
- keys string
- desc string
-}
-
-// helpSection groups bindings under a heading.
-type helpSection struct {
- title string
- bindings []helpBinding
-}
-
-// helpSections returns the static keybinding reference shown in the help overlay. Not
-// auto-derived from keys.go: composite key strings ("Enter b", "Home End", "↑↓") don't fit
-// key.Binding cleanly, and the overlay's descriptions can be richer than the terse bar
-// hints. Adding a binding here is the cost of having the overlay read as documentation.
-func helpSections() []helpSection {
- return []helpSection{
- {"Global", []helpBinding{
- {"q", "quit"},
- {"t", "toggle status bar"},
- {"h", "this help"},
- {"↑↓", "scroll up/down"},
- {"←→", "scroll left/right"},
- {"PgUp PgDn", "page up/down"},
- {"Home End", "top/bottom"},
- {"Shift+←→", "page horizontal"},
- }},
- {"View", []helpBinding{
- {"d", "toggle diff"},
- {"p", "pause"},
- {"r", "record"},
- {"/", "search"},
- {"b", "history"},
- {"Esc", "jump to live tail"},
- }},
- {"Picker", []helpBinding{
- {"←→", "frame ±1"},
- {"Home End", "first/last"},
- {"Enter b", "confirm"},
- {"Esc", "back to view"},
- }},
- {"Search", []helpBinding{
- {"n", "next match"},
- {"N", "prev match"},
- {"/", "new search"},
- {"Esc", "back"},
- }},
- {"Input", []helpBinding{
- {"Enter", "submit"},
- {"Esc", "cancel"},
- }},
- }
-}
-
-// renderHelpPanel composes the centered help overlay: a two-column layout (Global+View on
-// the left, Picker+Search+Input on the right) wrapped in a rounded border with no fill. A
-// dismiss tip ("h to close") is embedded in the bottom border, centered, so it doesn't eat
-// vertical space inside the panel.
-func renderHelpPanel() string {
- sections := helpSections()
- body := lipgloss.JoinHorizontal(lipgloss.Top,
- renderHelpColumn(sections[:2]),
- helpColumnGap,
- renderHelpColumn(sections[2:]),
- )
- return embedBottomBorderTip(helpPanelStyle.Render(body), "h to close")
-}
-
-// embedBottomBorderTip rewrites the panel's last (rounded-border) line, replacing its
-// horizontal-dash run with `` centred between the corners. Falls back to the
-// unmodified panel if the tip can't fit with at least one dash on each side.
-func embedBottomBorderTip(panel, tip string) string {
- lines := strings.Split(panel, "\n")
- if len(lines) == 0 {
- return panel
- }
- bottom := lines[len(lines)-1]
- bottomW := lipgloss.Width(bottom)
- // Need room for: 2 corners + 1-cell pad each side of the tip.
- if bottomW < lipgloss.Width(tip)+4 {
- return panel
- }
- paddedTip := lipgloss.NewStyle().Padding(0, 1).Render(tip)
- inner := lipgloss.PlaceHorizontal(bottomW-2, lipgloss.Center, paddedTip,
- lipgloss.WithWhitespaceChars("─"))
- lines[len(lines)-1] = "╰" + inner + "╯"
- return strings.Join(lines, "\n")
-}
-
-// renderHelpColumn renders a vertical stack of sections as " " rows,
-// keys padded to the widest key in the column so descriptions align.
-func renderHelpColumn(sections []helpSection) string {
- keyWidth := 0
- for _, s := range sections {
- for _, b := range s.bindings {
- keyWidth = max(keyWidth, lipgloss.Width(b.keys))
- }
- }
- keyCell := lipgloss.NewStyle().Bold(true).Width(keyWidth)
-
- var lines []string
- for i, s := range sections {
- if i > 0 {
- lines = append(lines, "")
- }
- lines = append(lines, s.title)
- for _, b := range s.bindings {
- lines = append(lines, fmt.Sprintf(" %s %s", keyCell.Render(b.keys), b.desc))
- }
- }
- return strings.Join(lines, "\n")
-}
diff --git a/internal/tui/help_test.go b/internal/tui/help_test.go
deleted file mode 100644
index dfff21d..0000000
--- a/internal/tui/help_test.go
+++ /dev/null
@@ -1,166 +0,0 @@
-package tui
-
-import (
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// helpSections must have no empty fields, no duplicate keys within a section, and a
-// non-empty section list. Cheap structural invariants that catch typos at PR time.
-func TestHelpSectionsValid(t *testing.T) {
- sections := helpSections()
- if len(sections) == 0 {
- t.Fatalf("helpSections() returned no sections")
- }
- for _, s := range sections {
- if s.title == "" {
- t.Errorf("section with empty title: %+v", s)
- }
- if len(s.bindings) == 0 {
- t.Errorf("section %q has no bindings", s.title)
- }
- seen := map[string]bool{}
- for _, b := range s.bindings {
- if b.keys == "" {
- t.Errorf("section %q: empty keys in %+v", s.title, b)
- }
- if b.desc == "" {
- t.Errorf("section %q: empty desc for keys %q", s.title, b.keys)
- }
- if seen[b.keys] {
- t.Errorf("section %q: duplicate keys %q", s.title, b.keys)
- }
- seen[b.keys] = true
- }
- }
-}
-
-// renderHelpPanel must fit a typical 80×24 terminal. Tightest reasonable bound: width ≤ 72
-// (leaves 4-cell margin per side), height ≤ 24 (fits exactly on a 24-row terminal).
-func TestRenderHelpPanelFitsTypicalTerminal(t *testing.T) {
- panel := renderHelpPanel()
- w := lipgloss.Width(panel)
- h := lipgloss.Height(panel)
- if w > 72 {
- t.Errorf("panel width = %d, want <= 72 (leaves margin in 80-col terminal)", w)
- }
- if h > 24 {
- t.Errorf("panel height = %d, want <= 24 (fits 24-row terminal)", h)
- }
-}
-
-func TestRenderHelpPanelContainsAllSections(t *testing.T) {
- plain := ansi.Strip(renderHelpPanel())
- for _, s := range helpSections() {
- if !strings.Contains(plain, s.title) {
- t.Errorf("rendered panel missing section %q", s.title)
- }
- }
-}
-
-// Keys should carry SGR Bold (attribute 1). Lipgloss may compose it with fg/bg into a
-// multi-attribute sequence like "\x1b[1;37;40m", so check for the bold attribute embedded
-// in any of the standard SGR shapes.
-func TestRenderHelpPanelBoldKeys(t *testing.T) {
- out := renderHelpPanel()
- if !strings.Contains(out, "\x1b[1m") && !strings.Contains(out, "\x1b[1;") {
- t.Errorf("expected at least one SGR-Bold span in the panel; got: %q", out)
- }
-}
-
-// Pressing 'h' from viewState toggles the help overlay flag on Model. A second 'h' closes it.
-func TestHelpKeyToggles(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24})
- if m.prefs.HelpVisible {
- t.Fatalf("setup: helpVisible should start false")
- }
-
- m = pressKey(t, m, 'h')
- if !m.prefs.HelpVisible {
- t.Errorf("after first h, helpVisible = false; want true")
- }
-
- m = pressKey(t, m, 'h')
- if m.prefs.HelpVisible {
- t.Errorf("after second h, helpVisible = true; want false (toggle)")
- }
-}
-
-// While helpVisible, other keys still reach the active state's handler — the overlay does
-// not block dispatch. Pressing 'b' transitions to picker; the overlay stays open.
-func TestHelpStickyDuringModeAction(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24})
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC), Stdout: "frame\n",
- }})
-
- m = pressKey(t, m, 'h')
- m = pressKey(t, m, 'b') // open picker
-
- if _, ok := m.state.(pickerState); !ok {
- t.Errorf("after b with help open, state = %T, want pickerState", m.state)
- }
- if !m.prefs.HelpVisible {
- t.Errorf("help overlay should stay open while user transitions to picker")
- }
-}
-
-// Pressing 'q' while help is open quits the app — global key dispatch is unaffected by
-// the overlay.
-func TestHelpQuitPassthrough(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24})
- m = pressKey(t, m, 'h')
-
- _, cmd := m.dispatchKey(tea.KeyPressMsg{Code: 'q', Text: "q"})
- if cmd == nil {
- t.Fatalf("expected tea.Quit cmd from q with help visible")
- }
- if msg := cmd(); msg != (tea.QuitMsg{}) {
- t.Errorf("q cmd produced %T, want tea.QuitMsg{}", msg)
- }
-}
-
-// In inputState, every key (including 'h') is consumed by the textinput. Help does NOT
-// open, and the 'h' character lands in the input's value.
-func TestHelpKeyInInputModeTypesLiteral(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24})
- m = pressKey(t, m, 'r') // open record input
- if _, ok := m.state.(inputState); !ok {
- t.Fatalf("setup: expected inputState after r, got %T", m.state)
- }
- before := m.state.(inputState).input.Value()
-
- m = pressKey(t, m, 'h')
-
- if m.prefs.HelpVisible {
- t.Errorf("helpVisible should stay false when h is pressed inside inputState")
- }
- if got := m.state.(inputState).input.Value(); !strings.HasSuffix(got, "h") || got == before {
- t.Errorf("h keystroke should have appended to input value; before=%q after=%q", before, got)
- }
-}
-
-func TestHelpOverlayRendersInView(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m.ready = true
- m = feed(t, m, tea.WindowSizeMsg{Width: 100, Height: 30})
- m = pressKey(t, m, 'h')
-
- plain := ansi.Strip(m.View().Content)
- for _, section := range []string{"Global", "View", "Picker", "Search", "Input"} {
- if !strings.Contains(plain, section) {
- t.Errorf("rendered View should contain section %q with help visible; got:\n%s", section, plain)
- }
- }
-}
diff --git a/internal/tui/helprender/helprender.go b/internal/tui/helprender/helprender.go
deleted file mode 100644
index 787a065..0000000
--- a/internal/tui/helprender/helprender.go
+++ /dev/null
@@ -1,25 +0,0 @@
-// Package helprender composes a centered overlay panel onto pre-rendered terminal content.
-// It owns the centering math; the cellbuf composition is delegated to internal/tui/overlay's
-// Sprite primitive. The panel itself is built elsewhere (in tui.renderHelpPanel) — this
-// package only knows how to paint a string at the center of another string of given
-// dimensions, with edge clipping when the panel is larger than the canvas.
-package helprender
-
-import (
- "charm.land/lipgloss/v2"
-
- "github.com/ivoronin/wch/internal/tui/overlay"
-)
-
-// Overlay returns content with panel painted on top, centered within (width, height). The
-// panel's existing styling (borders, bold spans, etc.) is preserved via cellbuf's nested-SGR
-// handling. When the panel exceeds either canvas dimension, its right/bottom is clipped —
-// no scrolling, no relayout. Empty panel or non-positive dimensions return content as-is.
-func Overlay(content, panel string, width, height int) string {
- if panel == "" || width <= 0 || height <= 0 {
- return content
- }
- x := max(0, (width-lipgloss.Width(panel))/2)
- y := max(0, (height-lipgloss.Height(panel))/2)
- return overlay.Sprite(content, width, height, panel, x, y)
-}
diff --git a/internal/tui/helprender/helprender_test.go b/internal/tui/helprender/helprender_test.go
deleted file mode 100644
index 8fbca4a..0000000
--- a/internal/tui/helprender/helprender_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package helprender
-
-import (
- "strings"
- "testing"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-// Empty panel or zero dimensions short-circuit and return content unchanged.
-func TestOverlayShortCircuits(t *testing.T) {
- content := "alpha\nbravo\ncharlie\n"
- if got := Overlay(content, "", 80, 24); got != content {
- t.Errorf("empty panel: got != content")
- }
- if got := Overlay(content, "panel", 0, 24); got != content {
- t.Errorf("zero width: got != content")
- }
- if got := Overlay(content, "panel", 80, 0); got != content {
- t.Errorf("zero height: got != content")
- }
-}
-
-// A small panel painted onto a larger canvas lands at the center; cells outside the panel
-// footprint stay as the original content.
-func TestOverlayCentersPanel(t *testing.T) {
- rows := make([]string, 10)
- for i := range rows {
- rows[i] = strings.Repeat("x", 20)
- }
- content := strings.Join(rows, "\n")
- panel := "ABC\nDEF" // 3×2
-
- got := ansi.Strip(Overlay(content, panel, 20, 10))
- lines := strings.Split(got, "\n")
- if len(lines) < 10 {
- t.Fatalf("output has %d lines, want at least 10", len(lines))
- }
- // Panel is 3×2, canvas 20×10 → top-left at ((20-3)/2, (10-2)/2) = (8, 4).
- if !strings.HasPrefix(lines[4][8:], "ABC") {
- t.Errorf("row 4 col 8: got %q, want prefix \"ABC\"; full row: %q", lines[4][8:], lines[4])
- }
- if !strings.HasPrefix(lines[5][8:], "DEF") {
- t.Errorf("row 5 col 8: got %q, want prefix \"DEF\"; full row: %q", lines[5][8:], lines[5])
- }
- // Row outside the panel keeps the original x-fill.
- if lines[0] != strings.Repeat("x", 20) {
- t.Errorf("row 0: got %q, want unchanged x-fill", lines[0])
- }
-}
-
-// A panel larger than the canvas gets clipped at the bottom/right; no panic.
-func TestOverlayClipsOversizedPanel(t *testing.T) {
- defer func() {
- if r := recover(); r != nil {
- t.Fatalf("Overlay panicked with oversized panel: %v", r)
- }
- }()
- content := strings.Repeat("x", 20) + "\n" + strings.Repeat("x", 20)
- // 30×5 panel, canvas 20×2 — way too big in both axes.
- panel := strings.Repeat("A", 30) + "\n" + strings.Repeat("B", 30) + "\n" + strings.Repeat("C", 30) + "\n" + strings.Repeat("D", 30) + "\n" + strings.Repeat("E", 30)
- out := Overlay(content, panel, 20, 2)
- plain := ansi.Strip(out)
- if !strings.Contains(plain, "A") {
- t.Errorf("expected at least some panel content (e.g. 'A') in output; got %q", plain)
- }
-}
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
deleted file mode 100644
index 43039b9..0000000
--- a/internal/tui/keys.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package tui
-
-import (
- "slices"
-
- "charm.land/bubbles/v2/key"
-)
-
-var globalKeys = struct {
- Quit key.Binding
- ToggleBar key.Binding
- Help key.Binding
-}{
- Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
- ToggleBar: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "status")),
- Help: key.NewBinding(key.WithKeys("h"), key.WithHelp("h", "help")),
-}
-
-// navKeys are the viewport-navigation defaults. handleGlobalKey scrolls for any of them
-// not intercepted by the active state. pickerState intercepts Left/Right (frame cursor)
-// and Home/End (first/last frame); inputState lets the textinput consume arrows for its
-// own cursor.
-var navKeys = struct {
- Left, Right key.Binding
- Up, Down key.Binding
- PageUp, PageDown key.Binding
- Home, End key.Binding
- ScrollLeft, ScrollRight key.Binding
-}{
- Left: key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "left")),
- Right: key.NewBinding(key.WithKeys("right"), key.WithHelp("→", "right")),
- Up: key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")),
- Down: key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")),
- PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
- PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")),
- Home: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "top")),
- End: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "bottom")),
- ScrollLeft: key.NewBinding(key.WithKeys("shift+left")),
- ScrollRight: key.NewBinding(key.WithKeys("shift+right")),
-}
-
-// commonKeys are intercepted with identical semantics in both viewState and pickerState:
-// toggle diff/pause, start/stop recording, open search. Held once to avoid duplicating
-// the bindings (and the matching switch arms) across both handlers.
-var commonKeys = struct {
- ToggleDiff key.Binding
- Pause key.Binding
- Record key.Binding
- Search key.Binding
- Escape key.Binding
-}{
- ToggleDiff: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "diff")),
- Pause: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "pause")),
- Record: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "record")),
- Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
- Escape: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
-}
-
-// viewKeys are viewState-specific bindings (entering the picker).
-var viewKeys = struct {
- Picker key.Binding
-}{
- Picker: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "history")),
-}
-
-// pickerKeys are pickerState-specific bindings (confirming a selection). Enter and the
-// picker-entry key (b) are symmetric aliases.
-var pickerKeys = struct {
- Confirm key.Binding
-}{
- Confirm: key.NewBinding(key.WithKeys("enter", "b"), key.WithHelp("enter", "confirm")),
-}
-
-// searchKeys are searchState-specific bindings. n/p navigate matches. '/' and Esc reuse
-// commonKeys.Search and commonKeys.Escape — same keys, same help, no point duplicating.
-var searchKeys = struct {
- NavNext key.Binding
- NavPrev key.Binding
-}{
- NavNext: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "next")),
- NavPrev: key.NewBinding(key.WithKeys("N"), key.WithHelp("N", "prev")),
-}
-
-// minimalBarBindings is the canonical bottom-bar trailer: state-specific extras followed
-// by the always-visible {Help, Quit} pair.
-func minimalBarBindings(extras ...key.Binding) []key.Binding {
- return slices.Concat(extras, []key.Binding{globalKeys.Help, globalKeys.Quit})
-}
diff --git a/internal/tui/layout.go b/internal/tui/layout.go
deleted file mode 100644
index b0a0ad7..0000000
--- a/internal/tui/layout.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package tui
-
-import (
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-)
-
-// threeColumnLayout holds computed widths for a three-section horizontal layout.
-type threeColumnLayout struct {
- leftWidth int // width for left section
- rightWidth int // width for right section
-}
-
-// calcThreeColumnLayout computes widths for a three-section layout where
-// the center section is centered within the content area.
-// totalWidth is the full available width, centerWidth is the width of the centered content.
-func calcThreeColumnLayout(totalWidth, centerWidth int) threeColumnLayout {
- contentWidth := totalWidth - statusBarStyle.GetHorizontalFrameSize()
- leftWidth := (contentWidth - centerWidth) / 2
- rightWidth := contentWidth - leftWidth - centerWidth
- return threeColumnLayout{
- leftWidth: leftWidth,
- rightWidth: rightWidth,
- }
-}
-
-// barLeftZoneWidth returns the width of the left zone of the standard three-column
-// status bar (using centerBlockWidth as the center). Callers that want to lay out
-// their left content with internal flex (e.g. searchState's query+counter) can
-// target the exact zone renderBarLayout uses.
-func barLeftZoneWidth(width int) int {
- return calcThreeColumnLayout(width, centerBlockWidth).leftWidth
-}
-
-// renderLeft renders content left-aligned within the given width.
-// Content is truncated with ellipsis if it exceeds the available width.
-func renderLeft(content string, width int) string {
- content = ansi.Truncate(content, width, "…")
- return lipgloss.NewStyle().Width(width).Render(content)
-}
-
-// renderRight renders content right-aligned within the given width.
-// Content is truncated with ellipsis if it exceeds the available width.
-func renderRight(content string, width int) string {
- content = ansi.Truncate(content, width, "…")
- return lipgloss.NewStyle().Width(width).Align(lipgloss.Right).Render(content)
-}
diff --git a/internal/tui/messages.go b/internal/tui/messages.go
deleted file mode 100644
index df31b6e..0000000
--- a/internal/tui/messages.go
+++ /dev/null
@@ -1,15 +0,0 @@
-// Package tui's intra-update messages. Only true asynchronous events live here: tick
-// (timer) and execResult (background runner result). Recording-related messages live with
-// the rest of the recording lifecycle in recording.go. State transitions, key intents, and
-// scroll commands are direct function calls in the dispatcher chain; not messages.
-package tui
-
-import "github.com/ivoronin/wch/internal/session"
-
-type (
- // execResultMsg carries the runner's output back to Update after a tick.
- execResultMsg struct{ exec session.Execution }
-
- // tickMsg fires every Config.Interval to schedule the next runner execution.
- tickMsg struct{}
-)
diff --git a/internal/tui/model.go b/internal/tui/model.go
deleted file mode 100644
index d040264..0000000
--- a/internal/tui/model.go
+++ /dev/null
@@ -1,444 +0,0 @@
-package tui
-
-import (
- "context"
- "time"
-
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2/compat"
-
- "github.com/ivoronin/wch/internal/recording"
- "github.com/ivoronin/wch/internal/runner"
- "github.com/ivoronin/wch/internal/session"
- "github.com/ivoronin/wch/internal/tui/notify"
-)
-
-// Config holds TUI configuration.
-type Config struct {
- Command string
- Interval time.Duration
- DiffEnabled bool
- ShowStatus bool
- NotifyOnChange bool
- AutoStart *recording.AutoStartRequest // non-nil: start a recording to this path at launch
- MaxHistory int // executions retained in memory; 0 = unlimited
-}
-
-// Model is the Bubble Tea model. Domain (session, runner), infrastructure (viewport,
-// notify), cross-state toggles, and the position cursor (historyIndex) live here. UI mode
-// is held in m.state — a sealed sum type defined in state.go.
-type Model struct {
- // Core
- session *session.Session
- runner *runner.Runner
-
- // Infrastructure: frames owns the rendered viewport (Frame + ShowAnchored + embedded
- // scrollview navigation). State.Body says "what to display"; frames decides "how".
- frames FrameViewModel
- notify notify.Model
-
- // Cross-state toggles
- prefs Preferences
-
- // Runtime
- executing bool
- width int
- height int
- ready bool
-
- // Persistence wiring
- flow *recording.Flow
- autoStart *recording.AutoStartRequest
-
- // History cursor for view/picker. dispatchExec advances it per state.FollowsTail.
- cursor Cursor
-
- // UI state. Exactly one of {viewState, pickerState, inputState, searchState} at all
- // times. Overlay states (input, search) carry prev — the state to restore on Esc.
- state state
-}
-
-// notifyTTL is how long a notification bubble stays visible before its Tick expires.
-const notifyTTL = time.Second
-
-// push enqueues a notification bubble and returns the resulting Model + Cmd. All recording-
-// status notifications go through here so the wiring stays in one place.
-func (m Model) push(level notify.Level, msg string) (Model, tea.Cmd) {
- var cmd tea.Cmd
- m.notify, cmd = m.notify.Push(msg, level, notifyTTL)
- return m, cmd
-}
-
-// New creates a live TUI model that watches cfg.Command. If cfg.AutoStart is non-nil,
-// recording to that path starts during Init.
-func New(cfg Config) Model {
- sess := session.NewSession(cfg.Command, cfg.Interval)
- sess.MaxHistory = cfg.MaxHistory
- return Model{
- session: sess,
- runner: runner.New(cfg.Command),
- flow: recording.New(sess),
- frames: newFrameViewModel(sess),
- cursor: noCursor(),
- state: viewState{},
- prefs: Preferences{
- Diff: cfg.DiffEnabled,
- StatusBar: cfg.ShowStatus,
- OSNotify: cfg.NotifyOnChange,
- },
- autoStart: cfg.AutoStart,
- notify: notify.New(),
- }
-}
-
-// NewReplay creates an offline TUI model replaying a loaded session. No runner, no recording,
-// no ticking. Replay-ness is encoded by the nil runner.
-func NewReplay(cfg Config, s *session.Session) Model {
- return Model{
- session: s,
- runner: nil,
- flow: recording.New(s),
- frames: newFrameViewModel(s),
- cursor: cursorAtTail(len(s.History)),
- state: viewState{},
- prefs: Preferences{
- Diff: cfg.DiffEnabled,
- StatusBar: cfg.ShowStatus,
- OSNotify: false,
- },
- notify: notify.New(),
- }
-}
-
-func (m Model) isLive() bool { return m.runner != nil }
-
-// Cleanup finalises any resources held by the Model (today: an active recording). Bubble
-// Tea v2 short-circuits Model.Update on QuitMsg — Update is never called for that message —
-// so the Model has no chance to flush its own teardown. main.go calls Cleanup after p.Run
-// returns. Idempotent: flow.Stop is a no-op when no recording is active.
-func (m Model) Cleanup() error {
- return m.flow.Stop()
-}
-
-// Init starts the TUI. Replay returns nil — no tick is ever scheduled. Live mode kicks off
-// the first execution tick and, if AutoStart was configured, begins recording.
-func (m Model) Init() tea.Cmd {
- bgQuery := tea.RequestBackgroundColor
- if !m.isLive() {
- return bgQuery
- }
- tick := func() tea.Msg { return tickMsg{} }
- if m.autoStart == nil {
- return tea.Batch(bgQuery, tick)
- }
- path := m.autoStart.Path
- return tea.Batch(
- bgQuery,
- tick,
- func() tea.Msg { return autoStartRecordingMsg{path: path} },
- )
-}
-
-// Update routes messages. Uniform events (resize, quit, tick, exec, autoStart) are handled
-// at the top; key and exec dispatch consults the active state.
-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var notifyCmd tea.Cmd
- m.notify, notifyCmd = m.notify.Update(msg)
-
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- m2, cmd := m.dispatchKey(msg)
- return m2, tea.Batch(cmd, notifyCmd)
- case tea.WindowSizeMsg:
- m2, cmd := m.handleResize(msg)
- return m2, tea.Batch(cmd, notifyCmd)
- case tickMsg:
- m2, cmd := m.handleTick()
- return m2, tea.Batch(cmd, notifyCmd)
- case execResultMsg:
- m2, cmd := m.dispatchExec(msg)
- return m2, tea.Batch(cmd, notifyCmd)
- case autoStartRecordingMsg:
- m2, cmd, _ := m.startRecording(msg.path)
- return m2, tea.Batch(cmd, notifyCmd)
- case tea.BackgroundColorMsg:
- compat.HasDarkBackground = msg.IsDark()
- return m, notifyCmd
- }
- // Anything else (bracketed-paste PasteMsg/PasteStart/PasteEndMsg, cursor.BlinkMsg from
- // textinput.Focus, tea.FocusMsg/BlurMsg, etc.) is forwarded to the active inputState's
- // textinput when one exists, so its blink loop and focus tracking stay alive across the
- // dispatcher. No-op in any other state.
- m2, cmd := m.forwardToInputTextinput(msg)
- return m2, tea.Batch(cmd, notifyCmd)
-}
-
-// forwardToInputTextinput hands a non-key message to the textinput of the active
-// inputState. Used for paste-mode messages (PasteMsg, PasteStart/End) and the
-// general fallback (cursor.BlinkMsg from textinput.Focus, FocusMsg/BlurMsg) that
-// the key dispatcher can't carry. No-op in any other state.
-func (m Model) forwardToInputTextinput(msg tea.Msg) (Model, tea.Cmd) {
- s, ok := m.state.(inputState)
- if !ok {
- return m, nil
- }
- in, cmd := s.input.Update(msg)
- s.input = in
- m.state = s
- return m, cmd
-}
-
-// dispatchKey is the central key router: the active state gets first dibs via its
-// Handle method; if it reports handled=false the global key handler runs (q/t/
-// navigation defaults). When the new state's bar visibility differs from the
-// pre-handler state's the viewport is resized; when the state TYPE changes (or bar
-// flips) we repaint so any overlay leftover (e.g. searchState's reverse-video
-// highlight) is replaced by the new state's content in the same step.
-func (m Model) dispatchKey(msg tea.KeyPressMsg) (Model, tea.Cmd) {
- barBefore := m.barShown()
- stateKindBefore := stateKind(m.state)
- nextM, s, cmd, handled := m.state.Handle(m, msg)
- m = nextM
- m.state = s
- barChanged := m.barShown() != barBefore
- stateChanged := stateKind(m.state) != stateKindBefore
- if barChanged {
- m = m.withResizedScrollview()
- }
- if barChanged || stateChanged {
- m = m.repaint()
- }
- if handled {
- return m, cmd
- }
- return m.handleGlobalKey(msg)
-}
-
-// stateKind returns a small integer tag identifying the concrete state type. Used by
-// dispatchKey to detect state transitions without depending on reflect.
-func stateKind(s state) int {
- switch s.(type) {
- case viewState:
- return 1
- case pickerState:
- return 2
- case inputState:
- return 3
- case searchState:
- return 4
- }
- return 0
-}
-
-// handleGlobalKey runs the global fall-through bindings: quit, status-bar toggle, and the
-// viewport-navigation defaults (arrows/Home/End/PgUp/PgDn/Shift+arrows). Mutations happen
-// directly on the viewport; no intra-update messages.
-func (m Model) handleGlobalKey(msg tea.KeyPressMsg) (Model, tea.Cmd) {
- switch {
- case key.Matches(msg, globalKeys.Quit):
- // Recording finalisation is owned by main.go's Cleanup() call after p.Run returns —
- // Bubble Tea v2 short-circuits QuitMsg before Model.Update sees it, so there is no
- // in-Update teardown seam.
- return m, tea.Quit
- case key.Matches(msg, globalKeys.ToggleBar):
- m.prefs.StatusBar = !m.prefs.StatusBar
- m = m.withResizedScrollview()
- m = m.repaint()
- return m, nil
- case key.Matches(msg, globalKeys.Help):
- m.prefs.HelpVisible = !m.prefs.HelpVisible
- return m, nil
- case key.Matches(msg, navKeys.Up):
- m.frames.ScrollUp(1)
- case key.Matches(msg, navKeys.Down):
- m.frames.ScrollDown(1)
- case key.Matches(msg, navKeys.Left):
- m.frames.ScrollLeft()
- case key.Matches(msg, navKeys.Right):
- m.frames.ScrollRight()
- case key.Matches(msg, navKeys.PageUp):
- m.frames.PageUp()
- case key.Matches(msg, navKeys.PageDown):
- m.frames.PageDown()
- case key.Matches(msg, navKeys.ScrollLeft):
- m.frames.ScrollLeftPage()
- case key.Matches(msg, navKeys.ScrollRight):
- m.frames.ScrollRightPage()
- case key.Matches(msg, navKeys.Home):
- m.frames.GotoLeftEdge()
- case key.Matches(msg, navKeys.End):
- m.frames.GotoRightEdge()
- }
- return m, nil
-}
-
-// dispatchExec appends the new execution, advances historyIndex if the active state's
-// follow policy says so, and refreshes the viewport. view/picker anchor against the previous
-// output; search owns its frozen body; input is transparent and delegates both freeze and
-// follow policy to its prev.
-func (m Model) dispatchExec(msg execResultMsg) (Model, tea.Cmd) {
- m.executing = false
-
- prev, _ := m.state.Body(m)
- prior := m.cursor
- wasAtTail := m.isFollowing()
- added, evicted, err := m.session.RecordIfChanged(msg.exec)
- var cmds []tea.Cmd
- if err != nil {
- var c tea.Cmd
- m, c = m.push(notify.LevelWarning, "Recording error: "+err.Error())
- cmds = append(cmds, c)
- }
- // MaxHistory eviction shifted every slot index down by 1. For a non-tail viewer the
- // cursor must follow so the user keeps reading the same frame (clamped to 0 when the
- // frame they were on was the one evicted).
- if evicted {
- m.cursor = m.cursor.AfterEvict()
- }
- if added && m.state.FollowsTail(wasAtTail) {
- m.cursor = m.cursor.ToTail(len(m.session.History))
- }
- // Skip the full diff/anchor recomputation when nothing the viewport derives from has
- // changed (duplicate tick: !added && cursor didn't move). Important for the wch
- // primary use case — watching slow-changing kubectl output, where most ticks are dedup'd.
- if added || m.cursor != prior {
- m = m.repaintAnchored(prev)
- }
- // prior.Valid() gates the very first frame (when there was no prior to compare against).
- // This replaces the older len(History) > 1 gate, which was unreachable when MaxHistory==1.
- if m.prefs.OSNotify && added && prior.Valid() {
- cmds = append(cmds, sendNotification())
- }
- cmds = append(cmds, m.scheduleNextTick())
- return m, tea.Batch(cmds...)
-}
-
-// repaint asks the active state for its body and commits it to the viewport without
-// anchor preservation. Used by handleResize, handleGlobalKey's bar toggle, and dispatchKey
-// on a state transition — the cursor (historyIndex) hasn't moved, so the eye-on-line
-// invariant doesn't apply.
-func (m Model) repaint() Model {
- body, ok := m.state.Body(m)
- if !ok {
- return m
- }
- m.frames.SetContent(body)
- return m
-}
-
-// snapTarget describes a viewport scroll-to-cell effect that follows a paint. Used by
-// searchState for its event-driven snap-to-match (search entry and n/p navigation) --
-// kept outside the state interface because Body has no way to know whether the call
-// context is event-triggered (snap wanted) or geometry-driven (snap unwanted).
-type snapTarget struct {
- line, col, length int
-}
-
-// repaintWith commits s.Body(m) to the viewport and, when snap is non-nil, scrolls so
-// the (line, col, length) region is visible. Used at sites where the freshly-built
-// state isn't yet installed on Model (search entry constructing a searchState; n/p
-// navigation mutating the searchState locally before returning it). When snap is nil,
-// behaves like repaint applied to s.
-func (m Model) repaintWith(s state, snap *snapTarget) Model {
- body, ok := s.Body(m)
- if !ok {
- return m
- }
- m.frames.SetContent(body)
- if snap != nil {
- m.frames.EnsureLineVisible(snap.line)
- m.frames.EnsureColumnVisible(snap.col, snap.length)
- }
- return m
-}
-
-// repaintAnchored asks the active state for its body and commits it via ShowAnchored
-// against prev. States that report IsFrozen (search; input over search) own their frozen
-// body and are left untouched.
-func (m Model) repaintAnchored(prev string) Model {
- if !m.cursor.Valid() {
- return m
- }
- if m.state.IsFrozen() {
- return m
- }
- body, ok := m.state.Body(m)
- if !ok {
- return m
- }
- m.frames.ShowAnchored(body, prev)
- return m
-}
-
-// handleTick processes the periodic execution tick.
-func (m Model) handleTick() (Model, tea.Cmd) {
- if m.prefs.Paused {
- return m, m.scheduleNextTick()
- }
- m.executing = true
- return m, m.executeCmd()
-}
-
-// handleResize responds to terminal size changes. The viewport geometry is updated, then
-// the content is re-committed via the active state's Body (state-aware: searchState
-// re-overlays its captured body with the highlight; view/picker re-derive from
-// historyIndex; input delegates to its prev). Scroll position is preserved — no snap.
-func (m Model) handleResize(msg tea.WindowSizeMsg) (Model, tea.Cmd) {
- m.width = msg.Width
- m.height = msg.Height
- m.ready = true
- m = m.withResizedScrollview()
- m = m.repaint()
- // ClearScreen forces a full terminal redraw, preventing visual artifacts in terminal
- // multiplexers like Zellij that don't handle Bubble Tea's differential rendering
- // correctly on resize.
- return m, tea.ClearScreen
-}
-
-// executeCmd runs the command and returns the result as a message.
-func (m Model) executeCmd() tea.Cmd {
- return func() tea.Msg {
- return execResultMsg{exec: m.runner.Execute(context.Background())}
- }
-}
-
-// scheduleNextTick schedules the next execution tick.
-func (m Model) scheduleNextTick() tea.Cmd {
- return tea.Tick(m.session.Interval, func(time.Time) tea.Msg {
- return tickMsg{}
- })
-}
-
-// withResizedScrollview returns a model with updated viewport dimensions.
-func (m Model) withResizedScrollview() Model {
- w, h := m.width, m.height
- if m.barShown() {
- h--
- }
- m.frames.SetSize(w, h)
- return m
-}
-
-// withCursor returns a model with the cursor moved and content updated. Clamped no-op moves
-// (e.g. pressing Right at the tail or Left at the head) short-circuit so we don't run a
-// full Strip+Align+SetContent for an identity re-render on every boundary keypress.
-func (m Model) withCursor(newIdx int) Model {
- moved := m.cursor.Move(newIdx, len(m.session.History))
- if moved == m.cursor {
- return m
- }
- prev, _ := m.state.Body(m)
- m.cursor = moved
- return m.repaintAnchored(prev)
-}
-
-// isFollowing returns true if viewing the latest history item.
-func (m Model) isFollowing() bool {
- return m.cursor.Following(len(m.session.History))
-}
-
-// sendNotification sends an OSC9 notification via raw terminal output.
-func sendNotification() tea.Cmd {
- return tea.Raw("\033]9;wch: output changed\a")
-}
diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go
deleted file mode 100644
index 1e63eb0..0000000
--- a/internal/tui/model_test.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package tui
-
-import (
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
-)
-
-// NewReplay never schedules a tick and renders the latest frame after the first WindowSizeMsg.
-// Init may return a non-nil cmd (e.g. background-color query) but must never schedule a tick.
-func TestNewReplayInitNoTickAndRendersLatest(t *testing.T) {
- s := preloadedReplaySession(3)
- m := NewReplay(Config{}, s)
-
- if cmd := m.Init(); cmd != nil {
- if msg := cmd(); msg != nil {
- if _, isTick := msg.(tickMsg); isTick {
- t.Errorf("replay Init() must not schedule a tick")
- }
- }
- }
- if m.isLive() {
- t.Errorf("replay isLive() should be false")
- }
- if m.cursor.Index() != 2 {
- t.Errorf("historyIndex=%d want 2 (last frame)", m.cursor.Index())
- }
-
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- if !m.ready {
- t.Errorf("Model.ready should be true after WindowSizeMsg")
- }
- body := m.frames.Frame(m.cursor.Index(), m.prefs.Diff)
- if !strings.Contains(body, "frame 2") {
- t.Errorf("expected latest frame rendered; got %q", body)
- }
-}
-
-// 'q' falls through from viewState's Handle to handleGlobalKey for the Quit cmd.
-func TestHandleKeyPriorityFallsThroughToGlobal(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
-
- _, cmd := m.dispatchKey(tea.KeyPressMsg{Code: 'q', Text: "q"})
- if cmd == nil {
- t.Fatalf("expected tea.Quit cmd from q-key fallthrough")
- }
- if msg := cmd(); msg != (tea.QuitMsg{}) {
- t.Errorf("q-key cmd produced %T (%v), want tea.QuitMsg{}", msg, msg)
- }
-}
diff --git a/internal/tui/notify/notify.go b/internal/tui/notify/notify.go
deleted file mode 100644
index ba45814..0000000
--- a/internal/tui/notify/notify.go
+++ /dev/null
@@ -1,110 +0,0 @@
-// Package notify renders transient corner notifications ("bubbles") as overlays on top of
-// existing Bubble Tea views. A new bubble pushed via Push appears in the configured corner;
-// each carries its own timer Cmd that, threaded back through Update, removes the bubble
-// after the requested TTL. Use Overlay to compose the bubble stack onto your own View
-// output without disrupting underlying content.
-package notify
-
-import (
- "slices"
- "time"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
-)
-
-// Level categorizes a notification. Default styling tints the border accordingly.
-type Level int
-
-const (
- LevelInfo Level = iota
- LevelWarning
-)
-
-// Position picks the corner the stack grows from. Newest bubble appears closest to the
-// corner; older ones slide outward.
-type Position int
-
-const (
- BottomRight Position = iota
- BottomLeft
- TopRight
- TopLeft
-)
-
-// defaultMaxVisible caps the visible stack so a flood of notifications doesn't cover the view.
-const defaultMaxVisible = 5
-
-// Model is a stack of transient notifications.
-type Model struct {
- bubbles []bubble
- position Position
- maxVisible int
- nextID int
- styles map[Level]lipgloss.Style
-}
-
-// bubble is one notification in the stack.
-type bubble struct {
- id int
- message string
- level Level
-}
-
-// expireMsg is the only message this package emits via tea.Tick; Update consumes it.
-type expireMsg struct{ id int }
-
-// Option configures a Model at construction.
-type Option func(*Model)
-
-// WithPosition picks the stack corner.
-func WithPosition(p Position) Option { return func(m *Model) { m.position = p } }
-
-// WithMaxVisible caps the number of simultaneous bubbles. n ≤ 0 disables the cap.
-func WithMaxVisible(n int) Option { return func(m *Model) { m.maxVisible = n } }
-
-// New constructs a Model. Defaults: BottomRight, maxVisible=5; per-level styling is set
-// by defaultStyles (info untinted, warning yellow-bordered).
-func New(opts ...Option) Model {
- m := Model{
- position: BottomRight,
- maxVisible: defaultMaxVisible,
- styles: defaultStyles(),
- }
- for _, o := range opts {
- o(&m)
- }
- return m
-}
-
-// Push enqueues a notification. The returned Cmd fires after ttl; threading it through
-// Update will remove the matching bubble.
-func (m Model) Push(message string, level Level, ttl time.Duration) (Model, tea.Cmd) {
- m.nextID++
- id := m.nextID
- m.bubbles = append(m.bubbles, bubble{id: id, message: message, level: level})
- // Evict the oldest if over the visible cap.
- if m.maxVisible > 0 && len(m.bubbles) > m.maxVisible {
- m.bubbles = m.bubbles[len(m.bubbles)-m.maxVisible:]
- }
- return m, tea.Tick(ttl, func(time.Time) tea.Msg {
- return expireMsg{id: id}
- })
-}
-
-// Update consumes the internal expiry messages. Foreign messages return the model unchanged.
-func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
- exp, ok := msg.(expireMsg)
- if !ok {
- return m, nil
- }
- if i := slices.IndexFunc(m.bubbles, func(b bubble) bool { return b.id == exp.id }); i >= 0 {
- m.bubbles = slices.Delete(m.bubbles, i, i+1)
- }
- return m, nil
-}
-
-// Active reports whether any bubble is currently being shown.
-func (m Model) Active() bool {
- return len(m.bubbles) > 0
-}
diff --git a/internal/tui/notify/notify_test.go b/internal/tui/notify/notify_test.go
deleted file mode 100644
index de01088..0000000
--- a/internal/tui/notify/notify_test.go
+++ /dev/null
@@ -1,248 +0,0 @@
-package notify
-
-import (
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-)
-
-// Push schedules a Tick that, when fed back through Update, removes the bubble.
-func TestPushExpireRoundTrip(t *testing.T) {
- m := New()
- var cmd tea.Cmd
- m, cmd = m.Push("hi", LevelInfo, 50*time.Millisecond)
- if !m.Active() {
- t.Fatalf("expected Active() == true after Push")
- }
- if cmd == nil {
- t.Fatalf("Push did not return a Cmd")
- }
-
- msg := cmd()
- exp, ok := msg.(expireMsg)
- if !ok {
- t.Fatalf("Tick produced %T, want expireMsg", msg)
- }
-
- m, _ = m.Update(exp)
- if m.Active() {
- t.Errorf("expected Active() == false after matching expireMsg")
- }
-}
-
-// A stale expireMsg (id no longer in the stack) is a harmless no-op — it must not wipe a
-// newer bubble.
-func TestStaleExpireIgnored(t *testing.T) {
- m := New()
- var firstCmd tea.Cmd
- m, firstCmd = m.Push("first", LevelInfo, time.Second)
- staleExp := firstCmd().(expireMsg)
-
- // Remove the first bubble explicitly, then push a newer one.
- m, _ = m.Update(staleExp)
- m, _ = m.Push("second", LevelInfo, time.Second)
- if !m.Active() {
- t.Fatalf("setup: second bubble should be active")
- }
-
- // Fire the stale expire again — must not affect the new bubble.
- m, _ = m.Update(staleExp)
- if !m.Active() {
- t.Errorf("stale expire wiped a newer bubble")
- }
-}
-
-// WithMaxVisible caps the stack; oldest bubbles are evicted on overflow. Stale expires for
-// evicted ones are no-ops.
-func TestMaxVisibleEviction(t *testing.T) {
- m := New(WithMaxVisible(2))
- var cmd1 tea.Cmd
- m, cmd1 = m.Push("one", LevelInfo, time.Second)
- m, _ = m.Push("two", LevelInfo, time.Second)
- m, _ = m.Push("three", LevelInfo, time.Second)
-
- if got := len(m.bubbles); got != 2 {
- t.Fatalf("len(bubbles)=%d, want 2 (cap)", got)
- }
- if m.bubbles[0].message != "two" || m.bubbles[1].message != "three" {
- t.Errorf("retained bubbles=%q,%q; want two,three (oldest evicted)",
- m.bubbles[0].message, m.bubbles[1].message)
- }
- // Fire the (now stale) expire of "one" — must not change the cap.
- m, _ = m.Update(cmd1().(expireMsg))
- if got := len(m.bubbles); got != 2 {
- t.Errorf("evicted-bubble expire mutated stack: len=%d, want 2", got)
- }
-}
-
-// Update returns the model untouched for foreign messages.
-func TestUpdateForeignMessages(t *testing.T) {
- m := New()
- m, _ = m.Push("hi", LevelInfo, time.Second)
- wantLen := len(m.bubbles)
- for _, msg := range []tea.Msg{
- tea.WindowSizeMsg{Width: 80, Height: 24},
- tea.KeyPressMsg{Code: 'q', Text: "q"},
- tea.QuitMsg{},
- } {
- m, _ = m.Update(msg)
- if got := len(m.bubbles); got != wantLen {
- t.Errorf("foreign %T mutated stack: len=%d want %d", msg, got, wantLen)
- }
- }
-}
-
-// Overlay places the sprite in the configured corner.
-func TestOverlayPlacement(t *testing.T) {
- const baseW, baseH = 60, 12
-
- base := buildBase(baseW, baseH, '.')
-
- cases := []struct {
- pos Position
- nameContains string
- checkCorner func(t *testing.T, lines []string)
- }{
- {
- pos: BottomRight,
- nameContains: "BottomRight",
- checkCorner: func(t *testing.T, lines []string) {
- // Bottom row should not start with the border in column 0.
- if strings.HasPrefix(lines[baseH-1], "╰") {
- t.Errorf("BottomRight: bubble found at left edge: %q", lines[baseH-1])
- }
- // Top-left corner should be untouched.
- if !strings.HasPrefix(lines[0], "....") {
- t.Errorf("BottomRight: top-left was overwritten: %q", lines[0])
- }
- },
- },
- {
- pos: BottomLeft,
- nameContains: "BottomLeft",
- checkCorner: func(t *testing.T, lines []string) {
- if !strings.HasPrefix(lines[baseH-1], "╰") {
- t.Errorf("BottomLeft: bubble not at left edge of bottom row: %q", lines[baseH-1])
- }
- },
- },
- {
- pos: TopRight,
- nameContains: "TopRight",
- checkCorner: func(t *testing.T, lines []string) {
- if strings.HasPrefix(lines[0], "╭") {
- t.Errorf("TopRight: bubble found at left edge of top row: %q", lines[0])
- }
- if !strings.HasSuffix(strings.TrimRight(lines[0], " "), "╮") {
- t.Errorf("TopRight: bubble not at right edge of top row: %q", lines[0])
- }
- },
- },
- {
- pos: TopLeft,
- nameContains: "TopLeft",
- checkCorner: func(t *testing.T, lines []string) {
- if !strings.HasPrefix(lines[0], "╭") {
- t.Errorf("TopLeft: bubble not at left edge of top row: %q", lines[0])
- }
- },
- },
- }
- for _, c := range cases {
- t.Run(c.nameContains, func(t *testing.T) {
- m := New(WithPosition(c.pos))
- m, _ = m.Push("hello", LevelInfo, time.Second)
- out := m.Overlay(base, baseW, baseH, Insets{})
- stripped := ansi.Strip(out)
- lines := strings.Split(stripped, "\n")
- if len(lines) != baseH {
- t.Fatalf("output has %d lines, want %d", len(lines), baseH)
- }
- for i, l := range lines {
- if visibleWidth(l) != baseW {
- t.Errorf("line %d width=%d, want %d (%q)", i, visibleWidth(l), baseW, l)
- }
- }
- c.checkCorner(t, lines)
- // The message must be present somewhere.
- if !strings.Contains(stripped, "hello") {
- t.Errorf("rendered output missing message: %q", stripped)
- }
- })
- }
-}
-
-// A bubble larger than the available area must clip rather than panic, and the result
-// keeps the requested base dimensions.
-func TestOverlayClipsAtEdge(t *testing.T) {
- const baseW, baseH = 10, 3
- base := buildBase(baseW, baseH, '.')
-
- m := New()
- m, _ = m.Push("a very long notification that exceeds the area dramatically", LevelInfo, time.Second)
- out := m.Overlay(base, baseW, baseH, Insets{})
- stripped := ansi.Strip(out)
- lines := strings.Split(stripped, "\n")
- if len(lines) != baseH {
- t.Errorf("expected %d lines, got %d", baseH, len(lines))
- }
- for i, l := range lines {
- if visibleWidth(l) != baseW {
- t.Errorf("line %d width=%d, want %d", i, visibleWidth(l), baseW)
- }
- }
-}
-
-// Insets push the bubble inward by the requested cells, leaving the reserved edges clear.
-func TestOverlayHonoursInsets(t *testing.T) {
- const baseW, baseH = 60, 12
- base := buildBase(baseW, baseH, '.')
-
- m := New(WithPosition(BottomRight))
- m, _ = m.Push("x", LevelInfo, time.Second)
- out := m.Overlay(base, baseW, baseH, Insets{Right: 2, Bottom: 3})
- stripped := ansi.Strip(out)
- lines := strings.Split(stripped, "\n")
-
- // Right inset = 2 → rightmost 2 columns of every line must remain "..".
- for i, l := range lines {
- if !strings.HasSuffix(l, "..") {
- t.Errorf("line %d right inset violated: %q", i, l)
- }
- }
- // Bottom inset = 3 → bottom 3 lines must be entirely dots.
- for _, idx := range []int{baseH - 1, baseH - 2, baseH - 3} {
- if got := lines[idx]; got != strings.Repeat(".", baseW) {
- t.Errorf("line %d (bottom inset) was overwritten: %q", idx, got)
- }
- }
-}
-
-// When no bubble is active, Overlay returns the input verbatim.
-func TestOverlayInactiveIsPassthrough(t *testing.T) {
- m := New()
- base := buildBase(20, 5, '.')
- if got := m.Overlay(base, 20, 5, Insets{}); got != base {
- t.Errorf("Overlay (inactive) mutated content: %q vs %q", got, base)
- }
-}
-
-// --- helpers ---------------------------------------------------------------
-
-func buildBase(w, h int, ch rune) string {
- row := strings.Repeat(string(ch), w)
- lines := make([]string, h)
- for i := range lines {
- lines[i] = row
- }
- return strings.Join(lines, "\n")
-}
-
-// visibleWidth measures the cell width of a line ignoring ANSI escape sequences.
-func visibleWidth(s string) int {
- return lipgloss.Width(s)
-}
diff --git a/internal/tui/notify/render.go b/internal/tui/notify/render.go
deleted file mode 100644
index 5420999..0000000
--- a/internal/tui/notify/render.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package notify
-
-import (
- "slices"
-
- "charm.land/lipgloss/v2"
-
- "github.com/ivoronin/wch/internal/tui/overlay"
-)
-
-// Insets describe how much space to leave clear inside the overlay area between the
-// bubbles and each edge. Useful to keep bubbles off fixed UI elements (e.g. a status bar at
-// the bottom).
-type Insets struct{ Top, Right, Bottom, Left int }
-
-// Overlay composes the current bubble stack onto content. content is interpreted as a
-// styled string of dimensions width × height (terminal cells); the result is the same
-// width × height with bubbles laid into the configured corner, respecting insets. Cells
-// outside the bubble footprint show through unchanged.
-func (m Model) Overlay(content string, width, height int, in Insets) string {
- if !m.Active() || width <= 0 || height <= 0 {
- return content
- }
- sprite := m.renderSprite()
- sw := lipgloss.Width(sprite)
- sh := lipgloss.Height(sprite)
- x, y := m.corner(width, height, sw, sh, in)
- return overlay.Sprite(content, width, height, sprite, x, y)
-}
-
-// renderSprite produces the styled bubble stack as a single string. All bubbles share the
-// same width (the widest in the stack) so the column reads cleanly when stacked.
-func (m Model) renderSprite() string {
- if len(m.bubbles) == 0 {
- return ""
- }
- // Find the widest message+prefix; pad shorter ones up to that with spaces so each
- // bubble renders at identical visible width.
- contents := make([]string, len(m.bubbles))
- maxContent := 0
- for i, b := range m.bubbles {
- contents[i] = prefixFor(b.level) + b.message
- maxContent = max(maxContent, lipgloss.Width(contents[i]))
- }
-
- parts := make([]string, len(m.bubbles))
- for i, b := range m.bubbles {
- padded := lipgloss.NewStyle().Width(maxContent).Render(contents[i])
- parts[i] = m.styles[b.level].Render(padded)
- }
-
- // Newest at the corner: that's the natural append order for Bottom* positions;
- // reverse for Top* so the newest sits at the top.
- if m.position == TopLeft || m.position == TopRight {
- slices.Reverse(parts)
- }
- return lipgloss.JoinVertical(lipgloss.Left, parts...)
-}
-
-// corner returns the (x, y) top-left of the sprite for the configured Position, accounting
-// for the given insets so the bubble keeps clear of reserved edges.
-func (m Model) corner(baseW, baseH, sw, sh int, in Insets) (int, int) {
- switch m.position {
- case BottomRight:
- return baseW - sw - in.Right, baseH - sh - in.Bottom
- case BottomLeft:
- return in.Left, baseH - sh - in.Bottom
- case TopRight:
- return baseW - sw - in.Right, in.Top
- default: // TopLeft
- return in.Left, in.Top
- }
-}
diff --git a/internal/tui/notify/style.go b/internal/tui/notify/style.go
deleted file mode 100644
index 60fa59c..0000000
--- a/internal/tui/notify/style.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package notify
-
-import (
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-)
-
-// defaultStyles returns the per-level styles. Info uses the terminal's default colors
-// (no tint); Warning gets a yellow-tinted border. The level symbol itself (ℹ / ⚠) is the
-// only accented piece — see prefixFor.
-func defaultStyles() map[Level]lipgloss.Style {
- base := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- Padding(0, 1)
- return map[Level]lipgloss.Style{
- LevelInfo: base,
- LevelWarning: base.BorderForeground(ansi.Yellow),
- }
-}
-
-// Level symbol accents: a small splash of color on the leading glyph so the level reads at
-// a glance without tinting the rest of the bubble.
-var (
- infoAccent = lipgloss.NewStyle().Foreground(ansi.Cyan)
- warningAccent = lipgloss.NewStyle().Foreground(ansi.Yellow)
-)
-
-// prefixFor returns the level-specific icon prefix shown before the message, with its
-// accent color baked in.
-func prefixFor(l Level) string {
- switch l {
- case LevelWarning:
- return warningAccent.Render("⚠ ")
- default:
- return infoAccent.Render("ℹ ")
- }
-}
diff --git a/internal/tui/overlay/overlay.go b/internal/tui/overlay/overlay.go
deleted file mode 100644
index a4b9a95..0000000
--- a/internal/tui/overlay/overlay.go
+++ /dev/null
@@ -1,83 +0,0 @@
-// Package overlay owns the cellbuf machinery shared by tui's four renderers (diffrender,
-// searchrender, helprender, notify): each one wants to paint styled cells onto a base
-// string and read back a styled string. Walk covers the in-place pattern (mutate cells of
-// the base buffer); Sprite covers the composition pattern (copy a sprite buffer onto a
-// base). Together they collapse the NewBuffer → SetContent → Render → CRLF-strip dance
-// to one place, so each renderer keeps only its domain logic (what to highlight, where
-// to put the sprite).
-package overlay
-
-import (
- "strings"
-
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/cellbuf"
-)
-
-// CellCap bounds the cell grid Walk will build per call. Beyond it, Walk returns the
-// base unchanged rather than allocate a huge grid. Empirically large enough for any
-// realistic terminal (4M cells ≈ 2000×2000).
-const CellCap = 4_000_000
-
-// Walk loads base into a cell buffer of (w, h) and calls mutate to paint cells in
-// place. Returns the rendered string with cellbuf's CRLF row separators normalized to
-// LF. Returns base unchanged when w == 0 or w*h > CellCap.
-func Walk(base string, w, h int, mutate func(*cellbuf.Buffer)) string {
- if w == 0 || w*h > CellCap {
- return base
- }
- buf := cellbuf.NewBuffer(w, h)
- cellbuf.SetContent(buf, base)
- mutate(buf)
- return render(buf)
-}
-
-// Sprite composes sprite onto base at (x, y) and returns the result. The result has the
-// same dimensions as base (baseW, baseH); cells outside the sprite footprint pass
-// through unchanged. cellbuf carries SGR state across the merge so the sprite's styling
-// survives intact. The base dimensions are caller-supplied so a renderer can declare
-// the canvas size that matters to it (e.g. terminal viewport, not the base string's
-// natural width).
-func Sprite(base string, baseW, baseH int, sprite string, x, y int) string {
- buf := cellbuf.NewBuffer(baseW, baseH)
- cellbuf.SetContent(buf, base)
-
- sw, sh := spriteDims(sprite)
- spriteBuf := cellbuf.NewBuffer(sw, sh)
- cellbuf.SetContent(spriteBuf, sprite)
-
- for dy := range sh {
- for dx := range sw {
- c := spriteBuf.Cell(dx, dy)
- if c == nil || c.Width == 0 {
- continue
- }
- buf.SetCell(x+dx, y+dy, c)
- }
- }
- return render(buf)
-}
-
-// MaxDisplayWidth returns the widest display-width line in s, in terminal cells. Used
-// by Walk callers that derive the cell-buffer width from the base string itself.
-func MaxDisplayWidth(s string) int {
- w := 0
- for _, line := range strings.Split(s, "\n") {
- w = max(w, ansi.StringWidth(line))
- }
- return w
-}
-
-// spriteDims returns the natural cell dimensions of a styled sprite string.
-func spriteDims(s string) (w, h int) {
- for _, line := range strings.Split(s, "\n") {
- w = max(w, ansi.StringWidth(line))
- h++
- }
- return w, h
-}
-
-// render finalises buf to a string with LF row separators (cellbuf emits CRLF).
-func render(buf *cellbuf.Buffer) string {
- return strings.ReplaceAll(cellbuf.Render(buf), "\r\n", "\n")
-}
diff --git a/internal/tui/overlay/overlay_test.go b/internal/tui/overlay/overlay_test.go
deleted file mode 100644
index 2b16338..0000000
--- a/internal/tui/overlay/overlay_test.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package overlay
-
-import (
- "strings"
- "testing"
-
- "github.com/charmbracelet/x/ansi"
- "github.com/charmbracelet/x/cellbuf"
-)
-
-func TestMaxDisplayWidth(t *testing.T) {
- cases := []struct {
- in string
- want int
- }{
- {"", 0},
- {"foo", 3},
- {"foo\nbarbaz", 6},
- {"\x1b[31mred\x1b[0m", 3}, // ANSI sequence ignored
- {"短\n longer line", 12}, // wide rune + longer plain line
- {"short\n\x1b[1mlong with bold\x1b[0m\nmid", 14}, // mixed
- }
- for _, c := range cases {
- if got := MaxDisplayWidth(c.in); got != c.want {
- t.Errorf("MaxDisplayWidth(%q) = %d, want %d", c.in, got, c.want)
- }
- }
-}
-
-func TestWalkRendersAndMutates(t *testing.T) {
- base := "abc\ndef"
- called := false
- got := Walk(base, 3, 2, func(buf *cellbuf.Buffer) {
- called = true
- buf.Cell(0, 0).Style.Fg = ansi.Red
- })
- if !called {
- t.Errorf("Walk did not invoke mutate")
- }
- if stripped := ansi.Strip(got); stripped != "abc\ndef" {
- t.Errorf("Walk output lost content: stripped=%q want %q", stripped, "abc\ndef")
- }
- if strings.Contains(got, "\r\n") {
- t.Errorf("Walk output contained CRLF: %q", got)
- }
- if !strings.Contains(got, "\x1b[") {
- t.Errorf("Walk did not produce styled output: %q", got)
- }
-}
-
-func TestWalkReturnsBaseWhenZeroWidth(t *testing.T) {
- base := "ignored"
- got := Walk(base, 0, 5, func(*cellbuf.Buffer) {
- t.Errorf("mutate must not be called when w == 0")
- })
- if got != base {
- t.Errorf("Walk(w=0) = %q, want base unchanged", got)
- }
-}
-
-func TestWalkReturnsBaseWhenCapExceeded(t *testing.T) {
- base := "ignored"
- got := Walk(base, CellCap, 2, func(*cellbuf.Buffer) {
- t.Errorf("mutate must not be called when w*h > CellCap")
- })
- if got != base {
- t.Errorf("Walk(cap exceeded) = %q, want base unchanged", got)
- }
-}
-
-func TestSpriteCopiesAtOffset(t *testing.T) {
- base := "abcde\nfghij\nklmno"
- sprite := "XY\nZW"
- got := Sprite(base, 5, 3, sprite, 1, 1)
- stripped := ansi.Strip(got)
-
- want := "abcde\nfXYij\nkZWno"
- if stripped != want {
- t.Errorf("Sprite composition wrong\n got: %q\nwant: %q", stripped, want)
- }
-}
-
-func TestSpritePreservesBaseOutsideFootprint(t *testing.T) {
- base := "aaa\nbbb\nccc"
- sprite := "X"
- got := ansi.Strip(Sprite(base, 3, 3, sprite, 0, 0))
- want := "Xaa\nbbb\nccc"
- if got != want {
- t.Errorf("Sprite leaked outside footprint\n got: %q\nwant: %q", got, want)
- }
-}
-
-func TestSpriteUsesLFSeparators(t *testing.T) {
- got := Sprite("ab\ncd", 2, 2, "X", 0, 0)
- if strings.Contains(got, "\r\n") {
- t.Errorf("Sprite output contained CRLF: %q", got)
- }
-}
diff --git a/internal/tui/preferences.go b/internal/tui/preferences.go
deleted file mode 100644
index 1c57485..0000000
--- a/internal/tui/preferences.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package tui
-
-// Preferences are the runtime toggles the user flips during a session. Lives
-// as a single nested field on Model (m.prefs) so all read/write sites mention
-// the same prefix and the toggle set is visible at one declaration.
-//
-// CLI-derived preferences (Diff, StatusBar, OSNotify) are populated from
-// Config in New / NewReplay. Runtime-only toggles (Paused, HelpVisible)
-// default to false.
-type Preferences struct {
- Diff bool // toggled by 'd'; controls renderFrame's diff overlay
- StatusBar bool // toggled by 't'; user side of barShown's OR with state.ShowsBar
- // OSNotify gates the OSC9 ping on exec changes; set via -b at launch. Named for
- // the OSC9 channel, not the trigger; cfg.NotifyOnChange maps here.
- OSNotify bool
- Paused bool // toggled by 'p'; suppresses tick-driven execution
- HelpVisible bool // toggled by 'h'; gates the help overlay in View
-}
diff --git a/internal/tui/preferences_test.go b/internal/tui/preferences_test.go
deleted file mode 100644
index 602be7a..0000000
--- a/internal/tui/preferences_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package tui
-
-import (
- "testing"
- "time"
-)
-
-// TestPreferencesDefaults pins that New(Config{...}) copies the cfg-derived
-// preferences into m.prefs and that the runtime-only flags (Paused,
-// HelpVisible) start false. A future Config field that forgets to populate
-// prefs would break this test.
-func TestPreferencesDefaults(t *testing.T) {
- cfg := Config{
- Command: "x",
- Interval: time.Second,
- DiffEnabled: true,
- ShowStatus: true,
- NotifyOnChange: true,
- }
- m := New(cfg)
-
- want := Preferences{
- Diff: true,
- StatusBar: true,
- OSNotify: true,
- Paused: false,
- HelpVisible: false,
- }
- // NOTE: switch to reflect.DeepEqual or per-field asserts when adding non-comparable fields.
- if m.prefs != want {
- t.Errorf("New prefs = %+v, want %+v", m.prefs, want)
- }
-}
diff --git a/internal/tui/recording.go b/internal/tui/recording.go
deleted file mode 100644
index 6a432d5..0000000
--- a/internal/tui/recording.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package tui
-
-import (
- "errors"
- "time"
-
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
-
- "github.com/ivoronin/wch/internal/recording"
- "github.com/ivoronin/wch/internal/tui/notify"
-)
-
-const (
- recordPromptLabel = "Record to: "
- recordStartedMessage = "Recording started"
- recordStoppedMessage = "Recording stopped"
-)
-
-// autoStartRecordingMsg fires once during Init when AutoStart was non-nil: a deferred
-// flow.Start so the failure path (rare; the CLI already verified the file doesn't exist)
-// can surface a warning bubble instead of crashing the program.
-type autoStartRecordingMsg struct{ path string }
-
-// newRecordInput builds the textinput for the record-filename prompt: bar-matched palette,
-// branded prompt label, value pre-filled, cursor parked at the end so Enter accepts the
-// default and the user can backspace into the command portion.
-func newRecordInput(initial string) textinput.Model {
- in := newBarInput(recordPromptLabel)
- in.SetValue(initial)
- in.SetCursor(len(initial))
- return in
-}
-
-// startRecording asks Flow to begin a recording and translates the outcome into a
-// notification bubble. ok reports whether the recording is now active. Shared by the
-// auto-start launch path and the interactive record-filename submit.
-func (m Model) startRecording(path string) (Model, tea.Cmd, bool) {
- err := m.flow.Start(path)
- switch {
- case err == nil:
- m2, cmd := m.push(notify.LevelInfo, recordStartedMessage)
- return m2, cmd, true
- case errors.Is(err, recording.ErrPathExists):
- m2, cmd := m.push(notify.LevelWarning, "File exists: "+path)
- return m2, cmd, false
- default:
- m2, cmd := m.push(notify.LevelWarning, "Recording error: "+err.Error())
- return m2, cmd, false
- }
-}
-
-// toggleRecord is the 'r'-key shared handler for view and picker. Recording in progress →
-// stop + notification; idle → open the filename input. Replay no-ops defensively (the key
-// is not advertised in replay help either).
-func (m Model) toggleRecord(from state) (Model, state, tea.Cmd) {
- if !m.isLive() {
- return m, from, nil
- }
- if m.flow.IsActive() {
- var cmd tea.Cmd
- if err := m.flow.Stop(); err != nil {
- m, cmd = m.push(notify.LevelWarning, "Recording stop error: "+err.Error())
- } else {
- m, cmd = m.push(notify.LevelInfo, recordStoppedMessage)
- }
- return m, from, cmd
- }
- return m.openInput(from, newRecordInput(recording.DefaultFilename(m.session.Command, time.Now())), applyRecordSubmit)
-}
-
-// applyRecordSubmit validates the typed path and either starts recording (popping back to
-// the predecessor) or flashes a warning while keeping the input open with the user's value
-// intact for them to correct.
-func applyRecordSubmit(m Model, s inputState) (Model, state, tea.Cmd) {
- path, err := recording.NormalizePath(s.input.Value())
- if err != nil {
- m2, cmd := m.push(notify.LevelWarning, "Empty or invalid path")
- return m2, s, cmd
- }
- m, cmd, ok := m.startRecording(path)
- if !ok {
- return m, s, cmd
- }
- return m, s.prev, cmd
-}
diff --git a/internal/tui/recording_test.go b/internal/tui/recording_test.go
deleted file mode 100644
index 5b35f4e..0000000
--- a/internal/tui/recording_test.go
+++ /dev/null
@@ -1,259 +0,0 @@
-package tui
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
- "github.com/charmbracelet/x/ansi"
-
- "github.com/ivoronin/wch/internal/recording"
- "github.com/ivoronin/wch/internal/session"
-)
-
-// --- Replay & recording integration ---------------------------------------
-
-func preloadedReplaySession(n int) *session.Session {
- s := session.NewSession("kubectl", time.Second)
- for i := 0; i < n; i++ {
- _, _, _ = s.RecordIfChanged(session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, i, 0, time.UTC),
- Stdout: fmt.Sprintf("frame %d\n", i),
- })
- }
- return s
-}
-
-// Regression: opening an input prompt must leave the textinput focused. Without focus,
-// textinput.Update drops every keypress and the user can't type. Previously openInput built
-// the inputState with an unfocused textinput value and then called Focus() on a separate
-// local copy, so the stored input remained focused=false.
-func TestRecordInputFocusedAndAcceptsKeystrokes(t *testing.T) {
- m := New(Config{Command: "kubectl", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24})
- m = pressKey(t, m, 'r')
-
- in, ok := m.state.(inputState)
- if !ok {
- t.Fatalf("setup: m.state = %T, want inputState", m.state)
- }
- if !in.input.Focused() {
- t.Fatalf("input must be focused immediately after openInput; was not")
- }
-
- before := in.input.Value()
- m = pressKey(t, m, 'X') // arbitrary printable
- got := m.state.(inputState).input.Value()
- if got == before {
- t.Errorf("typing 'X' didn't change the input value (still %q) — keystroke was dropped", got)
- }
- if !strings.HasSuffix(got, "X") {
- t.Errorf("input value = %q, want a trailing X", got)
- }
-}
-
-// Pressing 'r' on a live model switches into inputState with a pre-filled default filename.
-func TestRecordKeyOpensInputMode(t *testing.T) {
- m := New(Config{Command: "kubectl get pods -A", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24})
-
- m = pressKey(t, m, 'r')
-
- in, ok := m.state.(inputState)
- if !ok {
- t.Fatalf("after r-key, m.state = %T, want inputState", m.state)
- }
- v := in.input.Value()
- if !strings.HasPrefix(v, "kubectl_get_pods_A_") || !strings.HasSuffix(v, ".wch.jsonl") {
- t.Errorf("pre-filled value = %q, want kubectl_get_pods_A_.wch.jsonl", v)
- }
- if m.session.IsRecording() {
- t.Errorf("session must not be recording yet — only the prompt is open")
- }
-}
-
-// Submitting a valid path starts recording, returns to viewState, and the backlog plus
-// subsequent frames round-trip via session.Load.
-func TestSubmitInputStartsRecording(t *testing.T) {
- path := filepath.Join(t.TempDir(), "wch.jsonl")
- m := New(Config{Command: "kubectl", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
-
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC), Stdout: "alpha\n",
- }})
-
- m = pressKey(t, m, 'r')
- if _, ok := m.state.(inputState); !ok {
- t.Fatalf("setup: expected inputState after r, got %T", m.state)
- }
- m = submitInputValue(t, m, path)
-
- if !m.session.IsRecording() {
- t.Fatalf("expected IsRecording() after submit")
- }
- if _, ok := m.state.(viewState); !ok {
- t.Errorf("after successful submit, m.state = %T, want viewState", m.state)
- }
- if !m.notify.Active() {
- t.Errorf("expected notification bubble on successful start")
- }
-
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC), Stdout: "beta\n",
- }})
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 2, 0, time.UTC), Stdout: "gamma\n",
- }})
-
- m = pressKey(t, m, 'r')
- if m.session.IsRecording() {
- t.Errorf("expected IsRecording() == false after second toggle")
- }
-
- loaded, err := recording.Load(path)
- if err != nil {
- t.Fatalf("recording.Load: %v", err)
- }
- want := []string{"alpha\n", "beta\n", "gamma\n"}
- if len(loaded.History) != len(want) {
- t.Fatalf("loaded len=%d want %d", len(loaded.History), len(want))
- }
- for i, w := range want {
- if loaded.History[i].Stdout != w {
- t.Errorf("frame %d stdout=%q want %q", i, loaded.History[i].Stdout, w)
- }
- }
-}
-
-// Empty input is refused: warning bubble fires; the prompt stays open with the value.
-func TestSubmitEmptyValueRefuses(t *testing.T) {
- m := New(Config{Command: "kubectl", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = pressKey(t, m, 'r')
- m = submitInputValue(t, m, " ")
-
- if m.session.IsRecording() {
- t.Errorf("Recording must not start on empty submit")
- }
- if _, ok := m.state.(inputState); !ok {
- t.Errorf("after refusal, m.state = %T, want inputState (prompt stays open)", m.state)
- }
- if !m.notify.Active() {
- t.Errorf("expected warning bubble on empty submit")
- }
-}
-
-// Submitting a path that already exists is refused with a warning; prompt stays open.
-func TestSubmitExistingFileRefuses(t *testing.T) {
- path := filepath.Join(t.TempDir(), "exists.wch.jsonl")
- if err := os.WriteFile(path, []byte("placeholder"), 0o644); err != nil {
- t.Fatalf("seed file: %v", err)
- }
-
- m := New(Config{Command: "kubectl", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = pressKey(t, m, 'r')
- m = submitInputValue(t, m, path)
-
- if m.session.IsRecording() {
- t.Errorf("Recording must not start on existing-file submit")
- }
- if _, ok := m.state.(inputState); !ok {
- t.Errorf("after refusal, m.state = %T, want inputState", m.state)
- }
- if !m.notify.Active() {
- t.Errorf("expected warning bubble on existing-file submit")
- }
-}
-
-// Esc out of inputState returns to viewState without starting a recording.
-func TestCancelClosesInput(t *testing.T) {
- m := New(Config{Command: "kubectl", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = pressKey(t, m, 'r')
- m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
-
- if m.session.IsRecording() {
- t.Errorf("cancel must not start a recording")
- }
- if _, ok := m.state.(viewState); !ok {
- t.Errorf("after cancel, m.state = %T, want viewState", m.state)
- }
-}
-
-// In replay, 'r' is a no-op and the Record binding is dropped from viewHelpBindings.
-func TestRecordKeyInReplayMode(t *testing.T) {
- m := NewReplay(Config{}, preloadedReplaySession(1))
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
-
- for _, b := range viewHelpBindings(m) {
- if b.Help().Key == commonKeys.Record.Help().Key {
- t.Errorf("Record binding should not appear in replay help; got %v", b.Help())
- }
- }
-
- m = pressKey(t, m, 'r')
- if m.session.IsRecording() {
- t.Errorf("replay must not start recording")
- }
- if _, ok := m.state.(inputState); ok {
- t.Errorf("replay must not open input prompt")
- }
-}
-
-// Successful submit pushes a "Recording started" bubble onto the notify stack.
-func TestSubmitRecordPushesBubble(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "wch.jsonl")
- m := New(Config{Command: "x", Interval: time.Second})
- m.ready = true
- m = feed(t, m, tea.WindowSizeMsg{Width: 120, Height: 30})
-
- m = pressKey(t, m, 'r')
- m = submitInputValue(t, m, path)
- if !m.notify.Active() {
- t.Fatalf("expected notify.Active() after successful start")
- }
- rendered := ansi.Strip(m.View().Content)
- if !strings.Contains(rendered, "Recording started") {
- t.Errorf("rendered View missing start message; got:\n%s", rendered)
- }
-}
-
-// Cleanup finalises the recording when called after p.Run returns. Bubble Tea v2
-// short-circuits Model.Update on QuitMsg, so the cleanup contract lives on Model and is
-// invoked by main.go — not by the QuitMsg handler.
-func TestCleanupFinalizesRecording(t *testing.T) {
- path := filepath.Join(t.TempDir(), "wch.jsonl")
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC), Stdout: "kept\n",
- }})
-
- m = pressKey(t, m, 'r')
- m = submitInputValue(t, m, path)
- if !m.session.IsRecording() {
- t.Fatalf("setup: should be recording after submit")
- }
-
- if err := m.Cleanup(); err != nil {
- t.Fatalf("Cleanup: %v", err)
- }
- if m.session.IsRecording() {
- t.Errorf("IsRecording() must be false after Cleanup")
- }
-
- loaded, err := recording.Load(path)
- if err != nil {
- t.Fatalf("recording.Load: %v", err)
- }
- if len(loaded.History) == 0 {
- t.Errorf("expected at least one frame in the finalized file")
- }
-}
diff --git a/internal/tui/scrollview/scrollview.go b/internal/tui/scrollview/scrollview.go
deleted file mode 100644
index f369dc7..0000000
--- a/internal/tui/scrollview/scrollview.go
+++ /dev/null
@@ -1,264 +0,0 @@
-package scrollview
-
-import (
- "strings"
-
- "charm.land/bubbles/v2/viewport"
- "charm.land/lipgloss/v2"
-)
-
-// Scrollbar symbols
-const (
- vTrackChar = "│"
- vThumbChar = "┃"
- hTrackChar = "─"
- hThumbChar = "━"
-)
-
-// Scrollbar styles
-var (
- trackStyle = lipgloss.NewStyle().Faint(true)
- thumbStyle = lipgloss.NewStyle().Bold(true)
-)
-
-// Scrollview wraps bubbles viewport with horizontal scrolling and scrollbar rendering.
-type Scrollview struct {
- viewport.Model // embedded - navigation and scroll methods auto-promoted
-
- content string // raw content
- lines []string // cached split lines
- maxWidth int // cached max line width
- showBar bool // show scrollbar
- totalWidth int // user-requested width (content + scrollbar space)
- totalHeight int // user-requested height (content + scrollbar space)
- needsVBar bool // vertical scrollbar needed (computed in updateLayout)
- needsHBar bool // horizontal scrollbar needed (computed in updateLayout)
-}
-
-// NewScrollview creates a new Scrollview with the given dimensions.
-func NewScrollview(width, height int) Scrollview {
- return Scrollview{
- Model: viewport.New(viewport.WithWidth(width), viewport.WithHeight(height)),
- showBar: true,
- totalWidth: width,
- totalHeight: height,
- }
-}
-
-// SetContent sets the viewport content and preserves scroll position.
-func (v *Scrollview) SetContent(content string) {
- v.content = content
-
- // Cache split lines and max width
- if content == "" {
- v.lines = nil
- v.maxWidth = 0
- } else {
- v.lines = strings.Split(content, "\n")
- v.maxWidth = 0
- for _, line := range v.lines {
- v.maxWidth = max(v.maxWidth, lipgloss.Width(line))
- }
- }
-
- // Adjust height for horizontal scrollbar BEFORE setting content on Model
- v.updateLayout()
-
- yoff := v.YOffset()
- xoff := v.XOffset()
- v.Model.SetContent(content)
-
- // Preserve vertical scroll position (clamped)
- ymax := max(0, v.TotalLineCount()-v.Height())
- v.SetYOffset(min(yoff, ymax))
-
- // Preserve horizontal scroll (clamped)
- v.SetXOffset(min(xoff, v.maxXOffset()))
-}
-
-// updateLayout adjusts embedded viewport dimensions based on scrollbar needs.
-func (v *Scrollview) updateLayout() {
- // Compute scrollbar needs using cached values. The dimension guards (totalHeight/
- // totalWidth > 0) keep degenerate geometry from claiming a scrollbar: when wch's
- // output is piped to a non-TTY the program receives a 0x0 WindowSizeMsg, and the
- // bar reservation in the caller can drive a dimension negative. Without the guard,
- // len(v.lines) > v.totalHeight is trivially true (e.g. 0 > -1) and View renders a
- // vertical bar over zero-line content, dividing by zero in calcScrollbarThumb.
- v.needsVBar = v.showBar && v.totalHeight > 0 && len(v.lines) > v.totalHeight
- v.needsHBar = v.showBar && v.totalWidth > 0 && v.maxWidth > v.totalWidth
-
- // Reserve space for scrollbars
- w := v.totalWidth
- h := v.totalHeight
- if v.needsVBar {
- w-- // reserve 1 column for v-scrollbar
- }
- if v.needsHBar {
- h-- // reserve 1 line for h-scrollbar
- }
- v.SetWidth(w)
- v.SetHeight(h)
-}
-
-// calcScrollbarThumb computes the start position and size of a scrollbar thumb.
-// offset is the current scroll position, visible is the viewport size, total is the content size.
-func calcScrollbarThumb(offset, visible, total int) (start, size int) {
- // max(1, total) guards the division: callers only render a thumb when total > 0, but
- // stay defensive so degenerate geometry can never divide by zero.
- size = max(1, visible*visible/max(1, total))
- start = offset * (visible - size) / max(1, total-visible)
- // Clamp so that start+size never exceeds visible (defensive against stale offset)
- start = min(start, max(0, visible-size))
- return
-}
-
-// View renders the viewport content with scrollbars.
-func (v Scrollview) View() string {
- content := v.Model.View()
-
- if !v.showBar {
- return content
- }
-
- lines := strings.Split(content, "\n")
- totalLines := v.TotalLineCount()
- visibleLines := len(lines)
-
- // Add vertical scrollbar to each line (must append char-by-char to existing lines)
- if v.needsVBar {
- vThumbStart, vThumbSize := calcScrollbarThumb(v.YOffset(), visibleLines, totalLines)
-
- vTrack := trackStyle.Render(vTrackChar)
- vThumb := thumbStyle.Render(vThumbChar)
-
- for i := range lines {
- if i >= vThumbStart && i < vThumbStart+vThumbSize {
- lines[i] += vThumb
- } else {
- lines[i] += vTrack
- }
- }
- }
-
- // Add horizontal scrollbar at bottom (can batch-style whole segments)
- if v.needsHBar {
- hThumbStart, hThumbSize := calcScrollbarThumb(v.XOffset(), v.Width(), v.maxWidth)
-
- hTrackBefore := strings.Repeat(hTrackChar, hThumbStart)
- hThumb := strings.Repeat(hThumbChar, hThumbSize)
- hTrackAfter := strings.Repeat(hTrackChar, v.Width()-hThumbStart-hThumbSize)
-
- hBar := lipgloss.JoinHorizontal(lipgloss.Top,
- trackStyle.Render(hTrackBefore),
- thumbStyle.Render(hThumb),
- trackStyle.Render(hTrackAfter),
- )
-
- // When both scrollbars present, leave corner empty; otherwise hbar takes full width
- if v.needsVBar {
- hBar += " "
- }
-
- lines = append(lines, hBar)
- }
-
- return lipgloss.JoinVertical(lipgloss.Left, lines...)
-}
-
-// maxXOffset returns the maximum horizontal scroll offset.
-func (v *Scrollview) maxXOffset() int {
- return max(0, v.maxWidth-v.Width())
-}
-
-// ScrollLeft scrolls the viewport left by one column.
-func (v *Scrollview) ScrollLeft() {
- v.SetXOffset(max(0, v.XOffset()-1))
-}
-
-// ScrollRight scrolls the viewport right by one column.
-func (v *Scrollview) ScrollRight() {
- v.SetXOffset(min(v.XOffset()+1, v.maxXOffset()))
-}
-
-// ScrollLeftPage scrolls the viewport left by one page width.
-func (v *Scrollview) ScrollLeftPage() {
- v.SetXOffset(max(0, v.XOffset()-v.Width()))
-}
-
-// ScrollRightPage scrolls the viewport right by one page width.
-func (v *Scrollview) ScrollRightPage() {
- v.SetXOffset(min(v.XOffset()+v.Width(), v.maxXOffset()))
-}
-
-// GotoLeftEdge scrolls to the left edge of content.
-func (v *Scrollview) GotoLeftEdge() {
- v.SetXOffset(0)
-}
-
-// GotoRightEdge scrolls to the right edge of content.
-func (v *Scrollview) GotoRightEdge() {
- v.SetXOffset(v.maxXOffset())
-}
-
-// SetSize updates the viewport dimensions.
-func (v *Scrollview) SetSize(width, height int) {
- v.totalWidth = width
- v.totalHeight = height
- v.updateLayout()
- // Clamp horizontal scroll to new valid range
- v.SetXOffset(min(v.XOffset(), v.maxXOffset()))
-}
-
-// SetShowScrollbar enables or disables the scrollbar.
-func (v *Scrollview) SetShowScrollbar(show bool) {
- v.showBar = show
- v.updateLayout()
-}
-
-// NeedsVerticalScrollbar reports whether a vertical scrollbar is currently being rendered
-// (the scrollbar is enabled and content overflows the visible height).
-func (v Scrollview) NeedsVerticalScrollbar() bool { return v.needsVBar }
-
-// NeedsHorizontalScrollbar reports the equivalent for the horizontal scrollbar.
-func (v Scrollview) NeedsHorizontalScrollbar() bool { return v.needsHBar }
-
-// EnsureLineVisible scrolls vertically by the minimum amount required to put line within the
-// visible window: nothing if it's already on screen, snap to the top if it's above, snap
-// to the bottom otherwise. Returns the resulting YOffset.
-func (v *Scrollview) EnsureLineVisible(line int) int {
- y := v.YOffset()
- h := v.Height()
- switch {
- case line < y:
- v.SetYOffset(line)
- case h > 0 && line >= y+h:
- v.SetYOffset(line - h + 1)
- }
- return v.YOffset()
-}
-
-// EnsureColumnVisible scrolls horizontally so the range [col, col+length) is fully inside
-// the viewport. If the range is already visible, no change. Otherwise snap the left or right
-// edge of the range to the viewport boundary, preferring to keep the left edge in view when
-// the range is wider than the viewport.
-func (v *Scrollview) EnsureColumnVisible(col, length int) int {
- length = max(length, 1)
- x := v.XOffset()
- w := v.Width()
- if w <= 0 {
- return x
- }
- switch {
- case col < x:
- v.SetXOffset(col)
- case col+length > x+w:
- // Range right edge is past the viewport. If the range fits, scroll just enough to
- // fit it; otherwise prefer the left edge in view.
- if length <= w {
- v.SetXOffset(col + length - w)
- } else {
- v.SetXOffset(col)
- }
- }
- return v.XOffset()
-}
diff --git a/internal/tui/scrollview/scrollview_test.go b/internal/tui/scrollview/scrollview_test.go
deleted file mode 100644
index 53d6af0..0000000
--- a/internal/tui/scrollview/scrollview_test.go
+++ /dev/null
@@ -1,171 +0,0 @@
-package scrollview
-
-import (
- "strings"
- "testing"
-)
-
-func TestViewNoPanicOnResizeAfterScroll(t *testing.T) {
- // Simulate: wide content, scroll right, then shrink terminal width.
- // This triggers a negative Repeat count in View() because the scrollbar
- // thumb position calculated from the stale XOffset exceeds the new width.
- sv := NewScrollview(80, 10)
-
- // Content wider than viewport to enable horizontal scrollbar
- wide := strings.Repeat("x", 200)
- lines := make([]string, 20)
- for i := range lines {
- lines[i] = wide
- }
- sv.SetContent(strings.Join(lines, "\n"))
-
- // Scroll far right
- for range 100 {
- sv.ScrollRight()
- }
-
- // Shrink viewport (simulates narrowing terminal window)
- sv.SetSize(20, 10)
-
- // Scroll far right while narrow (large maxXOffset when viewport is small)
- for range 200 {
- sv.ScrollRight()
- }
-
- // Expand viewport back (simulates widening terminal window).
- // Now XOffset from the narrow state exceeds the valid range for the wider viewport,
- // causing calcScrollbarThumb to produce start+size > Width.
- sv.SetSize(150, 10)
-
- // View() should not panic
- sv.View()
-}
-
-// TestViewNoPanicOnDegenerateSize reproduces issue #14: piping wch output to a non-TTY
-// (e.g. `wch date | cat`) delivers a 0x0 WindowSizeMsg, and the caller's bar-space
-// reservation can drive a dimension negative. A negative totalHeight made the
-// len(lines) > totalHeight overflow check trivially true even for empty content, so
-// View() asked calcScrollbarThumb to size a thumb over zero total lines and divided by
-// zero. No scrollbar should be claimed and View() must not panic.
-func TestViewNoPanicOnDegenerateSize(t *testing.T) {
- sv := NewScrollview(0, 0)
- sv.SetSize(0, -1) // mirrors withResizedScrollview's h-- on a 0-height window
-
- // Empty content: no lines to scroll, so neither bar should be needed.
- sv.SetContent("")
- if sv.NeedsVerticalScrollbar() || sv.NeedsHorizontalScrollbar() {
- t.Errorf("degenerate size, empty content: NeedsV=%v NeedsH=%v, want false/false",
- sv.NeedsVerticalScrollbar(), sv.NeedsHorizontalScrollbar())
- }
- sv.View() // must not panic
-
- // Real content under degenerate geometry must also stay panic-free.
- sv.SetContent(strings.Repeat("line\n", 50))
- sv.View()
-}
-
-// NeedsVerticalScrollbar / NeedsHorizontalScrollbar reflect whether each bar is currently
-// rendered: they require both showBar=true and the content overflowing the corresponding
-// axis. Disabling the scrollbar drops both to false.
-func TestNeedsScrollbars(t *testing.T) {
- sv := NewScrollview(20, 5)
- sv.SetShowScrollbar(true)
-
- // Empty / no overflow → neither bar.
- sv.SetContent("hi")
- if sv.NeedsVerticalScrollbar() || sv.NeedsHorizontalScrollbar() {
- t.Errorf("empty content: NeedsV=%v NeedsH=%v, want false/false",
- sv.NeedsVerticalScrollbar(), sv.NeedsHorizontalScrollbar())
- }
-
- // Tall content → vertical bar; one short line → no horizontal bar.
- tall := strings.Repeat("hi\n", 50)
- sv.SetContent(tall)
- if !sv.NeedsVerticalScrollbar() {
- t.Errorf("tall content: NeedsVerticalScrollbar() = false, want true")
- }
- if sv.NeedsHorizontalScrollbar() {
- t.Errorf("tall (but narrow) content: NeedsHorizontalScrollbar() = true, want false")
- }
-
- // Wide content → horizontal bar.
- wide := strings.Repeat("x", 200)
- sv.SetContent(wide)
- if !sv.NeedsHorizontalScrollbar() {
- t.Errorf("wide content: NeedsHorizontalScrollbar() = false, want true")
- }
-
- // Both tall and wide → both bars.
- bothLines := make([]string, 50)
- for i := range bothLines {
- bothLines[i] = wide
- }
- sv.SetContent(strings.Join(bothLines, "\n"))
- if !sv.NeedsVerticalScrollbar() || !sv.NeedsHorizontalScrollbar() {
- t.Errorf("tall+wide content: NeedsV=%v NeedsH=%v, want true/true",
- sv.NeedsVerticalScrollbar(), sv.NeedsHorizontalScrollbar())
- }
-
- // Disable the scrollbar entirely → both report false even with overflow.
- sv.SetShowScrollbar(false)
- if sv.NeedsVerticalScrollbar() || sv.NeedsHorizontalScrollbar() {
- t.Errorf("scrollbar disabled: NeedsV=%v NeedsH=%v, want false/false",
- sv.NeedsVerticalScrollbar(), sv.NeedsHorizontalScrollbar())
- }
-}
-
-// EnsureLineVisible scrolls only when the line is outside the viewport: above → snap to top,
-// below → snap to bottom, already inside → no-op.
-func TestEnsureLineVisible(t *testing.T) {
- sv := NewScrollview(20, 5)
- sv.SetContent(strings.Repeat("x\n", 50))
-
- sv.SetYOffset(10)
-
- // Already visible (within [10, 14]).
- if got := sv.EnsureLineVisible(12); got != 10 {
- t.Errorf("already-visible: YOffset=%d want 10", got)
- }
-
- // Above viewport → snap line to top.
- if got := sv.EnsureLineVisible(3); got != 3 {
- t.Errorf("above: YOffset=%d want 3", got)
- }
-
- // Below viewport.
- sv.SetYOffset(10)
- if got := sv.EnsureLineVisible(40); got != 36 { // 40 - 5 + 1
- t.Errorf("below: YOffset=%d want 36", got)
- }
-}
-
-// EnsureColumnVisible mirrors EnsureLineVisible on the horizontal axis. When the range is
-// wider than the viewport, prefer the left edge to keep the start of the match in view.
-func TestEnsureColumnVisible(t *testing.T) {
- sv := NewScrollview(10, 5)
- sv.SetContent(strings.Repeat("x", 100))
-
- sv.SetXOffset(20)
-
- // Already visible: col 25 with length 3 fits inside [20, 30).
- if got := sv.EnsureColumnVisible(25, 3); got != 20 {
- t.Errorf("already-visible: XOffset=%d want 20", got)
- }
-
- // Left of viewport.
- if got := sv.EnsureColumnVisible(5, 3); got != 5 {
- t.Errorf("left: XOffset=%d want 5", got)
- }
-
- // Right of viewport: range fits → just enough to show it.
- sv.SetXOffset(20)
- if got := sv.EnsureColumnVisible(50, 3); got != 43 { // 50 + 3 - 10
- t.Errorf("right (fits): XOffset=%d want 43", got)
- }
-
- // Right of viewport, range wider than viewport → keep left edge in view.
- sv.SetXOffset(20)
- if got := sv.EnsureColumnVisible(50, 20); got != 50 {
- t.Errorf("right (oversize): XOffset=%d want 50", got)
- }
-}
diff --git a/internal/tui/search.go b/internal/tui/search.go
deleted file mode 100644
index 1ad0da6..0000000
--- a/internal/tui/search.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package tui
-
-import (
- "strings"
- "unicode"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-// searchMatch is one occurrence of a query in the searched snapshot, expressed in display-
-// width cell coordinates (not byte offsets and not rune indices) so it lines up with the
-// cellbuf overlay grid used by searchrender.
-type searchMatch struct {
- line int // 0-based line index in the stripped body
- col int // 0-based display-width column at which the match starts
- length int // display width of the match
-}
-
-// findMatches walks every line of stripped body and collects every occurrence of query,
-// using smart-case semantics: case-insensitive when query is all-lowercase, exact when query
-// has any uppercase rune. unicode.IsUpper covers non-ASCII alphabets (Ü, Ç, Ñ, …) — limiting
-// the check to A-Z would silently fold queries like "Über" or "Lösung" to case-insensitive.
-// Returns matches in document order; an empty result means "no match" and the caller must NOT
-// enter searchMode (notification is shown instead).
-func findMatches(stripped, query string) []searchMatch {
- if query == "" {
- return nil
- }
- haystack := stripped
- needle := query
- if !strings.ContainsFunc(query, unicode.IsUpper) {
- haystack = strings.ToLower(stripped)
- needle = strings.ToLower(query)
- }
- queryWidth := ansi.StringWidth(query)
- var matches []searchMatch
- for lineIdx, line := range strings.Split(haystack, "\n") {
- off := 0 // byte cursor into line
- offWidth := 0 // display width consumed up to off (incremental — saves O(N) per match)
- for {
- i := strings.Index(line[off:], needle)
- if i < 0 {
- break
- }
- byteStart := off + i
- // Add the width of the gap between the previous off and byteStart, not the
- // whole line[:byteStart]. Avoids the O(matches * lineLen) hot path.
- offWidth += ansi.StringWidth(line[off:byteStart])
- matches = append(matches, searchMatch{
- line: lineIdx,
- col: offWidth,
- length: queryWidth,
- })
- offWidth += queryWidth
- off = byteStart + len(needle)
- if off >= len(line) {
- break
- }
- }
- }
- return matches
-}
diff --git a/internal/tui/search_test.go b/internal/tui/search_test.go
deleted file mode 100644
index c3c518b..0000000
--- a/internal/tui/search_test.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package tui
-
-import (
- "reflect"
- "strings"
- "testing"
-)
-
-func TestFindMatchesSmartCase(t *testing.T) {
- body := "pod-01 Running\npod-02 Pending\npod-03 PENDING\npod-04 pending\n"
-
- // All-lowercase query → case-insensitive, picks up Pending/PENDING/pending.
- got := findMatches(body, "pending")
- want := []searchMatch{
- {line: 1, col: 7, length: 7},
- {line: 2, col: 7, length: 7},
- {line: 3, col: 7, length: 7},
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("smart-case lowercase: got %v want %v", got, want)
- }
-
- // Mixed-case query → exact match only.
- got = findMatches(body, "Pending")
- want = []searchMatch{{line: 1, col: 7, length: 7}}
- if !reflect.DeepEqual(got, want) {
- t.Errorf("smart-case exact: got %v want %v", got, want)
- }
-}
-
-func TestFindMatchesMultiplePerLine(t *testing.T) {
- got := findMatches("foo bar foo bar foo", "foo")
- want := []searchMatch{
- {line: 0, col: 0, length: 3},
- {line: 0, col: 8, length: 3},
- {line: 0, col: 16, length: 3},
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("got %v want %v", got, want)
- }
-}
-
-func TestFindMatchesEmptyAndNoMatch(t *testing.T) {
- if findMatches("anything", "") != nil {
- t.Errorf("empty query must yield nil matches")
- }
- if findMatches("alpha\nbeta\n", "gamma") != nil {
- t.Errorf("no-occurrence query must yield nil matches")
- }
- if findMatches("", "foo") != nil {
- t.Errorf("empty body must yield nil matches")
- }
-}
-
-func TestFindMatchesWideRunes(t *testing.T) {
- // Each CJK rune is 2 display cells. "緑Running" -> col of "Running" is 2 (after one wide rune).
- body := "緑Running\nRunning"
- got := findMatches(body, "Running")
- want := []searchMatch{
- {line: 0, col: 2, length: 7},
- {line: 1, col: 0, length: 7},
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("wide rune cols: got %v want %v", got, want)
- }
-}
-
-func TestFindMatchesAcrossLines(t *testing.T) {
- body := strings.Repeat("alpha\n", 3) + "alphabeta"
- got := findMatches(body, "alpha")
- want := []searchMatch{
- {line: 0, col: 0, length: 5},
- {line: 1, col: 0, length: 5},
- {line: 2, col: 0, length: 5},
- {line: 3, col: 0, length: 5},
- }
- if !reflect.DeepEqual(got, want) {
- t.Errorf("got %v want %v", got, want)
- }
-}
diff --git a/internal/tui/searchrender/searchrender.go b/internal/tui/searchrender/searchrender.go
deleted file mode 100644
index bf30d5f..0000000
--- a/internal/tui/searchrender/searchrender.go
+++ /dev/null
@@ -1,37 +0,0 @@
-// Package searchrender draws the in-snapshot search overlay: a single reverse-video span on
-// the currently selected match. It is the optional terminal renderer companion to the
-// search-matching logic in internal/tui, kept separate so cellbuf-overlay knowledge stays
-// local to one package. Mirrors the architecture of internal/tui/diffrender.
-package searchrender
-
-import (
- "strings"
-
- "github.com/charmbracelet/x/cellbuf"
-
- "github.com/ivoronin/wch/internal/tui/overlay"
-)
-
-// Render overlays a reverse-video highlight onto the cells corresponding to the selected
-// match (line, col, length in display-width cells). selLine/selCol/selLength describe the
-// match in display-width coordinates that line up with cellbuf's grid. The body's existing
-// styling (kubectl colors + any diff highlights baked in upstream) is preserved; only the
-// reverse attribute (SGR 7) is added on the matched cells, so the terminal swaps the cell's
-// own fg/bg without us picking a theme-specific colour. If selLength is 0, body is returned
-// unchanged.
-func Render(body string, selLine, selCol, selLength int) string {
- if selLength <= 0 {
- return body
- }
- w := overlay.MaxDisplayWidth(body)
- h := strings.Count(body, "\n") + 1
- return overlay.Walk(body, w, h, func(buf *cellbuf.Buffer) {
- for x := selCol; x < selCol+selLength && x < buf.Width(); x++ {
- c := buf.Cell(x, selLine)
- if c == nil {
- continue
- }
- c.Style.Reverse(true)
- }
- })
-}
diff --git a/internal/tui/searchrender/searchrender_test.go b/internal/tui/searchrender/searchrender_test.go
deleted file mode 100644
index c0b6c15..0000000
--- a/internal/tui/searchrender/searchrender_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package searchrender
-
-import (
- "strings"
- "testing"
-
- "github.com/charmbracelet/x/ansi"
-)
-
-// Render applies the SGR 7 reverse attribute to the selected span. After stripping ANSI we
-// should see the same visible text; the ANSI sequences should include a SGR 7 enter and a
-// SGR 27 leave (or a Reset that turns it off) around the match cells.
-func TestRenderAppliesReverseToSelection(t *testing.T) {
- body := "alpha bravo charlie\nfoo bar baz"
- got := Render(body, 0, 6, 5) // "bravo"
-
- if ansi.Strip(got) != body {
- t.Errorf("stripped output changed:\nbefore: %q\nafter: %q", body, ansi.Strip(got))
- }
- if !strings.Contains(got, "\x1b[7m") {
- t.Errorf("expected SGR 7 (reverse) in output; got %q", got)
- }
-}
-
-// A zero-length selection short-circuits and returns the body untouched.
-func TestRenderEmptySelectionPasses(t *testing.T) {
- body := "alpha bravo"
- got := Render(body, 0, 0, 0)
- if got != body {
- t.Errorf("empty selection should pass through; got %q want %q", got, body)
- }
-}
-
-// Body wider × taller than overlayCellCap returns body unchanged (degrades gracefully).
-func TestRenderTooLargeReturnsBody(t *testing.T) {
- // 2001 cells × 2001 rows ≈ 4M, just over the cap.
- wide := strings.Repeat("x", 2001)
- rows := make([]string, 2001)
- for i := range rows {
- rows[i] = wide
- }
- body := strings.Join(rows, "\n")
- got := Render(body, 0, 0, 5)
- if got != body {
- t.Errorf("oversized body should pass through unchanged")
- }
-}
diff --git a/internal/tui/state.go b/internal/tui/state.go
deleted file mode 100644
index b53ad6e..0000000
--- a/internal/tui/state.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package tui
-
-import (
- "time"
-
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
-)
-
-// viewPolicy is the display-side contract of a state: what to render in the viewport
-// (Body), what timestamp to show in the bar's center clock (Timestamp), whether the bar
-// shows unconditionally (ShowsBar), whether new executions should repaint (IsFrozen),
-// how historyIndex advances (FollowsTail), and what to put in the bar (RenderBar).
-// Display concerns never reach Handle. Body and Timestamp's (_, bool) shapes let a
-// state opt out (e.g. viewState before the first frame, searchState with zero matches)
-// without the orchestrator inventing a sentinel value.
-type viewPolicy interface {
- Body(Model) (string, bool)
- Timestamp(Model) (time.Time, bool)
- ShowsBar() bool
- IsFrozen() bool
- FollowsTail(wasAtTail bool) bool
- RenderBar(Model) string
-}
-
-// inputHandler is the input-side contract of a state: a key press becomes a new model,
-// the next state, and a side-effect cmd. handled gates the global-key fallback so an
-// active overlay (input prompt, search) can consume printable chars before the global
-// q/t bindings see them.
-type inputHandler interface {
- Handle(Model, tea.KeyPressMsg) (Model, state, tea.Cmd, bool)
-}
-
-// state is the sealed sum type of UI modes. Exactly one struct below inhabits Model.state
-// at any time; the isState marker keeps unrelated types out of the union. Display and
-// input live behind the viewPolicy and inputHandler interfaces so a future reader can
-// see the two concerns separately without scanning a 6-method blob.
-type state interface {
- isState()
- viewPolicy
- inputHandler
-}
-
-// viewState is the default: the viewport shows the frame at Model.historyIndex, with diff
-// highlights when Model.prefs.Diff is set. All other display fields are read directly off Model.
-type viewState struct{}
-
-// pickerState is the history-timeline picker. The cursor is Model.historyIndex; cursor
-// movement reassigns it.
-type pickerState struct{}
-
-// inputState is a single-line prompt overlaid on top of a host state (prev). Used by both
-// the record-filename and search-query flows; submit decides what to do with the typed
-// value. inputState is transparent: Body/FollowsTail/IsFrozen all delegate to prev. The
-// input prompt itself lives in the bar.
-type inputState struct {
- input textinput.Model
- prev state
- submit func(Model, inputState) (Model, state, tea.Cmd)
-}
-
-// searchState shows a frozen snapshot with the selected match highlighted. body is captured
-// once on entry; matches are computed once. follow remembers whether the user was at the
-// live tail on entry so historyIndex can advance in the background — Esc then drops into
-// the underlying viewState already at the new tail without a second keystroke.
-type searchState struct {
- query string
- body string
- matches []searchMatch
- selected int
- follow bool
- captured time.Time
- prev state
-}
-
-func (viewState) isState() {}
-func (pickerState) isState() {}
-func (inputState) isState() {}
-func (searchState) isState() {}
diff --git a/internal/tui/state_common.go b/internal/tui/state_common.go
deleted file mode 100644
index a1a82fe..0000000
--- a/internal/tui/state_common.go
+++ /dev/null
@@ -1,32 +0,0 @@
-// Package tui — handlers shared across multiple state types live here so they
-// don't get filed under any single state's _.go file.
-package tui
-
-import (
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
-)
-
-// handleCommonKey handles the diff/pause/record/search bindings shared by
-// viewState and pickerState. Returns handled=false if msg matches none of
-// them. Lives here (not in either state's file) because both states call it
-// and neither owns the shape.
-func (m Model) handleCommonKey(s state, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) {
- switch {
- case key.Matches(msg, commonKeys.ToggleDiff):
- // Same frame → anchor would be identity. Skip the diff.Align dance and re-render
- // directly; scroll position stays put because viewport's offset is not touched.
- m.prefs.Diff = !m.prefs.Diff
- return m.repaint(), s, nil, true
- case key.Matches(msg, commonKeys.Pause):
- m.prefs.Paused = !m.prefs.Paused
- return m, s, nil, true
- case key.Matches(msg, commonKeys.Record):
- m2, st, cmd := m.toggleRecord(s)
- return m2, st, cmd, true
- case key.Matches(msg, commonKeys.Search):
- m2, st, cmd := m.openSearchInput(s)
- return m2, st, cmd, true
- }
- return m, s, nil, false
-}
diff --git a/internal/tui/state_input.go b/internal/tui/state_input.go
deleted file mode 100644
index 9cc80ad..0000000
--- a/internal/tui/state_input.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package tui
-
-import (
- "strings"
- "time"
-
- "charm.land/bubbles/v2/key"
- "charm.land/bubbles/v2/textinput"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-
- "github.com/ivoronin/wch/internal/tui/notify"
-)
-
-// inputBarStyles is the default textinput palette for any feature that wants its prompt to
-// live inside the bottom status bar: every sub-element carries the bar's fg/bg so a
-// long-value scroll-overflow doesn't bleed unstyled cells. Placeholder and suggestion inherit
-// the bar plate and dim via Faint. The cursor is an explicit Cyan - visible against both
-// light and dark bar plates.
-var inputBarStyles = func() textinput.Styles {
- on := barInnerStyle
- dim := barInnerStyle.Faint(true)
- state := textinput.StyleState{
- Text: on,
- Prompt: on,
- Placeholder: dim,
- Suggestion: dim,
- }
- s := textinput.DefaultDarkStyles()
- s.Focused = state
- s.Blurred = state
- s.Cursor.Color = ansi.Cyan
- return s
-}()
-
-// inputKeys gates the universal input bindings (submit/cancel) shown in every input flow.
-var inputKeys = struct {
- Submit key.Binding
- Cancel key.Binding
-}{
- Submit: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "save")),
- Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
-}
-
-// Body for inputState delegates to its host: the input prompt lives in the bar; the
-// viewport content belongs to whatever state input is sitting on top of.
-func (s inputState) Body(m Model) (string, bool) {
- return s.prev.Body(m)
-}
-
-// Timestamp delegates to the host: input is transparent for bar-clock concerns.
-func (s inputState) Timestamp(m Model) (time.Time, bool) {
- return s.prev.Timestamp(m)
-}
-
-// ShowsBar returns true because the input prompt lives in the bar; hiding it
-// would leave the user typing into nothing. The decision does not depend on
-// prev — input always wants its own bar.
-func (inputState) ShowsBar() bool { return true }
-
-// IsFrozen delegates to prev: inputState is transparent for viewport-derived
-// concerns, so freezing semantics belong to the host.
-func (s inputState) IsFrozen() bool { return s.prev.IsFrozen() }
-
-// FollowsTail delegates to prev for the same reason: tail-follow policy
-// belongs to the host state, not the input prompt overlaid on top.
-func (s inputState) FollowsTail(wasAtTail bool) bool {
- return s.prev.FollowsTail(wasAtTail)
-}
-
-// RenderBar renders the input widget across the full bar width, leaving room
-// for the prompt and the bar's own horizontal padding so long values scroll
-// inside the field instead of pushing the prompt off the left edge.
-func (s inputState) RenderBar(m Model) string {
- w := m.width - statusBarStyle.GetHorizontalFrameSize() - lipgloss.Width(s.input.Prompt)
- if w > 0 {
- s.input.SetWidth(w)
- }
- return statusBarStyle.Width(m.width).Render(s.input.View())
-}
-
-// Handle routes a key in inputState. Submit calls the configured submit
-// function directly (no message hop). Cancel pops back to prev. Every other
-// key is forwarded to the textinput; handled=true on every key keeps globals
-// (q/t) from stealing printable chars.
-func (s inputState) Handle(m Model, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) {
- switch {
- case key.Matches(msg, inputKeys.Submit):
- m2, st, cmd := s.submit(m, s)
- return m2, st, cmd, true
- case key.Matches(msg, inputKeys.Cancel):
- return m, s.prev, nil, true
- }
- in, cmd := s.input.Update(msg)
- s.input = in
- return m, s, cmd, true
-}
-
-// openInput is the shared constructor for the two input flows: builds an inputState wrapping
-// the supplied textinput + submit callback on top of prev, and returns the textinput's focus
-// Cmd. Order matters: textinput.Focus has a pointer receiver, so it must run BEFORE the
-// inputState is built — otherwise the struct captures an unfocused snapshot of `in` and
-// textinput.Update drops every keystroke.
-func (m Model) openInput(prev state, in textinput.Model, submit func(Model, inputState) (Model, state, tea.Cmd)) (Model, state, tea.Cmd) {
- focusCmd := in.Focus()
- return m, inputState{input: in, prev: prev, submit: submit}, focusCmd
-}
-
-// openSearchInput builds the inputState for a search prompt. If we are already inside an
-// active search (the user pressed '/' to start over), input.prev skips the abandoned search
-// so Esc from the replacement returns to the pre-search predecessor.
-func (m Model) openSearchInput(from state) (Model, state, tea.Cmd) {
- prev := from
- if s, ok := from.(searchState); ok {
- prev = s.prev
- }
- return m.openInput(prev, newBarInput(searchPromptLabel), applySearchSubmit)
-}
-
-// applySearchSubmit decides what to do with the typed query: empty → silent pop; no matches
-// → notification + pop; otherwise → enter a fresh searchState with the captured body and
-// matches, applying the selection overlay + scroll-to-match.
-func applySearchSubmit(m Model, s inputState) (Model, state, tea.Cmd) {
- q := strings.TrimSpace(s.input.Value())
- if q == "" {
- return m, s.prev, nil
- }
- // Capture the underlying state's body. After openSearchInput's prev-peel,
- // s.prev is always view/picker — never searchState — so the body is just
- // the current frame. Frame returns "" for an invalid index, so the no-cursor
- // case (no history yet) flows into the "no matches" branch below without a
- // separate guard.
- i, ok := m.cursor.At()
- body := m.frames.Frame(i, m.prefs.Diff)
- matches := findMatches(ansi.Strip(body), q)
- if len(matches) == 0 {
- var cmd tea.Cmd
- m, cmd = m.push(notify.LevelInfo, "no matches")
- return m, s.prev, cmd
- }
- next := searchState{
- query: q,
- body: body,
- matches: matches,
- selected: 0,
- follow: m.isFollowing(),
- prev: s.prev,
- }
- if ok {
- next.captured = m.session.History[i].Timestamp
- }
- m = m.repaintWith(next, next.snap())
- return m, next, nil
-}
-
-// newBarInput constructs a textinput pre-styled to live in the bottom bar.
-func newBarInput(prompt string) textinput.Model {
- in := textinput.New()
- in.Prompt = prompt
- in.SetStyles(inputBarStyles)
- return in
-}
diff --git a/internal/tui/state_input_test.go b/internal/tui/state_input_test.go
deleted file mode 100644
index 2949164..0000000
--- a/internal/tui/state_input_test.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package tui
-
-import (
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
-)
-
-// In inputState, a bracketed-paste msg should land in the textinput's value. Update must
-// forward tea.PasteMsg to the active state — otherwise terminal pastes silently vanish.
-func TestInputStateReceivesPasteMsg(t *testing.T) {
- m := New(Config{Command: "kubectl", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24})
- m = pressKey(t, m, 'r')
- in, ok := m.state.(inputState)
- if !ok {
- t.Fatalf("setup: state = %T, want inputState", m.state)
- }
- in.input.SetValue("")
- m.state = in
-
- m = feed(t, m, tea.PasteMsg{Content: "hello"})
-
- got := m.state.(inputState).input.Value()
- if !strings.Contains(got, "hello") {
- t.Errorf("input value after paste = %q, want it to contain \"hello\"", got)
- }
-}
-
-// TestInputStateBodyDelegatesToPrev: inputState is transparent; its Body returns
-// exactly what prev.Body returns.
-func TestInputStateBodyDelegatesToPrev(t *testing.T) {
- m := makePaintModel(t)
- prev := viewState{}
- in := inputState{prev: prev}
-
- prevBody, prevOk := prev.Body(m)
- gotBody, gotOk := in.Body(m)
- if gotOk != prevOk {
- t.Errorf("inputState.Body ok=%v, want %v", gotOk, prevOk)
- }
- if gotBody != prevBody {
- t.Errorf("inputState.Body did not delegate to prev")
- }
-}
-
-func TestInputStateShowsBar(t *testing.T) {
- cases := []struct {
- name string
- prev state
- }{
- {"on view", viewState{}},
- {"on picker", pickerState{}},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- s := inputState{prev: c.prev}
- if !s.ShowsBar() {
- t.Errorf("inputState.ShowsBar() = false, want true")
- }
- })
- }
-}
-
-func TestInputStateIsFrozenDelegatesToPrev(t *testing.T) {
- cases := []struct {
- name string
- prev state
- want bool
- }{
- {"on view", viewState{}, false},
- {"on picker", pickerState{}, false},
- {"on search", searchState{prev: viewState{}}, true},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- if got := (inputState{prev: c.prev}).IsFrozen(); got != c.want {
- t.Errorf("IsFrozen() = %v, want %v", got, c.want)
- }
- })
- }
-}
-
-func TestInputStateFollowsTailDelegatesToPrev(t *testing.T) {
- cases := []struct {
- name string
- prev state
- wasAtTail bool
- want bool
- }{
- {"on view at tail", viewState{}, true, true},
- {"on view not at tail", viewState{}, false, false},
- {"on search inherits search follow", searchState{prev: viewState{}, follow: true}, false, true},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- if got := (inputState{prev: c.prev}).FollowsTail(c.wasAtTail); got != c.want {
- t.Errorf("FollowsTail(%v) = %v, want %v", c.wasAtTail, got, c.want)
- }
- })
- }
-}
-
-// TestInputStateHandleEscPopsToPrev pins that Esc returns to the wrapped prev state.
-func TestInputStateHandleEscPopsToPrev(t *testing.T) {
- m := makePaintModel(t)
- in := inputState{prev: viewState{}, input: newBarInput("/")}
- m.state = in
- _, st, _, handled := in.Handle(m, tea.KeyPressMsg{Code: tea.KeyEsc})
- if !handled {
- t.Fatalf("inputState.Handle(Esc) reported handled=false")
- }
- if _, ok := st.(viewState); !ok {
- t.Errorf("inputState.Handle(Esc) returned %T, want viewState (the prev)", st)
- }
-}
diff --git a/internal/tui/state_picker.go b/internal/tui/state_picker.go
deleted file mode 100644
index ac48c60..0000000
--- a/internal/tui/state_picker.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package tui
-
-import (
- "time"
-
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// timestampLen is the display width of a formatted timestamp.
-var timestampLen = lipgloss.Width(timestampFmt)
-
-// pickerSpacerLeft / pickerSpacerRight are hoisted style values used per item in the picker
-// timeline. Lipgloss styles are immutable so the per-render NewStyle().Padding...() calls
-// were pure waste — these constants do the same work once at startup.
-var (
- pickerSpacerLeft = lipgloss.NewStyle().PaddingRight(itemSpacing)
- pickerSpacerRight = lipgloss.NewStyle().PaddingLeft(itemSpacing)
-)
-
-// Body for pickerState is identical to viewState: the picker timeline lives in the bar,
-// the viewport shows the frame at the cursor position.
-func (pickerState) Body(m Model) (string, bool) {
- i, ok := m.cursor.At()
- return m.frames.Frame(i, m.prefs.Diff), ok
-}
-
-// Timestamp returns the timestamp of the frame at the cursor position -- the picker's
-// cursor and view's cursor are the same field on Model.
-func (pickerState) Timestamp(m Model) (time.Time, bool) {
- i, ok := m.cursor.At()
- if !ok {
- return time.Time{}, false
- }
- return m.session.History[i].Timestamp, true
-}
-
-// ShowsBar returns true because the picker timeline IS the bar — without it
-// the user has no visible cursor or surrounding context for the history
-// position they are navigating.
-func (pickerState) ShowsBar() bool { return true }
-
-// IsFrozen returns false: picker shows the same body as view, and that body
-// must keep tracking live executions while the user browses history.
-func (pickerState) IsFrozen() bool { return false }
-
-// FollowsTail mirrors viewState: the underlying historyIndex advances to the
-// new tail iff the user was at the tail. The picker's own cursor sits on
-// historyIndex by definition.
-func (pickerState) FollowsTail(wasAtTail bool) bool { return wasAtTail }
-
-// RenderBar renders the timeline-style bar replacing the status bar: a left
-// and right column of timestamps surrounding the centered selected timestamp.
-func (pickerState) RenderBar(m Model) string {
- return renderPickerTimeline(m.session.History, m.cursor.Index(), m.width)
-}
-
-// Handle processes a key for pickerState. Common bindings (diff/pause/record/
-// search) are tried first via handleCommonKey; picker-specific bindings
-// (Confirm, cursor movement) come after. ↑/↓ are unhandled here and fall
-// through to global scroll.
-func (s pickerState) Handle(m Model, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) {
- if newM, st, cmd, handled := m.handleCommonKey(s, msg); handled {
- return newM, st, cmd, true
- }
- switch {
- case key.Matches(msg, pickerKeys.Confirm), key.Matches(msg, commonKeys.Escape):
- return m, viewState{}, nil, true
- case key.Matches(msg, navKeys.Left):
- return m.withCursor(m.cursor.Index() - 1), s, nil, true
- case key.Matches(msg, navKeys.Right):
- return m.withCursor(m.cursor.Index() + 1), s, nil, true
- case key.Matches(msg, navKeys.Home), key.Matches(msg, navKeys.ScrollLeft):
- return m.withCursor(0), s, nil, true
- case key.Matches(msg, navKeys.End), key.Matches(msg, navKeys.ScrollRight):
- return m.withCursor(len(m.session.History) - 1), s, nil, true
- }
- return m, s, nil, false
-}
-
-// renderPickerTimeline is the pure-data picker bar renderer: given the history slice, the
-// selected index, and the available width, it builds the horizontal timeline strip.
-func renderPickerTimeline(history []session.Execution, selected, width int) string {
- if len(history) == 0 {
- return statusBarStyle.Width(width).Render("")
- }
-
- itemWidth := timestampLen + itemSpacing
-
- timestamp := history[selected].Timestamp.Format(timestampFmt)
- layout := calcThreeColumnLayout(width, timestampLen)
-
- leftItems, rightItems := pickerItems(history, selected, layout.leftWidth-arrowWidth, layout.rightWidth-arrowWidth, itemWidth)
-
- left := pickerSide(layout.leftWidth, leftItems, selected > len(leftItems), true)
- right := pickerSide(layout.rightWidth, rightItems, selected+len(rightItems)+1 < len(history), false)
-
- center := pickerSelectedStyle.Render(timestamp)
- content := lipgloss.JoinHorizontal(lipgloss.Top, left, center, right)
- return statusBarStyle.Width(width).Render(content)
-}
-
-// pickerItems returns the timestamps that fit in the left/right sections around the selection.
-func pickerItems(history []session.Execution, selected, leftSpace, rightSpace, itemWidth int) (left, right []string) {
- for i := selected - 1; i >= 0 && leftSpace >= itemWidth; i-- {
- left = append(left, history[i].Timestamp.Format(timestampFmt))
- leftSpace -= itemWidth
- }
- for i := selected + 1; i < len(history) && rightSpace >= itemWidth; i++ {
- right = append(right, history[i].Timestamp.Format(timestampFmt))
- rightSpace -= itemWidth
- }
- return left, right
-}
-
-// pickerSide renders one side of the picker timeline (left or right of the selected
-// timestamp). The two sides are mirror images: items run inward toward the centered
-// selection, an arrow appears at the outer edge when more entries exist beyond the visible
-// window. left=true renders the left side (items reversed, spacer pads on the right of each
-// item, arrow at the left, items right-aligned); left=false renders the right side.
-func pickerSide(width int, items []string, more, left bool) string {
- arrow := lipgloss.NewStyle().Width(arrowWidth).Render("")
- if more {
- if left {
- arrow = "◀ "
- } else {
- arrow = " ▶"
- }
- }
-
- spacer := pickerSpacerRight
- if left {
- spacer = pickerSpacerLeft
- }
- spacedItems := make([]string, len(items))
- for i, item := range items {
- idx := i
- if left {
- idx = len(items) - 1 - i
- }
- spacedItems[idx] = spacer.Render(item)
- }
- itemsStr := lipgloss.JoinHorizontal(lipgloss.Top, spacedItems...)
-
- if left {
- content := renderRight(itemsStr, width-arrowWidth)
- return pickerItemStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, arrow, content))
- }
- content := renderLeft(itemsStr, width-arrowWidth)
- return pickerItemStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, content, arrow))
-}
diff --git a/internal/tui/state_picker_test.go b/internal/tui/state_picker_test.go
deleted file mode 100644
index ee5a366..0000000
--- a/internal/tui/state_picker_test.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package tui
-
-import (
- "fmt"
- "reflect"
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// pickerState.Handle maps cursor keys to historyIndex movements and Confirm/Esc to a state
-// return of viewState{}. Unhandled keys (e.g. arbitrary letters) report handled=false so
-// the global fallback can fire.
-func TestHandlePickerKey(t *testing.T) {
- // Need a model with history so withCursor doesn't clamp to -1.
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- for i := 0; i < 5; i++ {
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, i, 0, time.UTC),
- Stdout: fmt.Sprintf("frame %d\n", i),
- }})
- }
-
- cases := []struct {
- name string
- key tea.KeyPressMsg
- wantState string // type name, checked via type switch
- wantIndex int
- }{
- {"left", tea.KeyPressMsg{Code: tea.KeyLeft}, "tui.pickerState", 3},
- {"right (clamped)", tea.KeyPressMsg{Code: tea.KeyRight}, "tui.pickerState", 4},
- {"confirm", tea.KeyPressMsg{Code: tea.KeyEnter}, "tui.viewState", 4},
- {"esc", tea.KeyPressMsg{Code: tea.KeyEsc}, "tui.viewState", 4},
- }
-
- for _, c := range cases {
- // historyIndex starts at 4 (tail).
- mm := m
- mm.state = pickerState{}
- mm.cursor = cursorAt(4)
- newM, s, _, handled := pickerState{}.Handle(mm, c.key)
- if !handled {
- t.Errorf("%s: handled=false, want true", c.name)
- }
- got := reflect.TypeOf(s).String()
- if got != c.wantState {
- t.Errorf("%s: state = %s, want %s", c.name, got, c.wantState)
- }
- if newM.cursor.Index() != c.wantIndex {
- t.Errorf("%s: historyIndex = %d, want %d", c.name, newM.cursor.Index(), c.wantIndex)
- }
- }
-
- // Unhandled key.
- _, _, _, handled := pickerState{}.Handle(m, tea.KeyPressMsg{Code: 'x', Text: "x"})
- if handled {
- t.Errorf("unhandled key reported handled=true")
- }
-}
-
-// An empty history yields a blank, width-sized bar (no panic, no out-of-range access).
-func TestRenderPickerTimelineEmpty(t *testing.T) {
- if got, want := renderPickerTimeline(nil, 0, 40), statusBarStyle.Width(40).Render(""); got != want {
- t.Errorf("renderPickerTimeline(empty)=%q want %q", got, want)
- }
-}
-
-// The bar shows the selected timestamp plus its neighbours and fits the given width.
-func TestRenderPickerTimelineShowsTimestamps(t *testing.T) {
- const width = 80
- base := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
- history := make([]session.Execution, 5)
- for i := range history {
- history[i] = session.Execution{Timestamp: base.Add(time.Duration(i) * time.Second)}
- }
-
- out := renderPickerTimeline(history, 2, width)
-
- if w := lipgloss.Width(out); w != width {
- t.Errorf("rendered width=%d want %d", w, width)
- }
- plain := ansi.Strip(out)
- for i, exec := range history {
- ts := exec.Timestamp.Format(timestampFmt)
- if !strings.Contains(plain, ts) {
- t.Errorf("bar missing timestamp %d (%q): %q", i, ts, plain)
- }
- }
-}
-
-// TestPickerStateBodyReturnsFrame: picker renders the same body as view -- the
-// timeline lives in the bar, not the viewport.
-func TestPickerStateBodyReturnsFrame(t *testing.T) {
- m := makePaintModel(t)
- m.state = pickerState{}
-
- body, ok := (pickerState{}).Body(m)
- if !ok {
- t.Fatalf("pickerState.Body: ok=false with history present")
- }
- if want := m.frames.Frame(m.cursor.Index(), m.prefs.Diff); body != want {
- t.Errorf("pickerState.Body returned different body than frames.Frame")
- }
-}
-
-func TestPickerStateShowsBar(t *testing.T) {
- if got := (pickerState{}).ShowsBar(); got != true {
- t.Errorf("pickerState.ShowsBar() = %v, want true", got)
- }
-}
-
-func TestPickerStateIsFrozen(t *testing.T) {
- if got := (pickerState{}).IsFrozen(); got != false {
- t.Errorf("pickerState.IsFrozen() = %v, want false", got)
- }
-}
-
-func TestPickerStateFollowsTail(t *testing.T) {
- cases := []struct {
- name string
- wasAtTail bool
- want bool
- }{
- {"at tail", true, true},
- {"not at tail", false, false},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- if got := (pickerState{}).FollowsTail(c.wasAtTail); got != c.want {
- t.Errorf("FollowsTail(%v) = %v, want %v", c.wasAtTail, got, c.want)
- }
- })
- }
-}
-
-// TestPickerStateHandleEscReturnsToView pins Esc in pickerState returns to viewState.
-func TestPickerStateHandleEscReturnsToView(t *testing.T) {
- m := makePaintModel(t)
- m.state = pickerState{}
- _, st, _, handled := pickerState{}.Handle(m, tea.KeyPressMsg{Code: tea.KeyEsc})
- if !handled {
- t.Fatalf("pickerState.Handle(Esc) reported handled=false")
- }
- if _, ok := st.(viewState); !ok {
- t.Errorf("pickerState.Handle(Esc) returned %T, want viewState", st)
- }
-}
diff --git a/internal/tui/state_search.go b/internal/tui/state_search.go
deleted file mode 100644
index 92dddbe..0000000
--- a/internal/tui/state_search.go
+++ /dev/null
@@ -1,131 +0,0 @@
-package tui
-
-import (
- "fmt"
- "time"
-
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
-
- "github.com/ivoronin/wch/internal/tui/searchrender"
-)
-
-// searchPromptLabel is what appears in front of the editable search query in inputState
-// when opened via the search flow.
-const searchPromptLabel = "/"
-
-// Body for searchState returns the captured body with the selected match highlighted.
-// It deliberately does NOT scroll the viewport -- that would let a bar toggle ('t') or a
-// terminal resize yank the user's scroll position back to the match even when they had
-// scrolled away to read context. Scroll-to-match happens explicitly via the snap()
-// target passed to repaintWith at search entry and on n/p navigation.
-func (s searchState) Body(m Model) (string, bool) {
- if len(s.matches) == 0 {
- return "", false
- }
- sel := s.matches[s.selected]
- return searchrender.Render(s.body, sel.line, sel.col, sel.length), true
-}
-
-// Timestamp returns the captured frame's timestamp (set at search entry from the cursor's
-// then-current frame). The clock stays pinned to the frame the user searched in even as
-// new executions advance the underlying cursor in the background.
-func (s searchState) Timestamp(m Model) (time.Time, bool) {
- if s.captured.IsZero() {
- return time.Time{}, false
- }
- return s.captured, true
-}
-
-// snap returns the viewport scroll-to-cell effect for the currently selected match,
-// or nil when no matches. Used at search entry and on n/p navigation -- never from
-// Body, so unrelated re-paints (bar toggle, resize) don't disturb the user's scroll
-// position.
-func (s searchState) snap() *snapTarget {
- if len(s.matches) == 0 {
- return nil
- }
- sel := s.matches[s.selected]
- return &snapTarget{line: sel.line, col: sel.col, length: sel.length}
-}
-
-// ShowsBar returns false: search hides under -t by design (the user opted out
-// of the bar, and the highlighted match in the viewport remains visible).
-// Model.barShown ORs this with m.prefs.StatusBar at the caller, so search's bar
-// appears exactly when the user-configured preference is on. The return value
-// is prev-agnostic: even in the search-from-picker flow (where prev is
-// pickerState) the bar still hides — search's UX takes precedence over the
-// picker's bar need.
-func (searchState) ShowsBar() bool { return false }
-
-// IsFrozen returns true: searchState's viewport holds a captured body that
-// the user navigates with n/p. New history must not repaint — otherwise the
-// highlight overlay disappears mid-navigation.
-func (searchState) IsFrozen() bool { return true }
-
-// FollowsTail returns the captured follow flag (set at search entry from the
-// user's at-tail state). New history advances historyIndex in the background
-// so an Esc from search lands at the new tail without a second keystroke.
-func (s searchState) FollowsTail(_ bool) bool { return s.follow }
-
-// RenderBar composes the three-column status bar with search-specific slot
-// contents: the query and the match counter sit together at the bar's left
-// edge with a single-space separator; when the query is too long to fit
-// alongside the counter in the left zone, the query is ellipsized so the
-// counter always stays visible.
-func (s searchState) RenderBar(m Model) string {
- query := searchPromptLabel + s.query
- // Brackets give the counter just enough visual weight to read as a discrete element
- // without leaning on color or bold — those would compete with the timestamp+❄ group.
- counter := fmt.Sprintf("[%d of %d]", s.selected+1, len(s.matches))
- indicator := indicatorStyle.Render("❄")
- left := queryWithCounter(query, counter, barLeftZoneWidth(m.width))
- // n is advertised since it's the primary post-match action; the rest (p, / restart)
- // lives in the help overlay.
- help := renderHelp(minimalBarBindings(commonKeys.Escape, searchKeys.NavNext))
- return m.renderBarLayout(left, indicator, help)
-}
-
-// Handle processes a key for searchState. n/p navigates matches with wrap;
-// '/' opens a new search input (replacing this search on the stack); Esc
-// pops back to prev. Unhandled keys fall through to globalKeys so navigation
-// defaults scroll the frozen body without losing the highlight.
-func (s searchState) Handle(m Model, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) {
- switch {
- case key.Matches(msg, searchKeys.NavNext):
- m, s = s.advance(m, 1)
- return m, s, nil, true
- case key.Matches(msg, searchKeys.NavPrev):
- m, s = s.advance(m, -1)
- return m, s, nil, true
- case key.Matches(msg, commonKeys.Search):
- m2, st, cmd := m.openSearchInput(s)
- return m2, st, cmd, true
- case key.Matches(msg, commonKeys.Escape):
- return m, s.prev, nil, true
- }
- return m, s, nil, false
-}
-
-// advance moves the selection by delta (wrapping around), repaints the highlight,
-// and snaps the viewport to the new match. Shared by NavNext and NavPrev.
-func (s searchState) advance(m Model, delta int) (Model, searchState) {
- n := len(s.matches)
- s.selected = ((s.selected+delta)%n + n) % n
- return m.repaintWith(s, s.snap()), s
-}
-
-// queryWithCounter joins head and tail with a single space, ellipsizing head if the pair
-// doesn't fit in zoneWidth. The tail is treated as a hard requirement; if the zone is too
-// narrow to fit even tail+space+1 cell of head, the tail is returned alone.
-func queryWithCounter(head, tail string, zoneWidth int) string {
- tailW := lipgloss.Width(tail)
- if zoneWidth <= tailW+1 {
- return tail
- }
- headBudget := zoneWidth - tailW - 1
- head = ansi.Truncate(head, headBudget, "…")
- return lipgloss.JoinHorizontal(lipgloss.Top, head, " ", tail)
-}
diff --git a/internal/tui/state_search_test.go b/internal/tui/state_search_test.go
deleted file mode 100644
index d1fd73f..0000000
--- a/internal/tui/state_search_test.go
+++ /dev/null
@@ -1,402 +0,0 @@
-package tui
-
-import (
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
-
- "github.com/ivoronin/wch/internal/session"
- "github.com/ivoronin/wch/internal/tui/searchrender"
-)
-
-// searchTestModel builds a sized model with one frame containing repeated "Pending" entries
-// for the search integration tests.
-func searchTestModel(t *testing.T) Model {
- t.Helper()
- m := New(Config{Command: "kubectl get pods -A", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 80, Height: 24})
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC),
- Stdout: "pod-01 Running\npod-02 Pending\npod-03 Running\npod-04 Pending\npod-05 PENDING\n",
- }})
- return m
-}
-
-// Pressing '/' opens inputState with the search prompt and an empty value; the pre-search
-// state lives on as input.prev.
-func TestSearchOpenInput(t *testing.T) {
- m := searchTestModel(t)
- m = pressKey(t, m, '/')
-
- in, ok := m.state.(inputState)
- if !ok {
- t.Fatalf("after /, m.state = %T, want inputState", m.state)
- }
- if in.input.Value() != "" {
- t.Errorf("search input should start empty; got %q", in.input.Value())
- }
- if in.input.Prompt != searchPromptLabel {
- t.Errorf("search input prompt = %q, want %q", in.input.Prompt, searchPromptLabel)
- }
- if _, ok := in.prev.(viewState); !ok {
- t.Errorf("input.prev = %T, want viewState", in.prev)
- }
-}
-
-// Submitting a query with matches transitions to searchState; body and matches are captured.
-func TestSearchSubmitWithMatchesEntersSearchMode(t *testing.T) {
- m := searchTestModel(t)
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "Pending")
-
- sm, ok := m.state.(searchState)
- if !ok {
- t.Fatalf("after submit with matches, m.state = %T, want searchState", m.state)
- }
- if sm.query != "Pending" {
- t.Errorf("query = %q, want Pending", sm.query)
- }
- if len(sm.matches) != 2 {
- t.Errorf("matches = %d, want 2", len(sm.matches))
- }
- if sm.selected != 0 {
- t.Errorf("selected = %d, want 0", sm.selected)
- }
- if _, ok := sm.prev.(viewState); !ok {
- t.Errorf("search.prev = %T, want viewState", sm.prev)
- }
-}
-
-// No matches → notification + pop back to the pre-input state.
-func TestSearchSubmitNoMatchNotifiesAndPops(t *testing.T) {
- m := searchTestModel(t)
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "asdfqwer")
-
- if _, ok := m.state.(viewState); !ok {
- t.Errorf("after no-match submit, m.state = %T, want viewState", m.state)
- }
- if !m.notify.Active() {
- t.Errorf("expected notification bubble on no-match")
- }
-}
-
-// Empty/whitespace query is a silent cancel.
-func TestSearchSubmitEmptyIsSilent(t *testing.T) {
- m := searchTestModel(t)
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, " ")
-
- if _, ok := m.state.(viewState); !ok {
- t.Errorf("after empty submit, m.state = %T, want viewState", m.state)
- }
- if m.notify.Active() {
- t.Errorf("empty submit must not push a notification")
- }
-}
-
-// n/p navigates matches with wrap.
-func TestSearchNavigationWraps(t *testing.T) {
- m := searchTestModel(t)
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "pending") // smart-case → 3 matches
-
- sm := m.state.(searchState)
- if len(sm.matches) != 3 {
- t.Fatalf("setup: matches = %d, want 3", len(sm.matches))
- }
-
- m = pressKey(t, m, 'n')
- if m.state.(searchState).selected != 1 {
- t.Errorf("after 1×n, selected = %d, want 1", m.state.(searchState).selected)
- }
- m = pressKey(t, m, 'n')
- m = pressKey(t, m, 'n')
- if m.state.(searchState).selected != 0 {
- t.Errorf("after 3×n, selected = %d, want 0 (wrap)", m.state.(searchState).selected)
- }
- m = pressKey(t, m, 'N')
- if m.state.(searchState).selected != 2 {
- t.Errorf("after N from 0, selected = %d, want 2 (wrap)", m.state.(searchState).selected)
- }
-}
-
-// execResultMsg during searchState appends to history but does NOT change the viewport
-// content (frozen snapshot).
-func TestSearchFreezeDuringExecResult(t *testing.T) {
- m := searchTestModel(t)
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "Pending")
-
- before := strings.Clone(m.state.(searchState).body)
- historyLenBefore := len(m.session.History)
-
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC),
- Stdout: "pod-99 Running\n",
- }})
-
- if _, ok := m.state.(searchState); !ok {
- t.Errorf("searchState lost across execResultMsg; got %T", m.state)
- }
- if got := m.state.(searchState).body; got != before {
- t.Errorf("frozen body changed after execResultMsg")
- }
- if len(m.session.History) == historyLenBefore {
- t.Errorf("history should still grow during searchState; remained %d", historyLenBefore)
- }
-}
-
-// search-from-tail: Esc must resume live following in one keystroke.
-func TestSearchEscRestoresLiveTail(t *testing.T) {
- m := searchTestModel(t)
- if !m.isFollowing() {
- t.Fatalf("setup: model should start at the live tail")
- }
- indexAtSearchStart := m.cursor.Index()
-
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "Pending")
-
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC),
- Stdout: "pod-06 Pending\n",
- }})
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 2, 0, time.UTC),
- Stdout: "pod-07 Running\n",
- }})
-
- m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
- if _, ok := m.state.(viewState); !ok {
- t.Fatalf("after Esc, m.state = %T, want viewState", m.state)
- }
- if !m.isFollowing() {
- t.Errorf("after Esc, isFollowing() = false; want true (search-from-tail resumes following)")
- }
- if m.cursor.Index() == indexAtSearchStart {
- t.Errorf("after Esc, historyIndex = %d (stuck at search-start); should advance to %d",
- m.cursor.Index(), len(m.session.History)-1)
- }
-}
-
-// search-from-past: Esc returns to that same past frame; no follow.
-func TestSearchEscFromPastFrameStaysOnPast(t *testing.T) {
- m := searchTestModel(t)
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC),
- Stdout: "pod-06 Running\n",
- }})
- m = m.withCursor(0)
- if m.isFollowing() {
- t.Fatalf("setup: should be viewing past frame")
- }
- pastIndex := m.cursor.Index()
-
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "Pending")
-
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 2, 0, time.UTC),
- Stdout: "pod-07 Running\n",
- }})
-
- m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
- if m.cursor.Index() != pastIndex {
- t.Errorf("after Esc from past-frame search, historyIndex = %d, want %d", m.cursor.Index(), pastIndex)
- }
-}
-
-// Esc out of searchState returns to the pre-search state.
-func TestSearchEscReturnsToPreSearchMode(t *testing.T) {
- m := searchTestModel(t)
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "Pending")
- if _, ok := m.state.(searchState); !ok {
- t.Fatalf("setup: should be in searchState, got %T", m.state)
- }
-
- m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
- if _, ok := m.state.(viewState); !ok {
- t.Errorf("after Esc, m.state = %T, want viewState", m.state)
- }
-}
-
-// picker → search → Esc returns to picker via the prev chain.
-func TestSearchFromPickerEscReturnsToPicker(t *testing.T) {
- m := searchTestModel(t)
- m = pressKey(t, m, 'b') // enter picker
- if _, ok := m.state.(pickerState); !ok {
- t.Fatalf("setup: should be in pickerState, got %T", m.state)
- }
-
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "Pending")
- if _, ok := m.state.(searchState); !ok {
- t.Fatalf("should be searchState, got %T", m.state)
- }
-
- m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
- if _, ok := m.state.(pickerState); !ok {
- t.Errorf("after Esc from search-via-picker, m.state = %T, want pickerState", m.state)
- }
-}
-
-// '/' inside searchState replaces the current search on the stack: input.prev skips the
-// abandoned search. Esc from the replacement returns to the original predecessor (viewState).
-func TestSearchSubmitFromSearchModeReplacesOnStack(t *testing.T) {
- m := searchTestModel(t)
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "Running")
- first := m.state.(searchState)
-
- // '/' from within searchState.
- m = pressKey(t, m, '/')
- in, ok := m.state.(inputState)
- if !ok {
- t.Fatalf("after / in search, m.state = %T, want inputState", m.state)
- }
- // input.prev should be the searchState's prev (viewState), NOT the old searchState.
- if _, ok := in.prev.(viewState); !ok {
- t.Errorf("input.prev = %T, want viewState (search-from-search replaces)", in.prev)
- }
-
- m = submitInputValue(t, m, "Pending")
- second, ok := m.state.(searchState)
- if !ok {
- t.Fatalf("after second submit, m.state = %T, want searchState", m.state)
- }
- if second.query == first.query {
- t.Errorf("new searchState kept old query %q; should be Pending", second.query)
- }
-
- m = feed(t, m, tea.KeyPressMsg{Code: tea.KeyEsc})
- if _, ok := m.state.(viewState); !ok {
- t.Errorf("after Esc from replacing search, m.state = %T, want viewState", m.state)
- }
-}
-
-// TestSearchStateBodyReturnsOverlay: searchState.Body returns the captured body
-// with the selected match wrapped in the searchrender overlay.
-func TestSearchStateBodyReturnsOverlay(t *testing.T) {
- m := makePaintModel(t)
- body := m.frames.Frame(m.cursor.Index(), m.prefs.Diff)
- ss := searchState{
- query: "pod-15",
- body: body,
- matches: findMatches(body, "pod-15"),
- selected: 0,
- }
- if len(ss.matches) == 0 {
- t.Fatalf("test setup: no matches for pod-15 in body")
- }
- sel := ss.matches[0]
- want := searchrender.Render(body, sel.line, sel.col, sel.length)
-
- got, ok := ss.Body(m)
- if !ok {
- t.Fatalf("searchState.Body: ok=false with matches present")
- }
- if got != want {
- t.Errorf("searchState.Body did not produce the searchrender overlay")
- }
-}
-
-// TestSearchStateBodyZeroMatches: with zero matches, searchState.Body reports
-// ok=false so the orchestrator skips repaint.
-func TestSearchStateBodyZeroMatches(t *testing.T) {
- m := makePaintModel(t)
- ss := searchState{body: "anything", matches: nil}
- if _, ok := ss.Body(m); ok {
- t.Errorf("searchState.Body: ok=true with zero matches")
- }
-}
-
-// TestRestartSearchClearsOverlay: pressing '/' from within searchState
-// transitions to inputState{prev: viewState}; the viewport must show the
-// live (un-highlighted) body, not the prior search's overlay.
-//
-// The dispatchKey bar-transition hook ends with m.repaint(), which for inputState
-// delegates to its prev's Body, which yields the live frame. Before the seam
-// landed, the viewport stayed on the prior search overlay until the next exec.
-func TestRestartSearchClearsOverlay(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m.prefs.StatusBar = false
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", podNames(20))}})
-
- // Enter search.
- m = pressKey(t, m, '/')
- m = submitInputValue(t, m, "pod-15")
- if _, ok := m.state.(searchState); !ok {
- t.Fatalf("after submit: m.state = %T, want searchState", m.state)
- }
- overlay := m.frames.View()
- if !strings.Contains(overlay, "\x1b[7m") {
- t.Fatalf("test setup: overlay snapshot lacks reverse-video highlight; searchState did not paint as expected")
- }
-
- // Re-search via '/'. State transitions search → input{prev: view}.
- // The bar-transition resize hook must repaint, which (via input → view)
- // commits the live body to the viewport.
- m = pressKey(t, m, '/')
- if _, ok := m.state.(inputState); !ok {
- t.Fatalf("after second '/': m.state = %T, want inputState", m.state)
- }
- got := m.frames.View()
-
- if got == overlay {
- t.Fatalf("viewport still shows the search overlay after re-search; expected the live body to replace it")
- }
- if strings.Contains(got, "\x1b[7m") {
- t.Errorf("viewport still carries reverse-video overlay after re-search; got: %q", got)
- }
-}
-
-func TestSearchStateShowsBar(t *testing.T) {
- if got := (searchState{prev: viewState{}}).ShowsBar(); got != false {
- t.Errorf("searchState.ShowsBar() = %v, want false", got)
- }
-}
-
-func TestSearchStateIsFrozen(t *testing.T) {
- if got := (searchState{prev: viewState{}}).IsFrozen(); got != true {
- t.Errorf("searchState.IsFrozen() = %v, want true", got)
- }
-}
-
-func TestSearchStateFollowsTailUsesCapturedFlag(t *testing.T) {
- cases := []struct {
- name string
- follow bool
- wasAtTail bool
- want bool
- }{
- {"follow=true ignores wasAtTail=false", true, false, true},
- {"follow=false ignores wasAtTail=true", false, true, false},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- s := searchState{prev: viewState{}, follow: c.follow}
- if got := s.FollowsTail(c.wasAtTail); got != c.want {
- t.Errorf("FollowsTail(%v) = %v, want %v", c.wasAtTail, got, c.want)
- }
- })
- }
-}
-
-// TestSearchStateHandleEscReturnsToPrev pins Esc returns to the prev (pre-search) state.
-func TestSearchStateHandleEscReturnsToPrev(t *testing.T) {
- m := makePaintModel(t)
- ss := searchState{prev: viewState{}}
- m.state = ss
- _, st, _, handled := ss.Handle(m, tea.KeyPressMsg{Code: tea.KeyEsc})
- if !handled {
- t.Fatalf("searchState.Handle(Esc) reported handled=false")
- }
- if _, ok := st.(viewState); !ok {
- t.Errorf("searchState.Handle(Esc) returned %T, want viewState (prev)", st)
- }
-}
diff --git a/internal/tui/state_view.go b/internal/tui/state_view.go
deleted file mode 100644
index e18bd81..0000000
--- a/internal/tui/state_view.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package tui
-
-import (
- "time"
-
- "charm.land/bubbles/v2/key"
- tea "charm.land/bubbletea/v2"
-)
-
-// Body returns the rendered frame at the cursor position. Reports ok=false when there is
-// no history yet so the caller leaves the viewport untouched. Frame returns "" for i<0,
-// matching the no-cursor case.
-func (viewState) Body(m Model) (string, bool) {
- i, ok := m.cursor.At()
- return m.frames.Frame(i, m.prefs.Diff), ok
-}
-
-// Timestamp returns the timestamp of the frame at the cursor position. Reports ok=false
-// when there is no history yet.
-func (viewState) Timestamp(m Model) (time.Time, bool) {
- i, ok := m.cursor.At()
- if !ok {
- return time.Time{}, false
- }
- return m.session.History[i].Timestamp, true
-}
-
-// ShowsBar returns false because viewState's bar visibility depends only on
-// the user preference m.prefs.StatusBar — there is no view-driven need for the bar
-// to stay on. Model.barShown ORs this with m.prefs.StatusBar at the caller.
-func (viewState) ShowsBar() bool { return false }
-
-// IsFrozen returns false: viewState always repaints on a new exec; the user
-// is watching live output.
-func (viewState) IsFrozen() bool { return false }
-
-// FollowsTail returns the caller's wasAtTail: viewState advances historyIndex
-// to the new tail iff the user was already at the tail before the new exec
-// arrived.
-func (viewState) FollowsTail(wasAtTail bool) bool { return wasAtTail }
-
-// RenderBar composes the standard three-column status bar for viewState.
-// renderBarLayout → renderLeft already truncates to its leftWidth slot, so no
-// pre-truncate is needed here.
-func (viewState) RenderBar(m Model) string {
- return m.renderBarLayout(m.session.Command, m.renderIndicator(), renderHelp(viewHelpBindings(m)))
-}
-
-// Handle processes a key for viewState. Common bindings (diff/pause/record/
-// search) are tried first via handleCommonKey; viewState-specific bindings
-// come after. Unmatched keys fall through to handleGlobalKey (q/t/navigation)
-// via dispatchKey's `if !handled` path.
-func (s viewState) Handle(m Model, msg tea.KeyPressMsg) (Model, state, tea.Cmd, bool) {
- if newM, st, cmd, handled := m.handleCommonKey(s, msg); handled {
- return newM, st, cmd, true
- }
- switch {
- case key.Matches(msg, viewKeys.Picker):
- if len(m.session.History) == 0 {
- return m, s, nil, true
- }
- return m, pickerState{}, nil, true
- case key.Matches(msg, commonKeys.Escape):
- n := len(m.session.History)
- if !m.cursor.Following(n) {
- return m.withCursor(n - 1), s, nil, true
- }
- return m, s, nil, true
- }
- return m, s, nil, false
-}
-
-// viewHelpBindings returns the bar trailer for viewState. At the live tail we advertise
-// b to enter the picker; when viewing a past frame we swap that for Esc (back to tail).
-func viewHelpBindings(m Model) []key.Binding {
- if m.isFollowing() {
- return minimalBarBindings(viewKeys.Picker)
- }
- return minimalBarBindings(commonKeys.Escape)
-}
diff --git a/internal/tui/state_view_test.go b/internal/tui/state_view_test.go
deleted file mode 100644
index 18cb623..0000000
--- a/internal/tui/state_view_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package tui
-
-import (
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// TestViewStateBodyReturnsFrame: viewState.Body returns the frame at historyIndex,
-// matching frames.Frame(historyIndex, prefs.Diff).
-func TestViewStateBodyReturnsFrame(t *testing.T) {
- m := makePaintModel(t)
- m.state = viewState{}
-
- body, ok := (viewState{}).Body(m)
- if !ok {
- t.Fatalf("viewState.Body: ok=false with history present")
- }
- if want := m.frames.Frame(m.cursor.Index(), m.prefs.Diff); body != want {
- t.Errorf("viewState.Body returned different body than frames.Frame")
- }
-}
-
-// TestViewStateBodyEmptyHistory: viewState.Body returns ok=false before any
-// frame is recorded, so the orchestrator leaves the viewport untouched.
-func TestViewStateBodyEmptyHistory(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- if _, ok := (viewState{}).Body(m); ok {
- t.Errorf("viewState.Body: ok=true with empty history")
- }
-}
-
-func TestViewStateShowsBar(t *testing.T) {
- if got := (viewState{}).ShowsBar(); got != false {
- t.Errorf("viewState.ShowsBar() = %v, want false", got)
- }
-}
-
-func TestViewStateIsFrozen(t *testing.T) {
- if got := (viewState{}).IsFrozen(); got != false {
- t.Errorf("viewState.IsFrozen() = %v, want false", got)
- }
-}
-
-func TestViewStateFollowsTail(t *testing.T) {
- cases := []struct {
- name string
- wasAtTail bool
- want bool
- }{
- {"at tail", true, true},
- {"not at tail", false, false},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- if got := (viewState{}).FollowsTail(c.wasAtTail); got != c.want {
- t.Errorf("FollowsTail(%v) = %v, want %v", c.wasAtTail, got, c.want)
- }
- })
- }
-}
-
-// TestViewStateHandlePicker pins that 'b' from viewState enters pickerState.
-func TestViewStateHandlePicker(t *testing.T) {
- m := makePaintModel(t)
- m.state = viewState{}
- _, st, _, handled := viewState{}.Handle(m, tea.KeyPressMsg{Code: 'b', Text: "b"})
- if !handled {
- t.Fatalf("viewState.Handle('b') reported handled=false")
- }
- if _, ok := st.(pickerState); !ok {
- t.Errorf("viewState.Handle('b') returned %T, want pickerState", st)
- }
-}
-
-// viewHelpBindings swaps the first slot based on whether the user is following the tail
-// (b history) or viewing a past frame (Esc back). Help and Quit are always present.
-func TestViewHelpBindingsSwapsFirstSlotByFollowing(t *testing.T) {
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC), Stdout: "alpha\n",
- }})
- m = feed(t, m, execResultMsg{exec: session.Execution{
- Timestamp: time.Date(2026, 5, 30, 12, 0, 1, 0, time.UTC), Stdout: "beta\n",
- }})
-
- if !m.isFollowing() {
- t.Fatalf("setup: should be following")
- }
- bindings := viewHelpBindings(m)
- if got := len(bindings); got != 3 {
- t.Errorf("at tail: viewHelpBindings len = %d, want 3 (Picker, Help, Quit)", got)
- }
- if got := bindings[0].Help().Key; got != viewKeys.Picker.Help().Key {
- t.Errorf("at tail: first binding key = %q, want %q", got, viewKeys.Picker.Help().Key)
- }
-
- m = m.withCursor(0) // view first frame
- if m.isFollowing() {
- t.Fatalf("setup: should be viewing past")
- }
- bindings = viewHelpBindings(m)
- if got := len(bindings); got != 3 {
- t.Errorf("past frame: viewHelpBindings len = %d, want 3 (Esc, Help, Quit)", got)
- }
- if got := bindings[0].Help().Key; got != commonKeys.Escape.Help().Key {
- t.Errorf("past frame: first binding key = %q, want %q", got, commonKeys.Escape.Help().Key)
- }
-}
diff --git a/internal/tui/styles.go b/internal/tui/styles.go
deleted file mode 100644
index 3a4ab9b..0000000
--- a/internal/tui/styles.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package tui
-
-import (
- "charm.land/lipgloss/v2"
- "charm.land/lipgloss/v2/compat"
- "github.com/charmbracelet/x/ansi"
-)
-
-// insertFg is the foreground the diff renderer applies to changed cells.
-var insertFg ansi.Color = ansi.Green
-
-// Layout constants
-const (
- itemSpacing = 2 // visual gap between picker timestamps (matches " ")
- arrowWidth = 2 // width of "◀ " or " ▶" navigation arrows
- timestampFmt = "15:04:05" // HH:MM:SS format for history timestamps
-)
-
-var (
- // barBg / barFg are the only AdaptiveColor pair the bar surface needs. Plates use these;
- // accents on top use named ANSI so the terminal's palette resolves them per theme.
- barBg = compat.AdaptiveColor{
- Light: lipgloss.Color("#e5e5e5"),
- Dark: lipgloss.Color("#262626"),
- }
- barFg = compat.AdaptiveColor{
- Light: lipgloss.Color("#1f1f1f"),
- Dark: lipgloss.Color("#d6d6d6"),
- }
-
- statusBarStyle = lipgloss.NewStyle().
- Background(barBg).
- Foreground(barFg).
- Padding(0, 1)
-
- // barInnerStyle is the fg/bg every inline bar element carries so its own SGR Reset
- // doesn't tear the plate. Each element re-opens this style.
- barInnerStyle = lipgloss.NewStyle().
- Background(barBg).
- Foreground(barFg)
-
- helpStyle = lipgloss.NewStyle().
- PaddingLeft(1).
- Background(barBg).
- Foreground(barFg)
-
- // helpColumnGap separates the two columns inside the help overlay panel.
- helpColumnGap = lipgloss.NewStyle().Width(4).Render("")
-
- helpPanelStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- Padding(1, 2)
-
- // indicatorStyle must render exactly 1 visible cell — centerBlockWidth assumes 1-cell
- // slots with gaps emitted explicitly by renderCenterBlock. Padding here would push the
- // bar past contentWidth and wrap the help text onto a second row.
- indicatorStyle = lipgloss.NewStyle().
- Background(barBg).
- Foreground(barFg)
-
- errorStyle = lipgloss.NewStyle().
- Foreground(ansi.Red)
-
- // recStyle: same 1-cell invariant as indicatorStyle. Red dot on the bar's own plate, not
- // a red slab — the glyph itself signals recording, the surrounding bg stays consistent.
- recStyle = lipgloss.NewStyle().
- Background(barBg).
- Foreground(ansi.Red)
-
- pickerItemStyle = lipgloss.NewStyle().
- Background(barBg).
- Foreground(barFg)
-
- // pickerSelectedStyle: yellow plate, black text — distinguishes the active timestamp
- // from the surrounding bar plate.
- pickerSelectedStyle = lipgloss.NewStyle().
- Background(ansi.Yellow).
- Foreground(ansi.Black)
-)
-
-// boldKeep wraps s in a bold span that terminates with SGR 22 (bold-off) instead of the
-// full reset that lipgloss.Render emits. Use inside a styled outer span (e.g. helpStyle)
-// where a full reset would erase the outer fg/bg for the tail of the joined string.
-func boldKeep(s string) string {
- return "\x1b[1m" + s + "\x1b[22m"
-}
diff --git a/internal/tui/testhelpers_test.go b/internal/tui/testhelpers_test.go
deleted file mode 100644
index 7cba511..0000000
--- a/internal/tui/testhelpers_test.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package tui
-
-// Helpers shared by every *_test.go in this package. Lives without a paired source
-// because it's explicitly helper-only — no Test* functions, just setup code that
-// would otherwise be duplicated across multiple test files.
-
-import (
- "fmt"
- "strings"
- "testing"
- "time"
-
- tea "charm.land/bubbletea/v2"
-
- "github.com/ivoronin/wch/internal/session"
-)
-
-// podTable renders a kubectl-like table: a stable header plus one row per name, each row
-// carrying the given (volatile) age.
-func podTable(age string, names []string) string {
- rows := []string{"NAME READY STATUS AGE"}
- for _, n := range names {
- rows = append(rows, n+" 1/1 Running "+age)
- }
- return strings.Join(rows, "\n")
-}
-
-func podNames(n int) []string {
- out := make([]string, n)
- for i := range out {
- out[i] = fmt.Sprintf("pod-%02d", i+1)
- }
- return out
-}
-
-func feed(t *testing.T, m Model, msg tea.Msg) Model {
- t.Helper()
- next, _ := m.Update(msg)
- return next.(Model)
-}
-
-// pressKey simulates a single key press through the full dispatcher (Update path).
-func pressKey(t *testing.T, m Model, code rune) Model {
- t.Helper()
- return feed(t, m, tea.KeyPressMsg{Code: code, Text: string(code)})
-}
-
-// submitInputValue sets the textinput's value directly on m.state (which must be inputState)
-// and presses Enter so the configured submit function runs. Bypasses character-by-character
-// typing, which would otherwise dominate the test setup.
-func submitInputValue(t *testing.T, m Model, value string) Model {
- t.Helper()
- s, ok := m.state.(inputState)
- if !ok {
- t.Fatalf("submitInputValue: m.state = %T, want inputState", m.state)
- }
- s.input.SetValue(value)
- m.state = s
- return feed(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
-}
-
-func newSizedModel(t *testing.T, initial string) Model {
- t.Helper()
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- return feed(t, m, execResultMsg{exec: session.Execution{Stdout: initial}})
-}
-
-// makePaintModel returns a model sized 40x10 with a single recorded execution.
-// All Body equivalence tests share the same setup so that the "what should the
-// viewport look like?" baseline is identical across cases.
-func makePaintModel(t *testing.T) Model {
- t.Helper()
- m := New(Config{Command: "x", Interval: time.Second})
- m = feed(t, m, tea.WindowSizeMsg{Width: 40, Height: 10})
- m = feed(t, m, execResultMsg{exec: session.Execution{Stdout: podTable("5m", podNames(20))}})
- return m
-}
diff --git a/internal/tui/view.go b/internal/tui/view.go
deleted file mode 100644
index 91570ab..0000000
--- a/internal/tui/view.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package tui
-
-import (
- tea "charm.land/bubbletea/v2"
-
- "github.com/ivoronin/wch/internal/tui/helprender"
- "github.com/ivoronin/wch/internal/tui/notify"
-)
-
-// View renders the UI. Layer order: viewport → bar → help overlay (when toggled) → notify
-// bubbles. Notify draws last so a transient bubble still surfaces over the help panel.
-func (m Model) View() tea.View {
- var content string
- if !m.ready {
- content = "Initializing..."
- } else {
- content = m.frames.View()
- if m.barShown() {
- if bar := m.state.RenderBar(m); bar != "" {
- content += "\n" + bar
- }
- }
- if m.prefs.HelpVisible {
- content = helprender.Overlay(content, renderHelpPanel(), m.width, m.height)
- }
- if m.notify.Active() {
- // Adaptive insets: snug to the bottom-right corner of the *available* content
- // area. We add an inset only when there's something to avoid overlaying — the
- // vertical scrollbar on the right, the status bar and/or horizontal scrollbar
- // at the bottom. No fixed gaps.
- var insets notify.Insets
- if m.frames.NeedsVerticalScrollbar() {
- insets.Right = 1
- }
- if m.barShown() {
- insets.Bottom++
- }
- if m.frames.NeedsHorizontalScrollbar() {
- insets.Bottom++
- }
- content = m.notify.Overlay(content, m.width, m.height, insets)
- }
- }
-
- v := tea.NewView(content)
- v.AltScreen = true
- if m.isLive() {
- v.WindowTitle = "wch: " + m.session.Command
- } else {
- v.WindowTitle = "wch (replay): " + m.session.Command
- }
- return v
-}
diff --git a/src/args.zig b/src/args.zig
new file mode 100644
index 0000000..57d39b4
--- /dev/null
+++ b/src/args.zig
@@ -0,0 +1,71 @@
+//! Parses watch options and preserves the remaining command arguments.
+
+const std = @import("std");
+const clap = @import("clap");
+const build_metadata = @import("build_metadata");
+
+const option_parameters = clap.parseParamsComptime(
+ \\-h, --help Display this help and exit.
+ \\-v, --version Display version and exit.
+ \\-i, --interval Seconds between runs. One by default.
+ \\-l, --limit How many runs to keep. 3600 by default.
+ \\
+);
+
+const usage_text = "usage: wch [options] command [args...]\n\noptions:\n";
+
+/// Validated command-line input.
+pub const WatchOptions = struct {
+ run_interval: std.Io.Duration,
+ history_limit: usize,
+ /// Command arguments retain their original shell boundaries.
+ command_arguments: []const []const u8,
+};
+
+/// Parse arena-backed options, printing help or errors before exiting when needed.
+pub fn parse(
+ arena: std.mem.Allocator,
+ io: std.Io,
+ process_arguments: std.process.Args,
+) !WatchOptions {
+ const raw_arguments = try process_arguments.toSlice(arena);
+ const user_arguments = try arena.alloc([]const u8, raw_arguments.len - 1);
+ for (raw_arguments[1..], user_arguments) |raw_argument, *user_argument|
+ user_argument.* = raw_argument;
+
+ var diagnostic: clap.Diagnostic = .{};
+ var argument_parser: clap.args.SliceIterator = .{ .args = user_arguments };
+ const parsed_arguments = clap.parseEx(
+ clap.Help,
+ &option_parameters,
+ clap.parsers.default,
+ &argument_parser,
+ .{
+ .diagnostic = &diagnostic,
+ .allocator = arena,
+ // Leave every argument after the command to the command itself.
+ .terminating_positional = 0,
+ },
+ ) catch |parse_error| {
+ // The diagnostic is complete; returning the error would add a useless stack trace.
+ try diagnostic.reportToFile(io, .stderr(), parse_error);
+ std.process.exit(1);
+ };
+ if (parsed_arguments.args.version != 0) {
+ const version_text = try std.fmt.allocPrint(arena, "wch {s}\n", .{build_metadata.version});
+ try std.Io.File.stdout().writeStreamingAll(io, version_text);
+ std.process.exit(0);
+ }
+ if (parsed_arguments.args.help != 0 or parsed_arguments.positionals[0] == null) {
+ try std.Io.File.stdout().writeStreamingAll(io, usage_text);
+ try clap.helpToFile(io, .stdout(), clap.Help, &option_parameters, .{});
+ std.process.exit(0);
+ }
+
+ return .{
+ .run_interval = .fromSeconds(@max(parsed_arguments.args.interval orelse 1, 1)),
+ .history_limit = parsed_arguments.args.limit orelse 3600,
+ // parseEx stops after consuming the command name.
+ .command_arguments = user_arguments[argument_parser.index - 1 ..],
+ };
+}
diff --git a/src/bar.zig b/src/bar.zig
new file mode 100644
index 0000000..e2043ac
--- /dev/null
+++ b/src/bar.zig
@@ -0,0 +1,186 @@
+//! Draws live or history status in the bottom row.
+
+const std = @import("std");
+const vaxis = @import("vaxis");
+const zeit = @import("zeit");
+
+const clock_width = 8;
+const edge_padding: u16 = 1;
+const history_spacing: u16 = 2;
+
+const bar_style: vaxis.Style = .{ .bg = .{ .index = 8 }, .fg = .{ .index = 15 } };
+const shortcut_style: vaxis.Style = .{ .bg = bar_style.bg, .fg = bar_style.fg, .bold = true };
+
+const live_help: []const vaxis.Segment = &.{
+ .{ .text = "b", .style = shortcut_style },
+ .{ .text = " hist", .style = bar_style },
+ .{ .text = " · ", .style = bar_style },
+ .{ .text = "q", .style = shortcut_style },
+ .{ .text = " quit", .style = bar_style },
+};
+
+const history_help: []const vaxis.Segment = &.{
+ .{ .text = "j", .style = shortcut_style },
+ .{ .text = " prev", .style = bar_style },
+ .{ .text = " · ", .style = bar_style },
+ .{ .text = "k", .style = shortcut_style },
+ .{ .text = " next", .style = bar_style },
+ .{ .text = " · ", .style = bar_style },
+ .{ .text = "b", .style = shortcut_style },
+ .{ .text = " live", .style = bar_style },
+ .{ .text = " · ", .style = bar_style },
+ .{ .text = "q", .style = shortcut_style },
+ .{ .text = " quit", .style = bar_style },
+};
+
+const StatusIndicator = enum {
+ idle,
+ running,
+ history,
+
+ /// Return the glyph for this state.
+ fn glyph(self: StatusIndicator) []const u8 {
+ return switch (self) {
+ .idle => "·",
+ .running => "*",
+ .history => "←",
+ };
+ }
+};
+
+pub const StatusBar = struct {
+ pub const height = 1;
+
+ /// Borrowed until the status bar is released.
+ command_label: []const u8,
+ timezone: zeit.TimeZone,
+ // Vaxis cells borrow formatted text until the frame is rendered.
+ clock_buffer: [clock_width]u8 = undefined,
+ run_position_buffer: [64]u8 = undefined,
+
+ /// Create a status bar using local time when available.
+ pub fn init(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ command_label: []const u8,
+ ) StatusBar {
+ return .{
+ .command_label = command_label,
+ .timezone = zeit.local(allocator, io, .{}) catch zeit.utc,
+ };
+ }
+
+ /// Release timezone storage.
+ pub fn deinit(self: *StatusBar) void {
+ self.timezone.deinit();
+ self.* = undefined;
+ }
+
+ /// Draw the command, latest time, activity, and live-mode help.
+ pub fn drawLive(
+ self: *StatusBar,
+ window: vaxis.Window,
+ last_run_at: ?std.Io.Timestamp,
+ run_in_progress: bool,
+ ) void {
+ window.fill(.{ .style = bar_style });
+ const help_column = window.width -|
+ window.print(live_help, .{ .wrap = .none, .commit = false }).col -|
+ edge_padding;
+ _ = window.print(live_help, .{ .col_offset = help_column, .wrap = .none });
+
+ const clock_text = if (last_run_at) |completed_at|
+ formatClockTime(self, completed_at)
+ else
+ "";
+ const clock_column = clockColumn(window.width);
+ const indicator_column = clock_column +| clock_width +| 1;
+ const indicator_text = (if (run_in_progress) StatusIndicator.running else StatusIndicator.idle).glyph();
+
+ const middle_status_visible = indicator_column +| window.gwidth(indicator_text) <= help_column;
+ if (middle_status_visible) {
+ _ = window.printSegment(
+ .{ .text = clock_text, .style = bar_style },
+ .{ .col_offset = clock_column, .wrap = .none },
+ );
+ _ = window.printSegment(
+ .{ .text = indicator_text, .style = bar_style },
+ .{ .col_offset = indicator_column, .wrap = .none },
+ );
+ }
+
+ const command_end_column = if (middle_status_visible) clock_column else help_column;
+ self.drawCommand(window, command_end_column);
+ }
+
+ /// Draw history position, time, and navigation help.
+ pub fn drawHistory(
+ self: *StatusBar,
+ window: vaxis.Window,
+ run_number: usize,
+ run_count: usize,
+ completed_at: std.Io.Timestamp,
+ ) void {
+ window.fill(.{ .style = bar_style });
+ const clock_column = clockColumn(window.width);
+ _ = window.printSegment(
+ .{ .text = formatClockTime(self, completed_at), .style = bar_style },
+ .{ .col_offset = clock_column, .wrap = .none },
+ );
+ _ = window.printSegment(
+ .{ .text = StatusIndicator.history.glyph(), .style = bar_style },
+ .{ .col_offset = clock_column +| clock_width +| 1, .wrap = .none },
+ );
+
+ const run_position_text = std.fmt.bufPrint(
+ &self.run_position_buffer,
+ "Run {d}/{d}",
+ .{ run_number, run_count },
+ ) catch unreachable;
+ if (edge_padding +| window.gwidth(run_position_text) +| history_spacing <= clock_column)
+ _ = window.printSegment(
+ .{ .text = run_position_text, .style = bar_style },
+ .{ .col_offset = edge_padding, .wrap = .none },
+ );
+
+ const help_column = window.width -|
+ window.print(history_help, .{ .wrap = .none, .commit = false }).col -|
+ edge_padding;
+ if (clock_column +| clock_width +| history_spacing <= help_column)
+ _ = window.print(history_help, .{ .col_offset = help_column, .wrap = .none });
+ }
+
+ /// Draw the command before an end column and mark truncation.
+ fn drawCommand(self: *const StatusBar, window: vaxis.Window, end_column: u16) void {
+ const command_window = window.child(.{
+ .x_off = edge_padding,
+ .width = end_column -| edge_padding -| edge_padding,
+ });
+ if (command_window.printSegment(
+ .{ .text = self.command_label, .style = bar_style },
+ .{ .wrap = .none },
+ ).overflow)
+ command_window.writeCell(command_window.width -| 1, 0, .{
+ .char = .{ .grapheme = "…", .width = 1 },
+ .style = bar_style,
+ });
+ }
+};
+
+/// Format a timestamp as local `HH:MM:SS` in the status bar buffer.
+fn formatClockTime(
+ self: *StatusBar,
+ timestamp: std.Io.Timestamp,
+) []const u8 {
+ const local_time = zeit.instant(.{ .unix_nano = timestamp.nanoseconds }, &self.timezone).time();
+ return std.fmt.bufPrint(
+ &self.clock_buffer,
+ "{d:0>2}:{d:0>2}:{d:0>2}",
+ .{ local_time.hour, local_time.minute, local_time.second },
+ ) catch unreachable;
+}
+
+/// Center the fixed-width clock.
+fn clockColumn(bar_width: u16) u16 {
+ return (bar_width -| clock_width) / 2;
+}
diff --git a/src/diff.zig b/src/diff.zig
new file mode 100644
index 0000000..567819b
--- /dev/null
+++ b/src/diff.zig
@@ -0,0 +1,315 @@
+//! Computes terminal-independent line and word changes with Dizzy.
+
+const std = @import("std");
+const dizzy = @import("dizzy");
+
+const word_delimiters = delimiters: {
+ var delimiter_set = std.StaticBitSet(256).initEmpty();
+ for (" \t\r\n.,:;/|()[]") |delimiter| delimiter_set.set(delimiter);
+ break :delimiters delimiter_set;
+};
+
+/// Part lists and their arena-backed Dizzy edits.
+/// Delete ranges index `before`, insert ranges index `after`, and equal ranges pair both.
+/// The slices borrow input text and live in the caller's arena.
+pub const Changes = struct {
+ before: []const []const u8,
+ after: []const []const u8,
+ edits: []const dizzy.Edit,
+
+ /// Map a before line to its after position or deletion point.
+ pub fn mapBeforeLine(self: Changes, before_line: u32) u32 {
+ var after_line_index: u32 = 0;
+
+ for (self.edits) |edit| {
+ switch (edit.kind) {
+ .insert => after_line_index = edit.range.end,
+ .delete => if (before_line < edit.range.end) return after_line_index,
+ .equal => {
+ if (before_line < edit.range.end)
+ return after_line_index + before_line - edit.range.start;
+ after_line_index += edit.range.end - edit.range.start;
+ },
+ }
+ }
+
+ return after_line_index;
+ }
+};
+
+/// Diff visible lines, treating the first output as unchanged.
+pub fn compareLines(
+ arena: std.mem.Allocator,
+ before_output: ?[]const u8,
+ after_output: []const u8,
+) !Changes {
+ const after_lines = try splitLines(arena, after_output);
+ const previous_output = before_output orelse return unchangedLines(arena, after_lines);
+ if (std.mem.eql(u8, previous_output, after_output))
+ return unchangedLines(arena, after_lines);
+ const before_lines = try splitLines(arena, previous_output);
+
+ const before_columns_by_line = try splitColumns(arena, before_lines);
+ const after_columns_by_line = try splitColumns(arena, after_lines);
+ const stable_column = try chooseStableColumn(
+ arena,
+ before_columns_by_line,
+ after_columns_by_line,
+ );
+ const before_keys = try selectKeys(
+ arena,
+ before_lines,
+ before_columns_by_line,
+ stable_column,
+ );
+ const after_keys = try selectKeys(
+ arena,
+ after_lines,
+ after_columns_by_line,
+ stable_column,
+ );
+
+ return compareParts(arena, before_lines, after_lines, before_keys, after_keys);
+}
+
+/// Diff words while keeping delimiter runs as parts.
+pub fn compareWords(
+ arena: std.mem.Allocator,
+ before_line: []const u8,
+ after_line: []const u8,
+) !Changes {
+ const before_parts = try splitWords(arena, before_line);
+ const after_parts = try splitWords(arena, after_line);
+ return compareParts(arena, before_parts, after_parts, before_parts, after_parts);
+}
+
+/// Represent the first output as unchanged lines.
+fn unchangedLines(arena: std.mem.Allocator, lines: []const []const u8) !Changes {
+ const edits = if (lines.len == 0)
+ &.{}
+ else
+ try arena.dupe(dizzy.Edit, &.{.{
+ .kind = .equal,
+ .range = .{ .start = 0, .end = @intCast(lines.len) },
+ }});
+ return .{ .before = lines, .after = lines, .edits = edits };
+}
+
+/// Split LF-delimited text and omit its final empty line and trailing carriage returns.
+fn splitLines(arena: std.mem.Allocator, output: []const u8) ![]const []const u8 {
+ if (output.len == 0) return &.{};
+
+ var lines: std.ArrayList([]const u8) = .empty;
+ const text = if (output[output.len - 1] == '\n') output[0 .. output.len - 1] else output;
+ var line_iterator = std.mem.splitScalar(u8, text, '\n');
+ while (line_iterator.next()) |line|
+ try lines.append(arena, std.mem.trimEnd(u8, line, "\r"));
+
+ return lines.items;
+}
+
+/// Split one line into alternating delimiter and non-delimiter runs.
+fn splitWords(arena: std.mem.Allocator, line: []const u8) ![]const []const u8 {
+ var parts: std.ArrayList([]const u8) = .empty;
+
+ var part_start: usize = 0;
+ for (line, 0..) |byte, byte_index| {
+ if (word_delimiters.isSet(byte) == word_delimiters.isSet(line[part_start])) continue;
+ try parts.append(arena, line[part_start..byte_index]);
+ part_start = byte_index;
+ }
+ if (part_start < line.len) try parts.append(arena, line[part_start..]);
+
+ return parts.items;
+}
+
+/// Split each line into whitespace-delimited columns.
+fn splitColumns(
+ arena: std.mem.Allocator,
+ lines: []const []const u8,
+) ![]const []const []const u8 {
+ var columns_by_line: std.ArrayList([]const []const u8) = .empty;
+
+ for (lines) |line| {
+ var line_columns: std.ArrayList([]const u8) = .empty;
+ var column_iterator = std.mem.tokenizeAny(u8, line, " \t\r\n");
+ while (column_iterator.next()) |column| try line_columns.append(arena, column);
+ try columns_by_line.append(arena, line_columns.items);
+ }
+
+ return columns_by_line.items;
+}
+
+const KeyOccurrences = struct {
+ before_count: usize = 0,
+ after_count: usize = 0,
+};
+
+/// Pick the column with the most values unique on both sides.
+fn chooseStableColumn(
+ arena: std.mem.Allocator,
+ before_columns_by_line: []const []const []const u8,
+ after_columns_by_line: []const []const []const u8,
+) !?usize {
+ var maximum_column_count: usize = 0;
+ for (before_columns_by_line) |line_columns|
+ maximum_column_count = @max(maximum_column_count, line_columns.len);
+ for (after_columns_by_line) |line_columns|
+ maximum_column_count = @max(maximum_column_count, line_columns.len);
+
+ var occurrences_by_key = std.StringHashMap(KeyOccurrences).init(arena);
+ defer occurrences_by_key.deinit();
+
+ const maximum_unique_key_count = @min(
+ before_columns_by_line.len,
+ after_columns_by_line.len,
+ );
+ var best_column: ?usize = null;
+ var best_unique_key_count: usize = 0;
+
+ for (0..maximum_column_count) |column_index| {
+ occurrences_by_key.clearRetainingCapacity();
+
+ for (before_columns_by_line) |line_columns| {
+ if (column_index >= line_columns.len) continue;
+ const occurrence_entry = try occurrences_by_key.getOrPut(line_columns[column_index]);
+ if (!occurrence_entry.found_existing) occurrence_entry.value_ptr.* = .{};
+ occurrence_entry.value_ptr.before_count += 1;
+ }
+
+ for (after_columns_by_line) |line_columns| {
+ if (column_index >= line_columns.len) continue;
+ const occurrence_entry = try occurrences_by_key.getOrPut(line_columns[column_index]);
+ if (!occurrence_entry.found_existing) occurrence_entry.value_ptr.* = .{};
+ occurrence_entry.value_ptr.after_count += 1;
+ }
+
+ var unique_key_count: usize = 0;
+ var occurrence_iterator = occurrences_by_key.valueIterator();
+ while (occurrence_iterator.next()) |occurrences| {
+ if (occurrences.before_count == 1 and occurrences.after_count == 1)
+ unique_key_count += 1;
+ }
+
+ if (unique_key_count <= best_unique_key_count) continue;
+
+ best_unique_key_count = unique_key_count;
+ best_column = column_index;
+ if (unique_key_count == maximum_unique_key_count) return column_index;
+ }
+
+ return best_column;
+}
+
+/// Read keys from one column, falling back to the complete line.
+fn selectKeys(
+ arena: std.mem.Allocator,
+ lines: []const []const u8,
+ columns_by_line: []const []const []const u8,
+ stable_column: ?usize,
+) ![]const []const u8 {
+ const column_index = stable_column orelse return lines;
+
+ const comparison_keys = try arena.alloc([]const u8, lines.len);
+ for (lines, columns_by_line, comparison_keys) |line, line_columns, *comparison_key|
+ comparison_key.* = if (column_index < line_columns.len)
+ line_columns[column_index]
+ else
+ line;
+
+ return comparison_keys;
+}
+
+const PartDiffer = dizzy.SliceDiffer([]const u8, std.hash_map.StringContext);
+
+/// Run Dizzy on comparison keys and return edits over the original parts.
+fn compareParts(
+ arena: std.mem.Allocator,
+ before_parts: []const []const u8,
+ after_parts: []const []const u8,
+ before_keys: []const []const u8,
+ after_keys: []const []const u8,
+) !Changes {
+ std.debug.assert(before_parts.len == before_keys.len);
+ std.debug.assert(after_parts.len == after_keys.len);
+
+ const scratch = try arena.alloc(u32, 4 * (before_parts.len + after_parts.len) + 2);
+ var edits: std.ArrayList(dizzy.Edit) = .empty;
+ try PartDiffer.diff(arena, &edits, before_keys, after_keys, scratch);
+
+ // Keep deletions before insertions so replacements read old then new.
+ for (0..edits.items.len -| 1) |edit_index|
+ if (edits.items[edit_index].kind == .insert and
+ edits.items[edit_index + 1].kind == .delete)
+ std.mem.swap(
+ dizzy.Edit,
+ &edits.items[edit_index],
+ &edits.items[edit_index + 1],
+ );
+
+ return .{ .before = before_parts, .after = after_parts, .edits = edits.items };
+}
+
+test "pairs changed rows by their stable key" {
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ const line_changes = try compareLines(
+ arena.allocator(),
+ "api-1 Running 0 5m\r\napi-2 Running 0 5m\r\n",
+ "api-1 Running 0 6m\napi-2 Pending 0 6m",
+ );
+ try std.testing.expectEqualDeep(&[_]dizzy.Edit{.{
+ .kind = .equal,
+ .range = .{ .start = 0, .end = 2 },
+ }}, line_changes.edits);
+}
+
+test "leaves unrelated rows unpaired" {
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ const line_changes = try compareLines(
+ arena.allocator(),
+ "api-1 Running 0 5m\nweb-1 Running 0 5m",
+ "api-1 Running 0 6m\ndb-9 Pending 3 1s",
+ );
+ try std.testing.expectEqualDeep(&[_]dizzy.Edit{
+ .{ .kind = .equal, .range = .{ .start = 0, .end = 1 } },
+ .{ .kind = .delete, .range = .{ .start = 1, .end = 2 } },
+ .{ .kind = .insert, .range = .{ .start = 1, .end = 2 } },
+ }, line_changes.edits);
+}
+
+test "falls back to whole lines without a key column" {
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ var deleted_lines: usize = 0;
+ var inserted_lines: usize = 0;
+ const line_changes = try compareLines(
+ arena.allocator(),
+ "group a\ngroup b",
+ "group c\ngroup d",
+ );
+ for (line_changes.edits) |edit| switch (edit.kind) {
+ .delete => deleted_lines += edit.range.end - edit.range.start,
+ .insert => inserted_lines += edit.range.end - edit.range.start,
+ else => return error.UnexpectedEdit,
+ };
+
+ try std.testing.expectEqual(@as(usize, 2), deleted_lines);
+ try std.testing.expectEqual(@as(usize, 2), inserted_lines);
+}
+
+test "diffs words and keeps delimiter runs" {
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ const word_changes = try compareWords(arena.allocator(), "cpu: 12", "cpu: 13");
+ try std.testing.expectEqualDeep(&[_]dizzy.Edit{
+ .{ .kind = .equal, .range = .{ .start = 0, .end = 2 } },
+ .{ .kind = .delete, .range = .{ .start = 2, .end = 3 } },
+ .{ .kind = .insert, .range = .{ .start = 2, .end = 3 } },
+ }, word_changes.edits);
+}
diff --git a/src/history.zig b/src/history.zig
new file mode 100644
index 0000000..227b83a
--- /dev/null
+++ b/src/history.zig
@@ -0,0 +1,155 @@
+//! Stores recent distinct runs and resolves stable cursors into display views.
+
+const std = @import("std");
+const Run = @import("run.zig").Run;
+
+pub const History = struct {
+ pub const Cursor = usize;
+
+ /// Borrowed until History changes.
+ pub const View = struct {
+ cursor: Cursor,
+ run: *const Run,
+ previous_output: ?[]const u8,
+ run_number: usize,
+ run_count: usize,
+ };
+
+ allocator: std.mem.Allocator,
+ runs: std.ArrayList(Run) = .empty,
+ run_limit: usize,
+ oldest_cursor: Cursor = 0,
+
+ /// Create an empty history that retains at least one run.
+ pub fn init(allocator: std.mem.Allocator, run_limit: usize) History {
+ return .{ .allocator = allocator, .run_limit = @max(run_limit, 1) };
+ }
+
+ /// Free every retained run and the run list.
+ pub fn deinit(self: *History) void {
+ for (self.runs.items) |*run| run.deinit(self.allocator);
+ self.runs.deinit(self.allocator);
+ self.* = undefined;
+ }
+
+ /// Take ownership of a run and retain it when storage permits.
+ pub fn append(self: *History, run: Run) void {
+ var owned_run = run;
+ if (self.runs.getLastOrNull()) |newest_run| {
+ if (std.mem.eql(u8, owned_run.output, newest_run.output)) {
+ owned_run.deinit(self.allocator);
+ return;
+ }
+ }
+
+ self.runs.append(self.allocator, owned_run) catch {
+ owned_run.deinit(self.allocator);
+ return;
+ };
+ owned_run = undefined;
+ if (self.runs.items.len > self.run_limit) {
+ var dropped_run = self.runs.orderedRemove(0);
+ dropped_run.deinit(self.allocator);
+ self.oldest_cursor += 1;
+ }
+ }
+
+ /// Resolve null to the newest run and an expired cursor to the oldest.
+ pub fn resolve(self: *const History, cursor: ?Cursor) ?View {
+ const retained_runs = self.runs.items;
+ if (retained_runs.len == 0) return null;
+
+ const selected_run_index = if (cursor) |selected_cursor|
+ self.runIndex(selected_cursor)
+ else
+ retained_runs.len - 1;
+ const selected_run = &retained_runs[selected_run_index];
+ return .{
+ .cursor = self.oldest_cursor + selected_run_index,
+ .run = selected_run,
+ .previous_output = if (selected_run_index == 0)
+ null
+ else
+ retained_runs[selected_run_index - 1].output,
+ .run_number = selected_run_index + 1,
+ .run_count = retained_runs.len,
+ };
+ }
+
+ /// Return the previous cursor, clamped to the oldest retained run.
+ pub fn previous(self: *const History, cursor: Cursor) Cursor {
+ std.debug.assert(self.runs.items.len > 0);
+
+ const current_run_index = self.runIndex(cursor);
+ return self.oldest_cursor + (current_run_index -| 1);
+ }
+
+ /// Return the next cursor, clamped to the newest retained run.
+ pub fn next(self: *const History, cursor: Cursor) Cursor {
+ std.debug.assert(self.runs.items.len > 0);
+
+ const current_run_index = self.runIndex(cursor);
+ const next_run_index = @min(current_run_index + 1, self.runs.items.len - 1);
+ return self.oldest_cursor + next_run_index;
+ }
+
+ /// Resolve an unretained cursor to the oldest run index.
+ fn runIndex(self: *const History, cursor: Cursor) usize {
+ if (cursor < self.oldest_cursor) return 0;
+ const run_index = cursor - self.oldest_cursor;
+ return if (run_index < self.runs.items.len) run_index else 0;
+ }
+};
+
+/// Create a test run with owned output.
+fn createTestRun(allocator: std.mem.Allocator, output: []const u8) !Run {
+ return .{ .completed_at = .zero, .output = try allocator.dupe(u8, output) };
+}
+
+test "history resolves and moves through retained runs" {
+ const allocator = std.testing.allocator;
+ var history: History = .init(allocator, 2);
+ defer history.deinit();
+
+ history.append(try createTestRun(allocator, "a"));
+ const expired_cursor = history.resolve(null).?.cursor;
+ for ([_][]const u8{ "a", "b", "c", "d" }) |run_output|
+ history.append(try createTestRun(allocator, run_output));
+
+ const oldest_run_view = history.resolve(expired_cursor).?;
+ const newest_run_view = history.resolve(null).?;
+
+ try std.testing.expectEqualStrings("c", oldest_run_view.run.output);
+ try std.testing.expect(oldest_run_view.previous_output == null);
+ try std.testing.expectEqual(1, oldest_run_view.run_number);
+ try std.testing.expectEqual(2, oldest_run_view.run_count);
+ try std.testing.expectEqualStrings("d", newest_run_view.run.output);
+ try std.testing.expectEqualStrings("c", newest_run_view.previous_output.?);
+ try std.testing.expectEqual(2, newest_run_view.run_number);
+ try std.testing.expectEqualSlices(
+ History.Cursor,
+ &.{
+ oldest_run_view.cursor,
+ newest_run_view.cursor,
+ oldest_run_view.cursor,
+ newest_run_view.cursor,
+ },
+ &.{
+ history.previous(oldest_run_view.cursor),
+ history.next(oldest_run_view.cursor),
+ history.previous(newest_run_view.cursor),
+ history.next(newest_run_view.cursor),
+ },
+ );
+}
+
+test "a run that cannot be stored is dropped" {
+ var failing_allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 });
+ const allocator = failing_allocator.allocator();
+
+ var history: History = .init(allocator, std.math.maxInt(usize));
+ defer history.deinit();
+
+ history.append(try createTestRun(allocator, "run"));
+ try std.testing.expect(history.resolve(null) == null);
+}
diff --git a/src/main.zig b/src/main.zig
new file mode 100644
index 0000000..a3a2f66
--- /dev/null
+++ b/src/main.zig
@@ -0,0 +1,127 @@
+const std = @import("std");
+const vaxis = @import("vaxis");
+const cli = @import("args.zig");
+const Model = @import("model.zig").Model;
+const Run = @import("run.zig").Run;
+
+/// Events from Vaxis and the command watcher.
+const Event = union(enum) {
+ key_press: vaxis.Key,
+ mouse: vaxis.Mouse,
+ winsize: vaxis.Winsize,
+ run_started,
+ run_finished: Run,
+};
+
+const EventLoop = vaxis.Loop(Event);
+
+/// Run the command on schedule and post lifecycle events until cancellation.
+fn watchCommand(
+ allocator: std.mem.Allocator,
+ event_loop: *EventLoop,
+ command_arguments: []const []const u8,
+ run_interval: std.Io.Duration,
+) void {
+ while (true) {
+ event_loop.postEvent(.run_started) catch return;
+
+ // Cancellation may surface from capture before sleep sees it.
+ if (Run.capture(allocator, event_loop.io, command_arguments)) |captured_run| {
+ var run = captured_run;
+ event_loop.postEvent(.{ .run_finished = run }) catch {
+ run.deinit(allocator);
+ return;
+ };
+ } else |capture_error| if (capture_error == error.Canceled) return;
+
+ event_loop.io.sleep(run_interval, .awake) catch return;
+ }
+}
+
+/// Run the terminal event loop until the user quits or an operation fails.
+pub fn main(process: std.process.Init) !void {
+ const io = process.io;
+ const allocator = process.gpa;
+
+ var terminal_buffer: [1024]u8 = undefined;
+
+ // Parse before entering alternate screen so help and errors remain visible.
+ const watch_options = try cli.parse(process.arena.allocator(), io, process.minimal.args);
+
+ // Reverse defer order stops the watcher before terminal teardown.
+ var terminal: vaxis.Tty = try .init(io, &terminal_buffer);
+ defer terminal.deinit();
+
+ var tui: vaxis.Vaxis = try .init(io, allocator, process.environ_map, .{});
+ defer tui.deinit(allocator, terminal.writer());
+
+ var event_loop: EventLoop = .init(io, &terminal, &tui);
+ try event_loop.start();
+ defer event_loop.stop();
+
+ try tui.enterAltScreen(terminal.writer());
+ try tui.queryTerminal(terminal.writer(), .fromSeconds(1));
+
+ // Avoid a lock-taking signal handler when in-band resize works.
+ if (!tui.state.in_band_resize) try event_loop.installResizeHandler();
+ defer event_loop.uninstallResizeHandler();
+
+ // Mouse mode supplies wheel events; pixel coordinates are unused.
+ tui.caps.sgr_pixels = false;
+ try tui.setMouseMode(terminal.writer(), true);
+
+ // The status bar needs a display string; execution keeps argument boundaries.
+ const command_label = try std.mem.join(
+ process.arena.allocator(),
+ " ",
+ watch_options.command_arguments,
+ );
+
+ var model: Model = .init(allocator, io, watch_options.history_limit, command_label);
+ defer model.deinit();
+
+ // Cancel first to wake a blocked producer, then free runs still in the queue.
+ var command_watcher = try io.concurrent(
+ watchCommand,
+ .{
+ allocator,
+ &event_loop,
+ watch_options.command_arguments,
+ watch_options.run_interval,
+ },
+ );
+ defer {
+ command_watcher.cancel(io);
+ while (event_loop.tryEvent() catch null) |event| switch (event) {
+ .run_finished => |queued_run| {
+ var run = queued_run;
+ run.deinit(allocator);
+ },
+ else => {},
+ };
+ }
+
+ while (true) {
+ // Draw one frame per queued burst, not per input event.
+ try event_loop.pollEvent();
+ while (try event_loop.tryEvent()) |event| switch (event) {
+ .key_press => |key| {
+ if (key.matches('q', .{})) return;
+ model.handleKeyPress(key, tui.window());
+ },
+ .mouse => |mouse| model.scrollWithMouse(mouse),
+ .winsize => |window_size| try tui.resize(allocator, terminal.writer(), window_size),
+ .run_started => model.run_in_progress = true,
+ .run_finished => |run| model.finishRun(run),
+ };
+
+ const window = tui.window();
+ window.clear();
+ try model.drawFrame(window);
+ try tui.render(terminal.writer());
+ }
+}
+
+test {
+ std.testing.refAllDecls(@This());
+}
diff --git a/src/model.zig b/src/model.zig
new file mode 100644
index 0000000..03ed6b8
--- /dev/null
+++ b/src/model.zig
@@ -0,0 +1,152 @@
+//! Owns run selection and draws complete screen frames.
+
+const std = @import("std");
+const vaxis = @import("vaxis");
+const History = @import("history.zig").History;
+const Output = @import("output.zig").Output;
+const Viewport = @import("viewport.zig").Viewport;
+const Run = @import("run.zig").Run;
+const StatusBar = @import("bar.zig").StatusBar;
+
+/// Return the screen area above the one-line status bar.
+fn viewportWindow(window: vaxis.Window) vaxis.Window {
+ return window.child(.{
+ .width = window.width,
+ .height = window.height -| StatusBar.height,
+ });
+}
+
+pub const Model = struct {
+ history: History,
+ output: Output,
+ viewport: Viewport,
+ status_bar: StatusBar,
+
+ /// Null follows the newest run.
+ selected_cursor: ?History.Cursor = null,
+ /// Null before the first run is shown.
+ displayed_cursor: ?History.Cursor = null,
+ run_in_progress: bool = false,
+
+ /// Create an empty model and its owned modules.
+ pub fn init(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ history_limit: usize,
+ command_label: []const u8,
+ ) Model {
+ return .{
+ .history = .init(allocator, history_limit),
+ .output = .init(allocator),
+ .viewport = .{},
+ .status_bar = .init(allocator, io, command_label),
+ };
+ }
+
+ /// Release model-owned storage.
+ pub fn deinit(self: *Model) void {
+ self.history.deinit();
+ self.output.deinit();
+ self.status_bar.deinit();
+ self.* = undefined;
+ }
+
+ /// Record a completed run and leave the model idle.
+ pub fn finishRun(self: *Model, run: Run) void {
+ self.run_in_progress = false;
+ self.history.append(run);
+ }
+
+ /// Apply model keys, then pass unclaimed input to the viewport.
+ pub fn handleKeyPress(self: *Model, key: vaxis.Key, window: vaxis.Window) void {
+ if (key.matches('b', .{})) {
+ self.selected_cursor = if (self.selected_cursor == null)
+ self.displayed_cursor
+ else
+ null;
+ return;
+ }
+
+ if (self.selected_cursor) |selected_cursor| {
+ if (key.matches(vaxis.Key.escape, .{})) {
+ self.selected_cursor = null;
+ return;
+ }
+
+ if (key.matches('j', .{})) {
+ self.selected_cursor = self.history.previous(selected_cursor);
+ return;
+ }
+ if (key.matches('k', .{})) {
+ self.selected_cursor = self.history.next(selected_cursor);
+ return;
+ }
+ }
+
+ self.viewport.scrollWithKey(key, viewportWindow(window));
+ }
+
+ /// Forward mouse input to the viewport.
+ pub fn scrollWithMouse(self: *Model, mouse: vaxis.Mouse) void {
+ self.viewport.scrollWithMouse(mouse);
+ }
+
+ /// Resolve selection, refresh output, and draw the viewport and status bar.
+ pub fn drawFrame(self: *Model, window: vaxis.Window) std.mem.Allocator.Error!void {
+ const viewport_window = viewportWindow(window);
+ const status_bar_window = window.child(.{
+ .y_off = @intCast(viewport_window.height),
+ .width = window.width,
+ .height = StatusBar.height,
+ });
+
+ const run_view = self.history.resolve(self.selected_cursor);
+
+ if (run_view) |resolved_run_view| try self.showRun(viewport_window, resolved_run_view);
+ self.viewport.draw(viewport_window, self.output.display_lines);
+
+ if (self.selected_cursor != null) {
+ const selected_run_view = run_view orelse return;
+ self.status_bar.drawHistory(
+ status_bar_window,
+ selected_run_view.run_number,
+ selected_run_view.run_count,
+ selected_run_view.run.completed_at,
+ );
+ } else {
+ self.status_bar.drawLive(
+ status_bar_window,
+ if (run_view) |latest_run_view| latest_run_view.run.completed_at else null,
+ self.run_in_progress,
+ );
+ }
+ }
+
+ /// Replace displayed output when the resolved history run changes.
+ fn showRun(
+ self: *Model,
+ viewport_window: vaxis.Window,
+ run_view: History.View,
+ ) std.mem.Allocator.Error!void {
+ if (self.displayed_cursor == run_view.cursor) return;
+
+ const viewport_position = self.viewport.position(viewport_window);
+ const visible_line = switch (viewport_position) {
+ .line => |line_index| line_index,
+ else => null,
+ };
+ const mapped_visible_line = try self.output.replaceText(
+ viewport_window.screen.width_method,
+ run_view.previous_output,
+ run_view.run.output,
+ visible_line,
+ );
+ const next_position: Viewport.Position = if (mapped_visible_line) |line_index|
+ .{ .line = line_index }
+ else
+ viewport_position;
+ self.viewport.replaceLines(viewport_window, self.output.display_lines, next_position);
+
+ self.displayed_cursor = run_view.cursor;
+ }
+};
diff --git a/src/output.zig b/src/output.zig
new file mode 100644
index 0000000..328843a
--- /dev/null
+++ b/src/output.zig
@@ -0,0 +1,257 @@
+//! Owns normalized command output and its display-ready terminal segments.
+
+const std = @import("std");
+const vaxis = @import("vaxis");
+const diff = @import("diff.zig");
+
+const added_style: vaxis.Style = .{ .fg = .{ .index = 2 } };
+const tab_stop = 8;
+const DisplayLine = []const vaxis.Segment;
+
+pub const Output = struct {
+ normalized_output: ?[]const u8 = null,
+ display_lines: []const DisplayLine = &.{},
+ storage: std.heap.ArenaAllocator,
+
+ /// Create empty output storage.
+ pub fn init(allocator: std.mem.Allocator) Output {
+ return .{ .storage = .init(allocator) };
+ }
+
+ /// Release all output storage.
+ pub fn deinit(self: *Output) void {
+ self.storage.deinit();
+ self.* = undefined;
+ }
+
+ /// Atomically replace output and map an optional line from the displayed output.
+ pub fn replaceText(
+ self: *Output,
+ width_method: vaxis.gwidth.Method,
+ previous_output: ?[]const u8,
+ current_output: []const u8,
+ visible_line: ?u32,
+ ) std.mem.Allocator.Error!?u32 {
+ const backing_allocator = self.storage.child_allocator;
+
+ var next_storage: std.heap.ArenaAllocator = .init(backing_allocator);
+ errdefer next_storage.deinit();
+
+ var line_scratch: std.heap.ArenaAllocator = .init(backing_allocator);
+ defer line_scratch.deinit();
+ var word_scratch: std.heap.ArenaAllocator = .init(backing_allocator);
+ defer word_scratch.deinit();
+
+ const retained_allocator = next_storage.allocator();
+ const line_allocator = line_scratch.allocator();
+
+ const normalized_previous_output = if (previous_output) |raw_previous_output|
+ try expandTabs(line_allocator, width_method, raw_previous_output)
+ else
+ null;
+ const normalized_current_output = try expandTabs(
+ retained_allocator,
+ width_method,
+ current_output,
+ );
+ const line_changes = try diff.compareLines(
+ line_allocator,
+ normalized_previous_output,
+ normalized_current_output,
+ );
+
+ var mapped_visible_line: ?u32 = null;
+ if (visible_line) |line_index| if (self.normalized_output) |displayed_output| {
+ const previous_is_displayed = if (normalized_previous_output) |normalized_previous|
+ std.mem.eql(u8, displayed_output, normalized_previous)
+ else
+ false;
+ const mapping_changes = if (previous_is_displayed)
+ line_changes
+ else
+ try diff.compareLines(
+ line_allocator,
+ displayed_output,
+ normalized_current_output,
+ );
+ mapped_visible_line = mapping_changes.mapBeforeLine(line_index);
+ };
+
+ const rendered_lines = try renderLines(
+ retained_allocator,
+ &word_scratch,
+ line_changes,
+ );
+
+ self.storage.deinit();
+ self.* = .{
+ .normalized_output = normalized_current_output,
+ .display_lines = rendered_lines,
+ .storage = next_storage,
+ };
+ return mapped_visible_line;
+ }
+};
+
+/// Expand tabs to terminal stops using display-cell widths.
+fn expandTabs(
+ arena: std.mem.Allocator,
+ width_method: vaxis.gwidth.Method,
+ output_text: []const u8,
+) ![]const u8 {
+ if (std.mem.findScalar(u8, output_text, '\t') == null)
+ return arena.dupe(u8, output_text);
+
+ var expanded_output: std.ArrayList(u8) = .empty;
+ var column: usize = 0;
+ var remaining_output = output_text;
+
+ while (std.mem.findAny(u8, remaining_output, "\t\n\r")) |control_index| {
+ const preceding_text = remaining_output[0..control_index];
+ try expanded_output.appendSlice(arena, preceding_text);
+
+ if (remaining_output[control_index] == '\t') {
+ column += vaxis.gwidth.gwidth(preceding_text, width_method);
+ const space_count = tab_stop - column % tab_stop;
+ try expanded_output.appendNTimes(arena, ' ', space_count);
+ column += space_count;
+ } else {
+ try expanded_output.append(arena, remaining_output[control_index]);
+ column = 0;
+ }
+ remaining_output = remaining_output[control_index + 1 ..];
+ }
+
+ try expanded_output.appendSlice(arena, remaining_output);
+ return expanded_output.items;
+}
+
+/// Render the new side of line changes with additions highlighted.
+fn renderLines(
+ retained_allocator: std.mem.Allocator,
+ word_scratch: *std.heap.ArenaAllocator,
+ line_changes: diff.Changes,
+) ![]const DisplayLine {
+ var rendered_lines: std.ArrayList(DisplayLine) = .empty;
+ var after_line_index: usize = 0;
+
+ for (line_changes.edits) |edit| {
+ const line_count: usize = @intCast(edit.range.end - edit.range.start);
+ switch (edit.kind) {
+ .equal => {
+ for (
+ line_changes.before[edit.range.start..edit.range.end],
+ line_changes.after[after_line_index..][0..line_count],
+ ) |before_line, after_line| {
+ const rendered_line = if (std.mem.eql(u8, before_line, after_line))
+ try retained_allocator.dupe(vaxis.Segment, &.{.{ .text = after_line }})
+ else
+ try renderWordChanges(
+ retained_allocator,
+ word_scratch,
+ before_line,
+ after_line,
+ );
+ try rendered_lines.append(retained_allocator, rendered_line);
+ }
+ after_line_index += line_count;
+ },
+ .delete => {},
+ .insert => {
+ for (line_changes.after[after_line_index..][0..line_count]) |inserted_line|
+ try rendered_lines.append(
+ retained_allocator,
+ try retained_allocator.dupe(vaxis.Segment, &.{.{
+ .text = inserted_line,
+ .style = added_style,
+ }}),
+ );
+ after_line_index += line_count;
+ },
+ }
+ }
+
+ return rendered_lines.items;
+}
+
+/// Render one matched line with inserted words highlighted.
+fn renderWordChanges(
+ retained_allocator: std.mem.Allocator,
+ word_scratch: *std.heap.ArenaAllocator,
+ before_line: []const u8,
+ after_line: []const u8,
+) !DisplayLine {
+ _ = word_scratch.reset(.retain_capacity);
+ const word_changes = try diff.compareWords(
+ word_scratch.allocator(),
+ before_line,
+ after_line,
+ );
+
+ var rendered_segments: std.ArrayList(vaxis.Segment) = .empty;
+ var after_part_index: usize = 0;
+
+ for (word_changes.edits) |edit| {
+ const part_count: usize = @intCast(edit.range.end - edit.range.start);
+ switch (edit.kind) {
+ .delete => {},
+ .equal, .insert => {
+ const after_parts = word_changes.after[after_part_index..][0..part_count];
+ try rendered_segments.append(retained_allocator, .{
+ .text = partSpan(after_parts),
+ .style = if (edit.kind == .insert) added_style else .{},
+ });
+ after_part_index += part_count;
+ },
+ }
+ }
+ std.debug.assert(after_part_index == word_changes.after.len);
+
+ return rendered_segments.items;
+}
+
+/// Return the text covered by adjacent parts without copying it.
+fn partSpan(parts: []const []const u8) []const u8 {
+ std.debug.assert(parts.len > 0);
+
+ const first_part = parts[0];
+ const last_part = parts[parts.len - 1];
+ const span_length = @intFromPtr(last_part.ptr) + last_part.len - @intFromPtr(first_part.ptr);
+ return first_part.ptr[0..span_length];
+}
+
+test "tabs follow terminal stops" {
+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena.deinit();
+
+ const expanded = try expandTabs(arena.allocator(), .unicode, "漢\tx\nabcdefgh\ty");
+ try std.testing.expectEqualStrings("漢 x\nabcdefgh y", expanded);
+}
+
+test "maps owned output after the caller changes" {
+ var output: Output = .init(std.testing.allocator);
+ defer output.deinit();
+
+ var initial_output = [_]u8{ 'a', '\n', 'b', '\n', 'c', '\n', 'd' };
+ _ = try output.replaceText(.unicode, null, &initial_output, null);
+ @memset(&initial_output, 'x');
+
+ try std.testing.expectEqual(
+ @as(?u32, 2),
+ try output.replaceText(
+ .unicode,
+ "id old\na\nb\nc\nd",
+ "id new\na\nb\nc\nd",
+ 1,
+ ),
+ );
+
+ const normalized_output = output.normalized_output.?;
+ const output_start = @intFromPtr(normalized_output.ptr);
+ const output_end = output_start + normalized_output.len;
+ for (output.display_lines) |display_line| for (display_line) |segment| {
+ const segment_start = @intFromPtr(segment.text.ptr);
+ try std.testing.expect(segment_start >= output_start);
+ try std.testing.expect(segment_start + segment.text.len <= output_end);
+ };
+}
diff --git a/src/run.zig b/src/run.zig
new file mode 100644
index 0000000..066f7ee
--- /dev/null
+++ b/src/run.zig
@@ -0,0 +1,62 @@
+//! Captures one execution of the watched command.
+
+const std = @import("std");
+
+pub const Run = struct {
+ completed_at: std.Io.Timestamp,
+ /// Owned stderr followed by stdout, with tabs left unchanged.
+ output: []const u8,
+
+ /// Run once, returning launch failures as output and cancellation as an error.
+ pub fn capture(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ command_arguments: []const []const u8,
+ ) !Run {
+ // A fixed shell interprets one command string independently of `$SHELL`.
+ const process_arguments: []const []const u8 = if (command_arguments.len == 1)
+ &.{ "/bin/sh", "-c", command_arguments[0] }
+ else
+ command_arguments;
+
+ if (std.process.run(allocator, io, .{ .argv = process_arguments })) |process_result| {
+ const captured_output = if (process_result.stderr.len == 0) stdout_only: {
+ allocator.free(process_result.stderr);
+ break :stdout_only process_result.stdout;
+ } else if (process_result.stdout.len == 0) stderr_only: {
+ allocator.free(process_result.stdout);
+ break :stderr_only process_result.stderr;
+ } else both_streams: {
+ defer allocator.free(process_result.stdout);
+ defer allocator.free(process_result.stderr);
+ break :both_streams try std.mem.concat(
+ allocator,
+ u8,
+ &.{ process_result.stderr, process_result.stdout },
+ );
+ };
+
+ return .{
+ .completed_at = .now(io, .real),
+ .output = captured_output,
+ };
+ } else |launch_error| {
+ if (launch_error == error.Canceled) return error.Canceled;
+
+ return .{
+ .completed_at = .now(io, .real),
+ .output = try std.fmt.allocPrint(
+ allocator,
+ "Error running command: {any}",
+ .{launch_error},
+ ),
+ };
+ }
+ }
+
+ /// Free captured output.
+ pub fn deinit(self: *Run, allocator: std.mem.Allocator) void {
+ allocator.free(self.output);
+ self.* = undefined;
+ }
+};
diff --git a/src/viewport.zig b/src/viewport.zig
new file mode 100644
index 0000000..02d991f
--- /dev/null
+++ b/src/viewport.zig
@@ -0,0 +1,279 @@
+//! Scrolls display-ready terminal lines without retaining them.
+
+const std = @import("std");
+const vaxis = @import("vaxis");
+
+const DisplayLine = []const vaxis.Segment;
+
+// Matching heavy glyphs keep rail-free thumbs visible over content.
+const vertical_thumb_cell: vaxis.Cell = .{
+ .char = .{ .grapheme = "┃", .width = 1 },
+ .style = .{ .fg = .{ .index = 8 } },
+};
+const horizontal_thumb_cell: vaxis.Cell = .{
+ .char = .{ .grapheme = "━", .width = 1 },
+ .style = .{ .fg = .{ .index = 8 } },
+};
+
+const wheel_step = 3;
+
+const ContentSize = struct { width: u16, height: u16 };
+
+pub const Viewport = struct {
+ pub const Position = union(enum) {
+ top,
+ bottom,
+ line: u32,
+ };
+
+ top_line: u32 = 0,
+ left_column: u32 = 0,
+ line_count: usize = 0,
+ content_width: u32 = 0,
+
+ /// Resolve the text area after both scrollbars account for each other.
+ fn contentSize(self: Viewport, window: vaxis.Window) ContentSize {
+ var content_size: ContentSize = .{ .width = window.width, .height = window.height };
+ // Each scrollbar can only enable the other, so two passes reach a fixed point.
+ for (0..2) |_| {
+ content_size.width = window.width -|
+ @intFromBool(self.line_count > content_size.height);
+ content_size.height = window.height -|
+ @intFromBool(self.content_width > content_size.width);
+ }
+ return content_size;
+ }
+
+ /// Report top, bottom, or the first visible line, preferring top when content fits.
+ pub fn position(self: Viewport, window: vaxis.Window) Position {
+ if (self.top_line == 0) return .top;
+ if (self.top_line >= self.line_count -| self.contentSize(window).height) return .bottom;
+ return .{ .line = self.top_line };
+ }
+
+ /// Measure replacement lines and move vertically while preserving horizontal scroll.
+ pub fn replaceLines(
+ self: *Viewport,
+ window: vaxis.Window,
+ display_lines: []const DisplayLine,
+ target_position: Position,
+ ) void {
+ self.line_count = display_lines.len;
+ self.content_width = measureContentWidth(window, display_lines);
+
+ self.top_line = switch (target_position) {
+ .top => 0,
+ .bottom => @as(u32, @intCast(self.line_count)) -| self.contentSize(window).height,
+ .line => |line_index| line_index,
+ };
+ }
+
+ /// Pull both offsets inside the drawable range.
+ fn clampPosition(self: *Viewport, window: vaxis.Window) void {
+ const content_size = self.contentSize(window);
+ self.top_line = @min(
+ self.top_line,
+ @as(u32, @intCast(self.line_count)) -| content_size.height,
+ );
+ // A Vaxis child cannot reach past maxInt(u16), even when content is wider.
+ const maximum_left_column = std.math.maxInt(u16) - content_size.width;
+ self.left_column = @min(
+ self.left_column,
+ @min(self.content_width -| content_size.width, maximum_left_column),
+ );
+ }
+
+ /// Apply one keyboard scroll command without clamping the result.
+ pub fn scrollWithKey(self: *Viewport, key: vaxis.Key, window: vaxis.Window) void {
+ if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) {
+ self.top_line +|= 1;
+ } else if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) {
+ self.top_line -|= 1;
+ } else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) {
+ self.left_column +|= 1;
+ } else if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) {
+ self.left_column -|= 1;
+ } else if (key.matches(vaxis.Key.down, .{ .shift = true }) or
+ key.matches(vaxis.Key.page_down, .{}))
+ {
+ self.top_line +|= self.contentSize(window).height;
+ } else if (key.matches(vaxis.Key.up, .{ .shift = true }) or
+ key.matches(vaxis.Key.page_up, .{}))
+ {
+ self.top_line -|= self.contentSize(window).height;
+ } else if (key.matches(vaxis.Key.right, .{ .shift = true })) {
+ self.left_column +|= self.contentSize(window).width;
+ } else if (key.matches(vaxis.Key.left, .{ .shift = true })) {
+ self.left_column -|= self.contentSize(window).width;
+ } else if (key.matches(vaxis.Key.escape, .{})) {
+ // Model consumes Escape in history; live mode uses it to return to top.
+ self.top_line = 0;
+ }
+ }
+
+ /// Apply one mouse-wheel scroll command.
+ pub fn scrollWithMouse(self: *Viewport, mouse: vaxis.Mouse) void {
+ switch (mouse.button) {
+ .wheel_up => self.top_line -|= wheel_step,
+ .wheel_down => self.top_line +|= wheel_step,
+ // Horizontal wheel events intentionally move content in the opposite direction.
+ .wheel_left => self.left_column +|= wheel_step,
+ .wheel_right => self.left_column -|= wheel_step,
+ else => return,
+ }
+ }
+
+ /// Clamp offsets, draw visible lines, and overlay scrollbar thumbs.
+ pub fn draw(self: *Viewport, window: vaxis.Window, display_lines: []const DisplayLine) void {
+ std.debug.assert(display_lines.len == self.line_count);
+ self.clampPosition(window);
+ std.debug.assert(self.left_column <= std.math.maxInt(u16) -| window.width);
+ const content_size = self.contentSize(window);
+
+ // A wider child shifted left lets Vaxis clip horizontal scroll for us.
+ const content_window = window.child(.{
+ .x_off = -@as(i17, @intCast(self.left_column)),
+ .width = content_size.width + @as(u16, @intCast(self.left_column)),
+ .height = content_size.height,
+ });
+
+ for (0..content_size.height) |viewport_row| {
+ const line_index = self.top_line + viewport_row;
+ if (line_index >= display_lines.len) break;
+ // The child extends off-screen, so wrapping would create false rows.
+ _ = content_window.print(
+ display_lines[line_index],
+ .{ .row_offset = @intCast(viewport_row), .wrap = .none },
+ );
+ }
+
+ if (content_size.width < window.width) {
+ const vertical_thumb = scrollbarThumb(
+ content_size.height,
+ @intCast(self.line_count),
+ self.top_line,
+ );
+ for (vertical_thumb.offset..vertical_thumb.offset + vertical_thumb.length) |thumb_row|
+ window.writeCell(window.width -| 1, @intCast(thumb_row), vertical_thumb_cell);
+ }
+ if (content_size.height < window.height) {
+ const horizontal_thumb = scrollbarThumb(
+ content_size.width,
+ self.content_width,
+ self.left_column,
+ );
+ for (horizontal_thumb.offset..horizontal_thumb.offset + horizontal_thumb.length) |thumb_column|
+ window.writeCell(
+ @intCast(thumb_column),
+ window.height -| 1,
+ horizontal_thumb_cell,
+ );
+ }
+ }
+};
+
+/// Measure the widest line in terminal cells.
+fn measureContentWidth(window: vaxis.Window, display_lines: []const DisplayLine) u32 {
+ var content_width: u32 = 0;
+ for (display_lines) |display_line| {
+ var line_width: u32 = 0;
+ for (display_line) |segment| line_width += displayWidth(window, segment.text);
+ content_width = @max(content_width, line_width);
+ }
+ return content_width;
+}
+
+/// Measure printable ASCII directly and leave all other text to Vaxis.
+fn displayWidth(window: vaxis.Window, text: []const u8) u32 {
+ for (text) |byte| if (!std.ascii.isPrint(byte)) return window.gwidth(text);
+ return @intCast(text.len);
+}
+
+const ScrollbarThumb = struct { offset: u16, length: u16 };
+
+/// Scale an overflowing axis into a thumb that lands exactly at both ends.
+fn scrollbarThumb(
+ track_length: u16,
+ content_length: u32,
+ content_offset: u32,
+) ScrollbarThumb {
+ const thumb_length: u16 = @min(
+ track_length,
+ @max(1, @as(u16, @intCast(@as(u32, track_length) * track_length / content_length))),
+ );
+ const thumb_travel = track_length -| thumb_length;
+ const scroll_range = content_length - track_length;
+ return .{
+ .offset = @intCast(@as(u32, thumb_travel) * content_offset / scroll_range),
+ .length = thumb_length,
+ };
+}
+
+test "positions and clamps stay inside the content" {
+ const line_segments = [_]vaxis.Segment{.{ .text = "x" }};
+ const display_lines = [_]DisplayLine{&line_segments} ** 30;
+
+ var view = try vaxis.widgets.View.init(std.testing.allocator, .{ .width = 10, .height = 8 });
+ defer view.deinit();
+ const window = view.window();
+
+ var viewport: Viewport = .{};
+ viewport.replaceLines(window, &display_lines, .top);
+ try std.testing.expectEqual(@as(u32, 0), viewport.top_line);
+
+ viewport.top_line = 1;
+ try std.testing.expectEqualDeep(
+ @as(Viewport.Position, .{ .line = 1 }),
+ viewport.position(window),
+ );
+ viewport.replaceLines(window, &display_lines, .{ .line = 2 });
+ try std.testing.expectEqual(@as(u32, 2), viewport.top_line);
+
+ viewport.replaceLines(window, &display_lines, .bottom);
+ try std.testing.expectEqual(@as(u32, 22), viewport.top_line);
+ try std.testing.expectEqualDeep(
+ @as(Viewport.Position, .bottom),
+ viewport.position(window),
+ );
+
+ viewport.top_line = 100;
+ viewport.clampPosition(window);
+ try std.testing.expectEqual(@as(u32, 22), viewport.top_line);
+
+ viewport.left_column = 100;
+ viewport.clampPosition(window);
+ try std.testing.expectEqual(@as(u32, 0), viewport.left_column);
+
+ var tall_view = try vaxis.widgets.View.init(std.testing.allocator, .{ .width = 10, .height = 40 });
+ defer tall_view.deinit();
+ viewport.clampPosition(tall_view.window());
+ try std.testing.expectEqual(@as(u32, 0), viewport.top_line);
+}
+
+test "drawing clamps content wider than a Vaxis child" {
+ var view = try vaxis.widgets.View.init(std.testing.allocator, .{ .width = 80, .height = 4 });
+ defer view.deinit();
+ const window = view.window();
+
+ const wide_text = "x" ** 66_000;
+ const line_segments = [_]vaxis.Segment{.{ .text = wide_text }};
+ const display_lines = [_]DisplayLine{&line_segments};
+
+ var viewport: Viewport = .{};
+ viewport.replaceLines(window, &display_lines, .top);
+
+ viewport.left_column = 90_000;
+ viewport.draw(window, &display_lines);
+ try std.testing.expect(viewport.left_column <= std.math.maxInt(u16) - window.width);
+ try std.testing.expectEqual(@as(u8, 'x'), view.readCell(0, 0).?.char.grapheme[0]);
+}
+
+test "display width preserves Vaxis handling outside printable ASCII" {
+ var view = try vaxis.widgets.View.init(std.testing.allocator, .{ .width = 10, .height = 2 });
+ defer view.deinit();
+ const window = view.window();
+
+ try std.testing.expectEqual(@as(u32, 5), displayWidth(window, "plain"));
+ try std.testing.expectEqual(@as(u32, window.gwidth("漢")), displayWidth(window, "漢"));
+ try std.testing.expectEqual(@as(u32, window.gwidth("\x1b")), displayWidth(window, "\x1b"));
+}