Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 28 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,28 +183,33 @@ Precedence: env vars > config file > defaults.

## Supported terminals

### Shift+Enter works natively

inline-cli auto-detects your terminal and enables the right protocol:

**Kitty keyboard protocol** (CSI u):

| Terminal | Status |
| ----------------- | ------------ |
| **kitty** | Full support |
| **WezTerm** | Full support |
| **ghostty** | Full support |
| **iTerm2** (3.5+) | Full support |
| **foot** | Full support |

**xterm modifyOtherKeys** (CSI 27;2;13~):

| Terminal | Status |
| ------------------------------------- | ------------ |
| **xterm** | Full support |
| **VTE-based** (GNOME Terminal, Tilix) | Full support |

Both protocols are bound automatically — no manual configuration needed.
### Shift+Enter works automatically (zsh)

inline-cli enables the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)
during line editing, so Shift+Enter works out of the box in modern terminals:

| Terminal | Shift+Enter | Notes |
| ------------------------------------- | ----------- | --------------------- |
| **Ghostty** | Automatic | |
| **kitty** | Automatic | |
| **WezTerm** | Automatic | |
| **iTerm2** (3.5+) | Automatic | |
| **foot** | Automatic | |
| **xterm** | Automatic | Via modifyOtherKeys |
| **VTE-based** (GNOME Terminal, Tilix) | Automatic | |

### Bash: terminal configuration required

Bash's readline cannot bind CSI sequences, so Shift+Enter requires a
terminal-side remap to `\n` (0x0a). <kbd>Ctrl</kbd>+<kbd>J</kbd> always
works without configuration.

| Terminal | Config |
| --------- | ------------------------------------------------------------------ |
| Ghostty | `keybind = shift+enter=text:\n` |
| kitty | `map shift+enter send_text all \x0a` |
| WezTerm | `{ key = "Enter", mods = "SHIFT", action = SendString("\x0a") }` |
| iTerm2 | Keys > Key Mappings > Shift+Return > Send Hex Code `0a` |

### Fallback: Ctrl+J

Expand All @@ -215,7 +220,7 @@ Terminals that support neither protocol use <kbd>Ctrl</kbd>+<kbd>J</kbd>:
| **Terminal.app** (macOS) | <kbd>Ctrl</kbd>+<kbd>J</kbd> |
| **Alacritty** | <kbd>Ctrl</kbd>+<kbd>J</kbd> |

> **tmux note:** Extended key sequences are stripped by default. <kbd>Ctrl</kbd>+<kbd>J</kbd> always works. For Shift+Enter, add `set -g extended-keys on` to your tmux config.
> **tmux note:** Extended key sequences are stripped by default. <kbd>Ctrl</kbd>+<kbd>J</kbd> always works. For Shift+Enter, add `set -g extended-keys on` and `set -g extended-keys-format csi-u` to your tmux config.

## Prompt indicator

Expand Down
9 changes: 5 additions & 4 deletions cmd/inline-cli/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/CCALITA/inline-cli/internal/config"
"github.com/CCALITA/inline-cli/internal/daemon"
"github.com/CCALITA/inline-cli/internal/render"
)

// backendInfo describes a supported backend.
Expand Down Expand Up @@ -39,9 +40,9 @@ func (b backendInfo) installStatus() string {
return ""
}
if b.isInstalled() {
return " \033[32m✓ installed\033[0m"
return " " + render.Green("✓ installed")
}
return " \033[31m✗ not found\033[0m"
return " " + render.Red("✗ not found")
}

func findBackend(name string) (backendInfo, bool) {
Expand Down Expand Up @@ -72,7 +73,7 @@ func restartDaemonIfRunning() {
if err := d.Stop(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to stop daemon: %v\n", err)
} else {
fmt.Println("Daemon stopped. Next query will use the new backend.")
fmt.Printf("%s daemon stopped. Next query will use the new backend.\n", render.Green("✓"))
}
}
}
Expand Down Expand Up @@ -160,7 +161,7 @@ func runBackendSet(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to save config: %w", err)
}

fmt.Printf("Backend set to %q\n", name)
fmt.Printf("%s Backend set to %q\n", render.Green("✓"), name)
restartDaemonIfRunning()

return nil
Expand Down
60 changes: 49 additions & 11 deletions cmd/inline-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
_ "embed"
"fmt"
"net"
"os"
"strings"
"time"
Expand All @@ -18,6 +19,10 @@ import (

var version = "dev"

// errAlreadyDisplayed signals that the error was already shown to the user
// via the renderer — cobra should not print it again.
var errAlreadyDisplayed = fmt.Errorf("")

//go:embed shell_zsh.sh
var zshScript string

Expand All @@ -26,9 +31,22 @@ var bashScript string

func main() {
rootCmd := &cobra.Command{
Use: "inline-cli",
Short: "Inline Claude assistant for your terminal",
Version: version,
Use: "inline-cli",
Short: "Inline Claude assistant for your terminal",
Long: `Inline Claude assistant for your terminal.

Type a question directly in your shell prompt and press Ctrl+J (or Shift+Enter
in supported terminals) to get an AI response streamed inline — no context
switching needed.

Quick start:
1. Run 'inline-cli setup' to choose a backend
2. Add shell integration: eval "$(inline-cli init zsh)" (or bash)
3. Restart your shell and start asking questions with Ctrl+J

Shift+Enter requires terminal configuration — see 'inline-cli init --help'.`,
Version: version,
SilenceErrors: true,
}

rootCmd.AddCommand(
Expand All @@ -42,7 +60,9 @@ func main() {
)

if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
if err != errAlreadyDisplayed {
fmt.Fprintln(os.Stderr, err)
}
os.Exit(1)
}
}
Expand Down Expand Up @@ -130,7 +150,7 @@ func runDaemonStart(cmd *cobra.Command, args []string) error {
return err
}

fmt.Println("daemon started")
fmt.Printf("%s daemon started\n", render.Green("✓"))
return nil
}

Expand All @@ -145,7 +165,7 @@ func runDaemonStop(cmd *cobra.Command, args []string) error {
return err
}

fmt.Println("daemon stopped")
fmt.Printf("%s daemon stopped\n", render.Green("✓"))
return nil
}

Expand Down Expand Up @@ -195,9 +215,9 @@ func runQuery(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to start daemon: %w", err)
}

// Give daemon a moment to start listening.
if !d.IsRunning() {
time.Sleep(200 * time.Millisecond)
// Wait for the daemon socket to become available.
if err := waitForSocket(cfg.SocketPath, 3*time.Second); err != nil {
return fmt.Errorf("daemon started but socket not ready: %w", err)
}

client := ipc.NewClient(cfg.SocketPath)
Expand All @@ -206,7 +226,7 @@ func runQuery(cmd *cobra.Command, args []string) error {
renderer := render.NewRenderer(os.Stdout)
var md *render.Markdown
if !raw {
md = render.NewMarkdown(80)
md = render.NewMarkdown(renderer.Width())
}

ch, err := client.Query(dir, prompt, requestID)
Expand Down Expand Up @@ -253,7 +273,7 @@ func runQuery(cmd *cobra.Command, args []string) error {
renderer.ClearThinking()
}
renderer.ShowError(resp.Message)
return fmt.Errorf("query failed: %s", resp.Message)
return errAlreadyDisplayed
}
}

Expand Down Expand Up @@ -329,3 +349,21 @@ func runInit(cmd *cobra.Command, args []string) error {
fmt.Print(script)
return nil
}

// waitForSocket polls until the Unix socket is accepting connections or the timeout expires.
func waitForSocket(socketPath string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
delay := 50 * time.Millisecond
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("unix", socketPath, 100*time.Millisecond)
if err == nil {
conn.Close()
return nil
}
time.Sleep(delay)
if delay < 400*time.Millisecond {
delay *= 2
}
}
return fmt.Errorf("timed out waiting for socket %s", socketPath)
}
12 changes: 7 additions & 5 deletions cmd/inline-cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import (
"strings"

"github.com/spf13/cobra"
"golang.org/x/term"

"github.com/CCALITA/inline-cli/internal/config"
"github.com/CCALITA/inline-cli/internal/render"
)

func newSetupCmd() *cobra.Command {
Expand Down Expand Up @@ -56,19 +58,19 @@ func runSetup(cmd *cobra.Command, args []string) error {
if existing == "" {
fmt.Println()
fmt.Print("Enter your Anthropic API key (or press Enter to skip): ")
line, err := reader.ReadString('\n')
keyBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println() // newline after hidden input
if err != nil {
return fmt.Errorf("failed to read input: %w", err)
}
key := strings.TrimSpace(line)
key := strings.TrimSpace(string(keyBytes))
if key != "" {
fmt.Println()
fmt.Println("Add this to your shell profile:")
fmt.Printf(" export ANTHROPIC_API_KEY='%s'\n", strings.ReplaceAll(key, "'", "'\\''"))
}
} else {
fmt.Println()
fmt.Println("\033[32m✓\033[0m ANTHROPIC_API_KEY is already set")
fmt.Printf("%s ANTHROPIC_API_KEY is already set\n", render.Green("✓"))
}
}

Expand All @@ -77,7 +79,7 @@ func runSetup(cmd *cobra.Command, args []string) error {
}

fmt.Println()
fmt.Printf("\033[32m✓\033[0m Backend set to %q\n", chosen.Name)
fmt.Printf("%s Backend set to %q\n", render.Green("✓"), chosen.Name)
restartDaemonIfRunning()

fmt.Println()
Expand Down
20 changes: 9 additions & 11 deletions cmd/inline-cli/shell_bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,16 @@ _inline_cli_query() {
#
# Ctrl+J (\C-j) is the primary binding — works in every terminal.
#
# Shift+Enter setup:
# Configure your terminal to send \n (LF) for Shift+Enter.
# Regular Enter sends \r (CR) → accept-line. Shift+Enter sends \n → our function.
# Why Shift+Enter doesn't auto-work in bash:
# Bash's readline cannot bind CSI escape sequences like \e[13;2u.
# Even with the kitty keyboard protocol enabled, readline won't
# recognize the sequence. The only way to get Shift+Enter in bash
# is to configure your terminal to remap it to \n (0x0a = Ctrl+J):
#
# Ghostty: keybind = shift+enter=text:\n
# kitty: map shift+enter send_text all \x0a
# WezTerm: { key = "Enter", mods = "SHIFT", action = SendString("\x0a") }
# iTerm2: Keys > Key Mappings > Shift+Return → Send Hex Code 0a
#
# Bash's bind cannot capture raw CSI sequences the way zsh's bindkey can,
# so Ctrl+J is the reliable binding. Terminals that remap Shift+Enter to
# \n (0x0a = Ctrl+J) will trigger this automatically.
# Ghostty: keybind = shift+enter=text:\n
# kitty: map shift+enter send_text all \x0a
# WezTerm: { key = "Enter", mods = "SHIFT", action = SendString("\x0a") }
# iTerm2: Keys > Key Mappings > Shift+Return → Send Hex Code 0a

bind -x '"\C-j": _inline_cli_query'

Expand Down
Loading